diff --git a/.gitattributes b/.gitattributes index a6344aac8c09253b3b630fb776ae94478aa0275b..1116638bd187735dadd80b77df18fef42261b669 100644 --- a/.gitattributes +++ b/.gitattributes @@ -33,3 +33,32 @@ saved_model/**/* filter=lfs diff=lfs merge=lfs -text *.zip filter=lfs diff=lfs merge=lfs -text *.zst filter=lfs diff=lfs merge=lfs -text *tfevents* filter=lfs diff=lfs merge=lfs -text +align.bundle filter=lfs diff=lfs merge=lfs -text +code/notes/0102[[:space:]]-[[:space:]]Policy[[:space:]]Gradient[[:space:]]and[[:space:]]Baselines.pdf filter=lfs diff=lfs merge=lfs -text +code/notes/0103[[:space:]]-[[:space:]]Importance[[:space:]]Sampling[[:space:]]and[[:space:]]Off-Policy[[:space:]]Updates.pdf filter=lfs diff=lfs merge=lfs -text +code/notes/0101[[:space:]]-[[:space:]]Autoregressive[[:space:]]Likelihood[[:space:]]and[[:space:]]SFT.pdf filter=lfs diff=lfs merge=lfs -text +code/notes/0104[[:space:]]-[[:space:]]Trust[[:space:]]Regions[[:space:]]and[[:space:]]the[[:space:]]PPO[[:space:]]Clip.pdf filter=lfs diff=lfs merge=lfs -text +code/notes/0106[[:space:]]-[[:space:]]DAPO.pdf filter=lfs diff=lfs merge=lfs -text +code/notes/0105[[:space:]]-[[:space:]]GRPO[[:space:]]and[[:space:]]Dr[[:space:]]GRPO.pdf filter=lfs diff=lfs merge=lfs -text +code/notes/0109[[:space:]]-[[:space:]]KL[[:space:]]Estimators.pdf filter=lfs diff=lfs merge=lfs -text +code/notes/0107[[:space:]]-[[:space:]]CISPO.pdf filter=lfs diff=lfs merge=lfs -text +code/notes/0108[[:space:]]-[[:space:]]Training-Inference[[:space:]]Mismatch.pdf filter=lfs diff=lfs merge=lfs -text +code/notes/0202[[:space:]]-[[:space:]]Rejection[[:space:]]Sampling[[:space:]]and[[:space:]]Expert[[:space:]]Iteration[[:space:]]as[[:space:]]Policy[[:space:]]Improvement.pdf filter=lfs diff=lfs merge=lfs -text +code/notes/0201[[:space:]]-[[:space:]]Verifier[[:space:]]in[[:space:]]the[[:space:]]Loop[[:space:]]as[[:space:]]a[[:space:]]Binary-Reward[[:space:]]MDP.pdf filter=lfs diff=lfs merge=lfs -text +code/notes/0110[[:space:]]-[[:space:]]Reward[[:space:]]Shaping[[:space:]]for[[:space:]]Verifier[[:space:]]Rewards.pdf filter=lfs diff=lfs merge=lfs -text +code/notes/0203[[:space:]]-[[:space:]]pass@k[[:space:]]Estimator,[[:space:]]Variance[[:space:]]and[[:space:]]the[[:space:]]pass@1[[:space:]]Trade-off.pdf filter=lfs diff=lfs merge=lfs -text +code/notes/0204[[:space:]]-[[:space:]]Self-Correction[[:space:]]as[[:space:]]a[[:space:]]Multi-Turn[[:space:]]MDP.pdf filter=lfs diff=lfs merge=lfs -text +code/notes/0205[[:space:]]-[[:space:]]Model[[:space:]]Averaging[[:space:]]and[[:space:]]Weight[[:space:]]Interpolation.pdf filter=lfs diff=lfs merge=lfs -text +code/notes/0207[[:space:]]-[[:space:]]Test-Time[[:space:]]Scaling[[:space:]]Cost[[:space:]]Model.pdf filter=lfs diff=lfs merge=lfs -text +code/notes/0206[[:space:]]-[[:space:]]Curriculum[[:space:]]by[[:space:]]Pass-Rate[[:space:]]Band.pdf filter=lfs diff=lfs merge=lfs -text +code/notes/0208[[:space:]]-[[:space:]]Statistics[[:space:]]of[[:space:]]Evaluation.pdf filter=lfs diff=lfs merge=lfs -text +code/notes/0209[[:space:]]-[[:space:]]Variance[[:space:]]Components[[:space:]]and[[:space:]]Equivalence[[:space:]]Tests.pdf filter=lfs diff=lfs merge=lfs -text +code/notes/0210[[:space:]]-[[:space:]]Data[[:space:]]Selection[[:space:]]and[[:space:]]Contamination.pdf filter=lfs diff=lfs merge=lfs -text +code/notes/0301[[:space:]]-[[:space:]]Curry-Howard.pdf filter=lfs diff=lfs merge=lfs -text +code/notes/0302[[:space:]]-[[:space:]]Dependent[[:space:]]Types.pdf filter=lfs diff=lfs merge=lfs -text +code/notes/0303[[:space:]]-[[:space:]]Hoare[[:space:]]Logic[[:space:]]and[[:space:]]Refinement.pdf filter=lfs diff=lfs merge=lfs -text +code/notes/0304[[:space:]]-[[:space:]]Failed[[:space:]]Proof[[:space:]]to[[:space:]]Bug.pdf filter=lfs diff=lfs merge=lfs -text +code/notes/0305[[:space:]]-[[:space:]]Spec[[:space:]]Generation[[:space:]]Risk.pdf filter=lfs diff=lfs merge=lfs -text +code/notes/0306[[:space:]]-[[:space:]]Verified[[:space:]]Code[[:space:]]Benchmarks.pdf filter=lfs diff=lfs merge=lfs -text +code/notes/0307[[:space:]]-[[:space:]]Aeneas[[:space:]]Semantics.pdf filter=lfs diff=lfs merge=lfs -text +code/align/assets/tokenizer/goedel-prover-v2-8b/tokenizer.json filter=lfs diff=lfs merge=lfs -text diff --git a/align.bundle b/align.bundle new file mode 100644 index 0000000000000000000000000000000000000000..86dad65a7914814ed1e02690bd4cf1d88854a17e --- /dev/null +++ b/align.bundle @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:df359f82968d15e36d48b4b604240543f253978d711d41f48401b1da00388b1e +size 18368694 diff --git a/code/align/assets/tokenizer/goedel-prover-v2-8b/tokenizer.json b/code/align/assets/tokenizer/goedel-prover-v2-8b/tokenizer.json new file mode 100644 index 0000000000000000000000000000000000000000..cd71f61a15a522601badb3dc960d800d9cb3766c --- /dev/null +++ b/code/align/assets/tokenizer/goedel-prover-v2-8b/tokenizer.json @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:aeb13307a71acd8fe81861d94ad54ab689df773318809eed3cbe794b4492dae4 +size 11422654 diff --git a/code/assets/tokenizer/mistral-7b-v0.3/tokenizer.model b/code/assets/tokenizer/mistral-7b-v0.3/tokenizer.model new file mode 100644 index 0000000000000000000000000000000000000000..f3b30c02e7f12eb7fbc7e89595b97c43067d73c6 --- /dev/null +++ b/code/assets/tokenizer/mistral-7b-v0.3/tokenizer.model @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:37f00374dea48658ee8f5d0f21895b9bc55cb0103939607c8185bfd1c6ca1f89 +size 587404 diff --git a/code/bugfind/bench/kernels/mldsa_norm/post/src/lib.rs b/code/bugfind/bench/kernels/mldsa_norm/post/src/lib.rs new file mode 100644 index 0000000000000000000000000000000000000000..a9e74d53b6e3de7f8b26f7a85260fea6fd4a6374 --- /dev/null +++ b/code/bugfind/bench/kernels/mldsa_norm/post/src/lib.rs @@ -0,0 +1,3 @@ +pub fn response_bound(gamma1_exponent: u32, beta: i32) -> i32 { + (1i32 << gamma1_exponent) - beta +} diff --git a/code/bugfind/bench/kernels/mldsa_norm/pre/Cargo.toml b/code/bugfind/bench/kernels/mldsa_norm/pre/Cargo.toml new file mode 100644 index 0000000000000000000000000000000000000000..7926830c9f225c87c58322c28e5229085968c745 --- /dev/null +++ b/code/bugfind/bench/kernels/mldsa_norm/pre/Cargo.toml @@ -0,0 +1,9 @@ +[package] +name = "k_mldsa_norm_pre" +version = "0.1.0" +edition = "2021" + +[lib] +path = "src/lib.rs" + +[workspace] diff --git a/code/bugfind/bench/kernels/mldsa_norm/pre/src/lib.rs b/code/bugfind/bench/kernels/mldsa_norm/pre/src/lib.rs new file mode 100644 index 0000000000000000000000000000000000000000..210741ee2a3ee8425de30bf64d4cb205494171fb --- /dev/null +++ b/code/bugfind/bench/kernels/mldsa_norm/pre/src/lib.rs @@ -0,0 +1,3 @@ +pub fn response_bound(gamma1_exponent: u32, beta: i32) -> i32 { + (2i32 << gamma1_exponent) - beta +} diff --git a/code/bugfind/bench/kernels/nanoid_mask/post/Cargo.lock b/code/bugfind/bench/kernels/nanoid_mask/post/Cargo.lock new file mode 100644 index 0000000000000000000000000000000000000000..37836a613d8c06b7233849176cb0225a1272dff5 --- /dev/null +++ b/code/bugfind/bench/kernels/nanoid_mask/post/Cargo.lock @@ -0,0 +1,7 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "k_nanoid_mask_post" +version = "0.1.0" diff --git a/code/bugfind/bench/kernels/nanoid_mask/post/Cargo.toml b/code/bugfind/bench/kernels/nanoid_mask/post/Cargo.toml new file mode 100644 index 0000000000000000000000000000000000000000..4f49bf034dfec5cd3d4bdc0580116803809d360f --- /dev/null +++ b/code/bugfind/bench/kernels/nanoid_mask/post/Cargo.toml @@ -0,0 +1,9 @@ +[package] +name = "k_nanoid_mask_post" +version = "0.1.0" +edition = "2021" + +[lib] +path = "src/lib.rs" + +[workspace] diff --git a/code/bugfind/bench/kernels/nanoid_mask/post/src/lib.rs b/code/bugfind/bench/kernels/nanoid_mask/post/src/lib.rs new file mode 100644 index 0000000000000000000000000000000000000000..4c690a6dfb747e768e50d4d14e27806a7beb7ec5 --- /dev/null +++ b/code/bugfind/bench/kernels/nanoid_mask/post/src/lib.rs @@ -0,0 +1,7 @@ +pub fn alphabet_mask(len: usize) -> usize { + let mut p = 1usize; + while p < len { + p = p * 2; + } + p - 1 +} diff --git a/code/bugfind/bench/kernels/nanoid_mask/pre/Cargo.lock b/code/bugfind/bench/kernels/nanoid_mask/pre/Cargo.lock new file mode 100644 index 0000000000000000000000000000000000000000..1fe7c4be38ae388964a78cde5b58c73470d97cdc --- /dev/null +++ b/code/bugfind/bench/kernels/nanoid_mask/pre/Cargo.lock @@ -0,0 +1,7 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "k_nanoid_mask_pre" +version = "0.1.0" diff --git a/code/bugfind/bench/kernels/nanoid_mask/pre/Cargo.toml b/code/bugfind/bench/kernels/nanoid_mask/pre/Cargo.toml new file mode 100644 index 0000000000000000000000000000000000000000..d1045ae5ba98ff38cca296384070b3311bedc905 --- /dev/null +++ b/code/bugfind/bench/kernels/nanoid_mask/pre/Cargo.toml @@ -0,0 +1,9 @@ +[package] +name = "k_nanoid_mask_pre" +version = "0.1.0" +edition = "2021" + +[lib] +path = "src/lib.rs" + +[workspace] diff --git a/code/bugfind/bench/kernels/nanoid_mask/pre/src/lib.rs b/code/bugfind/bench/kernels/nanoid_mask/pre/src/lib.rs new file mode 100644 index 0000000000000000000000000000000000000000..d7b1390be05f1dd8eefb6e3a115ca4b7ef5c789f --- /dev/null +++ b/code/bugfind/bench/kernels/nanoid_mask/pre/src/lib.rs @@ -0,0 +1,3 @@ +pub fn alphabet_mask(len: usize) -> usize { + len - 1 +} diff --git a/code/bugfind/bench/kernels/nostr_iv/post/Cargo.lock b/code/bugfind/bench/kernels/nostr_iv/post/Cargo.lock new file mode 100644 index 0000000000000000000000000000000000000000..4f8df6cdc54b6a0d897b405c4439234e0265e9a2 --- /dev/null +++ b/code/bugfind/bench/kernels/nostr_iv/post/Cargo.lock @@ -0,0 +1,7 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "k_nostr_iv_post" +version = "0.1.0" diff --git a/code/bugfind/bench/kernels/nostr_iv/post/Cargo.toml b/code/bugfind/bench/kernels/nostr_iv/post/Cargo.toml new file mode 100644 index 0000000000000000000000000000000000000000..a99ce46a368cba5fa1a13cc885b208f8d66e493a --- /dev/null +++ b/code/bugfind/bench/kernels/nostr_iv/post/Cargo.toml @@ -0,0 +1,9 @@ +[package] +name = "k_nostr_iv_post" +version = "0.1.0" +edition = "2021" + +[lib] +path = "src/lib.rs" + +[workspace] diff --git a/code/bugfind/bench/kernels/nostr_iv/post/src/lib.rs b/code/bugfind/bench/kernels/nostr_iv/post/src/lib.rs new file mode 100644 index 0000000000000000000000000000000000000000..d25cc5de23823a53542b2890ca3586601554e26c --- /dev/null +++ b/code/bugfind/bench/kernels/nostr_iv/post/src/lib.rs @@ -0,0 +1,12 @@ +pub fn load_iv(iv: &[u8]) -> Result<[u8; 16], u8> { + if iv.len() != 16 { + return Err(1); + } + let mut out = [0u8; 16]; + let mut i = 0usize; + while i < 16 { + out[i] = iv[i]; + i += 1; + } + Ok(out) +} diff --git a/code/bugfind/bench/kernels/nostr_iv/pre/Cargo.lock b/code/bugfind/bench/kernels/nostr_iv/pre/Cargo.lock new file mode 100644 index 0000000000000000000000000000000000000000..6e77c8d1f45cc9b55562be9d383182c68490cc39 --- /dev/null +++ b/code/bugfind/bench/kernels/nostr_iv/pre/Cargo.lock @@ -0,0 +1,7 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "k_nostr_iv_pre" +version = "0.1.0" diff --git a/code/bugfind/bench/kernels/nostr_iv/pre/Cargo.toml b/code/bugfind/bench/kernels/nostr_iv/pre/Cargo.toml new file mode 100644 index 0000000000000000000000000000000000000000..8909e3eea3040f3165ceb8f8325d9223d0a3d402 --- /dev/null +++ b/code/bugfind/bench/kernels/nostr_iv/pre/Cargo.toml @@ -0,0 +1,9 @@ +[package] +name = "k_nostr_iv_pre" +version = "0.1.0" +edition = "2021" + +[lib] +path = "src/lib.rs" + +[workspace] diff --git a/code/bugfind/bench/kernels/nostr_iv/pre/src/lib.rs b/code/bugfind/bench/kernels/nostr_iv/pre/src/lib.rs new file mode 100644 index 0000000000000000000000000000000000000000..41badde1ef5a8190cdc72d1275ad6e1fd7215e24 --- /dev/null +++ b/code/bugfind/bench/kernels/nostr_iv/pre/src/lib.rs @@ -0,0 +1,9 @@ +pub fn load_iv(iv: &[u8]) -> Result<[u8; 16], u8> { + let mut out = [0u8; 16]; + let mut i = 0usize; + while i < 16 { + out[i] = iv[i]; + i += 1; + } + Ok(out) +} diff --git a/code/bugfind/bench/kernels/odoh_split/post/Cargo.lock b/code/bugfind/bench/kernels/odoh_split/post/Cargo.lock new file mode 100644 index 0000000000000000000000000000000000000000..86137506fe5178e6febb2696bb1eb41287d4abfd --- /dev/null +++ b/code/bugfind/bench/kernels/odoh_split/post/Cargo.lock @@ -0,0 +1,7 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "k_odoh_split_post" +version = "0.1.0" diff --git a/code/bugfind/bench/kernels/odoh_split/post/Cargo.toml b/code/bugfind/bench/kernels/odoh_split/post/Cargo.toml new file mode 100644 index 0000000000000000000000000000000000000000..b5c3665d85084fba0eb177a87645bb80ec808579 --- /dev/null +++ b/code/bugfind/bench/kernels/odoh_split/post/Cargo.toml @@ -0,0 +1,9 @@ +[package] +name = "k_odoh_split_post" +version = "0.1.0" +edition = "2021" + +[lib] +path = "src/lib.rs" + +[workspace] diff --git a/code/bugfind/bench/kernels/odoh_split/post/src/lib.rs b/code/bugfind/bench/kernels/odoh_split/post/src/lib.rs new file mode 100644 index 0000000000000000000000000000000000000000..2c6ab0219a19c4d0cb2e335a8a41aab1b52cd14d --- /dev/null +++ b/code/bugfind/bench/kernels/odoh_split/post/src/lib.rs @@ -0,0 +1,7 @@ +pub fn encrypted_payload_len(msg: &[u8], key_size: usize) -> Result { + if key_size > msg.len() { + return Err(1); + } + let ct = &msg[key_size..]; + Ok(ct.len()) +} diff --git a/code/bugfind/bench/kernels/odoh_split/pre/Cargo.lock b/code/bugfind/bench/kernels/odoh_split/pre/Cargo.lock new file mode 100644 index 0000000000000000000000000000000000000000..85d3c14daa916ab81acbb30cbc775886703bad2d --- /dev/null +++ b/code/bugfind/bench/kernels/odoh_split/pre/Cargo.lock @@ -0,0 +1,7 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "k_odoh_split_pre" +version = "0.1.0" diff --git a/code/bugfind/bench/kernels/odoh_split/pre/Cargo.toml b/code/bugfind/bench/kernels/odoh_split/pre/Cargo.toml new file mode 100644 index 0000000000000000000000000000000000000000..d37349f582dab1a61ac241726859fe7a63cacbc2 --- /dev/null +++ b/code/bugfind/bench/kernels/odoh_split/pre/Cargo.toml @@ -0,0 +1,9 @@ +[package] +name = "k_odoh_split_pre" +version = "0.1.0" +edition = "2021" + +[lib] +path = "src/lib.rs" + +[workspace] diff --git a/code/bugfind/bench/kernels/odoh_split/pre/src/lib.rs b/code/bugfind/bench/kernels/odoh_split/pre/src/lib.rs new file mode 100644 index 0000000000000000000000000000000000000000..3368dc7e270a98c1b9e873e61a953f291cd3f80f --- /dev/null +++ b/code/bugfind/bench/kernels/odoh_split/pre/src/lib.rs @@ -0,0 +1,4 @@ +pub fn encrypted_payload_len(msg: &[u8], key_size: usize) -> Result { + let ct = &msg[key_size..]; + Ok(ct.len()) +} diff --git a/code/bugfind/bench/kernels/phone_ctx/post/Cargo.lock b/code/bugfind/bench/kernels/phone_ctx/post/Cargo.lock new file mode 100644 index 0000000000000000000000000000000000000000..1ba148fee96bf8fa2f4159f85327f20fb0d1ab6e --- /dev/null +++ b/code/bugfind/bench/kernels/phone_ctx/post/Cargo.lock @@ -0,0 +1,7 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "k_phone_ctx_post" +version = "0.1.0" diff --git a/code/bugfind/bench/kernels/phone_ctx/post/Cargo.toml b/code/bugfind/bench/kernels/phone_ctx/post/Cargo.toml new file mode 100644 index 0000000000000000000000000000000000000000..c3dba3c3945b4929895cba98c4ddb79314cb1fc6 --- /dev/null +++ b/code/bugfind/bench/kernels/phone_ctx/post/Cargo.toml @@ -0,0 +1,9 @@ +[package] +name = "k_phone_ctx_post" +version = "0.1.0" +edition = "2021" + +[lib] +path = "src/lib.rs" + +[workspace] diff --git a/code/bugfind/bench/kernels/phone_ctx/post/src/lib.rs b/code/bugfind/bench/kernels/phone_ctx/post/src/lib.rs new file mode 100644 index 0000000000000000000000000000000000000000..24c24a5b2c69ec9c1fc929205ef0995e55b97afa --- /dev/null +++ b/code/bugfind/bench/kernels/phone_ctx/post/src/lib.rs @@ -0,0 +1,3 @@ +pub fn context_prefix_skip(s: &[u8]) -> usize { + if !s.is_empty() && s[0] == b'+' { 1 } else { 0 } +} diff --git a/code/bugfind/bench/kernels/phone_ctx/pre/Cargo.lock b/code/bugfind/bench/kernels/phone_ctx/pre/Cargo.lock new file mode 100644 index 0000000000000000000000000000000000000000..de15e376cf86f6cc157ad9dfaa9777d34cd5d0e6 --- /dev/null +++ b/code/bugfind/bench/kernels/phone_ctx/pre/Cargo.lock @@ -0,0 +1,7 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "k_phone_ctx_pre" +version = "0.1.0" diff --git a/code/bugfind/bench/kernels/phone_ctx/pre/Cargo.toml b/code/bugfind/bench/kernels/phone_ctx/pre/Cargo.toml new file mode 100644 index 0000000000000000000000000000000000000000..e6a39dd6ad426e76d842b367ffb75550d11ace3c --- /dev/null +++ b/code/bugfind/bench/kernels/phone_ctx/pre/Cargo.toml @@ -0,0 +1,9 @@ +[package] +name = "k_phone_ctx_pre" +version = "0.1.0" +edition = "2021" + +[lib] +path = "src/lib.rs" + +[workspace] diff --git a/code/bugfind/bench/kernels/phone_ctx/pre/src/lib.rs b/code/bugfind/bench/kernels/phone_ctx/pre/src/lib.rs new file mode 100644 index 0000000000000000000000000000000000000000..9f99584770d434ee4d59725dc41eb48e1d8610ef --- /dev/null +++ b/code/bugfind/bench/kernels/phone_ctx/pre/src/lib.rs @@ -0,0 +1,3 @@ +pub fn context_prefix_skip(s: &[u8]) -> usize { + if s[0] == b'+' { 1 } else { 0 } +} diff --git a/code/bugfind/bench/kernels/poly1305_key/post/Cargo.lock b/code/bugfind/bench/kernels/poly1305_key/post/Cargo.lock new file mode 100644 index 0000000000000000000000000000000000000000..97d448d27a9d43920d76674be35a71b56e4253fb --- /dev/null +++ b/code/bugfind/bench/kernels/poly1305_key/post/Cargo.lock @@ -0,0 +1,7 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "k_poly1305_key_post" +version = "0.1.0" diff --git a/code/bugfind/bench/kernels/poly1305_key/post/Cargo.toml b/code/bugfind/bench/kernels/poly1305_key/post/Cargo.toml new file mode 100644 index 0000000000000000000000000000000000000000..423d1fbf41def16484cdc1782d869d16ba52a116 --- /dev/null +++ b/code/bugfind/bench/kernels/poly1305_key/post/Cargo.toml @@ -0,0 +1,9 @@ +[package] +name = "k_poly1305_key_post" +version = "0.1.0" +edition = "2021" + +[lib] +path = "src/lib.rs" + +[workspace] diff --git a/code/bugfind/bench/kernels/poly1305_key/post/src/lib.rs b/code/bugfind/bench/kernels/poly1305_key/post/src/lib.rs new file mode 100644 index 0000000000000000000000000000000000000000..4b4518e8ae6413e4e350ee78fc192c1139570822 --- /dev/null +++ b/code/bugfind/bench/kernels/poly1305_key/post/src/lib.rs @@ -0,0 +1,4 @@ +pub const KEY_LEN: usize = 32; +pub fn key_len_ok(key: &[u8]) -> bool { + key.len() == KEY_LEN +} diff --git a/code/bugfind/bench/kernels/poly1305_key/pre/Cargo.lock b/code/bugfind/bench/kernels/poly1305_key/pre/Cargo.lock new file mode 100644 index 0000000000000000000000000000000000000000..81b0ec82bf8b48055f0c0345454ba8a641b9d5fe --- /dev/null +++ b/code/bugfind/bench/kernels/poly1305_key/pre/Cargo.lock @@ -0,0 +1,7 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "k_poly1305_key_pre" +version = "0.1.0" diff --git a/code/bugfind/bench/kernels/poly1305_key/pre/Cargo.toml b/code/bugfind/bench/kernels/poly1305_key/pre/Cargo.toml new file mode 100644 index 0000000000000000000000000000000000000000..6a6d6dbe3a039d96800c2558f1f84e29e2d7d687 --- /dev/null +++ b/code/bugfind/bench/kernels/poly1305_key/pre/Cargo.toml @@ -0,0 +1,9 @@ +[package] +name = "k_poly1305_key_pre" +version = "0.1.0" +edition = "2021" + +[lib] +path = "src/lib.rs" + +[workspace] diff --git a/code/bugfind/bench/kernels/poly1305_key/pre/src/lib.rs b/code/bugfind/bench/kernels/poly1305_key/pre/src/lib.rs new file mode 100644 index 0000000000000000000000000000000000000000..a2512f1f5031109f274c8e54458994ed55bdb06e --- /dev/null +++ b/code/bugfind/bench/kernels/poly1305_key/pre/src/lib.rs @@ -0,0 +1,4 @@ +pub const KEY_LEN: usize = 16; +pub fn key_len_ok(key: &[u8]) -> bool { + key.len() == KEY_LEN +} diff --git a/code/bugfind/bench/kernels/ring_ctr/post/Cargo.lock b/code/bugfind/bench/kernels/ring_ctr/post/Cargo.lock new file mode 100644 index 0000000000000000000000000000000000000000..b1440a0b03be61bb7649315aaa70cc09758c097e --- /dev/null +++ b/code/bugfind/bench/kernels/ring_ctr/post/Cargo.lock @@ -0,0 +1,7 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "k_ring_ctr_post" +version = "0.1.0" diff --git a/code/bugfind/bench/kernels/ring_ctr/post/Cargo.toml b/code/bugfind/bench/kernels/ring_ctr/post/Cargo.toml new file mode 100644 index 0000000000000000000000000000000000000000..fa5a0df32acb4cff095cd9afe3292a3be77ba6d1 --- /dev/null +++ b/code/bugfind/bench/kernels/ring_ctr/post/Cargo.toml @@ -0,0 +1,9 @@ +[package] +name = "k_ring_ctr_post" +version = "0.1.0" +edition = "2021" + +[lib] +path = "src/lib.rs" + +[workspace] diff --git a/code/bugfind/bench/kernels/ring_ctr/post/src/lib.rs b/code/bugfind/bench/kernels/ring_ctr/post/src/lib.rs new file mode 100644 index 0000000000000000000000000000000000000000..bf843207f177a66ee37769d393d2437122656f94 --- /dev/null +++ b/code/bugfind/bench/kernels/ring_ctr/post/src/lib.rs @@ -0,0 +1,3 @@ +pub fn increment_by(old_value: u32, increment_by: u32) -> u32 { + old_value.wrapping_add(increment_by) +} diff --git a/code/bugfind/bench/kernels/ring_ctr/pre/Cargo.lock b/code/bugfind/bench/kernels/ring_ctr/pre/Cargo.lock new file mode 100644 index 0000000000000000000000000000000000000000..8d26b4751a81d5aa4b64a641dbf81ff72c580891 --- /dev/null +++ b/code/bugfind/bench/kernels/ring_ctr/pre/Cargo.lock @@ -0,0 +1,7 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "k_ring_ctr_pre" +version = "0.1.0" diff --git a/code/bugfind/bench/kernels/ring_ctr/pre/Cargo.toml b/code/bugfind/bench/kernels/ring_ctr/pre/Cargo.toml new file mode 100644 index 0000000000000000000000000000000000000000..a55a5bea4591b488b76c1e635f746cc14a542408 --- /dev/null +++ b/code/bugfind/bench/kernels/ring_ctr/pre/Cargo.toml @@ -0,0 +1,9 @@ +[package] +name = "k_ring_ctr_pre" +version = "0.1.0" +edition = "2021" + +[lib] +path = "src/lib.rs" + +[workspace] diff --git a/code/bugfind/bench/kernels/ring_ctr/pre/src/lib.rs b/code/bugfind/bench/kernels/ring_ctr/pre/src/lib.rs new file mode 100644 index 0000000000000000000000000000000000000000..d7574ecab1db9a99b8d04b8ff2623fd1f7f08117 --- /dev/null +++ b/code/bugfind/bench/kernels/ring_ctr/pre/src/lib.rs @@ -0,0 +1,3 @@ +pub fn increment_by(old_value: u32, increment_by: u32) -> u32 { + old_value + increment_by +} diff --git a/code/bugfind/bench/kernels/stack_insert/post/Cargo.lock b/code/bugfind/bench/kernels/stack_insert/post/Cargo.lock new file mode 100644 index 0000000000000000000000000000000000000000..f9fb94beeea5f17b111131aeb322b3a4bf90a255 --- /dev/null +++ b/code/bugfind/bench/kernels/stack_insert/post/Cargo.lock @@ -0,0 +1,7 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "k_stack_insert_post" +version = "0.1.0" diff --git a/code/bugfind/bench/kernels/stack_insert/post/Cargo.toml b/code/bugfind/bench/kernels/stack_insert/post/Cargo.toml new file mode 100644 index 0000000000000000000000000000000000000000..7c3c3e75740bdddab00250566483f0720ba1c1b3 --- /dev/null +++ b/code/bugfind/bench/kernels/stack_insert/post/Cargo.toml @@ -0,0 +1,9 @@ +[package] +name = "k_stack_insert_post" +version = "0.1.0" +edition = "2021" + +[lib] +path = "src/lib.rs" + +[workspace] diff --git a/code/bugfind/bench/kernels/stack_insert/post/src/lib.rs b/code/bugfind/bench/kernels/stack_insert/post/src/lib.rs new file mode 100644 index 0000000000000000000000000000000000000000..bef6f8e2359eb99d9fc21e13b7735d17b1250e11 --- /dev/null +++ b/code/bugfind/bench/kernels/stack_insert/post/src/lib.rs @@ -0,0 +1,6 @@ +pub fn insert_shift_len(len: usize, index: usize) -> Result { + if index > len { + return Err(1); + } + Ok(len - index) +} diff --git a/code/bugfind/bench/kernels/stack_insert/pre/Cargo.lock b/code/bugfind/bench/kernels/stack_insert/pre/Cargo.lock new file mode 100644 index 0000000000000000000000000000000000000000..27094b38080abf2c911faa789abbdfe4330a518b --- /dev/null +++ b/code/bugfind/bench/kernels/stack_insert/pre/Cargo.lock @@ -0,0 +1,7 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "k_stack_insert_pre" +version = "0.1.0" diff --git a/code/bugfind/bench/kernels/stack_insert/pre/Cargo.toml b/code/bugfind/bench/kernels/stack_insert/pre/Cargo.toml new file mode 100644 index 0000000000000000000000000000000000000000..08b9f185e963def19009988bbf98000f93474e09 --- /dev/null +++ b/code/bugfind/bench/kernels/stack_insert/pre/Cargo.toml @@ -0,0 +1,9 @@ +[package] +name = "k_stack_insert_pre" +version = "0.1.0" +edition = "2021" + +[lib] +path = "src/lib.rs" + +[workspace] diff --git a/code/bugfind/bench/kernels/stack_insert/pre/src/lib.rs b/code/bugfind/bench/kernels/stack_insert/pre/src/lib.rs new file mode 100644 index 0000000000000000000000000000000000000000..f062aa33f3e4ad8dc0d812eaf702b3bac5f3e37e --- /dev/null +++ b/code/bugfind/bench/kernels/stack_insert/pre/src/lib.rs @@ -0,0 +1,3 @@ +pub fn insert_shift_len(len: usize, index: usize) -> Result { + Ok(len.wrapping_sub(index)) +} diff --git a/code/bugfind/bench/kernels/tar_pax/post/Cargo.lock b/code/bugfind/bench/kernels/tar_pax/post/Cargo.lock new file mode 100644 index 0000000000000000000000000000000000000000..a74618d50f06123e40d4505d5245f3ccb02ec8c3 --- /dev/null +++ b/code/bugfind/bench/kernels/tar_pax/post/Cargo.lock @@ -0,0 +1,7 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "k_tar_pax_post" +version = "0.1.0" diff --git a/code/bugfind/bench/kernels/tar_pax/post/Cargo.toml b/code/bugfind/bench/kernels/tar_pax/post/Cargo.toml new file mode 100644 index 0000000000000000000000000000000000000000..a40c7ebc3c9370808eaa2adab751e2b54f7c14b5 --- /dev/null +++ b/code/bugfind/bench/kernels/tar_pax/post/Cargo.toml @@ -0,0 +1,9 @@ +[package] +name = "k_tar_pax_post" +version = "0.1.0" +edition = "2021" + +[lib] +path = "src/lib.rs" + +[workspace] diff --git a/code/bugfind/bench/kernels/tar_pax/post/src/lib.rs b/code/bugfind/bench/kernels/tar_pax/post/src/lib.rs new file mode 100644 index 0000000000000000000000000000000000000000..4756466623c9c871fa76d1cfefc3f7b109e016fc --- /dev/null +++ b/code/bugfind/bench/kernels/tar_pax/post/src/lib.rs @@ -0,0 +1,11 @@ +pub fn apply_pax(entry_type: u8, has_pax: bool) -> bool { + let mut is_extension_header = false; + if entry_type == b'L' || entry_type == b'K' || entry_type == b'x' || entry_type == b'g' { + is_extension_header = true; + } + if has_pax { + if is_extension_header { false } else { true } + } else { + false + } +} diff --git a/code/bugfind/bench/kernels/tar_pax/pre/Cargo.lock b/code/bugfind/bench/kernels/tar_pax/pre/Cargo.lock new file mode 100644 index 0000000000000000000000000000000000000000..97b1006f73665a3af5e0dd0115e13a4dc4a026f5 --- /dev/null +++ b/code/bugfind/bench/kernels/tar_pax/pre/Cargo.lock @@ -0,0 +1,7 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "k_tar_pax_pre" +version = "0.1.0" diff --git a/code/bugfind/bench/kernels/tar_pax/pre/Cargo.toml b/code/bugfind/bench/kernels/tar_pax/pre/Cargo.toml new file mode 100644 index 0000000000000000000000000000000000000000..f67573efed6b677ee94e9c7b1eb4ecdc21505d6f --- /dev/null +++ b/code/bugfind/bench/kernels/tar_pax/pre/Cargo.toml @@ -0,0 +1,9 @@ +[package] +name = "k_tar_pax_pre" +version = "0.1.0" +edition = "2021" + +[lib] +path = "src/lib.rs" + +[workspace] diff --git a/code/bugfind/bench/kernels/tar_pax/pre/src/lib.rs b/code/bugfind/bench/kernels/tar_pax/pre/src/lib.rs new file mode 100644 index 0000000000000000000000000000000000000000..ffe2e734b7ebf7b385d53ae6423a9f1e161563b3 --- /dev/null +++ b/code/bugfind/bench/kernels/tar_pax/pre/src/lib.rs @@ -0,0 +1,3 @@ +pub fn apply_pax(entry_type: u8, has_pax: bool) -> bool { + has_pax +} diff --git a/code/bugfind/bench/kernels/tinyhttp_hdr/post/Cargo.lock b/code/bugfind/bench/kernels/tinyhttp_hdr/post/Cargo.lock new file mode 100644 index 0000000000000000000000000000000000000000..0547ab4f607503365cc6287196d57ab20b32cb04 --- /dev/null +++ b/code/bugfind/bench/kernels/tinyhttp_hdr/post/Cargo.lock @@ -0,0 +1,7 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "k_tinyhttp_hdr_post" +version = "0.1.0" diff --git a/code/bugfind/bench/kernels/tinyhttp_hdr/post/Cargo.toml b/code/bugfind/bench/kernels/tinyhttp_hdr/post/Cargo.toml new file mode 100644 index 0000000000000000000000000000000000000000..1b4f598c415befc6fb829ea1fb9d52f5dd201dcb --- /dev/null +++ b/code/bugfind/bench/kernels/tinyhttp_hdr/post/Cargo.toml @@ -0,0 +1,9 @@ +[package] +name = "k_tinyhttp_hdr_post" +version = "0.1.0" +edition = "2021" + +[lib] +path = "src/lib.rs" + +[workspace] diff --git a/code/bugfind/bench/kernels/tinyhttp_hdr/post/src/lib.rs b/code/bugfind/bench/kernels/tinyhttp_hdr/post/src/lib.rs new file mode 100644 index 0000000000000000000000000000000000000000..b6b90bdeeb71715dbaeade7f806bdea2c2e7cba9 --- /dev/null +++ b/code/bugfind/bench/kernels/tinyhttp_hdr/post/src/lib.rs @@ -0,0 +1,14 @@ +fn is_ws(b: u8) -> bool { + b == b' ' || b == b'\t' || b == b'\n' || b == b'\r' || b == 11u8 || b == 12u8 +} +pub fn header_field_ok(s: &[u8]) -> bool { + let mut ok = true; + let mut i = 0usize; + while i < s.len() { + if s[i] >= 128 || is_ws(s[i]) { + ok = false; + } + i += 1; + } + ok +} diff --git a/code/bugfind/bench/kernels/tinyhttp_hdr/pre/Cargo.lock b/code/bugfind/bench/kernels/tinyhttp_hdr/pre/Cargo.lock new file mode 100644 index 0000000000000000000000000000000000000000..614853563e17ee51e4f97cfde6ccdadadc0b2b08 --- /dev/null +++ b/code/bugfind/bench/kernels/tinyhttp_hdr/pre/Cargo.lock @@ -0,0 +1,7 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "k_tinyhttp_hdr_pre" +version = "0.1.0" diff --git a/code/bugfind/bench/kernels/tinyhttp_hdr/pre/Cargo.toml b/code/bugfind/bench/kernels/tinyhttp_hdr/pre/Cargo.toml new file mode 100644 index 0000000000000000000000000000000000000000..b855e4fae9c91d4f227cf33ed3f6b52db44fca77 --- /dev/null +++ b/code/bugfind/bench/kernels/tinyhttp_hdr/pre/Cargo.toml @@ -0,0 +1,9 @@ +[package] +name = "k_tinyhttp_hdr_pre" +version = "0.1.0" +edition = "2021" + +[lib] +path = "src/lib.rs" + +[workspace] diff --git a/code/bugfind/bench/kernels/tinyhttp_hdr/pre/src/lib.rs b/code/bugfind/bench/kernels/tinyhttp_hdr/pre/src/lib.rs new file mode 100644 index 0000000000000000000000000000000000000000..a7f61d4d24683c2e19a56ee7aa55b1dffde1440e --- /dev/null +++ b/code/bugfind/bench/kernels/tinyhttp_hdr/pre/src/lib.rs @@ -0,0 +1,22 @@ +fn is_ws(b: u8) -> bool { + b == b' ' || b == b'\t' || b == b'\n' || b == b'\r' || b == 11u8 || b == 12u8 +} +pub fn header_field_ok(s: &[u8]) -> bool { + let mut start = 0usize; + let mut end = s.len(); + while start < end && is_ws(s[start]) { + start += 1; + } + while end > start && is_ws(s[end - 1]) { + end -= 1; + } + let mut ok = true; + let mut i = start; + while i < end { + if s[i] >= 128 { + ok = false; + } + i += 1; + } + ok +} diff --git a/code/bugfind/bench/kernels/toodee_drain/post/Cargo.lock b/code/bugfind/bench/kernels/toodee_drain/post/Cargo.lock new file mode 100644 index 0000000000000000000000000000000000000000..41f27dd973d002c28936bf7d6d13c4a68bc24084 --- /dev/null +++ b/code/bugfind/bench/kernels/toodee_drain/post/Cargo.lock @@ -0,0 +1,7 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "k_toodee_drain_post" +version = "0.1.0" diff --git a/code/bugfind/bench/kernels/toodee_drain/post/Cargo.toml b/code/bugfind/bench/kernels/toodee_drain/post/Cargo.toml new file mode 100644 index 0000000000000000000000000000000000000000..75015e0e837481f181de38ec6ba6fde546fd0b33 --- /dev/null +++ b/code/bugfind/bench/kernels/toodee_drain/post/Cargo.toml @@ -0,0 +1,9 @@ +[package] +name = "k_toodee_drain_post" +version = "0.1.0" +edition = "2021" + +[lib] +path = "src/lib.rs" + +[workspace] diff --git a/code/bugfind/bench/kernels/toodee_drain/post/src/lib.rs b/code/bugfind/bench/kernels/toodee_drain/post/src/lib.rs new file mode 100644 index 0000000000000000000000000000000000000000..0b2cd4620f3a0b9433f7c44d5de7a79fe0738333 --- /dev/null +++ b/code/bugfind/bench/kernels/toodee_drain/post/src/lib.rs @@ -0,0 +1,3 @@ +pub fn drain_col_copy_len(orig_cols: usize, col: usize) -> usize { + orig_cols - col - 1 +} diff --git a/code/bugfind/bench/kernels/toodee_drain/pre/Cargo.lock b/code/bugfind/bench/kernels/toodee_drain/pre/Cargo.lock new file mode 100644 index 0000000000000000000000000000000000000000..aa0bf65da87d0b9b5d36a5429d2d9f981ed42a57 --- /dev/null +++ b/code/bugfind/bench/kernels/toodee_drain/pre/Cargo.lock @@ -0,0 +1,7 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "k_toodee_drain_pre" +version = "0.1.0" diff --git a/code/bugfind/bench/kernels/toodee_drain/pre/Cargo.toml b/code/bugfind/bench/kernels/toodee_drain/pre/Cargo.toml new file mode 100644 index 0000000000000000000000000000000000000000..029d0d4ccad658277a9e47690959810cb39007c9 --- /dev/null +++ b/code/bugfind/bench/kernels/toodee_drain/pre/Cargo.toml @@ -0,0 +1,9 @@ +[package] +name = "k_toodee_drain_pre" +version = "0.1.0" +edition = "2021" + +[lib] +path = "src/lib.rs" + +[workspace] diff --git a/code/bugfind/bench/kernels/toodee_drain/pre/src/lib.rs b/code/bugfind/bench/kernels/toodee_drain/pre/src/lib.rs new file mode 100644 index 0000000000000000000000000000000000000000..8daa6eb4b2d39b80d8b7d67241a0674530875b5b --- /dev/null +++ b/code/bugfind/bench/kernels/toodee_drain/pre/src/lib.rs @@ -0,0 +1,3 @@ +pub fn drain_col_copy_len(orig_cols: usize, col: usize) -> usize { + orig_cols - col +} diff --git a/code/bugfind/bench/kernels/tract_len/post/Cargo.lock b/code/bugfind/bench/kernels/tract_len/post/Cargo.lock new file mode 100644 index 0000000000000000000000000000000000000000..809b4ac359a98b44817897e1d6dbed5cf1a7ee6d --- /dev/null +++ b/code/bugfind/bench/kernels/tract_len/post/Cargo.lock @@ -0,0 +1,7 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "k_tract_len_post" +version = "0.1.0" diff --git a/code/bugfind/bench/kernels/tract_len/post/Cargo.toml b/code/bugfind/bench/kernels/tract_len/post/Cargo.toml new file mode 100644 index 0000000000000000000000000000000000000000..902ecbfd60ef2c4c9e03834cd1057ddbc3697aec --- /dev/null +++ b/code/bugfind/bench/kernels/tract_len/post/Cargo.toml @@ -0,0 +1,9 @@ +[package] +name = "k_tract_len_post" +version = "0.1.0" +edition = "2021" + +[lib] +path = "src/lib.rs" + +[workspace] diff --git a/code/bugfind/bench/kernels/tract_len/post/src/lib.rs b/code/bugfind/bench/kernels/tract_len/post/src/lib.rs new file mode 100644 index 0000000000000000000000000000000000000000..32d718fe7eb9bf8a40d3c35b4532a504db7a49c7 --- /dev/null +++ b/code/bugfind/bench/kernels/tract_len/post/src/lib.rs @@ -0,0 +1,22 @@ +pub fn tensor_len_ok(dims: &[usize], bits_per_item: usize, data_size_bytes: usize) -> bool { + let mut len = 1usize; + let mut overflow = false; + let mut i = 0usize; + while i < dims.len() { + let d = dims[i]; + if d != 0 && len > usize::MAX / d { + overflow = true; + } else { + len = len * d; + } + i += 1; + } + if overflow { + return false; + } + let per = bits_per_item / 8; + if per != 0 && len > usize::MAX / per { + return false; + } + len * per == data_size_bytes +} diff --git a/code/bugfind/bench/kernels/tract_len/pre/Cargo.lock b/code/bugfind/bench/kernels/tract_len/pre/Cargo.lock new file mode 100644 index 0000000000000000000000000000000000000000..83e30b5221eaa54fff37cde0f34180b60704e617 --- /dev/null +++ b/code/bugfind/bench/kernels/tract_len/pre/Cargo.lock @@ -0,0 +1,7 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "k_tract_len_pre" +version = "0.1.0" diff --git a/code/bugfind/bench/kernels/tract_len/pre/Cargo.toml b/code/bugfind/bench/kernels/tract_len/pre/Cargo.toml new file mode 100644 index 0000000000000000000000000000000000000000..a99dd0fa5cae5395d08eeeec33a86674872fcd98 --- /dev/null +++ b/code/bugfind/bench/kernels/tract_len/pre/Cargo.toml @@ -0,0 +1,9 @@ +[package] +name = "k_tract_len_pre" +version = "0.1.0" +edition = "2021" + +[lib] +path = "src/lib.rs" + +[workspace] diff --git a/code/bugfind/bench/kernels/tract_len/pre/src/lib.rs b/code/bugfind/bench/kernels/tract_len/pre/src/lib.rs new file mode 100644 index 0000000000000000000000000000000000000000..2c7249fc87b647ba6cbfe9757b68fd5ab86b55e8 --- /dev/null +++ b/code/bugfind/bench/kernels/tract_len/pre/src/lib.rs @@ -0,0 +1,9 @@ +pub fn tensor_len_ok(dims: &[usize], bits_per_item: usize, data_size_bytes: usize) -> bool { + let mut len = 1usize; + let mut i = 0usize; + while i < dims.len() { + len = len * dims[i]; + i += 1; + } + len * (bits_per_item / 8) == data_size_bytes +} diff --git a/code/bugfind/bench/kernels/transpose_dims/post/Cargo.lock b/code/bugfind/bench/kernels/transpose_dims/post/Cargo.lock new file mode 100644 index 0000000000000000000000000000000000000000..250c7be8b9f2073834d8c11bbb648f63cc2eeb72 --- /dev/null +++ b/code/bugfind/bench/kernels/transpose_dims/post/Cargo.lock @@ -0,0 +1,7 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "k_transpose_dims_post" +version = "0.1.0" diff --git a/code/bugfind/bench/kernels/transpose_dims/post/Cargo.toml b/code/bugfind/bench/kernels/transpose_dims/post/Cargo.toml new file mode 100644 index 0000000000000000000000000000000000000000..498161c64d1ed06a514b4f9ad68b34fa1391e416 --- /dev/null +++ b/code/bugfind/bench/kernels/transpose_dims/post/Cargo.toml @@ -0,0 +1,9 @@ +[package] +name = "k_transpose_dims_post" +version = "0.1.0" +edition = "2021" + +[lib] +path = "src/lib.rs" + +[workspace] diff --git a/code/bugfind/bench/kernels/transpose_dims/post/src/lib.rs b/code/bugfind/bench/kernels/transpose_dims/post/src/lib.rs new file mode 100644 index 0000000000000000000000000000000000000000..56fac6a9a83c55eace14fe96762a175b1be95eae --- /dev/null +++ b/code/bugfind/bench/kernels/transpose_dims/post/src/lib.rs @@ -0,0 +1,6 @@ +pub fn dims_match(width: usize, height: usize, len: usize) -> bool { + match width.checked_mul(height) { + Some(p) => p == len, + None => false, + } +} diff --git a/code/bugfind/bench/kernels/transpose_dims/pre/Cargo.lock b/code/bugfind/bench/kernels/transpose_dims/pre/Cargo.lock new file mode 100644 index 0000000000000000000000000000000000000000..3c4c30cd4afb4167e310b4d59764ce87c8aa5961 --- /dev/null +++ b/code/bugfind/bench/kernels/transpose_dims/pre/Cargo.lock @@ -0,0 +1,7 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "k_transpose_dims_pre" +version = "0.1.0" diff --git a/code/bugfind/bench/kernels/transpose_dims/pre/Cargo.toml b/code/bugfind/bench/kernels/transpose_dims/pre/Cargo.toml new file mode 100644 index 0000000000000000000000000000000000000000..d6008fabbfd697bf9db4781df4f7de88d0cdb8a9 --- /dev/null +++ b/code/bugfind/bench/kernels/transpose_dims/pre/Cargo.toml @@ -0,0 +1,9 @@ +[package] +name = "k_transpose_dims_pre" +version = "0.1.0" +edition = "2021" + +[lib] +path = "src/lib.rs" + +[workspace] diff --git a/code/bugfind/bench/kernels/transpose_dims/pre/src/lib.rs b/code/bugfind/bench/kernels/transpose_dims/pre/src/lib.rs new file mode 100644 index 0000000000000000000000000000000000000000..4f5eea490be9bf9d8195a5b07de75e55ed01a968 --- /dev/null +++ b/code/bugfind/bench/kernels/transpose_dims/pre/src/lib.rs @@ -0,0 +1,3 @@ +pub fn dims_match(width: usize, height: usize, len: usize) -> bool { + width * height == len +} diff --git a/code/bugfind/bench/kernels/trustdns_ptr/post/Cargo.lock b/code/bugfind/bench/kernels/trustdns_ptr/post/Cargo.lock new file mode 100644 index 0000000000000000000000000000000000000000..878860e3babea6399cc24a2cfdca2574d778dce2 --- /dev/null +++ b/code/bugfind/bench/kernels/trustdns_ptr/post/Cargo.lock @@ -0,0 +1,7 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "k_trustdns_ptr_post" +version = "0.1.0" diff --git a/code/bugfind/bench/kernels/trustdns_ptr/post/Cargo.toml b/code/bugfind/bench/kernels/trustdns_ptr/post/Cargo.toml new file mode 100644 index 0000000000000000000000000000000000000000..500ac27a00921bfb754e81fdde2c496a1802d1a2 --- /dev/null +++ b/code/bugfind/bench/kernels/trustdns_ptr/post/Cargo.toml @@ -0,0 +1,9 @@ +[package] +name = "k_trustdns_ptr_post" +version = "0.1.0" +edition = "2021" + +[lib] +path = "src/lib.rs" + +[workspace] diff --git a/code/bugfind/bench/kernels/trustdns_ptr/post/src/lib.rs b/code/bugfind/bench/kernels/trustdns_ptr/post/src/lib.rs new file mode 100644 index 0000000000000000000000000000000000000000..ad8c1fbb4db30029eddaf30711f9792c1989a494 --- /dev/null +++ b/code/bugfind/bench/kernels/trustdns_ptr/post/src/lib.rs @@ -0,0 +1,4 @@ +pub fn pointer_ok(location: usize, pointer_location: usize, name_start: usize) -> bool { + let _ = pointer_location; + location < name_start +} diff --git a/code/bugfind/bench/kernels/trustdns_ptr/pre/Cargo.lock b/code/bugfind/bench/kernels/trustdns_ptr/pre/Cargo.lock new file mode 100644 index 0000000000000000000000000000000000000000..d8c44be71cf9bcbc910e0c2e158375a64f2c5e56 --- /dev/null +++ b/code/bugfind/bench/kernels/trustdns_ptr/pre/Cargo.lock @@ -0,0 +1,7 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "k_trustdns_ptr_pre" +version = "0.1.0" diff --git a/code/bugfind/bench/kernels/trustdns_ptr/pre/Cargo.toml b/code/bugfind/bench/kernels/trustdns_ptr/pre/Cargo.toml new file mode 100644 index 0000000000000000000000000000000000000000..558515ed77bc8114972ac894d569a7a80cc34418 --- /dev/null +++ b/code/bugfind/bench/kernels/trustdns_ptr/pre/Cargo.toml @@ -0,0 +1,9 @@ +[package] +name = "k_trustdns_ptr_pre" +version = "0.1.0" +edition = "2021" + +[lib] +path = "src/lib.rs" + +[workspace] diff --git a/code/bugfind/bench/kernels/trustdns_ptr/pre/src/lib.rs b/code/bugfind/bench/kernels/trustdns_ptr/pre/src/lib.rs new file mode 100644 index 0000000000000000000000000000000000000000..48597e721fe0aa4006251c82f0d19cd10a169451 --- /dev/null +++ b/code/bugfind/bench/kernels/trustdns_ptr/pre/src/lib.rs @@ -0,0 +1,4 @@ +pub fn pointer_ok(location: usize, pointer_location: usize, name_start: usize) -> bool { + let _ = name_start; + location < pointer_location +} diff --git a/code/bugfind/bench/kernels/versionize_fam/post/Cargo.lock b/code/bugfind/bench/kernels/versionize_fam/post/Cargo.lock new file mode 100644 index 0000000000000000000000000000000000000000..0cea6f32ce8a32f912c1ee61ec92999addc3ba7a --- /dev/null +++ b/code/bugfind/bench/kernels/versionize_fam/post/Cargo.lock @@ -0,0 +1,7 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "k_versionize_fam_post" +version = "0.1.0" diff --git a/code/bugfind/bench/kernels/versionize_fam/post/Cargo.toml b/code/bugfind/bench/kernels/versionize_fam/post/Cargo.toml new file mode 100644 index 0000000000000000000000000000000000000000..d0c84351a77d78acebaf0dcb3b7b55c1cdd51f7b --- /dev/null +++ b/code/bugfind/bench/kernels/versionize_fam/post/Cargo.toml @@ -0,0 +1,9 @@ +[package] +name = "k_versionize_fam_post" +version = "0.1.0" +edition = "2021" + +[lib] +path = "src/lib.rs" + +[workspace] diff --git a/code/bugfind/bench/kernels/versionize_fam/post/src/lib.rs b/code/bugfind/bench/kernels/versionize_fam/post/src/lib.rs new file mode 100644 index 0000000000000000000000000000000000000000..447815b2f66065b9b6e721804a69c3009757bb2e --- /dev/null +++ b/code/bugfind/bench/kernels/versionize_fam/post/src/lib.rs @@ -0,0 +1,10 @@ +pub fn fam_entry(entries: &[u32], header_len: usize, i: usize) -> Result { + if header_len != entries.len() { + return Err(1); + } + if i < header_len { + Ok(entries[i]) + } else { + Err(2) + } +} diff --git a/code/bugfind/bench/kernels/versionize_fam/pre/Cargo.lock b/code/bugfind/bench/kernels/versionize_fam/pre/Cargo.lock new file mode 100644 index 0000000000000000000000000000000000000000..2f3ad3f9a3e4ce953fd4fffa7b56b28602229544 --- /dev/null +++ b/code/bugfind/bench/kernels/versionize_fam/pre/Cargo.lock @@ -0,0 +1,7 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "k_versionize_fam_pre" +version = "0.1.0" diff --git a/code/bugfind/bench/kernels/versionize_fam/pre/Cargo.toml b/code/bugfind/bench/kernels/versionize_fam/pre/Cargo.toml new file mode 100644 index 0000000000000000000000000000000000000000..677379456f65dd06dc83d1aaf4546d98ea7a35dd --- /dev/null +++ b/code/bugfind/bench/kernels/versionize_fam/pre/Cargo.toml @@ -0,0 +1,9 @@ +[package] +name = "k_versionize_fam_pre" +version = "0.1.0" +edition = "2021" + +[lib] +path = "src/lib.rs" + +[workspace] diff --git a/code/bugfind/bench/kernels/versionize_fam/pre/src/lib.rs b/code/bugfind/bench/kernels/versionize_fam/pre/src/lib.rs new file mode 100644 index 0000000000000000000000000000000000000000..9c39870420cd3c50df3996450dcf9feb63c2b615 --- /dev/null +++ b/code/bugfind/bench/kernels/versionize_fam/pre/src/lib.rs @@ -0,0 +1,7 @@ +pub fn fam_entry(entries: &[u32], header_len: usize, i: usize) -> Result { + if i < header_len { + Ok(entries[i]) + } else { + Err(2) + } +} diff --git a/code/bugfind/smoke/tiny/Cargo.toml b/code/bugfind/smoke/tiny/Cargo.toml new file mode 100644 index 0000000000000000000000000000000000000000..7f86997e4dde161f2f772b22f1fdbebd558a071a --- /dev/null +++ b/code/bugfind/smoke/tiny/Cargo.toml @@ -0,0 +1,7 @@ +[package] +name = "tiny" +version = "0.1.0" +edition = "2021" + +[lib] +path = "src/lib.rs" diff --git a/code/bugfind/smoke/tiny/src/lib.rs b/code/bugfind/smoke/tiny/src/lib.rs new file mode 100644 index 0000000000000000000000000000000000000000..0f5e8896b2b58c05558f93f7a86d4c3ff56aaed0 --- /dev/null +++ b/code/bugfind/smoke/tiny/src/lib.rs @@ -0,0 +1,30 @@ +//! Smoke-test crate: pure integer functions, one with a seeded overflow bug. + +/// Correct: |a - b| without overflow. +pub fn abs_diff(a: u32, b: u32) -> u32 { + if a >= b { a - b } else { b - a } +} + +/// Correct: saturating add via a wider type. +pub fn sat_add_u8(a: u8, b: u8) -> u8 { + let s = (a as u16) + (b as u16); + if s > 255 { 255 } else { s as u8 } +} + +/// Greatest common divisor (Euclid). +pub fn gcd(a: u32, b: u32) -> u32 { + let mut x = a; + let mut y = b; + while y != 0 { + let t = x % y; + x = y; + y = t; + } + x +} + +/// BUG (seeded): intended `floor((a + b) / 2)`, but `a + b` overflows u32 for large inputs +/// (panics in debug, wraps in release). Correct form: `a / 2 + b / 2 + (a & b & 1)`. +pub fn avg_buggy(a: u32, b: u32) -> u32 { + (a + b) / 2 +} diff --git a/code/deploy/systemd/50-apps-only.conf b/code/deploy/systemd/50-apps-only.conf new file mode 100644 index 0000000000000000000000000000000000000000..66f22dc505d050257e4590ef46354d5b7079268b --- /dev/null +++ b/code/deploy/systemd/50-apps-only.conf @@ -0,0 +1,17 @@ +TMPDIR=/apps/development/rlhf/hostcache/claude-tmp +TMP=/apps/development/rlhf/hostcache/claude-tmp +TEMP=/apps/development/rlhf/hostcache/claude-tmp +XDG_CACHE_HOME=/apps/development/rlhf/hostcache/cache +HF_HOME=/apps/development/rlhf/hf_cache +HF_HUB_DISABLE_XET=1 +PIP_CACHE_DIR=/apps/development/rlhf/hostcache/pip +UV_CACHE_DIR=/apps/development/rlhf/hostcache/uv +npm_config_cache=/apps/development/rlhf/hostcache/npm +TORCH_HOME=/apps/development/rlhf/hostcache/torch +TRITON_CACHE_DIR=/apps/development/rlhf/hostcache/triton +TORCHINDUCTOR_CACHE_DIR=/apps/development/rlhf/hostcache/inductor +CUDA_CACHE_PATH=/apps/development/rlhf/hostcache/cuda +GOCACHE=/apps/development/rlhf/hostcache/go-build +GOMODCACHE=/apps/development/rlhf/hostcache/gomod +PLAYWRIGHT_BROWSERS_PATH=/apps/development/rlhf/hostcache/cache/ms-playwright +PUPPETEER_CACHE_DIR=/apps/development/rlhf/hostcache/cache/puppeteer diff --git a/code/deploy/systemd/align-hostguard.service b/code/deploy/systemd/align-hostguard.service new file mode 100644 index 0000000000000000000000000000000000000000..cdd7c8617b01539e66236b9feeda9c8b70f7d9bc --- /dev/null +++ b/code/deploy/systemd/align-hostguard.service @@ -0,0 +1,15 @@ +[Unit] +Description=align host watchdog (host CPU <= 92%, memory, D-state, GPU temp, disk) +After=default.target + +[Service] +Type=simple +ExecStart=/apps/development/rlhf/align/.venv/bin/python /apps/development/rlhf/align/scripts/hostguard.py watch +Restart=always +RestartSec=5 +CPUWeight=1000 +MemoryMax=1G +Environment=PYTHONUNBUFFERED=1 + +[Install] +WantedBy=default.target diff --git a/code/deploy/systemd/align.slice b/code/deploy/systemd/align.slice new file mode 100644 index 0000000000000000000000000000000000000000..fdf5eea92967938cca6eff7489a02008b9ee0e79 --- /dev/null +++ b/code/deploy/systemd/align.slice @@ -0,0 +1,11 @@ +[Unit] +Description=align jobs (kernel-capped; see align/utils/hostguard.py) + +[Slice] +CPUAccounting=yes +CPUQuota=4000% +MemoryAccounting=yes +MemoryHigh=119G +MemoryMax=130G +MemorySwapMax=0 +TasksMax=16384 diff --git a/code/docs/evidence/codex_review_2026-10-14.md b/code/docs/evidence/codex_review_2026-10-14.md new file mode 100644 index 0000000000000000000000000000000000000000..493e6495e7066d8f3f077b3814ef845d56cb9dec --- /dev/null +++ b/code/docs/evidence/codex_review_2026-10-14.md @@ -0,0 +1,92 @@ +# Codex independent review of the SOTA blueprint (2026-10-14) + +Tool: codex-cli 0.162.1, read-only sandbox, reasoning effort high, ~2.17M tokens. Session 01a139bb-81e9-7ca1-afb9-dc5d3fd26251. Applied in docs/SOTA_PLAN.md v2. Verbatim final answer below. + +**1. Verdict** + +No, I would not bet on the plan as written. +I would bet on a modest improvement over your reproduced baseline. +The single biggest flaw is the evidence design: an adaptively selected training staircase cannot establish the promised controlled conclusions about individual levers or “data versus RL.” +The evaluation budget and Rust-toolchain assumptions are also insufficiently tested. + +**2. Course corrections to apply NOW** + +1. **Enforce split hygiene in code before the next EI run.** The driver scores the first `--eval-problems` file without enforcing its split; the documented command still supplies test and selects against a test target. Freeze development inputs, reject test files during selection, and remove test-based stopping targets. Otherwise one copied command compromises every subsequent checkpoint comparison. [Driver](/apps/development/rlhf/align/scripts/p2/expert_iteration.py:309) + +2. **Freeze extraction and verification together.** Your extraction path replaces the model’s displayed statement with the reference before verification. Thus “strict verifier” does **not** mean verbatim-statement evaluation. Report accepted-but-rewritten outputs and distinguish proof-completion evaluation from faithful whole-file generation. Rechecking the already reconstructed code cannot reveal this difference. Otherwise comparisons with Pythagoras/GAR remain mismatched. [Existing disclosure](/apps/development/rlhf/align/docs/P2.md:401) + +3. **Complete decontamination before training, including retrieval.** Screen informal sources, statements, trajectory messages, mutation parents and children, and the proof bank. Preserve source problem IDs and ancestry. Statement hashes miss alternate formalizations and nearby benchmark variants; base-model contamination also remains outside your control. Otherwise removing contamination later means regenerating data and retraining. + +4. **Resolve environment coverage now.** Build the exact ProofNet/Putnam inputs and measure supported/elaborating coverage before freezing the verifier. The current parser rejects repository definitions and contextual declarations; it already excludes 225/360 MOBench problems. Aeneas currently pins **Lean 4.31.0**. Keep a legacy benchmark environment and a separately pinned repository environment, with a shared verification policy. Otherwise Phase 3/5 requires verifier redesign. [Aeneas pin](https://raw.githubusercontent.com/AeneasVerif/aeneas/main/backends/lean/lean-toolchain) + +5. **Register an actual statistical endpoint.** At two-sided α=.05, five new solves with zero losses give McNemar **p=.0625**; six give **p=.03125**. Ten wins and five losses give **p≈.302**. Thus ≥207/244 is a score target, not a statistically supported improvement. Choose one primary contrast, report wins/losses and paired intervals, and handle secondary comparisons explicitly. Otherwise the headline and the statistical conclusion contradict each other. + +6. **Replace the staircase with a few informative controls.** From the same checkpoint, compare EI SFT against RL under a declared resource budget; compare mutation data against additional unmutated data; compare feedback against restart sampling at matched resources. Hold averaging policy fixed or isolate it. Repeat the central training contrast if affordable. Otherwise R2–R4 measure conditional stage increments, not independent lever effects. + +7. **Rebudget using the complete evaluation matrix.** At 550 generated tokens/s, 50k variants × two attempts × 5k tokens takes **≈253 generation hours**, before mutation and SFT. At the same rate, Putnam’s 672 problems ×32 trajectories ×4–8 rounds ×5k tokens takes **≈217–434 hours per arm**, assuming few early successes. Pilot these workloads and reserve evaluation capacity first. Otherwise training consumes the hours needed to substantiate it. + +8. **Fix correction accounting before calling it class B.** The existing path caps each response/context; it has no explicit cumulative-output ledger. It also skips all repairs once any sample solves a problem and repairs only failures carrying compiler errors. Define root trajectory IDs, branching, cumulative tokens, truncation and skipped-feedback outcomes. Otherwise “pass@32, two repairs, 40k total” is not what the harness measures. [Correction path](/apps/development/rlhf/align/extras/vllm_rollout/lean_gen.py:269) + +9. **Run a bounded RL feasibility check before committing L5.** SFT throughput does not establish RL throughput or memory. The FP32 head creates roughly a **2.5 GB weight copy** plus **1.25 GB logits per 2,048-token chunk**, before other temporaries. Your measured SFT headroom is only 1.9 GiB. Check generation/training handoff, correction lengths, logprob parity and effective updates. Otherwise “15–40 hours” budgets an unvalidated execution path. + +10. **Pilot Rust translation and bug adjudication immediately.** Start with a few functions, explicit preconditions and known bugs. Require executable Rust counterexamples or establish why a certificate corresponds to reachable behavior. Keep every original flag in the precision denominator, including invalid properties. Otherwise you may build a pipeline that certifies specification mistakes and then reports inflated precision. + +**3. Gaps in the research** + +- **The withdrawn conclusions remain in the synthesis.** “All matched-k controls converge,” “RL only sharpens,” and “RL is necessary only for agentic training” are unsupported. DeepSeek-Prover-V1.5’s SFT→RL gap persists at large budgets. Also, \(p(1-p)\) is not a capability barrier at \(p<1/k\); it motivates sparse-sampling concerns, not an impossibility claim. [Table 1](https://arxiv.org/html/2408.08152v1) + +- **Pythagoras was incompletely audited.** Appendix A.3 explicitly describes benchmark decontamination; “not decontaminated explicitly” is wrong. Its abstract identifies the 93/672 Putnam result as **32B**. Its mutation benchmark uses Codex, whereas training mutations use Qwen3.6-27B, so “same mutator” is inaccurate. Nevertheless, +6.97 pp lacks the post-RL/pre-ALF control needed to attribute the whole difference to mutation alone. [Report](https://arxiv.org/html/2606.12594v1) + +- **L5 changes the mechanism being cited.** Half fixed correction prompts is Goedel-style correction training. Leanabell performs verifier calls inside generated trajectories, preceded by cold-start SFT. These are different interventions; its controlled result does not directly validate your proposed implementation or budget. [Method and Table 2](https://arxiv.org/html/2507.08649v1) + +- **Missing attribution is not positive evidence for the scaffold hypothesis.** Three analyses using overlapping sources are not independent confirmation. Untrained agents demonstrate that training is unnecessary for some results; they do not identify Leanstral’s gain decomposition. + +- **The protocol-spread decomposition is overconfident.** Different papers changing cap, sampling, statements and verifier together cannot identify “five points from cap, two from verifier.” Zero recovered timeouts only addresses the stored attempts rechecked under that procedure. + +- **“Statistical tie” is too strong.** Failure to establish superiority is not equivalence, especially across different benchmark versions and budgets. + +- **Relevant omitted levers:** pass@k-oriented optimization, conjecture/prover self-play, proof-bank reuse, mixed prompting and compiler-guided subgoal repair. Investigate cheaply before adding another objective variant. [Pass@K Policy Optimization](https://arxiv.org/abs/2505.15201), [STP](https://arxiv.org/abs/2502.00212) + +- **GAR’s gain is not independently reproduced evidence for GAR.** Its baseline reproduction is third-party evidence; its own method’s gain is self-reported. + +**4. Gaps in the plan** + +- **Pass@k needs one precise definition.** With n=k=32, solved/244 is a valid unbiased estimate under independent attempts. With n>k, use \(1-\binom{n-c}{k}/\binom nk\). McNemar on first-32 binary outcomes tests a different statistic from the smoother n=64 estimator. For B/C, one sample must be a complete trajectory; repairs are not independent samples. + +- **The quoted 2.4 pp SE is not seed variability.** It is the binomial approximation across problems. Separate benchmark uncertainty, generation variability and training variability. More samples can reduce generation noise; they do not create more independent problems. + +- **G1/G2 are compute heuristics, not evidence gates.** Three extra valid solves cannot establish significance even without losses. Repeated checkpoint/alpha selection further overfits development. Confirm promising gates with fresh generation seeds and some higher-k development measurement. + +- **miniF2F-only development does not support secondary-benchmark ambition.** Add pinned ProofNet development coverage and a separate repository-task development set. Otherwise selection favors competition proofs while Rust/repository competence remains unmeasured. + +- **Protocol classes are honest descriptors, not automatic SOTA categories.** Class A’s 207 target is below the listed Pythagoras bar. Class C beating 23.7% compares an agent to a non-agent. “Cap ≥30k” leaves compute unbounded. Claim matched-harness improvements and resource frontiers; a protocol tuple documents differences but does not eliminate them. + +- **Tokens/solved is not dollars/solved.** Include prefill, verifier CPU, GPU wall time, retries, translation and human validation separately. + +- **The 200k memory argument ignores TP=4.** Approximately 29 GB aggregate KV becomes roughly 7.4 GB/card when sharded. Native context support, extrapolation quality and throughput are stronger reasons to exclude it. + +- **The publication and outcome odds are unsupported judgments.** Null results are publishable when informative and adequately controlled; a tiny stopped run is not automatically a paper. Decouple the bug study from the prover paper. + +**5. Anything in the workbook/data plan that is wrong** + +- **The 226-valid label hides substantial filtering.** The manifest shows **13 unsupported statements, four elaboration failures and one overlap**. It is a filtered development subset. Repair source/header compatibility where possible; prioritize removing training overlaps over deleting validation examples. [Manifest](/apps/development/rlhf/align/.tmp/p2/problems/minif2f_valid.jsonl.manifest.json) + +- **G8 is insufficient.** Compilation under 4.9 proves compatibility and formal correctness, not informal fidelity or preserved meaning after porting. Record retained yield and domain loss; otherwise version filtering silently selects easier material. + +- **The “alpha hash” is a heuristic, not binder-aware alpha-equivalence.** It renames some global identifiers, misses uppercase binders and ignores scope. It needs measured false-positive/false-negative behavior. [Implementation](/apps/development/rlhf/align/scripts/p2/p2_common.py:298) + +- **G2’s labels and negation procedure need correction.** Perturbing a true theorem does not guarantee falsity. Negate the **closed proposition**: `¬ ∀ x, H x → P x`, rather than universally asserting `¬ P x` under retained binders. + +- **Never-solved Pset items are not reliably “hard.”** Goedel reports that over 80% of sampled unsolved Pset statements were incorrectly formalized. Screen types, contradictory hypotheses and source fidelity before allocating mutation/proving hours. [Goedel report](https://arxiv.org/html/2508.03613v1) + +- **G3 assumes an unconfirmed release.** The inventory explicitly says OProofs’ trajectory subset is unconfirmed. Likewise, `RL_dataset_V2` contains prompts/messages, not automatically verified successful repairs. + +- **API 200 is availability, not quality verification.** “High confidence” rows still contain unknown versions, provenance and proof semantics. FormalMATH’s `valid` flag does not replace your verifier. + +- **G1’s PR reward is incomplete.** `lake build` can pass with `sorry`, altered statements or disabled checks. Ground-truth patches often have legitimate alternatives; theorem preservation and dependency auditing must define success. + +- **WBS estimates/statuses conflict.** Final evaluation is simultaneously ~12 and ~80–110 hours; the phase ranges sum to ~400–500. G0 is incomplete while its data-producing stages are running. Treat existing outputs as provisional until rechecked. + +- **A flock and 0.82 memory utilization do not reserve shared GPUs.** They coordinate cooperating processes only. The calendar needs measured availability and explicit CPU as well as GPU quotas. + +tokens used: 2169586 diff --git a/code/docs/evidence/deep_verification_2026-10-14.md b/code/docs/evidence/deep_verification_2026-10-14.md new file mode 100644 index 0000000000000000000000000000000000000000..881bf24c802d1ad0f2cbbf1720ddae78f6925fcc --- /dev/null +++ b/code/docs/evidence/deep_verification_2026-10-14.md @@ -0,0 +1,398 @@ +# Deep verification of the align codebase (2026-10-14) + +Method: 12 line-by-line unit audits (Sonnet; each states the mathematical/behavioural spec, then checks the code term by term and runs fp64/exhaustive/autograd CPU checks), every P1/P2 re-checked by an independent adversarial verifier that tries to refute it with its own reproduction, and every confirmed P1 checked a second time. A parallel Codex pass was blocked by the Codex usage limit and is queued (`.tmp/audit_2026-10-14/`, `sequential.sh`). Raw results: `.tmp/audit_2026-10-14/workflow_result.json` (not in git). + +Frozen files (imported by the live P2 chains) are NOT edited while the chains run; their findings are deferred to the Phase-0a verifier freeze (`.tmp/audit_2026-10-14/frozen_files.txt`). + +## Coverage per unit + +| Unit | Files read | Verified-OK invariants | P1 | P2 | P3 | +|---|---|---|---|---|---| +| U01-dist-control | 21 | 10 | 0 | 4 | 14 | +| U02-optim-kernels | 9 | 10 | 0 | 1 | 10 | +| U03-losses-stages | 25 | 9 | 1 | 5 | 16 | +| U04-rl | 26 | 13 | 1 | 4 | 12 | +| U05-ppo | 16 | 11 | 0 | 2 | 10 | +| U06-model | 15 | 12 | 0 | 4 | 11 | +| U07-rollout-inference | 20 | 8 | 1 | 3 | 15 | +| U08-verify | 15 | 6 | 2 | 9 | 14 | +| U09-data-tokenizer | 22 | 12 | 0 | 8 | 14 | +| U10-eval-config | 14 | 9 | 0 | 10 | 14 | +| U11-scripts | 34 | 8 | 3 | 17 | 14 | +| U12-p2-pipeline | 25 | 6 | 1 | 8 | 12 | + +After adversarial verification: confirmed P1 7, confirmed P2 49, confirmed P3 170, refuted 4, uncertain 9. + +## Confirmed P1 and P2 (status filled in after the fix pass) + +| Id | Sev | File:line | Finding | Status | +|---|---|---|---|---| +| U03-1 | P1 | `align/train/dpo_stage.py:799` | write_arm_result runs on all 4 ranks, non-atomic, same path: arm_result.json is corrupted on disk | see fix pass | +| U08-1 | P1 | `align/verify/lean/strict.py:245` | Lexer desync: `\/-` and `//-` start a Python comment that Lean never sees; banned code hidden | DEFERRED (frozen file; Phase-0a verifier freeze) | +| U08-2 | P1 | `align/verify/lean/strict.py:302` | Lexer desync: numeric token swallows following letters (`1macro`, `2r"\"`) hiding keywords/raw strings | DEFERRED (frozen file; Phase-0a verifier freeze) | +| U11-1 | P1 | `scripts/dpo.py:476` | DPO resume (supervisor retry) always crashes at the throughput probe after resuming | see fix pass | +| U11-2 | P1 | `scripts/gate_flash_attention.py:68` | Gate artifact is written after an all-SKIPPED pytest run (exit 0): triton_fa2 becomes selectable unproven | see fix pass | +| U11-3 | P1 | `scripts/dpo.py:316` | arm_result.json written by EVERY rank, non-atomically, with rank-dependent content | see fix pass | +| U12-1 | P1 | `scripts/p2/expert_iteration.py:690` | --redo only deletes markers; stale checkpoint/gen/eval files are silently reused by the re-run | see fix pass | +| U01-1 | P2 | `align/train/trainer.py:867` | Coordinated SIGTERM stop cannot finish inside torchrun's 30 s / supervisor's 20 s grace | see fix pass | +| U01-2 | P2 | `align/utils/supervisor.py:413` | Operator stop of torchrun/workers is seen as retryable exit 1; supervisor relaunches what the human stopped | see fix pass | +| U01-3 | P2 | `align/train/trainer.py:1154` | R5 checkpoint pin is rank-local: a tripwire on a rank != 0 never pins, rank 0 prunes the checkpoint | see fix pass | +| U01-4 | P2 | `align/train/stage_smoke.py:498` | Stage-11 quality gate evaluates the BASE snapshot, not the trained checkpoint (and drops paired seeds) | see fix pass | +| U02-1 | P2 | `scripts/gate_flash_attention.py:68` | Equivalence gate is written even when every GPU test was skipped (pytest exit 0) | see fix pass | +| U03-2 | P2 | `align/train/sft_loss.py:113` | Loss is a mean of per-micro-batch token-means; n_tokens is computed then ignored (no global token normalisation) | see fix pass | +| U03-3 | P2 | `align/train/qwen3_stage.py:194` | LoRA export rounds W+delta to bf16 once: delta is dead-zoned/quantised and fidelity to the trained adapter is never gated | see fix pass | +| U03-4 | P2 | `align/train/qwen3_stage.py:196` | LoRA resume does not bind the adapter to the base weights; snapshot resolver picks the lexicographically last dir | see fix pass | +| U03-6 | P2 | `align/train/dpo_stage.py:708` | Pre-registered rerun decision is derived from rank-local tripwire alarms; ranks can diverge into different programs | see fix pass | +| U04-1 | P2 | `align/rl/batch_source.py:322` | Surviving rows < world*grad_accum after dead-group drop raises RuntimeError and kills the run | see fix pass | +| U04-2 | P2 | `align/rl/batch_source.py:328` | Row quantisation discards up to ~50% of live rollouts per step and varies effective step size 2x | see fix pass | +| U04-3 | P2 | `align/rl/batch_source.py:310` | Rank-0-only rollout/canary/Lean verification runs inside a collective wait bounded by the 30 min PG timeout | see fix pass | +| U05-1 | P2 | `align/ppo/lora.py:450` | bf16 merged export drops most of a small LoRA delta: rollout engine samples a different policy than the trainer | see fix pass | +| U05-2 | P2 | `align/ppo/trainer.py:690` | Non-finite new_lp skip path never runs model backward: FSDP2 lockstep break if only one rank is non-finite | see fix pass | +| U06-1 | P2 | `align/model/qwen3.py:390` | Any non-None attention_mask forces a [B,1,T,T] bias and the MATH SDPA kernel (no FLASH) | see fix pass | +| U06-2 | P2 | `align/model/weight_average.py:200` | weight_average finalises output (marker + dir rename) with no fsync of shards; re-run trusts marker | see fix pass | +| U07-1 | P2 | `align/inference/engine.py:690` | Default 'native' rollout backend cannot drive Qwen3 (Goedel-Prover-V2-8B): TypeError on first generate | DEFERRED (frozen file; Phase-0a verifier freeze) | +| U07-2 | P2 | `align/inference/engine.py:630` | Engine progress guard misfires: 3 consecutive all-immediately-finished prefill batches raise 'no progress' | DEFERRED (frozen file; Phase-0a verifier freeze) | +| U07-3 | P2 | `align/rollout/protocol.py:208` | Spool resume: heartbeat file from the dead previous worker makes wait_for_result declare the NEW worker dead | see fix pass | +| U07-4 | P2 | `align/inference/engine.py:268` | Dense Engine has no finite-logit check: greedy decoding on NaN logits returns plausible tokens with NaN logprobs | DEFERRED (frozen file; Phase-0a verifier freeze) | +| U08-3 | P2 | `align/verify/lean/repl_pool.py:218` | OS sandbox exists but is not wired into ReplPool; candidates run with the trainer's full uid privileges | DEFERRED (frozen file; Phase-0a verifier freeze) | +| U08-5 | P2 | `align/verify/lean/strict.py:84` | alignCollectAxioms fuel exhaustion silently returns a partial axiom list (fails OPEN) | DEFERRED (frozen file; Phase-0a verifier freeze) | +| U08-8 | P2 | `align/verify/lean/repl_pool.py:250` | REPL children are not tied to the parent's lifetime: SIGKILL/OOM of the trainer orphans live Lean processes | DEFERRED (frozen file; Phase-0a verifier freeze) | +| U09-1 | P2 | `align/data/store.py:172` | ShardWriter.add_row writes bytes BEFORE schema/JSON validation: caught error desyncs ids from extras | see fix pass | +| U09-2 | P2 | `align/data/store.py:274` | ShardWriter.__exit__ finalizes (writes a valid receipt) even when the with-block raised | see fix pass | +| U09-3 | P2 | `align/data/store.py:247` | Shard content_hash excludes idx.npy and is never re-verified on open; idx/trailing-byte corruption undetected | see fix pass | +| U09-4 | P2 | `align/data/store.py:381` | ShardDataset(names=None) globs every receipt in the dir: stale shards from older builds are silently trained on | see fix pass | +| U09-5 | P2 | `align/data/build.py:762` | Held-out split is shuffle-of-set: unstable under any input change and different per stage (SFT vs Arm B leak) | see fix pass | +| U09-6 | P2 | `align/data/prm800k_pairs.py:243` | PairCapper/curate never dedupe by pair_id: identical pairs from duplicate records enter train and the 'extra' set | see fix pass | +| U09-7 | P2 | `align/data/build.py:829` | build_all leaves the previous manifest.json valid while shards are rewritten; manifest/receipts written non-atomically | see fix pass | +| U10-1 | P2 | `align/utils/tracking.py:84` | Tracker append after a torn tail glues the new record onto it; read_history then raises | see fix pass | +| U10-5 | P2 | `align/eval/harness.py:605` | assert_meets_golden passes anything >= the Wilson LOWER bound of the target, contradicting its docstring | see fix pass | +| U10-7 | P2 | `align/eval/verifier.py:373` | A pool that breaks mid-batch silently scores the unverified tail as INCORRECT | see fix pass | +| U11-10 | P2 | `scripts/qwen3_sft_probe.py:414` | --verify-resume-only exits 0 even when the bitwise resume verification FAILED | see fix pass | +| U11-11 | P2 | `scripts/qwen3_parity.py:330` | Parity `run` silently reuses previously saved align tensors: verdict can describe old code or another checkpoint | see fix pass | +| U11-13 | P2 | `scripts/lean_ceiling_test.py:654` | Ceiling test exits 0 with no known-good evidence: a verifier that rejects everything is certified PASS | see fix pass | +| U11-15 | P2 | `scripts/eval_checkpoint.py:120` | --quality-gate on a subset eval writes the SFT quality-gate receipt that unlocks DPO (gate accepts n=5) | see fix pass | +| U11-16 | P2 | `scripts/generate.py:133` | Documented 4-shard idiom takes the heavy-job lock on shard 0 only; shards 1-3 run without lock or exclusion | see fix pass | +| U11-18 | P2 | `scripts/qwen3_export_check.py:186` | vLLM child is pinned with CUDA_VISIBLE_DEVICES='0' = PHYSICAL GPU 0, not the parent's first visible device | see fix pass | +| U11-4 | P2 | `scripts/dpo.py:427` | Fresh-arm checkpoint rotation (os.rename) is executed by all ranks: all but one raise FileNotFoundError | see fix pass | +| U11-5 | P2 | `scripts/dpo.py:54` | dpo.py override syntax: documented form exits 2, bare form is accepted and silently IGNORED | see fix pass | +| U11-6 | P2 | `scripts/pretrain.py:47` | pretrain.py, supervise.py, build_stageb_data.py fail with ModuleNotFoundError when launched as documented | see fix pass | +| U11-7 | P2 | `scripts/grpo.py:145` | vLLM rollout worker (own session, serves forever) leaks if anything between spawn and the try/finally raises or rank 0 is killed | see fix pass | +| U11-8 | P2 | `scripts/qwen3_export_check.py:365` | Export check exit status ignores the mapping round-trip and the 'adapter moved the weights' checks it advertises | see fix pass | +| U11-9 | P2 | `scripts/qwen3_export_check.py:68` | Merged-bf16 export is never compared with the adapter-applied model; at the measured adapter size ~90% of delta elements round to zero | see fix pass | +| U12-2 | P2 | `extras/vllm_rollout/lean_gen.py:739` | lean_gen exits 0 when interrupted by SIGTERM; EI/pipeline never check generation completeness | DEFERRED (frozen file; Phase-0a verifier freeze) | +| U12-3 | P2 | `scripts/p2/pipeline_gen_verify.py:146` | pipeline_gen_verify has none of launch_baseline's orphan-reaping / GPU-drain / /dev/shm sweep | DEFERRED (frozen file; Phase-0a verifier freeze) | +| U12-5 | P2 | `scripts/p2/build_valid.py:112` | Dev split (miniF2F-valid) is not decontaminated against the training pool by informal text; 8 pool problems overlap | see fix pass | +| U12-7 | P2 | `scripts/p2/expert_iteration.py:476` | average stage runs weight_average with no heavy lock, no host preflight, no thread cap | see fix pass | + +## Verified-OK invariants (what was checked and found correct) + +**U01-dist-control** +- WarmupCosineSchedule: bounded, unimodal, peak == base_lr, clamped past the horizon, applied to the optimizer +- ResumableBatchStream + Trainer resume are lossless and bit-exact (weights, fp32 master, scheduler, stream, RNG) +- Gradient accumulation scaling: (loss/accum).backward() equals the large-batch gradient for equal-size micro-batches +- Rank-consistent skip-cap HALT and coordinated stop (all decisions come from one MAX all-reduce) +- checkpoint_problem is total and detects truncation / structural damage; atomic publish order is correct +- Sharded format: chunk_range == torch.chunk, manifest tiling check, optimizer reshard re-cut +- Heavy-job flock: cross-process exclusion, rank-0-only, inode discipline +- HALT marker contract: written atomically with the verdict, filtered by attempt start time +- GradNormTripwire: NaN is blind, inf/spike halts, latched +- DistributedBatchSampler gives every rank the same step count each epoch + +**U02-optim-kernels** +- adamw_update_ equals torch.optim.AdamW exactly (bias correction, decoupled decay, eps placement) +- Optimizer clip rule, fp32 fallback, pre-clip last_grad_norm and per-param step counting are correct +- Dynamic codebook construction and nearest_code are correct (256 strictly increasing codes, ties to lower, edge clamp) +- Block-wise quantized step: chunk invariance, pad tail, 8-bit vs fp32 trajectory, SR unbiasedness +- state_dict/load_state_dict round trip is bit-exact, snapshot is non-aliased, skip leaves state untouched, v-overflow guard works, world-size and shape mismatches are refused +- Flash-attention forward and backward verified against fp64 naive attention (causal, non-causal, GQA, key padding, tails, all block configs) +- Fused linear cross-entropy value and gradients match the eager reference for all chunk sizes, ignore_index, frozen head and all-masked batches +- Fused DPO (sigmoid, ipo, sigmoid_norm, dpop; with NLL aux; pair and token chunking) matches closed forms, eager autograd and finite differences +- Equivalence gate fails closed on missing, malformed, stale-hash and drifted-environment artifacts +- _toolchain.ensure_triton_can_compile is a no-op on this box and fails loudly if headers disappear + +**U03-losses-stages** +- SFT next-token shift, label mask and in-batch token-mean verified against fp64 reference +- fused_linear_cross_entropy equals eager CE for frozen (LoRA) and trainable lm_head, including ignore_index and partial chunks +- DPO objective, chosen/rejected pairing, completion-mask boundary, shift, ref join by pair_id and coupled vs decoupled gradients verified end to end +- Ref-cache key refuses each of its 7 components; join order and roundtrip correct; truncated file detected; writes are tmp+replace +- path_bias_decision and wilson interval behave per spec (NaN/inf/empty force firing; threshold arithmetic) +- Tripwire response mapping, horizon arithmetic and launch-topology refusal are correct +- MoE window buffer, packed all-reduce layout, scale world*m/n_buf and last-micro exactness verified for equal-size micro-batches +- LoRA stage collectives are rank-symmetric and the checkpoint contract is internally consistent +- Rank-sharded preference sampler equalises steps per rank and the empty-completion edge yields finite loss and gradients + +**U04-rl** +- Group advantage: centered/standard, G=1, all-equal, ragged +- DAPO PPO-clip surrogate gradient and CISPO stop-gradient (incl. pi<1/e sign case) +- Full module loss/gradient vs independent fp64 reference (reduction constant, grad_accum, dp scaling, rho, shifts) +- compute_rho_correction: clamp, masks, sequence-level and TV paths +- KL estimators k1/k2/k3/k4 as values; k3-as-loss gradient direction +- FP32 LM-head logprobs: chunking, checkpointed backward, temperature, entropy, matmul-precision restore +- assemble_rollout: label alignment of engine logprobs, group-centered advantages, whole-group drop +- Soft overlong punishment boundaries and construction guards +- expand_advantages: [B] bit-identical expansion; [B,T] input-space shift; stray/NaN/shape refusals +- Multi-turn credit (episode, per_turn_loo), dead-group rule, row/logprob alignment +- CollapseTripwire state_dict/load_state_dict resume determinism and validation +- optim_step pre-clip gradient norm, denominator certification, DP/accum scaling, gate NaN/inf handling +- Prompt-stream resume state: fingerprint, permutation check, cursor bounds, pending-restore ordering + +**U05-ppo** +- GAE recursion, truncation bootstrap, lam_actor!=lam_critic returns, label-frame masking +- masked_whiten: Bessel variance over valid tokens only; global across ranks +- ppo_loss: clipped surrogate, clipped value loss, k3 in-loss KL, explicit minibatch denominator +- build_token_rewards / transform_step_scores / locate_step_positions: reward position, KL sign, label-frame alignment +- LoRA: scaling, B=0 identity, adapters-off == base bit-exact, merge/unmerge, merged+disabled refusal, export_merged +- policy_forward shift, value alignment, bootstrap index, reference pass, warmup isolation, micro-split gradient +- Optimizer-window control plane: all-skipped window, critic grad sync, boundary agreement, resume payload +- RatioTripwire and hacking_alarms key contract +- Stage config: struct mode, launch refusals, derived save cadence +- assemble_ppo_rollout / take_rows: verbatim ids, label alignment, ragged refusals +- ValueHead init/precision and PRM scorer frame/persistence + +**U06-model** +- Qwen3 forward == HF Qwen3ForCausalLM (per-head QK-norm, GQA 8:2, RoPE, SwiGLU, RMSNorm eps), with padding masks +- Mistral forward and KV-cache semantics: RoPE offset, GQA map, causal bias for chunked prefill, left padding +- OLMoE forward, router logits, full-width QK-norm and load-balancing loss (hf convention) equal HF; both norm_topk_prob settings; both MoE backends +- MoE dispatch/combine: forward and gradients (x, router, gate_up, down) vs fp64 dense reference; loop == grouped_mm; deterministic fixed-order combine; dropless counts +- Load-balancing loss conventions and additivity under global totals +- Expert-parallel all-to-all: send/recv counts, receive permutation and inverse == single-device result, forward AND backward, including skewed routing and zero-row/zero-token ranks +- finalize_expert_grads / global_grad_norm / clip_grads_ scale and collective placement; non-finite norms are caught downstream +- Tensor parallelism: column/row splits, all-reduce placement, vocab-parallel embedding/LM head with padding, vocab-parallel logprob/argmax/logsumexp == single device +- HF key mapping is total and shape-checked on the real Goedel-Prover-V2-8B and OLMoE-1B-7B-0125 snapshots; round trips; on-disk interop with transformers +- RoPE: inv_freq stays fp32 under .to(bf16), rotate-half convention, fp32 rotation +- Config readers (Mistral, OLMoE, Qwen3) are total/loud; count_params and gate thresholds are derived +- Weight averaging (1-a)*theta0 + a*theta: fp64 accumulation, chunk independence, endpoints, and layout checks + +**U07-rollout-inference** +- VERIFIED: dense Engine logprob == teacher-forced log_softmax(logits/T)[token] (rho contract) +- VERIFIED: dense Engine batched == unbatched (greedy), == bucketed reference, incl. EOS and max_seq_len cap; slot compaction, position ids, ragged masks +- VERIFIED: top-p = minimal nucleus with renormalisation; top-1 always kept; no NaN row +- VERIFIED: PagedEngine scheduler (chunked prefill, preemption-by-recompute, prefix sharing) is batch-invariant and leak-free; allocator books balance after every step +- VERIFIED: BlockAllocator/PagedKVCache invariants (double free, range, LIFO, copy-free append, gather layout) +- VERIFIED: EOS / stop / max-token finish ordering, block-budget guard and position bookkeeping in PagedEngine +- VERIFIED (by argument + existing tests): TP engine replicated scheduler cannot diverge silently; failure is symmetric +- VERIFIED: spool protocol mechanics (atomic JSON, duplicate-id refusal, version gates, three failure signatures) and native result contract + +**U08-verify** +- VERIFIED: static_check header/signature/modifier/duplicate/banned-word rules behave as specified on 24 probes +- VERIFIED (by argument): statement bridge and axiom walk are sound given U08-4/5 are fixed +- VERIFIED: audit result is bound to a per-call secret nonce and cannot be pre-forged by the candidate text +- VERIFIED: REPL pool fails closed and recovers on timeout, crash, oversize, garbage, double response, memory blow-up, grandchildren +- VERIFIED: exact rational equivalence, tuple/interval/set/union/relation semantics and precedence behave per spec; engine never raises on garbage +- READ-THROUGH OK (not executed): sandbox mount/pivot/supervisor logic is consistent; only wiring is missing + +**U09-data-tokenizer** +- Qwen byte-level encode == HF tokenizers 0.23.3 (ids) +- Frozen \p{L}/\p{N}/\s class tables reproduce the Rust pre-tokenizer for every code point +- NFC normalizer with frozen barriers equals the Rust NFC normalizer +- Decode parity incl. split UTF-8, out-of-range ids, skip_special_tokens (both tokenizers) +- Mistral Metaspace('first') + added-token carving + BPE tie-break + BOS parity with HF +- render_qwen3_chat equals the shipped Jinja chat_template (system/user/assistant subset) +- encode_solution: token-prefix prompt_len, boundary certification, EOS/BOS, completion label mask +- LengthGroupedSampler / DistributedBatchSampler partition, budget, determinism, equal per-rank step counts +- iter_lines Range-resume never duplicates or splits a record; auth stripped on cross-host redirect +- Mandatory metadata, boundary-receipt validation and row-count cross-check (happy path) +- pack_tokens: eos join, cap on EMITTED tokens, dropped tail, empty-doc skip +- PRM800K walk/eligibility, answer-marker splitting and Arm B site discovery follow their stated specs + +**U10-eval-config** +- pass_at_k equals the unbiased estimator 1 - C(n-c,k)/C(n,k), exactly +- mcnemar_p_value, mcnemar_exact, min_detectable_split, mcnemar_power verified +- Wilson interval and cluster/paired bootstrap implementations verified +- auroc midrank is exact; mcnemar/mcnemar_exact tails are correct +- Struct mode rejects unknown keys at every depth, including via overrides +- Type coercion rules for int/float/bool/tuple/Optional behave as documented (CLI path) +- Tracker mandatory-key / None enforcement and atomic config.json verified +- score_results denominators, format_compliance, majority-vote credit, Wilson CI usage verified +- Extraction round-trip and pair-scorer ceiling logic verified + +**U11-scripts** +- VERIFIED: resource guards run before torch in sft/pretrain/dpo/grpo/ppo/precompute/generate/eval_checkpoint +- VERIFIED: per-rank data sharding in sft/pretrain and the OLMoE smoke sampler is disjoint and complete +- VERIFIED: merge_lora_state == adapter applied (fp64) and == trainer export_merged_state_dict bit-for-bit; refuses malformed adapters +- VERIFIED: exact McNemar and the pairing/refusal logic +- VERIFIED: pre-registration hash and case-table integrity +- VERIFIED: supervise.py child-command parsing; olmoe/qwen3 resume analysers; ckpt_convert staging +- VERIFIED: lean_setup.sh preflight/idempotence/pin guards (modulo U11-30) +- SCOPE NOTE: scripts/p2/* not reviewed (excluded by the unit focus); grpo/ppo stop-path and stage_result writes verified by reading + +**U12-p2-pipeline** +- pass@k, solved@k, McNemar exact, direction and baseline numbers verified +- SFT keep rules (cap, distinct, corrections-first, easy exclusion, fidelity, EOS) verified +- Verification resume keyed by (problem, sample, round, cap) with code_sha256, torn-tail repair and dead-pool abort verified +- Snapshot of complete lines, lock inheritance and signal sequencing verified by reading +- Goedel prompt/extraction port is AST-identical to the reference; seeds and resume provenance checks verified +- Band selection and pass-rate table verified against spec + +## Confirmed P3 (backlog) + +- U01-10 `align/train/trainer.py:1796` No run-identity check on resume: grad_accum, config world size, seed and horizon changes pass silently +- U01-11 `align/train/trainer.py:1733` keep_checkpoints <= 0 deletes every checkpoint including the one just written; value unvalidated +- U01-12 `align/train/trainer.py:739` max_wall_seconds budget restarts on every (re)launch +- U01-13 `align/train/trainer.py:249` Gathered/per-rank checkpoints have no content checksum: same-size corruption passes checkpoint_problem +- U01-14 `align/train/train_module.py:96` Loss normalisation is a mean of per-micro-batch means, not a global token mean +- U01-15 `align/train/train_module.py:187` load_model_state_dict uses full.chunk(ws)[rk], which IndexErrors on uneven small dims; gather-layout resume loads every rank's optimizer state +- U01-16 `align/train/stage_gates.py:549` assert_distinct_devices builds its gloo group without the armed timeout +- U01-17 `align/train/trainer.py:1155` tripwire_alarms grows by one entry per log step after a latched non-HALT tripwire fires +- U01-18 `tests/:0` Untested control-plane invariants found in this unit +- U01-5 `align/train/stage_gates.py:638` assert_horizon_agrees raises NameError (undefined scheduler_horizon_optimizer_steps) +- U01-6 `align/train/trainer.py:621` Tail micro-batches of a partial accumulation window are trained then silently discarded; horizon counts a step that never happens +- U01-7 `align/train/trainer.py:1020` Arm-time self-test / trunk-probe failures raise plain RuntimeError: exit 1, no HALT.json (documented as HALT 80) +- U01-8 `align/utils/guard.py:379` Hard rule 4 (D-state) not enforced by preflight_host_check; diskstats device hard-coded and fails open +- U01-9 `align/utils/guard.py:454` acquire_heavy_job_lock re-entry briefly releases the lock and can drop it entirely +- U02-10 `tests/test_flash_attention.py:375` Flash attention has only GPU tests, with no coverage of left-padding/fully-masked rows, non-contiguous inputs, or a CPU-runnable path +- U02-2 `align/kernels/ops.py:225` rope_triton computes the wrong position index for [B,H,S,D] input when n_heads>1 and seq>1 +- U02-3 `align/train/offload_adamw.py:218` OffloadAdamW with max_grad_norm=None permanently freezes elements whose g^2 overflows fp32 +- U02-4 `align/train/offload_adamw.py:629` Pinned-staging cap compares a per-rank requirement to a world-summed cap and counts only the master +- U02-5 `align/train/offload_adamw.py:320` _grad_norm materialises float64 copies of each gradient (about 2.5 GB transient for a 151936-vocab head shard) +- U02-6 `align/kernels/fused_ce.py:196` fused CE/DPO run the full backward during forward even under no_grad or eval +- U02-7 `align/kernels/fused_dpo.py:211` ref_logp dtype is not validated; a bf16 reference cache would silently quantise the margin +- U02-8 `align/train/adamw8bit.py:619` load_state_dict overwrites eps/betas/weight_decay/max_grad_norm from the checkpoint, silently ignoring config changes on resume +- U02-9 `align/train/quant8.py:18` Docstring claims 'few percent' relative resolution down to 1e-6*absmax; it only holds for the top 1-2 decades +- U03-10 `align/train/dpo_stage.py:616` Banner scheduler_horizon is arithmetic over expected_pairs (includes held-out), not the Trainer's actual horizon +- U03-11 `align/train/dpo_stage.py:644` render_dpo_banner claims to enforce section-01's global mandatory keys but only checks DPO_BANNER_KEYS +- U03-12 `align/train/ref_cache.py:788` batch_invariance_report ignores a NaN-ratio (fired) result when an earlier batch size was finite +- U03-13 `align/train/ref_cache.py:322` Ref cache: no fsync, no finiteness or length checks, silent defaults for max_len and pad id +- U03-14 `align/train/ref_cache.py:163` Ref-cache key omits the numeric path (attention backend, compute dtype, logits chunking) that defines the constants +- U03-15 `align/train/qwen3_stage.py:288` Under FSDP2 MixedPrecisionPolicy(param_dtype=bf16) the LoRA delta is computed in bf16, contradicting the fp32-adapter/fp32-delta docstrings +- U03-16 `align/train/dpo_stage.py:318` SFT quality-gate pass is not bound to the checkpoint actually used; explicit config override bypasses the receipt +- U03-17 `align/train/dpo_stage.py:1191` Pairs with an empty completion on one or both sides are trained on (dilute the pair mean) and the length-drift baseline uses a different skip rule +- U03-18 `align/train/dpo_stage.py:1171` Length-drift tripwire reads `pairs` (micro-batch pairs) while the fraction is over a 512-pair window; correct only at log_every_micro_steps=1; latched alarms appended every step +- U03-19 `align/train/ref_cache.py:267` checkpoint_content_hash is computed by every rank at gate 3 (4 x 14.5 GB streamed reads on a shared NVMe) +- U03-20 `align/train/moe_module.py:593` DPO/MoE telemetry: router stats include padding (DpoBatch has no attention_mask); SFT through scripts/sft.py passes tuples so mask is also None +- U03-21 `align/train/dpo_stage.py:799` write_arm_result is also non-atomic and never fsyncs (separate from the multi-rank race) +- U03-22 `tests/test_dpo_stage.py:1` Untested invariants in the SFT/DPO/ref-cache/LoRA stage code +- U03-5 `align/train/dpo_stage.py:293` Ref-cache key and banner bind to manifest.json, not to the shards actually loaded; ambiguous split resolution; no coverage gate +- U03-7 `align/train/sft_loss.py:83` All-masked micro-batch raises on one rank only and deterministically recurs on resume +- U03-8 `align/train/moe_module.py:289` MoE global-scope aux scale is exact only for equal-token micro-batches (1.4x / 2.33x measured) +- U03-9 `align/train/dpo_stage.py:235` heldout_every_optimizer_steps is declared and ignored: held-out preference accuracy is only measured after training +- U04-10 `align/rl/batch_source.py:379` Canary HALT breach counter and canary history are not part of state_dict; resume resets them +- U04-11 `align/rl/batch_source.py:395` Collapse tripwire is rank-local, fed from the last micro-slice only, and never reduced across ranks +- U04-12 `align/rl/grpo_loss.py:337` assert_a3_conformant does not pin rho knobs, kl_estimator, fp32_head or temperature +- U04-13 `align/rl/grpo_module.py:271` Default (fp32_head=False) path computes logprobs, rho input and entropy from bf16 logits +- U04-15 `align/rl/assembly.py:151` Edge cases: empty prompt, prompt_rows validated only for first 3 rows, canary verifier count unchecked +- U04-16 `align/rl/multiturn_source.py:451` Multi-turn: remainder row drop biases toward last episode's later turns; every micro padded to step-global longest turn +- U04-17 `tests/:0` Invariants with no test coverage in tests/ +- U04-4 `align/rl/tripwire.py:75` CollapseTripwire.observe raises ValueError rank-locally inside the loss adapter, before backward +- U04-5 `align/rl/batch_source.py:297` PPO-clip / CISPO ratio is identically 1 by construction; staleness up to 11 steps is handled only by rho TIS (clamp 2, no mask) +- U04-6 `align/rl/batch_source.py:106` Dead-group classification keyed on reward/total with hard-coded max 1.1; all-wrong groups are kept for format/length noise +- U04-7 `align/rl/advantages.py:52` filter_zero_std_group uses exact np.std==0; fails for identical rewards when G is not a power of two +- U04-8 `align/rl/envs/lean_multiturn.py:294` Duplicate credit implementations: env.assign_advantages (self-inclusive baseline) and inert LeanMultiTurnConfig.advantage_mode/credit_gamma +- U04-9 `align/rl/stage_config.py:119` stage_config.validate pins the single-turn denominator; multi-turn source needs max_turns x larger D and nothing enforces it +- U05-10 `align/ppo/trainer.py:919` Rank-0-only exception in export_merged_state_dict strands peers in the next collective +- U05-12 `tests/test_ppo.py:1` Invariants with no test coverage in the PPO unit +- U05-3 `align/ppo/lora.py:419` unload_lora(merge_adapter_name=X) silently keeps a different already-merged adapter baked into the weights +- U05-4 `align/ppo/gae.py:136` Masked reductions use x*mask, so one NaN/Inf in a padded slot poisons the whole batch +- U05-5 `align/ppo/trainer.py:504` Truncated rows get BOTH the terminal outcome reward at the last token AND a V(s_T) bootstrap; V(s_T) is never trained +- U05-6 `align/ppo/trainer.py:571` Loss denominator is the per-rank minibatch token count, not the global one +- U05-7 `align/ppo/rewards.py:445` k3 used as the reward-side KL penalty (per-token reward) has a biased policy-gradient; docstring says k1 for in-reward +- U05-8 `align/ppo/trainer.py:1525` hacking_alarms and engine_logprob_gap only log/pin; nothing halts, and rank-local stats make alarms rank-dependent +- U05-9 `align/ppo/trainer.py:263` NOGRAD_CHUNK_ROWS=16 is vocab-blind: no-grad logprob pass allocates ~3 [16,T,V] fp32 tensors +- U06-10 `align/model/mistral.py:244` Per-layer host sync in GQA map resolution (bool() on a device tensor) every forward +- U06-11 `align/model/checkpoint.py:260` load_hf_safetensors: duplicate keys across shards silently overwrite; stale model.safetensors shadows an index; resolve_snapshot_dir picks name-sorted last hash +- U06-12 `align/model/qwen3_checkpoint.py:265` save_hf_qwen3 failure cleanup deletes same-named shards of a pre-existing good checkpoint; overwrite creates a mixed-version window +- U06-14 `align/model/tp.py:621` build_tp_model vocab tail padding uses a CPU zeros tensor: fails (loud) for a GPU StateDictSource when vocab % tp != 0 +- U06-15 `tests/:1` Invariants of this unit with no covering test +- U06-3 `align/model/moe_ep.py:309` No guard against EP expert shards being wrapped by fully_shard (apply_fsdp per-block) +- U06-4 `align/model/qwen3_checkpoint.py:300` load_hf_qwen3(model, dir) on a meta-device model is a silent no-op (docstring promises it works) +- U06-5 `align/model/weight_average.py:113` fp64 -> bf16/fp16 cast is double-rounded (fp64->fp32->bf16), contradicting 'rounded ONCE, bit-identical' +- U06-6 `align/model/weight_average.py:113` Endpoints are not exact copies when the other checkpoint holds inf/NaN (0*inf=NaN) +- U06-7 `align/model/checkpoint.py:207` Standalone HF weight writers rename without fsync; olmoe/qwen3 config writers are non-atomic +- U06-8 `align/model/moe.py:652` All-padding layer (n_tokens=0) turns load-balance, z-loss and entropy into NaN +- U06-9 `align/model/mistral.py:455` fp64 (debug) models: finfo(fp64).min mask becomes -inf after fp32 softmax cast -> NaN on masked rows +- U07-10 `align/rollout/paged.py:247` fp8 KV cache is an unscaled cast: |x|>448 becomes NaN; fp8/bf16 KV shift logprobs vs the training forward +- U07-12 `align/rollout/native.py:293` Weight sync keyed on path string equality; first sync re-reads the full checkpoint into host RAM +- U07-13 `align/rollout/native.py:311` Completions truncated by max_seq_len_cap look like normal max_tokens completions to the trainer +- U07-14 `align/inference/engine.py:690` Dense prefill materialises [B, Tmax, V] logits for all positions; VRAM preflight ignores it +- U07-15 `align/rollout/engine.py:389` PagedEngine long-lived-state and hot-path cost issues (opt-in engine) +- U07-16 `align/rollout/paged.py:195` add_seq with an invalid shared block leaks refs taken for the earlier blocks +- U07-17 `align/inference/engine.py:471` prepare_requests does not validate max_new_tokens >= 1; request_id is used unvalidated as a spool filename +- U07-5 `align/rollout/native.py:186` Single EOS id only; Goedel generation_config lists two EOS ids and top_k=20 +- U07-6 `align/rollout/client.py:175` terminate_worker leaves vLLM EngineCore orphaned if the leader was already reaped +- U07-7 `align/inference/receipts.py:93` checkpoint_fingerprint loads the whole checkpoint into host RAM and hash_weights fails on sharded checkpoints +- U07-8 `align/inference/receipts.py:60` Gate status and completions JSONL are written non-atomically; gate record is not bound to the weights +- U07-9 `align/rollout/native.py:226` 'Pure function of (weights, prompts, seed)' reproducibility claim is false across engine histories +- U08-10 `align/verify/equiv.py:249` Texty-answer fallback compares only the casefolded alphanumeric key: structurally different math with 3+ letter runs is equal +- U08-11 `align/verify/normalize.py:49` Unit words are stripped without checking they agree: `12 hours` == `12 minutes`, `2 meters` == `2 km`; `x=5` == `y=5` +- U08-12 `align/verify/lean/strict.py:385` `_root_.Lean.*` / `_root_.IO.*` bypass the meta-API identifier ban +- U08-13 `align/verify/normalize.py:64` Quadratic/ReDoS paths on long adversarial text: normalize before length cap, extract_boxed, stripping +- U08-14 `align/verify/extract.py:166` verify_text scores a verbatim-correct symbolic bare answer as wrong (gold 'n' vs pred 'n') +- U08-15 `align/verify/expr.py:889` Variable `i` is always the imaginary unit in the polynomial rung (i^2 == -1) +- U08-16 `align/verify/lean/strict.py:637` timeout/crash/memory collapse to accuracy 0 with no 'undecided' state and no retry; wall-clock timeouts depend on host load +- U08-17 `align/verify/lean/strict.py:758` verify(): proof budget starts before pool.acquire(), queue wait is charged to the proof; audit gets only max(1s) of leftover +- U08-18 `align/verify/lean/strict.py:821` A single un-elaboratable reference header raises LeanInfraError and aborts the entire verify_batch +- U08-19 `align/verify/lean/repl_pool.py:792` Host preflight is opt-in and silently skipped on ImportError +- U08-20 `align/verify/lean/toolchain.py:136` Toolchain pin (Lean 4.9.0-rc1 / Mathlib rev) is never enforced at runtime +- U08-21 `align/verify/lean/repl_pool.py:347` request() only kills the child on ReplError; any other exception after the write leaves a live REPL with a pending answer +- U08-22 `align/verify/lean/reward.py:53` Unclosed `` is not stripped: a fenced proof inside a truncated reasoning block is scored +- U08-23 `tests/test_lean_verifier.py:288` No test pins lexer-vs-Lean tokenisation beyond hand-picked cases (no differential fuzz, no `\/-`, `//-`, digit-glue, `r` after numeral) +- U08-24 `tests/test_lean_verifier.py:1` Untested verifier invariants: axiom-walk truncation, stale env after worker death, surrogate input, infra-vs-undecided semantics +- U08-25 `tests/test_verify_ceiling.py:274` Math verifier tests have a ceiling (gold-vs-gold) and a random-wrong control but no adversarial false-accept suite +- U08-4 `align/verify/lean/strict.py:356` `_align_ref` (a sorry-backed theorem) is visible to the candidate and not lexically banned +- U08-6 `align/verify/lean/repl_pool.py:506` Worker death between requests re-spawns the REPL and replays STALE env ids -> silent compile_error false negative +- U08-7 `align/verify/lean/repl_pool.py:371` A lone surrogate in a completion raises UnicodeEncodeError and aborts the whole verify_batch +- U08-9 `align/verify/expr.py:830` Decimal-literal tolerance is RELATIVE 1e-6: an integer gold accepts any decimal pred within 1e-6*|gold| +- U09-10 `align/tokenizer/bytelevel.py:408` User/model text containing special-token literals is tokenized as control tokens (no escaping) in chat rendering and packing +- U09-12 `align/data/stageb_sources.py:231` NaN/inf difficulty is banded into the hardest band instead of skipped +- U09-13 `align/data/tokenization.py:114` BoundarySpec.tokenizer_hash covers the vocab only, not merges/pipeline/added-token flags +- U09-14 `align/tokenizer/bytelevel.py:416` Certified unicodedata version only pinned in a test, not enforced at runtime +- U09-15 `align/tokenizer/bytelevel.py:466` Encode cache bounds entry COUNT (200k) but not piece size +- U09-16 `align/tokenizer/core.py:605` _resolve_dir picks the newest snapshot by mtime when given an HF cache model dir +- U09-17 `align/data/store.py:127` add_row coerces silently (floats truncate, negatives and out-of-vocab ids accepted) and no explicit UTF-8 encoding anywhere in align/data +- U09-18 `align/data/evalsets.py:128` Decontamination is exact normalized-text match, trusts stored problem_id and checks only row count of eval json +- U09-21 `tests:0` Invariants of this unit with no covering test +- U09-8 `align/data/loader.py:215` collate_sft fails open on prompt_len: missing -> trains on the prompt; >=len -> all-masked row; negative wraps +- U09-9 `align/data/loader.py:112` LengthGroupedSampler silently emits over-budget singleton batches; no seq_len filter exists in the training sampler +- U10-10 `align/config.py:656` No range or finiteness validation: NaN lr, zero batch sizes, negative counts all load cleanly +- U10-11 `align/config.py:532` TOML strings and override strings are indistinguishable, so docstring strictness claims are false +- U10-12 `align/config.py:871` dumps_toml emits invalid TOML for control characters in strings +- U10-13 `align/eval/metrics.py:95` min_detectable_effect silently ignores its power and alpha arguments +- U10-14 `align/eval/metrics.py:21` metrics.wilson_interval does not validate k in [0, n] +- U10-15 `align/eval/metrics.py:145` ECE and AUROC silently drop or mis-handle NaN/out-of-range inputs +- U10-16 `align/eval/harness.py:234` Reported p_value is rounded to 5 decimals (tiny p reads as exactly 0.0); delta sign opposes proof_stats +- U10-17 `align/eval/verifier.py:362` verify_batch swallows KeyboardInterrupt/SystemExit as an INCORRECT outcome +- U10-18 `align/eval/harness.py:503` run_eval accepts degenerate batch_size/empty problem lists and returns a clean-looking empty report +- U10-19 `align/eval/golden.py:194` golden_generate_fn ignores cfg.temperature/top_p/seeds and relies on engine result order +- U10-2 `align/eval/harness.py:173` compare_paired uses chi-square McNemar at >=25 discordant pairs, which flips significance decisions vs the pre-registered exact test +- U10-21 `align/utils/tracking.py:114` Tracker.log lets metrics override reserved step/stage keys; resumed runs append duplicate step rows with no attempt marker +- U10-22 `align/registry.py:42` Banner completeness checks only absence/None, and the mandatory-key list is a mutable global +- U10-23 `align/exceptions.py:1` Gate failures raise bare RuntimeError, not AlignGateError; docstring claims nothing raises these yet +- U10-3 `align/eval/harness.py:160` compare_paired silently pairs on the intersection of problem ids +- U10-4 `align/eval/harness.py:500` run_eval ignores cfg.n_samples; the report claims n_samples=k but contains one sample per problem +- U10-6 `align/eval/harness.py:416` Eval-set receipt check fails open when the sidecar is absent, and the hash omits problem text +- U10-8 `align/config.py:879` dumps_toml/load round-trip silently re-enables a None-valued field whose default is not None (max_grad_norm) +- U10-9 `align/config.py:607` Bare `list` fields keep override elements as strings (--trace.tier1_layers=0,8,16 -> ['0','8','16']) +- U11-12 `scripts/qwen3_parity.py:90` Parity gates (qwen3 and olmoe) swallow NaN: max-abs becomes 0.0 and the fp32 gate can PASS with NaN logits +- U11-14 `scripts/ceiling_test.py:97` Reward-path ceiling receipt can be overall_pass=true with zero sources / tiny n and is not bound to the code it certified +- U11-17 `scripts/precompute_ref_logprobs.py:321` Reference logprobs are computed with attn=sdpa while both DPO arms train with triton_fa2; backend is not in the cache key and never measured +- U11-20 `scripts/precompute_ref_logprobs.py:169` CUDA-unavailable fallback silently computes an fp32 CPU reference but stamps it forward_impl='single_gpu' +- U11-21 `scripts/grpo_halt_at_108.sh:21` Pattern-based pkill on a shared box (incl. any user's VLLM::EngineCore) in a stale hard-coded one-shot script +- U11-22 `scripts/dpo.py:346` DpoStageConfig.heldout_every_optimizer_steps is set in both arm TOMLs but consumed nowhere +- U11-23 `scripts/sft.py:136` Stage scripts always resume from the newest checkpoint in run_dir with no run-identity check or --fresh option +- U11-24 `scripts/ckpt_convert.py:56` `inspect` exits 0 on a corrupt/incomplete sharded checkpoint +- U11-25 `scripts/primary_contrast.py:102` Paired contrast joins on problem_id only (no eval-set/generator identity check); n=0 with --allow-partial crashes +- U11-26 `scripts/grpo_watch.py:143` Dashboard hard-codes grad_accum=85 / total_steps=500 (horizon was cut to 108) and can crash on rows lacking grad_norm +- U11-27 `scripts/dedup_arm_b_shards.py:232` Swap then non-atomic manifest rewrite: a crash between leaves new shards with the old manifest and blocks re-run +- U11-28 `scripts/build_stageb_data.py:98` build_stageb_data.py has no resource guards or off-root path assertion; decontamination is exact-normalised-text only +- U11-29 `scripts/precompute_ref_logprobs.py:95` Exit-code contract: scripts without run_with_exit_codes turn PreflightRefused (83) into exit 1; grpo/ppo/precompute omit rss_cfg +- U11-30 `scripts/lean_setup.sh:42` lean_setup.sh: `/apps/*` glob accepts /apps/../x; elan installer is piped from the network without a checksum +- U11-31 `scripts/eval_checkpoint.py:1` No tests for eval_checkpoint, ceiling_test, build_stageb_data, gate_flash_attention, lean_ceiling_test.main, grpo_watch, bench_*; dpo resume wiring is a source-grep test +- U11-32 `scripts/grpo.py:112` grpo/ppo/dpo/generate are Mistral-only (NmoMistralForCausalLM); no arch dispatch for the Goedel-Prover-V2-8B (Qwen3) plan +- U11-33 `scripts/qwen3_export_check.py:250` Export check assumes scaling=alpha/r (no rslora option) +- U11-34 `scripts/bench_7b_end_to_end.py:226` Benchmark merges stale per-process partials and writes into the other repo (nano-mistral-o1) +- U12-10 `scripts/p2/expert_iteration.py:192` solved_map takes the first n records in file order, not samples 0..n-1 +- U12-11 `scripts/p2/expert_iteration.py:221` stop_decision best_round ignores earlier rounds when the final round uses a different n +- U12-12 `scripts/p2/p2_common.py:298` alpha_sha does not rename Greek/uppercase variables; SFT builder trusts stored statement_sha and checks no alpha layer +- U12-13 `scripts/p2/verify_generations.py:228` Infrastructure deaths are recorded as permanent rejections unless more than half of a >=8 chunk died +- U12-14 `scripts/p2/eval_report.py:353` Verifier-equality check uses only the first verification record of each run +- U12-15 `extras/vllm_rollout/lean_gen.py:577` lean_gen mutates --out (torn-tail truncation) and the skipped file before taking the heavy lock +- U12-16 `scripts/p2/verify_generations.py:178` Whole pending generation set (full token lists) is held in memory; snapshot copies the whole file each pass +- U12-17 `scripts/p2/build_valid.py:56` build_valid skips the ':= by' uniqueness guard that build_problems applies +- U12-18 `scripts/p2/build_problems.py:234` sample_pool heap tuple (-rank, pid, rec) raises TypeError on duplicate problem_id; no pid dedup +- U12-19 `extras/vllm_rollout/lean_gen.py:356` One malformed parent aborts the whole correction round (no per-parent skip) +- U12-20 `extras/vllm_rollout/rollout_worker.py:111` Worker heartbeat is not touched during engine rebuild or engine.generate +- U12-21 `tests/test_expert_iteration.py:1` Invariants of the P2 pipeline with no test +- U12-4 `scripts/p2/verify_generations.py:323` verify_generations has no SIGTERM handler; supervisor stop orphans 8 Lean REPL workers outside the lock +- U12-6 `scripts/p2/verify_generations.py:153` Length-capped samples are never verified, unlike Goedel's protocol; pass@k is a lower bound not comparable to published numbers +- U12-8 `scripts/p2/expert_iteration.py:382` Chain hash covers pure resource knobs and --rounds, so routine changes invalidate finished work and push operators into unsafe --redo +- U12-9 `scripts/p2/expert_iteration.py:735` --prune-export makes the export stage permanently 'pending' and can destroy the averaged result's inputs on a crash diff --git a/code/docs/evidence/lean_ceiling_2026-10-12/registered.log b/code/docs/evidence/lean_ceiling_2026-10-12/registered.log new file mode 100644 index 0000000000000000000000000000000000000000..34c8ea21f0c8c014f6054dc0019f317b3a22f897 --- /dev/null +++ b/code/docs/evidence/lean_ceiling_2026-10-12/registered.log @@ -0,0 +1,328 @@ +host busy (another heavy align job holds the lock (olmoe-parity-align-fp32 pid=3324521). Two heavy jobs at once is what killed the ...); retrying in 30s +host busy (another heavy align job holds the lock (olmoe-parity-hf-fp32 pid=3325588). Two heavy jobs at once is what killed the pre...); retrying in 30s +== measuring install == +{ + "disk_bytes": { + "total": 5981549078, + "elan_toolchain": 998849218, + "workspace": 4661413444, + "mathlib_package": 4414677409, + "repl": 98457581 + }, + "evicted": { + "files": 11171, + "bytes": 4495686414 + }, + "cached_fraction_after_evict": null, + "cached_fraction_after_cold_import": 4096.0, + "import_s": { + "cold": 39.67338741198182, + "warm": [ + 3.190861428156495, + 3.2010255875065923, + 3.188470327295363 + ], + "warm_median": 3.190861428156495 + }, + "single_repl_after_import_mib": { + "rss": 3439.05078125, + "pss": 3437.2529296875, + "pss_anon": 243.8984375, + "pss_file": 3193.3544921875, + "anon": 243.8984375, + "private_dirty": 243.8984375, + "vm_size": 4071.078125, + "threads": 1.0 + }, + "pool_warm_s": 3.3790985317900777, + "n_workers": 8, + "per_worker_mib": [ + { + "rss": 3459.078125, + "pss": 651.6533203125, + "pss_anon": 243.8984375, + "pss_file": 407.7548828125, + "anon": 243.8984375, + "private_dirty": 243.8984375, + "vm_size": 4071.0625, + "threads": 1.0 + }, + { + "rss": 3457.05859375, + "pss": 651.9150390625, + "pss_anon": 243.8984375, + "pss_file": 408.0166015625, + "anon": 243.8984375, + "private_dirty": 243.8984375, + "vm_size": 4071.0625, + "threads": 1.0 + }, + { + "rss": 3404.6640625, + "pss": 643.4306640625, + "pss_anon": 243.8984375, + "pss_file": 399.5322265625, + "anon": 243.8984375, + "private_dirty": 243.8984375, + "vm_size": 4071.0625, + "threads": 1.0 + }, + { + "rss": 3402.390625, + "pss": 643.294921875, + "pss_anon": 243.89453125, + "pss_file": 399.400390625, + "anon": 243.89453125, + "private_dirty": 243.89453125, + "vm_size": 4071.0625, + "threads": 1.0 + }, + { + "rss": 3464.171875, + "pss": 652.8515625, + "pss_anon": 243.8984375, + "pss_file": 408.953125, + "anon": 243.8984375, + "private_dirty": 243.8984375, + "vm_size": 4071.0625, + "threads": 1.0 + }, + { + "rss": 3410.16015625, + "pss": 644.1171875, + "pss_anon": 243.8984375, + "pss_file": 400.21875, + "anon": 243.8984375, + "private_dirty": 243.8984375, + "vm_size": 4071.0625, + "threads": 1.0 + }, + { + "rss": 3408.28515625, + "pss": 644.283203125, + "pss_anon": 243.8984375, + "pss_file": 400.384765625, + "anon": 243.8984375, + "private_dirty": 243.8984375, + "vm_size": 4071.0625, + "threads": 1.0 + }, + { + "rss": 3460.30078125, + "pss": 652.029296875, + "pss_anon": 243.89453125, + "pss_file": 408.134765625, + "anon": 243.89453125, + "private_dirty": 243.89453125, + "vm_size": 4071.0625, + "threads": 1.0 + } + ], + "pss_mib": { + "min": 643.294921875, + "mean": 647.9468994140625, + "max": 652.8515625, + "sum": 5183.5751953125 + }, + "pss_anon_mib": { + "mean": 243.8974609375 + }, + "rss_sum_mib": 27466.109375 +} +== known-good: fetching == +== known-good: independent compile-only filter == +compile-clean: 178/180 excluded: 2 +== known-good: strict verification == + [ 4] lean_workbook_10014 ACCEPT 0.54s [] + [ 1] lean_workbook_1001 ACCEPT 1.42s [] + [ 7] lean_workbook_10026 ACCEPT 1.43s [] + [ 10] lean_workbook_10041 ACCEPT 0.78s [] + [ 6] lean_workbook_1002 ACCEPT 2.36s [] + [ 8] lean_workbook_1003 ACCEPT 2.32s [] + [ 11] lean_workbook_10049 ACCEPT 1.28s [] + [ 2] lean_workbook_10012 ACCEPT 3.56s [] + [ 5] lean_workbook_10016 ACCEPT 4.59s [] + [ 13] lean_workbook_10057 ACCEPT 3.38s [] + [ 16] lean_workbook_10064 ACCEPT 1.88s [] + [ 15] lean_workbook_1006 ACCEPT 2.91s [] + [ 18] lean_workbook_10074 ACCEPT 0.93s [] + [ 20] lean_workbook_1008 ACCEPT 1.78s [] + [ 14] lean_workbook_10058 ACCEPT 7.62s [] + [ 21] lean_workbook_10081 ACCEPT 3.17s [] + [ 22] lean_workbook_10082 ACCEPT 3.25s [] + [ 23] lean_workbook_10083 ACCEPT 3.30s [] + [ 17] lean_workbook_10065 ACCEPT 9.89s [] + [ 26] lean_workbook_30739 ACCEPT 0.13s [] + [ 24] lean_workbook_30724 ACCEPT 2.01s [] + [ 27] lean_workbook_3074 ACCEPT 0.59s [] + [ 28] lean_workbook_30746 ACCEPT 2.92s [] + [ 19] lean_workbook_10075 ACCEPT 14.33s [] + [ 25] lean_workbook_30734 ACCEPT 5.69s [] + [ 0] lean_workbook_10009 ACCEPT 21.39s [] + [ 32] lean_workbook_30765 ACCEPT 1.22s [] + [ 33] lean_workbook_30766 ACCEPT 1.28s [] + [ 31] lean_workbook_30762 ACCEPT 2.12s [] + [ 35] lean_workbook_30771 ACCEPT 2.45s [] + [ 34] lean_workbook_30767 ACCEPT 2.63s [] + [ 36] lean_workbook_30773 ACCEPT 2.67s [] + [ 37] lean_workbook_30777 ACCEPT 0.77s [] + [ 38] lean_workbook_30781 ACCEPT 1.31s [] + [ 39] lean_workbook_30782 ACCEPT 0.90s [] + [ 40] lean_workbook_30784 ACCEPT 1.41s [] + [ 41] lean_workbook_30786 ACCEPT 1.81s [] + [ 42] lean_workbook_30787 ACCEPT 3.47s [] + [ 29] lean_workbook_30748 ACCEPT 14.56s [] + [ 46] lean_workbook_30819 ACCEPT 0.81s [] + [ 44] lean_workbook_30791 ACCEPT 3.99s [] + [ 48] lean_workbook_30821 ACCEPT 1.24s [] + [ 47] lean_workbook_30820 ACCEPT 3.64s [] + [ 30] lean_workbook_30756 ACCEPT 17.43s [] + [ 49] lean_workbook_50999 ACCEPT 5.05s [] + [ 52] lean_workbook_51015 ACCEPT 0.98s [] + [ 53] lean_workbook_51019 ACCEPT 0.09s [] + [ 43] lean_workbook_30790 ACCEPT 13.53s [] + [ 55] lean_workbook_51024 ACCEPT 1.51s [] + [ 56] lean_workbook_51026 ACCEPT 0.17s [] + [ 3] lean_workbook_10013 ACCEPT 44.59s [] + [ 57] lean_workbook_51029 ACCEPT 2.23s [] + [ 58] lean_workbook_51030 ACCEPT 0.73s [] + [ 54] lean_workbook_51020 ACCEPT 6.85s [] + [ 50] lean_workbook_51005 ACCEPT 11.19s [] + [ 59] lean_workbook_51034 ACCEPT 3.62s [] + [ 63] lean_workbook_51049 ACCEPT 2.48s [] + [ 64] lean_workbook_5105 ACCEPT 0.09s [] + [ 65] lean_workbook_51052 ACCEPT 1.92s [] + [ 60] lean_workbook_51037 ACCEPT 10.44s [] + [ 66] lean_workbook_51059 ACCEPT 4.66s [] + [ 45] lean_workbook_30800 ACCEPT 28.04s [] + [ 61] lean_workbook_51038 ACCEPT 14.89s [] + [ 69] lean_workbook_51069 ACCEPT 4.52s [] + [ 70] lean_workbook_51077 ACCEPT 3.68s [] + [ 72] lean_workbook_51090 ACCEPT 2.72s [] + [ 73] lean_workbook_51091 ACCEPT 1.95s [] + [ 67] lean_workbook_51062 ACCEPT 14.14s [] + [ 74] lean_workbook_plus_20026 ACCEPT 2.93s [] + [ 9] lean_workbook_10039 ACCEPT 71.63s [] + [ 77] lean_workbook_plus_20030 ACCEPT 0.13s [] + [ 78] lean_workbook_plus_20032 ACCEPT 0.84s [] + [ 75] lean_workbook_plus_20027 ACCEPT 5.91s [] + [ 80] lean_workbook_plus_20056 ACCEPT 0.26s [] + [ 76] lean_workbook_plus_20028 ACCEPT 3.77s [] + [ 79] lean_workbook_plus_20046 ACCEPT 3.80s [] + [ 82] lean_workbook_plus_20066 ACCEPT 1.83s [] + [ 62] lean_workbook_5104 ACCEPT 31.80s [] + [ 81] lean_workbook_plus_20061 ACCEPT 5.40s [] + [ 83] lean_workbook_plus_20074 ACCEPT 4.71s [] + [ 12] lean_workbook_10053 ACCEPT 80.40s [] + [ 88] lean_workbook_plus_20099 ACCEPT 0.96s [] + [ 84] lean_workbook_plus_20076 ACCEPT 7.92s [] + [ 85] lean_workbook_plus_20083 ACCEPT 9.70s [] + [ 90] lean_workbook_plus_20111 ACCEPT 9.38s [] + [ 86] lean_workbook_plus_20095 ACCEPT 15.17s [] + [ 92] lean_workbook_plus_20113 ACCEPT 2.36s [] + [ 87] lean_workbook_plus_20097 ACCEPT 15.76s [] + [ 95] lean_workbook_plus_20135 ACCEPT 5.06s [] + [ 68] lean_workbook_51065 ACCEPT 54.72s [] + [ 97] lean_workbook_plus_20139 ACCEPT 1.81s [] + [ 98] lean_workbook_plus_20142 ACCEPT 0.08s [] + [ 91] lean_workbook_plus_20112 ACCEPT 25.72s [] + [ 99] lean_workbook_plus_42103 ACCEPT 1.51s [] + [101] lean_workbook_plus_42107 ACCEPT 0.99s [] + [ 93] lean_workbook_plus_20118 ACCEPT 21.30s [] + [102] lean_workbook_plus_42117 ACCEPT 5.78s [] + [104] lean_workbook_plus_42122 ACCEPT 0.45s [] + [103] lean_workbook_plus_42120 ACCEPT 5.39s [] + [106] lean_workbook_plus_42133 ACCEPT 0.17s [] + [107] lean_workbook_plus_42142 ACCEPT 1.63s [] + [108] lean_workbook_plus_42144 ACCEPT 2.23s [] + [109] lean_workbook_plus_42146 ACCEPT 0.13s [] + [110] lean_workbook_plus_42147 ACCEPT 0.38s [] + [111] lean_workbook_plus_42148 ACCEPT 2.36s [] + [112] lean_workbook_plus_42153 ACCEPT 1.05s [] + [113] lean_workbook_plus_42155 ACCEPT 0.74s [] + [114] lean_workbook_plus_42159 ACCEPT 0.44s [] + [115] lean_workbook_plus_42161 ACCEPT 0.14s [] + [ 89] lean_workbook_plus_20105 ACCEPT 48.88s [] + [116] lean_workbook_plus_42163 ACCEPT 13.74s [] + [118] lean_workbook_plus_42173 ACCEPT 1.97s [] + [ 51] lean_workbook_51009 ACCEPT 115.50s [] + [117] lean_workbook_plus_42167 ACCEPT 20.01s [] + [119] lean_workbook_plus_42176 ACCEPT 4.38s [] + [121] lean_workbook_plus_42185 ACCEPT 2.08s [] + [105] lean_workbook_plus_42126 ACCEPT 33.25s [] + [100] lean_workbook_plus_42105 ACCEPT 43.60s [] + [125] lean_workbook_plus_64007 ACCEPT 2.27s [] + [122] lean_workbook_plus_42190 ACCEPT 9.32s [] + [124] lean_workbook_plus_64003 ACCEPT 6.51s [] + [126] lean_workbook_plus_6401 ACCEPT 2.84s [] + [129] lean_workbook_plus_64020 ACCEPT 1.44s [] + [130] lean_workbook_plus_64022 ACCEPT 0.18s [] + [127] lean_workbook_plus_64014 ACCEPT 2.90s [] + [131] lean_workbook_plus_64029 ACCEPT 1.44s [] + [132] lean_workbook_plus_64044 ACCEPT 1.38s [] + [123] lean_workbook_plus_42197 ACCEPT 11.70s [] + [135] lean_workbook_plus_6407 ACCEPT 1.73s [] + [133] lean_workbook_plus_64047 ACCEPT 3.87s [] + [137] lean_workbook_plus_64078 ACCEPT 0.20s [] + [ 94] lean_workbook_plus_2013 ACCEPT 77.67s [] + [139] lean_workbook_plus_64081 ACCEPT 1.66s [] + [140] lean_workbook_plus_64084 ACCEPT 1.06s [] + [136] lean_workbook_plus_64070 ACCEPT 10.37s [] + [141] lean_workbook_plus_6409 ACCEPT 0.91s [] + [143] lean_workbook_plus_64100 ACCEPT 0.80s [] + [142] lean_workbook_plus_64095 ACCEPT 2.35s [] + [145] lean_workbook_plus_64103 ACCEPT 0.10s [] + [ 96] lean_workbook_plus_20138 ACCEPT 77.71s [] + [144] lean_workbook_plus_64101 ACCEPT 1.11s [] + [148] rs_add_comm ACCEPT 0.11s [] + [149] rs_sq_nonneg ACCEPT 0.62s [] + [146] lean_workbook_plus_64104 ACCEPT 1.10s [] + [150] rs_gauss ACCEPT 0.21s [] + [152] rs_dvd ACCEPT 0.09s [] + [147] lean_workbook_plus_64105 ACCEPT 1.43s [] + [154] rs_odd_sum ACCEPT 0.22s [] + [155] rs_exists ACCEPT 0.06s [] + [153] rs_abs ACCEPT 0.65s [] + [157] rs_calc ACCEPT 0.05s [] + [158] rs_let_in_type ACCEPT 0.04s [] + [159] rs_comm_ring ACCEPT 0.21s [] + [151] rs_amgm ACCEPT 1.42s [] + [161] rs_set_eq ACCEPT 0.18s [] + [156] rs_irrational_nat ACCEPT 0.98s [] + [163] rs_word_in_comment_and_string ACCEPT 0.05s [] + [162] rs_card_filter ACCEPT 0.17s [] + [164] rs_nested_comment ACCEPT 0.05s [] + [165] rs_char_quote ACCEPT 0.06s [] + [167] rs_rcases ACCEPT 0.04s [] + [160] rs_lemma_kw ACCEPT 0.86s [] + [168] rs_mod4 ACCEPT 0.23s [] + [169] rs_prime97 ACCEPT 0.17s [] + [166] rs_set_option_in ACCEPT 1.07s [] + [170] rs_helper_lemma ACCEPT 1.10s [] + [173] rs_em ACCEPT 0.04s [] + [174] rs_funext ACCEPT 0.09s [] + [171] rs_exp_pos ACCEPT 1.51s [] + [176] rs_default_arg ACCEPT 0.11s [] + [177] rs_structure_instance ACCEPT 0.05s [] + [134] lean_workbook_plus_64050 ACCEPT 19.73s [] + [175] rs_field_simp ACCEPT 0.79s [] + [172] rs_by_contra ACCEPT 1.26s [] + [138] lean_workbook_plus_64079 ACCEPT 35.22s [] + [128] lean_workbook_plus_64019 ACCEPT 72.00s [] + [ 71] lean_workbook_51088 REJECT 180.26s ['timeout'] + [120] lean_workbook_plus_4218 ACCEPT 158.64s [] +== adversarial: strict == +== adversarial: naive compile-only (liveness) == +== adversarial: semantic layer only (lexical gate off) == + +================ RESULT ================ + verdict ACCEPT verdict REJECT total +truth GOOD proofs 177 1 178 +truth BAD proofs 0 61 61 + +known-good accepted 177/178 = 0.9944 Wilson95 [0.9689, 0.9990] floor 0.98 PASS=True + REJECTED known-good: lean_workbook_51088 ['timeout'] {'detail': 'no response within 179.4s'} +adversarial accepted 0/61 PASS=True (naive compile-only would accept 51) +throughput: {"n_proofs": 178, "wall_s": 310.8683668440208, "proofs_per_s": 0.5725896198673444, "workers": 8, "latency_s": {"p50": 1.9659304870292544, "p90": 21.389278178103268, "p99": 158.64453719183803, "max": 180.26452291756868, "mean": 9.762855807329748}, "note": "unique statement per proof (no reference-env reuse across proofs); warm workers"} +results: .tmp/lean_ceiling/registered/result.json +done rc=0 diff --git a/code/docs/evidence/lean_ceiling_2026-10-12/result.json b/code/docs/evidence/lean_ceiling_2026-10-12/result.json new file mode 100644 index 0000000000000000000000000000000000000000..5655ffe15d34043bd0a14d84a72aca712f3387dc --- /dev/null +++ b/code/docs/evidence/lean_ceiling_2026-10-12/result.json @@ -0,0 +1,4454 @@ +{ + "started": "20261012_201339", + "toolchain": "leanprover/lean4:v4.9.0-rc1 mathlib=d1d1e4b72c repl=v4.9.0-rc1", + "pins": { + "lean_toolchain": "leanprover/lean4:v4.9.0-rc1", + "mathlib_rev": "d1d1e4b72c6e009edd97cabe1d661b021fe8eeae", + "repl_tag": "v4.9.0-rc1", + "repl_rev": "7df77c0a1c9188ab58c76d4138a06ae5e0c32282", + "installed": "2026-10-12T19:00:23+05:30", + "source_of_pin": "Goedel-Prover-V2 submodule xinhjBrant/mathlib4@2f65ba7f (parent leanprover-community/mathlib4@d1d1e4b7)" + }, + "workers": 8, + "limits": { + "anon_cap_gb": 4.0, + "as_limit_gb": 16.0, + "fsize_mb": 256, + "nice": 5, + "max_output_bytes": 16777216, + "poll_s": 0.1, + "mem_poll_s": 0.5, + "header_timeout_s": 600.0 + }, + "prereg_registered": true, + "table_sha256": "61636c7136eb647801c588b00a35bd8bcc6ef4db126c21ea75769092883ff7e2", + "cores": 48, + "install": { + "disk_bytes": { + "total": 5981549078, + "elan_toolchain": 998849218, + "workspace": 4661413444, + "mathlib_package": 4414677409, + "repl": 98457581 + }, + "evicted": { + "files": 11171, + "bytes": 4495686414 + }, + "cached_fraction_after_evict": null, + "cached_fraction_after_cold_import": 4096.0, + "import_s": { + "cold": 39.67338741198182, + "warm": [ + 3.190861428156495, + 3.2010255875065923, + 3.188470327295363 + ], + "warm_median": 3.190861428156495 + }, + "single_repl_after_import_mib": { + "rss": 3439.05078125, + "pss": 3437.2529296875, + "pss_anon": 243.8984375, + "pss_file": 3193.3544921875, + "anon": 243.8984375, + "private_dirty": 243.8984375, + "vm_size": 4071.078125, + "threads": 1.0 + }, + "pool_warm_s": 3.3790985317900777, + "n_workers": 8, + "per_worker_mib": [ + { + "rss": 3459.078125, + "pss": 651.6533203125, + "pss_anon": 243.8984375, + "pss_file": 407.7548828125, + "anon": 243.8984375, + "private_dirty": 243.8984375, + "vm_size": 4071.0625, + "threads": 1.0 + }, + { + "rss": 3457.05859375, + "pss": 651.9150390625, + "pss_anon": 243.8984375, + "pss_file": 408.0166015625, + "anon": 243.8984375, + "private_dirty": 243.8984375, + "vm_size": 4071.0625, + "threads": 1.0 + }, + { + "rss": 3404.6640625, + "pss": 643.4306640625, + "pss_anon": 243.8984375, + "pss_file": 399.5322265625, + "anon": 243.8984375, + "private_dirty": 243.8984375, + "vm_size": 4071.0625, + "threads": 1.0 + }, + { + "rss": 3402.390625, + "pss": 643.294921875, + "pss_anon": 243.89453125, + "pss_file": 399.400390625, + "anon": 243.89453125, + "private_dirty": 243.89453125, + "vm_size": 4071.0625, + "threads": 1.0 + }, + { + "rss": 3464.171875, + "pss": 652.8515625, + "pss_anon": 243.8984375, + "pss_file": 408.953125, + "anon": 243.8984375, + "private_dirty": 243.8984375, + "vm_size": 4071.0625, + "threads": 1.0 + }, + { + "rss": 3410.16015625, + "pss": 644.1171875, + "pss_anon": 243.8984375, + "pss_file": 400.21875, + "anon": 243.8984375, + "private_dirty": 243.8984375, + "vm_size": 4071.0625, + "threads": 1.0 + }, + { + "rss": 3408.28515625, + "pss": 644.283203125, + "pss_anon": 243.8984375, + "pss_file": 400.384765625, + "anon": 243.8984375, + "private_dirty": 243.8984375, + "vm_size": 4071.0625, + "threads": 1.0 + }, + { + "rss": 3460.30078125, + "pss": 652.029296875, + "pss_anon": 243.89453125, + "pss_file": 408.134765625, + "anon": 243.89453125, + "private_dirty": 243.89453125, + "vm_size": 4071.0625, + "threads": 1.0 + } + ], + "pss_mib": { + "min": 643.294921875, + "mean": 647.9468994140625, + "max": 652.8515625, + "sum": 5183.5751953125 + }, + "pss_anon_mib": { + "mean": 243.8974609375 + }, + "rss_sum_mib": 27466.109375 + }, + "known_good_source": { + "dataset": "Goedel-LM/Lean-workbook-proofs", + "offsets": [ + 0, + 5000, + 10000, + 15000, + 20000, + 25000 + ], + "slice_len": 25, + "n_rows": 150, + "raw_sha256": "55932494b6e6f73353467bbb93ba03fc9138b4a5e6a7ed039ab3a499787aebef", + "dataset_revision": "b731852af8d8ab11498fda27bce9020738c01c59" + }, + "known_good_candidates": 180, + "known_good_excluded": [ + { + "id": "lean_workbook_10036", + "source": "lean-workbook-proofs", + "why": "timeout" + }, + { + "id": "lean_workbook_plus_64037", + "source": "lean-workbook-proofs", + "why": "timeout" + } + ], + "known_good_filter_s": 272.97531804814935, + "strict_warm_s": 5.05052897054702, + "warm_memory_mib": [ + { + "rss": 3430.91796875, + "pss": 659.7939453125, + "pss_anon": 259.43359375, + "pss_file": 400.3603515625, + "anon": 259.43359375, + "private_dirty": 259.43359375, + "vm_size": 4090.75, + "threads": 1.0 + }, + { + "rss": 3441.55078125, + "pss": 661.375, + "pss_anon": 259.421875, + "pss_file": 401.953125, + "anon": 259.421875, + "private_dirty": 259.421875, + "vm_size": 4090.75, + "threads": 1.0 + }, + { + "rss": 3486.23046875, + "pss": 668.3388671875, + "pss_anon": 259.43359375, + "pss_file": 408.9052734375, + "anon": 259.43359375, + "private_dirty": 259.43359375, + "vm_size": 4090.75, + "threads": 1.0 + }, + { + "rss": 3443.515625, + "pss": 661.7392578125, + "pss_anon": 259.43359375, + "pss_file": 402.3056640625, + "anon": 259.43359375, + "private_dirty": 259.43359375, + "vm_size": 4090.75, + "threads": 1.0 + }, + { + "rss": 3428.3359375, + "pss": 659.7353515625, + "pss_anon": 259.421875, + "pss_file": 400.3134765625, + "anon": 259.421875, + "private_dirty": 259.421875, + "vm_size": 4090.75390625, + "threads": 1.0 + }, + { + "rss": 3489.19140625, + "pss": 668.7275390625, + "pss_anon": 259.4375, + "pss_file": 409.2900390625, + "anon": 259.4375, + "private_dirty": 259.4375, + "vm_size": 4090.75390625, + "threads": 1.0 + }, + { + "rss": 3493.81640625, + "pss": 669.9228515625, + "pss_anon": 259.4375, + "pss_file": 410.4853515625, + "anon": 259.4375, + "private_dirty": 259.4375, + "vm_size": 4090.7578125, + "threads": 1.0 + }, + { + "rss": 3488.0703125, + "pss": 668.775390625, + "pss_anon": 259.421875, + "pss_file": 409.353515625, + "anon": 259.421875, + "private_dirty": 259.421875, + "vm_size": 4090.75390625, + "threads": 1.0 + } + ], + "throughput": { + "n_proofs": 178, + "wall_s": 310.8683668440208, + "proofs_per_s": 0.5725896198673444, + "workers": 8, + "latency_s": { + "p50": 1.9659304870292544, + "p90": 21.389278178103268, + "p99": 158.64453719183803, + "max": 180.26452291756868, + "mean": 9.762855807329748 + }, + "note": "unique statement per proof (no reference-env reuse across proofs); warm workers" + }, + "pss_during_run_mib": { + "0": { + "pss_mean": 740.8700284090909, + "pss_max": 820.1396484375, + "pss_anon_max": 358.265625, + "rss_max": 3556.0703125, + "samples": 242 + }, + "1": { + "pss_mean": 782.5435538804236, + "pss_max": 868.9306640625, + "pss_anon_max": 404.19140625, + "rss_max": 3614.95703125, + "samples": 242 + }, + "2": { + "pss_mean": 777.4816672585227, + "pss_max": 872.728515625, + "pss_anon_max": 400.6015625, + "rss_max": 3652.7734375, + "samples": 242 + }, + "3": { + "pss_mean": 743.1690139139979, + "pss_max": 816.7421875, + "pss_anon_max": 352.33203125, + "rss_max": 3562.28515625, + "samples": 242 + }, + "4": { + "pss_mean": 762.4189130294421, + "pss_max": 835.8359375, + "pss_anon_max": 371.98046875, + "rss_max": 3570.90625, + "samples": 242 + }, + "5": { + "pss_mean": 792.3810611441115, + "pss_max": 862.6181640625, + "pss_anon_max": 389.28125, + "rss_max": 3646.37890625, + "samples": 242 + }, + "6": { + "pss_mean": 717.1913372537364, + "pss_max": 725.4443359375, + "pss_anon_max": 311.46875, + "rss_max": 3568.89453125, + "samples": 184 + }, + "7": { + "pss_mean": 756.6207668840393, + "pss_max": 823.4423828125, + "pss_anon_max": 351.77734375, + "rss_max": 3603.140625, + "samples": 242 + } + }, + "host_during_run": { + "min_mem_available_gb": 136.87619018554688 + }, + "pool_stats": { + "spawned": 9, + "timeouts": 1, + "crashes": 0, + "memory_kills": 0, + "oversize": 0, + "protocol": 0, + "requests": 1042, + "recycled": 0, + "per_code": { + "timeout": 1 + } + }, + "throughput_grpo_shape": { + "n_proofs": 96, + "wall_s": 21.912011086009443, + "proofs_per_s": 4.381158791093112, + "accepted": 96, + "shape": "12 median-latency statements x 8 identical samples" + }, + "report": { + "known_good": { + "n": 178, + "accepted": 177, + "rate": 0.9943820224719101, + "wilson95": [ + 0.9688677124404923, + 0.9990076252160178 + ], + "floor": 0.98, + "pass": true, + "rejected": [ + { + "id": "lean_workbook_51088", + "source": "lean-workbook-proofs", + "accepted": false, + "reasons": [ + "timeout" + ], + "diag": { + "detail": "no response within 179.4s" + }, + "elapsed_s": 180.26452291756868 + } + ] + }, + "adversarial": { + "n": 61, + "accepted": 0, + "pass": true, + "by_category": { + "admit": { + "n": 2, + "accepted": 0 + }, + "banned_construct": { + "n": 6, + "accepted": 0 + }, + "changed_statement": { + "n": 6, + "accepted": 0 + }, + "definition_hole": { + "n": 4, + "accepted": 0 + }, + "env_manipulation": { + "n": 9, + "accepted": 0 + }, + "extra_axiom": { + "n": 4, + "accepted": 0 + }, + "header_tamper": { + "n": 4, + "accepted": 0 + }, + "native_decide": { + "n": 3, + "accepted": 0 + }, + "no_code": { + "n": 4, + "accepted": 0 + }, + "sorry": { + "n": 7, + "accepted": 0 + }, + "timeout_bomb": { + "n": 3, + "accepted": 0 + }, + "weakened_hypothesis": { + "n": 3, + "accepted": 0 + }, + "wrong_name": { + "n": 6, + "accepted": 0 + } + }, + "accepted_cases": [], + "naive_compile_only_would_accept": 51, + "semantic_only_would_accept": [ + "eval_side_effect", + "exit_after_theorem", + "attribute_on_target", + "unsafe_implemented_by", + "partial_def", + "extern_attr", + "search_tactic_exact_q", + "maxRecDepth_abuse", + "helper_def_with_proof" + ] + }, + "confusion": { + "TP": 177, + "FN": 1, + "FP": 0, + "TN": 61, + "n_good": 178, + "n_bad": 61 + } + }, + "good_results": [ + { + "id": "lean_workbook_10009", + "source": "lean-workbook-proofs", + "accepted": true, + "reasons": [], + "diag": { + "worker": 1, + "n_messages": 0, + "axioms": [ + "propext", + "Quot.sound", + "Classical.choice" + ], + "foreign": [] + }, + "elapsed_s": 21.389278178103268 + }, + { + "id": "lean_workbook_1001", + "source": "lean-workbook-proofs", + "accepted": true, + "reasons": [], + "diag": { + "worker": 3, + "n_messages": 2, + "axioms": [ + "propext", + "Quot.sound", + "Classical.choice" + ], + "foreign": [] + }, + "elapsed_s": 1.416024748235941 + }, + { + "id": "lean_workbook_10012", + "source": "lean-workbook-proofs", + "accepted": true, + "reasons": [], + "diag": { + "worker": 6, + "n_messages": 0, + "axioms": [ + "propext", + "Quot.sound", + "Classical.choice" + ], + "foreign": [] + }, + "elapsed_s": 3.563496246933937 + }, + { + "id": "lean_workbook_10013", + "source": "lean-workbook-proofs", + "accepted": true, + "reasons": [], + "diag": { + "worker": 2, + "n_messages": 0, + "axioms": [ + "propext", + "Quot.sound", + "Classical.choice" + ], + "foreign": [] + }, + "elapsed_s": 44.590996225364506 + }, + { + "id": "lean_workbook_10014", + "source": "lean-workbook-proofs", + "accepted": true, + "reasons": [], + "diag": { + "worker": 5, + "n_messages": 2, + "axioms": [ + "propext" + ], + "foreign": [] + }, + "elapsed_s": 0.5364140141755342 + }, + { + "id": "lean_workbook_10016", + "source": "lean-workbook-proofs", + "accepted": true, + "reasons": [], + "diag": { + "worker": 4, + "n_messages": 3, + "axioms": [ + "propext", + "Quot.sound", + "Classical.choice" + ], + "foreign": [] + }, + "elapsed_s": 4.588216203264892 + }, + { + "id": "lean_workbook_1002", + "source": "lean-workbook-proofs", + "accepted": true, + "reasons": [], + "diag": { + "worker": 0, + "n_messages": 0, + "axioms": [ + "propext", + "Quot.sound", + "Classical.choice" + ], + "foreign": [] + }, + "elapsed_s": 2.361315432935953 + }, + { + "id": "lean_workbook_10026", + "source": "lean-workbook-proofs", + "accepted": true, + "reasons": [], + "diag": { + "worker": 7, + "n_messages": 3, + "axioms": [ + "propext", + "Quot.sound", + "Classical.choice" + ], + "foreign": [] + }, + "elapsed_s": 1.4264537999406457 + }, + { + "id": "lean_workbook_1003", + "source": "lean-workbook-proofs", + "accepted": true, + "reasons": [], + "diag": { + "worker": 5, + "n_messages": 0, + "axioms": [ + "propext", + "Quot.sound", + "Classical.choice" + ], + "foreign": [] + }, + "elapsed_s": 2.316251111216843 + }, + { + "id": "lean_workbook_10039", + "source": "lean-workbook-proofs", + "accepted": true, + "reasons": [], + "diag": { + "worker": 3, + "n_messages": 0, + "axioms": [ + "propext", + "Quot.sound", + "Classical.choice" + ], + "foreign": [] + }, + "elapsed_s": 71.63111355155706 + }, + { + "id": "lean_workbook_10041", + "source": "lean-workbook-proofs", + "accepted": true, + "reasons": [], + "diag": { + "worker": 7, + "n_messages": 1, + "axioms": [ + "propext", + "Quot.sound", + "Classical.choice" + ], + "foreign": [] + }, + "elapsed_s": 0.783287413418293 + }, + { + "id": "lean_workbook_10049", + "source": "lean-workbook-proofs", + "accepted": true, + "reasons": [], + "diag": { + "worker": 7, + "n_messages": 0, + "axioms": [ + "propext", + "Quot.sound", + "Classical.choice" + ], + "foreign": [] + }, + "elapsed_s": 1.2767162518575788 + }, + { + "id": "lean_workbook_10053", + "source": "lean-workbook-proofs", + "accepted": true, + "reasons": [], + "diag": { + "worker": 0, + "n_messages": 0, + "axioms": [ + "propext", + "Quot.sound", + "Classical.choice" + ], + "foreign": [] + }, + "elapsed_s": 80.4048453560099 + }, + { + "id": "lean_workbook_10057", + "source": "lean-workbook-proofs", + "accepted": true, + "reasons": [], + "diag": { + "worker": 5, + "n_messages": 4, + "axioms": [ + "propext", + "Quot.sound", + "Classical.choice" + ], + "foreign": [] + }, + "elapsed_s": 3.384892553091049 + }, + { + "id": "lean_workbook_10058", + "source": "lean-workbook-proofs", + "accepted": true, + "reasons": [], + "diag": { + "worker": 7, + "n_messages": 0, + "axioms": [ + "propext", + "Quot.sound", + "Classical.choice" + ], + "foreign": [] + }, + "elapsed_s": 7.615243745036423 + }, + { + "id": "lean_workbook_1006", + "source": "lean-workbook-proofs", + "accepted": true, + "reasons": [], + "diag": { + "worker": 6, + "n_messages": 1, + "axioms": [ + "propext", + "Quot.sound", + "Classical.choice" + ], + "foreign": [] + }, + "elapsed_s": 2.9137531192973256 + }, + { + "id": "lean_workbook_10064", + "source": "lean-workbook-proofs", + "accepted": true, + "reasons": [], + "diag": { + "worker": 4, + "n_messages": 1, + "axioms": [ + "propext", + "Quot.sound", + "Classical.choice" + ], + "foreign": [] + }, + "elapsed_s": 1.8788955491036177 + }, + { + "id": "lean_workbook_10065", + "source": "lean-workbook-proofs", + "accepted": true, + "reasons": [], + "diag": { + "worker": 5, + "n_messages": 0, + "axioms": [ + "propext", + "Quot.sound", + "Classical.choice" + ], + "foreign": [] + }, + "elapsed_s": 9.89057217631489 + }, + { + "id": "lean_workbook_10074", + "source": "lean-workbook-proofs", + "accepted": true, + "reasons": [], + "diag": { + "worker": 4, + "n_messages": 1, + "axioms": [ + "propext", + "Quot.sound", + "Classical.choice" + ], + "foreign": [] + }, + "elapsed_s": 0.934989926405251 + }, + { + "id": "lean_workbook_10075", + "source": "lean-workbook-proofs", + "accepted": true, + "reasons": [], + "diag": { + "worker": 6, + "n_messages": 0, + "axioms": [ + "propext", + "Quot.sound", + "Classical.choice" + ], + "foreign": [] + }, + "elapsed_s": 14.334615141153336 + }, + { + "id": "lean_workbook_1008", + "source": "lean-workbook-proofs", + "accepted": true, + "reasons": [], + "diag": { + "worker": 4, + "n_messages": 2, + "axioms": [ + "propext", + "Quot.sound", + "Classical.choice" + ], + "foreign": [] + }, + "elapsed_s": 1.780697274953127 + }, + { + "id": "lean_workbook_10081", + "source": "lean-workbook-proofs", + "accepted": true, + "reasons": [], + "diag": { + "worker": 4, + "n_messages": 0, + "axioms": [ + "propext", + "Quot.sound", + "Classical.choice" + ], + "foreign": [] + }, + "elapsed_s": 3.166681433096528 + }, + { + "id": "lean_workbook_10082", + "source": "lean-workbook-proofs", + "accepted": true, + "reasons": [], + "diag": { + "worker": 7, + "n_messages": 0, + "axioms": [ + "propext", + "Quot.sound", + "Classical.choice" + ], + "foreign": [] + }, + "elapsed_s": 3.2538710022345185 + }, + { + "id": "lean_workbook_10083", + "source": "lean-workbook-proofs", + "accepted": true, + "reasons": [], + "diag": { + "worker": 4, + "n_messages": 0, + "axioms": [ + "propext", + "Quot.sound", + "Classical.choice" + ], + "foreign": [] + }, + "elapsed_s": 3.302369116805494 + }, + { + "id": "lean_workbook_30724", + "source": "lean-workbook-proofs", + "accepted": true, + "reasons": [], + "diag": { + "worker": 7, + "n_messages": 0, + "axioms": [ + "propext", + "Quot.sound", + "Classical.choice" + ], + "foreign": [] + }, + "elapsed_s": 2.00956158246845 + }, + { + "id": "lean_workbook_30734", + "source": "lean-workbook-proofs", + "accepted": true, + "reasons": [], + "diag": { + "worker": 4, + "n_messages": 0, + "axioms": [ + "propext", + "Quot.sound", + "Classical.choice" + ], + "foreign": [] + }, + "elapsed_s": 5.689583906903863 + }, + { + "id": "lean_workbook_30739", + "source": "lean-workbook-proofs", + "accepted": true, + "reasons": [], + "diag": { + "worker": 5, + "n_messages": 0, + "axioms": [ + "propext", + "Quot.sound" + ], + "foreign": [] + }, + "elapsed_s": 0.13158465828746557 + }, + { + "id": "lean_workbook_3074", + "source": "lean-workbook-proofs", + "accepted": true, + "reasons": [], + "diag": { + "worker": 5, + "n_messages": 2, + "axioms": [ + "propext", + "Quot.sound", + "Classical.choice" + ], + "foreign": [] + }, + "elapsed_s": 0.5867194831371307 + }, + { + "id": "lean_workbook_30746", + "source": "lean-workbook-proofs", + "accepted": true, + "reasons": [], + "diag": { + "worker": 7, + "n_messages": 0, + "axioms": [ + "propext", + "Quot.sound", + "Classical.choice" + ], + "foreign": [] + }, + "elapsed_s": 2.9233706016093493 + }, + { + "id": "lean_workbook_30748", + "source": "lean-workbook-proofs", + "accepted": true, + "reasons": [], + "diag": { + "worker": 5, + "n_messages": 0, + "axioms": [ + "propext", + "Quot.sound", + "Classical.choice" + ], + "foreign": [] + }, + "elapsed_s": 14.560273798182607 + }, + { + "id": "lean_workbook_30756", + "source": "lean-workbook-proofs", + "accepted": true, + "reasons": [], + "diag": { + "worker": 7, + "n_messages": 1, + "axioms": [ + "propext", + "Quot.sound", + "Classical.choice" + ], + "foreign": [] + }, + "elapsed_s": 17.425122398883104 + }, + { + "id": "lean_workbook_30762", + "source": "lean-workbook-proofs", + "accepted": true, + "reasons": [], + "diag": { + "worker": 6, + "n_messages": 0, + "axioms": [ + "propext", + "Quot.sound", + "Classical.choice" + ], + "foreign": [] + }, + "elapsed_s": 2.118530915118754 + }, + { + "id": "lean_workbook_30765", + "source": "lean-workbook-proofs", + "accepted": true, + "reasons": [], + "diag": { + "worker": 4, + "n_messages": 0, + "axioms": [ + "propext", + "Quot.sound", + "Classical.choice" + ], + "foreign": [] + }, + "elapsed_s": 1.21502417139709 + }, + { + "id": "lean_workbook_30766", + "source": "lean-workbook-proofs", + "accepted": true, + "reasons": [], + "diag": { + "worker": 1, + "n_messages": 2, + "axioms": [ + "propext", + "Quot.sound", + "Classical.choice" + ], + "foreign": [] + }, + "elapsed_s": 1.2838669931516051 + }, + { + "id": "lean_workbook_30767", + "source": "lean-workbook-proofs", + "accepted": true, + "reasons": [], + "diag": { + "worker": 4, + "n_messages": 0, + "axioms": [ + "propext", + "Quot.sound", + "Classical.choice" + ], + "foreign": [] + }, + "elapsed_s": 2.627797936089337 + }, + { + "id": "lean_workbook_30771", + "source": "lean-workbook-proofs", + "accepted": true, + "reasons": [], + "diag": { + "worker": 1, + "n_messages": 0, + "axioms": [ + "propext", + "Quot.sound", + "Classical.choice" + ], + "foreign": [] + }, + "elapsed_s": 2.4530225517228246 + }, + { + "id": "lean_workbook_30773", + "source": "lean-workbook-proofs", + "accepted": true, + "reasons": [], + "diag": { + "worker": 6, + "n_messages": 0, + "axioms": [ + "propext", + "Quot.sound", + "Classical.choice" + ], + "foreign": [] + }, + "elapsed_s": 2.6735387044027448 + }, + { + "id": "lean_workbook_30777", + "source": "lean-workbook-proofs", + "accepted": true, + "reasons": [], + "diag": { + "worker": 1, + "n_messages": 3, + "axioms": [ + "propext", + "Quot.sound", + "Classical.choice" + ], + "foreign": [] + }, + "elapsed_s": 0.7708664946258068 + }, + { + "id": "lean_workbook_30781", + "source": "lean-workbook-proofs", + "accepted": true, + "reasons": [], + "diag": { + "worker": 4, + "n_messages": 0, + "axioms": [ + "propext", + "Quot.sound", + "Classical.choice" + ], + "foreign": [] + }, + "elapsed_s": 1.3082255059853196 + }, + { + "id": "lean_workbook_30782", + "source": "lean-workbook-proofs", + "accepted": true, + "reasons": [], + "diag": { + "worker": 6, + "n_messages": 1, + "axioms": [ + "propext", + "Quot.sound", + "Classical.choice" + ], + "foreign": [] + }, + "elapsed_s": 0.9032308263704181 + }, + { + "id": "lean_workbook_30784", + "source": "lean-workbook-proofs", + "accepted": true, + "reasons": [], + "diag": { + "worker": 1, + "n_messages": 2, + "axioms": [ + "propext", + "Quot.sound", + "Classical.choice" + ], + "foreign": [] + }, + "elapsed_s": 1.4073052499443293 + }, + { + "id": "lean_workbook_30786", + "source": "lean-workbook-proofs", + "accepted": true, + "reasons": [], + "diag": { + "worker": 4, + "n_messages": 1, + "axioms": [ + "propext", + "Quot.sound", + "Classical.choice" + ], + "foreign": [] + }, + "elapsed_s": 1.8148101661354303 + }, + { + "id": "lean_workbook_30787", + "source": "lean-workbook-proofs", + "accepted": true, + "reasons": [], + "diag": { + "worker": 6, + "n_messages": 0, + "axioms": [ + "propext", + "Quot.sound", + "Classical.choice" + ], + "foreign": [] + }, + "elapsed_s": 3.4747072011232376 + }, + { + "id": "lean_workbook_30790", + "source": "lean-workbook-proofs", + "accepted": true, + "reasons": [], + "diag": { + "worker": 1, + "n_messages": 1, + "axioms": [ + "propext", + "Quot.sound", + "Classical.choice" + ], + "foreign": [] + }, + "elapsed_s": 13.532466862350702 + }, + { + "id": "lean_workbook_30791", + "source": "lean-workbook-proofs", + "accepted": true, + "reasons": [], + "diag": { + "worker": 4, + "n_messages": 0, + "axioms": [ + "propext", + "Quot.sound", + "Classical.choice" + ], + "foreign": [] + }, + "elapsed_s": 3.9896129490807652 + }, + { + "id": "lean_workbook_30800", + "source": "lean-workbook-proofs", + "accepted": true, + "reasons": [], + "diag": { + "worker": 6, + "n_messages": 0, + "axioms": [ + "propext", + "Quot.sound", + "Classical.choice" + ], + "foreign": [] + }, + "elapsed_s": 28.036835743114352 + }, + { + "id": "lean_workbook_30819", + "source": "lean-workbook-proofs", + "accepted": true, + "reasons": [], + "diag": { + "worker": 5, + "n_messages": 13, + "axioms": [ + "propext", + "Quot.sound", + "Classical.choice" + ], + "foreign": [] + }, + "elapsed_s": 0.8098775381222367 + }, + { + "id": "lean_workbook_30820", + "source": "lean-workbook-proofs", + "accepted": true, + "reasons": [], + "diag": { + "worker": 5, + "n_messages": 1, + "axioms": [ + "propext", + "Quot.sound", + "Classical.choice" + ], + "foreign": [] + }, + "elapsed_s": 3.642616624943912 + }, + { + "id": "lean_workbook_30821", + "source": "lean-workbook-proofs", + "accepted": true, + "reasons": [], + "diag": { + "worker": 4, + "n_messages": 4, + "axioms": [ + "propext", + "Quot.sound", + "Classical.choice" + ], + "foreign": [] + }, + "elapsed_s": 1.238724815659225 + }, + { + "id": "lean_workbook_50999", + "source": "lean-workbook-proofs", + "accepted": true, + "reasons": [], + "diag": { + "worker": 4, + "n_messages": 1, + "axioms": [ + "propext", + "Quot.sound", + "Classical.choice" + ], + "foreign": [] + }, + "elapsed_s": 5.047453400678933 + }, + { + "id": "lean_workbook_51005", + "source": "lean-workbook-proofs", + "accepted": true, + "reasons": [], + "diag": { + "worker": 5, + "n_messages": 0, + "axioms": [ + "propext", + "Quot.sound", + "Classical.choice" + ], + "foreign": [] + }, + "elapsed_s": 11.186856443993747 + }, + { + "id": "lean_workbook_51009", + "source": "lean-workbook-proofs", + "accepted": true, + "reasons": [], + "diag": { + "worker": 7, + "n_messages": 0, + "axioms": [ + "propext", + "Quot.sound", + "Classical.choice" + ], + "foreign": [] + }, + "elapsed_s": 115.49580179527402 + }, + { + "id": "lean_workbook_51015", + "source": "lean-workbook-proofs", + "accepted": true, + "reasons": [], + "diag": { + "worker": 4, + "n_messages": 0, + "axioms": [ + "propext", + "Quot.sound", + "Classical.choice" + ], + "foreign": [] + }, + "elapsed_s": 0.9756413120776415 + }, + { + "id": "lean_workbook_51019", + "source": "lean-workbook-proofs", + "accepted": true, + "reasons": [], + "diag": { + "worker": 4, + "n_messages": 2, + "axioms": [ + "propext" + ], + "foreign": [] + }, + "elapsed_s": 0.0894742589443922 + }, + { + "id": "lean_workbook_51020", + "source": "lean-workbook-proofs", + "accepted": true, + "reasons": [], + "diag": { + "worker": 4, + "n_messages": 0, + "axioms": [ + "propext", + "Quot.sound", + "Classical.choice" + ], + "foreign": [] + }, + "elapsed_s": 6.852308536879718 + }, + { + "id": "lean_workbook_51024", + "source": "lean-workbook-proofs", + "accepted": true, + "reasons": [], + "diag": { + "worker": 1, + "n_messages": 1, + "axioms": [ + "propext", + "Quot.sound", + "Classical.choice" + ], + "foreign": [] + }, + "elapsed_s": 1.5106780137866735 + }, + { + "id": "lean_workbook_51026", + "source": "lean-workbook-proofs", + "accepted": true, + "reasons": [], + "diag": { + "worker": 1, + "n_messages": 0, + "axioms": [ + "propext" + ], + "foreign": [] + }, + "elapsed_s": 0.17167649045586586 + }, + { + "id": "lean_workbook_51029", + "source": "lean-workbook-proofs", + "accepted": true, + "reasons": [], + "diag": { + "worker": 1, + "n_messages": 0, + "axioms": [ + "propext", + "Quot.sound", + "Classical.choice" + ], + "foreign": [] + }, + "elapsed_s": 2.2348853163421154 + }, + { + "id": "lean_workbook_51030", + "source": "lean-workbook-proofs", + "accepted": true, + "reasons": [], + "diag": { + "worker": 2, + "n_messages": 0, + "axioms": [ + "propext", + "Quot.sound", + "Classical.choice" + ], + "foreign": [] + }, + "elapsed_s": 0.7294852361083031 + }, + { + "id": "lean_workbook_51034", + "source": "lean-workbook-proofs", + "accepted": true, + "reasons": [], + "diag": { + "worker": 1, + "n_messages": 0, + "axioms": [ + "propext", + "Quot.sound", + "Classical.choice" + ], + "foreign": [] + }, + "elapsed_s": 3.624804857186973 + }, + { + "id": "lean_workbook_51037", + "source": "lean-workbook-proofs", + "accepted": true, + "reasons": [], + "diag": { + "worker": 2, + "n_messages": 10, + "axioms": [ + "propext", + "Quot.sound", + "Classical.choice" + ], + "foreign": [] + }, + "elapsed_s": 10.442804332822561 + }, + { + "id": "lean_workbook_51038", + "source": "lean-workbook-proofs", + "accepted": true, + "reasons": [], + "diag": { + "worker": 4, + "n_messages": 1, + "axioms": [ + "propext", + "Quot.sound", + "Classical.choice" + ], + "foreign": [] + }, + "elapsed_s": 14.887170376256108 + }, + { + "id": "lean_workbook_5104", + "source": "lean-workbook-proofs", + "accepted": true, + "reasons": [], + "diag": { + "worker": 5, + "n_messages": 0, + "axioms": [ + "propext", + "Quot.sound", + "Classical.choice" + ], + "foreign": [] + }, + "elapsed_s": 31.79560344759375 + }, + { + "id": "lean_workbook_51049", + "source": "lean-workbook-proofs", + "accepted": true, + "reasons": [], + "diag": { + "worker": 1, + "n_messages": 4, + "axioms": [ + "propext", + "Quot.sound", + "Classical.choice" + ], + "foreign": [] + }, + "elapsed_s": 2.482481217943132 + }, + { + "id": "lean_workbook_5105", + "source": "lean-workbook-proofs", + "accepted": true, + "reasons": [], + "diag": { + "worker": 1, + "n_messages": 1, + "axioms": [ + "propext", + "Quot.sound" + ], + "foreign": [] + }, + "elapsed_s": 0.08736089617013931 + }, + { + "id": "lean_workbook_51052", + "source": "lean-workbook-proofs", + "accepted": true, + "reasons": [], + "diag": { + "worker": 1, + "n_messages": 0, + "axioms": [ + "propext", + "Quot.sound", + "Classical.choice" + ], + "foreign": [] + }, + "elapsed_s": 1.9210750935599208 + }, + { + "id": "lean_workbook_51059", + "source": "lean-workbook-proofs", + "accepted": true, + "reasons": [], + "diag": { + "worker": 1, + "n_messages": 0, + "axioms": [ + "propext", + "Quot.sound", + "Classical.choice" + ], + "foreign": [] + }, + "elapsed_s": 4.657105605117977 + }, + { + "id": "lean_workbook_51062", + "source": "lean-workbook-proofs", + "accepted": true, + "reasons": [], + "diag": { + "worker": 2, + "n_messages": 0, + "axioms": [ + "propext", + "Quot.sound", + "Classical.choice" + ], + "foreign": [] + }, + "elapsed_s": 14.137669442221522 + }, + { + "id": "lean_workbook_51065", + "source": "lean-workbook-proofs", + "accepted": true, + "reasons": [], + "diag": { + "worker": 1, + "n_messages": 0, + "axioms": [ + "propext", + "Quot.sound", + "Classical.choice" + ], + "foreign": [] + }, + "elapsed_s": 54.71976857446134 + }, + { + "id": "lean_workbook_51069", + "source": "lean-workbook-proofs", + "accepted": true, + "reasons": [], + "diag": { + "worker": 6, + "n_messages": 2, + "axioms": [ + "propext", + "Quot.sound", + "Classical.choice" + ], + "foreign": [] + }, + "elapsed_s": 4.520684037357569 + }, + { + "id": "lean_workbook_51077", + "source": "lean-workbook-proofs", + "accepted": true, + "reasons": [], + "diag": { + "worker": 4, + "n_messages": 0, + "axioms": [ + "propext", + "Quot.sound", + "Classical.choice" + ], + "foreign": [] + }, + "elapsed_s": 3.6766891721636057 + }, + { + "id": "lean_workbook_51088", + "source": "lean-workbook-proofs", + "accepted": false, + "reasons": [ + "timeout" + ], + "diag": { + "detail": "no response within 179.4s" + }, + "elapsed_s": 180.26452291756868 + }, + { + "id": "lean_workbook_51090", + "source": "lean-workbook-proofs", + "accepted": true, + "reasons": [], + "diag": { + "worker": 4, + "n_messages": 0, + "axioms": [ + "propext", + "Quot.sound", + "Classical.choice" + ], + "foreign": [] + }, + "elapsed_s": 2.723732501268387 + }, + { + "id": "lean_workbook_51091", + "source": "lean-workbook-proofs", + "accepted": true, + "reasons": [], + "diag": { + "worker": 4, + "n_messages": 2, + "axioms": [ + "propext", + "Quot.sound", + "Classical.choice" + ], + "foreign": [] + }, + "elapsed_s": 1.9549352079629898 + }, + { + "id": "lean_workbook_plus_20026", + "source": "lean-workbook-proofs", + "accepted": true, + "reasons": [], + "diag": { + "worker": 4, + "n_messages": 0, + "axioms": [ + "propext", + "Quot.sound", + "Classical.choice" + ], + "foreign": [] + }, + "elapsed_s": 2.9317699586972594 + }, + { + "id": "lean_workbook_plus_20027", + "source": "lean-workbook-proofs", + "accepted": true, + "reasons": [], + "diag": { + "worker": 2, + "n_messages": 0, + "axioms": [ + "propext", + "Quot.sound", + "Classical.choice" + ], + "foreign": [] + }, + "elapsed_s": 5.908241213299334 + }, + { + "id": "lean_workbook_plus_20028", + "source": "lean-workbook-proofs", + "accepted": true, + "reasons": [], + "diag": { + "worker": 4, + "n_messages": 0, + "axioms": [ + "propext", + "Quot.sound", + "Classical.choice" + ], + "foreign": [] + }, + "elapsed_s": 3.766408543102443 + }, + { + "id": "lean_workbook_plus_20030", + "source": "lean-workbook-proofs", + "accepted": true, + "reasons": [], + "diag": { + "worker": 3, + "n_messages": 4, + "axioms": [ + "propext" + ], + "foreign": [] + }, + "elapsed_s": 0.13343923911452293 + }, + { + "id": "lean_workbook_plus_20032", + "source": "lean-workbook-proofs", + "accepted": true, + "reasons": [], + "diag": { + "worker": 3, + "n_messages": 0, + "axioms": [ + "propext", + "Quot.sound" + ], + "foreign": [] + }, + "elapsed_s": 0.8437133766710758 + }, + { + "id": "lean_workbook_plus_20046", + "source": "lean-workbook-proofs", + "accepted": true, + "reasons": [], + "diag": { + "worker": 3, + "n_messages": 0, + "axioms": [ + "propext", + "Quot.sound", + "Classical.choice" + ], + "foreign": [] + }, + "elapsed_s": 3.796980796381831 + }, + { + "id": "lean_workbook_plus_20056", + "source": "lean-workbook-proofs", + "accepted": true, + "reasons": [], + "diag": { + "worker": 2, + "n_messages": 1, + "axioms": [ + "propext" + ], + "foreign": [] + }, + "elapsed_s": 0.25815323274582624 + }, + { + "id": "lean_workbook_plus_20061", + "source": "lean-workbook-proofs", + "accepted": true, + "reasons": [], + "diag": { + "worker": 2, + "n_messages": 0, + "axioms": [ + "propext", + "Quot.sound", + "Classical.choice" + ], + "foreign": [] + }, + "elapsed_s": 5.401492465287447 + }, + { + "id": "lean_workbook_plus_20066", + "source": "lean-workbook-proofs", + "accepted": true, + "reasons": [], + "diag": { + "worker": 4, + "n_messages": 0, + "axioms": [ + "propext", + "Quot.sound", + "Classical.choice" + ], + "foreign": [] + }, + "elapsed_s": 1.8349946020171046 + }, + { + "id": "lean_workbook_plus_20074", + "source": "lean-workbook-proofs", + "accepted": true, + "reasons": [], + "diag": { + "worker": 3, + "n_messages": 0, + "axioms": [ + "propext", + "Quot.sound", + "Classical.choice" + ], + "foreign": [] + }, + "elapsed_s": 4.707565409131348 + }, + { + "id": "lean_workbook_plus_20076", + "source": "lean-workbook-proofs", + "accepted": true, + "reasons": [], + "diag": { + "worker": 4, + "n_messages": 2, + "axioms": [ + "propext", + "Quot.sound", + "Classical.choice" + ], + "foreign": [] + }, + "elapsed_s": 7.920165207237005 + }, + { + "id": "lean_workbook_plus_20083", + "source": "lean-workbook-proofs", + "accepted": true, + "reasons": [], + "diag": { + "worker": 5, + "n_messages": 0, + "axioms": [ + "propext", + "Quot.sound", + "Classical.choice" + ], + "foreign": [] + }, + "elapsed_s": 9.704303331673145 + }, + { + "id": "lean_workbook_plus_20095", + "source": "lean-workbook-proofs", + "accepted": true, + "reasons": [], + "diag": { + "worker": 2, + "n_messages": 0, + "axioms": [ + "propext", + "Quot.sound", + "Classical.choice" + ], + "foreign": [] + }, + "elapsed_s": 15.169009679928422 + }, + { + "id": "lean_workbook_plus_20097", + "source": "lean-workbook-proofs", + "accepted": true, + "reasons": [], + "diag": { + "worker": 3, + "n_messages": 0, + "axioms": [ + "propext", + "Quot.sound", + "Classical.choice" + ], + "foreign": [] + }, + "elapsed_s": 15.764912374317646 + }, + { + "id": "lean_workbook_plus_20099", + "source": "lean-workbook-proofs", + "accepted": true, + "reasons": [], + "diag": { + "worker": 0, + "n_messages": 0, + "axioms": [ + "propext", + "Quot.sound", + "Classical.choice" + ], + "foreign": [] + }, + "elapsed_s": 0.959578413516283 + }, + { + "id": "lean_workbook_plus_20105", + "source": "lean-workbook-proofs", + "accepted": true, + "reasons": [], + "diag": { + "worker": 0, + "n_messages": 0, + "axioms": [ + "propext", + "Quot.sound", + "Classical.choice" + ], + "foreign": [] + }, + "elapsed_s": 48.88171361107379 + }, + { + "id": "lean_workbook_plus_20111", + "source": "lean-workbook-proofs", + "accepted": true, + "reasons": [], + "diag": { + "worker": 4, + "n_messages": 0, + "axioms": [ + "propext", + "Quot.sound", + "Classical.choice" + ], + "foreign": [] + }, + "elapsed_s": 9.382818650454283 + }, + { + "id": "lean_workbook_plus_20112", + "source": "lean-workbook-proofs", + "accepted": true, + "reasons": [], + "diag": { + "worker": 5, + "n_messages": 1, + "axioms": [ + "propext", + "Quot.sound", + "Classical.choice" + ], + "foreign": [] + }, + "elapsed_s": 25.72436468116939 + }, + { + "id": "lean_workbook_plus_20113", + "source": "lean-workbook-proofs", + "accepted": true, + "reasons": [], + "diag": { + "worker": 4, + "n_messages": 4, + "axioms": [ + "propext", + "Quot.sound", + "Classical.choice" + ], + "foreign": [] + }, + "elapsed_s": 2.3634372912347317 + }, + { + "id": "lean_workbook_plus_20118", + "source": "lean-workbook-proofs", + "accepted": true, + "reasons": [], + "diag": { + "worker": 2, + "n_messages": 0, + "axioms": [ + "propext", + "Quot.sound", + "Classical.choice" + ], + "foreign": [] + }, + "elapsed_s": 21.296498339623213 + }, + { + "id": "lean_workbook_plus_2013", + "source": "lean-workbook-proofs", + "accepted": true, + "reasons": [], + "diag": { + "worker": 4, + "n_messages": 0, + "axioms": [ + "propext", + "Quot.sound", + "Classical.choice" + ], + "foreign": [] + }, + "elapsed_s": 77.67213509697467 + }, + { + "id": "lean_workbook_plus_20135", + "source": "lean-workbook-proofs", + "accepted": true, + "reasons": [], + "diag": { + "worker": 3, + "n_messages": 0, + "axioms": [ + "propext", + "Quot.sound", + "Classical.choice" + ], + "foreign": [] + }, + "elapsed_s": 5.0602683229371905 + }, + { + "id": "lean_workbook_plus_20138", + "source": "lean-workbook-proofs", + "accepted": true, + "reasons": [], + "diag": { + "worker": 3, + "n_messages": 0, + "axioms": [ + "propext", + "Quot.sound", + "Classical.choice" + ], + "foreign": [] + }, + "elapsed_s": 77.70804452523589 + }, + { + "id": "lean_workbook_plus_20139", + "source": "lean-workbook-proofs", + "accepted": true, + "reasons": [], + "diag": { + "worker": 1, + "n_messages": 0, + "axioms": [ + "propext", + "Quot.sound", + "Classical.choice" + ], + "foreign": [] + }, + "elapsed_s": 1.8070192746818066 + }, + { + "id": "lean_workbook_plus_20142", + "source": "lean-workbook-proofs", + "accepted": true, + "reasons": [], + "diag": { + "worker": 1, + "n_messages": 3, + "axioms": [ + "propext" + ], + "foreign": [] + }, + "elapsed_s": 0.07701690401881933 + }, + { + "id": "lean_workbook_plus_42103", + "source": "lean-workbook-proofs", + "accepted": true, + "reasons": [], + "diag": { + "worker": 1, + "n_messages": 2, + "axioms": [ + "propext", + "Quot.sound", + "Classical.choice" + ], + "foreign": [] + }, + "elapsed_s": 1.5095659587532282 + }, + { + "id": "lean_workbook_plus_42105", + "source": "lean-workbook-proofs", + "accepted": true, + "reasons": [], + "diag": { + "worker": 5, + "n_messages": 1, + "axioms": [ + "propext", + "Quot.sound", + "Classical.choice" + ], + "foreign": [] + }, + "elapsed_s": 43.602924023754895 + }, + { + "id": "lean_workbook_plus_42107", + "source": "lean-workbook-proofs", + "accepted": true, + "reasons": [], + "diag": { + "worker": 1, + "n_messages": 1, + "axioms": [ + "propext", + "Quot.sound", + "Classical.choice" + ], + "foreign": [] + }, + "elapsed_s": 0.9901053067296743 + }, + { + "id": "lean_workbook_plus_42117", + "source": "lean-workbook-proofs", + "accepted": true, + "reasons": [], + "diag": { + "worker": 1, + "n_messages": 0, + "axioms": [ + "propext", + "Quot.sound", + "Classical.choice" + ], + "foreign": [] + }, + "elapsed_s": 5.778251218609512 + }, + { + "id": "lean_workbook_plus_42120", + "source": "lean-workbook-proofs", + "accepted": true, + "reasons": [], + "diag": { + "worker": 2, + "n_messages": 1, + "axioms": [ + "propext", + "Quot.sound", + "Classical.choice" + ], + "foreign": [] + }, + "elapsed_s": 5.3884611474350095 + }, + { + "id": "lean_workbook_plus_42122", + "source": "lean-workbook-proofs", + "accepted": true, + "reasons": [], + "diag": { + "worker": 1, + "n_messages": 1, + "axioms": [ + "propext", + "Quot.sound" + ], + "foreign": [] + }, + "elapsed_s": 0.45164875872433186 + }, + { + "id": "lean_workbook_plus_42126", + "source": "lean-workbook-proofs", + "accepted": true, + "reasons": [], + "diag": { + "worker": 1, + "n_messages": 0, + "axioms": [ + "propext", + "Quot.sound", + "Classical.choice" + ], + "foreign": [] + }, + "elapsed_s": 33.254596064798534 + }, + { + "id": "lean_workbook_plus_42133", + "source": "lean-workbook-proofs", + "accepted": true, + "reasons": [], + "diag": { + "worker": 2, + "n_messages": 0, + "axioms": [ + "propext" + ], + "foreign": [] + }, + "elapsed_s": 0.1695808209478855 + }, + { + "id": "lean_workbook_plus_42142", + "source": "lean-workbook-proofs", + "accepted": true, + "reasons": [], + "diag": { + "worker": 2, + "n_messages": 2, + "axioms": [ + "propext", + "Quot.sound", + "Classical.choice" + ], + "foreign": [] + }, + "elapsed_s": 1.6294601922854781 + }, + { + "id": "lean_workbook_plus_42144", + "source": "lean-workbook-proofs", + "accepted": true, + "reasons": [], + "diag": { + "worker": 2, + "n_messages": 0, + "axioms": [ + "propext", + "Quot.sound", + "Classical.choice" + ], + "foreign": [] + }, + "elapsed_s": 2.227940507233143 + }, + { + "id": "lean_workbook_plus_42146", + "source": "lean-workbook-proofs", + "accepted": true, + "reasons": [], + "diag": { + "worker": 2, + "n_messages": 0, + "axioms": [ + "propext" + ], + "foreign": [] + }, + "elapsed_s": 0.1276031481102109 + }, + { + "id": "lean_workbook_plus_42147", + "source": "lean-workbook-proofs", + "accepted": true, + "reasons": [], + "diag": { + "worker": 2, + "n_messages": 1, + "axioms": [ + "propext" + ], + "foreign": [] + }, + "elapsed_s": 0.3826118977740407 + }, + { + "id": "lean_workbook_plus_42148", + "source": "lean-workbook-proofs", + "accepted": true, + "reasons": [], + "diag": { + "worker": 2, + "n_messages": 0, + "axioms": [ + "propext", + "Quot.sound", + "Classical.choice" + ], + "foreign": [] + }, + "elapsed_s": 2.3638201085850596 + }, + { + "id": "lean_workbook_plus_42153", + "source": "lean-workbook-proofs", + "accepted": true, + "reasons": [], + "diag": { + "worker": 2, + "n_messages": 3, + "axioms": [ + "propext", + "Quot.sound", + "Classical.choice" + ], + "foreign": [] + }, + "elapsed_s": 1.0545912384986877 + }, + { + "id": "lean_workbook_plus_42155", + "source": "lean-workbook-proofs", + "accepted": true, + "reasons": [], + "diag": { + "worker": 2, + "n_messages": 0, + "axioms": [ + "propext", + "Quot.sound", + "Classical.choice" + ], + "foreign": [] + }, + "elapsed_s": 0.7429696535691619 + }, + { + "id": "lean_workbook_plus_42159", + "source": "lean-workbook-proofs", + "accepted": true, + "reasons": [], + "diag": { + "worker": 2, + "n_messages": 0, + "axioms": [ + "propext", + "Quot.sound", + "Classical.choice" + ], + "foreign": [] + }, + "elapsed_s": 0.440457790158689 + }, + { + "id": "lean_workbook_plus_42161", + "source": "lean-workbook-proofs", + "accepted": true, + "reasons": [], + "diag": { + "worker": 2, + "n_messages": 0, + "axioms": [ + "propext" + ], + "foreign": [] + }, + "elapsed_s": 0.1427657101303339 + }, + { + "id": "lean_workbook_plus_42163", + "source": "lean-workbook-proofs", + "accepted": true, + "reasons": [], + "diag": { + "worker": 2, + "n_messages": 1, + "axioms": [ + "propext", + "Quot.sound", + "Classical.choice" + ], + "foreign": [] + }, + "elapsed_s": 13.735782880336046 + }, + { + "id": "lean_workbook_plus_42167", + "source": "lean-workbook-proofs", + "accepted": true, + "reasons": [], + "diag": { + "worker": 0, + "n_messages": 0, + "axioms": [ + "propext", + "Quot.sound", + "Classical.choice" + ], + "foreign": [] + }, + "elapsed_s": 20.011124591343105 + }, + { + "id": "lean_workbook_plus_42173", + "source": "lean-workbook-proofs", + "accepted": true, + "reasons": [], + "diag": { + "worker": 2, + "n_messages": 1, + "axioms": [ + "propext", + "Quot.sound", + "Classical.choice" + ], + "foreign": [] + }, + "elapsed_s": 1.9659304870292544 + }, + { + "id": "lean_workbook_plus_42176", + "source": "lean-workbook-proofs", + "accepted": true, + "reasons": [], + "diag": { + "worker": 2, + "n_messages": 4, + "axioms": [ + "propext", + "Quot.sound", + "Classical.choice" + ], + "foreign": [] + }, + "elapsed_s": 4.380365560762584 + }, + { + "id": "lean_workbook_plus_4218", + "source": "lean-workbook-proofs", + "accepted": true, + "reasons": [], + "diag": { + "worker": 7, + "n_messages": 0, + "axioms": [ + "propext", + "Quot.sound", + "Classical.choice" + ], + "foreign": [] + }, + "elapsed_s": 158.64453719183803 + }, + { + "id": "lean_workbook_plus_42185", + "source": "lean-workbook-proofs", + "accepted": true, + "reasons": [], + "diag": { + "worker": 0, + "n_messages": 0, + "axioms": [ + "propext", + "Quot.sound", + "Classical.choice" + ], + "foreign": [] + }, + "elapsed_s": 2.07715508248657 + }, + { + "id": "lean_workbook_plus_42190", + "source": "lean-workbook-proofs", + "accepted": true, + "reasons": [], + "diag": { + "worker": 2, + "n_messages": 0, + "axioms": [ + "propext", + "Quot.sound", + "Classical.choice" + ], + "foreign": [] + }, + "elapsed_s": 9.323512725532055 + }, + { + "id": "lean_workbook_plus_42197", + "source": "lean-workbook-proofs", + "accepted": true, + "reasons": [], + "diag": { + "worker": 0, + "n_messages": 0, + "axioms": [ + "propext", + "Quot.sound", + "Classical.choice" + ], + "foreign": [] + }, + "elapsed_s": 11.70318409241736 + }, + { + "id": "lean_workbook_plus_64003", + "source": "lean-workbook-proofs", + "accepted": true, + "reasons": [], + "diag": { + "worker": 1, + "n_messages": 0, + "axioms": [ + "propext", + "Quot.sound", + "Classical.choice" + ], + "foreign": [] + }, + "elapsed_s": 6.509756951592863 + }, + { + "id": "lean_workbook_plus_64007", + "source": "lean-workbook-proofs", + "accepted": true, + "reasons": [], + "diag": { + "worker": 5, + "n_messages": 0, + "axioms": [ + "propext", + "Quot.sound", + "Classical.choice" + ], + "foreign": [] + }, + "elapsed_s": 2.2650191029533744 + }, + { + "id": "lean_workbook_plus_6401", + "source": "lean-workbook-proofs", + "accepted": true, + "reasons": [], + "diag": { + "worker": 5, + "n_messages": 1, + "axioms": [ + "propext", + "Quot.sound", + "Classical.choice" + ], + "foreign": [] + }, + "elapsed_s": 2.836946143768728 + }, + { + "id": "lean_workbook_plus_64014", + "source": "lean-workbook-proofs", + "accepted": true, + "reasons": [], + "diag": { + "worker": 2, + "n_messages": 0, + "axioms": [ + "propext", + "Quot.sound", + "Classical.choice" + ], + "foreign": [] + }, + "elapsed_s": 2.8966817297041416 + }, + { + "id": "lean_workbook_plus_64019", + "source": "lean-workbook-proofs", + "accepted": true, + "reasons": [], + "diag": { + "worker": 1, + "n_messages": 0, + "axioms": [ + "propext", + "Quot.sound", + "Classical.choice" + ], + "foreign": [] + }, + "elapsed_s": 71.99639628920704 + }, + { + "id": "lean_workbook_plus_64020", + "source": "lean-workbook-proofs", + "accepted": true, + "reasons": [], + "diag": { + "worker": 5, + "n_messages": 1, + "axioms": [ + "propext", + "Quot.sound" + ], + "foreign": [] + }, + "elapsed_s": 1.4403014983981848 + }, + { + "id": "lean_workbook_plus_64022", + "source": "lean-workbook-proofs", + "accepted": true, + "reasons": [], + "diag": { + "worker": 5, + "n_messages": 2, + "axioms": [ + "propext" + ], + "foreign": [] + }, + "elapsed_s": 0.17738376278430223 + }, + { + "id": "lean_workbook_plus_64029", + "source": "lean-workbook-proofs", + "accepted": true, + "reasons": [], + "diag": { + "worker": 5, + "n_messages": 6, + "axioms": [ + "propext", + "Quot.sound", + "Classical.choice" + ], + "foreign": [] + }, + "elapsed_s": 1.4380856975913048 + }, + { + "id": "lean_workbook_plus_64044", + "source": "lean-workbook-proofs", + "accepted": true, + "reasons": [], + "diag": { + "worker": 2, + "n_messages": 1, + "axioms": [ + "propext", + "Quot.sound", + "Classical.choice" + ], + "foreign": [] + }, + "elapsed_s": 1.378081675618887 + }, + { + "id": "lean_workbook_plus_64047", + "source": "lean-workbook-proofs", + "accepted": true, + "reasons": [], + "diag": { + "worker": 5, + "n_messages": 0, + "axioms": [ + "propext", + "Quot.sound", + "Classical.choice" + ], + "foreign": [] + }, + "elapsed_s": 3.8715438256040215 + }, + { + "id": "lean_workbook_plus_64050", + "source": "lean-workbook-proofs", + "accepted": true, + "reasons": [], + "diag": { + "worker": 2, + "n_messages": 0, + "axioms": [ + "propext", + "Quot.sound", + "Classical.choice" + ], + "foreign": [] + }, + "elapsed_s": 19.726872603408992 + }, + { + "id": "lean_workbook_plus_6407", + "source": "lean-workbook-proofs", + "accepted": true, + "reasons": [], + "diag": { + "worker": 0, + "n_messages": 1, + "axioms": [ + "propext", + "Quot.sound", + "Classical.choice" + ], + "foreign": [] + }, + "elapsed_s": 1.7328647812828422 + }, + { + "id": "lean_workbook_plus_64070", + "source": "lean-workbook-proofs", + "accepted": true, + "reasons": [], + "diag": { + "worker": 0, + "n_messages": 0, + "axioms": [ + "propext", + "Quot.sound", + "Classical.choice" + ], + "foreign": [] + }, + "elapsed_s": 10.371187631040812 + }, + { + "id": "lean_workbook_plus_64078", + "source": "lean-workbook-proofs", + "accepted": true, + "reasons": [], + "diag": { + "worker": 5, + "n_messages": 1, + "axioms": [ + "propext", + "Quot.sound", + "Classical.choice" + ], + "foreign": [] + }, + "elapsed_s": 0.19889491237699986 + }, + { + "id": "lean_workbook_plus_64079", + "source": "lean-workbook-proofs", + "accepted": true, + "reasons": [], + "diag": { + "worker": 5, + "n_messages": 2, + "axioms": [ + "propext", + "Quot.sound", + "Classical.choice" + ], + "foreign": [] + }, + "elapsed_s": 35.21537778805941 + }, + { + "id": "lean_workbook_plus_64081", + "source": "lean-workbook-proofs", + "accepted": true, + "reasons": [], + "diag": { + "worker": 4, + "n_messages": 0, + "axioms": [ + "propext", + "Quot.sound", + "Classical.choice" + ], + "foreign": [] + }, + "elapsed_s": 1.6567185390740633 + }, + { + "id": "lean_workbook_plus_64084", + "source": "lean-workbook-proofs", + "accepted": true, + "reasons": [], + "diag": { + "worker": 4, + "n_messages": 5, + "axioms": [ + "propext", + "Quot.sound", + "Classical.choice" + ], + "foreign": [] + }, + "elapsed_s": 1.0626619039103389 + }, + { + "id": "lean_workbook_plus_6409", + "source": "lean-workbook-proofs", + "accepted": true, + "reasons": [], + "diag": { + "worker": 4, + "n_messages": 0, + "axioms": [ + "propext", + "Quot.sound", + "Classical.choice" + ], + "foreign": [] + }, + "elapsed_s": 0.9118623100221157 + }, + { + "id": "lean_workbook_plus_64095", + "source": "lean-workbook-proofs", + "accepted": true, + "reasons": [], + "diag": { + "worker": 0, + "n_messages": 4, + "axioms": [ + "propext", + "Quot.sound", + "Classical.choice" + ], + "foreign": [] + }, + "elapsed_s": 2.349280906841159 + }, + { + "id": "lean_workbook_plus_64100", + "source": "lean-workbook-proofs", + "accepted": true, + "reasons": [], + "diag": { + "worker": 4, + "n_messages": 1, + "axioms": [ + "propext", + "Quot.sound", + "Classical.choice" + ], + "foreign": [] + }, + "elapsed_s": 0.8026641495525837 + }, + { + "id": "lean_workbook_plus_64101", + "source": "lean-workbook-proofs", + "accepted": true, + "reasons": [], + "diag": { + "worker": 4, + "n_messages": 0, + "axioms": [ + "propext", + "Quot.sound", + "Classical.choice" + ], + "foreign": [] + }, + "elapsed_s": 1.1117532011121511 + }, + { + "id": "lean_workbook_plus_64103", + "source": "lean-workbook-proofs", + "accepted": true, + "reasons": [], + "diag": { + "worker": 0, + "n_messages": 1, + "axioms": [ + "propext" + ], + "foreign": [] + }, + "elapsed_s": 0.09527885913848877 + }, + { + "id": "lean_workbook_plus_64104", + "source": "lean-workbook-proofs", + "accepted": true, + "reasons": [], + "diag": { + "worker": 0, + "n_messages": 0, + "axioms": [ + "propext", + "Quot.sound", + "Classical.choice" + ], + "foreign": [] + }, + "elapsed_s": 1.0990447467193007 + }, + { + "id": "lean_workbook_plus_64105", + "source": "lean-workbook-proofs", + "accepted": true, + "reasons": [], + "diag": { + "worker": 3, + "n_messages": 9, + "axioms": [ + "propext", + "Quot.sound", + "Classical.choice" + ], + "foreign": [] + }, + "elapsed_s": 1.4291389277204871 + }, + { + "id": "rs_add_comm", + "source": "restated", + "accepted": true, + "reasons": [], + "diag": { + "worker": 4, + "n_messages": 0, + "axioms": [ + "propext", + "Quot.sound" + ], + "foreign": [] + }, + "elapsed_s": 0.10902609582990408 + }, + { + "id": "rs_sq_nonneg", + "source": "restated", + "accepted": true, + "reasons": [], + "diag": { + "worker": 4, + "n_messages": 0, + "axioms": [ + "propext", + "Quot.sound", + "Classical.choice" + ], + "foreign": [] + }, + "elapsed_s": 0.6219972241669893 + }, + { + "id": "rs_gauss", + "source": "restated", + "accepted": true, + "reasons": [], + "diag": { + "worker": 4, + "n_messages": 0, + "axioms": [ + "propext", + "Quot.sound" + ], + "foreign": [] + }, + "elapsed_s": 0.2108049364760518 + }, + { + "id": "rs_amgm", + "source": "restated", + "accepted": true, + "reasons": [], + "diag": { + "worker": 0, + "n_messages": 0, + "axioms": [ + "propext", + "Quot.sound", + "Classical.choice" + ], + "foreign": [] + }, + "elapsed_s": 1.4205228658393025 + }, + { + "id": "rs_dvd", + "source": "restated", + "accepted": true, + "reasons": [], + "diag": { + "worker": 4, + "n_messages": 0, + "axioms": [ + "propext", + "Quot.sound", + "Classical.choice" + ], + "foreign": [] + }, + "elapsed_s": 0.08598973229527473 + }, + { + "id": "rs_abs", + "source": "restated", + "accepted": true, + "reasons": [], + "diag": { + "worker": 4, + "n_messages": 0, + "axioms": [ + "propext", + "Quot.sound", + "Classical.choice" + ], + "foreign": [] + }, + "elapsed_s": 0.6476154765114188 + }, + { + "id": "rs_odd_sum", + "source": "restated", + "accepted": true, + "reasons": [], + "diag": { + "worker": 3, + "n_messages": 0, + "axioms": [ + "propext", + "Quot.sound" + ], + "foreign": [] + }, + "elapsed_s": 0.21710399258881807 + }, + { + "id": "rs_exists", + "source": "restated", + "accepted": true, + "reasons": [], + "diag": { + "worker": 3, + "n_messages": 0, + "axioms": [ + "propext" + ], + "foreign": [] + }, + "elapsed_s": 0.06294487789273262 + }, + { + "id": "rs_irrational_nat", + "source": "restated", + "accepted": true, + "reasons": [], + "diag": { + "worker": 3, + "n_messages": 0, + "axioms": [ + "propext", + "Quot.sound", + "Classical.choice" + ], + "foreign": [] + }, + "elapsed_s": 0.9776388639584184 + }, + { + "id": "rs_calc", + "source": "restated", + "accepted": true, + "reasons": [], + "diag": { + "worker": 4, + "n_messages": 0, + "axioms": [], + "foreign": [] + }, + "elapsed_s": 0.046826195903122425 + }, + { + "id": "rs_let_in_type", + "source": "restated", + "accepted": true, + "reasons": [], + "diag": { + "worker": 4, + "n_messages": 0, + "axioms": [], + "foreign": [] + }, + "elapsed_s": 0.03973446972668171 + }, + { + "id": "rs_comm_ring", + "source": "restated", + "accepted": true, + "reasons": [], + "diag": { + "worker": 4, + "n_messages": 0, + "axioms": [ + "propext" + ], + "foreign": [] + }, + "elapsed_s": 0.2081572925671935 + }, + { + "id": "rs_lemma_kw", + "source": "restated", + "accepted": true, + "reasons": [], + "diag": { + "worker": 4, + "n_messages": 0, + "axioms": [ + "propext", + "Quot.sound", + "Classical.choice" + ], + "foreign": [] + }, + "elapsed_s": 0.8627184815704823 + }, + { + "id": "rs_set_eq", + "source": "restated", + "accepted": true, + "reasons": [], + "diag": { + "worker": 0, + "n_messages": 0, + "axioms": [ + "propext", + "Quot.sound", + "Classical.choice" + ], + "foreign": [] + }, + "elapsed_s": 0.18310286477208138 + }, + { + "id": "rs_card_filter", + "source": "restated", + "accepted": true, + "reasons": [], + "diag": { + "worker": 0, + "n_messages": 0, + "axioms": [ + "propext", + "Quot.sound" + ], + "foreign": [] + }, + "elapsed_s": 0.16582784242928028 + }, + { + "id": "rs_word_in_comment_and_string", + "source": "restated", + "accepted": true, + "reasons": [], + "diag": { + "worker": 3, + "n_messages": 1, + "axioms": [ + "propext" + ], + "foreign": [] + }, + "elapsed_s": 0.053929729387164116 + }, + { + "id": "rs_nested_comment", + "source": "restated", + "accepted": true, + "reasons": [], + "diag": { + "worker": 3, + "n_messages": 0, + "axioms": [ + "propext" + ], + "foreign": [] + }, + "elapsed_s": 0.051016331650316715 + }, + { + "id": "rs_char_quote", + "source": "restated", + "accepted": true, + "reasons": [], + "diag": { + "worker": 0, + "n_messages": 1, + "axioms": [ + "propext" + ], + "foreign": [] + }, + "elapsed_s": 0.05531633272767067 + }, + { + "id": "rs_set_option_in", + "source": "restated", + "accepted": true, + "reasons": [], + "diag": { + "worker": 3, + "n_messages": 0, + "axioms": [ + "propext", + "Quot.sound", + "Classical.choice" + ], + "foreign": [] + }, + "elapsed_s": 1.0716808829456568 + }, + { + "id": "rs_rcases", + "source": "restated", + "accepted": true, + "reasons": [], + "diag": { + "worker": 0, + "n_messages": 0, + "axioms": [], + "foreign": [] + }, + "elapsed_s": 0.04064757190644741 + }, + { + "id": "rs_mod4", + "source": "restated", + "accepted": true, + "reasons": [], + "diag": { + "worker": 0, + "n_messages": 0, + "axioms": [ + "propext", + "Quot.sound" + ], + "foreign": [] + }, + "elapsed_s": 0.23279897402971983 + }, + { + "id": "rs_prime97", + "source": "restated", + "accepted": true, + "reasons": [], + "diag": { + "worker": 4, + "n_messages": 0, + "axioms": [ + "propext", + "Quot.sound", + "Classical.choice" + ], + "foreign": [] + }, + "elapsed_s": 0.17013403214514256 + }, + { + "id": "rs_helper_lemma", + "source": "restated", + "accepted": true, + "reasons": [], + "diag": { + "worker": 0, + "n_messages": 0, + "axioms": [ + "propext", + "Quot.sound", + "Classical.choice" + ], + "foreign": [] + }, + "elapsed_s": 1.0991019690409303 + }, + { + "id": "rs_exp_pos", + "source": "restated", + "accepted": true, + "reasons": [], + "diag": { + "worker": 4, + "n_messages": 0, + "axioms": [ + "propext", + "Quot.sound", + "Classical.choice" + ], + "foreign": [] + }, + "elapsed_s": 1.509533477947116 + }, + { + "id": "rs_by_contra", + "source": "restated", + "accepted": true, + "reasons": [], + "diag": { + "worker": 3, + "n_messages": 0, + "axioms": [ + "propext", + "Quot.sound", + "Classical.choice" + ], + "foreign": [] + }, + "elapsed_s": 1.25946472492069 + }, + { + "id": "rs_em", + "source": "restated", + "accepted": true, + "reasons": [], + "diag": { + "worker": 0, + "n_messages": 0, + "axioms": [ + "propext", + "Quot.sound", + "Classical.choice" + ], + "foreign": [] + }, + "elapsed_s": 0.04230682086199522 + }, + { + "id": "rs_funext", + "source": "restated", + "accepted": true, + "reasons": [], + "diag": { + "worker": 0, + "n_messages": 0, + "axioms": [ + "propext", + "Quot.sound" + ], + "foreign": [] + }, + "elapsed_s": 0.08743615634739399 + }, + { + "id": "rs_field_simp", + "source": "restated", + "accepted": true, + "reasons": [], + "diag": { + "worker": 0, + "n_messages": 0, + "axioms": [ + "propext", + "Quot.sound", + "Classical.choice" + ], + "foreign": [] + }, + "elapsed_s": 0.7925883373245597 + }, + { + "id": "rs_default_arg", + "source": "restated", + "accepted": true, + "reasons": [], + "diag": { + "worker": 4, + "n_messages": 0, + "axioms": [ + "propext", + "Quot.sound" + ], + "foreign": [] + }, + "elapsed_s": 0.1096957828849554 + }, + { + "id": "rs_structure_instance", + "source": "restated", + "accepted": true, + "reasons": [], + "diag": { + "worker": 4, + "n_messages": 0, + "axioms": [], + "foreign": [] + }, + "elapsed_s": 0.04694699868559837 + } + ], + "adversarial_results": [ + { + "id": "sorry_plain", + "category": "sorry", + "accepted": false, + "reasons": [ + "banned:sorry" + ], + "stage": "static", + "diag": {}, + "elapsed_s": 0.0003195973113179207, + "pool": "main", + "naive_compile_ok": true, + "semantic_only_accepted": false, + "semantic_only_reasons": [ + "sorry" + ] + }, + { + "id": "sorry_branch", + "category": "sorry", + "accepted": false, + "reasons": [ + "banned:sorry" + ], + "stage": "static", + "diag": {}, + "elapsed_s": 0.0003284178674221039, + "pool": "main", + "naive_compile_ok": true, + "semantic_only_accepted": false, + "semantic_only_reasons": [ + "sorry" + ] + }, + { + "id": "sorry_helper", + "category": "sorry", + "accepted": false, + "reasons": [ + "banned:sorry" + ], + "stage": "static", + "diag": {}, + "elapsed_s": 0.00021592527627944946, + "pool": "main", + "naive_compile_ok": true, + "semantic_only_accepted": false, + "semantic_only_reasons": [ + "sorry" + ] + }, + { + "id": "sorry_term", + "category": "sorry", + "accepted": false, + "reasons": [ + "banned:sorry" + ], + "stage": "static", + "diag": {}, + "elapsed_s": 0.00017273426055908203, + "pool": "main", + "naive_compile_ok": true, + "semantic_only_accepted": false, + "semantic_only_reasons": [ + "sorry" + ] + }, + { + "id": "sorryAx_direct", + "category": "sorry", + "accepted": false, + "reasons": [ + "banned:sorry" + ], + "stage": "static", + "diag": {}, + "elapsed_s": 0.00013204291462898254, + "pool": "main", + "naive_compile_ok": true, + "semantic_only_accepted": false, + "semantic_only_reasons": [ + "sorry" + ] + }, + { + "id": "sorry_after_char_quote", + "category": "sorry", + "accepted": false, + "reasons": [ + "banned:sorry" + ], + "stage": "static", + "diag": {}, + "elapsed_s": 0.0002706460654735565, + "pool": "main", + "naive_compile_ok": true, + "semantic_only_accepted": false, + "semantic_only_reasons": [ + "sorry" + ] + }, + { + "id": "sorry_after_comment_opener_string", + "category": "sorry", + "accepted": false, + "reasons": [ + "banned:sorry" + ], + "stage": "static", + "diag": {}, + "elapsed_s": 0.00025268644094467163, + "pool": "main", + "naive_compile_ok": true, + "semantic_only_accepted": false, + "semantic_only_reasons": [ + "sorry" + ] + }, + { + "id": "admit_plain", + "category": "admit", + "accepted": false, + "reasons": [ + "banned:sorry" + ], + "stage": "static", + "diag": {}, + "elapsed_s": 0.00012821238487958908, + "pool": "main", + "naive_compile_ok": true, + "semantic_only_accepted": false, + "semantic_only_reasons": [ + "sorry" + ] + }, + { + "id": "admit_branch", + "category": "admit", + "accepted": false, + "reasons": [ + "banned:sorry" + ], + "stage": "static", + "diag": {}, + "elapsed_s": 0.0001622634008526802, + "pool": "main", + "naive_compile_ok": true, + "semantic_only_accepted": false, + "semantic_only_reasons": [ + "sorry" + ] + }, + { + "id": "axiom_false", + "category": "extra_axiom", + "accepted": false, + "reasons": [ + "banned:axiom" + ], + "stage": "static", + "diag": {}, + "elapsed_s": 0.00027924589812755585, + "pool": "main", + "naive_compile_ok": true, + "semantic_only_accepted": false, + "semantic_only_reasons": [ + "axiom:bad" + ] + }, + { + "id": "axiom_claim", + "category": "extra_axiom", + "accepted": false, + "reasons": [ + "banned:axiom" + ], + "stage": "static", + "diag": {}, + "elapsed_s": 0.00013725273311138153, + "pool": "main", + "naive_compile_ok": true, + "semantic_only_accepted": false, + "semantic_only_reasons": [ + "axiom:claim" + ] + }, + { + "id": "axiom_polymorphic", + "category": "extra_axiom", + "accepted": false, + "reasons": [ + "banned:axiom" + ], + "stage": "static", + "diag": {}, + "elapsed_s": 0.00013331323862075806, + "pool": "main", + "naive_compile_ok": true, + "semantic_only_accepted": false, + "semantic_only_reasons": [ + "axiom:anything" + ] + }, + { + "id": "axiom_via_simpa", + "category": "extra_axiom", + "accepted": false, + "reasons": [ + "banned:axiom" + ], + "stage": "static", + "diag": {}, + "elapsed_s": 0.0002073841169476509, + "pool": "main", + "naive_compile_ok": true, + "semantic_only_accepted": false, + "semantic_only_reasons": [ + "axiom:bad2" + ] + }, + { + "id": "native_decide_tactic", + "category": "native_decide", + "accepted": false, + "reasons": [ + "banned:native_decide" + ], + "stage": "static", + "diag": {}, + "elapsed_s": 0.00020356476306915283, + "pool": "main", + "naive_compile_ok": true, + "semantic_only_accepted": false, + "semantic_only_reasons": [ + "axiom:Lean.ofReduceBool", + "axiom:Lean.trustCompiler" + ] + }, + { + "id": "native_decide_in_have", + "category": "native_decide", + "accepted": false, + "reasons": [ + "banned:native_decide" + ], + "stage": "static", + "diag": {}, + "elapsed_s": 0.0001534242182970047, + "pool": "main", + "naive_compile_ok": true, + "semantic_only_accepted": false, + "semantic_only_reasons": [ + "axiom:Lean.ofReduceBool", + "axiom:Lean.trustCompiler" + ] + }, + { + "id": "native_decide_in_helper_lemma", + "category": "native_decide", + "accepted": false, + "reasons": [ + "banned:native_decide" + ], + "stage": "static", + "diag": {}, + "elapsed_s": 0.00016764458268880844, + "pool": "main", + "naive_compile_ok": true, + "semantic_only_accepted": false, + "semantic_only_reasons": [ + "axiom:Lean.ofReduceBool", + "axiom:Lean.trustCompiler" + ] + }, + { + "id": "conclusion_changed", + "category": "changed_statement", + "accepted": false, + "reasons": [ + "statement_mismatch" + ], + "stage": "static", + "diag": {}, + "elapsed_s": 0.00019516516476869583, + "pool": "main", + "naive_compile_ok": true, + "semantic_only_accepted": false, + "semantic_only_reasons": [ + "statement_mismatch" + ] + }, + { + "id": "constant_changed", + "category": "changed_statement", + "accepted": false, + "reasons": [ + "statement_mismatch" + ], + "stage": "static", + "diag": {}, + "elapsed_s": 0.0002257348969578743, + "pool": "main", + "naive_compile_ok": true, + "semantic_only_accepted": false, + "semantic_only_reasons": [ + "statement_mismatch" + ] + }, + { + "id": "conclusion_trivialized", + "category": "changed_statement", + "accepted": false, + "reasons": [ + "statement_mismatch" + ], + "stage": "static", + "diag": {}, + "elapsed_s": 0.00012556370347738266, + "pool": "main", + "naive_compile_ok": true, + "semantic_only_accepted": false, + "semantic_only_reasons": [ + "statement_mismatch" + ] + }, + { + "id": "binder_order_swapped", + "category": "changed_statement", + "accepted": false, + "reasons": [ + "statement_mismatch" + ], + "stage": "static", + "diag": {}, + "elapsed_s": 0.0001634731888771057, + "pool": "main", + "naive_compile_ok": true, + "semantic_only_accepted": false, + "semantic_only_reasons": [ + "statement_mismatch" + ] + }, + { + "id": "statement_only_in_comment", + "category": "changed_statement", + "accepted": false, + "reasons": [ + "statement_mismatch" + ], + "stage": "static", + "diag": {}, + "elapsed_s": 7.829070091247559e-05, + "pool": "main", + "naive_compile_ok": true, + "semantic_only_accepted": false, + "semantic_only_reasons": [ + "statement_mismatch" + ] + }, + { + "id": "false_statement_made_true", + "category": "changed_statement", + "accepted": false, + "reasons": [ + "statement_mismatch" + ], + "stage": "static", + "diag": {}, + "elapsed_s": 0.00012079346925020218, + "pool": "main", + "naive_compile_ok": true, + "semantic_only_accepted": false, + "semantic_only_reasons": [ + "statement_mismatch" + ] + }, + { + "id": "extra_hypothesis_a_eq_b", + "category": "weakened_hypothesis", + "accepted": false, + "reasons": [ + "statement_mismatch" + ], + "stage": "static", + "diag": {}, + "elapsed_s": 0.00019154418259859085, + "pool": "main", + "naive_compile_ok": true, + "semantic_only_accepted": false, + "semantic_only_reasons": [ + "statement_mismatch" + ] + }, + { + "id": "hypothesis_false_added", + "category": "weakened_hypothesis", + "accepted": false, + "reasons": [ + "statement_mismatch" + ], + "stage": "static", + "diag": {}, + "elapsed_s": 0.00011706352233886719, + "pool": "main", + "naive_compile_ok": true, + "semantic_only_accepted": false, + "semantic_only_reasons": [ + "statement_mismatch" + ] + }, + { + "id": "hypothesis_strengthened", + "category": "weakened_hypothesis", + "accepted": false, + "reasons": [ + "statement_mismatch" + ], + "stage": "static", + "diag": {}, + "elapsed_s": 0.000125933438539505, + "pool": "main", + "naive_compile_ok": true, + "semantic_only_accepted": false, + "semantic_only_reasons": [ + "statement_mismatch" + ] + }, + { + "id": "instance_hole", + "category": "definition_hole", + "accepted": false, + "reasons": [ + "banned:instance" + ], + "stage": "static", + "diag": {}, + "elapsed_s": 0.00024369638413190842, + "pool": "main", + "naive_compile_ok": true, + "semantic_only_accepted": false, + "semantic_only_reasons": [ + "statement_mismatch" + ] + }, + { + "id": "variable_hole", + "category": "definition_hole", + "accepted": false, + "reasons": [ + "banned:variable" + ], + "stage": "static", + "diag": {}, + "elapsed_s": 0.00011912267655134201, + "pool": "main", + "naive_compile_ok": true, + "semantic_only_accepted": false, + "semantic_only_reasons": [ + "statement_mismatch" + ] + }, + { + "id": "variable_hole_hypothesis", + "category": "definition_hole", + "accepted": false, + "reasons": [ + "banned:variable" + ], + "stage": "static", + "diag": {}, + "elapsed_s": 0.00015163421630859375, + "pool": "main", + "naive_compile_ok": true, + "semantic_only_accepted": false, + "semantic_only_reasons": [ + "statement_mismatch" + ] + }, + { + "id": "def_shadow_hole", + "category": "definition_hole", + "accepted": false, + "reasons": [ + "banned:def", + "banned:local", + "banned:meta" + ], + "stage": "static", + "diag": {}, + "elapsed_s": 0.00020741485059261322, + "pool": "main", + "naive_compile_ok": true, + "semantic_only_accepted": false, + "semantic_only_reasons": [ + "statement_mismatch", + "meta_decl" + ] + }, + { + "id": "elab_adds_axiom", + "category": "env_manipulation", + "accepted": false, + "reasons": [ + "banned:meta_api:Lean", + "banned:meta_api:Elab", + "banned:meta" + ], + "stage": "static", + "diag": {}, + "elapsed_s": 0.0002526361495256424, + "pool": "main", + "naive_compile_ok": true, + "semantic_only_accepted": false, + "semantic_only_reasons": [ + "axiom:evil", + "meta_decl" + ] + }, + { + "id": "macro_cheat_tactic", + "category": "env_manipulation", + "accepted": false, + "reasons": [ + "banned:meta", + "banned:sorry" + ], + "stage": "static", + "diag": {}, + "elapsed_s": 0.000170024111866951, + "pool": "main", + "naive_compile_ok": true, + "semantic_only_accepted": false, + "semantic_only_reasons": [ + "sorry" + ] + }, + { + "id": "macro_rules_override", + "category": "env_manipulation", + "accepted": false, + "reasons": [ + "banned:meta", + "banned:sorry" + ], + "stage": "static", + "diag": {}, + "elapsed_s": 0.0002509262412786484, + "pool": "main", + "naive_compile_ok": true, + "semantic_only_accepted": false, + "semantic_only_reasons": [ + "sorry" + ] + }, + { + "id": "run_cmd_adds_axiom", + "category": "env_manipulation", + "accepted": false, + "reasons": [ + "banned:meta_api:Lean", + "banned:meta_api:Elab", + "banned:meta" + ], + "stage": "static", + "diag": {}, + "elapsed_s": 0.00022250507026910782, + "pool": "main", + "naive_compile_ok": true, + "semantic_only_accepted": false, + "semantic_only_reasons": [ + "axiom:evil2" + ] + }, + { + "id": "initialize_hook", + "category": "env_manipulation", + "accepted": false, + "reasons": [ + "banned:initialize", + "banned:meta_api:IO" + ], + "stage": "static", + "diag": {}, + "elapsed_s": 0.00021653436124324799, + "pool": "main", + "naive_compile_ok": true, + "semantic_only_accepted": false, + "semantic_only_reasons": [ + "meta_decl" + ] + }, + { + "id": "syntax_hijack", + "category": "env_manipulation", + "accepted": false, + "reasons": [ + "banned:meta", + "banned:sorry" + ], + "stage": "static", + "diag": {}, + "elapsed_s": 0.00020674429833889008, + "pool": "main", + "naive_compile_ok": true, + "semantic_only_accepted": false, + "semantic_only_reasons": [ + "sorry" + ] + }, + { + "id": "skipKernelTC_option", + "category": "env_manipulation", + "accepted": false, + "reasons": [ + "banned:set_option:debug.skipKernelTC" + ], + "stage": "static", + "diag": {}, + "elapsed_s": 0.00011439248919487, + "pool": "main", + "naive_compile_ok": false, + "semantic_only_accepted": false, + "semantic_only_reasons": [ + "compile_error" + ] + }, + { + "id": "eval_side_effect", + "category": "env_manipulation", + "accepted": false, + "reasons": [ + "banned:#eval", + "banned:meta_api:IO" + ], + "stage": "static", + "diag": {}, + "elapsed_s": 0.00010777171701192856, + "pool": "main", + "naive_compile_ok": true, + "semantic_only_accepted": true, + "semantic_only_reasons": [] + }, + { + "id": "exit_after_theorem", + "category": "env_manipulation", + "accepted": false, + "reasons": [ + "banned:#exit", + "banned:sorry" + ], + "stage": "static", + "diag": {}, + "elapsed_s": 0.00011836178600788116, + "pool": "main", + "naive_compile_ok": true, + "semantic_only_accepted": true, + "semantic_only_reasons": [] + }, + { + "id": "wrong_name_prime", + "category": "wrong_name", + "accepted": false, + "reasons": [ + "target_missing" + ], + "stage": "static", + "diag": {}, + "elapsed_s": 0.0001785140484571457, + "pool": "main", + "naive_compile_ok": true, + "semantic_only_accepted": false, + "semantic_only_reasons": [ + "target_missing" + ] + }, + { + "id": "wrong_name_other", + "category": "wrong_name", + "accepted": false, + "reasons": [ + "target_missing" + ], + "stage": "static", + "diag": {}, + "elapsed_s": 0.00013691280037164688, + "pool": "main", + "naive_compile_ok": true, + "semantic_only_accepted": false, + "semantic_only_reasons": [ + "target_missing" + ] + }, + { + "id": "namespace_wrapped", + "category": "wrong_name", + "accepted": false, + "reasons": [ + "banned:namespace" + ], + "stage": "static", + "diag": {}, + "elapsed_s": 0.0001985449343919754, + "pool": "main", + "naive_compile_ok": true, + "semantic_only_accepted": false, + "semantic_only_reasons": [ + "target_missing" + ] + }, + { + "id": "private_target", + "category": "wrong_name", + "accepted": false, + "reasons": [ + "target_modifier" + ], + "stage": "static", + "diag": {}, + "elapsed_s": 0.00022439472377300262, + "pool": "main", + "naive_compile_ok": true, + "semantic_only_accepted": false, + "semantic_only_reasons": [ + "target_missing" + ] + }, + { + "id": "attribute_on_target", + "category": "wrong_name", + "accepted": false, + "reasons": [ + "target_modifier" + ], + "stage": "static", + "diag": {}, + "elapsed_s": 0.00020104460418224335, + "pool": "main", + "naive_compile_ok": true, + "semantic_only_accepted": true, + "semantic_only_reasons": [] + }, + { + "id": "duplicate_target", + "category": "wrong_name", + "accepted": false, + "reasons": [ + "banned:sorry", + "target_missing" + ], + "stage": "static", + "diag": { + "detail": "target declared more than once" + }, + "elapsed_s": 0.00026160571724176407, + "pool": "main", + "naive_compile_ok": false, + "semantic_only_accepted": false, + "semantic_only_reasons": [ + "target_missing" + ] + }, + { + "id": "timeout_bomb_norm_num_prime", + "category": "timeout_bomb", + "accepted": false, + "reasons": [ + "timeout" + ], + "stage": "repl", + "diag": { + "detail": "no response within 25.3s" + }, + "elapsed_s": 30.31541334837675, + "pool": "bomb(1w,1GiB,30s)", + "naive_compile_ok": null + }, + { + "id": "crash_stack_overflow", + "category": "timeout_bomb", + "accepted": false, + "reasons": [ + "crash" + ], + "stage": "repl", + "diag": { + "detail": "REPL exited (rc=-6) mid-request. stderr tail: \"libc++abi: terminating due to uncaught exception of type lean::stack_space_exception: deep recursion was detected at 'replace' (potential solution: increase stack space in your system)\\n\"" + }, + "elapsed_s": 5.7354651764035225, + "pool": "bomb(1w,1GiB,30s)", + "naive_compile_ok": null + }, + { + "id": "memory_bomb_pow", + "category": "timeout_bomb", + "accepted": false, + "reasons": [ + "memory" + ], + "stage": "repl", + "diag": { + "detail": "private memory 1110MiB over cap 1024MiB" + }, + "elapsed_s": 19.868977280333638, + "pool": "bomb(1w,1GiB,30s)", + "naive_compile_ok": null + }, + { + "id": "unsafe_implemented_by", + "category": "banned_construct", + "accepted": false, + "reasons": [ + "banned:unsafe", + "banned:def", + "banned:implemented_by", + "banned:attr:implemented_by" + ], + "stage": "static", + "diag": {}, + "elapsed_s": 0.00018224399536848068, + "pool": "main", + "naive_compile_ok": true, + "semantic_only_accepted": true, + "semantic_only_reasons": [] + }, + { + "id": "partial_def", + "category": "banned_construct", + "accepted": false, + "reasons": [ + "banned:partial", + "banned:def" + ], + "stage": "static", + "diag": {}, + "elapsed_s": 0.00013169273734092712, + "pool": "main", + "naive_compile_ok": true, + "semantic_only_accepted": true, + "semantic_only_reasons": [] + }, + { + "id": "extern_attr", + "category": "banned_construct", + "accepted": false, + "reasons": [ + "banned:extern", + "banned:axiom", + "banned:attr:extern" + ], + "stage": "static", + "diag": {}, + "elapsed_s": 0.0001267334446310997, + "pool": "main", + "naive_compile_ok": true, + "semantic_only_accepted": true, + "semantic_only_reasons": [] + }, + { + "id": "search_tactic_exact_q", + "category": "banned_construct", + "accepted": false, + "reasons": [ + "banned:search" + ], + "stage": "static", + "diag": {}, + "elapsed_s": 0.0013700509443879128, + "pool": "main", + "naive_compile_ok": true, + "semantic_only_accepted": true, + "semantic_only_reasons": [] + }, + { + "id": "maxRecDepth_abuse", + "category": "banned_construct", + "accepted": false, + "reasons": [ + "banned:set_option:maxRecDepth>16384" + ], + "stage": "static", + "diag": {}, + "elapsed_s": 0.0001782439649105072, + "pool": "main", + "naive_compile_ok": true, + "semantic_only_accepted": true, + "semantic_only_reasons": [] + }, + { + "id": "helper_def_with_proof", + "category": "banned_construct", + "accepted": false, + "reasons": [ + "banned:def" + ], + "stage": "static", + "diag": {}, + "elapsed_s": 0.00011771265417337418, + "pool": "main", + "naive_compile_ok": true, + "semantic_only_accepted": true, + "semantic_only_reasons": [] + }, + { + "id": "header_dropped_open", + "category": "header_tamper", + "accepted": false, + "reasons": [ + "header_mismatch" + ], + "stage": "static", + "diag": {}, + "elapsed_s": 0.00015098322182893753, + "pool": "main", + "naive_compile_ok": true, + "semantic_only_accepted": false, + "semantic_only_reasons": [ + "header_mismatch" + ] + }, + { + "id": "header_extra_import", + "category": "header_tamper", + "accepted": false, + "reasons": [ + "header_mismatch" + ], + "stage": "static", + "diag": {}, + "elapsed_s": 0.00016301311552524567, + "pool": "main", + "naive_compile_ok": true, + "semantic_only_accepted": false, + "semantic_only_reasons": [ + "header_mismatch" + ] + }, + { + "id": "header_heartbeats_changed", + "category": "header_tamper", + "accepted": false, + "reasons": [ + "header_mismatch" + ], + "stage": "static", + "diag": {}, + "elapsed_s": 0.00015701353549957275, + "pool": "main", + "naive_compile_ok": true, + "semantic_only_accepted": false, + "semantic_only_reasons": [ + "header_mismatch" + ] + }, + { + "id": "header_missing", + "category": "header_tamper", + "accepted": false, + "reasons": [ + "header_mismatch" + ], + "stage": "static", + "diag": {}, + "elapsed_s": 0.00012700166553258896, + "pool": "main", + "naive_compile_ok": false, + "semantic_only_accepted": false, + "semantic_only_reasons": [ + "header_mismatch" + ] + }, + { + "id": "empty_completion", + "category": "no_code", + "accepted": false, + "reasons": [ + "no_code" + ], + "stage": "static", + "diag": {}, + "elapsed_s": 7.80448317527771e-07, + "pool": "main", + "naive_compile_ok": null + }, + { + "id": "prose_only", + "category": "no_code", + "accepted": false, + "reasons": [ + "no_code" + ], + "stage": "static", + "diag": {}, + "elapsed_s": 6.50063157081604e-07, + "pool": "main", + "naive_compile_ok": null + }, + { + "id": "unclosed_fence_truncated", + "category": "no_code", + "accepted": false, + "reasons": [ + "no_code" + ], + "stage": "static", + "diag": {}, + "elapsed_s": 5.699694156646729e-07, + "pool": "main", + "naive_compile_ok": null + }, + { + "id": "header_only", + "category": "no_code", + "accepted": false, + "reasons": [ + "target_missing" + ], + "stage": "static", + "diag": {}, + "elapsed_s": 3.9760954678058624e-05, + "pool": "main", + "naive_compile_ok": null + } + ] +} \ No newline at end of file diff --git a/code/docs/evidence/model_revisions_deleted_2026-10-19.txt b/code/docs/evidence/model_revisions_deleted_2026-10-19.txt new file mode 100644 index 0000000000000000000000000000000000000000..cd5055e3e35a2d6b4ab9b81af09c104442e28c16 --- /dev/null +++ b/code/docs/evidence/model_revisions_deleted_2026-10-19.txt @@ -0,0 +1,6 @@ +# Model caches deleted 2026-10-19 (disk space, user request). Re-download with: hf download --revision +Goedel-LM/Goedel-Prover-V2-32B 851bf85d329b0f819e1a44db30e05d16e07d15c0 62G +Goedel-LM/Goedel-Prover-V2-8B dfd02e6271a58375dfbf3ece0175277cf6b6a89a 16G +AI-MO/Kimina-Prover-Distill-8B 74d328a7b1f001ab4871812582fc66d9bf70c68b 16G +deepseek-ai/DeepSeek-Prover-V2-7B a8d9e14432b2e8dd9df2a4d4e70f1ba9bc8d9b7b 13G +allenai/OLMoE-1B-7B-0125 9b0c1aa87e34a20052389dce1f0cf01da783f654 26G diff --git a/code/docs/evidence/p2_baseline_2026-10-13/gen_minif2f_c16384.jsonl.manifest.json b/code/docs/evidence/p2_baseline_2026-10-13/gen_minif2f_c16384.jsonl.manifest.json new file mode 100644 index 0000000000000000000000000000000000000000..385f09926fc911d998178c737a4b37e25bf8119e --- /dev/null +++ b/code/docs/evidence/p2_baseline_2026-10-13/gen_minif2f_c16384.jsonl.manifest.json @@ -0,0 +1,103 @@ +{ + "sessions": [ + { + "session_id": "1791846086-54271", + "started_at": "2026-10-13T04:31:26+0530", + "ended_at": "2026-10-13T07:34:42+0530", + "status": "complete", + "argv": [ + "/apps/development/rlhf/align/extras/vllm_rollout/lean_gen.py", + "--model", + "/apps/development/rlhf/hf_cache/hub/models--Goedel-LM--Goedel-Prover-V2-8B/snapshots/dfd02e6271a58375dfbf3ece0175277cf6b6a89a", + "--problems", + "/apps/development/rlhf/align/.tmp/p2/problems/minif2f.jsonl", + "--out", + "/apps/development/rlhf/align/.tmp/p2/r1/baseline/gen_minif2f_c16384.jsonl", + "--n", + "8", + "--cap-tokens", + "16384", + "--round", + "0", + "--tp", + "4", + "--seed", + "0", + "--max-num-seqs", + "128", + "--inherit-lock", + "54244", + "--label", + "p2-baseline-gen-c16384" + ], + "args": { + "problems": "/apps/development/rlhf/align/.tmp/p2/problems/minif2f.jsonl", + "out": "/apps/development/rlhf/align/.tmp/p2/r1/baseline/gen_minif2f_c16384.jsonl", + "model": "/apps/development/rlhf/hf_cache/hub/models--Goedel-LM--Goedel-Prover-V2-8B/snapshots/dfd02e6271a58375dfbf3ece0175277cf6b6a89a", + "model_rev": null, + "n": 8, + "cap_tokens": 16384, + "round": 0, + "corrections_from": [], + "tp": 4, + "seed": 0, + "max_model_len": 40960, + "gpu_mem_util": 0.9, + "max_num_seqs": 128, + "inflight": null, + "flush_every": 8, + "flush_secs": 5.0, + "limit_problems": null, + "verify_tokenization": 4, + "no_error_thres": false, + "wait_lock": false, + "inherit_lock": 54244, + "dry_run": false, + "label": "p2-baseline-gen-c16384" + }, + "model_path": "/apps/development/rlhf/hf_cache/hub/models--Goedel-LM--Goedel-Prover-V2-8B/snapshots/dfd02e6271a58375dfbf3ece0175277cf6b6a89a", + "model_rev": "dfd02e6271a58375dfbf3ece0175277cf6b6a89a", + "lean_prompts_sha256": "700ab6c7fc1f1cb342ed2969bd5ddbcf1ffe0f8e070b9332fb1247fad57677af", + "parent_stats": null, + "already_done_at_start": 0, + "host_state": { + "ram_total_gb": 186.68231201171875, + "ram_available_gb": 141.75567245483398, + "swap_total_gb": 0.0, + "load1": 6.53, + "load5": 4.88, + "n_cores": 48, + "load_per_core": 0.13604166666666667, + "disk_in_flight": 0, + "d_state_procs": 0 + }, + "lock_wait_s": 0.0, + "vllm_version": "0.26.0", + "torch_version": "2.11.0+cu130", + "engine_load_s": 26.8, + "kv_cache": { + "num_gpu_blocks": 28533, + "block_size": 16, + "tokens": 456528, + "gpu_memory_utilization": 0.9 + }, + "torn_bytes_removed": 0, + "tokenization_check": { + "n": 4, + "ok": true + }, + "generate_wall_s": 10965.9, + "samples_written": 1952, + "tokens_generated": 9605934, + "finish_length": 114, + "tokens_per_s_end_to_end": 876.0, + "gpu_mem_used_peak_mib": { + "0": 22503, + "1": 22440, + "2": 22181, + "3": 22181 + }, + "aborted_inflight": 0 + } + ] +} \ No newline at end of file diff --git a/code/docs/evidence/p2_baseline_2026-10-13/raw_sha256.txt b/code/docs/evidence/p2_baseline_2026-10-13/raw_sha256.txt new file mode 100644 index 0000000000000000000000000000000000000000..d5e0fbbf0d4e0e305cc88ed8ea5cccae95e16575 --- /dev/null +++ b/code/docs/evidence/p2_baseline_2026-10-13/raw_sha256.txt @@ -0,0 +1,3 @@ +5df1456f13b96243aa7951438ec3a134676f13055e66453382392779bb0c5a8f .tmp/p2/r1/baseline/gen_minif2f_c16384.jsonl +eeb3bfade94f0bfdd8cbe3adfc197b222e78b158642c50acb6829a9bc9ced047 .tmp/p2/r1/baseline/ver_minif2f_c16384.jsonl +durable copy: /apps/development/rlhf/p2_runs/r1_baseline/ (gens, verdicts, problems) diff --git a/code/docs/evidence/p2_baseline_2026-10-13/report.json b/code/docs/evidence/p2_baseline_2026-10-13/report.json new file mode 100644 index 0000000000000000000000000000000000000000..e58ecf3b0431296a657d63665d9d8ac174adbdcb --- /dev/null +++ b/code/docs/evidence/p2_baseline_2026-10-13/report.json @@ -0,0 +1,181 @@ +{ + "preregistered_primary": { + "source": "minif2f", + "cap": 16384, + "k": 8, + "n_problems": 244, + "baseline_model_rev": "dfd02e6271a58375dfbf3ece0175277cf6b6a89a", + "sampling": { + "temperature": 1.0, + "top_p": 0.95, + "top_k": [ + 0, + -1 + ] + } + }, + "prereg": { + "status": "verified", + "recorded": "adc28ebedf2581777edc67ce843dbadc7c56b6b25bdf3e2e8e5424e75458eb58", + "computed": "adc28ebedf2581777edc67ce843dbadc7c56b6b25bdf3e2e8e5424e75458eb58" + }, + "runs": { + "base16k": { + "meta": { + "verifier_signatures": { + "00c377865e279a9f": 1952 + }, + "n_samples": 1952, + "budgets": [ + 180.0 + ], + "verifier": { + "pins": { + "lean_toolchain": "leanprover/lean4:v4.9.0-rc1", + "mathlib_rev": "d1d1e4b72c6e009edd97cabe1d661b021fe8eeae", + "repl_tag": "v4.9.0-rc1", + "repl_rev": "7df77c0a1c9188ab58c76d4138a06ae5e0c32282", + "installed": "2026-10-12T19:00:23+05:30", + "source_of_pin": "Goedel-Prover-V2 submodule xinhjBrant/mathlib4@2f65ba7f (parent leanprover-community/mathlib4@d1d1e4b7)" + }, + "config": { + "axiom_whitelist": [ + "Classical.choice", + "Quot.sound", + "propext" + ], + "statement_mode": "strict", + "allow_helper_defs": false, + "proof_timeout_s": 180.0, + "max_code_chars": 200000, + "kernel_recheck": true, + "prepend_missing_header": false, + "lexical_gate": true + }, + "workers": 8 + } + }, + "tables": { + "minif2f@16384": { + "cap_tokens": 16384, + "n_problems": 244, + "n_per_problem_min": 8, + "n_per_problem_max": 8, + "n_samples_round0": 1952, + "pass@1": { + "value": 0.6214139344262295, + "ci95_cluster_bootstrap": [ + 0.569672131147541, + 0.6726434426229508 + ], + "ci95_wilson_pooled_samples_ANTICONSERVATIVE": [ + 0.5996783203872208, + 0.6426726114136655 + ] + }, + "pass@k": { + "1": { + "value": 0.6214139344262295, + "ci95_cluster_bootstrap": [ + 0.569672131147541, + 0.6726434426229508 + ] + }, + "2": { + "value": 0.7006733021077284, + "ci95_cluster_bootstrap": [ + 0.6491510538641687, + 0.7510245901639344 + ] + }, + "4": { + "value": 0.7544496487119438, + "ci95_cluster_bootstrap": [ + 0.7023419203747073, + 0.8047423887587821 + ] + }, + "8": { + "value": 0.7868852459016393, + "ci95_cluster_bootstrap": [ + 0.7336065573770492, + 0.8360655737704918 + ] + }, + "16": null, + "32": null + }, + "solved@k": { + "1": { + "solved": 148, + "n": 244, + "fraction": 0.6065573770491803, + "ci95_wilson": [ + 0.5440645643804969, + 0.6657469830798513 + ] + }, + "2": { + "solved": 164, + "n": 244, + "fraction": 0.6721311475409836, + "ci95_wilson": [ + 0.6109585403162824, + 0.7279678055812032 + ] + }, + "4": { + "solved": 184, + "n": 244, + "fraction": 0.7540983606557377, + "ci95_wilson": [ + 0.6964042156201609, + 0.8039156283237463 + ] + }, + "8": { + "solved": 192, + "n": 244, + "fraction": 0.7868852459016393, + "ci95_wilson": [ + 0.7312622888143901, + 0.8336149543480859 + ] + }, + "16": null, + "32": null + }, + "completion": { + "length_capped_fraction": 0.0584016393442623, + "mean_completion_tokens": 4921.07274590164, + "no_code_fraction": 0.0584016393442623, + "reason_histogram": { + "compile_error": 551, + "no_code": 114, + "timeout": 60, + "memory": 13, + "banned:sorry": 1 + }, + "verify_s_mean": 16.5252818647541, + "context_clipped_fraction": 0.0, + "mean_effective_max_tokens": 16384.0 + }, + "design": { + "model_revs": [ + "dfd02e6271a58375dfbf3ece0175277cf6b6a89a" + ], + "sampling": [ + [ + 1.0, + 0.95, + 0 + ] + ] + } + } + } + } + }, + "comparisons": [], + "correction_lift": {} +} \ No newline at end of file diff --git a/code/docs/evidence/p2_baseline_2026-10-13/report.md b/code/docs/evidence/p2_baseline_2026-10-13/report.md new file mode 100644 index 0000000000000000000000000000000000000000..8e0116dec40da777d147d78b38c2e0f134021a64 --- /dev/null +++ b/code/docs/evidence/p2_baseline_2026-10-13/report.md @@ -0,0 +1,13 @@ +# P2 evaluation report + +Pre-registered primary endpoint: minif2f solved@8 at cap 16384, paired exact McNemar, alpha 0.05. All other rows are secondary and not multiplicity-corrected. + +Pre-registration (docs/P2.md): verified (sha256 adc28ebedf2581777edc67ce843dbadc7c56b6b25bdf3e2e8e5424e75458eb58) + +## Run `base16k` + +verifier budgets (s): [180.0]; samples: 1952 + +| set @ cap | problems | n/prob | pass@1 [95% cluster-bootstrap] | pass@1 | pass@2 | pass@4 | pass@8 | pass@16 | pass@32 | length-capped | mean tok | mean effective max_tokens | +|---|---|---|---|---|---|---|---|---|---|---|---|---| +| minif2f@16384 | 244 | 8 | 62.1% [57.0%, 67.3%] | 62.1% (148/244) | 70.1% (164/244) | 75.4% (184/244) | 78.7% (192/244) | n/a | n/a | 5.8% | 4921 | 16384 | diff --git a/code/docs/evidence/prereg/prereg_sota_v1.json b/code/docs/evidence/prereg/prereg_sota_v1.json new file mode 100644 index 0000000000000000000000000000000000000000..701714ba284dda659e93484f7ba5ca48bf47e832 --- /dev/null +++ b/code/docs/evidence/prereg/prereg_sota_v1.json @@ -0,0 +1,29 @@ +{ + "files": [ + { + "path": "docs/PREREG_SOTA.md", + "sha256": "126fbf2e36e6094c58ea43f1adbcfc4ef9089d563d9d36b5b3758d204ff84464" + }, + { + "path": "align/eval/paired.py", + "sha256": "8808e8bbf668db710906cd8eb6b4e58dfe1fd789452de09951846e074a53c438" + }, + { + "path": "align/eval/proof_stats.py", + "sha256": "cb976e4361bf82c411943b4a5bcaf820a0665a3b64a576440dc4cca6e11ef2cc" + }, + { + "path": "scripts/p2/eval_report.py", + "sha256": "8a405397f59ff9307b67855ce1d6ceaa7cdb92969fee6173a28da36d43822c00" + }, + { + "path": "scripts/p2/p2_common.py", + "sha256": "249dc16107df63c2381ee330dc46f78394c88da753d943220fe15d5343368e55" + }, + { + "path": "scripts/p2/prereg_hash.py", + "sha256": "723e3e747712f9ba543fdb507e31f2b19fe6c944bfedff06a6416ceeb77ce4ad" + } + ], + "sha256": "58bb39ed43df81e8a71acc573be5add79724a0a66c8e05e57746acf14d926430" +} diff --git a/code/docs/evidence/prereg/prereg_sota_v1.sha256.txt b/code/docs/evidence/prereg/prereg_sota_v1.sha256.txt new file mode 100644 index 0000000000000000000000000000000000000000..4681905a31c32f2acb7cb4b1937b455eba620172 --- /dev/null +++ b/code/docs/evidence/prereg/prereg_sota_v1.sha256.txt @@ -0,0 +1 @@ +prereg sota-v1 58bb39ed43df81e8a71acc573be5add79724a0a66c8e05e57746acf14d926430 2026-10-19 diff --git a/code/docs/evidence/prereg/prereg_sota_v1.sha256.txt.ots b/code/docs/evidence/prereg/prereg_sota_v1.sha256.txt.ots new file mode 100644 index 0000000000000000000000000000000000000000..b565e9beaf40f10c9d36b8e450ff39864f1c85b7 Binary files /dev/null and b/code/docs/evidence/prereg/prereg_sota_v1.sha256.txt.ots differ diff --git a/code/docs/evidence/sota_repro_2026-10-14/raw_sha256.txt b/code/docs/evidence/sota_repro_2026-10-14/raw_sha256.txt new file mode 100644 index 0000000000000000000000000000000000000000..34f382136e97a3781f6bea5af171ab578168032e --- /dev/null +++ b/code/docs/evidence/sota_repro_2026-10-14/raw_sha256.txt @@ -0,0 +1,2 @@ +7d388848e1425d011b8e2c886d06968638a4c54696db33dacb151df57ed33edf .tmp/p2/r1/baseline/gen_minif2f_c40960.jsonl +4b17056a0c11cc4d26cbe2f3b6af71203ce7707e60952491739cad720d147e1b .tmp/p2/r1/baseline/ver_minif2f_c40960.jsonl diff --git a/code/docs/evidence/sota_repro_2026-10-14/report.json b/code/docs/evidence/sota_repro_2026-10-14/report.json new file mode 100644 index 0000000000000000000000000000000000000000..67acb12031b7bd50ecbfb1d862d3dc51ae3d8f9c --- /dev/null +++ b/code/docs/evidence/sota_repro_2026-10-14/report.json @@ -0,0 +1,163 @@ +{ + "preregistered_primary": { + "source": "minif2f", + "cap": 16384, + "k": 8, + "n_problems": 244, + "baseline_model_rev": "dfd02e6271a58375dfbf3ece0175277cf6b6a89a", + "sampling": { + "temperature": 1.0, + "top_p": 0.95, + "top_k": [ + 0, + -1 + ] + } + }, + "prereg": { + "status": "verified", + "recorded": "adc28ebedf2581777edc67ce843dbadc7c56b6b25bdf3e2e8e5424e75458eb58", + "computed": "adc28ebedf2581777edc67ce843dbadc7c56b6b25bdf3e2e8e5424e75458eb58" + }, + "runs": { + "base40k_n32": { + "meta": { + "verifier_signatures": { + "00c377865e279a9f": 7808 + }, + "n_samples": 7808, + "budgets": [ + 180.0 + ], + "verifier": { + "pins": { + "lean_toolchain": "leanprover/lean4:v4.9.0-rc1", + "mathlib_rev": "d1d1e4b72c6e009edd97cabe1d661b021fe8eeae", + "repl_tag": "v4.9.0-rc1", + "repl_rev": "7df77c0a1c9188ab58c76d4138a06ae5e0c32282", + "installed": "2026-10-12T19:00:23+05:30", + "source_of_pin": "Goedel-Prover-V2 submodule xinhjBrant/mathlib4@2f65ba7f (parent leanprover-community/mathlib4@d1d1e4b7)" + }, + "config": { + "axiom_whitelist": [ + "Classical.choice", + "Quot.sound", + "propext" + ], + "statement_mode": "strict", + "allow_helper_defs": false, + "proof_timeout_s": 180.0, + "max_code_chars": 200000, + "kernel_recheck": true, + "prepend_missing_header": false, + "lexical_gate": true + }, + "workers": 8 + } + }, + "tables": { + "minif2f@40960": { + "cap_tokens": 40960, + "n_problems": 244, + "n_per_problem_min": 32, + "n_per_problem_max": 32, + "n_samples_round0": 7808, + "pass@1": { + "value": 0.6282018442622951, + "ci95_cluster_bootstrap": [ + 0.579405737704918, + 0.6771260245901639 + ], + "ci95_wilson_pooled_samples_ANTICONSERVATIVE": [ + 0.6174215820304778, + 0.6388560204469065 + ] + }, + "pass@k": { + "1": { + "value": 0.6282018442622951, + "ci95_cluster_bootstrap": [ + 0.5778688524590164, + 0.6769979508196722 + ] + }, + "8": { + "value": 0.7973266705506079, + "ci95_cluster_bootstrap": [ + 0.7476979052647977, + 0.8436311916580829 + ] + }, + "32": { + "value": 0.8278688524590164, + "ci95_cluster_bootstrap": [ + 0.7786885245901639, + 0.8729508196721312 + ] + } + }, + "solved@k": { + "1": { + "solved": 152, + "n": 244, + "fraction": 0.6229508196721312, + "ci95_wilson": [ + 0.5606775804769295, + 0.6814126665927032 + ] + }, + "8": { + "solved": 192, + "n": 244, + "fraction": 0.7868852459016393, + "ci95_wilson": [ + 0.7312622888143901, + 0.8336149543480859 + ] + }, + "32": { + "solved": 202, + "n": 244, + "fraction": 0.8278688524590164, + "ci95_wilson": [ + 0.7755158809357559, + 0.870058111249931 + ] + } + }, + "completion": { + "length_capped_fraction": 0.022540983606557378, + "mean_completion_tokens": 5669.047003073771, + "no_code_fraction": 0.022540983606557378, + "reason_histogram": { + "compile_error": 2273, + "timeout": 391, + "no_code": 176, + "memory": 47, + "banned:sorry": 8, + "crash": 6, + "parse_error": 2 + }, + "verify_s_mean": 21.240834221311474, + "context_clipped_fraction": 1.0, + "mean_effective_max_tokens": 40711.85655737705 + }, + "design": { + "model_revs": [ + "dfd02e6271a58375dfbf3ece0175277cf6b6a89a" + ], + "sampling": [ + [ + 1.0, + 0.95, + 0 + ] + ] + } + } + } + } + }, + "comparisons": [], + "correction_lift": {} +} \ No newline at end of file diff --git a/code/docs/evidence/sota_repro_2026-10-14/report.md b/code/docs/evidence/sota_repro_2026-10-14/report.md new file mode 100644 index 0000000000000000000000000000000000000000..1ce2a878daedad7e968093196ecab3343f693bfa --- /dev/null +++ b/code/docs/evidence/sota_repro_2026-10-14/report.md @@ -0,0 +1,13 @@ +# P2 evaluation report + +Pre-registered primary endpoint: minif2f solved@8 at cap 16384, paired exact McNemar, alpha 0.05. All other rows are secondary and not multiplicity-corrected. + +Pre-registration (docs/P2.md): verified (sha256 adc28ebedf2581777edc67ce843dbadc7c56b6b25bdf3e2e8e5424e75458eb58) + +## Run `base40k_n32` + +verifier budgets (s): [180.0]; samples: 7808 + +| set @ cap | problems | n/prob | pass@1 [95% cluster-bootstrap] | pass@1 | pass@2 | pass@4 | pass@8 | pass@16 | pass@32 | length-capped | mean tok | mean effective max_tokens | +|---|---|---|---|---|---|---|---|---|---|---|---|---| +| minif2f@40960 | 244 | 32 | 62.8% [57.9%, 67.7%] | 62.8% (152/244) | n/a | n/a | 79.7% (192/244) | n/a | 82.8% (202/244) | 2.3% | 5669 | 40712 | diff --git a/code/docs/research/BUGFINDING_LIT_REVIEW.md b/code/docs/research/BUGFINDING_LIT_REVIEW.md new file mode 100644 index 0000000000000000000000000000000000000000..de5c0da85e4453a10d2c386d7f3309b6e428ac1c --- /dev/null +++ b/code/docs/research/BUGFINDING_LIT_REVIEW.md @@ -0,0 +1,214 @@ +# Finding real software bugs with LLM theorem provers and LLM-driven formal verification: a literature review + +Status: L1 review, 2026-10-14. Target application of the `align` prover (Goedel-Prover-V2-8B, strict Lean verifier, 4x A10G). +Vault tag: `leanstral-bugfinding` (hyperresearch, `/apps/development/rlhf/research/notes/`). + +## 0. Method, scope and honesty notes + +Question: how can LLM theorem provers and LLM-driven formal verification (Lean 4, Dafny, Verus, Coq, Isabelle) be used to find real bugs in software, and which training recipes, benchmarks and evaluation protocols exist? + +Procedure (condensed lit-review mode, not a full PRISMA run): the `academic-research-skills:deep-research` skill was loaded, but its 13-agent pipeline needs a subagent tool that this session does not have, so the steps were carried out by hand. About 35 hyperresearch `scholar search` queries (OpenAlex, Crossref, CORE and others) were run, then each primary source was fetched with `hyperresearch fetch` and its text read from the vault note. The numbers below were read from those note bodies, not recalled. Where a number comes from a paper's abstract rather than a table I read, it is cited as "abstract". + +What was NOT verified, and why: + +| Item | Status | +|---|---| +| Vostd/Asterinas Verus spec-failure case study (doi 10.1145/3832783.3834504) | Publisher bot wall; known only from search metadata. Not used for any number. | +| AutoACSL (2606.20969), HarnessLLM, Safe4U, KaPilot, CheckerBench (2610.07557), SpecMind (2602.20610) | Appeared in searches; not fetched in full. Verification pass: the arXiv ids of AutoACSL, CheckerBench and SpecMind resolve to those titles, and CheckerBench's abstract numbers are used in one table row and in RQ2. The others are not used for numbers. | +| Primary-source check | All 35 arXiv ids in the references were re-fetched from arxiv.org in the verification pass; each resolves to the cited title. Abstract-level numbers in the methods table were compared with the live abstracts; the corrections are listed in the status notes above and inline. | +| Coq and Isabelle | Searches returned almost no LLM bug-finding work in either. The only Coq evidence is Vero's Coq-origin instances and Leanstral's Rocq-to-Lean case study. This is a coverage gap in this review, not evidence that none exists. | +| Several 2026 arXiv ids (26xx.xxxxx) | Fetched from arxiv.org and read as given. The review does not cross-check them against other venues. | +| Rust-Prover paper (2610.03994) | Model names ("Claude Opus 5.5" and others) and costs are the paper's own statements. Single-source. | +| Leanstral 1.5 numbers | From Mistral's own launch post and technical report (vendor claims, not peer reviewed; the report is a PDF on GitHub, `mistralai/LeanstralSafeVerify`, not on arXiv). Verification pass: the blog says "11 pointing to genuine bugs, 5 of them previously unreported"; the technical report (section 4.2) is clearer: 47 flags, 11 genuine, and "since some properties failed because of the same root cause, this corresponded to 5 bugs, which were all previously unreported". This review originally read the blog as 11 bugs, 5 new; that was wrong and is corrected below. Availability: Mistral's own model card (fetched 12 Oct 2026) marks `labs-leanstral-1-5` Deprecated with deprecation date 9/29/2026, which is already past on the review date (14 Oct 2026). A third-party note says 30 Sep. So the hosted API should be treated as unavailable or going away; the Apache-2.0 weights are on Hugging Face. | +| Rust-Prover (2610.03994) versions | arXiv shows v1 and v2. The numbers in the methods table were read from the vault copy (v1). The current abstract (v2) differs: 1259 of 1325 theorems proved in one run under 32 h, median 3.2 min and $1.17 per proof, 70% on the first iteration, 21,413 test cases. Both are given in the table. | +| Venue labels (AutoSpec LNCS 2024, SpecGen ICSE'25, Clover CAV'24, fuzz-driver ISSTA'24) | Not shown in the fetched text or the arXiv comments field. They come from the reviewer's background knowledge and were not re-checked. Venue labels for KNighter (SOSP 2025), VeriSoftBench (COLM 2026), nl2postcond (FSE 2024), SAFE and IRIS (ICLR 2025), CLEVER (NeurIPS 2025 D&B) and VERINA (ICLR 2026) were seen in the fetched text or the arXiv comments. | + +Citation convention: arXiv id plus the abstract or the table/section where the figure was read. Section numbers are given only where the fetched text shows them. + +## 1. Taxonomy + +Axis A is what the LLM produces. Axis B is what counts as a "found bug". The key observation is that *proof* is rarely what finds the bug; a failed proof is only weak evidence. The bug-finding signal comes from one of three different oracles (listed below the table). + +| Class | LLM role | Verifier role | What counts as a found bug | Examples | +|---|---|---|---|---| +| S. Spec synthesis | writes pre/post/invariants | checks spec is satisfiable and the code meets it | Spec fails on code (violation), or spec disagrees with docstring | AutoSpec, SpecGen, nl2postcond, NL2Contract | +| C. Consistency checking | writes code, spec, doc; cross-checks | proves spec-code and spec-doc agreement | Mismatch flagged between independent artifacts | Clover | +| P. Property proving / disproving | writes proof of property, or of its negation | kernel/SMT accepts proof | Proof of the negation of a plausible property (a verified counterexample) | Leanstral 1.5 Aeneas pipeline, Vero audit | +| V. Verified code generation | writes code + spec + proof | accepts or rejects | Not bug finding; produces bug-free-by-construction code. Also yields bugs in benchmarks | AlphaVerus, SAFE, VERINA, CLEVER, FVAPPS, DafnyBench, miniCodeProps, VeruSyn | +| R. Counterexample-guided repair | reads counterexamples to fix proofs or specs | produces counterexample | Counterexample to a candidate invariant or proof | ExVerus, Vero audit | +| N. Proof-free baselines | reads code / writes checkers / drivers | static analysis, fuzzer, symbolic execution, concrete replay | Crash, sanitizer hit, confirmed CVE | Big Sleep, IRIS, KNighter, Sailor, LLM fuzz drivers | + +Three distinct bug oracles follow from this: + +1. A kernel- or SMT-checked disproof (class P). The finding is sound given the spec and the code-to-logic translation. +2. A dynamically reproduced failure (class N, and class S when the postcondition is executable). Sound given the test harness. +3. A cross-artifact inconsistency (class C). No ground truth is needed, but the finding can be a defect in any of the three artifacts. + +Every class has an unverified link: the spec is the one component the verifier cannot check against intent (section 5). + +## 2. Methods table + +Language = target language of the *software*. "Found bug" is the paper's own criterion. All numbers are as reported. + +| Method (arXiv / venue) | Language, verifier | What counts as a found bug | Benchmark | Reported numbers (source) | +|---|---|---|---|---| +| AutoSpec (2404.00762, LNCS 2024) | C, Frama-C WP | Not a bug finder; synthesises specs that give a full proof | 4 benchmarks, 251 programs; X509-parser | Verifies 79% of programs, 1.592x over prior work; six X509 functions specified in minutes (abstract, intro) | +| SpecGen (2401.08807, ICSE'25) | Java, OpenJML | Not a bug finder; verifiable specs | SV-COMP Java + 120 hand-built programs | Verifiable specs for 279 of 385 programs; beats Houdini and Daikon (abstract) | +| nl2postcond (2310.01831, FSE'24) | Python/Java, run-time assertions | Postcondition violated by a buggy mutant or a real historical bug | EvalPlus (164 problems), Defects4J | GPT-4 accept@1 0.63-0.77; correct postconditions discriminate up to 81% of buggy programs on average (EvalPlus); on Defects4J the postconditions discriminate 70 buggy methods belonging to 64 known historical bugs, 12.2% of the bugs considered (abstract; intro; conclusion). These are known, already-fixed bugs, so this is discriminative power, not new-bug discovery. Table 2 of the paper is the EvalPlus bug-completeness table, not this result | +| NL2Contract (2510.12702) | Python, software verifier | Contract rejects buggy implementation, accepts reference | HumanEval-derived tasks | LLM verification soundness@1 66.7-81.1%, @5 87.9-88.7%, @10 90.0-93.9%; test-set correctness does not imply verification soundness (contributions section) | +| Clover (2310.17807, CAV'24) | Dafny | Inconsistency among code, docstring, annotations | CloverBench; MBPP-DFY-50 | Up to 87% acceptance of correct instances, zero false positives on adversarial incorrect ones; 6 incorrect programs found in MBPP-DFY-50 (abstract) | +| AlphaVerus (2412.06176) | Rust/Verus | Not a bug finder; verified code | HumanEval-Verus, MBPP-Verus | Table 2, pass@256: GPT-4o 27.1% / 35.9%; Llama-3.1-70B base 11.8% / 26.9%; AlphaVerus exploration 27.1% / 59.1%, plus Treefinement 32.9% / 65.7% | +| SAFE (2410.15756, ICLR'25) | Rust/Verus | Not a bug finder; proofs | VerusBench (human specs), CodeNet-Test | Abstract: 52.52% vs GPT-4o 14.39% on a human-expert benchmark. Intro: DeepSeekCoder reaches 43.17% / 43.83% direct, 79.14% VerusBench with self-debugging, vs GPT-4o 11.51% / 0.28% | +| VeruSyn (2602.04910) | Rust/Verus | Not a bug finder; data | VeruSAGE-Bench (560 of 849 tasks fit the model's context), VerusBench, HumanEval, MBPP | 6.9M synthesized verified programs; Qwen2.5-Coder-32B-Instruct fine-tuned with VeruSyn data scores 39% (Accuracy@100, no debugging) and 49% (Accuracy@600, 5 debugging rounds) on VeruSAGE-Bench, vs 10% / 19% for the raw model, 11% / 15% with AlphaVerus data and 6% / 11% with SAFE data; Claude Sonnet 4.5 scores 46% / 54% on the same table (Table 1, "Total (560)" column) | +| VeruSAGE (2512.18436) | Rust/Verus | Not a bug finder; agentic proofs | VeruSAGE-Bench, 849 tasks from 8 Verus systems | Best LLM-agent combo completes over 80%; over 90% of a set of tasks human experts had not finished (abstract) | +| ExVerus (2603.25810) | Rust/Verus | Counterexample to a failing proof/invariant, used to repair | VerusBench, DafnyBench, LCBench, HumanEval | Outperforms AutoVerus by 60.92% on average on VerusBench (section 4.2; the text does not say whether relative or percentage points); solves about 2x (LCBench) and 1.5x (HumanEval) as many tasks; the two tools are complementary, each solving tasks the other does not (appendix H). AutoVerus was run on a newer Verus than it was built for and is stated to degrade (footnote 3) | +| DafnyBench (2406.08467) | Dafny | Not a bug finder; hint generation | 750+ programs, ~53,000 lines | Best 68%; success falls with required code and hints; "solved" forbids edits to requires/ensures, `{:verify false}` and `assume false` (abstract, intro) | +| dafny-annotator (2411.15143) | Dafny | n/a | DafnyBench test split | Llama-3.1 8B greedy 15.7%; fine-tuned on DafnyBench + DafnySynth 50.6% (abstract) | +| DafnyPro (2601.05385) | Dafny | n/a | Clover, MBPP-Dafny, HumanEval-Dafny, DafnyBench | Sonnet 3.5 + DafnyPro 86% on DafnyBench (+16pp); fine-tuned Qwen 7B/14B 68%/70% (abstract) | +| RLVR in Dafny thesis (2605.30914) | Dafny, Lean | n/a | APPS-derived Dafny, VERINA | GRPO verified reward 2.2% -> 58.1% but with specification hacking; after filtering underspecified tasks, multi-turn RLVR 9.7% -> 31.1% (abstract) | +| FVAPPS (2502.05714) | Lean 4 | n/a | 4715 samples, 1083 curated; 406 theorems of 100 samples evaluated | Sonnet proves 30%, Gemini 18% (abstract) | +| miniCodeProps (2406.11915) | Lean 4 | n/a | 201 specs on lists, nats, trees | SOTA provers do well on easy, fail nearly all medium and hard (abstract) | +| CLEVER (2505.13938, NeurIPS D&B'25) | Lean 4 | n/a | 161 problems, spec-matching + implementation | Few-shot frontier LLMs solve at most 1 of 161 end-to-end (introduction) | +| VERINA (2505.23135, ICLR'26) | Lean 4 | n/a | 189 tasks | Best model o3: 72.6% code, 52.3% spec soundness and completeness, 4.9% proof, one trial per task (abstract) | +| VERINA+ / VeriScale (2605.22368) | Lean 4 | Adversarial implementations used to expose weak specs | VERINA with 83x larger test suites | Sharp score drops on SpecGen and CodeGen tasks vs original VERINA (abstract; magnitude not extracted) | +| WybeCoder (2603.29088) | Lean 4 + SMT, imperative | n/a | Verina and Clever translated to imperative specs | Solves 74% of Verina and 62% of Clever tasks at moderate compute (abstract) | +| AlgoVeri (2602.09464) | Dafny / Verus / Lean | n/a | 77 classical algorithms, identical contracts | Gemini-3 Flash: Dafny 40.3%, Verus 24.7%, Lean 7.8% (abstract) | +| VeriContest (2605.08553) | Rust/Verus | n/a | 946 problems | Best model: NL-to-code 92.18%, spec generation 48.31%, proofs 13.95%, end-to-end 5.29% (abstract) | +| Rust-Prover on VeriContest (2610.03994) | Rust -> Lean 4 | Specs restated and checked against test suites | 1007 problems, 1325 theorems | v1 (vault copy): all 1325 theorems proved by Claude Opus 5.5 in about 35 h, median 3.4 min and $1.20 per proof, 71% accepted at first check, translated Lean programs matched Rust output on 19,555 tests. v2 (current arXiv abstract): 1259 of 1325 in one run under 32 h, median 3.2 min and $1.17, 70% on the first iteration, 21,413 tests; all 1325 proved overall. Note it counts 1007 problems where the VeriContest abstract says 946 (benchmark was apparently extended; not reconciled here) | +| Verus-SpecGym (2605.26457) | Rust/Verus | Spec fails an executed official test or a Codeforces "hack" case | 581 spec-writing tasks | Best model 77.8%, other frontier 51.1-57.8%, open-source 21.5-25.5%; LLM-as-judge misses 26% of failures the executable check catches (abstract) | +| VeriSoftBench (2602.18307, COLM'26) | Lean 4 | n/a | 500 repository-context obligations | Mathlib-tuned provers transfer poorly; success falls with transitive dependency closure (abstract) | +| FVSpec (2606.01008) | Python -> Lean 4 specs | n/a | 7,413 property-based tests scraped; 2,623 (35%) translated to 9,415 Lean specs | Baselines for proof generation (abstract; numbers not extracted) | +| Vero (2608.13522) | Lean 4 repos from Python/Dafny/Verus/Coq | Formal proof that a spec is unsatisfiable or reference code is wrong | 43 multi-module instances | Strongest agent fully solves 27/43; audit mechanism surfaced latent spec and code errors missed by manual review (abstract, intro) | +| Aeneas/Rust-to-Lean experience report (2605.30106) | Rust -> Lean 4 | n/a (proofs of crypto primitives) | Plonky3, RISC Zero | AI provers closed two previously `sorry`-ed bounds; toolchain drift across Lean versions is a stated obstacle (abstract) | +| SymCrypt + Aeneas (2609.15648) | Rust -> Lean 4 | Safety, panic-freedom, functional correctness | 16.7 KLOC Rust | 237 KLOC Lean development; agents write proofs, kernel verifies (abstract) | +| C-to-Lean trust ledger (2609.38237) | C -> Lean 4 | n/a | SQLite, 8,602 functions | 60.7% hole-free, only 24.8% call-closed; found a translation that succeeded with a wrong result (abstract) | +| **Leanstral 1.5** (Mistral blog and technical report PDF, 2 Jul 2026; no arXiv) | Rust -> Lean 4 via Aeneas | Property whose negation the model proves (4 attempts x 30 turns to prove, then negation); triaged by the authors, method not described | 57 Rust repos translated by Aeneas | 47 flags, 11 of them genuine, which are 5 distinct root-cause bugs, all previously unreported on GitHub (tech report section 4.2; the blog's wording "11 genuine bugs, 5 of them previously unreported" is looser). The other 36 flags were properties "too strong (for instance failing because of a state that was unreachable from the public interface) or malformed", no counts given. Example: zigzag `sign` overflow at `u64::MAX` in `datrs/varinteger`. The report's footnote 4 says Claude, given that repository and asked to identify bugs, also caught it | +| Big Sleep (Project Zero blog, Oct 2024) | C, none (agent + debugger) | Exploitable crash reproduced | SQLite recent commits | One real stack buffer underflow in SQLite (Gemini 1.5 Pro agent) found by variant analysis from commit diffs; missed by OSS-Fuzz and SQLite's own testing. The post's own explanation for the miss in the worked example is harness configuration: the OSS-Fuzz harness is not built with the `generate_series` extension enabled. No rate reported (blog) | +| IRIS (2405.17238, ICLR'25) | Java, CodeQL + LLM | Taint path to sink matching a CWE | CWE-Bench-Java, 120 vulnerabilities | CodeQL 27; IRIS+GPT-4 55 (+28); false discovery rate 5pp better; 4 previously unknown vulnerabilities (abstract) | +| KNighter (2503.09002, SOSP'25) | C (Linux kernel), Clang Static Analyzer | Checker warning confirmed by maintainers | Linux kernel | 92 new bugs (average 4.3 years latent); 77 confirmed, 57 fixed, 30 CVEs (abstract) | +| Sailor (2604.06506) | C/C++, symbolic execution + concrete replay | Crash replayed on unmodified source | 10 projects, 6.8M lines | 379 new memory-safety vulnerabilities, 421 confirmed crashes; the strongest of five baselines (agentic detection with Claude Code, full codebase access) found only 12; without static-analysis targeting confirmed vulnerabilities drop 12.2x; without iterative LLM synthesis none are confirmed (abstract) | +| CheckerBench (2610.07557; abstract only, not fetched in full) | 5 language ecosystems, vulnerable and fixed revisions | Checker that fires on the vulnerable revision and not the fixed one | 300 tasks from 297 CVEs, 167 repositories, 85 CWEs | Mean Pass@1 32.30% across 21 model-harness configurations, best 45.33% (abstract). Same pre-fix/post-fix gold label that RQ2 uses | +| LLM fuzz-driver study (2307.12469, ISSTA'24) | C, libFuzzer | Fuzzing coverage and crashes | 86 questions, 30 C projects | 736,430 generated drivers, 0.85 billion tokens ($8,000+ charged), 3.75 CPU-years of fuzzing; LLM drivers on par with industry drivers in fuzzing outcome; "promising but obstacles remain" (abstract) | + +## 3. What the evidence says + +### 3.1 The only proof-based, LLM-driven, real-codebase bug-finding result is vendor-reported and has a low-precision funnel + +Leanstral 1.5 is the sole item in this review where a prover-class model finds previously unknown bugs in real repositories through a prove-or-disprove loop. The funnel (technical report section 4.2, a Mistral PDF; the blog is shorter): Aeneas translates Rust to Lean; the model infers the programmer's intent and writes properties; it gets 4 attempts of 30 turns each to prove a property; if all fail, it tries to prove the negation, and a proved negation flags a potential bug. Reported yield: 57 repositories, 47 flags, 11 flags that genuinely pointed to bugs, and because several flags shared a root cause, 5 distinct bugs, all previously unreported on GitHub. + +Two precision figures follow, both my arithmetic from the report's counts. Per flag: 11/47 = 23%. Per distinct bug: 5/47 = 11%, which is the number that matters if a maintainer must triage one report per root cause. The other 36 flags are described qualitatively only: properties that were "too strong (for instance failing because of a state that was unreachable from the public interface) or malformed". So the dominant false-alarm class is an over-strong property, which is the same failure NL2Contract reports for postcondition-only specifications (verifiers flag invalid inputs; adding inferred preconditions reduces false alarms, 2510.12702 abstract). A property gate therefore has an existing, named target: reachability and input-precondition constraints. The report gives no split of the 36 by class, no repository list, and no description of how "genuine" was decided. + +The report is candid about two things this review should not overstate. It says a rigorous comparison with testing, fuzzing or simply asking an LLM is future work, and its footnote 4 says Claude, given the `datrs/varinteger` repository and asked for potential bugs, caught the `sign` overflow. The blog's statement that testing and fuzzing "would typically miss" it is therefore the vendor's expectation, not a measured result, and a `u64::MAX` operand is a classic boundary value that fuzzers with interesting-value dictionaries do try. What the disproof adds over an LLM bug report is a kernel-checked witness (`sign core.num.U64.MAX = fail integerOverflow`, proved by `rfl`), not necessarily recall. + +### 3.2 Proof is the bottleneck for small and mid-size models, but the bottleneck is moving + +On Lean, proof success is low in 2025 benchmarks (VERINA proof 4.9%, CLEVER at most 1/161, miniCodeProps medium and hard nearly all failed, FVAPPS 18-30%). On Verus, VeriContest proof generation is 13.95% for the best model, yet VeruSAGE reports over 80% on system-verification tasks with agents. The Rust-Prover report claims every theorem in VeriContest closes when the specification is restated and proved in Lean with a frontier agent. Taken together, proof search is becoming cheap for frontier agents given an agentic scaffold, and the open problems are specification fidelity (Verus-SpecGym: even best model at 77.8%) and translation fidelity (C-to-Lean ledger: 24.8% call-closed). + +### 3.3 Training recipes that exist for provers on code + +| Recipe | Mechanism | Reported effect | Risk named by authors | +|---|---|---|---| +| Self-evolving synthesis (SAFE 2410.15756) | Synthesise specs and proofs, verifier filters, fine-tune; reuse failed proofs for self-debugging | 52.52% vs 14.39% GPT-4o (abstract) | Spec quality of synthesised data | +| Cross-language translation + tree search (AlphaVerus 2412.06176) | Dafny-to-Verus translation, Treefinement, critique model filters misaligned specs | Table 2 above | Reward hacking via trivial specs or verifier limitations (intro) | +| Mass synthesis plus agent trajectories (VeruSyn 2602.04910) | 6.9M programs; long-CoT data from agents | 32B model competitive with commercial models at lower cost | Single-source | +| Open-ended synthesis (dafny-annotator 2411.15143) | LLM proposes programs, Dafny validates | 15.7% -> 50.6% for an 8B model | None beyond data scarcity | +| Inference-time scaffolds (DafnyPro 2601.05385) | diff-checker blocks edits to program logic; pruner; hint library | 86% DafnyBench | Models silently alter the base code to pass | +| RLVR with GRPO (2605.30914) | Verifier score as reward, multi-turn | 2.2% -> 58.1% verified reward, then 9.7% -> 31.1% after filtering | Specification hacking before filtering | +| Counterexample-guided repair (ExVerus 2603.25810) | Counterexamples explain proof failure | Large gains over AutoVerus on VerusBench | Weaker on hard tasks needing more invariants | +| Production RL (Leanstral 1.5, blog and technical report) | Mid-training, SFT, CISPO RL; RL mix 50% LeanGym-PR, 20% LeanGym single-theorem, 30% prove-or-disprove multiturn (report section 2.2); the multiturn env asks the model to prove a theorem or its negation, full reward if the proof compiles and passes an axiom check; final check by a SafeVerify fork extended to check negations | PutnamBench 587/672 at 4M-token budget; FLTEval pass@1 21.9 -> 28.9 | Vendor-reported; no ablation of the disprove branch is published; the disprove statements are competition problems, not code properties | + +Note on what is and is not covered. Leanstral does train on negation proofs: 30% of its RL mix is a prove-or-disprove environment, and the verifier was extended to check negations. What no retrieved source reports is (a) disproof training on code properties rather than competition statements, (b) a reward shaped for bug yield or for a concrete witness, or (c) an ablation of the disprove branch. An earlier version of this review said no recipe trained on negations at all; that was wrong, and the novelty claim of RQ1 below is narrowed accordingly. + +### 3.4 Proof-free baselines set the yield bar + +| Baseline | Yield | Cost signal | +|---|---|---| +| KNighter | 92 new Linux kernel bugs, 30 CVEs | One LLM synthesis pass per bug pattern; checkers then run cheaply at scale | +| Sailor | 379 new memory-safety bugs on 6.8M lines; best agentic-LLM baseline (Claude Code, full access) found 12 | Needs a concrete-replay filter; the paper reports that dropping iterative LLM synthesis leaves zero confirmed | +| IRIS | 55/120 vs CodeQL 27/120 on CWE-Bench-Java | Whole-repository taint reasoning | +| Big Sleep | One serious SQLite bug | Variant analysis from a fixed-bug diff; the post attributes the fuzzers' miss in its worked example to a harness that lacked the relevant extension | + +These produce more bugs than the prover-based pipeline at the scale reported, and each has an execution- or analysis-based confirmation step. Sailor's baseline row is also the clearest evidence in the corpus that an LLM agent alone, without a verifier or replay stage, finds far fewer bugs at scale (12 versus 379), which supports adding a verification stage but does not compare a prover against fuzzing. A prover pipeline should be compared against them, and, more usefully, composed with them: the Sailor design (static analysis nominates sites; LLM builds the harness; concrete replay validates) is exactly the shape a "prover nominates, execution confirms" pipeline would have. + +## 4. Counterexample-guided and cross-check approaches + +- ExVerus: produce a counterexample from the failed proof, feed it back, repair. The counterexample is a concrete input violating a candidate invariant. It targets proof repair, but the same artefact is a bug witness when the spec is right. +- Leanstral: failed proof, then proof of the negation. Formal, but the negation's truth depends on the Aeneas model. +- Vero: lets agents prove a spec unsatisfiable or a reference implementation incorrect; this found latent errors that manual review missed. This is a direct precedent for using prover agents to find *benchmark* bugs. +- Clover: three-way consistency (code, docstring, annotations). Zero false positives on adversarial incorrect programs at 87% acceptance, and 6 incorrect programs in a human-written dataset. The number is small, but the protocol gives a no-ground-truth way to test a spec. +- nl2postcond / NL2Contract: docstring to executable postcondition, tested against buggy mutants and real historical bugs (70 buggy methods from 64 known Defects4J bugs; already known and fixed, so this measures discrimination, not discovery). NL2Contract adds the point that matters for precision: postconditions without preconditions make verifiers report false alarms on invalid inputs, and inferred preconditions reduce them (2510.12702 abstract). That is the same failure as Leanstral's "properties that were too strong". +- Verus-SpecGym: executable specs (`exec_spec`) checked against official tests and adversarial "hack" cases. A spec that rejects a valid test output or accepts a hack case is wrong. This is the cheapest oracle found for the weakest link. + +## 5. Threats to validity + +| Threat | Evidence in the corpus | Mitigation in the literature | +|---|---|---| +| Wrong, weak or vacuous spec | CLEVER designs around specs that "leak implementation logic or allow vacuous solutions"; RLVR thesis: verified reward 2.2% -> 58.1% with specification hacking; Verus-SpecGym: generated specs "omit important input assumptions, accept incorrect outputs, reject valid ones" | Held-out ground-truth specs (CLEVER); test-suite scaling (VeriScale: 83x); adversarial implementations; executable spec checks | +| Spec is right but over-strong, so "disproof" is a false alarm | Leanstral funnel: 11/47 flags genuine, 5 distinct bugs; the rest "too strong" (state unreachable from the public interface) or malformed; NL2Contract: missing preconditions cause false alarms | Human triage (Leanstral); inferred preconditions (NL2Contract); not systematically solved | +| LLM judge of specs is unreliable | Verus-SpecGym: judge misses 26% of failures that the executable check catches | Execution-based evaluators | +| Prover edits the program instead of proving it | DafnyPro Fig. 2, DafnyBench rules forbid `{:verify false}`, `assume false`, edits to requires/ensures | diff-checker; forbidding escape hatches; SafeVerify-style final checks (Leanstral) | +| Verifier unsoundness or toolchain gaps | AlphaVerus names "verifier limitations that cause trivial programs to pass" | Lean kernel check; critique filtering; ceiling tests | +| Translation to the logic is wrong (silent) | C-to-Lean ledger: a translation that "succeeded with an incorrect result"; 24.8% of translated functions are call-closed | Execution check of translated code against tests (Rust-Prover: 19,555 test cases matched in v1, 21,413 in v2) | +| Test-based benchmarks overestimate | VeriScale: larger suites give sharp drops | Larger and adversarial suites | +| "Test-set correct" is not "verification sound" | NL2Contract | Verify, not only test | +| Benchmark errors | Vero audit; Clover found 6 incorrect programs in MBPP-DFY-50 | Prover-assisted audit | +| Data contamination | Not systematically studied in the retrieved corpus | Not covered here; open | +| No comparison to fuzzing on the same targets | Leanstral post has none and its report calls the comparison future work; Big Sleep notes OSS-Fuzz missed its bug, attributing it in the worked example to a harness without the relevant extension | A paired experiment (RQ2) | + +Pre-registration implication: any bug-finding claim should report (a) how many flagged findings were human-triaged and by whom, (b) the split of false alarms between wrong spec, wrong translation and intended behaviour, (c) a fuzzing or static-analysis baseline on the same targets with the same budget. + +## 6. Gaps a small 8B prover on 4x A10G could fill + +Constraints from this repository: Goedel-Prover-V2-8B in bf16 (about 16 GB of weights; one A10G is 23 GB, so KV cache is the limit), Lean pinned at 4.9.0-rc1 with a June 2024 Mathlib (`docs/LEAN_VERIFIER.md`), a strict verifier with measured ceiling (177/178 known-good accepted, 0/61 adversarial accepted), at most 2 Lean workers while the other jobs run. Leanstral 1.5 itself is a 119B-parameter model (6B to 6.5B active): about 238 GB of weights in bf16 and about 119 GB at 8 bits, against 4 x 23 GB = 92 GB of A10G memory, and its Hugging Face card gives `--tensor-parallel-size 4` and a 200k context for larger GPUs. It does not fit this box without aggressive quantisation (about 60 GB at 4 bits, leaving little for a long KV cache; not tested), so it is not a like-for-like local competitor. Its hosted API is deprecated (section 0). + +1. **Spec-first yield, not proof strength.** Frontier agents close most proofs given a good spec on the benchmarks that report it (Rust-Prover, VeruSAGE; section 3.2; single-paper evidence for the strongest claim). The unsolved part is the funnel's precision: 23% per flag and 11% per distinct bug. A small model is unlikely to win on proof depth; whether it can make up for that with much higher sample volume is an untested assumption, and RQ1 and RQ2 measure it. The contribution would be a cheap, calibrated property-and-disproof generator with a precision gate. +2. **Disproof on code properties as a trained skill.** Leanstral already trains on negations of competition statements (30% of its RL mix), so the gap is narrower than "no one rewards negation proofs". No retrieved source reports negation training on code properties, a bug-yield reward, a witness requirement, or an ablation of the disprove branch. A Lean verifier that already rejects cheating (the ceiling test) can score "proved `not P`" the same way as "proved `P`"; whether the training signal is useful is what RQ1 tests. +3. **Toolchain mismatch is an open engineering gap, and it is wider than Rust.** Aeneas-based work (2605.30106, 2609.15648) reports Lean toolchain drift as an obstacle, and this repository's verifier is pinned to Lean 4.9.0-rc1 (June 2024 Mathlib). The Lean benchmarks the RQs use are on newer toolchains: FVAPPS states Lean 4.12.0 (2502.05714), VERINA's repository pins Lean and Mathlib v4.18.0 (read from its `lean-toolchain` and `lakefile.lean` on GitHub, not from the paper), VeriScale reports v4.24.0 and Vero v4.29.1. So FVAPPS, VERINA and Vero cannot be checked by the current verifier as is, and "Python-via-FVSpec or FVAPPS-style targets avoid the problem" (an earlier claim here) is false for the pinned toolchain. Each extra toolchain needs its own install and its own ceiling test (known-good accepted, adversarial rejected), and the prover itself may degrade on syntax newer than its training data. Not tested here. +4. **No benchmark of real bugs with a ground-truth oracle in the Lean setting.** Existing Lean benchmarks (FVAPPS, VERINA, CLEVER, miniCodeProps, VeriSoftBench) test proofs of correct code. Bug-finding evaluation exists only as the Leanstral anecdote. A paired benchmark from historical bug-fix commits (property fails on pre-fix, holds on post-fix) would be new. The design is borrowed from KNighter (patch-guided), Big Sleep (variant analysis from a fixed bug) and CheckerBench (vulnerable and fixed revisions with a pinned environment, 2610.07557, abstract only), none of which is in Lean. +5. **Spec validation without an LLM judge.** Executable-spec checks (Verus-SpecGym), adversarial test suites (VeriScale) and three-way consistency (Clover) have not been combined into one gate in front of a prover. A gate that is itself measurable (precision/recall against seeded spec errors) is a small, publishable component. +6. **Compute-matched comparison.** Leanstral's scaling is reported to 4M tokens per attempt (587/672 on PutnamBench at 4M, 44 at 50k). Nobody reports what an 8B model achieves on a bug-finding task at, say, 32k tokens times many samples. The cost-per-genuine-bug is the figure missing from the literature. +7. **Isabelle and Coq.** No evidence retrieved; any claim for those systems needs a separate search. + +## 7. Research questions for a publication + +Common design elements: all prover arms use the same strict verifier; a finding counts as a bug only if it passes an execution check (an input on which the original code violates the human-agreed intent) or independent human triage by two raters; report Wilson 95% intervals for single proportions and paired bootstrap or exact McNemar intervals for between-arm differences (a Wilson interval does not apply to a paired difference); at most 2 Lean workers and `CUDA_VISIBLE_DEVICES` isolation while other jobs run; no heavy-job lock taken by the experiment unless GPUs are free. Feasibility prerequisites for every RQ: a second Lean toolchain matching the benchmark (section 6, gap 3) with its own ceiling test; training limited to LoRA on bf16 weights, as the repository's plan does (`docs/SOTA_PLAN.md`: L5 is 20-40 on-policy LoRA steps of 32 prompts x 8 rollouts), because full fine-tuning an 8B model with optimizer state does not fit 4 x 23 GB without offload. + +### RQ1. Does training on negation proofs raise the yield of genuine bugs per property? + +Hypothesis H1 (falsifiable): Starting from the same 8B base, a LoRA RL stage (the repository's L5 is the template) with a reward that gives credit for a kernel-checked proof of either `P` or `not P` (the latter only when `not P` is witnessed by a concrete input that the execution check confirms) yields at least +10 percentage points more genuine bugs found on a fixed bank of properties than the same stage trained prove-only, with precision no more than 5pp lower. H1 is rejected if the 95% paired interval for the recall difference includes 0 or falls below +10pp at its point estimate, or if precision falls by more than 5pp. Novelty note: Leanstral already trains on negation proofs of competition statements (section 3.3); what H1 adds is code properties and the witness requirement. Power: with 300 or more paired items and a discordance rate near 0.3, the paired standard error is about 3pp and the minimum detectable difference at 80% power about 9pp, so 300 items is the floor; with 20-40 LoRA steps an effect this large is an optimistic bet and a null result is a plausible outcome that the design can report. + +Experiment: build a seeded-bug corpus by mutating the implementations in VERINA, CLEVER and FVAPPS (the reference specs are the curated ones; mutation operators as in nl2postcond and Clover adversarial categories), keeping only non-equivalent mutants, i.e. mutants that fail at least one of the benchmark's tests (VERINA ships test suites; VeriScale expands them 83x), plus a real-bug subset from RQ2. The property bank is fixed across arms (reference specs and a frozen set of model-written properties), so that "bugs found per 100 properties" compares like with like. Three arms: base model, prove-only RL, prove-or-disprove RL. Metrics: bug recall (negation proved on mutant), false-alarm rate (negation proved on the unmutated program). With curated reference specs this should be 0 when the reference implementation really satisfies its spec, and a nonzero count then points to a benchmark error or a translation error (the Vero audit found such errors in curated benchmarks). With model-written properties it is not 0 even for a sound verifier, because a property can simply be too strong, so report the two cases separately. Also report tokens per finding. Ablate the reward for the witness check to measure reward hacking directly (the DafnyPro and RLVR failure mode). + +### RQ2. Does a spec-validation gate raise precision from the reported 23% without destroying recall? + +Hypothesis H2: In a Leanstral-style funnel (model writes properties from code, tries a proof, then tries the negation), a gate that keeps only properties that (a) agree across three independently sampled re-derivations from the docstring, (b) pass an executable-spec test on the repository's own test suite and on adversarial cases, and (c) are not trivially true or false under mutation, raises the share of flagged violations that are genuine from the baseline measured for our own ungated funnel (Leanstral reports 11/47 = 23% per flag, 5/47 = 11% per distinct bug, on a different model and on Rust) to at least 50% and at least 20pp above that measured baseline, while keeping at least 80% of the genuine bugs the ungated funnel finds. H2 is rejected if either precision target is missed or recall loss exceeds 20%. Power: retention of 80% is only resolvable with enough ungated findings; with 40 genuine ungated findings the Wilson interval on a retained fraction near 0.8 is roughly +/-12pp, with 15 it is roughly +/-20pp, so plan for at least 40 (which may need well over 100 commits) or declare the recall criterion exploratory. + +Experiment: use a pre-fix/post-fix design on 100 or more real bug-fix commits from Python projects with property-based tests (FVSpec corpus, 2606.01008) or Dafny/Verus repositories that have a public fix history. The gold label is "property fails on pre-fix code and holds on post-fix code". Caveat: FVSpec's properties are scraped from current property-based tests, not from fix history, so the commits must be mined separately, and both the pre-fix and post-fix Python must be translated into Lean (FVSpec itself reports modelling Python in Lean as the hard part), which re-introduces the translation-fidelity threat of section 5; check each translation by running it against the repository's tests as Rust-Prover does. Compare against a proof-free baseline given the same token budget (a Big Sleep-style variant-analysis prompt, and a fuzzer using LLM-written drivers as in 2307.12469). Report overlap: how many bugs are found only by the prover pipeline, since the Leanstral post asserts, without measuring it, that boundary bugs like the zigzag overflow are missed by fuzzing (and its own footnote shows an LLM reading the code also finds that one). + +### RQ3. Does adding synthesized code-property data to math-prover training transfer, and at what cost to math? + +Hypothesis H3: Mixing 20% verifier-filtered code-property data (AlphaVerus/VeruSyn-style synthesis, filtered with the strict verifier and with VeriScale-style adversarial implementations to remove weak specs) into the SFT mixture of Goedel-Prover-V2-8B raises VERINA proof-generation success by at least 5pp (given the 4.9% best-model figure for one trial, the baseline must be measured first, and it needs a Lean 4.18 toolchain) with no more than 3pp regression on miniF2F pass@8 at 16k, measured on the existing P2 baseline (78.7%, 192/244 on miniF2F-test, `docs/SOTA_TARGETS.md`). H3 is rejected if the paired VERINA gain interval includes 0, or if the one-sided 95% paired upper bound on the miniF2F drop exceeds 3pp. The margin was 1pp in the first draft; that is unresolvable here: at 78.7% on 244 problems the binomial standard error is about 2.6pp, and a paired comparison with roughly 12% discordant problems still has a standard error near 2.2pp. Even 3pp needs pooling across seeds or miniF2F valid plus test (488 problems, paired standard error near 1.6pp). + +Experiment: three mixtures (0%, 10%, 20% code-property data), matched token budget and schedule, three seeds each (nine LoRA runs), evaluated on VERINA and miniF2F, and miniCodeProps if its Lean version is compatible with an installed toolchain (not checked). CLEVER and VeriSoftBench as held-out checks would each need a further toolchain and are better left to a follow-up. Note that the filtered synthetic data must come from somewhere: AlphaVerus and VeruSyn are Rust/Verus, so their pipelines produce Verus programs, not Lean; a Lean-native synthesis step (as in WybeCoder or the Rust-Prover translation) has to be built and is the largest unbudgeted piece of work. Report pass@1, pass@8 and the unbiased pass@k estimator; decontaminate the synthesised set against all evaluation sets before training. + +## 8. Open questions + +1. What was the split of Leanstral's 36 non-bug flags (too strong, malformed, translation error, intended behaviour)? The technical report names the first two classes qualitatively and gives no counts; a request to the authors, or a re-run on the same 57 repositories, would fix the baseline for RQ2. The 57 repositories are not listed. +2. Does the Lean 4.9 pin used by the verifier here stay compatible with any Aeneas version, or must a second toolchain be added? Not tested; for VERINA (Lean 4.18), FVAPPS (4.12) and Vero (4.29) a second toolchain is needed regardless. +3. Is a Python or Dafny target a better first domain than Rust, given the toolchain risk and that FVSpec and FVAPPS are already in Lean? +4. Leanstral 1.5 availability: Mistral's own model card marks the API model deprecated as of 9/29/2026 (a third-party note says 30 Sep), so the hosted endpoint should not be planned on. A local baseline is not realistic on 4x A10G: 119B parameters are about 238 GB in bf16 against 92 GB of VRAM (section 6); a 4-bit quantised run is the only candidate and was not tested. +5. Contamination of the 2024-2026 benchmarks with training data was not studied in any retrieved paper. +6. Vostd/Asterinas spec-failure case study (a rare real-world account of LLM spec failures) could not be fetched; worth retrieving through a browser lane. + +## 9. References (all fetched into the vault under tag `leanstral-bugfinding` unless marked) + +- AutoSpec: arXiv 2404.00762. SpecGen: 2401.08807. nl2postcond: 2310.01831. NL2Contract: 2510.12702. +- Clover: 2310.17807. AlphaVerus: 2412.06176. SAFE: 2410.15756. VeruSyn: 2602.04910. VeruSAGE: 2512.18436. ExVerus: 2603.25810. +- DafnyBench: 2406.08467. dafny-annotator: 2411.15143. DafnyPro: 2601.05385. RLVR/Dafny thesis: 2605.30914. +- FVAPPS: 2502.05714. miniCodeProps: 2406.11915. CLEVER: 2505.13938. VERINA: 2505.23135. VeriScale: 2605.22368. WybeCoder: 2603.29088. AlgoVeri: 2602.09464. VeriContest: 2605.08553. Rust-Prover: 2610.03994. Verus-SpecGym: 2605.26457. VeriSoftBench: 2602.18307. FVSpec: 2606.01008. Vero: 2608.13522. +- Rust-to-Lean pipeline: 2605.30106. SymCrypt/Aeneas: 2609.15648. C-to-Lean trust ledger: 2609.38237. +- IRIS: 2405.17238. KNighter: 2503.09002. Sailor: 2604.06506. LLM fuzz drivers: 2307.12469. +- Leanstral 1.5: Mistral AI, "Leanstral 1.5: Proof Abundance for All", 2 Jul 2026, https://mistral.ai/news/leanstral-1-5/ (vault note `leanstral-15-proof-abundance-for-all`); model card note `mistralaileanstral-15-119b-a6b-hugging-face`; retirement-conflict note `leanstral-15-retires-30-sept-2026-mistral-docs-conflict` (third party). +- Big Sleep: Google Project Zero, "From Naptime to Big Sleep", Oct 2024, https://projectzero.google/2024/10/from-naptime-to-big-sleep.html (vault note `from-naptime-to-big-sleep-using-large-language-models-to-catch-vulnerabilities-i`). +- Not fetched in full: Vostd/Asterinas case study doi 10.1145/3832783.3834504 (no figures used); AutoACSL 2606.20969 and SpecMind 2602.20610 (ids checked, no figures used); CheckerBench 2610.07557 (abstract read on arXiv, figures used only as marked). +- Leanstral technical report: Mistral AI, "Leanstral" technical report, 2 Jul 2026, PDF at https://github.com/mistralai/LeanstralSafeVerify (vault note `in-this-work-we-present-leanstral-a-series-of-generalist-code-agent-models-for`), sections 2.2, 4.2. Mistral model card https://docs.mistral.ai/models/model-cards/leanstral-1-5 (vault note `leanstral-15-mistral-ai-mistral-docs`). diff --git a/code/docs/results/kimina_agreement_report.json b/code/docs/results/kimina_agreement_report.json new file mode 100644 index 0000000000000000000000000000000000000000..365dd052427f08837587c7033c0303cd73ad2e97 --- /dev/null +++ b/code/docs/results/kimina_agreement_report.json @@ -0,0 +1,1794 @@ +{ + "phase_meta": { + "ours": { + "walls": { + "main": 3532.5154225580045, + "bomb": 52.437615262999316 + }, + "workers": 4 + }, + "control": { + "wall": 2984.9150769609987, + "workers": 4 + }, + "kimina": { + "walls": { + "main": 2901.3949153740105, + "bomb": 99.10678565400303 + }, + "workers": 4 + } + }, + "matrix": { + "kg": { + "n": 180, + "rows(ours)xcols(kimina)": { + "accept": { + "maybe": 177 + }, + "undecided": { + "error": 1, + "maybe": 2 + } + } + }, + "adv": { + "n": 61, + "rows(ours)xcols(kimina)": { + "reject": { + "reject": 18, + "maybe": 40 + }, + "undecided": { + "error": 3 + } + } + }, + "sample": { + "n": 500, + "rows(ours)xcols(kimina)": { + "accept": { + "maybe": 308 + }, + "reject": { + "reject": 155 + }, + "undecided": { + "error": 29, + "reject": 5, + "maybe": 3 + } + } + }, + "ALL": { + "n": 741, + "rows(ours)xcols(kimina)": { + "accept": { + "maybe": 485 + }, + "undecided": { + "error": 33, + "maybe": 5, + "reject": 5 + }, + "reject": { + "reject": 173, + "maybe": 40 + } + } + } + }, + "disagreements": [ + { + "id": "kg:lean_workbook_10036", + "set": "kg", + "klass": "UNDECIDED_ON_ONE_SIDE", + "ours": "undecided", + "ours_reasons": [ + "timeout" + ], + "ours_s": 180.0, + "kimina": "error", + "kimina_reasons": [ + "timeout" + ], + "kimina_detail": { + "error": "Lean REPL command timed out in 180.0 seconds" + }, + "kimina_s": 180.14, + "control": { + "ok": false, + "clean": false, + "why": "timeout" + } + }, + { + "id": "kg:lean_workbook_51088", + "set": "kg", + "klass": "UNDECIDED_ON_ONE_SIDE", + "ours": "undecided", + "ours_reasons": [ + "timeout" + ], + "ours_s": 180.0, + "kimina": "maybe", + "kimina_reasons": [], + "kimina_detail": {}, + "kimina_s": 81.55, + "control": { + "ok": true, + "clean": true, + "why": "" + } + }, + { + "id": "kg:lean_workbook_plus_64037", + "set": "kg", + "klass": "UNDECIDED_ON_ONE_SIDE", + "ours": "undecided", + "ours_reasons": [ + "timeout" + ], + "ours_s": 180.0, + "kimina": "maybe", + "kimina_reasons": [], + "kimina_detail": {}, + "kimina_s": 109.05, + "control": { + "ok": true, + "clean": true, + "why": "" + } + }, + { + "id": "adv:axiom_false", + "set": "adv", + "klass": "STRICT_ONLY_REJECT (expected: policy/audit layer)", + "ours": "reject", + "ours_reasons": [ + "banned:axiom" + ], + "ours_s": 0.0, + "kimina": "maybe", + "kimina_reasons": [], + "kimina_detail": {}, + "kimina_s": 0.13, + "control": null + }, + { + "id": "adv:axiom_claim", + "set": "adv", + "klass": "STRICT_ONLY_REJECT (expected: policy/audit layer)", + "ours": "reject", + "ours_reasons": [ + "banned:axiom" + ], + "ours_s": 0.0, + "kimina": "maybe", + "kimina_reasons": [], + "kimina_detail": {}, + "kimina_s": 0.1, + "control": null + }, + { + "id": "adv:axiom_polymorphic", + "set": "adv", + "klass": "STRICT_ONLY_REJECT (expected: policy/audit layer)", + "ours": "reject", + "ours_reasons": [ + "banned:axiom" + ], + "ours_s": 0.0, + "kimina": "maybe", + "kimina_reasons": [], + "kimina_detail": {}, + "kimina_s": 0.1, + "control": null + }, + { + "id": "adv:axiom_via_simpa", + "set": "adv", + "klass": "STRICT_ONLY_REJECT (expected: policy/audit layer)", + "ours": "reject", + "ours_reasons": [ + "banned:axiom" + ], + "ours_s": 0.0, + "kimina": "maybe", + "kimina_reasons": [], + "kimina_detail": {}, + "kimina_s": 0.1, + "control": null + }, + { + "id": "adv:native_decide_tactic", + "set": "adv", + "klass": "STRICT_ONLY_REJECT (expected: policy/audit layer)", + "ours": "reject", + "ours_reasons": [ + "banned:native_decide" + ], + "ours_s": 0.0, + "kimina": "maybe", + "kimina_reasons": [], + "kimina_detail": {}, + "kimina_s": 0.1, + "control": null + }, + { + "id": "adv:native_decide_in_have", + "set": "adv", + "klass": "STRICT_ONLY_REJECT (expected: policy/audit layer)", + "ours": "reject", + "ours_reasons": [ + "banned:native_decide" + ], + "ours_s": 0.0, + "kimina": "maybe", + "kimina_reasons": [], + "kimina_detail": {}, + "kimina_s": 0.1, + "control": null + }, + { + "id": "adv:native_decide_in_helper_lemma", + "set": "adv", + "klass": "STRICT_ONLY_REJECT (expected: policy/audit layer)", + "ours": "reject", + "ours_reasons": [ + "banned:native_decide" + ], + "ours_s": 0.0, + "kimina": "maybe", + "kimina_reasons": [], + "kimina_detail": {}, + "kimina_s": 0.12, + "control": null + }, + { + "id": "adv:conclusion_changed", + "set": "adv", + "klass": "STRICT_ONLY_REJECT (expected: policy/audit layer)", + "ours": "reject", + "ours_reasons": [ + "statement_mismatch" + ], + "ours_s": 0.0, + "kimina": "maybe", + "kimina_reasons": [], + "kimina_detail": {}, + "kimina_s": 0.35, + "control": null + }, + { + "id": "adv:constant_changed", + "set": "adv", + "klass": "STRICT_ONLY_REJECT (expected: policy/audit layer)", + "ours": "reject", + "ours_reasons": [ + "statement_mismatch" + ], + "ours_s": 0.0, + "kimina": "maybe", + "kimina_reasons": [], + "kimina_detail": {}, + "kimina_s": 0.51, + "control": null + }, + { + "id": "adv:conclusion_trivialized", + "set": "adv", + "klass": "STRICT_ONLY_REJECT (expected: policy/audit layer)", + "ours": "reject", + "ours_reasons": [ + "statement_mismatch" + ], + "ours_s": 0.0, + "kimina": "maybe", + "kimina_reasons": [], + "kimina_detail": {}, + "kimina_s": 0.1, + "control": null + }, + { + "id": "adv:binder_order_swapped", + "set": "adv", + "klass": "STRICT_ONLY_REJECT (expected: policy/audit layer)", + "ours": "reject", + "ours_reasons": [ + "statement_mismatch" + ], + "ours_s": 0.0, + "kimina": "maybe", + "kimina_reasons": [], + "kimina_detail": {}, + "kimina_s": 0.11, + "control": null + }, + { + "id": "adv:statement_only_in_comment", + "set": "adv", + "klass": "STRICT_ONLY_REJECT (expected: policy/audit layer)", + "ours": "reject", + "ours_reasons": [ + "statement_mismatch" + ], + "ours_s": 0.0, + "kimina": "maybe", + "kimina_reasons": [], + "kimina_detail": {}, + "kimina_s": 0.1, + "control": null + }, + { + "id": "adv:false_statement_made_true", + "set": "adv", + "klass": "STRICT_ONLY_REJECT (expected: policy/audit layer)", + "ours": "reject", + "ours_reasons": [ + "statement_mismatch" + ], + "ours_s": 0.0, + "kimina": "maybe", + "kimina_reasons": [], + "kimina_detail": {}, + "kimina_s": 0.1, + "control": null + }, + { + "id": "adv:extra_hypothesis_a_eq_b", + "set": "adv", + "klass": "STRICT_ONLY_REJECT (expected: policy/audit layer)", + "ours": "reject", + "ours_reasons": [ + "statement_mismatch" + ], + "ours_s": 0.0, + "kimina": "maybe", + "kimina_reasons": [], + "kimina_detail": {}, + "kimina_s": 0.31, + "control": null + }, + { + "id": "adv:hypothesis_false_added", + "set": "adv", + "klass": "STRICT_ONLY_REJECT (expected: policy/audit layer)", + "ours": "reject", + "ours_reasons": [ + "statement_mismatch" + ], + "ours_s": 0.0, + "kimina": "maybe", + "kimina_reasons": [], + "kimina_detail": {}, + "kimina_s": 0.1, + "control": null + }, + { + "id": "adv:hypothesis_strengthened", + "set": "adv", + "klass": "STRICT_ONLY_REJECT (expected: policy/audit layer)", + "ours": "reject", + "ours_reasons": [ + "statement_mismatch" + ], + "ours_s": 0.0, + "kimina": "maybe", + "kimina_reasons": [], + "kimina_detail": {}, + "kimina_s": 0.1, + "control": null + }, + { + "id": "adv:instance_hole", + "set": "adv", + "klass": "STRICT_ONLY_REJECT (expected: policy/audit layer)", + "ours": "reject", + "ours_reasons": [ + "banned:instance" + ], + "ours_s": 0.0, + "kimina": "maybe", + "kimina_reasons": [], + "kimina_detail": {}, + "kimina_s": 0.1, + "control": null + }, + { + "id": "adv:variable_hole", + "set": "adv", + "klass": "STRICT_ONLY_REJECT (expected: policy/audit layer)", + "ours": "reject", + "ours_reasons": [ + "banned:variable" + ], + "ours_s": 0.0, + "kimina": "maybe", + "kimina_reasons": [], + "kimina_detail": {}, + "kimina_s": 0.1, + "control": null + }, + { + "id": "adv:variable_hole_hypothesis", + "set": "adv", + "klass": "STRICT_ONLY_REJECT (expected: policy/audit layer)", + "ours": "reject", + "ours_reasons": [ + "banned:variable" + ], + "ours_s": 0.0, + "kimina": "maybe", + "kimina_reasons": [], + "kimina_detail": {}, + "kimina_s": 0.11, + "control": null + }, + { + "id": "adv:def_shadow_hole", + "set": "adv", + "klass": "STRICT_ONLY_REJECT (expected: policy/audit layer)", + "ours": "reject", + "ours_reasons": [ + "banned:def", + "banned:local", + "banned:meta" + ], + "ours_s": 0.0, + "kimina": "maybe", + "kimina_reasons": [], + "kimina_detail": {}, + "kimina_s": 0.1, + "control": null + }, + { + "id": "adv:elab_adds_axiom", + "set": "adv", + "klass": "STRICT_ONLY_REJECT (expected: policy/audit layer)", + "ours": "reject", + "ours_reasons": [ + "banned:meta_api:Lean", + "banned:meta_api:Elab", + "banned:meta" + ], + "ours_s": 0.0, + "kimina": "maybe", + "kimina_reasons": [], + "kimina_detail": {}, + "kimina_s": 0.1, + "control": null + }, + { + "id": "adv:run_cmd_adds_axiom", + "set": "adv", + "klass": "STRICT_ONLY_REJECT (expected: policy/audit layer)", + "ours": "reject", + "ours_reasons": [ + "banned:meta_api:Lean", + "banned:meta_api:Elab", + "banned:meta" + ], + "ours_s": 0.0, + "kimina": "maybe", + "kimina_reasons": [], + "kimina_detail": {}, + "kimina_s": 0.11, + "control": null + }, + { + "id": "adv:initialize_hook", + "set": "adv", + "klass": "STRICT_ONLY_REJECT (expected: policy/audit layer)", + "ours": "reject", + "ours_reasons": [ + "banned:initialize", + "banned:meta_api:IO" + ], + "ours_s": 0.0, + "kimina": "maybe", + "kimina_reasons": [], + "kimina_detail": {}, + "kimina_s": 0.31, + "control": null + }, + { + "id": "adv:eval_side_effect", + "set": "adv", + "klass": "STRICT_ONLY_REJECT (expected: policy/audit layer)", + "ours": "reject", + "ours_reasons": [ + "banned:#eval", + "banned:meta_api:IO" + ], + "ours_s": 0.0, + "kimina": "maybe", + "kimina_reasons": [], + "kimina_detail": {}, + "kimina_s": 0.1, + "control": null + }, + { + "id": "adv:exit_after_theorem", + "set": "adv", + "klass": "STRICT_ONLY_REJECT (expected: policy/audit layer)", + "ours": "reject", + "ours_reasons": [ + "banned:#exit", + "banned:sorry" + ], + "ours_s": 0.0, + "kimina": "maybe", + "kimina_reasons": [], + "kimina_detail": {}, + "kimina_s": 0.1, + "control": null + }, + { + "id": "adv:wrong_name_prime", + "set": "adv", + "klass": "STRICT_ONLY_REJECT (expected: policy/audit layer)", + "ours": "reject", + "ours_reasons": [ + "target_missing" + ], + "ours_s": 0.0, + "kimina": "maybe", + "kimina_reasons": [], + "kimina_detail": {}, + "kimina_s": 0.31, + "control": null + }, + { + "id": "adv:wrong_name_other", + "set": "adv", + "klass": "STRICT_ONLY_REJECT (expected: policy/audit layer)", + "ours": "reject", + "ours_reasons": [ + "target_missing" + ], + "ours_s": 0.0, + "kimina": "maybe", + "kimina_reasons": [], + "kimina_detail": {}, + "kimina_s": 0.1, + "control": null + }, + { + "id": "adv:namespace_wrapped", + "set": "adv", + "klass": "STRICT_ONLY_REJECT (expected: policy/audit layer)", + "ours": "reject", + "ours_reasons": [ + "banned:namespace" + ], + "ours_s": 0.0, + "kimina": "maybe", + "kimina_reasons": [], + "kimina_detail": {}, + "kimina_s": 0.31, + "control": null + }, + { + "id": "adv:private_target", + "set": "adv", + "klass": "STRICT_ONLY_REJECT (expected: policy/audit layer)", + "ours": "reject", + "ours_reasons": [ + "target_modifier" + ], + "ours_s": 0.0, + "kimina": "maybe", + "kimina_reasons": [], + "kimina_detail": {}, + "kimina_s": 0.2, + "control": null + }, + { + "id": "adv:attribute_on_target", + "set": "adv", + "klass": "STRICT_ONLY_REJECT (expected: policy/audit layer)", + "ours": "reject", + "ours_reasons": [ + "target_modifier" + ], + "ours_s": 0.0, + "kimina": "maybe", + "kimina_reasons": [], + "kimina_detail": {}, + "kimina_s": 0.2, + "control": null + }, + { + "id": "adv:timeout_bomb_norm_num_prime", + "set": "adv", + "klass": "UNDECIDED_ON_ONE_SIDE", + "ours": "undecided", + "ours_reasons": [ + "timeout" + ], + "ours_s": 30.03, + "kimina": "error", + "kimina_reasons": [ + "timeout" + ], + "kimina_detail": { + "error": "Lean REPL command timed out in 30.0 seconds" + }, + "kimina_s": 32.97, + "control": null + }, + { + "id": "adv:crash_stack_overflow", + "set": "adv", + "klass": "UNDECIDED_ON_ONE_SIDE", + "ours": "undecided", + "ours_reasons": [ + "crash" + ], + "ours_s": 4.71, + "kimina": "error", + "kimina_reasons": [ + "timeout" + ], + "kimina_detail": { + "error": "Lean REPL command timed out in 30.0 seconds" + }, + "kimina_s": 33.05, + "control": null + }, + { + "id": "adv:memory_bomb_pow", + "set": "adv", + "klass": "UNDECIDED_ON_ONE_SIDE", + "ours": "undecided", + "ours_reasons": [ + "memory" + ], + "ours_s": 17.7, + "kimina": "error", + "kimina_reasons": [ + "timeout" + ], + "kimina_detail": { + "error": "Lean REPL command timed out in 30.0 seconds" + }, + "kimina_s": 33.09, + "control": null + }, + { + "id": "adv:unsafe_implemented_by", + "set": "adv", + "klass": "STRICT_ONLY_REJECT (expected: policy/audit layer)", + "ours": "reject", + "ours_reasons": [ + "banned:unsafe", + "banned:def", + "banned:implemented_by", + "banned:attr:implemented_by" + ], + "ours_s": 0.0, + "kimina": "maybe", + "kimina_reasons": [], + "kimina_detail": {}, + "kimina_s": 0.1, + "control": null + }, + { + "id": "adv:partial_def", + "set": "adv", + "klass": "STRICT_ONLY_REJECT (expected: policy/audit layer)", + "ours": "reject", + "ours_reasons": [ + "banned:partial", + "banned:def" + ], + "ours_s": 0.0, + "kimina": "maybe", + "kimina_reasons": [], + "kimina_detail": {}, + "kimina_s": 0.1, + "control": null + }, + { + "id": "adv:extern_attr", + "set": "adv", + "klass": "STRICT_ONLY_REJECT (expected: policy/audit layer)", + "ours": "reject", + "ours_reasons": [ + "banned:extern", + "banned:axiom", + "banned:attr:extern" + ], + "ours_s": 0.0, + "kimina": "maybe", + "kimina_reasons": [], + "kimina_detail": {}, + "kimina_s": 0.1, + "control": null + }, + { + "id": "adv:search_tactic_exact_q", + "set": "adv", + "klass": "STRICT_ONLY_REJECT (expected: policy/audit layer)", + "ours": "reject", + "ours_reasons": [ + "banned:search" + ], + "ours_s": 0.0, + "kimina": "maybe", + "kimina_reasons": [], + "kimina_detail": {}, + "kimina_s": 0.2, + "control": null + }, + { + "id": "adv:maxRecDepth_abuse", + "set": "adv", + "klass": "STRICT_ONLY_REJECT (expected: policy/audit layer)", + "ours": "reject", + "ours_reasons": [ + "banned:set_option:maxRecDepth>16384" + ], + "ours_s": 0.0, + "kimina": "maybe", + "kimina_reasons": [], + "kimina_detail": {}, + "kimina_s": 0.1, + "control": null + }, + { + "id": "adv:helper_def_with_proof", + "set": "adv", + "klass": "STRICT_ONLY_REJECT (expected: policy/audit layer)", + "ours": "reject", + "ours_reasons": [ + "banned:def" + ], + "ours_s": 0.0, + "kimina": "maybe", + "kimina_reasons": [], + "kimina_detail": {}, + "kimina_s": 0.11, + "control": null + }, + { + "id": "adv:header_dropped_open", + "set": "adv", + "klass": "STRICT_ONLY_REJECT (expected: policy/audit layer)", + "ours": "reject", + "ours_reasons": [ + "header_mismatch" + ], + "ours_s": 0.0, + "kimina": "maybe", + "kimina_reasons": [], + "kimina_detail": {}, + "kimina_s": 0.31, + "control": null + }, + { + "id": "adv:header_extra_import", + "set": "adv", + "klass": "STRICT_ONLY_REJECT (expected: policy/audit layer)", + "ours": "reject", + "ours_reasons": [ + "header_mismatch" + ], + "ours_s": 0.0, + "kimina": "maybe", + "kimina_reasons": [], + "kimina_detail": {}, + "kimina_s": 3.78, + "control": null + }, + { + "id": "adv:header_heartbeats_changed", + "set": "adv", + "klass": "STRICT_ONLY_REJECT (expected: policy/audit layer)", + "ours": "reject", + "ours_reasons": [ + "header_mismatch" + ], + "ours_s": 0.0, + "kimina": "maybe", + "kimina_reasons": [], + "kimina_detail": {}, + "kimina_s": 0.32, + "control": null + }, + { + "id": "adv:header_only", + "set": "adv", + "klass": "STRICT_ONLY_REJECT (expected: policy/audit layer)", + "ours": "reject", + "ours_reasons": [ + "target_missing" + ], + "ours_s": 0.0, + "kimina": "maybe", + "kimina_reasons": [], + "kimina_detail": {}, + "kimina_s": 3.39, + "control": null + }, + { + "id": "gen:mathd_numbertheory_175#22", + "set": "sample", + "klass": "UNDECIDED_ON_ONE_SIDE", + "ours": "undecided", + "ours_reasons": [ + "timeout" + ], + "ours_s": 180.0, + "kimina": "error", + "kimina_reasons": [ + "timeout" + ], + "kimina_detail": { + "error": "Lean REPL command timed out in 180.0 seconds" + }, + "kimina_s": 180.12, + "control": { + "ok": false, + "clean": false, + "why": "timeout" + } + }, + { + "id": "gen:numbertheory_3pow2pownm1mod2pownp3eq2pownp2#22", + "set": "sample", + "klass": "UNDECIDED_ON_ONE_SIDE", + "ours": "undecided", + "ours_reasons": [ + "timeout" + ], + "ours_s": 180.0, + "kimina": "error", + "kimina_reasons": [ + "timeout" + ], + "kimina_detail": { + "error": "Lean REPL command timed out in 180.0 seconds" + }, + "kimina_s": 180.03, + "control": { + "ok": false, + "clean": false, + "why": "timeout" + } + }, + { + "id": "gen:numbertheory_exk2powkeqapb2mulbpa2_aeq1#25", + "set": "sample", + "klass": "UNDECIDED_ON_ONE_SIDE", + "ours": "undecided", + "ours_reasons": [ + "timeout" + ], + "ours_s": 180.0, + "kimina": "error", + "kimina_reasons": [ + "timeout" + ], + "kimina_detail": { + "error": "Lean REPL command timed out in 180.0 seconds" + }, + "kimina_s": 180.02, + "control": { + "ok": false, + "clean": false, + "why": "timeout" + } + }, + { + "id": "gen:imosl_2007_algebra_p6#23", + "set": "sample", + "klass": "UNDECIDED_ON_ONE_SIDE", + "ours": "undecided", + "ours_reasons": [ + "timeout" + ], + "ours_s": 180.04, + "kimina": "error", + "kimina_reasons": [ + "timeout" + ], + "kimina_detail": { + "error": "Lean REPL command timed out in 180.0 seconds" + }, + "kimina_s": 183.33, + "control": { + "ok": false, + "clean": false, + "why": "timeout" + } + }, + { + "id": "gen:amc12a_2021_p8#14", + "set": "sample", + "klass": "UNDECIDED_ON_ONE_SIDE", + "ours": "undecided", + "ours_reasons": [ + "timeout" + ], + "ours_s": 180.12, + "kimina": "error", + "kimina_reasons": [ + "timeout" + ], + "kimina_detail": { + "error": "Lean REPL command timed out in 180.0 seconds" + }, + "kimina_s": 180.02, + "control": { + "ok": false, + "clean": false, + "why": "timeout" + } + }, + { + "id": "gen:imo_1984_p6#12", + "set": "sample", + "klass": "UNDECIDED_ON_ONE_SIDE", + "ours": "undecided", + "ours_reasons": [ + "timeout" + ], + "ours_s": 180.0, + "kimina": "error", + "kimina_reasons": [ + "timeout" + ], + "kimina_detail": { + "error": "Lean REPL command timed out in 180.0 seconds" + }, + "kimina_s": 180.03, + "control": { + "ok": false, + "clean": false, + "why": "timeout" + } + }, + { + "id": "gen:amc12b_2002_p7#19", + "set": "sample", + "klass": "UNDECIDED_ON_ONE_SIDE", + "ours": "undecided", + "ours_reasons": [ + "timeout" + ], + "ours_s": 180.07, + "kimina": "error", + "kimina_reasons": [ + "timeout" + ], + "kimina_detail": { + "error": "Lean REPL command timed out in 180.0 seconds" + }, + "kimina_s": 180.07, + "control": { + "ok": false, + "clean": false, + "why": "timeout" + } + }, + { + "id": "gen:amc12a_2021_p12#7", + "set": "sample", + "klass": "UNDECIDED_ON_ONE_SIDE", + "ours": "undecided", + "ours_reasons": [ + "timeout" + ], + "ours_s": 180.0, + "kimina": "error", + "kimina_reasons": [ + "timeout" + ], + "kimina_detail": { + "error": "Lean REPL command timed out in 180.0 seconds" + }, + "kimina_s": 183.36, + "control": { + "ok": false, + "clean": false, + "why": "timeout" + } + }, + { + "id": "gen:amc12a_2020_p4#22", + "set": "sample", + "klass": "UNDECIDED_ON_ONE_SIDE", + "ours": "undecided", + "ours_reasons": [ + "memory" + ], + "ours_s": 20.82, + "kimina": "error", + "kimina_reasons": [ + "http_500" + ], + "kimina_detail": {}, + "kimina_s": 129.45, + "control": { + "ok": false, + "clean": false, + "why": "memory" + } + }, + { + "id": "gen:imo_1965_p2#11", + "set": "sample", + "klass": "UNDECIDED_ON_ONE_SIDE", + "ours": "undecided", + "ours_reasons": [ + "timeout" + ], + "ours_s": 180.0, + "kimina": "error", + "kimina_reasons": [ + "timeout" + ], + "kimina_detail": { + "error": "Lean REPL command timed out in 180.0 seconds" + }, + "kimina_s": 182.94, + "control": { + "ok": false, + "clean": false, + "why": "timeout" + } + }, + { + "id": "gen:imo_1965_p2#28", + "set": "sample", + "klass": "UNDECIDED_ON_ONE_SIDE", + "ours": "undecided", + "ours_reasons": [ + "timeout" + ], + "ours_s": 180.0, + "kimina": "error", + "kimina_reasons": [ + "timeout" + ], + "kimina_detail": { + "error": "Lean REPL command timed out in 180.0 seconds" + }, + "kimina_s": 180.05, + "control": { + "ok": false, + "clean": false, + "why": "timeout" + } + }, + { + "id": "gen:algebra_abpbcpcageq3_sumaonsqrtapbgeq3onsqrt2#30", + "set": "sample", + "klass": "UNDECIDED_ON_ONE_SIDE", + "ours": "undecided", + "ours_reasons": [ + "timeout" + ], + "ours_s": 180.0, + "kimina": "error", + "kimina_reasons": [ + "timeout" + ], + "kimina_detail": { + "error": "Lean REPL command timed out in 180.0 seconds" + }, + "kimina_s": 180.01, + "control": { + "ok": false, + "clean": false, + "why": "timeout" + } + }, + { + "id": "gen:amc12a_2021_p8#31", + "set": "sample", + "klass": "UNDECIDED_ON_ONE_SIDE", + "ours": "undecided", + "ours_reasons": [ + "timeout" + ], + "ours_s": 180.0, + "kimina": "error", + "kimina_reasons": [ + "timeout" + ], + "kimina_detail": { + "error": "Lean REPL command timed out in 180.0 seconds" + }, + "kimina_s": 180.09, + "control": { + "ok": false, + "clean": false, + "why": "timeout" + } + }, + { + "id": "gen:amc12a_2020_p4#10", + "set": "sample", + "klass": "UNDECIDED_ON_ONE_SIDE", + "ours": "undecided", + "ours_reasons": [ + "memory" + ], + "ours_s": 21.74, + "kimina": "error", + "kimina_reasons": [ + "http_500" + ], + "kimina_detail": {}, + "kimina_s": 128.87, + "control": { + "ok": false, + "clean": false, + "why": "memory" + } + }, + { + "id": "gen:numbertheory_3pow2pownm1mod2pownp3eq2pownp2#7", + "set": "sample", + "klass": "UNDECIDED_ON_ONE_SIDE", + "ours": "undecided", + "ours_reasons": [ + "timeout" + ], + "ours_s": 180.0, + "kimina": "error", + "kimina_reasons": [ + "timeout" + ], + "kimina_detail": { + "error": "Lean REPL command timed out in 180.0 seconds" + }, + "kimina_s": 180.04, + "control": { + "ok": false, + "clean": false, + "why": "timeout" + } + }, + { + "id": "gen:amc12a_2008_p25#4", + "set": "sample", + "klass": "UNDECIDED_ON_ONE_SIDE", + "ours": "undecided", + "ours_reasons": [ + "timeout" + ], + "ours_s": 180.0, + "kimina": "error", + "kimina_reasons": [ + "timeout" + ], + "kimina_detail": { + "error": "Lean REPL command timed out in 180.0 seconds" + }, + "kimina_s": 180.02, + "control": { + "ok": false, + "clean": false, + "why": "timeout" + } + }, + { + "id": "gen:numbertheory_exk2powkeqapb2mulbpa2_aeq1#27", + "set": "sample", + "klass": "UNDECIDED_ON_ONE_SIDE", + "ours": "undecided", + "ours_reasons": [ + "timeout" + ], + "ours_s": 180.0, + "kimina": "error", + "kimina_reasons": [ + "timeout" + ], + "kimina_detail": { + "error": "Lean REPL command timed out in 180.0 seconds" + }, + "kimina_s": 180.07, + "control": { + "ok": false, + "clean": false, + "why": "timeout" + } + }, + { + "id": "gen:mathd_numbertheory_451#14", + "set": "sample", + "klass": "UNDECIDED_ON_ONE_SIDE", + "ours": "undecided", + "ours_reasons": [ + "timeout" + ], + "ours_s": 180.0, + "kimina": "error", + "kimina_reasons": [ + "timeout" + ], + "kimina_detail": { + "error": "Lean REPL command timed out in 180.0 seconds" + }, + "kimina_s": 180.11, + "control": { + "ok": false, + "clean": false, + "why": "timeout" + } + }, + { + "id": "gen:aime_1988_p8#28", + "set": "sample", + "klass": "UNDECIDED_ON_ONE_SIDE", + "ours": "undecided", + "ours_reasons": [ + "timeout" + ], + "ours_s": 180.0, + "kimina": "error", + "kimina_reasons": [ + "timeout" + ], + "kimina_detail": { + "error": "Lean REPL command timed out in 180.0 seconds" + }, + "kimina_s": 180.14, + "control": { + "ok": false, + "clean": false, + "why": "timeout" + } + }, + { + "id": "gen:imo_1997_p5#5", + "set": "sample", + "klass": "UNDECIDED_ON_ONE_SIDE", + "ours": "undecided", + "ours_reasons": [ + "timeout" + ], + "ours_s": 180.0, + "kimina": "error", + "kimina_reasons": [ + "timeout" + ], + "kimina_detail": { + "error": "Lean REPL command timed out in 180.0 seconds" + }, + "kimina_s": 180.08, + "control": { + "ok": false, + "clean": false, + "why": "timeout" + } + }, + { + "id": "gen:numbertheory_exk2powkeqapb2mulbpa2_aeq1#30", + "set": "sample", + "klass": "UNDECIDED_ON_ONE_SIDE", + "ours": "undecided", + "ours_reasons": [ + "timeout" + ], + "ours_s": 180.17, + "kimina": "error", + "kimina_reasons": [ + "timeout" + ], + "kimina_detail": { + "error": "Lean REPL command timed out in 180.0 seconds" + }, + "kimina_s": 183.25, + "control": { + "ok": false, + "clean": false, + "why": "timeout" + } + }, + { + "id": "gen:mathd_algebra_137#11", + "set": "sample", + "klass": "UNDECIDED_ON_ONE_SIDE", + "ours": "undecided", + "ours_reasons": [ + "timeout" + ], + "ours_s": 180.0, + "kimina": "error", + "kimina_reasons": [ + "timeout" + ], + "kimina_detail": { + "error": "Lean REPL command timed out in 180.0 seconds" + }, + "kimina_s": 180.07, + "control": { + "ok": false, + "clean": false, + "why": "timeout" + } + }, + { + "id": "gen:aime_1994_p3#28", + "set": "sample", + "klass": "UNDECIDED_ON_ONE_SIDE", + "ours": "undecided", + "ours_reasons": [ + "timeout" + ], + "ours_s": 200.02, + "kimina": "error", + "kimina_reasons": [ + "timeout" + ], + "kimina_detail": { + "error": "Lean REPL command timed out in 180.0 seconds" + }, + "kimina_s": 180.01, + "control": { + "ok": false, + "clean": false, + "why": "timeout" + } + }, + { + "id": "gen:numbertheory_fxeq4powxp6powxp9powx_f2powmdvdf2pown#4", + "set": "sample", + "klass": "UNDECIDED_ON_ONE_SIDE", + "ours": "undecided", + "ours_reasons": [ + "timeout" + ], + "ours_s": 196.74, + "kimina": "reject", + "kimina_reasons": [ + "compile_error" + ], + "kimina_detail": { + "first_error": "omega could not prove the goal:\na possible counterexample may satisfy the constraints\n b \u2265 0\n e + g + i + j + k + l + o + p + q - r - s - u \u2265 1\n d \u2265 1\n c \u2265 0\n a \u2265 1\n a - b \u2265 1\nwhere\n a := \u2191n\n b := \u2191t\u271d\u00b9\n c := \u2191t\n d := \u21912 ^ t\n e := \u2191(4 ", + "n_errors": 2 + }, + "kimina_s": 57.63, + "control": { + "ok": false, + "clean": false, + "why": "error" + } + }, + { + "id": "gen:imo_1974_p3#17", + "set": "sample", + "klass": "UNDECIDED_ON_ONE_SIDE", + "ours": "undecided", + "ours_reasons": [ + "timeout" + ], + "ours_s": 180.04, + "kimina": "reject", + "kimina_reasons": [ + "compile_error" + ], + "kimina_detail": { + "first_error": "unsolved goals\nn\u271d\u00b2 : \u2115\nh_base : \u00ac5 \u2223 \u2211 k \u2208 Finset.range (0 + 1), (2 * 0 + 1).choose (2 * k + 1) * 2 ^ (3 * k)\nn\u271d\u00b9 n\u271d n : \u2115\nih : \u2200 m < n + 5, \u2211 k \u2208 Finset.range (m + 1), (2 * m + 1).choose (2 * k + 1) * 2 ^ (3 * k) % 5 \u2260 0\nh\u2085 :\n (\u2200 x < n, (", + "n_errors": 2 + }, + "kimina_s": 23.06, + "control": { + "ok": false, + "clean": false, + "why": "error" + } + }, + { + "id": "gen:amc12b_2020_p22#19", + "set": "sample", + "klass": "UNDECIDED_ON_ONE_SIDE", + "ours": "undecided", + "ours_reasons": [ + "timeout" + ], + "ours_s": 180.11, + "kimina": "reject", + "kimina_reasons": [ + "compile_error" + ], + "kimina_detail": { + "first_error": "linarith failed to find a contradiction\ncase a\nt : \u211d\nh : \u00act \u2264 0\nh\u2081 : t > 0\nh\u2082 : 2 ^ t > 0\nh\u2083 : 4 ^ t > 0\nh\u2084 h\u2085 h\u2086 : 2 ^ t - 3 * t > 0\nh\u2088 h\u2089 : (2 ^ t - 3 * t).log \u2264 (2 ^ t - 3 * t).log\nh\u2081\u2080 h\u2081\u2081 : 2 ^ t - 3 * t > 0\nh\u2081\u2083 h\u2081\u2084 : (2 ^ t - 3 * t).lo", + "n_errors": 1 + }, + "kimina_s": 46.39, + "control": { + "ok": false, + "clean": false, + "why": "error" + } + }, + { + "id": "gen:mathd_numbertheory_222#0", + "set": "sample", + "klass": "UNDECIDED_ON_ONE_SIDE", + "ours": "undecided", + "ours_reasons": [ + "timeout" + ], + "ours_s": 180.04, + "kimina": "maybe", + "kimina_reasons": [], + "kimina_detail": {}, + "kimina_s": 30.56, + "control": { + "ok": true, + "clean": true, + "why": "" + } + }, + { + "id": "gen:aime_1983_p3#8", + "set": "sample", + "klass": "UNDECIDED_ON_ONE_SIDE", + "ours": "undecided", + "ours_reasons": [ + "timeout" + ], + "ours_s": 180.02, + "kimina": "reject", + "kimina_reasons": [ + "compile_error" + ], + "kimina_detail": { + "first_error": "linarith failed to find a contradiction\nf : \u211d \u2192 \u211d\nh\u2080 : \u2200 (x : \u211d), f x = x ^ 2 + (18 * x + 30) - 2 * \u221a(x ^ 2 + (18 * x + 45))\nh\u2081 : Fintype \u2191(f \u207b\u00b9' {0})\nx : \u211d\nh : x ^ 2 + (18 * x + 30) - 2 * \u221a(x ^ 2 + (18 * x + 45)) = 0\nh\u2084 : \u221a(x ^ 2 + (18 * x", + "n_errors": 3 + }, + "kimina_s": 46.06, + "control": { + "ok": false, + "clean": false, + "why": "error" + } + }, + { + "id": "gen:imo_1985_p6#20", + "set": "sample", + "klass": "UNDECIDED_ON_ONE_SIDE", + "ours": "undecided", + "ours_reasons": [ + "timeout" + ], + "ours_s": 180.02, + "kimina": "reject", + "kimina_reasons": [ + "compile_error" + ], + "kimina_detail": { + "first_error": "type mismatch\n 1 / 3\nhas type\n \u211d : outParam Type\nbut is expected to have type\n NNReal : Type", + "n_errors": 8 + }, + "kimina_s": 143.89, + "control": { + "ok": false, + "clean": false, + "why": "error" + } + }, + { + "id": "gen:mathd_numbertheory_277#5", + "set": "sample", + "klass": "UNDECIDED_ON_ONE_SIDE", + "ours": "undecided", + "ours_reasons": [ + "timeout" + ], + "ours_s": 180.06, + "kimina": "maybe", + "kimina_reasons": [], + "kimina_detail": {}, + "kimina_s": 152.16, + "control": { + "ok": true, + "clean": true, + "why": "" + } + }, + { + "id": "gen:amc12_2000_p1#27", + "set": "sample", + "klass": "UNDECIDED_ON_ONE_SIDE", + "ours": "undecided", + "ours_reasons": [ + "timeout" + ], + "ours_s": 180.0, + "kimina": "maybe", + "kimina_reasons": [], + "kimina_detail": {}, + "kimina_s": 63.98, + "control": { + "ok": true, + "clean": true, + "why": "" + } + }, + { + "id": "gen:imo_1965_p2#4", + "set": "sample", + "klass": "UNDECIDED_ON_ONE_SIDE", + "ours": "undecided", + "ours_reasons": [ + "timeout" + ], + "ours_s": 180.0, + "kimina": "error", + "kimina_reasons": [ + "timeout" + ], + "kimina_detail": { + "error": "Lean REPL command timed out in 180.0 seconds" + }, + "kimina_s": 180.03, + "control": { + "ok": false, + "clean": false, + "why": "timeout" + } + }, + { + "id": "gen:algebra_apbmpcneq0_aeq0anbeq0anceq0#17", + "set": "sample", + "klass": "UNDECIDED_ON_ONE_SIDE", + "ours": "undecided", + "ours_reasons": [ + "timeout" + ], + "ours_s": 180.0, + "kimina": "error", + "kimina_reasons": [ + "timeout" + ], + "kimina_detail": { + "error": "Lean REPL command timed out in 180.0 seconds" + }, + "kimina_s": 180.08, + "control": { + "ok": false, + "clean": false, + "why": "timeout" + } + }, + { + "id": "gen:amc12a_2021_p22#21", + "set": "sample", + "klass": "UNDECIDED_ON_ONE_SIDE", + "ours": "undecided", + "ours_reasons": [ + "timeout" + ], + "ours_s": 180.09, + "kimina": "error", + "kimina_reasons": [ + "timeout" + ], + "kimina_detail": { + "error": "Lean REPL command timed out in 180.0 seconds" + }, + "kimina_s": 180.05, + "control": { + "ok": false, + "clean": false, + "why": "timeout" + } + }, + { + "id": "gen:mathd_numbertheory_222#29", + "set": "sample", + "klass": "UNDECIDED_ON_ONE_SIDE", + "ours": "undecided", + "ours_reasons": [ + "timeout" + ], + "ours_s": 180.05, + "kimina": "error", + "kimina_reasons": [ + "timeout" + ], + "kimina_detail": { + "error": "Lean REPL command timed out in 180.0 seconds" + }, + "kimina_s": 180.11, + "control": { + "ok": false, + "clean": false, + "why": "timeout" + } + }, + { + "id": "gen:imo_1984_p6#1", + "set": "sample", + "klass": "UNDECIDED_ON_ONE_SIDE", + "ours": "undecided", + "ours_reasons": [ + "timeout" + ], + "ours_s": 180.0, + "kimina": "error", + "kimina_reasons": [ + "timeout" + ], + "kimina_detail": { + "error": "Lean REPL command timed out in 180.0 seconds" + }, + "kimina_s": 180.1, + "control": { + "ok": false, + "clean": false, + "why": "timeout" + } + }, + { + "id": "gen:amc12_2000_p20#2", + "set": "sample", + "klass": "UNDECIDED_ON_ONE_SIDE", + "ours": "undecided", + "ours_reasons": [ + "timeout" + ], + "ours_s": 180.04, + "kimina": "error", + "kimina_reasons": [ + "timeout" + ], + "kimina_detail": { + "error": "Lean REPL command timed out in 180.0 seconds" + }, + "kimina_s": 180.07, + "control": { + "ok": false, + "clean": false, + "why": "timeout" + } + } + ], + "n_items": {}, + "ceiling": { + "kg_ours_accept": 177, + "kg_n": 180, + "kg_kimina_maybe": 179, + "kg_kimina_reject": 0, + "adv_ours_accept": 0, + "adv_n": 61, + "adv_kimina_maybe(compile-only would pass these on)": 40, + "adv_kimina_reject": 18, + "gated_adv_accepts": 0 + }, + "disagreement_classes": { + "UNDECIDED_ON_ONE_SIDE": 43, + "STRICT_ONLY_REJECT (expected: policy/audit layer)": 40 + }, + "latency_s": { + "ours_strict": { + "sample": { + "n": 500, + "sum_s": 12056.7, + "mean": 24.113, + "p50": 3.434, + "p90": 91.602, + "p99": 180.105, + "max": 200.022 + }, + "kg": { + "n": 180, + "sum_s": 1805.0, + "mean": 10.028, + "p50": 1.93, + "p90": 25.44, + "p99": 180.004, + "max": 180.004 + } + }, + "ours_compile_only": { + "sample": { + "n": 500, + "sum_s": 10089.2, + "mean": 20.178, + "p50": 2.368, + "p90": 58.322, + "p99": 180.095, + "max": 183.332 + }, + "kg": { + "n": 180, + "sum_s": 1536.5, + "mean": 8.536, + "p50": 0.895, + "p90": 20.181, + "p99": 119.662, + "max": 180.005 + } + }, + "kimina_compile_only": { + "sample": { + "n": 500, + "sum_s": 9887.7, + "mean": 19.775, + "p50": 2.26, + "p90": 58.04, + "p99": 180.137, + "max": 183.36 + }, + "kg": { + "n": 180, + "sum_s": 1411.9, + "mean": 7.844, + "p50": 0.818, + "p90": 17.258, + "p99": 109.049, + "max": 180.141 + } + } + }, + "busy_throughput_proofs_per_s_per_core": { + "n": 680, + "ours_strict": 0.0491, + "ours_compile_only": 0.0585, + "kimina_compile_only": 0.0602 + }, + "gated": { + "n": 680, + "strict_accepts": 485, + "gated_accepts": 485, + "prefilter_rejects": 160, + "strict_calls_saved": 160, + "sum_strict_s_saved": 4100.3, + "sum_kimina_s_spent": 11299.6, + "sum_strict_s_total": 13861.7, + "sum_strict_s_on_survivors": 9761.5 + }, + "engine_control": { + "n_decided_by_both": 650, + "clean_agree": 648, + "diffs": [ + { + "id": "kg:lean_workbook_1001", + "ours_compile_clean": false, + "ours_why": "crash", + "kimina": "maybe", + "kimina_reasons": [] + }, + { + "id": "gen:mathd_algebra_338#18", + "ours_compile_clean": false, + "ours_why": "crash", + "kimina": "maybe", + "kimina_reasons": [] + } + ] + }, + "throughput": { + "ours_strict": { + "n": 738, + "wall_s": 3532.5, + "proofs_per_s": 0.2089, + "proofs_per_s_per_core": 0.0522, + "cores": 4 + }, + "ours_compile_only": { + "n": 680, + "wall_s": 2984.9, + "proofs_per_s": 0.2278, + "proofs_per_s_per_core": 0.057, + "cores": 4 + }, + "kimina_compile_only": { + "n": 738, + "wall_s": 2901.4, + "proofs_per_s": 0.2544, + "proofs_per_s_per_core": 0.0636, + "cores": 4 + } + } +} \ No newline at end of file diff --git a/code/docs/workbook/.gitignore b/code/docs/workbook/.gitignore new file mode 100644 index 0000000000000000000000000000000000000000..aacc40c40f692d43404b247142bd34166b136e9a --- /dev/null +++ b/code/docs/workbook/.gitignore @@ -0,0 +1 @@ +data/.workbook.lock diff --git a/code/docs/workbook/Leanstral_8B_Workbook.xlsx b/code/docs/workbook/Leanstral_8B_Workbook.xlsx new file mode 100644 index 0000000000000000000000000000000000000000..491345c5c28716e82967f9b9eeeafa37c4afa6b6 Binary files /dev/null and b/code/docs/workbook/Leanstral_8B_Workbook.xlsx differ diff --git a/code/docs/workbook/build_workbook.py b/code/docs/workbook/build_workbook.py new file mode 100644 index 0000000000000000000000000000000000000000..a8b74654eeaf69f1ad811a70bbce6b29af48cbe6 --- /dev/null +++ b/code/docs/workbook/build_workbook.py @@ -0,0 +1,140 @@ +"""Build the Leanstral-8B project workbook from the JSON sheets in ./data. + +The xlsx is a BUILD ARTEFACT: edit data/*.json (or use the `journal` / `status` +subcommands below), never the workbook, or the next build will overwrite your edit. + +Style follows the Duplex training workbook: one header row (bold white Calibri on +#305496, centred, wrapped), body cells top-aligned and wrapped, frozen header row, +autofilter over the used range, and Source / Confidence / Status columns. + + python build_workbook.py # rebuild Leanstral_8B_Workbook.xlsx + python build_workbook.py journal "event" "result" "evidence" "next action" + python build_workbook.py status WBS 4.1 "Done" # set the Status cell of one row by Id + +Docs tooling, not align runtime: it is outside the dependency firewall's scan and +may import openpyxl. +""" + +from __future__ import annotations + +import contextlib +import datetime as dt +import fcntl +import json +import os +import sys +from pathlib import Path + +HERE = Path(__file__).resolve().parent +DATA = HERE / "data" +OUT = HERE / "Leanstral_8B_Workbook.xlsx" +LOCK = DATA / ".workbook.lock" + + +@contextlib.contextmanager +def locked(): + """One writer at a time: journal/status are read-modify-write on data/*.json, and two concurrent + CLI calls would otherwise read the same snapshot and drop each other's change.""" + LOCK.parent.mkdir(parents=True, exist_ok=True) + with open(LOCK, "w") as fh: + fcntl.flock(fh, fcntl.LOCK_EX) + try: + yield + finally: + fcntl.flock(fh, fcntl.LOCK_UN) + +# sheet order in the workbook; each name maps to data/.json +SHEETS = ["README", "WBS", "Recipe_Stack", "Datasets", "Data_Gaps", "Evals_Gates", + "Experiment_Plan", "Journal", "Sources"] + + +def load(name: str) -> dict: + d = json.loads((DATA / f"{name}.json").read_text(encoding="utf-8")) + cols, widths = d["columns"], d["widths"] + if len(cols) != len(widths): + raise SystemExit(f"{name}: {len(cols)} columns but {len(widths)} widths") + for i, r in enumerate(d["rows"]): + if len(r) != len(cols): + raise SystemExit(f"{name} row {i + 2}: {len(r)} cells, expected {len(cols)}: {r[:2]}") + return d + + +def save(name: str, d: dict) -> None: + p = DATA / f"{name}.json" + tmp = p.with_name(f"{p.name}.tmp{os.getpid()}") + tmp.write_text(json.dumps(d, indent=1, ensure_ascii=False) + "\n", encoding="utf-8") + os.replace(tmp, p) + + +def build() -> Path: + from openpyxl import Workbook + from openpyxl.styles import Alignment, Font, PatternFill + from openpyxl.utils import get_column_letter + + head_font = Font(name="Calibri", bold=True, color="FFFFFFFF") + head_fill = PatternFill("solid", fgColor="FF305496") + head_align = Alignment(horizontal="center", vertical="center", wrap_text=True) + body_font = Font(name="Calibri", size=11) + body_align = Alignment(vertical="top", wrap_text=True) + + wb = Workbook() + wb.remove(wb.worksheets[0]) + for name in SHEETS: + d = load(name) + ws = wb.create_sheet(name) + ws.append(d["columns"]) + for c in ws[1]: + c.font, c.fill, c.alignment = head_font, head_fill, head_align + for r in d["rows"]: + ws.append(r) + for row in ws.iter_rows(min_row=2): + for c in row: + c.font, c.alignment = body_font, body_align + for i, w in enumerate(d["widths"], start=1): + ws.column_dimensions[get_column_letter(i)].width = w + ws.freeze_panes = "A2" + ws.auto_filter.ref = f"A1:{get_column_letter(len(d['columns']))}{ws.max_row}" + tmp = OUT.with_name(f"{OUT.stem}.tmp{os.getpid()}.xlsx") + wb.save(tmp) + os.replace(tmp, OUT) + return OUT + + +def journal(event: str, result: str, evidence: str, nxt: str) -> None: + d = load("Journal") + d["rows"].append([dt.date.today().isoformat(), event, result, evidence, nxt]) + save("Journal", d) + + +def set_status(sheet: str, row_id: str, status: str) -> None: + d = load(sheet) + col = d["columns"].index("Status") + hits = [r for r in d["rows"] if r[0] == row_id] + if len(hits) != 1: + raise SystemExit(f"{sheet}: {len(hits)} rows with Id {row_id!r}") + hits[0][col] = status + save(sheet, d) + + +def main(argv: list[str]) -> int: + with locked(): + return _main(argv) + + +def _main(argv: list[str]) -> int: + if argv[:1] == ["journal"]: + if len(argv) != 5: + raise SystemExit("usage: journal EVENT RESULT EVIDENCE NEXT") + journal(*argv[1:]) + elif argv[:1] == ["status"]: + if len(argv) != 4: + raise SystemExit("usage: status SHEET ID STATUS") + set_status(*argv[1:]) + elif argv: + raise SystemExit(__doc__) + print(build()) + return 0 + + +if __name__ == "__main__": + sys.exit(main(sys.argv[1:])) diff --git a/code/docs/workbook/data/Data_Gaps.json b/code/docs/workbook/data/Data_Gaps.json new file mode 100644 index 0000000000000000000000000000000000000000..39b1dd5a6a953c07adcf666389db3c1ecb2c29b8 --- /dev/null +++ b/code/docs/workbook/data/Data_Gaps.json @@ -0,0 +1,103 @@ +{ + "columns": [ + "Id", + "Missing data / tool", + "What exists instead", + "Build-it-ourselves plan", + "Est. box-hours", + "Source", + "Status" + ], + "widths": [ + 7, + 40, + 46, + 58, + 12, + 36, + 14 + ], + "rows": [ + [ + "G1", + "LeanGym-PR: executable repository pull-request tasks (Leanstral 50% of RL mix)", + "Nothing public. MathlibPR is a merge-readiness classifier, not task+patch. princhernwang/mathlib-refactor-history (gated) has refactor diffs.", + "Mine Mathlib4 git history: (pre-commit tree, PR description, post-commit diff) where the diff adds/repairs proofs; task = reproduce the patch until `lake build` of touched files passes under the pinned toolchain. Start with single-file proof PRs only. Success must also require: no sorry/axioms beyond standard, theorem statements preserved, dependency audit; ground-truth patch is one valid solution, not the only one.", + "~20 (CPU builds)", + "critic gap; in-this-work-we-present-leanstral-a-series-of-generalist-code-agent-models-for", + "Planned" + ], + [ + "G2", + "Prove-or-disprove Lean data (Leanstral 30% of RL mix)", + "Only xlr8harder/lean-proof-or-refute-300 (300 items) and formal-conjectures statements.", + "Negate the CLOSED proposition (¬ ∀ x, H x → P x) of never-solved statements after screening them for misformalisation; keep verified disproofs. Perturbing a true theorem does NOT guarantee falsity: any perturbed variant needs its own verified proof or disproof before it gets a label.", + "~10", + "critic gap", + "Planned" + ], + [ + "G3", + "Lean code-agent trajectories at SWE scale (Leanstral SFT 50%)", + "OProver/OProofs per-theorem repair trajectories (6.8M rows); small RRMA/Erdos sets; non-Lean SWE traces (SWE-Gym, SWE-smith). OProofs trajectory subset UNCONFIRMED; Goedel-LM/RL_dataset_V2 holds prompts/messages, not verified repairs.", + "Generate our own: correction rounds (chain5) already produce (attempt, compiler error, repair) triples; log them as multi-turn traces in the chat format; mix with OProofs repair subset after decontamination.", + "from 5.2/5.3", + "critic gap", + "Running (via WBS 5.2)" + ], + [ + "G4", + "Rust->Lean (Aeneas) property / negation dataset", + "None on HF. Dalek Bench (curve25519-dalek-lean-verify) and an Aeneas experience report exist; Verina, CLEVER, FVAPPS, vericoding are spec benchmarks, not Aeneas output.", + "Run Charon+Aeneas on a small set of pure-Rust crates (no unsafe/IO); auto-generate properties from doc comments/tests; prove-or-negate with our prover; hand-validate every flag. This is the bug-finding study.", + "~40", + "critic gap; BUGFINDING_LIT_REVIEW.md", + "Planned (WBS 9)" + ], + [ + "G5", + "Leanstral bug-finding release (57 repos / 47 flags / 11 real / 5 new)", + "Not public.", + "Reproduce at small scale with baselines (plain LLM review, cargo-fuzz) on the same crates; report precision after property validation.", + "in G4", + "in-this-work-we-present-leanstral-a-series-of-generalist-code-agent-models-for", + "Planned" + ], + [ + "G6", + "Verifier exploit / reward-hacking corpus", + "None public.", + "Our P1 adversarial suite (61 cases) + red-team classes (axiom walk, lexer, meta) become a versioned regression set shipped with the paper.", + "~2", + "654c20c; redteam/false-accepts", + "Running (WBS 3)" + ], + [ + "G7", + "Lean token counts for a mid-training mix", + "No source states Lean token totals.", + "Out of scope (no mid-training). If revisited: tokenise Mathlib + Lean-Github with the Qwen3 tokenizer in streaming shards.", + "-", + "critic gap", + "Out of scope" + ], + [ + "G8", + "Mathlib-version alignment", + "Most new Mathlib-derived sets target Mathlib 4.27-4.31; miniF2F-test header is Lean 4.9.0-rc1.", + "Only use data that compiles under our pinned 4.9.0-rc1 toolchain: re-verify every SFT proof with the strict verifier before training; drop failures (this is also the quality filter). Compiling under 4.9 proves compatibility, not informal fidelity: record retained yield and domain loss per source.", + "in 5.3", + "critic gap", + "Rule" + ], + [ + "G9", + "GitHub-hosted candidates unconfirmed (API quota hit)", + "vericoding (HF copy verified), curve25519-dalek-lean-verify unverified.", + "Re-check with an authenticated gh CLI (`gh auth login`) before use.", + "<1", + "workflow log", + "Open" + ] + ] +} \ No newline at end of file diff --git a/code/docs/workbook/data/Datasets.json b/code/docs/workbook/data/Datasets.json new file mode 100644 index 0000000000000000000000000000000000000000..7663656ed48b5832c56c0813b87668b150b48993 --- /dev/null +++ b/code/docs/workbook/data/Datasets.json @@ -0,0 +1,2299 @@ +{ + "columns": [ + "Stage", + "Name", + "Hub id / URL", + "Host", + "Kind", + "Contents", + "Size", + "Lean version", + "License", + "Use in our recipe", + "Contamination risk", + "Verified", + "Source", + "Confidence", + "Status" + ], + "widths": [ + 16, + 28, + 40, + 10, + 10, + 46, + 18, + 14, + 14, + 40, + 30, + 14, + 36, + 10, + 12 + ], + "rows": [ + [ + "S1 Mid-train corpus", + "FrenzyMath Lean Search v2 Mathlib export", + "FrenzyMath/lsv2-mathlib-v4.28.0-rc1-jsonl", + "HF-dataset", + "corpus", + "310,579 Mathlib declarations with name, module_name, type, kind, signature, value, informal_name.", + "310,579 rows", + "Mathlib v4.28.0-rc1", + "apache-2.0", + "Declaration-level Lean code plus informal names for mid-training and autoformalization pairs.", + "Low.", + "API 200 2026-10-14; sha c5f07cc7; gated=False", + "unknown", + "High", + "Candidate" + ], + [ + "S1 Mid-train corpus", + "FrenzyMath/mathlib_informal_v4.16.0", + "FrenzyMath/mathlib_informal_v4.16.0", + "HF-dataset", + "corpus", + "Mathlib declarations at Lean v4.16.0 with LLM-generated informal descriptions.", + "187,540 rows (train split, previously unverified); num_bytes 183,617,227 (~184 MB); download_size 203,767,220 (~204 MB); usedStorage 445,721,938 (~446 MB)", + "4.16.0 (stated in name)", + "apache-2.0 (cardData and license:apache-2.0 tag)", + "NL-aligned Mathlib text; use for mixed NL/Lean mid-training.", + "Low.", + "API 200 2026-10-14; 91b05eb5a6503753879bfa4c32cf63f70a964da2", + "https://arxiv.org/abs/2410.10878", + "High", + "Candidate" + ], + [ + "S1 Mid-train corpus", + "l3lab/ntp-mathlib", + "l3lab/ntp-mathlib", + "HF-dataset", + "corpus", + "Mathlib4 next-tactic prediction data: state, srcUpToTactic, nextTactic, declUpToTactic, declId, decl, file_tag. Instruct and context variants also exist.", + "213,206 rows (train split); num_bytes 5,009,179,859 (~5.0 GB); usedStorage 22,967,407,962 (~23 GB). Matches candidate.", + "Lean 4 Mathlib at ntp-toolkit extraction time (version unverified)", + "None in cardData; datasets-server info license field is empt", + "Contains whole-declaration context; a large Mathlib-derived corpus, but heavily redundant (each proof is expanded per tactic).", + "Low direct. Prefix repetition inflates token counts, so dedup before counting.", + "API 200 2026-10-14; 03b2ea6c3cf0a55203596445722ebb61c2328889", + "https://arxiv.org/abs/2407.04681", + "High", + "Candidate" + ], + [ + "S1 Mid-train corpus", + "Lean 4 core repo (stdlib, docs, tests)", + "https://github.com/leanprover/lean4", + "GitHub", + "corpus", + "Lean 4 compiler, Init/Std library in Lean, doc/ manual sources, and large tests/ tree of Lean files.", + "7207063 KB as reported by GitHub repo API (git repo including history; not a usable corpus size; token count unknown)", + "tag-dependent (main is latest)", + "Apache-2.0 (GitHub license.spdx_id confirmed)", + "Language-level and metaprogramming Lean text, and the code-agent flavour of Lean (non-math programs) that Leanstral stresses.", + "Low.", + "API 200 2026-10-14; unknown (repo API does not return HEAD sha; not fetched)", + "workflow wf_c3f2e9d0-871", + "High", + "Candidate" + ], + [ + "S1 Mid-train corpus", + "Lean language docs SFT", + "Pradheep1647/lean-language-docs-sft", + "HF-dataset", + "sft-data", + "Lean language documentation as SFT pairs. Content and size were not inspected (datasets-server returned no info).", + "unknown", + "unknown", + "cc-by-4.0", + "Possible doc-style data for mid-training mix; verify content first.", + "Low.", + "API 200 2026-10-14; sha 8ba4ea49; gated=False", + "none", + "Med", + "Candidate" + ], + [ + "S1 Mid-train corpus", + "Lean premises", + "l3lab/lean-premises", + "HF-dataset", + "corpus", + "Premise data from l3lab (miniCTX authors). Schema not retrieved.", + "unknown", + "unknown", + "apache-2.0", + "Retrieval or context for context-aware proving; optional.", + "Unknown.", + "API 200 2026-10-14; sha b08037ff; gated=False", + "unknown", + "Med", + "Candidate" + ], + [ + "S1 Mid-train corpus", + "Lean repository mid-training v1", + "Pradheep1647/lean-repository-midtraining-v1", + "HF-dataset", + "corpus", + "Text chunks with id, metadata, split, text from Lean repositories.", + "19,438 rows", + "unknown", + "apache-2.0", + "Small ready-made mid-training slice; useful to test the pipeline. Provenance and per-repo licences not inspected.", + "Unknown.", + "API 200 2026-10-14; sha b373d0a9; gated=False", + "none", + "Med", + "Candidate" + ], + [ + "S1 Mid-train corpus", + "LeanDojo Benchmark 4 corpus (zipfit mirror)", + "zipfit/leandojo_benchmark_4_corpus", + "HF-dataset", + "corpus", + "Mirror of LeanDojo Benchmark 4 premise corpus (Mathlib4 file contents with premises).", + "5,674 rows in train split (datasets-server); num_bytes 51,309,049 (~51 MB); download 60,420,959 bytes; usedStorage 76,323,588 bytes (~76 MB, matches candidate);", + "Lean 4 Mathlib at the LeanDojo Benchmark 4 commit (exact version unverified)", + "unknown (cardData license absent; matches candidate)", + "Mathlib4 snapshot with premise annotation; alternative to cloning Mathlib at a fixed LeanDojo commit.", + "Low; duplicates Mathlib, so dedup against the raw source.", + "API 200 (HF API and datasets-server info) 2026-10-14; cd800f75af722f40bf0ad046d2f66055dc97383a", + "https://arxiv.org/abs/2306.15626", + "Med", + "Candidate" + ], + [ + "S1 Mid-train corpus", + "LeanDojo tooling (tracer, benchmark)", + "https://github.com/lean-dojo/LeanDojo", + "GitHub", + "tool", + "Tracing tool that extracts declarations, tactic states, premises from Lean 4 repos; basis for the LeanDojo datasets above.", + "n/a (tool)", + "supports a range of Lean 4 versions", + "MIT (license.spdx_id = MIT, confirmed)", + "Re-trace our own repo list at the Lean 4.9 toolchain, avoiding mirror drift.", + "None (tool).", + "API 200 2026-10-14; default_branch main; sha unknown (repo endpoint does not ret", + "https://arxiv.org/abs/2306.15626", + "High", + "Candidate" + ], + [ + "S1 Mid-train corpus", + "Mathlib tactics dataset (mathlib-initiative)", + "mathlib-initiative/mathlib-tactics", + "HF-dataset", + "sft-data", + "Tactic-step records from Mathlib: module, positions, goals, goalsAfter, ppTac, elaborator.", + "1,992,213 rows", + "unknown (recent Mathlib; not stated)", + "apache-2.0", + "Tactic-level SFT, state-tactic pairs, and a cheap mid-training signal. Version-pinned to a recent Mathlib, so not directly 4.9.0-rc1.", + "Low to moderate. Mathlib itself contains few miniF2F statements, but check.", + "API 200 2026-10-14; sha 09d76b24; gated=False", + "unknown", + "Med", + "Candidate" + ], + [ + "S1 Mid-train corpus", + "Mathlib types and docstrings (mathlib-initiative)", + "mathlib-initiative/mathlib-types", + "HF-dataset", + "corpus", + "783,115 Mathlib declarations with name, module, type, docString.", + "783,115 rows", + "unknown", + "apache-2.0", + "Statement plus docstring pairs for mid-training and autoformalization-style data.", + "Low.", + "API 200 2026-10-14; sha a8efa3c7; gated=False", + "unknown", + "Med", + "Candidate" + ], + [ + "S1 Mid-train corpus", + "Mathlib4 source (upstream)", + "https://github.com/leanprover-community/mathlib4", + "GitHub", + "corpus", + "Canonical Mathlib4 Lean source tree; the highest-quality Lean math text, versioned by commit/tag.", + "515536 KB as reported by GitHub repo API (repo size, not a token count; token count unknown)", + "any via tag; main currently tracks latest Lean (pushed 2026-10-14)", + "Apache-2.0 (GitHub license.spdx_id confirmed)", + "Core of the mid-training corpus; pin to the Lean 4.9-era tag for the miniF2F header, plus a recent tag for diversity.", + "Low direct. Mathlib has no miniF2F/PutnamBench statements as a benchmark, but some competition-style lemmas appear. Run n-gram decontamination against miniF2F-test.", + "API 200 2026-10-14; unknown (repo API does not return HEAD sha; not fetched)", + "https://arxiv.org/abs/1910.09336", + "High", + "Candidate" + ], + [ + "S1 Mid-train corpus", + "OProofs (OProver)", + "OProver/OProofs", + "HF-dataset", + "sft-data", + "Lean 4 formal_statement, formal_proof, cot_proof, prompt columns. Per the paper summary, also agentic proving trajectories with Lean feedback; the trajectory subset is not confirmed in this schema.", + "6,804,694 rows", + "unknown", + "apache-2.0", + "Largest open Lean proof corpus found. Use as mid-training or SFT source after Lean-version compile check and decontamination. Inspect for a trajectory config before relying on it for agent traces.", + "Unknown. Built from many statement pools, so miniF2F/ProofNet/Putnam overlap is likely. Must be hash-decontaminated.", + "API 200 2026-10-14; sha 3bae0c06; gated=False", + "unverified (arXiv 2605.17283 surfaced in search as OProver, not fetched)", + "Med", + "Candidate" + ], + [ + "S1 Mid-train corpus", + "phanerozoic/Lean4-Changelog", + "phanerozoic/Lean4-Changelog", + "HF-dataset", + "corpus", + "Lean 4 release changelog text (Lean 4 version-to-version changes).", + "3,419 rows in train split (datasets-server); num_bytes 409,058 uncompressed; usedStorage 1,292,733 bytes (~1.3 MB, matches candidate); 1 parquet file", + "multiple", + "apache-2.0 (cardData; license tag matches)", + "Small docs-style text to teach API and version drift; marginal for token budget.", + "None.", + "API 200 (HF API and datasets-server info) 2026-10-14; b81d41238fee094c4df0e6ac91d86dec89265e78", + "workflow wf_c3f2e9d0-871", + "High", + "Candidate" + ], + [ + "S1 Mid-train corpus", + "phanerozoic/Lean4-Mathlib", + "phanerozoic/Lean4-Mathlib", + "HF-dataset", + "corpus", + "Declaration-level Mathlib extraction: statement, proof, type, symbolic_name, library, filename, imports, deps.", + "232431 rows (datasets-server info, train split); dataset_size 86265506 B; download_size 32514723 B; usedStorage 678280763 B (~678 MB)", + "unknown (card not read)", + "apache-2.0 (cardData)", + "Ready-made structured Mathlib declarations with dependency info; useful for dependency-ordered packing and as a cross-check on the raw Mathlib crawl.", + "Low direct; same as Mathlib.", + "API 200 2026-10-14; sha f17915ed50d1bdeb2e12cf05e005f73a60e71bce; card says sour", + "workflow wf_c3f2e9d0-871", + "Med", + "Candidate" + ], + [ + "S1 Mid-train corpus", + "phanerozoic/Lean4-PhysLean", + "phanerozoic/Lean4-PhysLean", + "HF-dataset", + "corpus", + "Declarations from the PhysLean (formerly HepLean) physics formalisation library.", + "10342 rows (datasets-server info, train split; candidate said unverified); dataset_size 6108607 B; download_size 2084506 B; usedStorage 41300602 B (~41 MB)", + "unknown", + "apache-2.0 (cardData)", + "Domain diversity (physics Lean) in the mid-training mix.", + "Low.", + "API 200 2026-10-14; sha bdc13b4e5051ce950426062136efbf3ba503e311; card says sour", + "workflow wf_c3f2e9d0-871", + "Med", + "Candidate" + ], + [ + "S1 Mid-train corpus", + "phanerozoic/Lean4-Stdlib", + "phanerozoic/Lean4-Stdlib", + "HF-dataset", + "corpus", + "Declaration-level extraction of Lean 4 core/Std library (statement, proof, type, library, filename).", + "52970 rows (datasets-server info); dataset_size 15781285 B; download_size 4639673 B; usedStorage 102850501 B (~103 MB)", + "unknown", + "apache-2.0 (cardData)", + "Programming/stdlib Lean to widen beyond Mathlib-style math.", + "Low.", + "API 200 2026-10-14; sha e64c08ae8e52aba14065a1bdb43c2d7f1880000a; card says sour", + "workflow wf_c3f2e9d0-871", + "Med", + "Candidate" + ], + [ + "S1 Mid-train corpus", + "bigcode/the-stack-v2 (Lean subset)", + "bigcode/the-stack-v2", + "HF-dataset", + "corpus", + "Software Heritage-based code corpus. The dataset ships file IDs; Lean files must be selected by language and content fetched from Software Heritage S3.", + "Full repo usedStorage 426,977,114,770 bytes (~427 GB, matches candidate); 921 siblings. Lean subset: data/Lean/train-00000-of-00001.parquet present; its size an", + "mixed", + "other (cardData.license=['other']; per-file licences per the", + "Possible extra Lean code beyond GitHub crawls; I did not verify Lean subset size or whether Lean is a named language config.", + "Medium; raw GitHub-derived.", + "API 200 (api); 401 (datasets-server /info, gated/not accessible unauthenticated) 2026-10-14; e565caa3a78c2423bd374333a472b049eb090e47", + "https://arxiv.org/abs/2402.19173", + "High", + "Decontaminate first" + ], + [ + "S1 Mid-train corpus", + "EleutherAI/proof-pile-2 (algebraic-stack Lean files)", + "EleutherAI/proof-pile-2", + "HF-dataset", + "corpus", + "Math pretraining mix (OpenWebMath, arXiv, algebraic-stack). Lean parts confirmed in file tree: algebraic-stack/train/github-lean-train-0000.jsonl.zst and lean_proofsteps.jsonl.zst, plus validation/test splits.", + "github-lean train shard 102,809,256 bytes (102.8 MB zst) and lean_proofsteps train 12,699,102 bytes (12.7 MB zst) both confirmed via blobs=true; validation and ", + "pre-Oct-2023 snapshot (repo last modified 2023-10-25), so mostly Lean 3 plus early Lean 4", + "unknown (cardData license absent; matches candidate)", + "Fetch only the two Lean shards (about 115 MB compressed) for GitHub Lean files and LeanDojo-derived proofsteps; do not pull the 120 GB repo.", + "Medium. Lean 3 era GitHub files may include miniF2F Lean 3 solutions and ProofNet-adjacent material. Lean 3 syntax is also incompatible with our Lean 4 target, so filter hard.", + "API 200 (HF API); datasets-server 501 (script-based, no viewer rows) 2026-10-14; 901a9273a770e9d4138c5ddd91802f9c5c6cdc4b", + "https://arxiv.org/abs/2310.10631", + "Med", + "Decontaminate first" + ], + [ + "S1 Mid-train corpus", + "hoskinson-center/proof-pile (v1)", + "hoskinson-center/proof-pile", + "HF-dataset", + "corpus", + "First-generation Proof-Pile (arXiv math, formal libraries incl. Lean 3 mathlib, Isabelle, Coq, etc.).", + "datasets-server (partial=true): train 270,000 rows, 5.01 GB num_bytes; validation 46,251; test 46,251; dataset_size 6.15 GB. usedStorage 42,155,055,536 bytes (~", + "mostly Lean 3 (unverified)", + "apache-2.0 (cardData list form ['apache-2.0']; matches candi", + "Probably skip; superseded by proof-pile-2 and has no Lean-named file in the sibling list I checked.", + "Medium (Lean 3 miniF2F-adjacent content possible).", + "API 200 (HF API and datasets-server info) 2026-10-14; 490b980249446f2f3bd2df3a8cf085d0f2de240a", + "https://arxiv.org/abs/2302.12433", + "High", + "Decontaminate first" + ], + [ + "S1 Mid-train corpus", + "sean-lamont/leandojo_traces", + "sean-lamont/leandojo_traces", + "HF-dataset", + "traces", + "Zipped LeanDojo search traces and models (goal_model, htps, bestfs traces, original_train, etc.).", + "usedStorage 16,136,085,024 bytes (~16.1 GB, matches candidate); 8 zip files (goal_model, goal_model_higher_tp, htps, original_no_retriever_bestfs_60_acc, origin", + "unknown", + "unknown (cardData license absent; matches candidate)", + "Not raw Lean text. Mostly search artefacts; low priority for S1, possibly relevant to S2 traces.", + "Medium (includes test-split search logs for LeanDojo benchmark; not miniF2F).", + "API 200 (HF API); datasets-server error: BadZipFile (zip archives, no parquet config) 2026-10-14; 250c349a2f52e02cb4423cb8c467bfa2df57e025", + "workflow wf_c3f2e9d0-871", + "Med", + "Decontaminate first" + ], + [ + "S1 Mid-train corpus", + "tinyproof0/Lean4-4.14-LeanUniverse", + "tinyproof0/Lean4-4.14-LeanUniverse", + "HF-dataset", + "corpus", + "A LeanUniverse-style crawl of many Lean 4 GitHub repos built against Lean 4.14 (directories licenses/, novel_premises/, random/ seen in the tree).", + "rows unknown: datasets-server config-info FAILED (no info returned). usedStorage 962,332504 bytes (~962 MB, matches candidate). corpus.jsonl size not reported i", + "4.14 (stated in name)", + "None in cardData (matches candidate). Per-repo licenses/ dir", + "Compiled, multi-repo Lean 4 code beyond Mathlib; license folder allows filtering by repo license.", + "Medium. GitHub crawl; may contain miniF2F/ProofNet solutions in some repos.", + "API 200 2026-10-14; 20af9e1aab60a2670eeec1e2689282f78662cfa4", + "workflow wf_c3f2e9d0-871", + "High", + "Decontaminate first" + ], + [ + "S1 Mid-train corpus; S2a SFT proofs", + "deepseek-ai/DeepSeek-Prover-V1", + "deepseek-ai/DeepSeek-Prover-V1", + "HF-dataset", + "corpus", + "Synthetic Lean 4 theorem plus proof pairs (name, split, formal_statement, goal, header, formal_proof).", + "27,503 rows (train, datasets-server); single dataset.jsonl; repo usedStorage 51,571,902 bytes", + "Lean 4 (v4.9.0-era likely, unverified)", + "other (cardData.license=other; LICENSE file present, text no", + "Small model-generated Lean; use only as filler, not as the core of mid-training.", + "Medium; the DeepSeek-Prover team reported decontaminating against miniF2F, but verify yourself.", + "API 200 (api) / 200 (datasets-server) 2026-10-14; 1ee889f608fb12ba3596757ee91a60acd663ea81", + "https://arxiv.org/abs/2405.14333", + "High", + "Decontaminate first" + ], + [ + "S1 Mid-train corpus; S2a SFT proofs", + "Goedel-LM/Lean-workbook-proofs", + "Goedel-LM/Lean-workbook-proofs", + "HF-dataset", + "corpus", + "Full Lean 4 proofs for Lean Workbook problems (problem_id, full_proof), produced by Goedel-Prover.", + "29,750 rows (train split, datasets-server); repo usedStorage 16,165,753 bytes; parquet bytes unverified", + "Lean 4 (version unverified)", + "unknown (cardData.license=None; no license tag; repo contain", + "Same lineage as our base model data; including it would replicate what Goedel-Prover-V2-8B already saw, so use with care for novelty.", + "Medium-High; Lean Workbook overlap, model-generated proofs.", + "API 200 (api) / 200 (datasets-server) 2026-10-14; b731852af8d8ab11498fda27bce9020738c01c59", + "https://arxiv.org/abs/2502.07640", + "Med", + "Decontaminate first" + ], + [ + "S1 Mid-train corpus; S2a SFT proofs", + "kfdong/STP_Lean_SFT", + "kfdong/STP_Lean_SFT", + "HF-dataset", + "sft-data", + "Self-play (STP) generated Lean 4 prompt/target pairs, plus an eval split.", + "235,430 train + 4,096 eval rows (datasets-server); repo usedStorage 871,197,069 bytes", + "Lean 4 (version unverified)", + "unknown (cardData.license=None; no license tag; no LICENSE f", + "More S2 than S1; noted here because it is a source of verified Lean proofs.", + "Medium-High; built on Lean Workbook/miniF2F-adjacent seed sets.", + "API 200 (api) / 200 (datasets-server) 2026-10-14; 7166a1964c466af947ae804a09857d69498b1b99", + "https://arxiv.org/abs/2502.00212", + "Med", + "Decontaminate first" + ], + [ + "S1 Mid-train corpus; S2a SFT proofs; S2b SFT agent/correction", + "FrenzyMath/Herald_proofs", + "FrenzyMath/Herald_proofs", + "HF-dataset", + "corpus", + "Herald: Lean 4 formal theorem, informal theorem, formal proof, informal proof, commented proof, header, from Mathlib-derived autoformalisation.", + "44,553 rows; num_bytes 161,927,210 (~162 MB); download_size 57,908,255; usedStorage 115,217,715", + "Lean 4 (Mathlib v4.16-era per sibling dataset name; unverified for this repo)", + "apache-2.0 (cardData and license:apache-2.0 tag)", + "Parallel NL-Lean text, usable in mid-training or as SFT filler; the commented_proof field is useful comment-rich Lean.", + "Low-Medium. Derived from Mathlib; informal statements are LLM-translated. Check for overlap with competition-style statements.", + "API 200 2026-10-14; 2d31b459df74d730bff85682de237d894b4eecfa", + "https://arxiv.org/abs/2410.10878", + "High", + "Candidate" + ], + [ + "S1 Mid-train corpus; S2a SFT proofs; S2b SFT agent/correction; S3 RL prompts", + "Goedel-LM/Goedel-Pset-v1", + "Goedel-LM/Goedel-Pset-v1", + "HF-dataset", + "corpus", + "Large pool of autoformalised Lean 4 problem statements (problem_id, formal_statement, informal_statement); statements only.", + "1,732,594 rows (train, datasets-server); 4 parquet shards; repo usedStorage 827,628,391 bytes", + "Lean 4 (version unverified)", + "unknown (cardData.license=None; no license tag; repo contain", + "Statement text only. Better for S3 prompts than S1 corpus.", + "Medium. Autoformalised from NuminaMath and others; possible overlap with miniF2F-style items.", + "API 200 (api) / 200 (datasets-server) 2026-10-14; 79fc4fbe87b4c694a941bc63ad92dc38d39d41db", + "https://arxiv.org/abs/2502.07640", + "Med", + "Decontaminate first" + ], + [ + "S1 Mid-train corpus; S2a SFT proofs; S3 RL prompts", + "AI-MO/NuminaMath-LEAN", + "AI-MO/NuminaMath-LEAN", + "HF-dataset", + "corpus", + "Competition problems with Lean 4 formal statements (uuid, problem, question_type, answer, author, formal_statement); proof columns not confirmed.", + "104,155 rows (train, datasets-server); repo usedStorage 157,131,057 bytes", + "Lean 4 (version unverified)", + "apache-2.0 (cardData and license tag)", + "Mostly an S3 prompt source; small for S1.", + "Medium; competition-derived.", + "API 200 (api) / 200 (datasets-server) 2026-10-14; 51fa67f1f647ae1ecd81eef9f19306aa8a7b3a94", + "https://arxiv.org/abs/2504.11354", + "High", + "Decontaminate first" + ], + [ + "S1 Mid-train corpus; S2a SFT proofs; S3 RL prompts", + "internlm/Lean-Github", + "internlm/Lean-Github", + "HF-dataset", + "corpus", + "Tactic-level records mined from GitHub Lean 4 repos: url, commit, file_path, full_name, start, end, tactic, state_before.", + "218,866 rows (datasets-server); num_bytes 436,501,379 (~437 MB uncompressed, matches candidate); on-disk usedStorage 84,088,551 bytes (~84 MB parquet); 1 file l", + "mixed, per-repo commits", + "apache-2.0 (cardData; license tag matches)", + "Broad non-Mathlib Lean coverage (state-before and tactic pairs), good as a proof-step supplement. Note it holds tactic pairs, not whole files.", + "Medium. GitHub crawl may include repos that formalise miniF2F/ProofNet solutions. Decontaminate by statement match.", + "API 200 (HF API and datasets-server info) 2026-10-14; 22220a1073f959ec6fe1cee596965a8275bcc550", + "https://arxiv.org/abs/2407.17227", + "High", + "Decontaminate first" + ], + [ + "S1 Mid-train corpus; S2a SFT proofs; S3 RL prompts", + "internlm/Lean-Workbook", + "internlm/Lean-Workbook", + "HF-dataset", + "corpus", + "Lean 4 competition-style problems with proven tactic-state data (id, status, tactic, state_before, state_after, natural_language_statement).", + "25,214 rows (train split, datasets-server confirmed); num_bytes 16,597,556 (~17 MB); download_size 4,600,174 (~4.6 MB); usedStorage 103,685,829 (~104 MB)", + "Lean 4 (version unverified)", + "apache-2.0 (cardData and license:apache-2.0 tag)", + "Small as a corpus, better in S3 or S4; include only lightly in mid-training.", + "Medium-High. Problems include AoPS and competition-adjacent statements; overlap with miniF2F is possible. Run exact and fuzzy match.", + "API 200 2026-10-14; 2e066e310b2c6d2c27616927ae131f82901c8f1c", + "https://arxiv.org/abs/2406.03847", + "High", + "Decontaminate first" + ], + [ + "S1 Mid-train corpus; S3 RL prompts", + "FrenzyMath/Herald_statements", + "FrenzyMath/Herald_statements", + "HF-dataset", + "corpus", + "Herald formal and informal theorem statements (without proofs).", + "579,883 rows (train split, previously unverified); num_bytes 310,480,955 (~310 MB); download_size 133,063,027 (~133 MB); usedStorage 133,063,027", + "unknown", + "apache-2.0 (cardData and license:apache-2.0 tag)", + "Statement-only text; helpful for statement-writing proficiency but low proof density.", + "Low-Medium.", + "API 200 2026-10-14; d8d849682ecfba92dda26026a97e10a662187f4c", + "https://arxiv.org/abs/2410.10878", + "Med", + "Candidate" + ], + [ + "S1 Mid-train corpus; S3 RL prompts", + "phanerozoic/Lean4-FormalConjectures", + "phanerozoic/Lean4-FormalConjectures", + "HF-dataset", + "corpus", + "Declarations from Google DeepMind formal-conjectures repo (open and solved problem statements in Lean).", + "4557 rows (datasets-server info, train split; candidate said unverified); dataset_size 1809583 B; download_size 836301 B; usedStorage 15331207 B (~15 MB)", + "unknown", + "apache-2.0 (cardData)", + "Small, high-value source of prove-or-disprove style statements; also useful later for S3 prompts.", + "Medium. Repo covers some Erdos/competition-adjacent problems and may overlap PutnamBench/miniF2F-style statements. Check before use.", + "API 200 2026-10-14; sha 14a2974933546cd0b068ca6825643f6e7e4db9f6; card says sour", + "workflow wf_c3f2e9d0-871", + "Med", + "Decontaminate first" + ], + [ + "S2a SFT proofs", + "DeepSeek-Prover-V2 generation data (mirror)", + "Cartinoe5930/DeepSeek-Prover-V2-dataset-new", + "HF-dataset", + "traces", + "'messages' chat column; third-party reconstruction of DeepSeek-Prover-V2-style data", + "66,722 train rows (datasets-server); 21,359,902 bytes total storage", + "unknown", + "unverified (no license in API tags or cardData; candidate 'u", + "Possible CoT-style proof traces. Provenance is unclear; treat as low priority.", + "Unverified. Provenance unknown.", + "API api=200, datasets-server info=200 2026-10-14; a8c903b3ee9593df42ad7427c135b77cbb4bd3ef", + "https://arxiv.org/abs/2504.21801", + "Med", + "Candidate" + ], + [ + "S2a SFT proofs", + "FineLeanCorpus", + "m-a-p/FineLeanCorpus", + "HF-dataset", + "corpus", + "NL statement paired with a Lean 4 formalization: id, statement, lean_code, compile_result, eval_reasons, difficulty, difficulty_rationale, domain. Card says each entry passes the Lean compiler plus CriticLean semantic check; 79.19% accuracy from manual sampling.", + "509,358 rows (datasets-server train); dataset_size 1.51 GB; usedStorage 14.69 GB across 26 files", + "unknown", + "unknown (cardData license is None and no license: tag)", + "Statement source for expert iteration, and for stratifying by difficulty. The lean_code is a formalization, so it may not contain proofs; check before using as SFT.", + "Unverified. Draws on many public competition sources; check against miniF2F.", + "API 200 2026-10-14; e32e721f08698ffc2821af8b8248000b0464318e", + "unknown", + "Med", + "Candidate" + ], + [ + "S2a SFT proofs", + "FormalMATH SFT (classic)", + "formalmathatepfl/sft_classic", + "HF-dataset", + "sft-data", + "uuid, data_source, question, answer, proof_plan, valid, proof_repair, lean_code", + "train 2,678,749; test 623,741; sft_replay_classic 161,030 rows (datasets-server); 13,705,211,170 bytes total storage; download 5,487,250,864 bytes; 46 files", + "unknown", + "unverified (no license in API tags or cardData; candidate 'u", + "Large pool with a 'valid' flag, so we can filter to verified proofs. Provenance of this EPFL-related set is unclear from the metadata.", + "Unverified; the test split might overlap benchmarks.", + "API api=200, datasets-server info=200 2026-10-14; 21027890d0208fdc3881bb79b969e7bdf01dabbf", + "unknown", + "Med", + "Candidate" + ], + [ + "S2a SFT proofs", + "Goedel workbook proofs migrated to Lean 4.27", + "banach1729/goedel-workbook-lean427", + "HF-dataset", + "sft-data", + "60,341 traced (state, tactic) pairs from 29,750 Goedel Workbook proofs, migrated to Lean 4.27.0 / Mathlib v4.27.0. 28,016 (94.1%) of the proofs compile on 4.27.", + "unverified. datasets-server returned a failed config-info, so no row count is confirmed. API siblings: 10,006 entries, all Lean source files under GoedelProofs/", + "4.27.0 / Mathlib v4.27.0 (card)", + "apache-2.0 (cardData.license confirmed)", + "Only useful if we move to a recent Lean. Lean 4.27 does not match the miniF2F Lean 4.9.0-rc1 header, so I would not use it for this eval.", + "Same as the upstream Lean Workbook proofs.", + "API 200 (api) / datasets-server config-info failed 2026-10-14; sha 4049a8c4c2c7ae05d41207dccba1e9bf8afeb664", + "unknown", + "High", + "Candidate" + ], + [ + "S2a SFT proofs", + "iiis-lean math formal corpus", + "iiis-lean/lean-math-formal-corpus", + "HF-dataset", + "corpus", + "2,877 problems per config with uid, dataset, source_id, source_origin, source_lean_version, nl_problem. Separate configs for Lean v4.9.0, v4.15.0 and v4.27.0.", + "8,631 rows", + "v4.9.0, v4.15.0, v4.27.0 configs", + "unknown (no cardData license)", + "Useful because it ships a v4.9.0 config, close to our 4.9.0-rc1 header. Check which source benchmarks it contains (may include miniF2F or ProofNet).", + "Unchecked; only Mathlib-derived set found with a v4.9.0 config (matches miniF2F header) - decontaminate by statement sha", + "API 200 2026-10-14; sha fc3e0ed3; gated=False", + "unknown", + "Med", + "Candidate" + ], + [ + "S2a SFT proofs", + "Lean Workbook (full statement list)", + "pkuAI4M/LeanWorkbook", + "HF-dataset", + "corpus", + "formal_statement plus natural_language_statement", + "140,214 rows (datasets-server num_examples, train split confirmed); 46,962,716 bytes uncompressed; 22,033,462 bytes download; 3 files", + "unknown", + "unknown (cardData.license None, no license tag; matches cand", + "Larger statement pool for RL prompts; no proofs.", + "Same as Lean Workbook.", + "API 200 (api) / 200 (datasets-server info) 2026-10-14; sha b7b59dd09fb6e771290ed616a7037bc62a16842a", + "https://arxiv.org/abs/2406.03847", + "Med", + "Candidate" + ], + [ + "S2a SFT proofs", + "Lean-STaR plus", + "ScalableMath/Lean-STaR-plus", + "HF-dataset", + "sft-data", + "input/output pairs: tactic-step prediction with interleaved natural-language thoughts, per the Lean-STaR paper", + "61,082 rows (datasets-server train); 62.2 MB download; single file STaR-generated-train.json", + "unknown (the paper builds on LeanDojo Lean 4; unverified)", + "unknown (cardData license is None and no license: tag)", + "CoT-flavoured tactic data, useful if we want rationale-style outputs.", + "Unverified; derived from the LeanDojo Mathlib benchmark.", + "API 200 2026-10-14; 2a6b3d5b92c09b498e80fab687bb7b2d2cfb9651", + "https://arxiv.org/abs/2407.10040", + "Med", + "Candidate" + ], + [ + "S2a SFT proofs", + "Lean4 subgoal completions", + "connorolson/lean4-subgoal-completions", + "HF-dataset", + "sft-data", + "Subgoal completion pairs. Schema and size not retrieved.", + "unknown", + "unknown", + "cc-by-4.0", + "Auxiliary SFT; verify content.", + "Unknown.", + "API 200 2026-10-14; sha 4f04d3e3; gated=False", + "none", + "Med", + "Candidate" + ], + [ + "S2a SFT proofs", + "LeanDojo Benchmark 4 (random split, mirror)", + "cat-searcher/leandojo-benchmark-4-random", + "HF-dataset", + "sft-data", + "Mathlib4 (state, tactic, target_state) with url, commit, file_path, split by random", + "250,814 train / 4,260 validation / 4,506 test (datasets-server); 48.8 MB download; 5 parquet files", + "unknown (commit field per row; unverified)", + "unknown (cardData license is None and no license: tag; LeanD", + "Tactic-level pretraining or SFT on Mathlib. Third-party mirror of LeanDojo Benchmark 4, since the official kaiyuy/* ids I tried returned errors.", + "Low for miniF2F, but high for any Mathlib-derived eval. Not a decontamination risk for competition problems.", + "API 200 2026-10-14; 8a88a90126dd900e33039562766d6b9b60c66fbf", + "https://arxiv.org/abs/2306.15626", + "Med", + "Candidate" + ], + [ + "S2a SFT proofs", + "LeanDojo data hammer", + "hanwenzhu/leandojo_data_hammer", + "HF-dataset", + "sft-data", + "LeanDojo-derived data used for hammer-style training. Schema not retrieved.", + "unknown (17 files)", + "unknown", + "unknown (no cardData license)", + "Auxiliary tactic data; low priority.", + "Unknown (LeanDojo Mathlib base).", + "API 200 2026-10-14; sha 948836e9; gated=False", + "unknown", + "Med", + "Candidate" + ], + [ + "S2a SFT proofs", + "LeanDojo-filtered", + "LLParallax/LeanDojo-filtered", + "HF-dataset", + "sft-data", + "58,992 theorems with url, commit, file_path, full_name, start, end, traced_tactics.", + "58,992 rows", + "unknown (commit field per row)", + "unknown (no cardData license)", + "Commit-pinned traced tactics; commit and file path fields allow rebuilding against a chosen Mathlib commit.", + "Low to moderate.", + "API 200 2026-10-14; sha 771a7056; gated=False", + "unknown", + "Med", + "Candidate" + ], + [ + "S2a SFT proofs", + "Mathlib subproofs (chasenorman)", + "chasenorman/subproofs-mathlib-v4.30.0", + "HF-dataset", + "sft-data", + "433,293 subproof records with type, tactic, removals, name, kind, goal. A rollout variant (chasenorman/rollout-subproofs-mathlib-v4.30.0, 4,139 rows) adds result fields.", + "433,293 rows", + "Mathlib v4.30.0 (stated in id; v4.29.0 and others also exist)", + "unknown (no cardData license)", + "Subgoal-level SFT or curriculum data. Version v4.30.0 is much newer than our header, so needs re-checking.", + "Low.", + "API 200 2026-10-14; sha 1451d85e; gated=False", + "unknown", + "Med", + "Candidate" + ], + [ + "S2a SFT proofs", + "Nemotron-Math-Proofs-v1 (NVIDIA)", + "nvidia/Nemotron-Math-Proofs-v1", + "HF-dataset", + "sft-data", + "Lean problems with formal_statement, lean_header, source and url fields. Per search snippet, proofs are model-generated and compiler-checked, with error-feedback self-correction. Proof columns are not in the first 8 listed fields.", + "243,314 rows", + "unknown (lean_header column present, not read)", + "cc-by-sa-4.0", + "SFT whole-proof data and a source of statements for the RL prompt pool. lean_header lets us check Lean version compatibility.", + "Likely. Sources include public statement sets; not measured.", + "API 200 2026-10-14; sha 97229c59; gated=False", + "unverified", + "Med", + "Candidate" + ], + [ + "S2a SFT proofs", + "STP_Lean (self-play theorem prover)", + "kfdong/STP_Lean", + "HF-dataset", + "sft-data", + "prompt, target, weight, iteration, tag. Self-play conjecture-and-proof training examples.", + "1,738,754 rows (datasets-server num_examples, train split confirmed); 3,270,624,094 bytes uncompressed; 2,429,396,930 bytes usedStorage; 9 files", + "unknown (STP used Lean 4 / Mathlib; verify)", + "mit (cardData.license and license tag confirmed)", + "Large pool of proofs found by self-play. Weights allow subsampling.", + "Unverified. Prompts mix Lean Workbook and LeanDojo-style statements; check miniF2F overlap.", + "API 200 (api) / 200 (datasets-server info) 2026-10-14; sha 30a52fa0b690a272c76ac42765345e64321d48d0", + "https://arxiv.org/abs/2502.00212", + "Med", + "Candidate" + ], + [ + "S2a SFT proofs", + "PutnamBench Lean 4 (decontamination reference)", + "ChristianZ97/PutnamBench-lean4", + "HF-dataset", + "benchmark", + "problem_name, formal_statement, informal_statement, informal_solution, tags, split", + "train 672 rows (datasets-server); 239,316 bytes total storage", + "unknown", + "apache-2.0", + "Overlap check for SFT data. Not training data.", + "Eval set. Do not train on it.", + "API api=200, datasets-server info=200 2026-10-14; f753021f5fd7e40da05e137c0d0cb8624790f227", + "https://arxiv.org/abs/2407.11214", + "Med", + "Eval only" + ], + [ + "S2a SFT proofs; S2b SFT agent/correction", + "Goedel-Prover-V2 SFT dataset", + "Goedel-LM/SFT_dataset_v2", + "HF-dataset", + "sft-data", + "Single 'messages' column (chat format) SFT data used for Goedel-Prover-V2; the card gives no field-level description or per-source breakdown", + "1,745,010 rows (datasets-server num_examples, train split confirmed); 26,392,353,371 bytes uncompressed; 6,106,217,900 bytes download; 56 files", + "unknown (card silent)", + "apache-2.0 (cardData.license and license tag confirmed)", + "Highest-priority candidate. It is the data behind our base model, so it is a sanity reference and a replay-mix component to limit forgetting. Inspect first-rows before use.", + "Unverified. Card has no decontamination statement; check against miniF2F-test before mixing.", + "API 200 (api) / 200 (datasets-server info) 2026-10-14; sha da71b450f771880ea314a8860532ee26bf494753", + "https://arxiv.org/abs/2508.03613", + "Med", + "Candidate" + ], + [ + "S2a SFT proofs; S2b SFT agent/correction", + "Leanabell-Prover SFT train data", + "stoney0062/Leanabell-Prover-Traindata-SFT", + "HF-dataset", + "sft-data", + "prompt and output pairs for Leanabell-Prover SFT", + "218,988 rows (datasets-server train); 376.6 MB download; single file traindata_sft_without_cot.jsonl", + "unknown", + "apache-2.0 (cardData and tag)", + "Extra SFT pool, with possible long-CoT-style outputs; inspect first-rows.", + "Unverified.", + "API 200 2026-10-14; 1f1faf82920cb8590dc5c8c091dd46ba91a133bf", + "https://arxiv.org/abs/2504.06122", + "Med", + "Candidate" + ], + [ + "S2a SFT proofs; S2b SFT agent/correction", + "Leanabell-Prover-V2 cold-start data", + "stoney0062/Leanabell-Prover-V2-Coldstart-DS", + "HF-dataset", + "sft-data", + "'conversations' column, cold-start chats for Leanabell-Prover-V2", + "5,638 rows (datasets-server train); 55.7 MB download; single file coldstart_data.json", + "unknown", + "apache-2.0 (cardData and tag)", + "Small cold-start set. May include multi-turn repair/feedback format, useful for the later multi-turn stage (inspect first).", + "Unverified.", + "API 200 2026-10-14; d6a6d5048521d4b2dab60479ac3a4859fb87b3dd", + "https://arxiv.org/abs/2507.08649", + "Med", + "Candidate" + ], + [ + "S2a SFT proofs; S2b SFT agent/correction; S3 RL prompts", + "Kimina-Prover Promptset", + "AI-MO/Kimina-Prover-Promptset", + "HF-dataset", + "rl-prompts", + "statement_id, natural_language, formal_statement, source, name. No proofs.", + "24,418 rows (datasets-server num_examples, train split confirmed); 15,135,350 bytes uncompressed; 4,360,794 bytes download; 3 files", + "unknown", + "unknown (no cardData license and no license tag; matches can", + "Prompt pool for generating our own verified proofs.", + "Unverified. Likely includes problems derived from miniF2F-like sources.", + "API 200 (api) / 200 (datasets-server info) / 200 (first-rows) 2026-10-14; sha 3009c548d90160d0f5e963d72238610c6732f812", + "https://arxiv.org/abs/2504.11354", + "Med", + "Candidate" + ], + [ + "S2a SFT proofs; S3 RL prompts", + "STP_Lean_0320", + "kfdong/STP_Lean_0320", + "HF-dataset", + "sft-data", + "Same schema as STP_Lean (prompt, target, weight, iteration, tag), a later and larger snapshot", + "3,262,558 rows (datasets-server train split); ~1.74 GB download, 5.77 GB dataset_size; 10 parquet shards", + "unknown", + "mit (cardData and license:mit tag)", + "Alternative to STP_Lean; use whichever is the latest snapshot and deduplicate against the other.", + "Unverified, as for STP_Lean.", + "API 200 2026-10-14; 8759699c72a373a49497cde1341a4a94c3845b2f", + "https://arxiv.org/abs/2502.00212", + "Med", + "Candidate" + ], + [ + "S2a SFT proofs; S4 Verifier/infra; S6 Eval (held-out)", + "miniF2F Lean 4 (eval, for decontamination)", + "cat-searcher/minif2f-lean4", + "HF-dataset", + "benchmark", + "miniF2F valid and test, 244 each, with header, formal_statement, informal_stmt, informal_proof", + "validation 244; test 244 rows (datasets-server); 332,956 bytes total storage", + "unknown (the Goedel header in our project is Lean 4.9.0-rc1)", + "mit", + "Reference for n-gram/AST overlap checks against every SFT dataset above. Not training data.", + "This is the eval itself. Keep miniF2F-test out of training, and use valid only for dev.", + "API api=200, datasets-server info=200 2026-10-14; 70a1249ce240667f6bcdd1ccd62f847f0e065d57", + "https://arxiv.org/abs/2109.00110", + "Med", + "Eval only" + ], + [ + "S2a SFT proofs; S6 Eval (held-out)", + "ProofNet (original)", + "hoskinson-center/proofnet", + "HF-dataset", + "benchmark", + "Undergraduate-level formal statements: nl_statement, nl_proof, formal_statement, src_header", + "test 186; validation 185 rows (datasets-server, config plain_text); 12,243,856 bytes total storage", + "Lean 3 original (the formal_statement is Lean 3; Lean 4 ports exist as third-party mirrors such as UDACA/proofnet-lean4; unverified)", + "mit", + "Overlap check for SFT data. Not training data.", + "Eval set. Do not train on it.", + "API api=200, datasets-server info=200 2026-10-14; cb8e75614830035a37f3a2a11de5e625eaf0bc31", + "https://arxiv.org/abs/2302.12433", + "High", + "Eval only" + ], + [ + "S2b SFT agent/correction", + "Douvras Lean proof-repair corpus", + "dougdotcon/douvras-lean-proof-repair", + "HF-dataset", + "sft-data", + "36 rows total (24/6/6) with failed_code, compiler_error, candidate_correction. The card says the snippets are synthetic and were NOT executed in the compiler.", + "36 rows total: train 24, validation 6, test 6 (datasets-server); dataset_size 13,385 bytes; usedStorage 26,678 bytes", + "unknown (mathlib_version column exists; values unverified)", + "cc-by-4.0 (cardData matches candidate)", + "Not usable as training data (unverified, tiny). Schema only.", + "Low (synthetic).", + "API 200 (HF API), 200 (datasets-server /info) 2026-10-14; 827461ee4da59ee6ab79d8181de513da3c29eaf6", + "unknown", + "Med", + "Candidate" + ], + [ + "S2b SFT agent/correction", + "Erdos 741 II Lean 4 Opus traces", + "vincentoh/erdos741ii-lean4-opus-traces", + "HF-dataset", + "traces", + "39 Claude Opus agent sessions with messages, reward, n_tool_calls, n_oracle_runs, last_oracle_score, ever_scored_1.", + "39 rows in train split; usedStorage 776,102 bytes (under 0.01 GB, confirmed); dataset_size 802,068 bytes", + "unknown", + "apache-2.0 (cardData matches candidate)", + "Tiny; useful as a format check only.", + "Low.", + "API 200 (HF API), 200 (datasets-server /info) 2026-10-14; 2a87ad63a73585e6c9610799842035d9a0f0a209", + "unknown", + "Med", + "Candidate" + ], + [ + "S2b SFT agent/correction", + "Lean Copilot", + "https://github.com/lean-dojo/LeanCopilot", + "GitHub", + "tool", + "LLM tactic suggestion and proof search inside Lean 4. 1,333 stars, pushed 2026-10-13.", + "1402 KB (repo size per GitHub API)", + "tracks recent Lean 4 releases (unverified)", + "MIT (SPDX MIT, 'MIT License')", + "Reference for tool-call integration; no trace dataset.", + "n/a", + "API 200 2026-10-14; default_branch=main; commit sha not fetched", + "https://arxiv.org/abs/2404.12534", + "High", + "Candidate" + ], + [ + "S2b SFT agent/correction", + "LeanAgent", + "https://github.com/lean-dojo/LeanAgent", + "GitHub", + "tool", + "Lifelong learning agent for Lean theorem proving, 87 stars, last push 2025-06-13.", + "15558 KB (repo size per GitHub API)", + "unknown", + "unverified (API license field is null; no SPDX id returned)", + "Reference only; a ReProver/LeanDojo-style tactic-level prover and not a code-agent trace source.", + "n/a", + "API 200 2026-10-14; default_branch=main; commit sha not fetched", + "https://arxiv.org/abs/2410.06209", + "Med", + "Candidate" + ], + [ + "S2b SFT agent/correction", + "Nemotron-Cascade SFT SWE", + "nvidia/Nemotron-Cascade-SFT-SWE", + "HF-dataset", + "sft-data", + "SWE-focused SFT data from NVIDIA Nemotron-Cascade; size category 100K-1M rows.", + "162,262 rows (split train across swe_localization.jsonl, swe_repair.jsonl, swe_testgen.jsonl); dataset_size 5.01 GB; download size null; repo usedStorage 8.53 G", + "n/a", + "cc-by-4.0 (cardData and tag agree)", + "Large generic code-agent SFT data. Check format (agentless vs agentic) before use.", + "None for Lean.", + "API 200 (api, datasets-server info) 2026-10-14; b4ef73ff9bbcfc33d1ec7a48e53017f9ce7af7a3", + "unknown", + "High", + "Candidate" + ], + [ + "S2b SFT agent/correction", + "Numina-Lean-Agent", + "https://github.com/project-numina/numina-lean-agent", + "GitHub", + "tool", + "Agentic Lean prover scaffold from Project Numina (279 stars, pushed 2026-07-08). I confirmed only that the repo exists via the API. I did not read the README, so tool details and any released traces are unverified.", + "4376 KB (repo size per GitHub API)", + "unknown", + "unverified (API license field is null; no SPDX id returned)", + "Candidate harness for producing Lean agent trajectories with our own model, with the Lean build as reward.", + "n/a (tool).", + "API 200 2026-10-14; default_branch=main; commit sha not fetched (repos endpoint ", + "unknown", + "Med", + "Candidate" + ], + [ + "S2b SFT agent/correction", + "RRMA Lean 4 agent traces", + "vincentoh/rrma-lean4-agent-traces", + "HF-dataset", + "traces", + "416 multi-agent Lean 4 proof-search sessions on Erdos #741(ii) and #125. Columns: messages, reward, n_turns, n_tool_calls, n_oracle_runs, last_oracle_score, rung. Generated by Claude Opus/Haiku/Sonnet agents with a build oracle. Card says 115 of 416 have reward=1.0 (v2 relabel).", + "416 rows in train split (datasets-server); usedStorage 30,843,511 bytes (~0.03 GB), dataset_size 35,200,490 bytes. Candidate's 'about 0.01 GB' is contradicted (", + "unknown", + "apache-2.0 (cardData matches candidate)", + "Format reference and a small seed for the Lean code-agent half (tool-call and oracle-feedback loop). Too small and too narrow for training alone. Use only reward=1.0 rows.", + "Low for miniF2F/PutnamBench (Erdos problems). Teacher outputs are Claude-generated, so check provider terms for distillation.", + "API 200 (HF API), 200 (datasets-server /info) 2026-10-14; d91b6e45486332cd53eb1ba072852e4b00219566", + "unknown", + "Med", + "Candidate" + ], + [ + "S2b SFT agent/correction", + "SWE-Gym OpenHands SFT trajectories", + "SWE-Gym/OpenHands-SFT-Trajectories", + "HF-dataset", + "traces", + "491 successful OpenHands trajectories on SWE-Gym tasks, as chat messages.", + "491 rows (split train.success.oss); dataset_size 31.8 MB; download 10.4 MB; repo usedStorage 10.4 MB (about 0.01 GB, matches)", + "n/a", + "mit (cardData and tag agree)", + "Small high-quality general code-agent SFT seed.", + "None for Lean benchmarks. Possible overlap with SWE-bench Verified repos.", + "API 200 (api, datasets-server info) 2026-10-14; 4aaa5a4a4b5861f4799d2336908760c190ac3b17", + "https://arxiv.org/abs/2412.21139", + "High", + "Candidate" + ], + [ + "S2b SFT agent/correction", + "SWE-Hero OpenHands trajectories (NVIDIA)", + "nvidia/SWE-Hero-openhands-trajectories", + "HF-dataset", + "traces", + "OpenHands SWE trajectories released by NVIDIA (2026-05). Row count not confirmed.", + "34,269 rows (split train, 14 parquet shards); dataset_size 8.49 GB; download 2.40 GB; repo usedStorage 14.67 GB", + "n/a", + "cc-by-4.0 (cardData and tag agree)", + "Alternative generic agent traces under cc-by-4.0.", + "None for Lean.", + "API 200 (api, datasets-server info) 2026-10-14; 150bc119e52c647216fce285fd801f16b6fd745b", + "unknown", + "High", + "Candidate" + ], + [ + "S2b SFT agent/correction", + "SWE-rebench OpenHands trajectories", + "nebius/SWE-rebench-openhands-trajectories", + "HF-dataset", + "traces", + "67,074 OpenHands trajectories with resolved flags, tools, model_patch and generated-test pass info.", + "67,074 rows (split train); dataset_size 16.6 GB; download 2.08 GB; repo usedStorage 2.08 GB (about 2.1 GB matches)", + "n/a", + "cc-by-4.0 (cardData and tag agree; LICENSE file present)", + "Large general code-agent SFT pool; filter resolved.", + "None for Lean.", + "API 200 (api, datasets-server info) 2026-10-14; 35455389ab51bf5e2306bfd436ef72d0f98bf882", + "https://arxiv.org/abs/2505.20411", + "High", + "Candidate" + ], + [ + "S2b SFT agent/correction", + "SWE-smith trajectories", + "SWE-bench/SWE-smith-trajectories", + "HF-dataset", + "traces", + "Agent trajectories on SWE-smith tasks with messages, resolved flag, patch; three tool-format variants (tool 24,100 / xml 26,076 / ticks 25,826 rows).", + "76,002 rows across splits tool 24,100 + xml 26,076 + ticks 25,826; dataset_size 9.30 GB; download 3.17 GB; repo usedStorage 8.78 GB", + "n/a", + "mit (cardData and tag agree)", + "Largest MIT-licensed generic agent trace source here; filter resolved=true and pick the tool-call format.", + "None for Lean; Python repos.", + "API 200 (api, datasets-server info) 2026-10-14; 08e109b4a59eaeebf80e4675cd125d42e7ac99a4", + "https://arxiv.org/abs/2504.21798", + "High", + "Candidate" + ], + [ + "S2b SFT agent/correction", + "Goedel-Prover-V2 RL dataset (lean4_revision / lean4_completion subsets)", + "Goedel-LM/RL_dataset_V2", + "HF-dataset", + "rl-prompts", + "98,632 rows with 'subset' and 'messages'. First rows show subsets 'lean4_revision' (self-correction format) and 'lean4_completion'. Statements include OMR and lean_workbook.", + "train 98,632 rows (datasets-server); download 336,508,509 bytes (about 0.34 GB, confirmed); 1,049,807,386 bytes on disk", + "unknown", + "apache-2.0", + "The revision subset is the closest released analogue of compiler-feedback correction traces. Also usable as a source of single-theorem RL prompts after the rarely-solved-but-not-never filter.", + "Medium. Same statement sources as SFT_dataset_v2; no miniF2F decontamination statement confirmed.", + "API api=200, datasets-server info=200 2026-10-14; 5c99dce377f387bd271d726f205e69380b792a9d", + "https://arxiv.org/abs/2508.03613", + "Med", + "Decontaminate first" + ], + [ + "S2b SFT agent/correction", + "Lean proof-or-refute 300", + "xlr8harder/lean-proof-or-refute-300", + "HF-dataset", + "benchmark", + "300 test prompts with answer polarity, certificate, mutation_kind (statements mutated so some are false). Matches the prove-or-disprove task shape.", + "300 rows in test split; dataset_size 1,138,538 bytes; usedStorage 289,378 bytes (under 0.01 GB, confirmed)", + "unknown", + "mit (cardData matches candidate)", + "Dev or eval for the prove-or-disprove 30% slice; its mutation recipe can be copied to make training problems. 300 rows is too small to train on.", + "Has a 'development_exposure' column; origin of statements unverified. Check overlap with miniF2F before mixing.", + "API 200 (HF API), 200 (datasets-server /info) 2026-10-14; 7b6990f99580820c553573bfbd18132f3a28228f", + "unknown", + "Med", + "Eval only" + ], + [ + "S2b SFT agent/correction", + "LeanPolish proof-compression traces", + "leanpolish-anon/lean-proof-compression", + "HF-dataset", + "traces", + "Configs goedel, mathlib, minif2f, putnam_bench, putnam_verified, putnam2025: proof-shortening edits with failed_attempts and failed_tactics, split into training/rejected. goedel config has 20,822 training and 28,525 rejected rows.", + "goedel config: training 20,822 + rejected 28,525 = 49,347 rows (candidate's 49,347 confirmed in total, but training split alone is 20,822). mathlib config: trai", + "unknown", + "apache-2.0 (cardData matches candidate)", + "Verified edit/rejection pairs for proof-rewrite behaviour. Use only the goedel and mathlib configs.", + "HIGH for minif2f and putnam_bench configs, which are built from those benchmarks. Exclude them.", + "API 200 (HF API), 200 (datasets-server /info) 2026-10-14; 64422193ada0449e2a60d4486955e7319920ed69", + "unknown", + "Med", + "Decontaminate first" + ], + [ + "S2b SFT agent/correction", + "NuminaMath-LEAN Proof Artifacts", + "iiis-lean/NuminaMath-LEAN-Proof-Artifacts", + "HF-dataset", + "corpus", + "36,679 samples with formal statements, prover proofs and human proofs plus REPL-validated tactic and message artifacts (lite and full configs), derived from AI-MO/NuminaMath-LEAN.", + "36679 train rows in both full and lite configs (datasets-server info); usedStorage 16895703649 bytes", + "Lean 4.15.0 (from card, REPL runs)", + "apache-2.0 (cardData)", + "Verified (statement, proof) pairs and Lean REPL error messages for building correction traces. No multi-turn traces.", + "Medium. Numina competition problems; miniF2F overlap not confirmed.", + "API HF api 200; datasets-server info 200 2026-10-14; 43f4ea4da40c48be8e89edb84cb0825d8a4dfe2d", + "unknown", + "High", + "Decontaminate first" + ], + [ + "S2b SFT agent/correction; S3 RL prompts", + "MathlibPR", + "MathlibPR/MathlibPR", + "HF-dataset", + "benchmark", + "Mathlib4 pull-request histories: snapshot-level merge-ready labels (stage1-3, 15,895 snapshots each), within-PR pairs (3,687), and 500/500 subsets. It is a merge-readiness judging benchmark, not patch-writing traces.", + "Stage configs stage1, stage2, stage3 each have 15,895 rows (test split) - candidate's per-stage-config row count confirmed. Total usedStorage 248,421,511 bytes ", + "varies by Mathlib commit (not a single version)", + "apache-2.0 (cardData matches candidate)", + "Source of real Mathlib4 PR metadata (repo, PR number, commit SHAs, diffs) to build PR-style repository tasks. Needs our own task construction and a Lean build to verify.", + "Low for miniF2F/PutnamBench statements (Mathlib source); Mathlib may include some competition-style lemmas.", + "API 200 (HF API), 200 (datasets-server /info) 2026-10-14; 9f85f2dfe192030f58f3977fe7224b2024aed546", + "unknown", + "High", + "Eval only" + ], + [ + "S3 RL prompts", + "DeepTheorem", + "Jiahao004/DeepTheorem", + "HF-dataset", + "rl-prompts", + "120,754 informal theorems with a truth_value flag (first-rows sample of 25 was all True), a proof, and variants", + "120,754 rows in split train (datasets-server); download_size 554,423,240 B; dataset_size 1,185,426,199 B; usedStorage 1,702,216,290 B; 3 parquet shards plus 3 a", + "n/a (informal, no Lean)", + "mit (cardData and license:mit tag)", + "Informal only, so it is not directly usable as Lean prompts. It would need autoformalizing first. The truth_value / variant design is a useful template for building prove-or-disprove pairs. Low priority.", + "Built from informal sources such as NuminaMath. Overlap with miniF2F is unverified and likely unchecked.", + "API 200 (HF api/datasets; datasets-server info 200) 2026-10-14; f5935720f176cedff4ecd8ebf83d1696e31cfac8", + "https://arxiv.org/abs/2505.23754", + "High", + "Candidate" + ], + [ + "S3 RL prompts", + "formal-conjectures (Lean 4 conjecture library)", + "https://github.com/google-deepmind/formal-conjectures", + "GitHub", + "corpus", + "Google DeepMind's Lean 4 library of formalized open and solved conjectures, many marked with answer(sorry) or true/false/undecided status, in a real repository with a Lake build", + "unknown (statement count not checked; no tree or file queries were made)", + "unknown (tracks recent Mathlib, likely far newer than 4.9.0-rc1)", + "Apache-2.0 (GitHub license.spdx_id)", + "Natural source for prove-or-disprove tasks, since the library formalizes statements whose truth value is open or recorded. It is also a real repo for PR-style environment tasks. Known-true or known-false items are the useful ones.", + "Open-problem statements, so miniF2F, PutnamBench and ProofNet overlap should be low. Needs a modern Lean version, so incompatible with our 4.9 header without porting.", + "API 200 (api.github.com/repos/google-deepmind/formal-conjectures) 2026-10-14; unverified (GitHub API repo endpoint does not return a commi", + "unverified", + "Med", + "Candidate" + ], + [ + "S3 RL prompts", + "Lean Workbook RL pass-rate variants (Slim205)", + "Slim205/lean_workbook_RL_V20_total", + "HF-dataset", + "rl-prompts", + "24,000 train and 434 test rows with problem_id, theorem, proof, is_proved, eval_complexity. Dozens of sibling variants (hard, interval, no_zero_examples, hinter) exist.", + "24,434 rows", + "unknown (Lean Workbook is 4.8.0-rc1 per other discoverer)", + "unknown (no cardData license)", + "Pre-bucketed Lean Workbook statements with solve flags; a template for our own pass-rate filter.", + "Lean Workbook derives from competition-style problems; miniF2F overlap not measured.", + "API 200 2026-10-14; sha ba8cf328; gated=False", + "none", + "Med", + "Candidate" + ], + [ + "S3 RL prompts", + "Lean-RLVR-dataset", + "rootacess/Lean-RLVR-dataset", + "HF-dataset", + "rl-prompts", + "100,000 rows with problem_id, formal_statement, informal_statement.", + "100,000 rows", + "unknown", + "unknown (no cardData license)", + "Statement pool for RL; provenance unknown so run decontamination.", + "Unknown; treat as possibly overlapping with Lean Workbook and miniF2F.", + "API 200 2026-10-14; sha da0c6e41; gated=False", + "none", + "Med", + "Candidate" + ], + [ + "S3 RL prompts", + "Lean4 OEIS", + "formalica/lean4oeis", + "HF-dataset", + "rl-prompts", + "OEIS-derived Lean 4 statements (100K+ files, size category 100K-1M). Schema not retrieved.", + "100K to 1M files (size category only)", + "unknown", + "cc-by-sa-4.0", + "Large synthetic statement pool; many are computational, so filter by solve rate before RL.", + "Low for competition sets (OEIS-derived).", + "API 200 2026-10-14; sha 279e12db; gated=False", + "none", + "Med", + "Candidate" + ], + [ + "S3 RL prompts", + "Lean_conjecturer_data_v02", + "Slim205/Lean_conjecturer_data_v02", + "HF-dataset", + "rl-prompts", + "95,430 train and 1,278 test rows of LLM-conjectured Lean statements (problem_id, formal_statement, proofs)", + "95430 train + 1278 test rows (datasets-server info); usedStorage 20263355 bytes; download_size 20263355 bytes", + "unknown", + "unverified (no cardData license, no license tag)", + "Synthetic conjectures with proofs attached. Provenance is uncertain, so use only after our own verification and dedup.", + "Seeding sources are unknown. Treat as potentially overlapping with Lean Workbook and miniF2F until checked.", + "API HF api 200; datasets-server info 200 2026-10-14; 105c06a3e6c6765b2bc1ed43046fd58a204d648e", + "unverified", + "Med", + "Candidate" + ], + [ + "S3 RL prompts", + "Mathlib refactor history (gated)", + "princhernwang/mathlib-refactor-history", + "HF-dataset", + "other", + "Mathlib refactor history, 10K-100K rows by size tag. Contents not inspected because access is manual-gated.", + "10K to 100K rows (size category only)", + "unknown", + "apache-2.0", + "Closest open analogue to repo-history PR tasks. Request access and inspect schema before planning around it.", + "Low for miniF2F.", + "API 200 2026-10-14; sha d18883a9; gated=manual", + "none", + "Med", + "Candidate" + ], + [ + "S3 RL prompts", + "NuminaMath-LEAN-Sol and Proof-Artifacts (iiis-lean)", + "iiis-lean/NuminaMath-LEAN-Sol", + "HF-dataset", + "rl-prompts", + "81,311 rows of NuminaMath-LEAN statements with formal proofs, an English flag and match metadata. The sibling iiis-lean/NuminaMath-LEAN-Proof-Artifacts has 36,679 rows with human proof validation status and messages (full and lite configs).", + "81311 train rows (datasets-server info); usedStorage 582466698 bytes; download_size 240385539 bytes", + "unknown", + "apache-2.0 (cardData)", + "Cleaned and solved subsets of NuminaMath-LEAN. Statements with a validated proof are known provable, which removes false-statement noise from the RL pool. Proof-Artifacts' validation status helps identify items that no longer compile.", + "Derivative of NuminaMath-LEAN, so the same miniF2F overlap risk applies.", + "API HF api 200; datasets-server info 200 2026-10-14; d2052841575cc33d8b20bbc48b61fc8ee6a5fc0c", + "unverified", + "Med", + "Candidate" + ], + [ + "S3 RL prompts", + "RL-Goedel-Pset Level-2/3/4-5 (difficulty-bucketed)", + "HangHor/RL-Goedel-Pset-Level-3-5", + "HF-dataset", + "rl-prompts", + "Goedel-Pset subsets bucketed by an apparent difficulty level (problem_id, formal_statement, informal_statement, source such as orca_math). Sibling sets: Level-2-5 (80,382 rows), Level-4-5 (46,290 rows), both confirmed by API.", + "63,658 rows in train split (datasets-server and hub cardData agree); train split 52,141,185 bytes per datasets-server (52,075,227 per hub cardData); download_si", + "unknown", + "mit (cardData.license 'mit'; tag license:mit)", + "Pre-bucketed shortcut for the band filter on Goedel-Pset. Start with Level-3-5 and Level-4-5, then re-measure pass rate with our own SFT model.", + "Derivative of Goedel-Pset-v1 (same risk). Provenance of the level definition is not documented in the card, so treat it as unverified.", + "API 200 (hub API and datasets-server info both returned 200) 2026-10-14; sha c2f60f158d68d0891e653cfc87e9e618abca0eb6", + "unverified", + "Med", + "Candidate" + ], + [ + "S3 RL prompts", + "VeriSoftBench", + "maxRyeery/VeriSoftBench", + "HF-dataset", + "benchmark", + "500 theorem-proving tasks from 23 real Lean 4 repositories (compiler verification, type systems, ZK proofs, smart contracts), with repository context, dependency info, and ground-truth proofs", + "500 rows in split train (datasets-server); download_size 9,215,308 B; dataset_size 8,081,374 B; verisoftbench.jsonl (usedStorage 6,842,249 B)", + "unknown (varies per repository)", + "mit (cardData and license:mit tag)", + "Repo-level proving tasks with realistic cross-file dependencies. It is the nearest thing to a repository RL environment, but it is small. The GitHub repo (utopia-group/VeriSoftBench, MIT, pushed 2026-10-13) holds the pipeline and the repos. Use it as a probe set for repo-context capability.", + "Software-verification repositories, so low miniF2F overlap. A test benchmark, so avoid training on it if it is used for eval.", + "API 200 (HF api/datasets; datasets-server info 200) 2026-10-14; 741c2a14e68a5293b70ef874ce8b7876ce053173", + "https://arxiv.org/abs/2602.18307", + "Med", + "Eval only" + ], + [ + "S3 RL prompts; S6 Eval (held-out)", + "CombiBench", + "AI-MO/CombiBench", + "HF-dataset", + "benchmark", + "100 combinatorics problems formalized in Lean 4 (theorem_name, natural_language, answer, source, tag, formal_statement), with a test_with_solution split", + "100 rows test + 100 rows test_with_solution (datasets-server info); usedStorage 608889 bytes; download_size 110669 bytes", + "unknown (Kimina-era Lean 4)", + "mit (cardData)", + "Too small for RL. Use as an out-of-distribution probe for combinatorics, which is weak in Goedel-style provers.", + "Drawn from IMO and national olympiad problems, so it may overlap with miniF2F-test IMO items. Not checked.", + "API HF api 200; datasets-server info 200 2026-10-14; 882ba08befd0856f5364db1e53d58c7e2cf704f9", + "https://arxiv.org/abs/2505.03171", + "Med", + "Eval only" + ], + [ + "S3 RL prompts; S6 Eval (held-out)", + "ConjectureBench", + "Formal-Math-Reasoning/ConjectureBench", + "HF-dataset", + "benchmark", + "457 items with source, full_name, header, formal_conjecture, informal_statement, formal_statement. Conjecture-finding (answer-guessing) formalizations.", + "457 rows in split train (datasets-server); download_size 175,147 B; dataset_size 343,889 B; usedStorage 534,323 B", + "unknown (header column present)", + "cc-by-4.0 (cardData and license:cc-by-4.0 tag)", + "Small set of statements whose answer must be conjectured, a loose analogue of prove-or-disprove. Useful as a probe, not as a pool.", + "Likely derived from existing benchmarks such as PutnamBench or competition sets. Not confirmed, so assume overlap until a check is run.", + "API 200 (HF api/datasets; datasets-server info 200) 2026-10-14; 20d9b2b58e899889686329d5d1066e1078fa6ac4", + "unverified", + "Med", + "Eval only" + ], + [ + "S3 RL prompts; S6 Eval (held-out)", + "DeepSeek-ProverBench", + "deepseek-ai/DeepSeek-ProverBench", + "HF-dataset", + "benchmark", + "325 Lean 4 formalized problems (name, area, header, formal_statement), including AIME 24/25 and textbook problems", + "325 rows train (datasets-server info); usedStorage 82194 bytes; download_size 238887 bytes", + "unknown (a header column is present per row)", + "unverified (cardData has no license field; no license tag)", + "Held-out probe set, not a training pool. Useful for checking that RL on the band-filtered pool has not overfit.", + "AIME problems overlap with miniF2F AIME items to some degree. A benchmark, so keep it out of training.", + "API HF api 200; datasets-server info 200 2026-10-14; 3b9f067088e5e005fab91434ddc05a903e0a6252", + "https://arxiv.org/abs/2504.21801", + "Med", + "Eval only" + ], + [ + "S3 RL prompts; S6 Eval (held-out)", + "FormalMATH-All", + "SphereLab/FormalMATH-All", + "HF-dataset", + "rl-prompts", + "5,560 human-verified Lean 4 formalizations of olympiad-to-undergrad problems with domain labels, plus compiler feedback fields and solution", + "5,560 rows in split train (datasets-server); download_size 14,818,432 B; dataset_size 13,356,268 B; repo files: FormalMATH_all.json, README.md. FormalMATH-Lite ", + "unknown (paper uses Lean 4 / Mathlib; verify)", + "mit (cardData and license:mit tag)", + "Small, clean, fidelity-checked statements. Better as a quality dev or probe set or a high-quality RL mix-in than as bulk. The FormalMATH-Lite config (425 rows) is the ready-made eval-sized subset.", + "Collected from competition and textbook sources, so overlap with miniF2F (AMC/AIME/IMO) is plausible. FormalMATH is itself an evaluation benchmark, so using it for training would burn it as an eval set.", + "API 200 (HF api/datasets; datasets-server info 200) 2026-10-14; 8eea2550f174ce8b95444334da18a7473c2d169c", + "https://arxiv.org/abs/2505.02735", + "Med", + "RL or eval, not both" + ], + [ + "S3 RL prompts; S6 Eval (held-out)", + "miniCTX", + "l3lab/miniCTX", + "HF-dataset", + "benchmark", + "Context-dependent theorem proving from real Lean projects (PFR, HEP, HTPI, SciLean, PNT, etc.), with srcContext, theoremStatement, file, positionMetadata, dependencyMetadata, and proofMetadata. Splits are valid and test per config.", + "6 configs, valid+test each. Split counts from datasets-server: hep 61+61, htpi 45+45, pfr 51+51, pfr_cross 43+43, pnt 85+85, scilean 46+46. Derived total (sum o", + "unknown (varies per project; fileCreated metadata present)", + "apache-2.0 (cardData and license:apache-2.0 tag)", + "Real-repository theorems with file context. Use for repo-context RL prompts or a held-out probe. The valid splits could serve as a small RL pool.", + "Recent project files, so low miniF2F overlap. A benchmark, so training on it removes it as an eval.", + "API 200 (HF api/datasets; datasets-server info 200) 2026-10-14; ba24e70d112679a004510b487ebdeee8c6606ec4", + "https://arxiv.org/abs/2408.03350", + "Med", + "Eval only" + ], + [ + "S4 Verifier/infra", + "LeanInteract", + "https://github.com/augustepoiroux/LeanInteract", + "GitHub", + "tool", + "Python interface to the Lean REPL with multi-version Lean support. 132 stars, pushed 2026-07-17.", + "4791 KB (repo size per GitHub API)", + "multi-version", + "MIT (SPDX MIT, 'MIT License')", + "Runs compiler-feedback loops and axiom checks for trace generation and rewards.", + "n/a", + "API 200 2026-10-14; default_branch=main; commit sha not fetched", + "unknown", + "High", + "Candidate" + ], + [ + "S4 Verifier/infra", + "leanprover-community/repl", + "https://github.com/leanprover-community/repl", + "GitHub", + "tool", + "Official Lean 4 REPL: JSON commands over stdin/stdout, with tactic mode and environment pickling. It is the substrate under LeanInteract and Kimina-style servers.", + "358 KB (GitHub repo size field)", + "tracks Lean releases; tags per version (4.9.0 tag unverified)", + "Apache-2.0 (GitHub spdx_id Apache-2.0)", + "Base layer for the multi-turn prove-or-disprove environment and for fast proof-checking in RL. NOT a safe verifier by itself: SafeVerify's README says direct REPL checking is exploitable. Always add a SafeVerify or Comparator pass.", + "None (tool)", + "API GitHub /repos/leanprover-community/repl: 200 2026-10-14; unverified (default branch master; sha not fetched)", + "unknown", + "High", + "Candidate" + ], + [ + "S4 Verifier/infra", + "leanprover/Comparator", + "https://reservoir.lean-lang.org/@leanprover/Comparator", + "Other", + "tool", + "Official 'trustworthy judge for Lean proofs'. Builds Solution.lean in a landrun sandbox, exports the proof term with lean4export, and replays it outside the sandbox with the Lean kernel and optionally nanoda. Checks that the solution proves the same statement as Challenge.lean. Uses a JSON config with permitted_axioms.", + "unknown", + "tracks current Lean; lean4export must match the project toolchain; 4.9.0 support unverified", + "unverified (Reservoir page, GitHub API not queried)", + "Strongest option for the final miniF2F-test certification, and for an RL reward if throughput allows. Heavier than SafeVerify: needs landrun, lean4export, systemd-run and a non-root user. Can be used as a second, independent check in spot audits of SafeVerify-accepted proofs.", + "None (tool)", + "not checked", + "https://lean-lang.org/doc/reference/4.33.0-rc1/ValidatingProofs/", + "Low", + "Candidate" + ], + [ + "S4 Verifier/infra", + "leanprover/lean4checker", + "https://github.com/leanprover/lean4checker", + "GitHub", + "tool", + "Standalone replay of every declaration in an .olean through the kernel. --fresh also replays imports. This is the base that SafeVerify's Environment.replay is adapted from.", + "160 KB (GitHub repo size field)", + "branch per Lean release (per-version tags unverified)", + "Apache-2.0 (GitHub spdx_id Apache-2.0)", + "Cheap second pass. Run on accepted proofs to catch environment-manipulation exploits (e.g. addDecl tricks). Archived upstream, with the function moving into Lean core, so check the Lean version before depending on it.", + "None (tool)", + "API GitHub /repos/leanprover/lean4checker: 200 2026-10-14; unverified (default branch master; sha not fetched)", + "unknown", + "High", + "Candidate" + ], + [ + "S4 Verifier/infra", + "leanprover/lean4export", + "https://reservoir.lean-lang.org/@leanprover/lean4export", + "Other", + "tool", + "Exports Lean .olean environments to a text format that external kernels (nanoda and others) can re-check. It is the export step Comparator depends on.", + "unknown", + "must match project toolchain; per-version support unverified", + "unverified", + "Only needed if we adopt Comparator or independent-kernel re-checking. Must match the project's Lean version.", + "None (tool)", + "not checked", + "unknown", + "Low", + "Candidate" + ], + [ + "S4 Verifier/infra", + "mistralai/SafeVerify (Leanstral fork of SafeVerify)", + "https://reservoir.lean-lang.org/@mistralai/SafeVerify", + "Other", + "tool", + "Mistral's fork of SafeVerify, the verifier named in Leanstral 1.5 release materials. Listed as a Lean Reservoir package (HTTP 200 on _payload.json). The README on that page is the upstream SafeVerify README. GitHub URL not API-confirmed (github.com/mistralai/LeanstralSafeVerify was a guess and was not confirmed).", + "unknown", + "unknown", + "unverified (upstream is Apache-2.0)", + "Reference for the 'modified SafeVerify' reward gate. Read the fork's diff against upstream before copying the design. Reservoir page confirms a mistralai/SafeVerify package exists, but not what the fork changed.", + "None (tool, no data)", + "not checked", + "https://mistral.ai/news/leanstral-1-5/", + "Low", + "Candidate" + ], + [ + "S4 Verifier/infra", + "project-numina/kimina-lean-server", + "https://github.com/project-numina/kimina-lean-server", + "GitHub", + "tool", + "Scalable Lean verification server (REPL pool, FastAPI, cached imports). Built for RL proof-reward pipelines (Kimina-Prover).", + "1416 KB (GitHub repo size field)", + "unverified (configurable; docs list several)", + "MIT (GitHub spdx_id MIT)", + "Most direct drop-in for a high-throughput RL reward service on 4 GPUs' worth of CPU workers. Add an axiom check; the server itself only checks that proofs compile.", + "None (tool)", + "API GitHub /repos/project-numina/kimina-lean-server: 200 2026-10-14; unverified (default branch main; sha not fetched)", + "unknown", + "High", + "Candidate" + ], + [ + "S4 Verifier/infra", + "HaimingW/PutnamBench-lean4", + "HaimingW/PutnamBench-lean4", + "HF-dataset", + "benchmark", + "PutnamBench Lean 4 statements. Can be used to test whether a verifier rejects sorry-filled stubs.", + "644 rows total (train 372 + test 272; datasets-server); parquet 786,533 bytes; usedStorage 3,149,975 bytes; 4 files", + "unknown", + "unknown (no license in cardData; datasets-server license fie", + "Do not use for training or RL prompts. Optional use: negative-control fixtures for the verifier.", + "IS PutnamBench.", + "API 200 2026-10-14; b50aeaa4e64c606b594edc3079586f11f19d2886", + "unknown", + "Med", + "Eval only" + ], + [ + "S4 Verifier/infra", + "leanprover/lean-eval", + "https://reservoir.lean-lang.org/@leanprover/lean-eval", + "Other", + "benchmark", + "Comparator-based formal-math benchmark. Trusted statements live in shared Lean modules, with one generated comparator workspace per problem. A problem counts as solved only if Comparator accepts. Per the search summary, lake test pins landrun, lean4export, comparator and nanoda to commits.", + "unknown", + "recent (tracks Tau Ceti and Mathlib pin; exact version unverified)", + "unverified", + "Working end-to-end template for wiring Comparator into an eval harness. Possible held-out set for RL-stage eval. Needs a recent Lean and Mathlib, so it is not usable on our 4.9.0-rc1 toolchain.", + "Unknown. Problem set is new, but overlap with miniF2F/Putnam/ProofNet statements is unverified.", + "not checked", + "https://lean-lang.org/eval/", + "Low", + "Eval only" + ], + [ + "S4 Verifier/infra", + "Pradheep1647/lean-verifier-formalizations", + "Pradheep1647/lean-verifier-formalizations", + "HF-dataset", + "benchmark", + "Lean 4 tasks for agentic coding harnesses, with fields task_statement, formal_statement, header, reference_proof, lean_toolchain, mathlib_rev, permitted_axioms ([propext, Quot.sound, Classical.choice]), repo_commit_sha, verifier_version. The first row's compile backend is 'prime-sandbox (lake env lean)'.", + "77 rows (datasets-server size endpoint; train split only, 17 columns); parquet 3,935,438 bytes (~3.9 MB); in-memory 10,831,458 bytes (~10.8 MB); usedStorage 146", + "v4.31.0 (first row; others unverified)", + "multiple: mit, apache-2.0, cc-by-4.0 (cardData license list;", + "Small test fixture for our verifier pipeline (permitted_axioms per row is the same contract as ours). Too small and too new for training. Needs Lean 4.31, so it does not run on our Lean 4.9.0 toolchain.", + "Low for miniF2F/Putnam/ProofNet (drawn from recent arXiv-style repos), but not verified.", + "API 200 2026-10-14; 2d581ee56e1c1b0be2d1887d39ba504e1d5e6428", + "unknown", + "High", + "Eval only" + ], + [ + "S4 Verifier/infra; S5 Bug finding", + "mistralai/Leanstral-1.5-119B-A6B", + "mistralai/Leanstral-1.5-119B-A6B", + "HF-model", + "other", + "Model card describing the target scaffold: Mistral Vibe CLI, a lean agent config (~/.vibe/agents/lean.toml), /leanstall, and the lean-lsp-mcp. It describes serving through vLLM.", + "usedStorage 120,943,486,394 bytes (~120.9 GB) over 13 files, including 7 consolidated safetensors shards. Per-file sizes not returned by the default API call.", + "unknown (Lean 4 and Mathlib current)", + "apache-2.0 (cardData.license and license tag agree)", + "Source of truth for the agent scaffold and tool-set our traces should mimic (lean-lsp-mcp tools plus vibe). The weights are not needed for S4.", + "N/A (model)", + "API 200 (HF /api/models/mistralai/Leanstral-1.5-119B-A6B) 2026-10-14; 81592da95d94ab0439bfce16df1d55b402e598b6 (sha)", + "https://mistral.ai/news/leanstral-1-5/", + "Med", + "Candidate" + ], + [ + "S4 Verifier/infra; S5 Bug finding", + "SafeVerify", + "https://github.com/GasStationManager/SafeVerify", + "GitHub", + "tool", + "Checks a submitted proof against the reference statement and rejects non-standard axioms and exploit patterns. 49 stars, pushed 2026-04-22.", + "165 KB (repo size per GitHub API)", + "unknown (pinned per Lean toolchain)", + "Apache-2.0 (SPDX Apache-2.0)", + "Base for our reward and trace-acceptance checker.", + "n/a", + "API 200 2026-10-14; default_branch=main; commit sha not fetched", + "unknown", + "Med", + "Candidate" + ], + [ + "S4 Verifier/infra; S5 Bug finding", + "yidannwang/Leanstral-1-5-results", + "yidannwang/Leanstral-1-5-results", + "HF-dataset", + "traces", + "Preserved Leanstral-1.5 agent trajectories on miniF2F, ProofNet, FATE-M, FATE-H and PutnamBench. Includes generated Lean files, compile logs and SafeVerify logs, per-attempt metadata and REDACTIONS.md. The README reports a Putnam note where the statement-locking verifier dropped a heartbeat option, so a candidate needed independent revalidation.", + "unknown: 67,775 files listed in siblings; usedStorage reports 0; datasets-server /info failed (config-info failed), so no row count", + "unknown", + "unknown (no license in cardData; no license field in the API", + "Reference for verifier behavior, including edge cases and failure logs. Useful for calibrating our SafeVerify wrapper against real accept and reject cases. NOT for training: it contains eval benchmark artifacts.", + "HIGH. Contains miniF2F, ProofNet and PutnamBench statements, proofs and trajectories. Exclude from any training mix.", + "API 200 2026-10-14; f2d4b411a7d298bb5ac8f5b189a79a704f1f95ff", + "https://mistral.ai/news/leanstral-1-5/", + "Med", + "Decontaminate first" + ], + [ + "S5 Bug finding", + "Aeneas", + "https://github.com/AeneasVerif/aeneas", + "GitHub", + "tool", + "Rust-to-pure-functional translation backend; emits Lean 4 (also Coq/F*/HOL4) from Charon's LLBC. Core tool of the Leanstral bug-finding pipeline.", + "unknown (tool; repo size 19,163 KB as reported by GitHub API)", + "unverified (tracks a pinned Lean 4 toolchain; read lean-toolchain in the repo; likely much newer than Goedel's 4.9.0-rc1)", + "Apache-2.0 (license.spdx_id)", + "Run on Rust repos to get Lean models for the property and negation proving loop. This is the only way to build our own bug-finding set.", + "None (tool)", + "API 200 2026-10-14; unverified (repos API response does not expose a commit sha;", + "https://arxiv.org/abs/2206.07185", + "High", + "Candidate" + ], + [ + "S5 Bug finding", + "Charon", + "https://github.com/AeneasVerif/charon", + "GitHub", + "tool", + "Rust MIR front-end that extracts ULLBC/LLBC from cargo crates. It feeds Aeneas and other backends.", + "unknown (tool; repo size 20,078 KB as reported by GitHub API)", + "n/a (front-end only)", + "Apache-2.0 (license.spdx_id)", + "Step 1 of Rust to Lean: extract LLBC from each target repo.", + "None (tool)", + "API 200 2026-10-14; unverified (no commit sha in repos API response; default bra", + "https://arxiv.org/abs/2410.18042", + "High", + "Candidate" + ], + [ + "S5 Bug finding", + "dafny-disco", + "metareflection/dafny-disco", + "HF-dataset", + "corpus", + "81,342 Dafny content items grouped by id/group (about 518 MB). Looks like a large Dafny corpus (I did not inspect the rows).", + "81,342 rows, split train (datasets-server); dataset_size 504,789,705 B (~504.8 MB); download 114,913,745 B (~114.9 MB); 2 parquet shards", + "n/a (Dafny)", + "cc-by-sa-4.0 (cardData and license tag agree)", + "A possible source of verified-program text for the translation direction. Low priority because it is not Lean.", + "None for Lean eval sets.", + "API 200 (api/datasets); datasets-server /info OK 2026-10-14; 4fc7b5adfe36c8402a718311be3e1b3d225530f1", + "unknown", + "High", + "Candidate" + ], + [ + "S5 Bug finding", + "DafnyGym", + "emugnier/DafnyGym", + "HF-dataset", + "rl-prompts", + "145 Dafny methods with assertions removed, plus .dfy files in assertions_removed/. An environment for repairing proofs in Dafny.", + "145 rows, split train, config main_data (datasets-server); dataset_size 328,873 B, download 368,334 B; usedStorage 80,832 B; 539 sibling files (.dfy per-lemma f", + "n/a (Dafny)", + "mit (cardData and license tag agree)", + "A reference for building a verifier-in-the-loop 'gym' for program-proof tasks. It is not directly usable for Lean.", + "None for Lean eval sets.", + "API 200 (api/datasets); datasets-server /info OK 2026-10-14; 44aa7efe3fc8e5228405d6337270d6968be75b40", + "unknown", + "High", + "Candidate" + ], + [ + "S5 Bug finding", + "FVAPPS (Formally Verified APPS)", + "quinn-dougherty/fvapps", + "HF-dataset", + "sft-data", + "Lean 4 theorem statements with sorry for property-style specs over APPS programming problems. Files are train.jsonl, test.jsonl, assured.jsonl and semiassured.jsonl.", + "exact row count unverified (datasets-server 401). Files: assured.jsonl, semiassured.jsonl, test.jsonl, train.jsonl, unassured.jsonl; usedStorage 95,844,962 byte", + "unverified (Lean 4; the repo may be pinned to an older Mathlib)", + "mit (cardData.license)", + "Source of Lean property statements, with the program and tests as context, for prove-property training or RL prompts. The Lean programs are real code properties, not math.", + "Low (APPS-derived). No miniF2F, PutnamBench or ProofNet overlap found.", + "API 200 (api/datasets); datasets-server /info 401 (gated/auth required) 2026-10-14; 3552a173e74837dd0017d72431c6ede8fa903bb7", + "https://arxiv.org/abs/2502.05714", + "High", + "Candidate" + ], + [ + "S5 Bug finding", + "hax", + "https://github.com/cryspen/hax", + "GitHub", + "tool", + "Rust verification toolchain. It translates a Rust subset to F*, Rocq and Lean backends.", + "unknown (tool; repo size 74,025 KB as reported by GitHub API)", + "unverified", + "Apache-2.0 (license.spdx_id)", + "Alternative or complement to Aeneas for Rust to Lean. Its Lean backend maturity is unverified.", + "None (tool)", + "API 200 2026-10-14; unverified (no commit sha in repos API response; default bra", + "unknown", + "High", + "Candidate" + ], + [ + "S5 Bug finding", + "RLVR code data (Lean translation)", + "saurabh5/rlvr-code-data-Lean", + "HF-dataset", + "rl-prompts", + "133,285 rows of translated programming problems with solutions and test cases, target_language Lean.", + "133,285 rows", + "unknown", + "unknown (no cardData license)", + "Program-writing tasks in Lean with tests; usable for verified-code RL, not for theorem proving.", + "Low for math benchmarks.", + "API 200 2026-10-14; sha cd40efdf; gated=False", + "none", + "Med", + "Candidate" + ], + [ + "S5 Bug finding", + "RustSec advisory-db", + "https://github.com/rustsec/advisory-db", + "GitHub", + "corpus", + "Machine-readable advisories for vulnerabilities and unsound code in crates.io crates, in TOML with affected versions, CVE/GHSA aliases and patched versions.", + "repo size 41851 kB (GitHub API 'size' field); advisory count not counted (unknown)", + "n/a", + "NOASSERTION (GitHub API license.spdx_id; license key 'other'", + "Ground truth for a held-out bug-finding eval. Pick crates with known soundness or logic bugs, run Charon and Aeneas on the pre-fix version, and check whether property or negation proving finds the bug.", + "None for Lean evals. Possible overlap with public bug lists the base model has seen.", + "API 200 2026-10-14; unknown (default branch main; sha not fetched)", + "unknown", + "High", + "Candidate" + ], + [ + "S5 Bug finding", + "Verus_Training_Data (SAFE / VeruSyn)", + "microsoft/Verus_Training_Data", + "HF-dataset", + "sft-data", + "Training data for Verus (verified Rust). SAFE SFT 25K samples (11K proof generation, 14K debugging); a 6.9M-sample SFT part1; 4.6K long-CoT part2; and raw VeruSyn trajectories (success 7396, error 271, timeout 1373).", + "Per-file bytes (blobs=true): sft_part1_6.9M.json 15,681,162,022 B; sft_part2_4557.json 95,055,075 B; sft_safe_25k.json 59,123,561 B; system_trajectory_843.jsonl", + "n/a (Verus)", + "unknown (cardData license null; no license tag) - confirmed", + "The only large verified-Rust corpus. Use it for debugging and repair trajectory shapes, or to bootstrap understanding of Rust code and specs. Not Lean, so it is a design reference.", + "None for Lean math evals.", + "API 200 (api/datasets); datasets-server /info FAILED (config-info failed, dataset_info empty) 2026-10-14; d3a283fabb152ceee6e3e350a30d37499ece4f90", + "https://arxiv.org/abs/2410.15756", + "Med", + "Candidate" + ], + [ + "S5 Bug finding", + "VeruSyn", + "anon-verusyn/verusyn", + "HF-dataset", + "sft-data", + "A mirror of VeruSyn SFT parts: part1 (6.9M) and part2 (4,557 samples with prompt/input/output).", + "part2: 4,557 rows, split train (datasets-server, config part2), dataset_size 91,450,596 B; files: sft_part1_6.9M.json 15,681,162,022 B (~15.7 GB, row count UNVE", + "n/a (Verus)", + "mit (cardData and license tag agree; datasets-server info li", + "An alternative handle on the same verified-Rust data. Same use as Verus_Training_Data.", + "None for Lean math evals.", + "API 200 (api/datasets); datasets-server /info OK for config part2 only 2026-10-14; 73ef2bc851358e513418df8a5ed5b551b3a417de", + "unknown", + "High", + "Candidate" + ], + [ + "S5 Bug finding", + "CLEVER (dataset)", + "amitayusht/clever", + "HF-dataset", + "benchmark", + "161 hand-curated HumanEval-derived tasks. Each has an NL spec, a formal ground-truth spec, an isomorphism theorem and a Lean implementation task (spec-certification plus implementation-certification).", + "161 train examples (datasets-server); download_size 340,602 bytes; dataset_size 341,241 bytes; usedStorage 151,034 bytes", + "unverified (see GitHub repo for toolchain)", + "mit (cardData.license; matches card)", + "Dev or eval set for 'write a Lean spec or property and prove it'. Also useful as a dev set for property generation, since a spec equals a property.", + "Low for miniF2F/PutnamBench/ProofNet (code tasks derived from HumanEval). Overlaps with HumanEval-derived code benchmarks.", + "API 200 (api/datasets); datasets-server /info 200 2026-10-14; 486ea1554eb7ce6104e35a138e60174cdeb08c7b", + "https://arxiv.org/abs/2505.13938", + "High", + "Eval only" + ], + [ + "S5 Bug finding", + "CodeWalk Verus proof completion", + "CodeWalk/verus-proof-completion", + "HF-dataset", + "benchmark", + "400 Verus (Rust) proof-completion problems at 4 chain levels (L1, L2, L3, L10, 100 each). Inputs only, no solutions.", + "unverified rows (datasets-server reported no splits); HF siblings: 411 files, sizes not returned by API; card claim of 400 problems not confirmed by API", + "n/a (Verus)", + "mit (cardData.license and license:mit tag)", + "Reference design for a multi-function proof benchmark. Not Lean.", + "None for Lean evals. The card carries a canary GUID: keep it out of training corpora.", + "API api 200; datasets-server info 200 but dataset_info empty (no splits reported) 2026-10-14; f0e6ec27f39a688ac130d2e98b386ea82f3098b4", + "unknown", + "High", + "Eval only" + ], + [ + "S5 Bug finding", + "Dafny-synthesis (MBPP-DFY)", + "https://github.com/Mondego/dafny-synthesis", + "GitHub", + "benchmark", + "MBPP-DFY-153: Dafny programs with specs and proofs generated from MBPP. Cited from memory as a starting hint.", + "unverified (about 153 per candidate name; not confirmed)", + "n/a (Dafny)", + "unverified (API not retrieved)", + "A Dafny-side reference for spec and proof generation on small programs.", + "None for Lean evals.", + "Missing (404)", + "https://arxiv.org/abs/2402.00247", + "Low", + "Missing (404)" + ], + [ + "S5 Bug finding", + "DafnyBench", + "wendy-sun/DafnyBench", + "HF-dataset", + "benchmark", + "782 Dafny programs with verification hints (assertions, invariants) removed. The model must restore them so Dafny verifies.", + "782 rows, split test (datasets-server); files: test.json; dataset_size 2,816,027 B, download 3,014,671 B; usedStorage 9,945,963 B", + "n/a (Dafny)", + "apache-2.0 (cardData and license tag agree)", + "Not Lean. Use only as a program-verification task-shape reference or to transfer to Lean by translating. It is an indirect proxy for 'prove the property'.", + "None for the Lean eval sets.", + "API 200 (api/datasets); datasets-server /info OK 2026-10-14; dec1a9774b95689dd5b7b741cdc0366e966433de", + "https://arxiv.org/abs/2406.08467", + "High", + "Eval only" + ], + [ + "S5 Bug finding", + "DafnyComp", + "ReFormDafny/DafnyComp", + "HF-dataset", + "benchmark", + "300 compositional Dafny verification tasks (fields: org_input, gt_output, dafny_input, stage_tag, prompt, ability).", + "300 rows, split test (datasets-server); dataset_size 7,863,189 B; download 2,326,883 B; single test.parquet", + "n/a (Dafny)", + "unknown (cardData has no license; no license tag)", + "A reference for compositional (multi-function) spec checking, similar to repo-level property proving. Not Lean.", + "None for Lean eval sets.", + "API 200 (api/datasets); datasets-server /info OK 2026-10-14; c574d71b2f022d7a62b6642ae9a4e4b799cbeb4f", + "unknown", + "Med", + "Eval only" + ], + [ + "S5 Bug finding", + "miniCodeProps", + "elohn/miniCodeProps", + "HF-dataset", + "benchmark", + "A Lean 4 benchmark of 201 properties of simple programs (sorts, lists, trees, etc.) with full-proof and next-step baselines, mined from Tons-of-Theorems-style code.", + "n<1K (size_categories tag confirmed); row count unverified (single file codeprops_bench_ps.jsonl, usedStorage 41,937 bytes); 201 properties not confirmed via AP", + "unverified (the repo carries an older Lean and Mathlib; likely 4.x mid-2024)", + "bsd-3-clause (cardData.license)", + "Dev or eval for proving program properties and their negations. It is small and CPU-cheap to check.", + "Low (program properties, not competition math).", + "API 200 (api/datasets); datasets-server /info 200 but failed config-info (no size) 2026-10-14; a11d7e216e4beb5dcf580edc06624e0baa95fdb7", + "https://arxiv.org/abs/2406.11915", + "High", + "Eval only" + ], + [ + "S5 Bug finding", + "Vericoding benchmark (Dafny / Verus / Lean)", + "beneficial-ai-foundation/vericoding", + "HF-dataset", + "benchmark", + "12,504 formal specs across languages (search snippet: 3,029 Dafny, 2,334 Verus/Rust, 7,141 Lean). Columns include language, source, vc-description, vc-preamble, vc-helpers.", + "12,504 rows", + "unknown (Lean likely needs a newer toolchain than 4.9.0-rc1)", + "mit", + "Lean spec-to-verified-code tasks for the property-proving and bug-finding stage. Also usable as RL prompts for program-verification.", + "Low for miniF2F/PutnamBench/ProofNet; these are program specs, not competition math.", + "API 200 2026-10-14; sha f14842db; gated=False", + "unverified", + "Med", + "Eval only" + ], + [ + "S5 Bug finding", + "VerinaLite", + "XiaoyangLiu-sjtu/VerinaLite", + "HF-dataset", + "benchmark", + "A reduced variant of Verina produced by the VeriScale expansion-and-reduction pipeline. Per-task folders hold description.txt, reject_inputs.json, signature.json and task.json.", + "unverified (folder layout verina_advanced_N/{description.txt, reject_inputs.json, signature.json, task.json, task.lean, test.json}; datasets-server has no confi", + "unknown", + "apache-2.0 (cardData.license)", + "A smaller, cheaper Verina slice for fast dev loops on a single A10G.", + "Low (a Verina derivative). The README is a one-liner.", + "API 200 (api/datasets); datasets-server /info 200 but failed config-info (no size) 2026-10-14; 5987e0926161447893ed047191497ee4c37089e7", + "https://arxiv.org/abs/2605.22368", + "Med", + "Eval only" + ], + [ + "S5 Bug finding", + "VerinaPlus", + "XiaoyangLiu-sjtu/VerinaPlus", + "HF-dataset", + "benchmark", + "An expanded Verina variant from the VeriScale pipeline, with more tasks per original problem. Same per-task folder layout as VerinaLite.", + "unverified (folder layout verina_advanced_N/...; datasets-server has no config info; task count not counted)", + "unknown", + "apache-2.0 (cardData.license)", + "A larger set of Verina-style tasks. It could serve as RL prompts after filtering to 'rarely but not never solved'.", + "Low (derived from Verina). Leakage into Verina eval is possible if the same base problems are used.", + "API 200 (api/datasets); datasets-server /info 200 but failed config-info (no size) 2026-10-14; e21184af802c198d94cd68a6de447104fcfb00db", + "https://arxiv.org/abs/2605.22368", + "Med", + "Eval only" + ], + [ + "S5 Bug finding; S6 Eval (held-out)", + "Verina", + "sunblaze-ucb/verina", + "HF-dataset", + "benchmark", + "189 Lean 4 verifiable-code-generation tasks. Each has an NL description, a reference Lean implementation, a formal specification, a proof, test cases and reject inputs, in two difficulty tiers (basic/advanced).", + "189 train examples (datasets-server); download_size 214,167 bytes; dataset_size 613,699 bytes; usedStorage 1,770,315 bytes; 1 parquet file (data/train-00000-of-", + "unverified (check the GitHub repo sunblaze-ucb/verina)", + "apache-2.0 (cardData.license)", + "Closest benchmark to 'generate property + prove it'. Use it to measure proving specs and implementations in Lean, and as a source of (spec, proof) pairs for SFT or RL warm-up.", + "Low (programming tasks, derived from MBPP and LiveCodeBench-style sources). It has no miniF2F or PutnamBench statements.", + "API 200 (api/datasets); datasets-server /info 200 2026-10-14; da391829470768353e02cfc9ca03f492ce109b58", + "https://arxiv.org/abs/2505.23135", + "High", + "Eval only" + ], + [ + "S6 Eval (held-out)", + "FATE (FrenzyMath) repo", + "https://github.com/frenzymath/FATE", + "GitHub", + "benchmark", + "FATE-M/H/X abstract-algebra and commutative-algebra Lean4 formalizations of increasing difficulty", + "unverified (GitHub repo size field 14 KB; not counted, as instructed)", + "unverified", + "MIT (license.spdx_id MIT)", + "Hard research-level eval where the baseline is near zero. Treat as a low-signal sanity test.", + "New, low overlap expected.", + "API 200 (GitHub api) 2026-10-14; unknown (default branch main; commit sha not fetched)", + "https://arxiv.org/abs/2505.17262 (unverified id)", + "High", + "Eval only" + ], + [ + "S6 Eval (held-out)", + "FormalMATH-Lite", + "SphereLab/FormalMATH-Lite", + "HF-dataset", + "benchmark", + "425-problem subset of FormalMATH (Lean4, olympiad to undergrad)", + "425 rows (train split, num_examples 425); dataset_size 833190 bytes; download_size 940415 bytes; usedStorage 343795 bytes", + "unverified", + "mit (cardData.license and tag)", + "Cheap broad eval for A10G budgets. Prefer Lite over All.", + "Sourced from olympiad and textbook problems. Overlap with miniF2F possible. Dedup before use.", + "API 200 (HF api and datasets-server info) 2026-10-14; 75e91a83573b6241fb85fe2a9cef8dd6b713f170", + "https://arxiv.org/abs/2505.02735", + "High", + "Eval only" + ], + [ + "S6 Eval (held-out)", + "Lean-eval benchmark (formalmathatepfl)", + "formalmathatepfl/lean-eval-benchmark", + "HF-dataset", + "benchmark", + "Large Lean eval set, 68,932 test rows, Lean v4.15.0 per card", + "68932 rows (test split, num_examples 68932); dataset_size 335050623 bytes; download_size 33205069 bytes; usedStorage 33205069 bytes", + "v4.15.0 (card)", + "none declared (no license tag and no cardData.license)", + "Possible big held-out pool. Provenance unclear, so treat as unvetted.", + "Unknown. Probably overlaps public corpora.", + "API 200 (HF api and datasets-server info) 2026-10-14; 5a673224433f394901399517d9311a0c72e85ea2", + "unknown", + "High", + "Eval only" + ], + [ + "S6 Eval (held-out)", + "miniCTX-v2", + "l3lab/miniCTX-v2", + "HF-dataset", + "benchmark", + "Updated miniCTX with ConNF, FLT, HepLean, Seymour, carleson, foundation, mathlib (50 valid and 50 test each, FLT 34/34)", + "334 rows per split: valid 334 and test 334 across 7 configs (ConNF 50/50, FLT 34/34, HepLean 50/50, Seymour 50/50, carleson 50/50, foundation 50/50, mathlib 50/", + "varies per project", + "apache-2.0 (cardData.license and tag)", + "Time-split repo-context eval. FLT split gives a tiny FLTEval-like probe.", + "Repos postdate many corpora. Check cutoffs against S1 data.", + "API 200 (HF api and datasets-server info) 2026-10-14; 91bd27f994c6fd6e3e1a85e7ad01c4ee0e6a01de", + "https://arxiv.org/abs/2408.03350", + "High", + "Eval only" + ], + [ + "S6 Eval (held-out)", + "miniF2F Lean4 (yangky11 repo)", + "https://github.com/yangky11/miniF2F-lean4", + "GitHub", + "benchmark", + "Lean4 miniF2F Lake project used by LeanDojo/ReProver ecosystems", + "unverified: API gives repo size only (397 KB); 244+244 statement count not recounted", + "unverified (see lean-toolchain in repo)", + "MIT (GitHub license.spdx_id: MIT)", + "Reference for header and Lean toolchain pin if statements need re-checking.", + "IS miniF2F.", + "API 200 2026-10-14; default branch main; commit sha not returned by repos endpoi", + "https://arxiv.org/abs/2306.15626", + "High", + "Eval only" + ], + [ + "S6 Eval (held-out)", + "MiniF2F traces (kobyjl)", + "kobyjl/mini_f2f_traces", + "HF-dataset", + "traces", + "3,940 attempt records on miniF2F challenges (challenge_id, theorem_name, challenge_lean, prover_id, attempt, stop_reason).", + "3,940 rows", + "unknown", + "mit", + "Reference for baseline comparison only. Add to the decontamination blocklist.", + "DIRECT: contains miniF2F statements and attempts.", + "API 200 2026-10-14; sha e6fb8501; gated=False", + "none", + "Med", + "Exclude (eval-derived)" + ], + [ + "S6 Eval (held-out)", + "miniF2F-test (AI-MO / Kimina)", + "AI-MO/minif2f_test", + "HF-dataset", + "benchmark", + "miniF2F test split 244, as used in Kimina-Prover eval", + "244 rows (train split, datasets-server num_examples); data/train-00000-of-00001.parquet, download_size 74285 bytes, dataset_size 156154 bytes; usedStorage 17081", + "unverified", + "apache-2.0 (cardData and license:apache-2.0 tag)", + "Cross-check statement set against cat-searcher copy; diff to catch port differences.", + "IS miniF2F test.", + "API api 200; datasets-server info 200 2026-10-14; 5def318348521dfc34875045a9ecddf729a2b49f", + "https://arxiv.org/abs/2504.11354", + "High", + "Eval only" + ], + [ + "S6 Eval (held-out)", + "miniF2F-test Lean4 (HaimingW)", + "HaimingW/miniF2F-test-lean4", + "HF-dataset", + "benchmark", + "miniF2F test Lean4 244 rows", + "244 rows (train split, datasets-server num_examples); download_size 134138 bytes, dataset_size 280937 bytes; usedStorage 487366 bytes", + "unverified", + "none declared (cardData.license null; no license: tag)", + "Third-party copy for cross-checking only. Prefer the license-clear copies.", + "IS miniF2F test.", + "API api 200; datasets-server info 200 2026-10-14; 7046e9d7f0de6d839013e4bb463835d273d4acc7", + "unknown", + "High", + "Eval only" + ], + [ + "S6 Eval (held-out)", + "miniF2F-v2 (corrected, v2s/v2c)", + "roozbeh-yz/miniF2F_v2", + "HF-dataset", + "benchmark", + "Corrected miniF2F with simplified (v2s) and competition-style (v2c) jsonl files, fixing statement/informal mismatches", + "unverified row counts (datasets-server returned no info); 2 jsonl files confirmed: miniF2F_v2c.jsonl and miniF2F_v2s.jsonl; per-file sizes not returned by API (", + "unverified", + "apache-2.0 (cardData and license:apache-2.0 tag)", + "Secondary robustness eval, reported separately from the headline miniF2F-test number.", + "Derivative of miniF2F. Exclude.", + "API api 200; datasets-server info 200 with failed config-info for config default (dataset_info empty) 2026-10-14; 065cb8057ccfc6e70f4afdd4d8542306c0f7cdb2", + "unknown (check card)", + "High", + "Eval only" + ], + [ + "S6 Eval (held-out)", + "ProofNet# (ProofNetSharp, Lean 4)", + "PAug/ProofNetSharp", + "HF-dataset", + "benchmark", + "Corrected Lean 4 ProofNet#, valid 185 / test 186", + "371 rows (valid 185 + test 186, datasets-server)", + "compatible v4.7.0 to v4.16.0-rc2 (card)", + "mit (cardData.license; top-level license field null)", + "Preferred out-of-domain (undergrad) eval, proofnet test only for final.", + "IS ProofNet. Exclude.", + "API 200 2026-10-14; a8da405fbd1e348a87445c2e562c747b7e26dc8f", + "https://arxiv.org/abs/2406.xxxxx (unverified; ProofNet# / PAug)", + "High", + "Eval only" + ], + [ + "S6 Eval (held-out)", + "ProofNet-lean4 (UDACA)", + "UDACA/proofnet-lean4", + "HF-dataset", + "benchmark", + "ProofNet Lean 4 port, 185 valid / 186 test; v2 and v3 variants exist (UDACA/proofnet-v2-lean4, proofnet-v3-lean4)", + "371 rows (validation 185 + test 186, datasets-server)", + "unverified", + "none declared (no cardData.license, no top-level license)", + "Alternate port to compare against ProofNet#.", + "IS ProofNet.", + "API 200 2026-10-14; 947c7189d853a3d0c2fffd25959ff3a3548b55e2", + "https://arxiv.org/abs/2302.12433", + "High", + "Eval only" + ], + [ + "S6 Eval (held-out)", + "ProofNet-Verified", + "marcusm117/ProofNet-Verified", + "HF-dataset", + "benchmark", + "Verified/cleaned ProofNet statements, 367 rows", + "367 rows (train split, datasets-server)", + "unverified", + "apache-2.0 (cardData.license)", + "Optional cleaned variant. Check what was verified before adopting.", + "Derivative of ProofNet.", + "API 200 2026-10-14; 474192c2364ee646cbb5314c44dbecd48ae89d92", + "unknown", + "High", + "Eval only" + ], + [ + "S6 Eval (held-out)", + "PutnamBench (lmms-lab mirror, Lean4/Isabelle/Coq)", + "lmms-lab/PutnamBench", + "HF-dataset", + "benchmark", + "PutnamBench with lean4 config (660 rows), isabelle (640), coq (410)", + "lean4 test 660 rows (676,720 bytes); isabelle test 640; coq test 410 (datasets-server)", + "unverified (upstream tracks recent Lean; likely newer than 4.9)", + "none declared (no cardData.license)", + "Hard OOD eval at pass@k. Expect near-zero for 8B models. Use only the lean4 config.", + "IS PutnamBench. Exclude.", + "API 200 2026-10-14; cd981a0a118b2c1152fec5861e282388e2391b05", + "https://arxiv.org/abs/2407.11214", + "High", + "Eval only" + ], + [ + "S6 Eval (held-out)", + "PutnamBench (trishullab upstream)", + "https://github.com/trishullab/PutnamBench", + "GitHub", + "benchmark", + "Canonical PutnamBench repo, Lean4/Isabelle/Coq formalisations", + "unknown: repo size 3503 KB; statement count not returned by API, so the 522 to 672 range is unverified", + "unverified (check lean-toolchain per commit)", + "null (GitHub license.spdx_id and name both null; no top-leve", + "Pin a commit hash for the statement set and Lean version. Needed because mirrors differ in counts.", + "IS PutnamBench.", + "API 200 2026-10-14; default branch main; commit sha not returned (unknown)", + "https://arxiv.org/abs/2407.11214", + "High", + "Eval only" + ], + [ + "S6 Eval (held-out)", + "Seed-Prover (ByteDance)", + "https://github.com/ByteDance-Seed/Seed-Prover", + "GitHub", + "benchmark", + "Seed-Prover release with IMO-style and miniF2F/PutnamBench results and generated proofs", + "unverified (GitHub repo size field 14034 KB is the repo size, not a dataset size)", + "unverified", + "Apache-2.0 (license.spdx_id Apache-2.0)", + "Reference for IMO formal statements and reproducing reported numbers. Statement files not confirmed.", + "Contains miniF2F/Putnam proofs. Never use in training.", + "API 200 (GitHub api) 2026-10-14; unknown (default branch main; commit sha not fetched)", + "https://arxiv.org/abs/2507.23726", + "High", + "Eval only" + ] + ] +} \ No newline at end of file diff --git a/code/docs/workbook/data/Evals_Gates.json b/code/docs/workbook/data/Evals_Gates.json new file mode 100644 index 0000000000000000000000000000000000000000..033266f3016c16899fc73df0169385ef5964460e --- /dev/null +++ b/code/docs/workbook/data/Evals_Gates.json @@ -0,0 +1,104 @@ +{ + "columns": [ + "Eval set / metric", + "What it measures", + "Split-integrity rule", + "Statistics", + "Promotion threshold", + "Source", + "Confidence", + "Status" + ], + "widths": [ + 36, + 40, + 48, + 40, + 36, + 36, + 10, + 14 + ], + "rows": [ + [ + "miniF2F-valid (226, FILTERED: 13 unsupported, 4 elab failures, 1 overlap)", + "Contrast decisions K1-K4, averaging, stop rule", + "Driver refuses test-split scoring (test_guard); every selection logged", + "pass@8 dev; promotions confirmed with fresh-seed n=32", + "Heuristic gates only (+3 is not significant)", + "1c483cc; scripts/p2/expert_iteration.py", + "High", + "Done (built)" + ], + [ + "ProofNet-dev (to build)", + "Keeps selection from being miniF2F-only", + "Pinned, decontaminated", + "pass@8", + "-", + "docs/SOTA_PLAN.md Phase 0", + "Med", + "Planned" + ], + [ + "PRIMARY: miniF2F-test class A", + "Final candidate vs anchor 202/244", + "Touched once (--allow-test-selection --rounds 1)", + "n=k=32 solved/244; exact two-sided McNemar; wins/losses; paired CI; mean pass@1", + "Significant needs ~+9 net (losses 4 -> 13 wins) ~ 211/244; 207 is descriptive only", + "docs/SOTA_PLAN.md section 1", + "High", + "Planned" + ], + [ + "SECONDARY: class B miniF2F-test, class A ProofNet-test", + "Correction mode; transfer", + "Same harness both arms; trajectory ledger", + "Holm-corrected McNemar", + "-", + "docs/SOTA_PLAN.md section 1", + "High", + "Planned" + ], + [ + "EXPLORATORY: class C, PutnamBench class A pass@8", + "Agentic; hard transfer", + "Protocol tuple; compared only to agentic systems / own anchor", + "descriptive", + "-", + "docs/SOTA_PLAN.md section 1", + "Med", + "Planned" + ], + [ + "Verifier budget", + "Proof check wall-clock", + "300 s both arms; 1,200 s sensitivity; timeouts undecided", + "timeout counts per arm", + "-", + "1cc55e4", + "High", + "Done (protocol)" + ], + [ + "Rewritten-statement count", + "problem_check substitutes the reference statement", + "Reported per arm", + "count of accepted samples whose displayed statement differs", + "must not differ materially between arms", + "docs/P2.md 8c item 18", + "High", + "Planned" + ], + [ + "Verifier false-accept suite", + "Reward hacking resistance", + "Adversarial proofs never in training", + "0 accepts required", + "0/61 (P1); red-team 0 confirmed", + "654c20c", + "High", + "Running" + ] + ] +} \ No newline at end of file diff --git a/code/docs/workbook/data/Experiment_Plan.json b/code/docs/workbook/data/Experiment_Plan.json new file mode 100644 index 0000000000000000000000000000000000000000..0d636c43fb3501a3c5f5f587bed9e5220815d390 --- /dev/null +++ b/code/docs/workbook/data/Experiment_Plan.json @@ -0,0 +1,124 @@ +{ + "columns": [ + "Id", + "Question", + "Design", + "Compute (box-hours)", + "Success criterion", + "Depends on", + "Status", + "Source" + ], + "widths": [ + 7, + 44, + 52, + 16, + 44, + 14, + 14, + 40 + ], + "rows": [ + [ + "X0", + "Does our stack reproduce Goedel-V2-8B?", + "miniF2F-test pass@32 @40k, strict verifier", + "~12", + "Within noise of 84.6%", + "-", + "Done: 202/244 (82.8%)", + "45f6f99" + ], + [ + "X1", + "Pool pass-rate distribution", + "2,000 x 8 @16k, overlapped verify", + "~25", + "Band >= 150 statements", + "X0", + "Running", + "chain4.log" + ], + [ + "G0", + "Is the measurement trustworthy?", + "Frozen verifier + anchor re-verify; coverage; 4-stage decontam; class-B ledger; RL step fits; Rust pilot", + "~30", + "All 7 Phase-0 items pass", + "X0", + "Planned", + "SOTA_PLAN.md Phase 0" + ], + [ + "K3", + "Feedback vs restarts", + "2 repairs/trajectory vs restart sampling, matched tokens, valid", + "~15", + "Paired valid difference, confirmed with fresh seed", + "G0", + "Planned", + "SOTA_PLAN.md section 3" + ], + [ + "K4", + "Averaging alpha", + "alpha in {0.6,0.7,0.8,0.9,1.0} after one L3 round, valid", + "~15", + "Best alpha; selections logged", + "G0", + "Planned", + "SOTA_PLAN.md section 3" + ], + [ + "K2", + "Is mutation the active ingredient?", + "2,000 verified mutations vs 2,000 extra unmutated proofs, same rows/tokens", + "~30", + "Winner scaled to 10-15k", + "G0", + "Planned", + "SOTA_PLAN.md section 3" + ], + [ + "K1", + "EI-SFT vs RL from the same checkpoint", + "10-15 RL steps vs token-matched L3 round; winner continued", + "~15-55", + "Winner chosen on valid, confirmed n=32 fresh seed", + "G0 (RL step)", + "Planned", + "SOTA_PLAN.md section 3" + ], + [ + "F1", + "PRIMARY final", + "Class A miniF2F-test, n=k=32, exact McNemar vs anchor", + "~23", + "~+9 net (~211/244) for significance", + "K1-K4", + "Planned", + "SOTA_PLAN.md section 1" + ], + [ + "F2", + "Secondary + exploratory finals", + "Class B miniF2F; ProofNet class A (Holm); class C ProofNet; Putnam pass@8", + "~160", + "Reported with protocol tuple", + "F1", + "Planned", + "SOTA_PLAN.md section 5" + ], + [ + "B1", + "Bug study (separate paper)", + "Aeneas (Lean 4.31 env), prove/negate closed proposition, vs LLM review vs cargo-fuzz", + "~40", + "Validated precision per method; executable counterexamples", + "G0 pilot", + "Planned", + "SOTA_PLAN.md Phase 5" + ] + ] +} \ No newline at end of file diff --git a/code/docs/workbook/data/Journal.json b/code/docs/workbook/data/Journal.json new file mode 100644 index 0000000000000000000000000000000000000000..de38679d22d08f2fa4f6306bb55c9cafd0e674b4 --- /dev/null +++ b/code/docs/workbook/data/Journal.json @@ -0,0 +1,158 @@ +{ + "columns": [ + "Date", + "Event", + "Result / number", + "Evidence", + "Next action" + ], + "widths": [ + 12, + 46, + 52, + 40, + 44 + ], + "rows": [ + [ + "2026-09-23", + "align consolidated (SFT/DPO/PPO/GRPO, torch-only)", + "Dependency firewall test in place", + "a4d4d5e", + "Hardening" + ], + [ + "2026-10-12", + "Strict Lean verifier P1 + OLMoE parity", + "Ceiling 177/178 good, 0/61 adversarial accepted", + "654c20c", + "Goedel SFT build" + ], + [ + "2026-10-13", + "Scope narrowed to post-training Goedel-Prover-V2-8B", + "OLMoE pretraining / Leanstral-scale out of scope", + "align_olmoe_leanstral_roadmap memory", + "Baseline" + ], + [ + "2026-10-13", + "P2 baseline pass@8 @16k", + "78.7% (192/244); 40k identical", + "780ce3d", + "pass@32 reproduction" + ], + [ + "2026-10-13", + "Generation/verification overlap", + "Fixed idle-GPU defect (verify was 1.43 h serial)", + "7b07b55", + "-" + ], + [ + "2026-10-14", + "SOTA gate 1: pass@32 @40k", + "82.8% (202/244); 30k-equivalent identical", + "45f6f99", + "300 s timeout re-check" + ], + [ + "2026-10-14", + "300 s re-check of 274 timed-out samples", + "0 recovered; baseline stays 202", + "1cc55e4", + "Adopt 300 s / 1,200 s protocol" + ], + [ + "2026-10-14", + "miniF2F-valid built", + "226 problems", + "1c483cc", + "Use for all dev decisions" + ], + [ + "2026-10-14", + "Pool round 0 started", + "2,000 x 8 @16k; 2,963 generated by 14:36 UTC (~600/h)", + "align/.tmp/p2/r1/pipe_pool/pipeline.jsonl", + "chain5 after completion" + ], + [ + "2026-10-14", + "Leanstral lineage research: 6 depth loci done", + "RL mostly sharpens; data > RL here; CISPO second-order; FP32 head/averaging not Leanstral", + "research/runs/leanstral-recipe-lineage-c14fca/comparisons.md", + "Steps 7-16 -> final report" + ], + [ + "2026-10-14", + "Open dataset inventory started", + "Sonnet discover / Haiku verify / Sonnet critic", + "workflow wf_c3f2e9d0-871", + "Fill Datasets sheet" + ], + [ + "2026-10-14", + "Open dataset inventory complete", + "133 entries API-checked (132 exist); 76 candidates, 38 eval-only, 17 need decontamination; 9 gaps -> Data_Gaps", + "workflow wf_c3f2e9d0-871; docs/workbook/data/Datasets.json", + "Decontaminate (WBS 4.3); build G1/G2 pools" + ], + [ + "2026-10-14", + "8B SOTA plan drafted", + "Classes A/B/C/D, levers L1-L7, gates G0-G5, ~400-460 box-hours, calendar to Dec 20", + "docs/SOTA_PLAN.md", + "Finalize after research report; start Phase 0" + ], + [ + "2026-10-14", + "Codex independent review applied", + "Plan v2: one primary endpoint (~+9 net for significance), K1-K4 controlled contrasts, eval reserved first (~180 GPU-h), budget 520-620 GPU-h; split guard added to EI driver", + "docs/evidence/codex_review_2026-10-14.md; docs/SOTA_PLAN.md", + "Phase 0 items 1-7" + ], + [ + "2026-10-14", + "Codex + Claude code reviews applied", + "Codex 3 P1 + 5 P2, Claude 8 findings on build_valid: guard fails closed, protected test sets, per-round recheck, workbook lock, build_valid ceiling test + crash handling, box-hour units", + "commit after 9648df1", + "WBS 3.6 eval_report registration" + ], + [ + "2026-10-14", + "300 s re-check of baseline timeouts complete", + "0/274 accepted (195 still time out at 300 s = undecided, 76 compile errors); anchor stays 202/244", + ".tmp/p2/r1/baseline/ver_minif2f_c40960.timeouts_unsolved_b300.jsonl", + "1,200 s sensitivity on the 195 in Phase 0a" + ], + [ + "2026-10-14", + "Deep code verification + fix pass", + "12 units, 141 OK invariants; confirmed P1 7 / P2 49 / P3 170; 40 fixed, 15 partial, 1 design decision; 10 deferred (frozen files)", + "docs/evidence/deep_verification_2026-10-14.md", + "Phase-0a verifier freeze merges the deferred items" + ], + [ + "2026-10-14", + "SOTA plan v3", + "n=64 unbiased pass@32 + paired sign-flip primary; supply audit; Kimina-Distill-8B contrasts; ETA P50 Nov 27, P90 Dec 8", + "docs/SOTA_PLAN.md, docs/IMPLEMENTATION_LANES.md", + "Launch lanes V1,V2,S,D,T,B,N; chain6 anchor n=64 queued" + ], + [ + "2026-10-14", + "Supply v1 built", + "111,016 decontaminated + elaborating statements (Numina 61,136; Pset L4-5 39,576; Nemotron 6,679; FormalMATH 3,625); elab-hash stage caught 10 extra eval duplicates; text-only triage AUC 0.52 (no signal)", + ".tmp/p2/supply/supply_v1.jsonl (sha256 b3c505eb...)", + "chain7 screening n=4 on a 4,000 stratified sample after Phase-0a merges" + ], + [ + "2026-10-14", + "Lanes landed", + "merged S, N, T, D, CP; done on branches (merge after chain6): V1 verifier freeze, V2 genharness + temperature/Kimina follow-up, MV math verifier, R RL loop", + "docs/IMPLEMENTATION_LANES.md", + "chain6 exit -> merge V1, MV, V2, R -> registered ceiling test -> recompute frozen hash -> re-verify anchor -> touch PHASE0A_MERGED" + ] + ] +} diff --git a/code/docs/workbook/data/README.json b/code/docs/workbook/data/README.json new file mode 100644 index 0000000000000000000000000000000000000000..7fce511011614bebf192016ff28a84f3291ecb78 --- /dev/null +++ b/code/docs/workbook/data/README.json @@ -0,0 +1,74 @@ +{ + "columns": [ + "Item", + "Content", + "Source" + ], + "widths": [ + 24, + 110, + 60 + ], + "rows": [ + [ + "Purpose", + "Project workbook for reproducing the Leanstral 1.5 recipe at small scale: post-training Goedel-Prover-V2-8B (Qwen3-8B derivative) on 4x A10G, aiming to beat published ~8B miniF2F pass@32 SOTA, then applying the prover to find bugs in real code.", + "/apps/development/rlhf/align/docs/SOTA_TARGETS.md" + ], + [ + "How to read", + "WBS = what is done / running / next. Recipe_Stack = Leanstral stage vs our 8B analogue. Datasets = open data and tools per stage (API-verified, 133 entries). Data_Gaps = what does not exist publicly and how we build it. Evals_Gates = what decides promotion. Experiment_Plan = the runs. Journal = dated log. Sources = vault notes and repo paths.", + "-" + ], + [ + "The plan", + "docs/SOTA_PLAN.md v2 (after Codex review): ONE primary endpoint (class A miniF2F-test, exact McNemar; significance needs ~+9 net ~ 211/244); controlled contrasts K1 EI-vs-RL, K2 mutation-vs-unmutated, K3 feedback-vs-restarts, K4 averaging; eval reserved first (~180 GPU-h); total ~520-620 GPU-h; bug study is a separate paper.", + "/apps/development/rlhf/align/docs/SOTA_PLAN.md" + ], + [ + "How to maintain", + "Do not edit the xlsx. Edit docs/workbook/data/.json, then run: python docs/workbook/build_workbook.py. Log an event: build_workbook.py journal EVENT RESULT EVIDENCE NEXT. Change a status: build_workbook.py status WBS 4.1 Done.", + "/apps/development/rlhf/align/docs/workbook/build_workbook.py" + ], + [ + "Legend: Confidence", + "High = read directly from a primary source or measured here. Med = primary but self-reported / single run, or derived by arithmetic. Low = inferred, second-hand, or conflicting sources.", + "-" + ], + [ + "Legend: Status", + "Done / Running / Queued / Planned / Backlog / Deferred / Out of scope. Datasets: Verified = live API returned 200 on the date shown; Unverified = not checked; Missing = API 404.", + "-" + ], + [ + "Legend: Source", + "Vault note id (research/notes/.md in /apps/development/rlhf), a repo path, or a commit hash in /apps/development/rlhf/align.", + "-" + ], + [ + "Scope (decided)", + "Post-training only on Goedel-Prover-V2-8B. Mid-training (6.5B Lean tokens) and Leanstral-scale (119B MoE) training are out of scope: no rented compute; mid-training alone would take ~24 days on this box.", + "align_olmoe_leanstral_roadmap memory; interim-report-leanstral-base-midtraining-and-compute-provenance" + ], + [ + "Thesis (one line)", + "On this box the lever is data, not RL: iterated verifier-filtered self-distillation + compiler-feedback correction + weight averaging, with CISPO RL as last-stage polish. RL on single proofs mostly sharpens pass@k; Leanstral's headline gains are unattributed to RL.", + "/apps/development/rlhf/research/runs/leanstral-recipe-lineage-c14fca/comparisons.md" + ], + [ + "Premise corrections", + "FP32 LM head and model averaging are NOT in the Leanstral report (zero hits); they come from MiniMax-M1 and Goedel-V2. Leanstral's CISPO clamp c, group size, LR, steps, teachers, compute, Lean version are all unreported.", + "interim-report-leanstral-base-midtraining-and-compute-provenance" + ], + [ + "Measurement floor", + "1 problem = 0.41 pp. The ~2.4 pp figure is binomial spread across problems, not seed noise. Exact two-sided McNemar needs +6 net with 0 losses, +9 with 4 losses. Decisions on miniF2F-valid only (driver refuses test-split selection).", + "interim-report-data-vs-rl-lever-and-8b-4xa10g-reproducibility" + ], + [ + "Host rules honoured", + "Shared box, no swap: preflight (>=45 GB MemAvailable, load < 96, disk queue < 64), one heavy job at a time (flock), <=2 dataloader workers/rank, nothing on / or /tmp, checkpoints <=100 steps bf16 keep <=2.", + "/apps/CLAUDE.md" + ] + ] +} \ No newline at end of file diff --git a/code/docs/workbook/data/Recipe_Stack.json b/code/docs/workbook/data/Recipe_Stack.json new file mode 100644 index 0000000000000000000000000000000000000000..f4577504e5484071a7fb2cf5c989787e605f1549 --- /dev/null +++ b/code/docs/workbook/data/Recipe_Stack.json @@ -0,0 +1,136 @@ +{ + "columns": [ + "Stage", + "Leanstral 1.5 (reported)", + "Our 8B / 4x A10G analogue", + "Data (see Datasets)", + "Gate to advance", + "Box-hours", + "Source", + "Confidence", + "Status" + ], + "widths": [ + 16, + 46, + 46, + 34, + 34, + 12, + 40, + 10, + 14 + ], + "rows": [ + [ + "Base", + "Mistral Small 4: 119B MoE, 128 routed + 1 shared expert, top-4, ~6B active, 36 layers, MLA", + "Goedel-Prover-V2-8B (dense Qwen3-8B derivative) - already Lean-trained", + "-", + "Reproduce published baseline", + "~12", + "interim-report-leanstral-base-midtraining-and-compute-provenance", + "High", + "Done (82.8% pass@32)" + ], + [ + "Mid-training", + "6.5B dedup Lean tokens + code-agent / instruction data; ratios unreported", + "Skipped: base is already Lean-specialised; 6.5B tokens ~24 days here", + "Datasets: S1", + "-", + "~575", + "interim-report-leanstral-base-midtraining-and-compute-provenance", + "High", + "Out of scope" + ], + [ + "SFT", + "50% Lean code-agent traces, failure-mode filtered; teachers unreported", + "Iterated verifier-filtered self-distillation (LoRA) on band problems + compiler-error correction pairs", + "Datasets: S2a, S2b; own pool rounds", + "valid pass@8 gain >= noise; no regression", + "~55 per round", + "interim-report-data-vs-rl-lever-and-8b-4xa10g-reproducibility", + "Med", + "Queued" + ], + [ + "Prompt filter", + "Keep prompts with low-but-nonzero SFT solve rate (8 rollouts)", + "Band (0, 0.75] from pool round n=8; ~11% of pool", + "Pool round 0", + "-", + "~25", + "in-this-work-we-present-leanstral-a-series-of-generalist-code-agent-models-for", + "High", + "Running" + ], + [ + "RL", + "CISPO, loss -(1/sum|o|) sum sg(min(pi/pi_sample, c)) A log pi; c unreported; outcome broadcast to all tokens; tool/prompt tokens masked; mix 50% LeanGym-PR / 20% single theorem / 30% prove-or-disprove multi-turn", + "Small on-policy LoRA CISPO, c~5, no lower clamp, neg-adv low-ratio sequence mask, 1-2 updates/rollout, single-turn + correction mode, <=40k ctx", + "Datasets: S3", + "paired valid gain; collapse tripwires", + "~15", + "interim-report-objective-choice-cispo-vs-grpo-effect-size-controlled", + "Med", + "Planned" + ], + [ + "Reward / verifier", + "Format partial + compile with standard axioms only (#print axioms); native_decide banned; LeanInteract + modified SafeVerify", + "Strict verifier (P1) + red-team fixes + OS sandbox; same verifier both arms", + "Datasets: S4", + "0 false accepts on adversarial suite", + "-", + "in-this-work-we-present-leanstral-a-series-of-generalist-code-agent-models-for", + "High", + "Running" + ], + [ + "Averaging", + "Not in Leanstral report (Goedel-V2 item)", + "alpha 0.6-0.9 between SFT round and RL/next round, chosen on valid", + "-", + "valid pass@32 restored", + "~6", + "interim-report-rl-adds-vs-sharpens-on-lean-passk", + "Med", + "Planned" + ], + [ + "FP32 LM head", + "Not in Leanstral report (MiniMax-M1 item)", + "Kept (cheap); expected ~0 gain on dense 8B", + "-", + "-", + "0", + "interim-report-objective-choice-cispo-vs-grpo-effect-size-controlled", + "Med", + "Done (implemented)" + ], + [ + "Scaffold", + "Mistral Vibe agent; compaction every 200k by mistral-small; persistent files/lemmas", + "Single-turn whole proof + bounded correction turns; 200k windows infeasible (~29 GB KV)", + "-", + "-", + "-", + "interim-report-multiturn-compiler-feedback-vs-singleturn-and-sampling", + "Med", + "Planned" + ], + [ + "Bug finding", + "Aeneas Rust->Lean; generate properties; pass@4 prove + pass@4 negation; 30 turns; 57 repos -> 47 flagged -> 11 real -> 5 new", + "Same pipeline on a small crate set after the prover is final", + "Datasets: S5", + "Manually confirmed bugs", + "-", + "in-this-work-we-present-leanstral-a-series-of-generalist-code-agent-models-for", + "High", + "Planned" + ] + ] +} diff --git a/code/docs/workbook/data/Sources.json b/code/docs/workbook/data/Sources.json new file mode 100644 index 0000000000000000000000000000000000000000..7d90aa07c399ed648eff6d084a31ee9e5bbb30c6 --- /dev/null +++ b/code/docs/workbook/data/Sources.json @@ -0,0 +1,88 @@ +{ + "columns": [ + "Note id / path", + "Title", + "Type", + "Used in sheets" + ], + "widths": [ + 70, + 60, + 30, + 40 + ], + "rows": [ + [ + "in-this-work-we-present-leanstral-a-series-of-generalist-code-agent-models-for", + "Leanstral technical report (LeanstralSafeVerify repo PDF)", + "vault primary", + "Recipe_Stack, WBS" + ], + [ + "interim-report-leanstral-base-midtraining-and-compute-provenance", + "Leanstral architecture, mid-training, compute provenance", + "vault interim (our pipeline)", + "README, Recipe_Stack, WBS" + ], + [ + "interim-report-data-vs-rl-lever-and-8b-4xa10g-reproducibility", + "Data vs RL lever; 8B on 4x A10G box-hours", + "vault interim (our pipeline)", + "README, Recipe_Stack, Experiment_Plan" + ], + [ + "interim-report-rl-adds-vs-sharpens-on-lean-passk", + "Does RL add or sharpen Lean pass@k", + "vault interim (our pipeline)", + "Recipe_Stack, WBS" + ], + [ + "interim-report-objective-choice-cispo-vs-grpo-effect-size-controlled", + "CISPO vs GRPO family, objectives written out", + "vault interim (our pipeline)", + "Recipe_Stack, WBS" + ], + [ + "interim-report-multiturn-compiler-feedback-vs-singleturn-and-sampling", + "Multi-turn compiler feedback vs single-turn and sampling", + "vault interim (our pipeline)", + "Recipe_Stack" + ], + [ + "interim-report-headline-benchmark-protocol-comparability", + "Benchmark protocol comparability", + "vault interim (our pipeline)", + "Evals_Gates" + ], + [ + "research/runs/leanstral-recipe-lineage-c14fca/comparisons.md", + "Cross-locus tensions", + "run artefact", + "README" + ], + [ + "/apps/development/rlhf/align/docs/SOTA_PLAN.md", + "8B SOTA plan: protocol classes, levers, gates, calendar", + "repo doc", + "WBS, Experiment_Plan, README" + ], + [ + "/apps/development/rlhf/align/docs/SOTA_TARGETS.md", + "Verified ~8B prover targets", + "repo doc", + "Evals_Gates, WBS" + ], + [ + "/apps/development/rlhf/align/docs/SELF_DISTILLATION.md", + "Self-distillation plan", + "repo doc", + "WBS, Experiment_Plan" + ], + [ + "/apps/development/rlhf/align/docs/P2.md", + "P2 baseline build and protocol", + "repo doc", + "WBS" + ] + ] +} \ No newline at end of file diff --git a/code/docs/workbook/data/WBS.json b/code/docs/workbook/data/WBS.json new file mode 100644 index 0000000000000000000000000000000000000000..5afbd8f1b3007f5fa7d546dd958494023b365447 --- /dev/null +++ b/code/docs/workbook/data/WBS.json @@ -0,0 +1,616 @@ +{ + "columns": [ + "Id", + "Work package", + "Deliverable", + "Owner path", + "Depends on", + "Est. box-hours", + "Start", + "Finish", + "Status", + "Source" + ], + "widths": [ + 7, + 34, + 52, + 40, + 12, + 12, + 11, + 11, + 12, + 40 + ], + "rows": [ + [ + "1", + "FOUNDATIONS", + "align post-training stack on torch/triton/numpy/safetensors only", + "align/", + "-", + "-", + "2026-09-23", + "2026-10-13", + "Done", + "a4d4d5e .. 3c71f8d" + ], + [ + "1.1", + "Consolidated stack (SFT/DPO/PPO/GRPO)", + "1,093 tests passing; dependency firewall", + "align/align/", + "-", + "-", + "2026-09-23", + "2026-10-12", + "Done", + "a4d4d5e, ac2bdcf" + ], + [ + "1.2", + "Strict Lean 4 verifier (P1)", + "Ceiling 177/178 good, 0/61 adversarial; bans exact?/apply?/native_decide; axiom audit", + "align/align/verify/lean/strict.py", + "1.1", + "-", + "2026-10-12", + "2026-10-12", + "Done", + "654c20c; docs/LEAN_VERIFIER.md" + ], + [ + "1.3", + "Qwen3 / Goedel-V2-8B in align", + "Logit parity 2.9e-5; LoRA SFT 2,152 tok/s @16k", + "align/align/model/", + "1.1", + "-", + "2026-10-13", + "2026-10-13", + "Done", + "34a525c" + ], + [ + "1.4", + "Generation + overlapped verification", + "vLLM TP=4 gen and Lean verify inside ONE heavy lock", + "scripts/p2/pipeline_gen_verify.py", + "1.3", + "-", + "2026-10-13", + "2026-10-13", + "Done", + "7b07b55" + ], + [ + "2", + "BASELINE (SOTA gate 1)", + "Reproduced Goedel-V2-8B on miniF2F", + "docs/P2.md", + "1", + "-", + "2026-10-13", + "2026-10-14", + "Done", + "45f6f99" + ], + [ + "2.1", + "pass@8 @16k", + "78.7% (192/244)", + "p2_runs/", + "1.4", + "~3", + "2026-10-13", + "2026-10-13", + "Done", + "780ce3d" + ], + [ + "2.2", + "pass@32 @40k", + "82.8% (202/244); 30k-equivalent identical", + "p2_runs/r1_baseline/", + "2.1", + "~12", + "2026-10-13", + "2026-10-14", + "Done", + "45f6f99" + ], + [ + "2.3", + "300 s re-check of timed-out samples", + "0 recovered -> baseline stays 202", + "align/.tmp/p2/r1/baseline/", + "2.2", + "~2", + "2026-10-14", + "2026-10-14", + "Done", + "1cc55e4" + ], + [ + "2.4", + "miniF2F validation split", + "226 problems, decontaminated vs test/MOBench/pool", + "scripts/p2/build_valid.py", + "-", + "<1", + "2026-10-14", + "2026-10-14", + "Done", + "1c483cc" + ], + [ + "2.5", + "Split-hygiene guard in EI driver", + "Driver refuses a test-split scored file unless --allow-test-selection (--rounds 1, no target); 50 tests pass", + "scripts/p2/expert_iteration.py", + "-", + "0", + "2026-10-14", + "2026-10-14", + "Done", + "Codex review item 1" + ], + [ + "3", + "VERIFIER HARDENING", + "One hardened verifier for both arms before final comparison", + "align/align/verify/lean/", + "1.2", + "-", + "2026-10-13", + "", + "Running", + "redteam/false-accepts worktree" + ], + [ + "3.1", + "Red-team false-accept hunt", + "0 confirmed false accepts; 3 latent weaknesses (e.g. axiom-walk fuel cap fails open)", + "../align-redteam (branch redteam/false-accepts)", + "1.2", + "-", + "2026-10-13", + "", + "Running", + "separate user session" + ], + [ + "3.2", + "Merge red-team fixes", + "Axiom walk fails CLOSED at fuel cap; other latent fixes", + "align/align/verify/lean/strict.py", + "3.1", + "-", + "", + "", + "Planned", + "-" + ], + [ + "3.3", + "Enable Lean REPL OS sandbox", + "sandbox.py wired into ReplPool (currently opt-in)", + "align/align/verify/lean/sandbox.py", + "3.2", + "-", + "", + "", + "Planned", + "3c71f8d; docs/LEAN_SANDBOX.md" + ], + [ + "3.4", + "Re-verify baseline under hardened verifier", + "Baseline count under the final verifier (may move from 202)", + "p2_runs/r1_baseline/", + "3.3", + "~2", + "", + "", + "Planned", + "1cc55e4" + ], + [ + "3.5", + "Class-B ledger + environment coverage", + "Trajectory ids, cumulative tokens, declared truncation; ProofNet/Putnam inputs built and elaboration coverage measured; ProofNet dev split", + "extras/vllm_rollout/lean_gen.py; scripts/p2/", + "3.3", + "~10", + "2026-10-15", + "2026-10-20", + "Planned", + "docs/SOTA_PLAN.md Phase 0 items 3, 5" + ], + [ + "3.6", + "Register SOTA analysis in eval_report", + "Separate registered config: class A miniF2F-test n=k=32 @40k, 300 s, exact McNemar vs anchor; secondaries labelled", + "scripts/p2/eval_report.py", + "3.4", + "0", + "", + "", + "Planned", + "Codex code review 2026-10-14 (P2)" + ], + [ + "4", + "DATA", + "Training pools for self-distillation and RL", + "align/.tmp/p2/", + "-", + "-", + "2026-10-14", + "", + "Running", + "-" + ], + [ + "4.1", + "Pool round 0 (2,000 statements x 8 @16k)", + "Pass-rate profile: ~11% band (1-6/8), ~42% never, ~47% easy", + "align/.tmp/p2/r1/pool/", + "2.2", + "~25", + "2026-10-14", + "", + "Running", + "chain4.log" + ], + [ + "4.2", + "Open dataset inventory", + "Datasets sheet: per-stage HF/GitHub entries, API-verified", + "docs/workbook/data/Datasets.json", + "-", + "0", + "2026-10-14", + "", + "Done", + "workflow wf_c3f2e9d0-871" + ], + [ + "4.3", + "Decontamination sweep (4 stages)", + "NL+Lean hashes, n-gram (10 NL / 5 Lean), binder-aware tree equivalence, proof skeleton; covers mutations, trajectories, proof bank; ancestry kept; heuristic error measured", + "scripts/p2/ (new decontam.py)", + "4.2", + "<1 GPU; CPU", + "2026-10-15", + "2026-10-20", + "Planned", + "docs/SOTA_PLAN.md Phase 0; source-analysis-pythagoras-prover (A.3)" + ], + [ + "4.4", + "Mid-training Lean corpus", + "6.5B-token continued pretraining", + "-", + "-", + "~575 (24 days)", + "", + "", + "Out of scope", + "interim-report-leanstral-base-midtraining-and-compute-provenance" + ], + [ + "5", + "SELF-DISTILLATION", + "Iterated verifier-filtered SFT rounds on band problems", + "scripts/p2/expert_iteration.py", + "4.1", + "-", + "", + "", + "Queued", + "docs/SELF_DISTILLATION.md" + ], + [ + "5.1", + "Base model on miniF2F-valid n=8 @16k", + "Dev-split reference for round selection", + "align/.tmp/p2/r1/chain5.sh", + "4.1", + "~3", + "", + "", + "Queued", + "chain5.sh" + ], + [ + "5.2", + "Repair / correction round 1", + "354 failed attempts re-tried with compiler feedback", + "align/.tmp/p2/r1/select_corrections.py", + "5.1", + "~6", + "", + "", + "Queued", + "chain5.sh" + ], + [ + "5.3", + "Band-targeted rejection-sampling LoRA SFT", + "Round-k adapters; promotion on valid only", + "scripts/p2/expert_iteration.py", + "5.2", + "~55", + "", + "", + "Planned", + "interim-report-data-vs-rl-lever-and-8b-4xa10g-reproducibility" + ], + [ + "5.4", + "Weight-averaging sweep", + "alpha in 0.6-0.9 between rounds, chosen on valid", + "scripts/p2/weight_average.py", + "5.3", + "~6", + "", + "", + "Planned", + "interim-report-rl-adds-vs-sharpens-on-lean-passk" + ], + [ + "5.5", + "L1 / K3: correction vs restarts", + "Class-B harness with trajectory ledger; feedback vs restart sampling at matched tokens on valid", + "extras/vllm_rollout/lean_gen.py", + "3.4", + "~15", + "2026-10-20", + "2026-10-26", + "Planned", + "docs/SOTA_PLAN.md K3" + ], + [ + "5.6", + "K2 pilot: mutation vs unmutated data", + "2,000 verified mutations vs 2,000 extra unmutated proofs, same rows/tokens; parents screened for misformalisation", + "scripts/p2/ (new mutate_statements.py)", + "4.3, 5.4", + "~30", + "2026-10-24", + "2026-11-02", + "Planned", + "docs/SOTA_PLAN.md K2" + ], + [ + "5.7", + "Scale K2 winner (10-15k)", + "Not 50k: 50k x2 x5k tok ~150-250 h generation alone", + "scripts/p2/expert_iteration.py", + "5.6", + "~60-90", + "2026-11-02", + "2026-11-23", + "Planned", + "docs/SOTA_PLAN.md Phase 2" + ], + [ + "6", + "RL POLISH", + "Small on-policy LoRA CISPO run", + "align/align/rl/cispo.py", + "5", + "-", + "", + "", + "Planned", + "interim-report-objective-choice-cispo-vs-grpo-effect-size-controlled" + ], + [ + "6.1", + "CISPO stability guard", + "Negative-advantage low-ratio sequence mask (ARLArena fix); c~5; 1-2 updates/rollout", + "align/align/rl/cispo.py", + "-", + "0", + "", + "", + "Backlog", + "interim-report-objective-choice-cispo-vs-grpo-effect-size-controlled" + ], + [ + "6.2", + "K1: EI-SFT vs RL from the same checkpoint", + "Token-matched pilot 10-15 RL steps vs one L3 round; then 20-40 steps for the winner", + "align/align/rl/", + "6.1, 6.4", + "~15-40", + "2026-10-26", + "2026-11-23", + "Planned", + "docs/SOTA_PLAN.md K1" + ], + [ + "6.3", + "L6 agentic scaffold (class C)", + "Multi-round refinement R in {4,8} + retrieval of verified lemmas; tuned on valid", + "align/align/rl/envs/lean_multiturn.py", + "5", + "~40", + "2026-11-09", + "2026-11-16", + "Planned", + "docs/SOTA_PLAN.md L6; oprover-a-unified-framework-for-agentic-formal-theorem" + ], + [ + "6.4", + "RL feasibility check (<=4 h)", + "One LoRA step at 16k with FP32 head (2.5 GB + 1.25 GB/2k-chunk vs 1.9 GiB headroom); handoff time; logprob parity", + "align/align/rl/cispo.py", + "1.3", + "~4", + "2026-10-17", + "2026-10-20", + "Planned", + "docs/SOTA_PLAN.md Phase 0 item 6" + ], + [ + "7", + "FINAL EVALUATION", + "Pre-registered: primary = class A miniF2F-test, exact McNemar (needs ~+9 net, ~211); secondary class B + ProofNet (Holm); exploratory class C + Putnam pass@8", + "docs/SOTA_PLAN.md", + "3.4, 5, 6", + "~180 (226 w/ 2nd seed)", + "2026-11-26", + "2026-12-08", + "Planned", + "docs/SOTA_PLAN.md sections 1, 5" + ], + [ + "7.1", + "Pre-registered final evals", + "Both arms, frozen verifier/config hash; --allow-test-selection --rounds 1", + "scripts/p2/eval_report.py", + "3.4, 5.7, 6.2, 6.3", + "~180", + "2026-11-26", + "2026-12-08", + "Planned", + "docs/SOTA_PLAN.md Phase 4" + ], + [ + "8", + "RESEARCH & FOUNDATIONS", + "First-principles understanding of the recipe", + "research/, align/notes, align/notebooks", + "-", + "0", + "2026-10-14", + "", + "Running", + "-" + ], + [ + "8.1", + "Leanstral recipe lineage (hyperresearch)", + "Final report research/notes/final_report_leanstral-recipe-lineage-c14fca.md", + "research/runs/leanstral-recipe-lineage-c14fca/", + "-", + "0", + "2026-10-14", + "", + "Running", + "comparisons.md (step 6 done)" + ], + [ + "8.2", + "Math notes + notebooks", + "0101-0110 policy optimisation, 0201-0208 verifier learning, 0301-0306 formal methods", + "align/notes/src, align/notebooks", + "-", + "0", + "2026-10-14", + "", + "Running", + "workflow wf_659e4ae4-362" + ], + [ + "8.3", + "Architecture V2 (AgileRL study)", + "Modular algorithm/registry design for align", + "align/docs/ARCHITECTURE_V2_PROPOSAL.md", + "-", + "0", + "2026-10-14", + "", + "Running", + "workflow wf_659e4ae4-362" + ], + [ + "9", + "BUG FINDING (separate paper)", + "Prover applied to real Rust code; decoupled from the prover paper", + "-", + "7", + "-", + "", + "", + "Planned", + "docs/SOTA_PLAN.md Phase 5" + ], + [ + "9.1", + "Bug-finding literature review", + "Aeneas pipeline, property generation, prove/negate", + "align/docs/research/BUGFINDING_LIT_REVIEW.md", + "-", + "0", + "2026-10-14", + "", + "Running", + "workflow wf_659e4ae4-362" + ], + [ + "9.2", + "Aeneas/Charon toolchain (Lean 4.31.0 env)", + "Separate pinned environment + repo-definition-aware verifier; pilot on 3-5 functions with known bugs", + "-", + "9.1", + "CPU", + "2026-10-16", + "2026-10-20", + "Planned", + "docs/SOTA_PLAN.md Phase 0 item 7" + ], + [ + "9.3", + "Prove-or-negate harness", + "pass@4 prove, then negate the CLOSED proposition; executable Rust counterexample required", + "-", + "9.2", + "-", + "2026-12-08", + "2026-12-22", + "Planned", + "-" + ], + [ + "9.4", + "Bug study baselines", + "Plain LLM review + cargo-fuzz on the same crates; validated precision per method", + "-", + "9.3", + "~40 (GPU) + CPU", + "2026-11-21", + "2026-12-05", + "Planned", + "docs/SOTA_PLAN.md Phase 5" + ], + [ + "10", + "PUBLICATION", + "Paper: reproduction + self-distillation result + bug-finding case study", + "-", + "7, 9", + "-", + "", + "", + "Planned", + "-" + ], + [ + "10.1", + "Papers", + "Paper 1 prover (K1-K4, classes A/B, exploratory C); Paper 2 bug study", + "-", + "7.1, 9.4", + "-", + "2026-12-08", + "", + "Planned", + "docs/SOTA_PLAN.md Phase 6" + ] + ] +} \ No newline at end of file diff --git a/code/extras/vllm_rollout/README.md b/code/extras/vllm_rollout/README.md new file mode 100644 index 0000000000000000000000000000000000000000..56aac7a18b145013be8dc18b49d74345ff914a4d --- /dev/null +++ b/code/extras/vllm_rollout/README.md @@ -0,0 +1,146 @@ +# vLLM rollout (optional extra) + +This directory is **not** part of the `align` package and nothing under `align/` +imports it (`tests/test_dependency_firewall.py::test_extras_are_not_reachable_from_core`). +The default GRPO rollout is the dependency-free native sampler, +`align/rollout/native.py`. Use this only when generation throughput is the +bottleneck and a dedicated engine GPU is available. + +## Runs in its own venv + +`rollout_worker.py` and `rollout_pilot.py` run under `.venv-rollout` +(vllm 0.26.0, ray, pyarrow, torch cu130), **never** the `align` venv, whose +only dependencies are torch, triton, numpy and safetensors. Build it with +`uv venv .venv-rollout` plus `torch==2.11.0 triton==3.6.0 vllm==0.26.0 ray pyarrow`. + +## How it talks to the trainer + +Only through `align/rollout/protocol.py` (stdlib-only by contract): one JSON +file per message in a spool directory, atomically written, with a heartbeat +file for liveness. The worker puts `rollout_worker.py`'s repo root on +`sys.path` and imports that single module; `import align` and +`import align.rollout` are lazy / stdlib-only so no `align` dependency reaches +this venv. + +Trainer side: + +```python +from align.rollout.client import make_rollout_client, spawn_worker + +proc = spawn_worker(spool, gpu=3, log_path=".../rollout_worker.log") +client = make_rollout_client("spool", spool=spool, timeout_s=3600) +``` + +`spawn_worker` launches `extras/vllm_rollout/rollout_worker.py` with the +required environment (flashinfer sampler off, caches under `/apps`, torchrun +variables scrubbed, own process group so teardown reaps the engine core). + +## Logprob basis caveat + +The worker requests `SamplingParams(logprobs=0)`, which in vLLM are the +engine's **raw** logprobs (before temperature). The native backend returns +`log_softmax(logits / T)` untruncated, which is what the GRPO module recomputes +teacher-forced. The two coincide at `temperature == 1.0` (the stage-b setting). +For `T != 1` the spool backend's logprobs differ from the training-side basis +by the temperature rescaling, so rho will not be ~1 on-policy there; use +`T = 1.0` with this worker, or set vLLM's `logprobs_mode` to a processed mode +and accept that top-p/top-k then enter the logprob too. + +Weight sync is filesystem-based: a changed `weights_dir` tears the engine down +and rebuilds it (~60 s), so sync every `sync_every` optimizer steps and let +`use_rho_correction=True` absorb the off-policyness. + +## Lean proving generation driver (experiment P2): `lean_gen.py` + `lean_prompts.py` + +Generates Goedel-Prover-V2 proof attempts with vLLM and writes the shared Generation +record JSONL (one line per sample). Runs in `.venv-rollout`; `lean_prompts.py` and the +pure half of `lean_gen.py` are stdlib-only at import and are unit-tested from the align +venv (`tests/test_lean_gen_contract.py`, 36 tests, 15/15 hand mutants killed). + +```bash +cd /apps/development/rlhf/align && export TMPDIR=$PWD/.tmp/p2/ +# round 0 +.venv-rollout/bin/python extras/vllm_rollout/lean_gen.py --problems problems.jsonl \ + --out gen_r0.jsonl --n 4 --cap-tokens 16384 --tp 4 --seed 0 --wait-lock +# correction round 1 (parents = failed round-0 samples; needs a verification JSONL, see below) +.venv-rollout/bin/python extras/vllm_rollout/lean_gen.py --problems problems.jsonl \ + --out gen_r1.jsonl --n 1 --cap-tokens 16384 --round 1 \ + --corrections-from verif_r0.jsonl gen_r0.jsonl --wait-lock +# plan + prompts only, no GPU / lock +... --dry-run +``` + +Rerunning the same command resumes. `.manifest.json` has one `sessions[]` entry per +launch (model rev, vllm/torch versions, args, load/generate wall, tokens, finish counts, KV +blocks, peak GPU memory); `.skipped.jsonl` lists correction prompts that did not fit. + +**Fidelity.** `python extras/vllm_rollout/lean_prompts.py fidelity --model --out r.json` +(rollout venv) imports the reference `src/utils.py` and asserts identical prompt strings and +token ids on all 244 minif2f problems, identical `extrac_code` + `problem_check` on 63 synthetic +outputs, `get_error_str` / `generate_correction_prompt` on 60 random error sets, and +`compile.py:handle` vs `compile_view`. + +**What a record means.** +* `cap_tokens` is the label every reported number carries. `sampling.max_tokens` is what vLLM + got: `min(cap, max_model_len - len(prompt))`. They differ only when the context window ends the + sample (`context_clipped: true`); at `--cap-tokens 40960 --max-model-len 40960` every sample is + clipped by its prompt length (~250-600 tokens). `--max-model-len` above the model's 40960 + position limit raises. +* `completion_ids` are vLLM's `token_ids`: they end with the stop token (`<|im_end|>`) when + `finish_reason == "stop"`. Train on them as they are; do not re-tokenize `completion_text`. +* `messages` are the PROMPT messages (what the chat template rendered; no completion), so a + correction round's history is `parent.messages + [assistant: parent.completion_text, user: errors]` + exactly as Goedel builds it. `lean_code` is Goedel's `full_code` or null, with the reason in + `lean_code_error` (extra field). +* Sampling: temperature 1.0, top_p 0.95, no top_k (Goedel's `SamplingParams`; the model's + `generation_config.json` says top_k=20 / T=0.6 but explicit params override it). One seed per + sample, derived from (seed, problem, sample_idx, round); not the cap. + +**Correction rounds need two extra fields in the VERIFICATION record** (the shared contract does +not carry them): `repl_errors` (list of Lean REPL messages with `severity == "error"`, each +`{pos:{line,column}, endPos:{line,column}|null, data}`) and `repl_code` (the exact text those +positions index into; for the Goedel flow that is `lean_prompts.compile_view(lean_code)`, i.e. the +code with `import` / `set_option` / `open` lines removed). Without them `` markers land on +the wrong lines, so a parent lacking them is skipped and counted (`no_repl_errors`), never guessed. + +**Throughput/memory choices.** `gpu_memory_utilization=0.90` (20.7 GB of the A10G's 22.5 GiB; +measured peak `nvidia-smi` use 21.7-22.5 GB, i.e. 0.5-1.3 GB of slack for NCCL/allocator; 0.92 was +not tried), `max_num_seqs=128`, prefix caching on, CUDA graphs on, FLASH_ATTN backend (works on +sm_86), `VLLM_USE_FLASHINFER_SAMPLER=0`. KV pool: 28,533 blocks x 16 = 456,528 tokens (TP=4: each +GPU holds 2 of 8 KV heads, 36 KB/token). The driver steps the engine itself (`add_request`/`step`) +with a refill window instead of `LLM.generate` chunks, so a 16k-token straggler does not idle the +GPUs. Measured on Goedel-Prover-V2-8B, TP=4, seeded sampling (minif2f in file order, T=1.0, top_p=0.95): + +| run | cap | samples | mean / median / p90 completion tokens | finish=length | end-to-end tok/s | tok/s while >=64 running | +|---|---|---|---|---|---|---| +| 32 problems x n=4, max_num_seqs 128 | 16384 | 128 | 5066 / 3242 / 13993 | 10/128 (7.8%) | 918 | 1602 | +| 64 problems x n=4, max_num_seqs 256 | 16384 | 256 | 4996 / 3033 / 13641 | 21/256 (8.2%) | 1043 | 1363 (KV pool 100% full at peak) | +| 8 problems x n=2, max_num_seqs 128 | 40960 (context-clipped, max_tokens ~40.6-40.8k) | 16 | 4450 / 2662 / 7339 | 0/16 | 256 (tail of 1 sequence) | n/a (max 16 running) | + +Per-sequence decode speed is ~25 tok/s at 85-128 concurrent sequences and ~74 tok/s alone: TP=4 +over PCIe (no NVLink) is allreduce-bound, so aggregate throughput saturates near 1.4-2.2k tok/s +beyond ~100 concurrent sequences, and 256 sequences only fill the KV pool. Keep `--max-num-seqs 128`. + +### Class-B trajectories, exit status, un-merged adapter (lane V2) + +* `--trajectory-cap-tokens 40000` enforces the class-B cumulative cap; every record carries a + `trajectory` ledger (root id, round, cumulative completion tokens, truncation, budget clipping); + failed parents with no compiler error are `skipped_no_feedback` in `.skipped.jsonl` + (or re-sampled with `--no-feedback-policy resample`). Goedel's rule (repair only problems + without an accepted sample) is the default; `--repair-solved` lifts it. Pass every earlier + round's `VERIF GEN` pair in `--corrections-from` so round-2+ lineage is complete (otherwise the + entry is `partial` and a cap refuses). `build_trajectory_ledger()` aggregates a final state per root. +* Exit status: 0 only for a complete sample set, 143 on SIGTERM/SIGINT. `pipeline_gen_verify.py` + also checks the manifest (exit 4 if not `complete`) and reaps the generator's process group, + sweeps `/dev/shm` and waits for the GPUs to drain after every generator exit. +* Un-merged adapter: `--lora-adapter [--lora-name adapter]` serves it via vLLM + `enable_lora`/`LoRARequest` (no bf16 merge). Adapter sha256 goes in the manifest, each record and + `model_rev` (`+lora-`). GPU smoke (run after the chains exit, takes the heavy lock): + +```bash +.venv-rollout/bin/python extras/vllm_rollout/lean_gen.py --problems <5 problems>.jsonl \ + --out .tmp/v2_smoke/gen_lora.jsonl --n 2 --cap-tokens 2048 --tp 1 --max-model-len 8192 \ + --lora-adapter --wait-lock +# expect: manifest sessions[-1].adapter.sha256 set, vllm engine log shows LoRA enabled, every +# record has "adapter", exit 0; then rerun the same command (must resume, 0 new samples). +``` diff --git a/code/extras/vllm_rollout/lean_gen.py b/code/extras/vllm_rollout/lean_gen.py new file mode 100644 index 0000000000000000000000000000000000000000..d719056139c0eef9fd6efe3542f18a24a4908a84 --- /dev/null +++ b/code/extras/vllm_rollout/lean_gen.py @@ -0,0 +1,1177 @@ +"""vLLM generation driver for Lean proving (experiment P2): Goedel prompts in, Generation records out. + +OPTIONAL EXTRA -- NOT part of the `align` package. Runs under `.venv-rollout` +(vllm 0.26.0, python3.12); never the `align` venv. All third-party imports (vllm, +transformers) are lazy inside functions, so the pure planning / resume / record logic +below is importable -- and tested -- without them (tests/test_lean_gen_contract.py). + + .venv-rollout/bin/python extras/vllm_rollout/lean_gen.py \\ + --problems P.jsonl --out gen_r0.jsonl --n 4 --cap-tokens 16384 --round 0 --tp 4 + + # correction round k: parents are round k-1 samples + their verification records + ... --round 1 --n 1 --corrections-from verif_r0.jsonl gen_r0.jsonl --out gen_r1.jsonl + +Design decisions, each tied to a defect it prevents: + +* Honest caps. `cap_tokens` is the nominal cap every reported number is labelled with; + `sampling.max_tokens` is what vLLM was actually given, `min(cap, max_model_len - + len(prompt))`. They differ only when the context window, not the cap, ends a sample, and + then the record says `context_clipped: true`. Without this a "40960 cap" run would + silently be a "40610 cap" run (prompt tokens come out of the same 40960 window). +* Resumable and kill-safe. Output is append-only JSONL. A sample is keyed + (problem_id, sample_idx, round, cap_tokens); keys already on disk are skipped. Records + are fsync'd in groups (`--flush-every` / `--flush-secs`); SIGKILL loses the unflushed + group plus whatever is in flight (<= `--inflight` samples, never the finished ones). A + torn last line (kill mid-write) is truncated back to the last newline on startup rather + than parsed or appended after -- appending after a torn line would glue two records. + A resume is only into a file this invocation could have written: records already on disk must + carry this run's `model_rev`, the seed `derive_seed` gives for their key, and the registered + temperature / top_p, and the manifest's earlier sessions must have used this `lean_prompts.py`; + otherwise it RAISES (`resume_conflicts`, `prompt_template_conflicts`) instead of skipping the + keys and appending a second policy's samples to the first one's file. +* No chunk barrier. vLLM `LLM.generate` returns only when its slowest sample finishes, and + sample lengths here span ~1k..16k+ tokens, so a batch barrier idles the GPUs on the + longest tail. This driver instead drives `llm.llm_engine.add_request/step` with a refill + window, so finished samples are written immediately and the batch stays full. +* Per-sample seeds. n is expanded client-side (n=1 per request) so every sample has its + own seed in its own record. Seeds are derived from (base seed, problem, sample, round) + and NOT the cap, so the same sample at cap 16384 and cap 40960 shares its seed. + Bitwise replay is not promised (batch composition changes float reduction order); the + seed makes the sampling randomness a recorded input, not a reproducibility claim. +* No re-tokenisation downstream. `prompt_ids` come from the model's tokenizer on the chat + template text; `completion_ids` are vLLM's `token_ids`, which INCLUDE the terminating + stop token (<|im_end|>) when `finish_reason == "stop"`; the SFT shard builder trains on + exactly these ids. +* Class-B trajectory ledger (docs/SOTA_PLAN.md v3 section 1). Every record carries + `trajectory`: {root_trajectory_id "|", root_sample_idx, round, + kind attempt|repair|resampled_no_feedback, completion_tokens, prior_completion_tokens, + cum_completion_tokens (the whole lineage so far), truncated, budget_clipped, parent_key, + partial}. `--trajectory-cap-tokens N` (class B: 40000) enforces a cumulative completion-token + cap per trajectory: a repair's `max_tokens` is clipped to the remaining budget + (`budget_clipped: true`) and one with none left is NOT generated but listed in + `.skipped.jsonl` as `skipped_trajectory_cap`. A failed parent with no compiler error to + show (truncated at the cap, no `repl_errors`, or an empty list) is never silently dropped: + `--no-feedback-policy skip` (default) writes it to `.skipped.jsonl` as + `skipped_no_feedback` with its reason; `resample` draws fresh round-0-style attempts for it + (`kind: resampled_no_feedback`, same root, tokens still accumulate). + Selection rule: Goedel's, the DEFAULT -- only failed samples of problems with NO accepted + sample in round k-1 are repaired (`load_parents`); `--repair-solved` also repairs failed samples + of already-solved problems (needed to account every trajectory, not to match Goedel). + Backward compatible: pre-ledger files have no `trajectory`; lineage is reconstructed from the + `parent` chain when the earlier rounds' files are passed in `--corrections-from`, otherwise the + entry is `partial` (a lower bound) and enforcing a cap on it refuses. Resume only keys on + (problem, sample, round, cap), so old files resume unchanged. +* Un-merged LoRA serving. `--lora-adapter DIR` (a PEFT dir: adapter_config.json + + adapter_model.safetensors) serves the adapter through vLLM `enable_lora` / `LoRARequest` + on the base `--model`, instead of the bf16 merge that drops small delta elements. The adapter's + sha256 is in the manifest session, in every record (`adapter`), and in `model_rev` + (`+lora-`), so a resume cannot mix it with the base file. GPU smoke command: + see extras/vllm_rollout/README.md. +* Exit status. 0 only when every planned sample was generated; SIGTERM/SIGINT exit 143 so a + supervisor can never mistake a partial sample set for a finished one (U12-2). +* Unsupported configuration raises: max_model_len above the model's position limit, + tp above the visible GPUs, a round-0 prompt that cannot fit, missing verification + records for a parent, and so on. The only skips are correction prompts that cannot fit + the context and parents with nothing to correct; both are counted in the manifest and + listed in `.skipped.jsonl`. +""" + +from __future__ import annotations + +import argparse +import dataclasses +import hashlib +import json +import os +import signal +import subprocess +import sys +import threading +import time +from typing import Iterator, Optional + +HERE = os.path.dirname(os.path.abspath(__file__)) +REPO_ROOT = os.path.dirname(os.path.dirname(HERE)) +sys.path.insert(0, HERE) + +import lean_prompts as LP # noqa: E402 (sibling module, stdlib only) + +DEFAULT_MODEL = ( + "/apps/development/rlhf/hf_cache/hub/models--Goedel-LM--Goedel-Prover-V2-8B/snapshots/" + "dfd02e6271a58375dfbf3ece0175277cf6b6a89a" +) +TEMPERATURE = 1.0 # Goedel inference.py --temp default and the dpskcot setting +TOP_P = 0.95 # hard-coded in Goedel inference.py +Key = tuple # (problem_id, sample_idx, round, cap_tokens) + + +# -------------------------------------------------------------------------- +# pure logic: seeds, keys, jsonl, planning +# -------------------------------------------------------------------------- + + +def derive_seed(base_seed: int, problem_id: str, sample_idx: int, round_: int) -> int: + """Per-sample seed in [0, 2**31). Independent of the cap on purpose (see module doc).""" + h = hashlib.sha256(f"{base_seed}|{problem_id}|{sample_idx}|{round_}".encode()).digest() + return int.from_bytes(h[:8], "big") % (2**31) + + +def rec_key(rec: dict) -> Key: + return (rec["problem_id"], int(rec["sample_idx"]), int(rec["round"]), int(rec["cap_tokens"])) + + +def repair_torn_tail(path: str) -> int: + """Truncate a JSONL file back to its last complete line. Returns bytes removed. + + A kill between `write` and `fsync` can leave a partial last line. Reading it would + raise (or worse, parse as a different record); appending after it would fuse two + records into one unparseable line and lose the NEW record too. + """ + if not os.path.exists(path): + return 0 + size = os.path.getsize(path) + if size == 0: + return 0 + with open(path, "rb+") as f: + f.seek(-1, os.SEEK_END) + if f.read(1) == b"\n": + return 0 + # walk back to the previous newline + pos = size + chunk = 1 << 16 + last_nl = -1 + while pos > 0 and last_nl < 0: + start = max(0, pos - chunk) + f.seek(start) + buf = f.read(pos - start) + i = buf.rfind(b"\n") + if i >= 0: + last_nl = start + i + pos = start + keep = last_nl + 1 # 0 if there was no newline at all + f.truncate(keep) + f.flush() + os.fsync(f.fileno()) + return size - keep + + +def read_jsonl(path: str) -> Iterator[dict]: + with open(path, encoding="utf-8") as f: + for ln, line in enumerate(f, 1): + if not line.strip(): + continue + try: + yield json.loads(line) + except json.JSONDecodeError as e: + raise ValueError(f"{path}:{ln}: not valid JSON ({e})") from e + + +def load_done_keys(out_path: str) -> set: + if not os.path.exists(out_path): + return set() + return {rec_key(r) for r in read_jsonl(out_path)} + + +def resume_state(out_path: str, repair: bool = True) -> tuple[set, int]: + """(done_keys, torn_bytes_removed) for an output file that a killed run may have left torn. + + The repair MUST precede the read: found by the real SIGKILL test, where the first + version read the file first and died on its own torn last line, i.e. the run could not + be resumed from exactly the crash the resume logic exists for. `repair=False` (dry run) + must not touch the file, so it ignores an unterminated last line instead. + """ + if repair: + torn = repair_torn_tail(out_path) + return load_done_keys(out_path), torn + if not os.path.exists(out_path): + return set(), 0 + with open(out_path, "rb") as f: + data = f.read() + cut = data.rfind(b"\n") + 1 # drop anything after the last newline + keys = {rec_key(json.loads(line)) for line in data[:cut].decode("utf-8").splitlines() if line.strip()} + return keys, len(data) - cut + + +def resume_conflicts(out_path: str, model_rev: str, base_seed: int, limit: int = 5, + adapter_sha: Optional[str] = None, temperature: float = TEMPERATURE, top_p: float = TOP_P, + prompt_format: str = "goedel") -> list[str]: + """Records already in `out_path` that THIS invocation could not have produced (empty list = resumable). + + THE MIXED-OUTPUT DEFECT (R2 review). A sample is keyed (problem, sample, round, cap) and nothing + else, so resuming into a file written by a DIFFERENT model (the post-SFT run pointed at the + baseline's --out), a different `--model-rev`, or a different `--seed` silently skips every key that + is already there and appends the rest: one output file, two policies, each row honestly labelled + with its own `model_rev` and nothing complaining. A baseline/candidate comparison built on it + compares the model with a blend of itself. Same call, same answer on a clean resume: the check is + over the records' own provenance (`model_rev`, `sampling.seed`), which a faithful resume reproduces + exactly (`derive_seed` is a pure function of (seed, problem, sample, round)). + """ + bad: list[str] = [] + n_bad = 0 + if not os.path.exists(out_path): + return bad + with open(out_path, "rb") as f: # streamed: the pool file is >1 GB + lines = enumerate(f, 1) + for ln, raw in lines: + if not raw.endswith(b"\n"): + break # torn tail of a killed writer (the repair truncates it); not a record + if not raw.strip(): + continue + r = json.loads(raw) + want = derive_seed(base_seed, r["problem_id"], int(r["sample_idx"]), int(r["round"])) + why = None + if r.get("model_rev") != model_rev: + why = f"model_rev {r.get('model_rev')!r} != {model_rev!r}" + elif r.get("sampling", {}).get("seed") != want: + why = f"sampling.seed {r.get('sampling', {}).get('seed')!r} != {want} (--seed {base_seed})" + elif (r.get("adapter") or {}).get("sha256") != adapter_sha: + why = f"adapter {(r.get('adapter') or {}).get('sha256')!r} != {adapter_sha!r}" + elif r.get("sampling", {}).get("temperature") != temperature or r.get("sampling", {}).get("top_p") != top_p: + why = f"sampling {r.get('sampling')!r} differs from temperature={temperature}, top_p={top_p}" + elif r.get("prompt_format", "goedel") != prompt_format: + why = f"prompt_format {r.get('prompt_format', 'goedel')!r} != {prompt_format!r}" + if why: + n_bad += 1 + if len(bad) < limit: + bad.append(f"line {ln} {rec_key(r)}: {why}") + if n_bad > len(bad): + bad.append(f"... and {n_bad - len(bad)} more") + return bad + + +# lean_prompts.py module bytes before the prompt-format / temperature work (commits d956862, 901a82c). Goedel +# prompts rendered by the current module are pinned byte-identical to that version by a test +# (tests/test_gen_temperature_kimina.py), so goedel sessions recorded under it stay resumable. +LEGACY_GOEDEL_PROMPTS_SHA256 = frozenset({"700ab6c7fc1f1cb342ed2969bd5ddbcf1ffe0f8e070b9332fb1247fad57677af"}) + +_PROBE_PROBLEMS = ( + {"problem_id": "probe_a", "formal_statement": "theorem probe_a (x : ℕ) (h : x = 2) : x + x = 4 := by sorry", + "informal_prefix": "/-- Show that x + x = 4 when x = 2. -/\n"}, + {"problem_id": "probe_b", "formal_statement": "theorem probe_b (a b : ℝ) (h₀ : 0 < a) (h₁ : a < b) : a ^ 2 < b ^ 2 := by", + "informal_prefix": None}, +) + + +def prompt_render_sha(prompt_format: str, tokenizer) -> str: + """sha256 of the prompts this invocation would actually SEND (rendered through the tokenizer's chat + template) for fixed probe problems, plus one goedel correction prompt. Unlike the module-bytes hash, + an edit to lean_prompts.py that leaves the rendered bytes unchanged does not block a resume, and an + edit that changes them does.""" + h = hashlib.sha256(prompt_format.encode()) + for prob in _PROBE_PROBLEMS: + if prompt_format == "goedel_bank": # probe the example block too + prob = dict(prob, bank_examples=[{"statement": "theorem probe_ex : 1 = 1 := by", "proof": " rfl"}]) + text, _ = LP.initial_prompt_for(prompt_format, prob, tokenizer) + h.update(text.encode()) + if prompt_format == "goedel": + p0 = _PROBE_PROBLEMS[0] + _, msgs = LP.initial_prompt_for("goedel", p0, tokenizer) + text, _ = LP.correction_prompt(p0["formal_statement"], msgs, "```lean4\nexact rfl\n```", "exact rfl", + [{"severity": "error", "pos": {"line": 1, "column": 0}, + "endPos": {"line": 1, "column": 9}, "data": "probe error"}], + tokenizer, 1) + h.update(text.encode()) + return h.hexdigest() + + +def prompt_template_conflicts(manifest_path: str, render_sha256: str, out_path: str, prompt_format: str = "goedel") -> list[str]: + """Earlier sessions of this output file that sampled under DIFFERENT prompt bytes. + + The policy is conditioned on the rendered prompt bytes; an edit between two sessions that changes them + leaves a file whose halves answer different prompts while every key looks consistent. Sessions record + `prompt_render_sha256`. Older sessions recorded only the module bytes (`lean_prompts_sha256`); they are + compatible iff that sha is the pinned legacy goedel module and this run is goedel.""" + if not os.path.exists(manifest_path) or not os.path.exists(out_path) or os.path.getsize(out_path) == 0: + return [] # nothing on disk was sampled under the old module + with open(manifest_path, encoding="utf-8") as f: + doc = json.load(f) + bad = set() + for s in doc.get("sessions", []): + rs = s.get("prompt_render_sha256") + if rs is not None: + if rs != render_sha256: + bad.add(f"render:{rs}") + elif not (prompt_format == "goedel" and s.get("lean_prompts_sha256") in LEGACY_GOEDEL_PROMPTS_SHA256): + bad.add(f"module:{s.get('lean_prompts_sha256')}") + return sorted(bad) + + +PROBLEM_FIELDS = ("problem_id", "source", "split", "formal_statement", "informal_prefix", "statement_sha") + + +def load_problems(path: str, limit: Optional[int] = None) -> list[dict]: + rows = [] + seen = set() + for r in read_jsonl(path): + missing = [k for k in PROBLEM_FIELDS if k not in r] + if missing: + raise ValueError(f"{path}: problem {r.get('problem_id')!r} lacks fields {missing}") + if r["problem_id"] in seen: + raise ValueError(f"{path}: duplicate problem_id {r['problem_id']!r}") + seen.add(r["problem_id"]) + rows.append(r) + if limit is not None and len(rows) >= limit: + break + if not rows: + raise ValueError(f"{path}: no problems") + return rows + + +@dataclasses.dataclass +class Spec: + """One sample to generate. `parent_ctx` is set only for correction rounds.""" + + problem: dict + sample_idx: int + round: int + parent: Optional[dict] = None # {"problem_id","sample_idx","round"} + parent_ctx: Optional[dict] = None # messages / completion_text / compile_code / repl_errors + resample: bool = False # fresh initial-style prompt at round >= 1 (no-feedback parents) + trajectory: Optional[dict] = None # ledger inputs; None = a root attempt (round 0) + + +def plan_initial(problems: list[dict], n: int, round_: int = 0) -> Iterator[Spec]: + for p in problems: + for i in range(n): + yield Spec(p, i, round_) + + +def record_trajectory(g: dict, index: dict) -> tuple[int, int, bool]: + """(root_sample_idx, cumulative completion tokens through `g`, partial) for a generation record. + + Trusts the record's own `trajectory` ledger when present; otherwise rebuilds it from the + `parent` chain through `index` ((problem, sample_idx, round) -> record, every round the caller + passed). A chain that leaves the provided files is `partial`: the root is the furthest ancestor + named and the token count a lower bound -- reported, never guessed.""" + t = g.get("trajectory") + if t and "cum_completion_tokens" in t: + return int(t["root_sample_idx"]), int(t["cum_completion_tokens"]), bool(t.get("partial", False)) + ids = g.get("completion_ids") + n = len(ids) if ids is not None else 0 + miss = ids is None # a record without its token ids cannot be counted: lower bound, flagged + if int(g["round"]) == 0: + return int(g["sample_idx"]), n, miss + par = g.get("parent") + if not par: + return int(g["sample_idx"]), n, True + nxt = index.get((par["problem_id"], int(par["sample_idx"]), int(par["round"]))) + if nxt is None: + return int(par["sample_idx"]), n, True + root, cum, partial = record_trajectory(nxt, index) + return root, cum + n, partial or miss + + +def load_parents_ex(pairs: list[tuple[str, str]], round_k: int, repair_solved: bool = False) -> tuple[list[dict], dict, list[dict]]: + """Join verification and generation records of round k-1 -> parent contexts. + + Returns (parents, stats, no_feedback). Mirrors Goedel's `load_data_for_correction` (the DEFAULT): + * only samples that FAILED, and only for problems with NO accepted sample in the + previous round, are corrected (`repair_solved=True` lifts the second condition); + * every failed sample is a parent (not just one per problem). + Failed parents with nothing to show (no `repl_errors`/`repl_code` keys, an empty list, or a + sample truncated at its cap) are returned in `no_feedback` with a `reason` instead of being + dropped: Goedel would send an empty "error message:" block; here the caller decides + (`--no-feedback-policy`). Each parent / no-feedback entry carries `traj` = (root_sample_idx, + cumulative completion tokens, partial) for the class-B ledger. + """ + gens: dict = {} + vers: dict = {} + index: dict = {} + for vpath, gpath in pairs: + for g in read_jsonl(gpath): + index[(g["problem_id"], int(g["sample_idx"]), int(g["round"]))] = g + if int(g["round"]) == round_k - 1: + gens[rec_key(g)] = g + for v in read_jsonl(vpath): + if int(v["round"]) == round_k - 1: + vers[rec_key(v)] = v + if not gens: + raise ValueError(f"no generation records with round == {round_k - 1} in {pairs}") + unverified = sorted(set(gens) - set(vers)) + if unverified: + raise ValueError( + f"{len(unverified)} round-{round_k - 1} generation records have no verification record " + f"(first: {unverified[0]}); verify every parent before correcting" + ) + solved = {k[0] for k, v in vers.items() if v["accepted"] and k in gens} + stats = {"parents_total": len(gens), "problems_solved_prev_round": len(solved), + "skipped_solved_problem": 0, "skipped_accepted": 0, "no_repl_errors": 0, "no_error_messages": 0, + "no_feedback_truncated": 0} + parents, no_feedback = [], [] + for k in sorted(gens): + g, v = gens[k], vers[k] + if v["accepted"]: + stats["skipped_accepted"] += 1 + continue + if k[0] in solved and not repair_solved: + stats["skipped_solved_problem"] += 1 + continue + traj = record_trajectory(g, index) + has_keys = isinstance(v.get("repl_errors"), list) and isinstance(v.get("repl_code"), str) + if not has_keys or not v["repl_errors"]: + stats["no_repl_errors" if not has_keys else "no_error_messages"] += 1 + if g.get("finish_reason") == "length": + stats["no_feedback_truncated"] += 1 + reason = "truncated" + else: + reason = "no_repl_keys" if not has_keys else "no_error_messages" + no_feedback.append({"gen": g, "ver": v, "reason": reason, "traj": traj}) + continue + parents.append({"gen": g, "ver": v, "traj": traj}) + return parents, stats, no_feedback + + +def load_parents(pairs: list[tuple[str, str]], round_k: int) -> tuple[list[dict], dict]: + """Back-compatible (parents, stats) view of `load_parents_ex` with Goedel's selection rule.""" + parents, stats, _ = load_parents_ex(pairs, round_k) + return parents, stats + + +def _parent_ref(g: dict) -> dict: + return {"problem_id": g["problem_id"], "sample_idx": int(g["sample_idx"]), "round": int(g["round"])} + + +def _traj_inputs(traj: tuple, g: dict, kind: str) -> dict: + root, cum, partial = traj + return {"root_sample_idx": root, "prior_completion_tokens": cum, "partial": partial, "kind": kind, + "parent_key": [g["problem_id"], int(g["sample_idx"]), int(g["round"])]} + + +def plan_corrections(problems: list[dict], parents: list[dict], n: int, round_k: int) -> Iterator[Spec]: + """n correction samples per failed parent; sample_idx = parent.sample_idx * n + i is unique + within (problem, round) and stable under resume.""" + by_id = {p["problem_id"]: p for p in problems} + for par in parents: + g, v = par["gen"], par["ver"] + prob = by_id.get(g["problem_id"]) + if prob is None: + raise ValueError(f"parent problem {g['problem_id']!r} is not in --problems") + ctx = { + "messages": g["messages"], + "completion_text": g["completion_text"], + "compile_code": v["repl_code"], + "repl_errors": v["repl_errors"], + } + for i in range(n): + yield Spec(prob, int(g["sample_idx"]) * n + i, round_k, _parent_ref(g), ctx, + trajectory=_traj_inputs(par["traj"], g, "repair")) + + +def plan_resamples(problems: list[dict], no_feedback: list[dict], n: int, round_k: int) -> Iterator[Spec]: + """`--no-feedback-policy resample`: n fresh round-0-style attempts for each failed parent that has + no compiler error to show. Same root trajectory; the failed parent's tokens stay on the ledger.""" + by_id = {p["problem_id"]: p for p in problems} + for nf in no_feedback: + g = nf["gen"] + prob = by_id.get(g["problem_id"]) + if prob is None: + raise ValueError(f"parent problem {g['problem_id']!r} is not in --problems") + for i in range(n): + yield Spec(prob, int(g["sample_idx"]) * n + i, round_k, _parent_ref(g), None, resample=True, + trajectory=_traj_inputs(nf["traj"], g, "resampled_no_feedback")) + + +def no_feedback_entry(nf: dict, round_k: int, policy: str, cap: Optional[int] = None) -> dict: + """The `.skipped.jsonl` line for a failed parent with no compiler error (never a silent drop).""" + g = nf["gen"] + root, cum, partial = nf["traj"] + return {"key": [g["problem_id"], int(g["sample_idx"]), round_k, cap], + "outcome": "skipped_no_feedback" if policy == "skip" else "resampled_no_feedback", + "reason": nf["reason"], "root_trajectory_id": f"{g['problem_id']}|{root}", + "cum_completion_tokens": cum, "partial": partial, "round": round_k} + + +def skip_outcome(why: str) -> str: + if why.startswith("skipped_trajectory_cap"): + return "skipped_trajectory_cap" + if why.startswith("prompt_"): + return "skipped_prompt_too_long" + return why + + +def append_skipped(path: str, entry: dict) -> bool: + """Append `entry` to the skipped ledger unless an identical line exists (a resume replans the same skips).""" + line = json.dumps(entry, sort_keys=True) + if os.path.exists(path): + with open(path, encoding="utf-8") as f: + for old in f: + try: + if json.dumps(json.loads(old), sort_keys=True) == line: + return False + except json.JSONDecodeError: + continue + with open(path, "a", encoding="utf-8") as f: + f.write(line + "\n") + return True + + +@dataclasses.dataclass +class Prepared: + spec: Spec + key: Key + prompt_text: str + prompt_ids: list + messages: list + max_tokens: int + seed: int + context_clipped: bool + budget_clipped: bool = False # the trajectory token cap, not the context window or --cap-tokens, set max_tokens + + +def prepare(spec: Spec, tokenizer, cap: int, max_model_len: int, base_seed: int, error_thres: bool = True, + traj_cap: Optional[int] = None, prompt_format: str = "goedel"): + """Build the prompt and decide the per-request budget. + + Returns (Prepared, None) or (None, skip_reason). A round-0 prompt that does not fit is a + configuration error and raises; a correction prompt that does not fit (parent output + + errors can exceed the window) is skipped, with the reason recorded by the caller. + """ + fs = spec.problem["formal_statement"] + remaining = None + if traj_cap is not None and spec.trajectory is not None: + if spec.trajectory.get("partial"): + raise ValueError(f"{spec.problem['problem_id']}: the trajectory lineage is incomplete (pre-ledger files); " + "pass every earlier round's generation file in --corrections-from to enforce " + "--trajectory-cap-tokens") + remaining = traj_cap - int(spec.trajectory["prior_completion_tokens"]) + if remaining < 1: + return None, (f"skipped_trajectory_cap: {spec.trajectory['prior_completion_tokens']} completion tokens " + f"already spent of {traj_cap}") + if spec.round == 0 or spec.resample: + text, messages = (LP.initial_prompt(fs, tokenizer) if prompt_format == "goedel" + else LP.initial_prompt_for(prompt_format, spec.problem, tokenizer)) + else: + if prompt_format != "goedel": + raise ValueError(f"--prompt-format {prompt_format} has no published correction prompt; round >= 1 needs goedel") + c = spec.parent_ctx + text, messages = LP.correction_prompt( + fs, c["messages"], c["completion_text"], c["compile_code"], c["repl_errors"], + tokenizer, spec.round, error_thres=error_thres) + ids = list(tokenizer.encode(text)) + room = max_model_len - len(ids) + if len(ids) > max_model_len * 3 // 4 or room < 1: + # Goedel inference.py drops prompts longer than 3/4 of max_model_len + if spec.round == 0: + raise ValueError(f"round-0 prompt of {spec.problem['problem_id']} is {len(ids)} tokens; " + f"max_model_len={max_model_len} cannot hold it") + return None, f"prompt_{len(ids)}_tokens_over_3/4_of_max_model_len_{max_model_len}" + limit = cap if remaining is None else min(cap, remaining) + mt = min(limit, room) + return Prepared(spec, (spec.problem["problem_id"], spec.sample_idx, spec.round, cap), text, ids, messages, + mt, derive_seed(base_seed, spec.problem["problem_id"], spec.sample_idx, spec.round), + room < limit, remaining is not None and remaining < min(cap, room)), None + + +def build_record(pr: Prepared, completion_ids: list, completion_text: str, finish_reason: str, cap: int, + model_path: str, model_rev: str, extra: Optional[dict] = None, temperature: float = TEMPERATURE, + top_p: float = TOP_P, prompt_format: str = "goedel") -> dict: + if finish_reason not in ("stop", "length"): + raise ValueError(f"unsupported finish_reason {finish_reason!r} (contract: 'stop'|'length')") + if finish_reason == "length" and len(completion_ids) != pr.max_tokens: + raise ValueError(f"{pr.key}: finish_reason=length with {len(completion_ids)} tokens != max_tokens {pr.max_tokens}") + ti = pr.spec.trajectory or {"root_sample_idx": pr.spec.sample_idx, "prior_completion_tokens": 0, + "kind": "attempt", "partial": False, "parent_key": None} + n_comp = len(completion_ids) + traj = { + "root_trajectory_id": f"{pr.spec.problem['problem_id']}|{ti['root_sample_idx']}", + "problem_id": pr.spec.problem["problem_id"], "root_sample_idx": int(ti["root_sample_idx"]), + "round": pr.spec.round, "kind": ti["kind"], "parent_key": ti["parent_key"], + "completion_tokens": n_comp, "prior_completion_tokens": int(ti["prior_completion_tokens"]), + "cum_completion_tokens": int(ti["prior_completion_tokens"]) + n_comp, + "truncated": finish_reason == "length", "budget_clipped": pr.budget_clipped, "partial": bool(ti["partial"]), + } + code, err = LP.lean_code_for_record(pr.spec.problem["formal_statement"], completion_text, prompt_format) + rec = { + "problem_id": pr.spec.problem["problem_id"], + "sample_idx": pr.spec.sample_idx, + "round": pr.spec.round, + "cap_tokens": cap, + "prompt_ids": pr.prompt_ids, + "completion_ids": completion_ids, + "prompt_text": pr.prompt_text, + "completion_text": completion_text, + "finish_reason": finish_reason, + "lean_code": code, + "lean_code_error": err, + "messages": pr.messages, + "model_path": model_path, + "model_rev": model_rev, + "sampling": {"temperature": temperature, "top_p": top_p, "top_k": 0, "n": 1, + "max_tokens": pr.max_tokens, "seed": pr.seed}, + "context_clipped": pr.context_clipped, + "parent": pr.spec.parent, + "trajectory": traj, + } + if prompt_format != "goedel": + rec["prompt_format"] = prompt_format # absent for goedel: those records stay byte-identical to before the flag + if extra: + rec.update(extra) + return rec + + +def sampled_logprobs_extra(c) -> dict: + """`{"sampling_logprobs": [...]}`: the logprob of each SAMPLED token, from vLLM's `logprobs=0` output + (a list of {token_id: Logprob} per position). Raises if any position lacks the sampled token, so a record + never carries a misaligned behaviour distribution.""" + toks = list(c.token_ids) + lps = c.logprobs + if lps is None or len(lps) != len(toks): + raise RuntimeError(f"engine returned {None if lps is None else len(lps)} logprob positions for {len(toks)} tokens") + out = [] + for t, d in zip(toks, lps): + if t not in d: + raise RuntimeError(f"sampled token {t} missing from its logprob entry") + out.append(float(d[t].logprob)) + return {"sampling_logprobs": out} + + +def model_rev_of(model_path: str, override: Optional[str] = None) -> str: + p = os.path.realpath(model_path) + if override: + return override + if os.path.basename(os.path.dirname(p)) == "snapshots": + return os.path.basename(p) + raise ValueError(f"cannot infer a revision from {model_path!r} (not an HF snapshot dir); pass --model-rev") + + +class AppendWriter: + """Append-only JSONL with grouped fsync. `add` buffers; `flush` writes + fsyncs the group.""" + + def __init__(self, path: str, flush_every: int = 8, flush_secs: float = 5.0): + self.torn_bytes_removed = repair_torn_tail(path) + self.f = open(path, "ab") + self.flush_every, self.flush_secs = flush_every, flush_secs + self.buf: list[bytes] = [] + self.last = time.monotonic() + self.n_written = 0 + + def add(self, rec: dict) -> None: + self.buf.append((json.dumps(rec, ensure_ascii=False, separators=(",", ":")) + "\n").encode("utf-8")) + if len(self.buf) >= self.flush_every or time.monotonic() - self.last >= self.flush_secs: + self.flush() + + def flush(self) -> None: + if self.buf: + # one write call per group: a kill leaves either a whole group or a torn tail, which + # repair_torn_tail removes; never an interleaving + self.f.write(b"".join(self.buf)) + self.f.flush() + os.fsync(self.f.fileno()) + self.n_written += len(self.buf) + self.buf = [] + self.last = time.monotonic() + + def close(self) -> None: + self.flush() + self.f.close() + + +def build_trajectory_ledger(gens: list[dict], vers: list[dict], skipped: list[dict]) -> dict: + """Per root trajectory: rounds, cumulative completion tokens, and a final state. Pure. + + final = accepted_round (earliest accepted attempt on the trajectory) | the skip outcome + (`skipped_no_feedback`, `skipped_trajectory_cap`, ...) | `unrepaired` (failed, no later record).""" + index = {(g["problem_id"], int(g["sample_idx"]), int(g["round"])): g for g in gens} + accepted = {rec_key(v): bool(v["accepted"]) for v in vers} + led: dict = {} + for g in gens: + root, cum, partial = record_trajectory(g, index) + tid = f"{g['problem_id']}|{root}" + e = led.setdefault(tid, {"rounds_seen": set(), "cum_completion_tokens": 0, "accepted_round": None, + "skips": [], "partial": False}) + e["rounds_seen"].add(int(g["round"])) + e["cum_completion_tokens"] = max(e["cum_completion_tokens"], cum) + e["partial"] |= partial + if accepted.get(rec_key(g)): + r = int(g["round"]) + e["accepted_round"] = r if e["accepted_round"] is None else min(e["accepted_round"], r) + for sk in skipped: + tid = sk.get("root_trajectory_id") + if tid in led: + led[tid]["skips"].append(sk.get("outcome")) + led[tid]["cum_completion_tokens"] = max(led[tid]["cum_completion_tokens"], int(sk.get("cum_completion_tokens", 0))) + out = {} + for tid, e in led.items(): + if e["accepted_round"] is not None: + final = f"accepted_round{e['accepted_round']}" + elif e["skips"]: + final = e["skips"][-1] + else: + final = "unrepaired" + out[tid] = {"final": final, "rounds": len(e["rounds_seen"]), "cum_completion_tokens": e["cum_completion_tokens"], + "partial": e["partial"]} + return out + + +LORA_RANKS = (1, 8, 16, 32, 64, 128, 256, 320, 512) # vLLM 0.26 MaxLoRARanks + + +def _sha256_file(path: str) -> str: + h = hashlib.sha256() + with open(path, "rb") as f: + for chunk in iter(lambda: f.read(1 << 20), b""): + h.update(chunk) + return h.hexdigest() + + +def adapter_provenance(path: str) -> dict: + """Validate a PEFT adapter dir and hash it (config + weights): the identity recorded for an un-merged run.""" + cfg_p = os.path.join(path, "adapter_config.json") + if not os.path.isfile(cfg_p): + raise ValueError(f"{path}: no adapter_config.json (not a PEFT adapter directory)") + wfile = next((f for f in ("adapter_model.safetensors", "adapter_model.bin") + if os.path.isfile(os.path.join(path, f))), None) + if wfile is None: + raise ValueError(f"{path}: no adapter_model.safetensors / adapter_model.bin") + with open(cfg_p, encoding="utf-8") as f: + cfg = json.load(f) + if "r" not in cfg: + raise ValueError(f"{cfg_p}: no LoRA rank `r`") + wsha, csha = _sha256_file(os.path.join(path, wfile)), _sha256_file(cfg_p) + return {"path": os.path.abspath(path), "weights_file": wfile, "weights_sha256": wsha, "config_sha256": csha, + "sha256": hashlib.sha256((csha + wsha).encode()).hexdigest(), "rank": int(cfg["r"]), + "lora_alpha": cfg.get("lora_alpha"), "target_modules": cfg.get("target_modules"), + "base_model_name_or_path": cfg.get("base_model_name_or_path")} + + +def lora_engine_kwargs(pv: dict) -> dict: + """`LLM(...)` kwargs that enable one adapter slot of at least the adapter's rank.""" + ranks = [r for r in LORA_RANKS if r >= pv["rank"]] + if not ranks: + raise ValueError(f"adapter rank {pv['rank']} exceeds vLLM's largest supported max_lora_rank {LORA_RANKS[-1]}") + return {"enable_lora": True, "max_loras": 1, "max_lora_rank": ranks[0]} + + +def rev_with_adapter(base_rev: str, pv: Optional[dict], explicit: bool) -> str: + """model_rev that cannot collide with the base model's: `+lora-` (unless the user fixed one).""" + return base_rev if (pv is None or explicit) else f"{base_rev}+lora-{pv['sha256'][:12]}" + + +def make_lora_request(pv: Optional[dict], name: str, request_cls, int_id: int = 1): + """`vllm.lora.request.LoRARequest(name, id, path)`; the class is injected so this stays importable without vllm.""" + if pv is None: + return None + return request_cls(lora_name=name, lora_int_id=int_id, lora_path=pv["path"]) + + +def adapter_record_extra(pv: Optional[dict], name: str) -> Optional[dict]: + if pv is None: + return None + return {"adapter": {"name": name, "sha256": pv["sha256"], "weights_sha256": pv["weights_sha256"], + "config_sha256": pv["config_sha256"], "rank": pv["rank"], "path": pv["path"], + "serving": "unmerged_vllm_lora"}} + + +def final_status(stop_flag: bool, inflight_left: int, exhausted: bool) -> str: + """`interrupted` only if a stop request actually left planned work undone.""" + return "interrupted" if stop_flag and (inflight_left > 0 or not exhausted) else "complete" + + +def exit_code_for(status: str) -> int: + """0 only for a complete sample set. 143 = 128 + SIGTERM for an interruption (U12-2).""" + return {"complete": 0, "interrupted": 143}.get(status, 1) + + +def write_manifest(path: str, session: dict) -> None: + """sessions[] list: a resumed run appends a session instead of overwriting history.""" + doc = {"sessions": []} + if os.path.exists(path): + with open(path, encoding="utf-8") as f: + doc = json.load(f) + sessions = [s for s in doc["sessions"] if s.get("session_id") != session["session_id"]] + sessions.append(session) + doc["sessions"] = sessions + tmp = path + ".tmp" + with open(tmp, "w", encoding="utf-8") as f: + json.dump(doc, f, indent=1, ensure_ascii=False) + f.flush() + os.fsync(f.fileno()) + os.replace(tmp, path) + + +# -------------------------------------------------------------------------- +# engine side (lazy third-party imports) +# -------------------------------------------------------------------------- + + +def prepare_env() -> None: + """Environment that must be right BEFORE `import vllm` (see rollout_worker.py).""" + d = os.environ.setdefault + d("HF_HOME", "/apps/development/rlhf/hf_cache") + d("HF_HUB_CACHE", "/apps/development/rlhf/hf_cache/hub") + d("HF_HUB_DISABLE_XET", "1") + d("TMPDIR", os.path.join(REPO_ROOT, ".tmp")) + d("XDG_CACHE_HOME", os.path.join(REPO_ROOT, ".cache")) + d("TRITON_CACHE_DIR", os.path.join(REPO_ROOT, ".cache", "triton")) + d("TORCHINDUCTOR_CACHE_DIR", os.path.join(REPO_ROOT, ".cache", "torchinductor")) + d("VLLM_USE_FLASHINFER_SAMPLER", "0") # flashinfer's JIT ninja build fails on this box + d("TOKENIZERS_PARALLELISM", "false") + d("PYTHONUNBUFFERED", "1") + for var in ("TMPDIR", "XDG_CACHE_HOME", "TRITON_CACHE_DIR", "TORCHINDUCTOR_CACHE_DIR", "HF_HOME", "HF_HUB_CACHE"): + if not os.path.realpath(os.environ[var]).startswith("/apps/"): + raise RuntimeError(f"{var}={os.environ[var]!r} is not under /apps; / has 13 GB and /tmp is noexec tmpfs") + os.makedirs(os.environ[var], exist_ok=True) + + +def preflight_and_lock(label: str, wait: bool, inherit_pid: int | None = None) -> dict: + """Host preflight (>=45 GB MemAvailable, load < 2x cores, disk queue < 64) + the repo + heavy-job flock. `align.utils.guard` is stdlib-only at import (align/__init__ is lazy).""" + sys.path.insert(0, REPO_ROOT) + from align.utils import guard + + t0 = time.monotonic() + # lock FIRST: with --wait-lock we may sleep for hours behind another job, and a preflight taken + # before that sleep describes a host that no longer exists + if inherit_pid is not None: # a supervisor holds the lock for the whole job (scripts/p2/launch_baseline.py) + guard.inherit_heavy_job_lock(inherit_pid) + else: + guard.acquire_heavy_job_lock(label, blocking=wait) + st = guard.preflight_host_check() + return {"host_state": st, "lock_wait_s": round(time.monotonic() - t0, 1)} + + +class GpuMonitor(threading.Thread): + """Peak per-GPU memory.used from nvidia-smi (what the driver sees, not torch's view).""" + + def __init__(self, period: float = 10.0): + super().__init__(daemon=True) + self.period, self.peak, self.stop_ev = period, {}, threading.Event() + + def run(self) -> None: + while not self.stop_ev.is_set(): + try: + out = subprocess.run( + ["nvidia-smi", "--query-gpu=index,memory.used", "--format=csv,noheader,nounits"], + capture_output=True, text=True, timeout=20).stdout + for line in out.strip().splitlines(): + i, m = (x.strip() for x in line.split(",")) + self.peak[i] = max(self.peak.get(i, 0), int(m)) + except Exception: + pass + self.stop_ev.wait(self.period) + + +def main(argv: Optional[list] = None) -> int: + ap = argparse.ArgumentParser(description="vLLM generation driver for Lean proving (P2).") + ap.add_argument("--problems", required=True) + ap.add_argument("--out", required=True) + ap.add_argument("--model", default=DEFAULT_MODEL) + ap.add_argument("--model-rev", default=None) + ap.add_argument("--n", type=int, required=True, help="samples per problem (round 0) or per failed parent (round>=1)") + ap.add_argument("--cap-tokens", type=int, required=True) + ap.add_argument("--round", type=int, default=0) + ap.add_argument("--corrections-from", nargs=2, action="append", metavar=("VERIF_JSONL", "GEN_JSONL"), default=[]) + ap.add_argument("--tp", type=int, default=4) + ap.add_argument("--seed", type=int, default=0) + ap.add_argument("--max-model-len", type=int, default=40960) + ap.add_argument("--gpu-mem-util", type=float, default=0.90) + ap.add_argument("--max-num-seqs", type=int, default=128) + ap.add_argument("--inflight", type=int, default=None, help="refill window (default max_num_seqs + 32)") + ap.add_argument("--flush-every", type=int, default=8) + ap.add_argument("--flush-secs", type=float, default=5.0) + ap.add_argument("--limit-problems", type=int, default=None) + ap.add_argument("--verify-tokenization", type=int, default=4, + help="send N prompts to vLLM as TEXT and assert its token ids equal ours") + ap.add_argument("--trajectory-cap-tokens", type=int, default=None, metavar="N", + help="cumulative completion-token cap per trajectory (class B: 40000); a repair is clipped to the " + "remaining budget, one with none left is skipped as skipped_trajectory_cap") + ap.add_argument("--no-feedback-policy", choices=("skip", "resample"), default="skip", + help="failed parents with no compiler error: record as skipped_no_feedback (default) or draw " + "fresh round-0-style attempts on the same trajectory") + ap.add_argument("--repair-solved", action="store_true", + help="also repair failed samples of problems that already have an accepted sample " + "(default = Goedel's rule: repair only unsolved problems)") + ap.add_argument("--lora-adapter", default=None, metavar="DIR", + help="serve this PEFT adapter UN-MERGED through vLLM enable_lora on top of --model") + ap.add_argument("--lora-name", default="adapter") + ap.add_argument("--no-error-thres", action="store_true", help="Goedel --error_thres False (all errors, full spans)") + ap.add_argument("--wait-lock", action="store_true", help="block on the heavy-job lock instead of refusing") + ap.add_argument("--inherit-lock", type=int, default=None, metavar="PARENT_PID", + help="the heavy-job lock is already held by this (parent) pid; verify that and do not take it again") + ap.add_argument("--dry-run", action="store_true", help="plan + build prompts only; no GPU, no lock") + ap.add_argument("--label", default="p2-lean-gen") + ap.add_argument("--temperature", type=float, default=TEMPERATURE, + help=f"sampling temperature (default {TEMPERATURE}: Goedel protocol; Kimina card recommends 0.6)") + ap.add_argument("--top-p", type=float, default=TOP_P, help=f"nucleus top_p (default {TOP_P})") + ap.add_argument("--prompt-format", choices=LP.PROMPT_FORMATS, default="goedel", + help="goedel (default, byte-identical prompts) or kimina (docs/KIMINA_BASE.md; round 0 / resample only)") + ap.add_argument("--record-logprobs", action="store_true", + help="RL rollouts (scripts/p2/rl_lean.py): store the sampler's logprob of every sampled token as " + "`sampling_logprobs` (the BEHAVIOUR policy). Meaningful only with top_p == 1.0 and top_k " + "disabled; the RL loop refuses records that say otherwise.") + a = ap.parse_args(argv) + if not a.temperature > 0: + ap.error("--temperature must be > 0") + if not 0 < a.top_p <= 1: + ap.error("--top-p must be in (0, 1]") + if a.prompt_format != "goedel" and a.round >= 1: + ap.error(f"--prompt-format {a.prompt_format} has no correction prompt; use --round 0") + + if a.round == 0 and a.corrections_from: + ap.error("--corrections-from needs --round >= 1") + if a.round >= 1 and not a.corrections_from: + ap.error("--round >= 1 needs --corrections-from VERIF GEN") + if a.n < 1 or a.cap_tokens < 1 or a.tp < 1: + ap.error("--n, --cap-tokens, --tp must be positive") + if a.trajectory_cap_tokens is not None and a.trajectory_cap_tokens < 1: + ap.error("--trajectory-cap-tokens must be positive") + if a.round == 0 and (a.repair_solved or a.no_feedback_policy != "skip" or a.trajectory_cap_tokens is not None): + ap.error("--repair-solved / --no-feedback-policy / --trajectory-cap-tokens only apply to --round >= 1") + adapter = adapter_provenance(a.lora_adapter) if a.lora_adapter else None + lora_kw = lora_engine_kwargs(adapter) if adapter else {} + rev = rev_with_adapter(model_rev_of(a.model, a.model_rev), adapter, explicit=bool(a.model_rev)) + cfg = json.load(open(os.path.join(a.model, "config.json"))) + if a.max_model_len > cfg["max_position_embeddings"]: + raise ValueError(f"--max-model-len {a.max_model_len} exceeds the model's max_position_embeddings " + f"{cfg['max_position_embeddings']} (no rope scaling configured); refusing") + inflight = a.inflight or a.max_num_seqs + 32 + + prepare_env() + problems = load_problems(a.problems, a.limit_problems) + parent_stats = None + no_feedback: list = [] + skipped_path = a.out + ".skipped.jsonl" + if a.round == 0: + specs: Iterator[Spec] = plan_initial(problems, a.n, 0) + else: + parents, parent_stats, no_feedback = load_parents_ex( + [tuple(p) for p in a.corrections_from], a.round, repair_solved=a.repair_solved) + specs = plan_corrections(problems, parents, a.n, a.round) + if a.no_feedback_policy == "resample": + specs = _chain(list(specs), plan_resamples(problems, no_feedback, a.n, a.round)) + + os.makedirs(os.path.dirname(os.path.abspath(a.out)), exist_ok=True) + done, torn_at_start = resume_state(a.out, repair=not a.dry_run) + conflicts = resume_conflicts(a.out, rev, a.seed, adapter_sha=adapter["sha256"] if adapter else None, + temperature=a.temperature, top_p=a.top_p, prompt_format=a.prompt_format) + if conflicts: + raise ValueError( + f"--out {a.out} already holds records this invocation (--model-rev {rev}, --seed {a.seed}) did not " + "produce; resuming would mix two policies / seed streams in one file. Use a fresh --out. First: " + + "; ".join(conflicts)) + from transformers import AutoTokenizer + + tok = AutoTokenizer.from_pretrained(a.model) + + prompts_sha = hashlib.sha256(open(os.path.join(HERE, "lean_prompts.py"), "rb").read()).hexdigest() + render_sha = prompt_render_sha(a.prompt_format, tok) + stale_prompts = prompt_template_conflicts(a.out + ".manifest.json", render_sha, a.out, a.prompt_format) + if stale_prompts: + raise ValueError(f"--out {a.out} holds samples written under different prompt bytes " + f"({stale_prompts}); the current rendering is {render_sha}. Use a fresh --out.") + + session = { + "session_id": f"{int(time.time())}-{os.getpid()}", + "started_at": time.strftime("%Y-%m-%dT%H:%M:%S%z"), + "ended_at": None, "status": "running", + "argv": sys.argv, "args": vars(a), + "model_path": a.model, "model_rev": rev, + "lean_prompts_sha256": prompts_sha, + "prompt_render_sha256": render_sha, + "temperature": a.temperature, "top_p": a.top_p, "prompt_format": a.prompt_format, + "prompt_format_sha256": hashlib.sha256(f"{prompts_sha}|{a.prompt_format}".encode()).hexdigest(), + "parent_stats": parent_stats, "already_done_at_start": len(done), + "adapter": adapter, "no_feedback_parents": len(no_feedback), + "no_feedback_policy": a.no_feedback_policy if a.round else None, + "trajectory_cap_tokens": a.trajectory_cap_tokens, + } + manifest_path = a.out + ".manifest.json" + if a.round and a.no_feedback_policy == "skip" and not a.dry_run: + # explicit outcome, not a silent drop: every failed parent with nothing to show is listed + for nf in no_feedback: + if append_skipped(skipped_path, no_feedback_entry(nf, a.round, "skip", a.cap_tokens)): + session["skipped_no_feedback"] = session.get("skipped_no_feedback", 0) + 1 + + # materialise lazily: tokenising 10^5 long correction prompts up front would stall the start + def todo() -> Iterator[Prepared]: + for s in specs: + key = (s.problem["problem_id"], s.sample_idx, s.round, a.cap_tokens) + if key in done: + session["skipped_already_done"] = session.get("skipped_already_done", 0) + 1 + continue + pr, why = prepare(s, tok, a.cap_tokens, a.max_model_len, a.seed, not a.no_error_thres, + traj_cap=a.trajectory_cap_tokens, prompt_format=a.prompt_format) + if pr is None: + outcome = skip_outcome(why) + session[outcome] = session.get(outcome, 0) + 1 + if not a.dry_run: + ent = {"key": list(key), "reason": why, "outcome": outcome} + if s.trajectory: + ent["root_trajectory_id"] = f"{s.problem['problem_id']}|{s.trajectory['root_sample_idx']}" + ent["cum_completion_tokens"] = s.trajectory["prior_completion_tokens"] + append_skipped(skipped_path, ent) + continue + session["budget_clipped"] = session.get("budget_clipped", 0) + int(pr.budget_clipped) + yield pr + + if a.dry_run: + n = clipped = 0 + lens = [] + for pr in todo(): + n += 1 + clipped += pr.context_clipped + lens.append(len(pr.prompt_ids)) + session.update(status="dry-run", planned=n, context_clipped=clipped, + prompt_tokens_max=max(lens, default=0), prompt_tokens_mean=sum(lens) / max(1, len(lens))) + print(json.dumps(session, ensure_ascii=False, indent=1, default=str)) + return 0 + + session.update(preflight_and_lock(a.label, a.wait_lock, a.inherit_lock)) + write_manifest(manifest_path, session) + + # --- bring the engine up ------------------------------------------------- + import vllm + from vllm import LLM, SamplingParams + from vllm.sampling_params import RequestOutputKind + import torch + + if a.tp > torch.cuda.device_count(): + raise RuntimeError(f"--tp {a.tp} but only {torch.cuda.device_count()} CUDA devices visible") + session.update(vllm_version=vllm.__version__, torch_version=torch.__version__) + mon = GpuMonitor() + mon.start() + t_load = time.perf_counter() + llm = LLM(model=a.model, tokenizer=a.model, dtype="bfloat16", tensor_parallel_size=a.tp, + enable_prefix_caching=True, gpu_memory_utilization=a.gpu_mem_util, max_model_len=a.max_model_len, + max_num_seqs=a.max_num_seqs, seed=a.seed, disable_log_stats=False, **lora_kw) + lora_req = None + if adapter: + from vllm.lora.request import LoRARequest + + lora_req = make_lora_request(adapter, a.lora_name, LoRARequest) + rec_extra = adapter_record_extra(adapter, a.lora_name) + if a.record_logprobs and a.top_p != 1.0: + raise ValueError("--record-logprobs needs --top-p 1.0: vLLM returns raw logprobs, which equal the sampling " + "(behaviour) distribution only without nucleus truncation") + session["engine_load_s"] = round(time.perf_counter() - t_load, 1) + eng = llm.llm_engine + try: + cc = eng.vllm_config.cache_config + session["kv_cache"] = {"num_gpu_blocks": cc.num_gpu_blocks, "block_size": cc.block_size, + "tokens": (cc.num_gpu_blocks or 0) * cc.block_size, + "gpu_memory_utilization": cc.gpu_memory_utilization} + except Exception as e: # informational only; vLLM also logs it + session["kv_cache"] = {"error": repr(e)} + + stop = {"flag": False} + + def _sig(signum, _frm): + stop["flag"] = True + + signal.signal(signal.SIGTERM, _sig) + signal.signal(signal.SIGINT, _sig) + + writer = AppendWriter(a.out, a.flush_every, a.flush_secs) + session["torn_bytes_removed"] = torn_at_start + writer.torn_bytes_removed + gen_iter = todo() + # tokenisation self-check: vLLM's own text path must yield OUR prompt ids + if a.verify_tokenization: + probe = [] + for pr in gen_iter: + probe.append(pr) + if len(probe) >= a.verify_tokenization: + break + gen_iter = _chain(probe, gen_iter) + ok = _verify_tokenization(eng, probe, SamplingParams) + session["tokenization_check"] = {"n": len(probe), "ok": ok} + if not ok: + raise RuntimeError("vLLM's tokenisation of the prompt TEXT differs from tokenizer.encode(); " + "prompt_ids in records would not be what the model saw") + + inflight_map: dict[str, Prepared] = {} + n_done = n_tokens = n_len = 0 + t0 = time.perf_counter() + last_log = t0 + exhausted = False + status = "crashed" # overwritten on a clean exit; a raise leaves it + try: + while True: + while not stop["flag"] and not exhausted and len(inflight_map) < inflight: + pr = next(gen_iter, None) + if pr is None: + exhausted = True + break + sp = SamplingParams(n=1, temperature=a.temperature, top_p=a.top_p, max_tokens=pr.max_tokens, + seed=pr.seed, output_kind=RequestOutputKind.FINAL_ONLY, + **({"logprobs": 0} if a.record_logprobs else {})) + rid = f"{pr.key[0]}|{pr.key[1]}|{pr.key[2]}|{pr.key[3]}" + eng.add_request(rid, {"prompt_token_ids": pr.prompt_ids}, sp, lora_request=lora_req) + inflight_map[rid] = pr + if not inflight_map: + break + if stop["flag"]: + eng.abort_request(list(inflight_map)) + break + for o in eng.step(): + if not o.finished: + continue + pr = inflight_map.pop(_match_rid(o.request_id, inflight_map)) + c = o.outputs[0] + if list(o.prompt_token_ids) != pr.prompt_ids: + raise RuntimeError(f"{pr.key}: engine prompt ids differ from the recorded prompt ids") + writer.add(build_record(pr, list(c.token_ids), c.text, c.finish_reason, a.cap_tokens, a.model, rev, + extra=({**(rec_extra or {}), **sampled_logprobs_extra(c)} if a.record_logprobs + else rec_extra), + temperature=a.temperature, top_p=a.top_p, prompt_format=a.prompt_format)) + n_done += 1 + n_tokens += len(c.token_ids) + n_len += c.finish_reason == "length" + now = time.perf_counter() + if now - last_log > 30: + print(f"[lean_gen] done={n_done} inflight={len(inflight_map)} tokens={n_tokens} " + f"avg_tok/s={n_tokens / (now - t0):.0f}", flush=True) + last_log = now + status = final_status(stop["flag"], len(inflight_map), exhausted) + finally: + writer.close() + mon.stop_ev.set() + wall = time.perf_counter() - t0 + session.update( + ended_at=time.strftime("%Y-%m-%dT%H:%M:%S%z"), status=status, + generate_wall_s=round(wall, 1), samples_written=n_done, tokens_generated=n_tokens, + finish_length=n_len, tokens_per_s_end_to_end=round(n_tokens / wall, 1) if wall else None, + gpu_mem_used_peak_mib=mon.peak, aborted_inflight=len(inflight_map), + ) + write_manifest(manifest_path, session) + print(json.dumps({k: session[k] for k in ("status", "samples_written", "tokens_generated", "generate_wall_s", + "tokens_per_s_end_to_end")})) + return exit_code_for(status) + + +def _chain(head: list, tail: Iterator): + yield from head + yield from tail + + +def _match_rid(rid: str, inflight: dict) -> str: + """vLLM may decorate request ids; accept an exact match or a unique prefix match.""" + if rid in inflight: + return rid + cand = [k for k in inflight if rid.startswith(k) or k.startswith(rid)] + if len(cand) != 1: + raise RuntimeError(f"cannot match engine request id {rid!r} to an in-flight request ({len(cand)} candidates)") + return cand[0] + + +def _verify_tokenization(eng, probe: list, SamplingParams) -> bool: + from vllm.sampling_params import RequestOutputKind + + want = {} + for i, pr in enumerate(probe): + rid = f"tokcheck-{i}" + eng.add_request(rid, pr.prompt_text, SamplingParams(max_tokens=1, temperature=0.0, + output_kind=RequestOutputKind.FINAL_ONLY)) + want[rid] = pr.prompt_ids + ok = True + left = set(want) + while left: + for o in eng.step(): + if o.finished: + rid = _match_rid(o.request_id, {k: 0 for k in left}) + left.discard(rid) + ok &= list(o.prompt_token_ids) == want[rid] + return ok + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/code/extras/vllm_rollout/lean_prompts.py b/code/extras/vllm_rollout/lean_prompts.py new file mode 100644 index 0000000000000000000000000000000000000000..5cf16a08aea3be394dfee911b8302316022b4bb7 --- /dev/null +++ b/code/extras/vllm_rollout/lean_prompts.py @@ -0,0 +1,633 @@ +"""Goedel-Prover-V2 prompt / extraction / correction logic, ported byte-for-byte. + +OPTIONAL EXTRA -- NOT part of the `align` package; stdlib-only at import time so +the align venv's contract tests can load it without vllm or transformers (the +tokenizer is always passed in as an object). + +Why a port instead of `import utils` from the reference repo: Goedel's +`src/utils.py` imports pandas and `jload` at module level and its `inference.py` +parses argv at import time, so it cannot be a library. Why byte-faithful rather +than "equivalent": the SFT in experiment P2 trains on the model's own samples, and +every sample's distribution is conditional on the exact prompt bytes. A prompt that +differs by one newline from the one Goedel evaluated with is a different policy, and +the baseline numbers (minif2f pass@k at a stated cap) would no longer be comparable +to the published ones. Fidelity is *proved*, not assumed: `python lean_prompts.py +fidelity` imports the reference `src/utils.py` and asserts identical prompt strings, +prompt token ids, extracted code and correction prompts (evidence in the P2-B report). + +What is ported (names kept so a diff against the reference reads line by line): + + DeepSeekCoTHandler.prover_inference round-0 prompt (CoT, "dpskcot") + DeepSeekCoTHandler.extrac_code last ```lean4 / ```lean fence + import head + DeepSeekCoTHandler.problem_check re-insert the ORIGINAL statement + DeepSeekCoTHandler.generate_correction_prompt correction rounds + get_error_str, remove_comments, replace_statement_in_proof, ... + compile_view (= `handle` in the reference's src/compile.py) + +Two deliberate differences, both outside the prompt bytes: + * `extrac_code` keeps the reference's "None" sentinel; `lean_code_for_record` + converts the sentinel and the "**Error**..." strings that + `replace_statement_in_proof` returns into (None, reason) for the record contract. + Goedel instead hands those strings to the compiler and lets them fail. + * `Qwen3` chat templates strip earlier assistant `` blocks; that is the + tokenizer's own behaviour and is kept (the reference calls the same template). +""" + +from __future__ import annotations + +import re +from typing import Optional + +# -------------------------------------------------------------------------- +# verbatim from Goedel-Prover-V2/src/utils.py +# -------------------------------------------------------------------------- + + +def get_error_str(code, errors, error_thres): + err_str = "" + code_lines = code.split('\n') + token_lengths = [len(line) + 1 for line in code_lines] + + # error_thres = False + + # error_num_thres = 8 if error_thres else error_num_thres + error_num_thres = 8 if error_thres else len(errors) + + for i, error in enumerate(errors[:error_num_thres]): + start_line = error['pos']['line'] - 1 + start_col = error['pos']['column'] + + if error['endPos'] is None: + end_line = start_line + end_col = len(code_lines[start_line]) + else: + end_line = error['endPos']['line'] - 1 + end_col = error['endPos']['column'] + + start_char_pos = sum(token_lengths[:start_line]) + start_col + end_char_pos = sum(token_lengths[:end_line]) + end_col + + err_str += f"\nError {i + 1}:\n" + err_str += f"\nCorresponding Code:\n```lean4\n" + + error_code = "" + for ii in range(-4, 0): + if start_line + ii >= 0: + error_code += f"{code_lines[start_line + ii]}\n" + if start_line != end_line: + error_code += code_lines[start_line][:start_col] + "" + code_lines[start_line][start_col:] + "\n" + + if not error_thres: + for j in range(start_line + 1, end_line): + error_code += f"{code_lines[j]}\n" + else: + show_line = 6 + for j in range(start_line + 1, min(end_line, start_line + show_line)): + error_code += f"{code_lines[j]}\n" + if end_line > start_line + show_line: + leading_spaces = len(code_lines[j]) - len(code_lines[j].lstrip(' ')) + error_code += "\n" + " " * leading_spaces + "... --[Truncated]-- ...\n" + + error_code += code_lines[end_line][:end_col] + "" + code_lines[end_line][end_col:] + "\n" + else: + error_code += code_lines[start_line][:start_col] + "" + code_lines[start_line][start_col:end_col] + "" + code_lines[start_line][end_col:] + "\n" + if end_line + 1 < len(code_lines): + error_code += f"{code_lines[end_line + 1]}\n" + + err_str += error_code + err_str += f"\n```\n" + err_str += f"\nError Message: {error['data']}\n" + + if len(errors) > error_num_thres: + err_str += f"\n... [Omitted {len(errors) - error_num_thres} more errors] ...\n" + + return err_str + + +def remove_comments(text): # remove comments + # First remove all /- ... -/ blocks + text = re.sub(r'/-.*?-/', '', text, flags=re.DOTALL) + # Then remove -- comments from each line + lines = text.split('\n') + cleaned_lines = [] + for line in lines: + # Split on -- and keep only the first part + cleaned_line = line.split('--', 1)[0] + cleaned_lines.append(cleaned_line) + # Join back together and remove excessive empty lines + cleaned_text = '\n'.join(cleaned_lines) + return cleaned_text.strip() + + +def return_theorem_to_prove(text): + # Pattern that matches from 'theorem' or 'lemma' to ':= by sorry' with any content in between + pattern = r'((?:theorem).*?:=\s*by\s*sorry)' + match = re.search(pattern, text, re.DOTALL) + return match.span() if match else None + + +def return_theorem_to_replace(text): + pattern = r'((?:^|\s)theorem\s+.*?:=\s*by)' + match = re.search(pattern, text, re.DOTALL) + return match.span() if match else None + + +def replace_statement_in_proof(statement, proof): + if ("apply?" in proof) or ("exact?" in proof): + return F"**Error**, 'apply?' or 'exact?' is used, which is not allowed." + stats_re = remove_comments(statement) + stats_span_ = return_theorem_to_prove(stats_re) + if stats_span_ is None: + error_app = '\n'.join(["\n"] + ['-- ' + x for x in statement.split('\n')]) + return F"**Error**, can not find 'theorem' and ':= sorry' in {error_app}" + proof_str = remove_comments(proof) + span = return_theorem_to_replace(proof_str) + if span is None: + error_app = '\n'.join(["\n"] + ['-- ' + x for x in proof.split('\n')]) + return F"**Error**, can not find 'theorem' and ':=' in {error_app}" + return stats_re[:stats_span_[1]].replace("sorry", "") + proof_str[span[1]:] + + +class DeepSeekCoTHandler: + """The reference's `--inference_handler dpskcot`, methods unchanged.""" + + def __init__(self): + pass + + def extrac_code(self, inputs): + import_head = "import Mathlib\nimport Aesop\n\nset_option maxHeartbeats 0\n\nopen BigOperators Real Nat Topology Rat\n\n" + pattern = r'```lean4\n(.*?)\n```' + matches = re.findall(pattern, inputs, re.DOTALL) + if matches: + return import_head + matches[-1] + pattern = r'```lean4\n(.*?)```' + matches = re.findall(pattern, inputs, re.DOTALL) + if matches: + return import_head + matches[-1] + pattern = r'```lean\n(.*?)```' + matches = re.findall(pattern, inputs, re.DOTALL) + if matches: + return import_head + matches[-1] + return "None" + + def prover_inference(self, lean4_code, tokenizer): + formal_statement = lean4_code.split(":= by")[0] + ":= by sorry" # include sorry https://huggingface.co/deepseek-ai/DeepSeek-Prover-V2-7B + prompt = F"Complete the following Lean 4 code:\n\n```lean4\n{formal_statement}```\n\nBefore producing the Lean 4 code to formally prove the given theorem, provide a detailed proof plan outlining the main proof steps and strategies.\nThe plan should highlight key ideas, intermediate lemmas, and proof structures that will guide the construction of the final formal proof." + messages = [ + {"role": "user", "content": prompt} + ] + text = tokenizer.apply_chat_template( + messages, + tokenize=False, + add_generation_prompt=True + ) + return text, messages + + def problem_check(self, statement, full_code): + full_code = replace_statement_in_proof(statement, full_code) + return full_code + + def generate_correction_prompt(self, lean4_code_original_stmt, + history_messages_from_prev_round, + prev_round_llm_raw_output, + error_message_for_prev_round, + tokenizer, current_correction_round_num): + original_stmt_for_prompt = lean4_code_original_stmt.split(":= by")[0] + ":= by sorry" + + current_messages = list(history_messages_from_prev_round) + + # Add PREVIOUS assistant's (failed) attempt + assistant_content = prev_round_llm_raw_output + current_messages.append({"role": "assistant", "content": assistant_content}) + + # Add CURRENT user feedback and request for new attempt + user_feedback_content = ( + f"The proof (Round {current_correction_round_num - 1}) is not correct. Following is the compilation error message, where we use to signal the position of the error.\n\n{error_message_for_prev_round}" + "\n\nBefore producing the Lean 4 code to formally prove the given theorem, provide a detailed analysis of the error message." + ) + current_messages.append({"role": "user", "content": user_feedback_content}) + + prompt_str = tokenizer.apply_chat_template(current_messages, tokenize=False, add_generation_prompt=True) + return prompt_str, current_messages + + +def compile_view(full_code): + """`handle` from the reference's src/compile.py. + + Goedel does NOT compile `full_code`: it drops every line whose stripped text starts + with import / set_option / open, and compiles the rest inside a REPL env that already + ran the header. The line numbers in the REPL's error dicts therefore index THIS text, + and `get_error_str` must be given this text, not `full_code`, or every `` marker + lands on the wrong line. + """ + lines = full_code.split('\n') + + filtered_lines = [line for line in lines if not ( + line.strip().startswith('import') or + line.strip().startswith('set_option') or + line.strip().startswith('open') + )] + + return '\n'.join(filtered_lines) + + +# -------------------------------------------------------------------------- +# glue for the record contract (not in the reference) +# -------------------------------------------------------------------------- + +_HANDLER = DeepSeekCoTHandler() + + +def goedel_statement(formal_statement: str) -> str: + """The reference rewrites `lean4_code` to this before BOTH the prompt and problem_check + (inference.py: `item_data["lean4_code"].split(":= by")[0] + ":= by sorry"`).""" + return formal_statement.split(":= by")[0] + ":= by sorry" + + +def initial_prompt(formal_statement: str, tokenizer) -> tuple[str, list]: + """(prompt_text, messages) for round 0.""" + return _HANDLER.prover_inference(goedel_statement(formal_statement), tokenizer) + + +def correction_prompt( + formal_statement: str, + parent_messages: list, + parent_completion_text: str, + parent_compile_code: str, + parent_repl_errors: list, + tokenizer, + round_k: int, + error_thres: bool = True, +) -> tuple[str, list]: + """(prompt_text, messages) for correction round `round_k` (>= 1). + + `error_thres=True` is the reference default (`--error_thres default=True`): at most 8 + errors, long error spans truncated to 6 lines. + """ + if round_k < 1: + raise ValueError(f"correction round must be >= 1, got {round_k}") + err = get_error_str(parent_compile_code, parent_repl_errors, error_thres) + return _HANDLER.generate_correction_prompt( + lean4_code_original_stmt=goedel_statement(formal_statement), + history_messages_from_prev_round=parent_messages, + prev_round_llm_raw_output=parent_completion_text, + error_message_for_prev_round=err, + tokenizer=tokenizer, + current_correction_round_num=round_k, + ) + + +def lean_code_for_record(formal_statement: str, completion_text: str, + prompt_format: str = "goedel") -> tuple[Optional[str], Optional[str]]: + """(lean_code, error). lean_code is the Goedel `full_code` (extracted proof with the + ORIGINAL statement re-inserted) or None; error says why it is None. + + The reference stores the sentinel "None" / "**Error**..." strings as `full_code` and + lets the compiler reject them. A record must not carry a string that merely looks like + Lean, so they become (None, reason): 'no_code_block', 'apply_or_exact_banned', + 'statement_not_found', 'proof_theorem_not_found'. + """ + if prompt_format == "kimina": + completion_text, why = kimina_final_answer(completion_text) + if why: + return None, why + elif prompt_format not in ("goedel", "goedel_bank"): + raise ValueError(f"unknown prompt format {prompt_format!r}") + extracted = _HANDLER.extrac_code(completion_text) + if extracted == "None": + return None, "no_code_block" + full = _HANDLER.problem_check(goedel_statement(formal_statement), extracted) + if full.startswith("**Error**"): + if "apply?' or 'exact?'" in full: + return None, "apply_or_exact_banned" + if "can not find 'theorem' and ':= sorry' in" in full: + return None, "statement_not_found" + return None, "proof_theorem_not_found" + return full, None + + +# -------------------------------------------------------------------------- +# Kimina-Prover-Distill-8B prompt format (docs/KIMINA_BASE.md). Policy conditioning differs from Goedel: +# system message, informal problem in the prompt, open `:= by` (no sorry), `` output. The byte +# fidelity gate (`run_fidelity`) certifies the Goedel format only. +# -------------------------------------------------------------------------- + +PROMPT_FORMATS = ("goedel", "kimina", "goedel_bank") +KIMINA_SYSTEM = "You are an expert in mathematics and Lean 4." + + +def _informal_text(informal_prefix: Optional[str]) -> str: + t = (informal_prefix or "").strip() + if t.startswith("/--"): + t = t[3:] + if t.endswith("-/"): + t = t[:-2] + return t.strip() + + +def kimina_statement(formal_statement: str, informal_prefix: Optional[str] = None) -> str: + """Statement as shown to Kimina: the header lines without blank lines, the informal docstring, the theorem up to + and including the first `:= by` (open goal, nothing after it).""" + m = re.search(r"\b(?:theorem|lemma)\b", formal_statement) + if m is None: + raise ValueError("kimina_statement: no theorem/lemma in the formal statement") + head = re.sub(r"/--.*?-/", "", formal_statement[:m.start()], flags=re.DOTALL) + head_lines = [ln for ln in head.split("\n") if ln.strip()] + rest = formal_statement[m.start():] + cut = rest.find(":= by") + if cut < 0: + raise ValueError("kimina_statement: no ':= by' in the formal statement") + theorem = rest[:cut + len(":= by")] + info = _informal_text(informal_prefix) + doc = f"/-- {info} -/\n" if info else "" + return "\n".join(head_lines) + "\n\n" + doc + theorem + + +def kimina_initial_prompt(formal_statement: str, informal_prefix: Optional[str], tokenizer) -> tuple[str, list]: + stmt = kimina_statement(formal_statement, informal_prefix) + user = ("Think about and solve the following problem step by step in Lean 4.\n" + f"# Problem:{_informal_text(informal_prefix)}\n# Formal statement:\n```lean4\n{stmt}\n```\n") + messages = [{"role": "system", "content": KIMINA_SYSTEM}, {"role": "user", "content": user}] + return tokenizer.apply_chat_template(messages, tokenize=False, add_generation_prompt=True), messages + + +def kimina_final_answer(completion_text: str) -> tuple[Optional[str], Optional[str]]: + """(text after the closing , error). A response still inside has no answer: its lean4 fences are + reasoning drafts, not a proof.""" + if "" in completion_text: + return completion_text.rsplit("", 1)[1], None + if "" in completion_text: + return None, "truncated_in_think" + return completion_text, None + + +# -------------------------------------------------------------------------- +# goedel_bank (K5 agentic arm, docs/K5_LEVERS.md): the Goedel round-0 prompt preceded by verified proofs of OTHER, +# decontaminated problems retrieved from the proof bank (`scripts/p2/k5_levers.py`). The problem row carries +# `bank_examples` = [{"statement": "theorem ... := by", "proof": ""}, ...]; with none, the prompt is byte-identical to "goedel". +# The Goedel byte-fidelity gate certifies only the "goedel" format; this is a separate, labelled policy conditioning. +# -------------------------------------------------------------------------- + +BANK_INTRO = ("Here are verified Lean 4 proofs of other, related problems. They may suggest useful lemmas and tactic " + "patterns for the problem that follows.") + + +def bank_block(examples: list) -> str: + parts = [BANK_INTRO] + for i, ex in enumerate(examples, 1): + parts.append(f"### Verified example {i}\n```lean4\n{ex['statement'].rstrip()}{ex['proof'].rstrip()}\n```") + return "\n\n".join(parts) + "\n\n" + + +def bank_initial_prompt(problem: dict, tokenizer) -> tuple[str, list]: + text, messages = initial_prompt(problem["formal_statement"], tokenizer) + ex = problem.get("bank_examples") or [] + if not ex: + return text, messages + messages = [{"role": "user", "content": bank_block(ex) + messages[0]["content"]}] + return tokenizer.apply_chat_template(messages, tokenize=False, add_generation_prompt=True), messages + + +def initial_prompt_for(prompt_format: str, problem: dict, tokenizer) -> tuple[str, list]: + if prompt_format == "goedel": + return initial_prompt(problem["formal_statement"], tokenizer) + if prompt_format == "goedel_bank": + return bank_initial_prompt(problem, tokenizer) + if prompt_format == "kimina": + return kimina_initial_prompt(problem["formal_statement"], problem.get("informal_prefix"), tokenizer) + raise ValueError(f"unknown prompt format {prompt_format!r}") + + +# -------------------------------------------------------------------------- +# fidelity proof against the reference (run in the ROLLOUT venv; needs transformers) +# -------------------------------------------------------------------------- + + +def _load_reference_utils(goedel_src: str): + """Import the reference's src/utils.py. It imports pandas and jload at module level for + functions we never call; if they are absent in this venv, stub them (and say so).""" + import importlib.util + import sys + import types + + stubbed = [] + for name in ("pandas", "jload"): + try: + __import__(name) + except ImportError: + m = types.ModuleType(name) + m.jload = m.jsave = lambda *a, **k: (_ for _ in ()).throw(RuntimeError("stubbed")) + sys.modules[name] = m + stubbed.append(name) + try: + spec = importlib.util.spec_from_file_location("goedel_ref_utils", goedel_src + "/utils.py") + mod = importlib.util.module_from_spec(spec) + spec.loader.exec_module(mod) + finally: + # a stub left in sys.modules breaks torch._dynamo's find_spec("pandas") later + for name in stubbed: + sys.modules.pop(name, None) + return mod, stubbed + + +def synthetic_outputs(statements: list[str]) -> list[tuple[str, str]]: + """Deterministic (statement, model_output) pairs: realistic outputs for real statements + plus the edge cases that distinguish extractors. >= 50 by construction.""" + cases: list[tuple[str, str]] = [] + heads = "import Mathlib\nimport Aesop\n\nset_option maxHeartbeats 400000\n\nopen BigOperators Real Nat Topology Rat\n\n" + for i, st in enumerate(statements[:30]): + stmt = st.split(":= by")[0] + body = f"{stmt}:= by\n nlinarith [sq_nonneg (a - b), sq_nonneg (a + b)]\n <;> simp_all\n" + plan = f"### Detailed Proof Plan\n1. Step one {i}.\n2. Step two.\n" + cases.append((st, f"{plan}\n```lean4\n{heads}{body}```\n")) # closed fence, final newline + st0 = statements[0] + stmt0 = st0.split(":= by")[0] + thm = f"{stmt0}:= by\n norm_num\n" + edge = [ + "I think this is true but I cannot prove it.", # no fence + "", # empty output + f"```lean4\n{thm}", # truncated: fence never closes + f"```lean4\n{thm}```", # no newline before closing fence (2nd pattern) + f"```lean4\n{thm}\n```", # newline before closing fence (1st pattern) + f"```lean\n{thm}```", # lean (not lean4) fence + f"```lean\n{thm}\n```", + f"```Lean4\n{thm}```", # wrong case: must not match + f"```lean4 \n{thm}```", # trailing space after tag: must not match + f"```lean4\r\n{thm}```", # CRLF: must not match + f"```lean4\n{thm}```\ntext\n```lean4\n{stmt0}:= by\n simp\n```", # two fences: last wins + f"```lean4\n{thm}```\ntext\n```lean\n{stmt0}:= by\n simp\n```", # lean4 first, lean last: lean4 wins (pattern order) + f"```lean\n{thm}```\ntext\n```lean4\n{stmt0}:= by\n simp\n```", # lean first, lean4 last + f"```python\nprint(1)\n```\n```lean4\n{thm}```", # foreign fence before + f"```lean4\n{thm}```\n```python\nprint(1)\n```", # foreign fence after + f"\n```lean4\n{stmt0}:= by\n simp\n```\n\n```lean4\n{thm}```", # think block with fence + f"```lean4\n{stmt0}:= by\n exact?\n```", # exact? banned + f"```lean4\n{stmt0}:= by\n apply?\n```", # apply? banned + f"```lean4\nlemma foo : True := by trivial\n```", # no theorem in proof + f"```lean4\ntheorem foo : True := by trivial\n```", # unrelated theorem name + f"```lean4\ntheorem x : True :=\n trivial\n```", # term proof: no ':= by' + f"```lean4\n{stmt0}:= by\n simp\n```", # extra spaces around by + f"```lean4\n{stmt0}:= by\n -- comment with theorem and := by\n simp\n```", # comment content + f"```lean4\n{stmt0}:= by\n /- block comment -/\n simp\n```", + f"```lean4\n```", # empty fence (no body: pattern 1 needs \\n between) + f"```lean4\n\n```", + f"```lean4\n{thm}\n```\n```lean4\n\n```", # last fence is empty + "x" * 3 + "```lean4\n" + thm + "```" + "y" * 3, # fence glued to text + f"```lean4\n{thm}\n```\n\n\n", # trailing whitespace + f"```lean4\n{thm}\n```" * 3, # repeated back-to-back + f"```lean4\nimport Mathlib\nopen Nat\n{thm}```", # model re-states header + ] + for e in edge: + cases.append((st0, e)) + # statement with no theorem (problem_check error branch) and with comments + cases.append(("import Mathlib\n\nexample : True := by sorry", f"```lean4\n{thm}```")) + cases.append((statements[1], f"```lean4\n{statements[1].split(':= by')[0]}:= by\n simp\n```")) + return cases + + +def synthetic_errors(code: str, rng) -> list[dict]: + """REPL-shaped error dicts positioned inside `code` (single-line, multi-line, endPos=None).""" + n = len(code.split("\n")) + out = [] + for _ in range(rng.randint(1, 11)): + l1 = rng.randint(1, n) + kind = rng.randint(0, 2) + c1 = rng.randint(0, 3) + if kind == 0: + end = None + elif kind == 1: + end = {"line": l1, "column": c1 + rng.randint(0, 6)} + else: + end = {"line": min(n, l1 + rng.randint(0, 12)), "column": rng.randint(0, 4)} + out.append({"severity": "error", "pos": {"line": l1, "column": c1}, "endPos": end, + "data": f"unsolved goals\nx : ℝ\n⊢ False {rng.randint(0, 9)}"}) + return out + + +def run_fidelity(goedel_src: str, model_dir: str, problems_jsonl: str, out_json: str) -> dict: + import json + import random + + from transformers import AutoTokenizer # rollout venv only + + ref, stubbed = _load_reference_utils(goedel_src) + + tok = AutoTokenizer.from_pretrained(model_dir, trust_remote_code=True) + rows = [json.loads(line) for line in open(problems_jsonl, encoding="utf-8")] + rh = ref.DeepSeekCoTHandler() + mine = DeepSeekCoTHandler() + rep: dict = {"stubbed_reference_imports": stubbed, "n_problems": len(rows), "mismatches": []} + + n_prompt_ok = n_ids_ok = 0 + for r in rows: + code = r["lean4_code"] + # reference path: inference.py rewrites lean4_code, then prover_inference + code_ref = code.split(":= by")[0] + ":= by sorry" + t_ref, m_ref = rh.prover_inference(code_ref, tok) + t_me, m_me = initial_prompt(code, tok) + # also from the dataset's `formal_statement` field (ends ':= by\n'): must give the same prompt + t_fs, _ = initial_prompt(r["formal_statement"], tok) + if t_ref == t_me == t_fs and m_ref == m_me: + n_prompt_ok += 1 + else: + rep["mismatches"].append(("prompt", r["name"])) + # the reference hands the TEXT to vLLM, which tokenizes it with encode(add_special_tokens=True) + i_ref = tok.encode(t_ref) + i_me = tok.encode(t_me) + if i_ref == i_me and len(tok.tokenize(t_ref)) == len(i_me): + n_ids_ok += 1 + else: + rep["mismatches"].append(("ids", r["name"])) + rep["prompt_string_identical"] = n_prompt_ok + rep["prompt_token_ids_identical"] = n_ids_ok + + stmts = [r["lean4_code"] for r in rows] + cases = synthetic_outputs(stmts) + ok = 0 + n_none = n_err = n_code = 0 + for st, out in cases: + st_ref = st.split(":= by")[0] + ":= by sorry" + e_ref = rh.extrac_code(out) + e_me = mine.extrac_code(out) + f_ref = "None" if e_ref in ("None", None) else rh.problem_check(st_ref, e_ref) + f_me = "None" if e_me in ("None", None) else mine.problem_check(goedel_statement(st), e_me) + if e_ref == e_me and f_ref == f_me: + ok += 1 + else: + rep["mismatches"].append(("extract", out[:80])) + n_none += f_ref == "None" + n_err += f_ref.startswith("**Error**") + n_code += not (f_ref == "None" or f_ref.startswith("**Error**")) + rec, why = lean_code_for_record(st, out) + expect = None if (f_ref == "None" or f_ref.startswith("**Error**")) else f_ref + if rec != expect: + rep["mismatches"].append(("record_code", out[:80])) + rep["n_synthetic_outputs"] = len(cases) + rep["extract_identical"] = ok + rep["synthetic_breakdown"] = {"no_code": n_none, "error_string": n_err, "code": n_code} + + # correction prompts + rng = random.Random(1234) + n_corr = n_corr_ok = 0 + for k in range(60): + r = rows[rng.randrange(len(rows))] + code = compile_view(f"{r['lean4_code'].split(':= by')[0]}:= by\n" + "\n".join(f" tac_{j}" for j in range(rng.randint(2, 30)))) + errs = synthetic_errors(code, rng) + for thres in (True, False): + if ref.get_error_str(code, errs, thres) != get_error_str(code, errs, thres): + rep["mismatches"].append(("error_str", k, thres)) + hist = [{"role": "user", "content": initial_prompt(r["lean4_code"], tok)[1][0]["content"]}] + out_txt = f"plan {k}\n```lean4\n{code}\n```" + rnd = rng.randint(1, 3) + err_str = ref.get_error_str(code, errs, True) + p_ref, m_ref = rh.generate_correction_prompt( + lean4_code_original_stmt=r["lean4_code"].split(":= by")[0] + ":= by sorry", + history_messages_from_prev_round=hist, + prev_round_llm_raw_output=out_txt, + error_message_for_prev_round=err_str, + tokenizer=tok, + current_correction_round_num=rnd, + ) + p_me, m_me = correction_prompt(r["lean4_code"], hist, out_txt, code, errs, tok, rnd) + n_corr += 1 + if p_ref == p_me and m_ref == m_me and tok.encode(p_ref) == tok.encode(p_me): + n_corr_ok += 1 + else: + rep["mismatches"].append(("correction", k)) + rep["n_correction_prompts"] = n_corr + rep["correction_identical"] = n_corr_ok + # compile_view vs the reference's `handle`: compile.py runs argparse + a compile farm at import, so + # its `handle` FunctionDef is lifted out with ast and executed alone. + import ast + + src = open(goedel_src + "/compile.py", encoding="utf-8").read() + fn = next(n for n in ast.parse(src).body if isinstance(n, ast.FunctionDef) and n.name == "handle") + ns: dict = {} + exec(compile(ast.Module(body=[fn], type_ignores=[]), "compile.py:handle", "exec"), ns) + n_h = 0 + for _, out in cases: + for text in (out, "import X\n open Y\nset_option a\n theorem t := by\n simp\nopening := 1\n"): + if ns["handle"](text) != compile_view(text): + rep["mismatches"].append(("handle", text[:60])) + n_h += 1 + rep["n_handle_cases"] = n_h + rep["ok"] = not rep["mismatches"] + with open(out_json, "w", encoding="utf-8") as f: + json.dump(rep, f, indent=1, ensure_ascii=False) + return rep + + +if __name__ == "__main__": + import argparse + import json + + ap = argparse.ArgumentParser(description=__doc__.split("\n")[0]) + sub = ap.add_subparsers(dest="cmd", required=True) + f = sub.add_parser("fidelity", help="assert identical behaviour vs the reference repo's src/utils.py") + f.add_argument("--goedel-src", default="/apps/development/rlhf/reference-repos/Goedel-Prover-V2/src") + f.add_argument("--model", required=True, help="snapshot dir (tokenizer + chat template)") + f.add_argument("--problems", default="/apps/development/rlhf/reference-repos/Goedel-Prover-V2/dataset/minif2f.jsonl") + f.add_argument("--out", required=True) + a = ap.parse_args() + rep = run_fidelity(a.goedel_src, a.model, a.problems, a.out) + print(json.dumps({k: v for k, v in rep.items() if k != "mismatches"}, ensure_ascii=False)) + raise SystemExit(0 if rep["ok"] else 1) diff --git a/code/extras/vllm_rollout/rollout_pilot.py b/code/extras/vllm_rollout/rollout_pilot.py new file mode 100644 index 0000000000000000000000000000000000000000..180711a0764098070c26ef06676503181d3706ee --- /dev/null +++ b/code/extras/vllm_rollout/rollout_pilot.py @@ -0,0 +1,89 @@ +"""M-D rollout pilot: measure vLLM generation economics for stage-b GRPO. + +OPTIONAL EXTRA (see extras/vllm_rollout/README.md). Runs under the ISOLATED +rollout venv (.venv-rollout: vllm 0.26.0, ray, pyarrow, torch cu130) -- +deliberately not the training venv, per the 2026-08-22 amendment's separation. +Self-contained: imports nothing from `align` (no protocol types are needed to +measure generation economics). pyarrow/vllm are fine HERE and nowhere under +align/. + +Deliverable (DESIGN v3 §B.1b pilot questions): output tokens/s at the +stage-b shape (G samples per prompt, max_response_len cap), projected +wall-clock per GRPO optimizer step and per N-step campaign, on 1 GPU and +(separately invoked) TP configurations. + + .venv-rollout/bin/python extras/vllm_rollout/rollout_pilot.py \ + --model --prompts-parquet \ + --n-prompts 32 --g 16 --max-tokens 768 --tp 1 +""" + +import argparse +import json +import os +import time + + +def main() -> int: + p = argparse.ArgumentParser(description=__doc__.splitlines()[0]) + p.add_argument("--model", required=True) + p.add_argument("--prompts-parquet", required=True) + p.add_argument("--n-prompts", type=int, default=32) + p.add_argument("--g", type=int, default=16) + p.add_argument("--max-tokens", type=int, default=768) + p.add_argument("--tp", type=int, default=1) + p.add_argument("--gpu-mem-util", type=float, default=0.85) + p.add_argument("--out", default="outputs/eval/rollout_pilot.json") + args = p.parse_args() + + import pyarrow.parquet as pq + from vllm import LLM, SamplingParams + + rows = pq.ParquetFile(args.prompts_parquet).read_row_group(0).to_pylist() + prompts = [ + f"Problem: {r['question']}\n\nSolve step by step, then give the final answer.\n\nStep 1:" + for r in rows[: args.n_prompts] + ] + + t0 = time.perf_counter() + llm = LLM( + model=args.model, + dtype="bfloat16", + tensor_parallel_size=args.tp, + gpu_memory_utilization=args.gpu_mem_util, + enforce_eager=False, + ) + load_s = time.perf_counter() - t0 + + sp = SamplingParams(n=args.g, temperature=1.0, top_p=1.0, max_tokens=args.max_tokens) + t1 = time.perf_counter() + outs = llm.generate(prompts, sp) + gen_s = time.perf_counter() - t1 + + out_tokens = sum(len(c.token_ids) for o in outs for c in o.outputs) + completions = sum(len(o.outputs) for o in outs) + tps = out_tokens / gen_s + report = { + "model": args.model, + "vllm_shape": {"n_prompts": args.n_prompts, "g": args.g, "max_tokens": args.max_tokens, "tp": args.tp}, + "engine_load_s": round(load_s, 1), + "generate_s": round(gen_s, 1), + "completions": completions, + "output_tokens": out_tokens, + "output_tokens_per_s": round(tps, 1), + "mean_completion_tokens": round(out_tokens / completions, 1), + # one GRPO optimizer step consumes n_prompts x G completions at this shape + "projected_s_per_grpo_step_same_shape": round(gen_s, 1), + } + os.makedirs(os.path.dirname(args.out), exist_ok=True) + tmp = args.out + ".partial" + with open(tmp, "w") as f: + json.dump(report, f, indent=1) + os.replace(tmp, args.out) + print(json.dumps(report, indent=1)) + return 0 + + +if __name__ == "__main__": + import sys + + sys.exit(main()) diff --git a/code/extras/vllm_rollout/rollout_worker.py b/code/extras/vllm_rollout/rollout_worker.py new file mode 100644 index 0000000000000000000000000000000000000000..a17d5eab0bdb3b8970b5e7e81295fafed7d7c4bf --- /dev/null +++ b/code/extras/vllm_rollout/rollout_worker.py @@ -0,0 +1,237 @@ +"""vLLM rollout worker: serves align/rollout/protocol.py requests from a spool dir. + +OPTIONAL EXTRA -- NOT part of the `align` package. The default GRPO rollout is +the dependency-free native sampler (align/rollout/native.py); this worker is +the high-throughput alternative, selected with `backend='spool'`. + +RUNS UNDER THE ISOLATED ROLLOUT VENV (.venv-rollout: vllm 0.26.0 / cu130), +never the training venv -- the 2026-08-22 sovereignty amendment confines +vLLM behind this process boundary. See extras/vllm_rollout/README.md. Launch it via +`align.rollout.client.spawn_worker`, which sets the REQUIRED environment: + + VLLM_USE_FLASHINFER_SAMPLER=0 (flashinfer's JIT ninja build fails on + this box's toolchain -- measured + 2026-08-23, rollout pilot) + TMPDIR / XDG_CACHE_HOME under /apps (the box's no-swap root is 13GB) + CUDA_VISIBLE_DEVICES= + +Weight sync = filesystem: when a request's `weights_dir` differs from the +engine's current one, the engine is torn down and rebuilt from the new +checkpoint (~60s measured). The trainer amortizes that by syncing every +`sync_every` optimizer steps; the rho correction owns the resulting +off-policyness. + +Failure contract: any per-request exception is written back as a RESULT +with `error` set (traceback tail in extras) so the trainer fails fast at +collect() instead of sitting at a timeout; the worker keeps serving. +""" + +import argparse +import os +import sys +import time +import traceback + + +def _die(msg: str) -> None: + print(f"rollout_worker refusing to start: {msg}", file=sys.stderr, flush=True) + sys.exit(2) + + +# ENV hygiene BEFORE importing anything heavy: this venv has no align guard, +# and a cache or TMPDIR off /apps is the exact class of fault that wedged +# this box on 2026-08-12 (root is 13GB, /tmp is a noexec tmpfs). +for _var in ("TMPDIR", "XDG_CACHE_HOME"): + _val = os.environ.get(_var, "") + if not _val.startswith("/apps"): + _die(f"{_var}={_val!r} does not resolve under /apps; set it before launch") + +# /extras/vllm_rollout/rollout_worker.py -> : three levels up. +_REPO_ROOT = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) +sys.path.insert(0, _REPO_ROOT) + +# align.rollout.protocol is stdlib-only BY CONTRACT (see its docstring); it is +# the single align import permitted here, taken from the repo tree directly so +# align never needs installing into the rollout venv. (`import align` and +# `import align.rollout` are both lazy/stdlib-only for exactly this reason.) +from align.rollout.protocol import ( # noqa: E402 + RolloutResult, + read_request, + spool_dirs, + touch_heartbeat, + write_result, +) + + +def _extract_sampled_logprobs(token_ids, sample_logprobs, request_id): + """Per-position logprob of the SAMPLED token. + + vllm 0.26 API shape (verified against .venv-rollout's vllm/outputs.py and + vllm/logprobs.py): CompletionOutput.logprobs is SampleLogprobs -- a + sequence over positions where each element is dict[int, Logprob] (or the + list-compatible FlatLogprobs view of the same); with + SamplingParams(logprobs=0) each position carries the sampled token's own + entry, so position t resolves as logprobs[t][token_ids[t]].logprob. + """ + if sample_logprobs is None: + raise RuntimeError( + f"request {request_id!r}: engine returned no logprobs despite " + "SamplingParams(logprobs=0); the rho correction cannot run without " + "pi^infer_old, so this result would be unusable." + ) + out = [] + for t, tok in enumerate(token_ids): + pos = sample_logprobs[t] + if pos is None or tok not in pos: + raise RuntimeError( + f"request {request_id!r}: position {t} lacks the sampled token " + f"{tok} in its logprob dict (keys: " + f"{sorted(pos) if pos else pos}); refusing to substitute." + ) + out.append(float(pos[tok].logprob)) + return out + + +def serve(args) -> int: + # Heartbeat BEFORE the vllm import: the import alone costs ~30s, and the + # first smoke run proved a trainer canary can race it -- the client's + # startup grace plus this early beat make "booting" visibly distinct from + # "never started". + spool_dirs(args.spool) + touch_heartbeat(args.spool) + from vllm import LLM, SamplingParams + + req_dir, _ = spool_dirs(args.spool) + engine = None + loaded_weights_dir = None + last_work = time.time() + print(f"rollout_worker serving spool={args.spool} tp={args.tp}", flush=True) + + while True: + touch_heartbeat(args.spool) + pending = sorted( + (os.path.join(req_dir, f) for f in os.listdir(req_dir) if f.endswith(".json")), + key=os.path.getmtime, + ) + if not pending: + if args.idle_exit_s and time.time() - last_work > args.idle_exit_s: + print(f"idle {args.idle_exit_s}s; exiting", flush=True) + return 0 + time.sleep(args.poll_s) + continue + + for path in pending: + request_id = os.path.basename(path)[: -len(".json")] + reloaded = False + try: + req = read_request(path) + if engine is None or req.weights_dir != loaded_weights_dir: + if engine is not None: + # vllm's own teardown first, then drop the reference; + # the fresh constructor re-measures free VRAM. + del engine + engine = None + import gc + + gc.collect() + import torch + + torch.cuda.empty_cache() + t0 = time.perf_counter() + engine = LLM( + model=req.weights_dir, + dtype="bfloat16", + tensor_parallel_size=args.tp, + gpu_memory_utilization=args.gpu_mem_util, + ) + loaded_weights_dir = req.weights_dir + reloaded = True + print( + f"engine loaded from {req.weights_dir} in {time.perf_counter() - t0:.1f}s", + flush=True, + ) + + sp = SamplingParams( + n=req.n_samples_per_prompt, + temperature=req.temperature, + top_p=req.top_p, + max_tokens=req.max_tokens, + seed=req.seed, + logprobs=0, # sampled token's own logprob per position + ) + t1 = time.perf_counter() + outs = engine.generate(req.prompts, sp) + gen_s = time.perf_counter() - t1 + + completions, logprobs, texts, prompt_token_ids = [], [], [], [] + for o in outs: + completions.append([list(map(int, c.token_ids)) for c in o.outputs]) + logprobs.append( + [ + _extract_sampled_logprobs(c.token_ids, c.logprobs, request_id) + for c in o.outputs + ] + ) + texts.append([c.text for c in o.outputs]) + # THE ENGINE'S OWN prompt tokenization, one list per prompt + # in request order. Completion ids continue from exactly + # this sequence; trainer-side assembly must concatenate + # these + completion ids and NEVER re-tokenize the prompt + # text (re-tokenization is not identity -- the alignment + # defect class the 2026-08-23 audit closed). + prompt_token_ids.append(list(map(int, o.prompt_token_ids))) + + write_result( + args.spool, + RolloutResult( + request_id=request_id, + completions=completions, + logprobs=logprobs, + texts=texts, + weights_dir=req.weights_dir, + engine_reloaded=reloaded, + generate_seconds=gen_s, + extras={"prompt_token_ids": prompt_token_ids}, + ), + ) + print( + f"served {request_id}: {sum(len(c) for c in completions)} " + f"completions in {gen_s:.1f}s (reloaded={reloaded})", + flush=True, + ) + except Exception as e: # noqa: BLE001 -- the trainer must fail fast, not time out + tb = traceback.format_exc() + print(f"request {request_id} FAILED: {e}\n{tb}", file=sys.stderr, flush=True) + write_result( + args.spool, + RolloutResult( + request_id=request_id, + completions=[], + logprobs=[], + texts=[], + weights_dir=loaded_weights_dir or "", + engine_reloaded=reloaded, + generate_seconds=0.0, + error=str(e), + extras={"traceback_tail": tb[-2000:]}, + ), + ) + finally: + # Consumed = removed; the result file is the record. + if os.path.exists(path): + os.remove(path) + last_work = time.time() + + +def main() -> int: + p = argparse.ArgumentParser(description=__doc__.splitlines()[0]) + p.add_argument("--spool", required=True) + p.add_argument("--gpu-mem-util", type=float, default=0.85) + p.add_argument("--poll-s", type=float, default=2.0) + p.add_argument("--tp", type=int, default=1) + p.add_argument("--idle-exit-s", type=float, default=0.0, help="0 = serve forever") + return serve(p.parse_args()) + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/code/notebooks/_m1_src/0101-autoregressive-likelihood-sft.py b/code/notebooks/_m1_src/0101-autoregressive-likelihood-sft.py new file mode 100644 index 0000000000000000000000000000000000000000..e342ba2a1c2b74e61545398927e213fd177d1733 --- /dev/null +++ b/code/notebooks/_m1_src/0101-autoregressive-likelihood-sft.py @@ -0,0 +1,121 @@ +# %% [markdown] +# # 0101 - Autoregressive LM likelihood, token vs sequence view, SFT = MLE = forward KL +# Companion to `notes/0101 - Autoregressive Likelihood and SFT.pdf`. +# Everything is fp64. The model is a tiny autoregressive policy whose whole sequence space (121 sequences) is enumerated, +# so each identity is checked EXACTLY (error ~1e-16), not by sampling. Pattern: naive loop first, vectorised second, assert they agree. +# %% +import sys, math, itertools +sys.path.insert(0, "..") +from _m1_lib import * +lm = TinyLM(V=4, L=4, seed=0, scale=1.0) +theta_s = lm.theta0.clone() # student +theta_T = TinyLM(V=4, L=4, seed=7, scale=1.5).theta0 # teacher p (same family -> realizable) +print("sequences:", lm.N, " params:", theta_s.numel()) +# %% [markdown] +# ## 1. The model is a proper distribution on sequences: sum_o pi(o) = 1 (note, Sec. 3.1) +# Naive: multiply conditional probabilities one token at a time with a python loop. Efficient: gather from one batched log_softmax. +# %% +p_naive = torch.stack([lm.seq_logprob_naive(theta_s, s).exp() for s in lm.seqs]) +p_vec = lm.probs(theta_s) +check("naive == vectorised sequence probability", maxabs(p_naive, p_vec), 1e-15) +check("sum_o pi(o) = 1 (normalisation, truncated at L)", abs(float(p_vec.sum()) - 1.0), 1e-14) +# per-state conditional distributions each sum to 1 (this is what the induction in the note uses) +worst = max(abs(float(lm.cond_probs(theta_s, s).sum()) - 1) for s in lm.prefixes()) +check("every conditional pi(.|o_= 0 (Gibbs): min(0, KL)", abs(min(0.0, float(KLf))), 0.0) +# reverse KL differs (what RL optimises is the *reverse* direction, see 0109) +KLr = (p_vec * (lp_s - p_T.log())).sum() +print(f"forward KL(p||pi)={float(KLf):.6f} reverse KL(pi||p)={float(KLr):.6f} (different objects)") +# %% [markdown] +# ## 3. Chain rule of KL: sequence KL = sum over positions of the *expected conditional* KL (token-level view) +# %% +def prefix_prob(theta, prefix): + pr = 1.0 + for k, a in enumerate(prefix): + pr = pr * lm.cond_probs(theta, prefix[:k])[a] + return pr +tok_KL = 0.0 +for s in lm.prefixes(): + w = prefix_prob(theta_T, s) # P_p(o_ reweights tokens by 1/|o|)") +assert rel > 1e-3 +# %% [markdown] +# ## 6. Sampling check (MC): empirical-risk gradient converges to the population one +# %% +gen = torch.Generator().manual_seed(123) +n = 400_000 +idx = torch.multinomial(p_T, n, replacement=True, generator=gen) +G_emp = -S_auto[idx].mean(0) +se = (S_auto[idx].var(0) / n).sqrt() +ok_ = se > 0 +z = float(((G_emp - grad_CE_hand).abs()[ok_] / se[ok_]).max()) +assert float((G_emp - grad_CE_hand).abs()[~ok_].max()) < 1e-14 # unused parameters have identically zero gradient +check("MC SFT gradient within 4 SE (max z over components)", z, 4.0, kind="mc") +# %% [markdown] +# ## 7. SFT = maximum likelihood = forward KL minimisation: L-BFGS from a random init recovers the teacher +# %% +gen_i = torch.Generator().manual_seed(77) # seeded: the quoted numbers must reproduce +th = (theta_s + 0.5 * torch.randn(theta_s.numel(), generator=gen_i)).requires_grad_(True) +opt = torch.optim.LBFGS([th], lr=1.0, max_iter=500, tolerance_grad=1e-14, tolerance_change=1e-16, line_search_fn="strong_wolfe") +def closure(): + opt.zero_grad(); loss = -(p_T * lm.seq_logprob(th)).sum(); loss.backward(); return loss +for _ in range(6): opt.step(closure) +KLend = float((p_T * (p_T.log() - lm.seq_logprob(th.detach()))).sum()) +check("KL(p||pi) after MLE (should be ~0)", KLend, 1e-10) +check("recovered sequence distribution == teacher", maxabs(lm.probs(th.detach()), p_T), 1e-6) +print("done; checks:", len(CHECKS)) diff --git a/code/notebooks/_m1_src/0102-policy-gradient-baselines.py b/code/notebooks/_m1_src/0102-policy-gradient-baselines.py new file mode 100644 index 0000000000000000000000000000000000000000..054bfbfbcf45e805dd7186a7d31f14125fc723ce --- /dev/null +++ b/code/notebooks/_m1_src/0102-policy-gradient-baselines.py @@ -0,0 +1,165 @@ +# %% [markdown] +# # 0102 - Policy gradient from first principles, REINFORCE, baselines +# Companion to `notes/0102 - Policy Gradient and Baselines.pdf`. Exact enumeration of the 121-sequence toy LM; fp64. +# %% +import sys, itertools +sys.path.insert(0, "..") +from _m1_lib import * +lm = TinyLM(V=4, L=4, seed=0, scale=1.0) +theta = lm.theta0.clone() +R = default_reward(lm) # binary verifier reward +p = lm.probs(theta) +S = score_matrix(lm, theta) # [N, P] grad log pi(o) +P_ = theta.numel() +print("N =", lm.N, " P =", P_, " E[R] =", float(p @ R)) +# %% [markdown] +# ## 1. Log-derivative trick: grad J = E_pi[R * score] (note Eq. 2) +# J(theta) = sum_o pi_theta(o) R(o). Three independent computations of its gradient. +# %% +th = theta.clone().requires_grad_(True) +J = (lm.probs(th) * R).sum() +g_auto = torch.autograd.grad(J, th)[0] # differentiate the *definition* (through pi) +g_ld = (p[:, None] * R[:, None] * S).sum(0) # E_pi[R * score] (the derived formula) +def Jfun(t): return float((lm.probs(t) * R).sum()) +h = 1e-6 +g_fd = torch.zeros_like(theta) +for i in range(P_): # naive central finite differences + e = torch.zeros_like(theta); e[i] = h + g_fd[i] = (Jfun(theta + e) - Jfun(theta - e)) / (2 * h) +check("log-derivative formula == autograd of J", maxabs(g_ld, g_auto), 1e-14) +check("log-derivative formula == central finite differences", maxabs(g_ld, g_fd), 1e-8) +# %% [markdown] +# ## 2. Baselines are free: E[b * score] = 0 for any b that does not depend on the sampled response +# %% +check("E_pi[score] = 0", float((p[:, None] * S).sum(0).abs().max()), 1e-14) +for b in [0.0, float(p @ R), 5.0, -3.0]: + g_b = (p[:, None] * (R - b)[:, None] * S).sum(0) + check(f"unbiased with constant baseline b={b:+.3f}", maxabs(g_b, g_auto), 1e-13) +# state-dependent baseline b_t(o_ successes carry the larger score norm, so b* > E[R]") +assert (m1_ > m0_) == (bstar > EvR) +# interval claim: any b in (0, 2b*) has Var(b) < Var(0); b outside is worse +inside = all(var_formula(float(b)) < var_formula(0.0) for b in torch.linspace(0.01, 2 * bstar - 0.01, 50)) +outside = all(var_formula(float(b)) > var_formula(0.0) for b in [2 * bstar + 0.01, 2 * bstar + 1.0, -0.01, -1.0]) +assert inside and outside +check("Var(b) < Var(0) iff b in (0, 2 b*) (50 inside, 4 outside points)", 0.0, 0.0) +# %% [markdown] +# ## 4. Naive vs efficient estimator (Monte-Carlo), and agreement with the exact gradient +# naive: one backward per sample, python loop. efficient: ONE backward through the surrogate -mean(sg(A) * log pi). +# %% +n = 2000 +gen = torch.Generator().manual_seed(11) +idx = torch.multinomial(p, n, replacement=True, generator=gen) +b = EvR +# naive +th = theta.clone().requires_grad_(True) +acc = torch.zeros_like(theta) +for i in idx.tolist(): + lp = lm.seq_logprob(th)[i] + acc += (float(R[i]) - b) * torch.autograd.grad(lp, th)[0] +g_naive = acc / n +# efficient +th = theta.clone().requires_grad_(True) +surrogate = ((R[idx] - b).detach() * lm.seq_logprob(th)[idx]).mean() # gradient of this = REINFORCE estimate +g_eff = torch.autograd.grad(surrogate, th)[0] +check("naive per-sample loop == single backward through surrogate", maxabs(g_naive, g_eff), 1e-13) +# MC accuracy against the exact gradient +n2 = 400_000 +idx2 = torch.multinomial(p, n2, replacement=True, generator=gen) +est = ((R[idx2] - b)[:, None] * S[idx2]) +g_mc, se = est.mean(0), est.std(0) / n2 ** 0.5 +ok_ = se > 0 +z = float(((g_mc - g_auto).abs()[ok_] / se[ok_]).max()) +check("MC REINFORCE gradient within 4 SE of exact (max z)", z, 4.0, kind="mc") +# %% [markdown] +# ## 5. Group baselines: leave-one-out is unbiased, "mean including self" is biased by (G-1)/G +# Exact enumeration of ALL G-tuples of samples (G=2: 14641 tuples, G=3: 1.77M tuples, evaluated per first sample). +# %% +N = lm.N +RR = R[None, :] ; pp = p[None, :] +def loo_and_mean(G): + """exact E[(R_1 - baseline_1) s_1] for baseline = mean of others (LOO) and mean of all (incl. self).""" + # sum over samples 2..G of rewards: build the joint over (o_2..o_G) as a tensor of (reward sum, prob) + if G == 2: + rs = R[None, :].expand(N, N); w = p[None, :].expand(N, N) + sum_others_R = rs; prob_others = w + elif G == 3: + sum_others_R = (R[:, None] + R[None, :]).reshape(-1)[None, :].expand(N, -1) + prob_others = (p[:, None] * p[None, :]).reshape(-1)[None, :].expand(N, -1) + # N x N^2 : first sample index x joint of other two + Ro1 = R[:, None] + base_loo = sum_others_R / (G - 1) + base_all = (Ro1 + sum_others_R) / G + wloo = (prob_others * (Ro1 - base_loo)).sum(1) # E over others of (R1 - b) + wall = (prob_others * (Ro1 - base_all)).sum(1) + g_loo = (p * wloo)[:, None] * S + g_all = (p * wall)[:, None] * S + return g_loo.sum(0), g_all.sum(0) +for G in (2, 3): + gl, ga = loo_and_mean(G) + check(f"G={G}: leave-one-out baseline is exactly unbiased", maxabs(gl, g_auto), 1e-13) + check(f"G={G}: mean-including-self gives exactly (G-1)/G * grad J", maxabs(ga, (G - 1) / G * g_auto), 1e-13) +# %% [markdown] +# ## 6. Causality / reward-to-go (per-token rewards): both unbiased, reward-to-go has lower variance here +# Per-token reward r_t = 1[o_t == 1]. Full return G = sum_t r_t; reward-to-go G_t = sum_{t'>=t} r_t'. +# %% +rt = (lm.tokens == 1).double() * lm.mask # [N, L] +Gfull = rt.sum(1) +Gto = rt.flip(1).cumsum(1).flip(1) * lm.mask # reward-to-go +th = theta.clone().requires_grad_(True) +gJ = torch.autograd.grad((lm.probs(th) * Gfull).sum(), th)[0] +est_full = (T.sum(1) * Gfull[:, None]) # sum_t score_t * G +est_rtg = (T * Gto[:, :, None]).sum(1) # sum_t score_t * G_t +g_full = (p[:, None] * est_full).sum(0); g_rtg = (p[:, None] * est_rtg).sum(0) +check("full-return estimator unbiased", maxabs(g_full, gJ), 1e-13) +check("reward-to-go estimator unbiased (dropped terms E[score_t r_t'] , t' long sequences +thq = lmL.theta0.clone(); thp = perturb(thq, 0.6, seed=9) +lq, lp = lmL.token_logprobs(thq), lmL.token_logprobs(thp) +qL = lq.sum(1).exp() +check("long-horizon model: sum_o q(o) = 1", abs(float(qL.sum()) - 1), 1e-12) +print("N sequences:", lmL.N, " mean length under q:", float((qL * lmL.lengths).sum())) +logw_cum = (lp - lq).cumsum(1) # log w_T for T = 1..L (masked tokens contribute 0 -> ratio 1 after EOS) +rows = [] +for T in range(1, lmL.L + 1): + wT = logw_cum[:, T - 1].exp() + m1 = float((qL * wT).sum()); m2 = float((qL * wT ** 2).sum()) + med = float((qL * (wT < 1).double()).sum()) # P_q(w_T < 1): the typical sample has weight < 1 + rows.append((T, m1, m2 - 1, med, float((qL * logw_cum[:, T - 1]).sum()))) + check(f"E_q[w_T] = 1 at T={T}", abs(m1 - 1), 1e-12) if T in (1, 6, 12) else None +print(" T E[w] chi2_T P(w<1) E[log w]") +for r in rows: print(f"{r[0]:2d} {r[1]:.4f} {r[2]:9.4f} {r[3]:.4f} {r[4]:+.4f}") +chi_T = torch.tensor([r[2] for r in rows]) +assert bool((chi_T[1:] >= chi_T[:-1] - 1e-12).all()) +check("chi^2_T non-decreasing in T (E_q[w_T^2] only grows)", 0.0, 0.0) +ratio_growth = float(chi_T[-1] / chi_T[0]) +print(f"chi2_12 / chi2_1 = {ratio_growth:.1f}") +# chain rule: E_q[log w_T] = - sum_{t<=T} E_q[KL_t(q || p)] computed independently on the prefix tree +def tree_kl(thq, thp, T): + tot = 0.0 + def rec(prefix, prob): + nonlocal tot + if len(prefix) >= T: return + cq, cp = lmL.cond_probs(thq, prefix), lmL.cond_probs(thp, prefix) + tot += prob * float((cq * (cq.log() - cp.log())).sum()) + for a in range(lmL.V - 1): + rec(prefix + (a,), prob * float(cq[a])) + rec((), 1.0) + return tot +for T in (3, 8): + check(f"E_q[log w_T] = -sum_t E[KL_t] (tree recursion), T={T}", abs(rows[T - 1][4] + tree_kl(thq, thp, T)), 1e-12) +# %% [markdown] +# ## 4. iid-token closed form E_q[w_T^2] = (1 + chi2_1)^T and ESS collapse +# %% +Vv = 3 +qa = torch.tensor([0.5, 0.3, 0.2]); pa = torch.tensor([0.45, 0.33, 0.22]) +chi1 = float((pa ** 2 / qa).sum()) - 1 +def brute(T): + seqs = torch.cartesian_prod(*[torch.arange(Vv)] * T) if T > 1 else torch.arange(Vv)[:, None] + lqs = qa[seqs].log().sum(1); lps = pa[seqs].log().sum(1) + return float((lqs.exp() * (lps - lqs).exp() ** 2).sum()) +for T in (1, 3, 6, 9): + check(f"iid tokens: E_q[w_T^2] = (1+chi2_1)^T, T={T}", abs(brute(T) - (1 + chi1) ** T) / (1 + chi1) ** T, 1e-12) +print(f"per-token chi2_1 = {chi1:.5f}") +for T in (1, 10, 100, 1000, 4000): + print(f"T={T:5d} chi2_T = {(1+chi1)**T-1:12.3f} ESS/n ~ 1/(1+chi2) = {(1+chi1)**(-T):.3e}") +# %% [markdown] +# ## 5. Naive vs stable weight computation (fp32 running product underflows; log-space is exact) +# %% +gen = torch.Generator().manual_seed(77) +def weights(T, sigma, dtype): + logr = (-0.5 * sigma ** 2 + sigma * torch.randn(T, generator=gen)).to(torch.float64) + naive = torch.tensor(1.0, dtype=dtype) + for x in logr.exp().to(dtype): naive = naive * x # naive running product of token ratios + stable = logr.sum().exp() # log-space + return float(naive), float(stable), float(logr.sum()) +n64, s64, _ = weights(50, 0.1, torch.float64) +check("T=50: naive fp64 product == exp(sum log r)", abs(n64 - s64) / s64, 1e-12) +n32, s64b, lw = weights(4000, 0.5, torch.float32) +print(f"T=4000 sigma=0.5: log w = {lw:.1f}; naive fp32 product = {n32}; log-space fp64 = {s64b:.3e}") +assert n32 == 0.0 and math.isfinite(lw) +# %% [markdown] +# ## 6. Token-level surrogate (PPO-style ratio) vs the exact off-policy gradient +# Exact: grad J(theta) = E_old[ w(o) (R-b) sum_t grad log pi_theta(o_t|s_t) ] (sequence-level weight, all tokens). +# Token-level surrogate: L(theta) = E_old[ sum_t r_t(theta) (R-b) ] with r_t = pi_theta(o_t|s_t)/pi_old(o_t|s_t). +# They agree at theta_old and drift apart as ||theta - theta_old|| grows: value error O(delta^2), gradient error O(delta). +# %% +lm = TinyLM(V=4, L=4, seed=0, scale=1.0) +R = default_reward(lm) +th_old = lm.theta0.clone() +q = lm.probs(th_old) +b = float(q @ R) +def exact_grad(th): + t = th.clone().requires_grad_(True) + return torch.autograd.grad((lm.probs(t) * R).sum(), t)[0] +def seq_is_grad(th): + t = th.clone().requires_grad_(True) + lpn = lm.seq_logprob(t) + wts = (lpn - lm.seq_logprob(th_old)).exp().detach() + return torch.autograd.grad((q * wts * (R - b) * lpn).sum(), t)[0] +def token_surrogate(th): + t = th.clone().requires_grad_(True) + r = (lm.token_logprobs(t) - lm.token_logprobs(th_old)).exp() * lm.mask # r_t(theta), 0 where masked + L = (q[:, None] * r * (R - b)[:, None]).sum() + return L, torch.autograd.grad(L, t)[0] +deltas = [0.0, 0.02, 0.04, 0.08, 0.16, 0.32] +L0 = float((q * lm.lengths * (R - b)).sum()) +gerr, verr = [], [] +for d in deltas: + th = perturb(th_old, d, seed=5) if d > 0 else th_old.clone() + ge = exact_grad(th) + check(f"sequence-level IS gradient == exact grad J, delta={d}", maxabs(seq_is_grad(th), ge), 1e-14) if d in (0.0, 0.32) else None + Lv, gt = token_surrogate(th) + gerr.append(float((gt - ge).norm())); verr.append(abs(float(Lv.detach()) - L0 - float((lm.probs(th) * R).sum() - (q * R).sum()))) + print(f"delta={d:5.2f} |grad_tok - grad_J| = {gerr[-1]:.3e} |dL - dJ| = {verr[-1]:.3e}") +check("token surrogate gradient == exact gradient at theta_old", gerr[0], 1e-14) +check("token surrogate value == J to first order at theta_old (error at delta=0)", verr[0], 1e-14) +import numpy as np +sl_g = np.polyfit(np.log(deltas[1:]), np.log(gerr[1:]), 1)[0] +sl_v = np.polyfit(np.log(deltas[1:]), np.log(verr[1:]), 1)[0] +print(f"log-log slopes: gradient error {sl_g:.2f} (expect ~1), value error {sl_v:.2f} (expect ~2)") +check("gradient-error slope ~ 1", abs(sl_g - 1.0), 0.15) +check("value-error slope ~ 2", abs(sl_v - 2.0), 0.15) +print("done; checks:", len(CHECKS)) diff --git a/code/notebooks/_m1_src/0104-trust-region-ppo-clip.py b/code/notebooks/_m1_src/0104-trust-region-ppo-clip.py new file mode 100644 index 0000000000000000000000000000000000000000..0ad943109142284cbecd891da7c81b3e3a585309 --- /dev/null +++ b/code/notebooks/_m1_src/0104-trust-region-ppo-clip.py @@ -0,0 +1,154 @@ +# %% [markdown] +# # 0104 - Trust regions -> PPO clipped surrogate; the clip as a pessimistic bound; the gradient case table +# Companion to `notes/0104 - Trust Regions and the PPO Clip.pdf`. Exact enumeration, fp64. +# %% +import sys, math, itertools +sys.path.insert(0, "..") +from _m1_lib import * +import numpy as np +lm = TinyLM(V=4, L=4, seed=0, scale=1.0) +R = default_reward(lm) +th_old = lm.theta0.clone() +q = lm.probs(th_old) +seqset = {s: i for i, s in enumerate(lm.seqs)} +V, L = lm.V, lm.L +# %% [markdown] +# ## 1. Exact value functions by backward recursion; advantage A_old(s,a) = V(s+a) - V(s) +# %% +from functools import lru_cache +def make_value(theta): + @lru_cache(None) + def Vf(prefix): + if prefix in seqset: return float(R[seqset[prefix]]) # terminal (EOS or length L): value = reward + cp = lm.cond_probs(theta, prefix) + return float(sum(cp[a] * Vf(prefix + (a,)) for a in range(V))) + return Vf +Vold = make_value(th_old) +check("V_old(root) = J(theta_old) = sum_o pi R", abs(Vold(()) - float(q @ R)), 1e-14) +states = lm.prefixes() +def A_old(prefix, a): return Vold(prefix + (a,)) - Vold(prefix) +check("E_{a~pi_old}[A_old(s,a)] = 0 at every state", max(abs(sum(float(lm.cond_probs(th_old, s)[a]) * A_old(s, a) for a in range(V))) for s in states), 1e-14) +# advantage along every enumerated sequence: Adv[o,t] = A_old(s_t, o_t) +Adv = torch.zeros(lm.N, L) +for i, s in enumerate(lm.seqs): + for t in range(len(s)): Adv[i, t] = A_old(s[:t], s[t]) +check("telescoping: sum_t A_old(s_t,o_t) = R(o) - J(theta_old) for EVERY sequence", maxabs(Adv.sum(1), R - Vold(())), 1e-14) +# %% [markdown] +# ## 2. Performance-difference lemma: J(theta') - J(theta_old) = E_{o~pi'}[ sum_t A_old(s_t,o_t) ] +# and the exact IS form with the PREFIX ratio w_{1:t} = prod_{s<=t} r_s (action at t is the only thing that matters for A_old). +# %% +def ratios(th): + lp, lq = lm.token_logprobs(th), lm.token_logprobs(th_old) + r = (lp - lq).exp() * lm.mask + (1 - lm.mask) # r_t (1 after EOS) + return r, lp +for d in (0.1, 0.4): + th = perturb(th_old, d, seed=3) + p = lm.probs(th) + dJ = float(p @ R - q @ R) + pd_lemma = float((p * Adv.sum(1)).sum()) # E_{pi'}[sum_t A_old] + r, _ = ratios(th) + wprefix = r.cumprod(1) # w_{1:t} + is_exact = float((q[:, None] * wprefix * Adv).sum()) # E_old[ sum_t w_{1:t} A_old(s_t,o_t) ] + L_sur = float((q[:, None] * r * Adv).sum()) # TRPO/PPO surrogate: only the token ratio + check(f"performance difference lemma, delta={d}", abs(dJ - pd_lemma), 1e-14) + check(f"IS with prefix ratio w_(1:t) is exact, delta={d}", abs(dJ - is_exact), 1e-14) + print(f" delta={d}: dJ={dJ:+.6f} surrogate L-J_old={L_sur:+.6f} (error {abs(dJ-L_sur):.2e})") +# first-order agreement of surrogate and J at theta_old +th = th_old.clone().requires_grad_(True) +lpn, lqo = lm.token_logprobs(th), lm.token_logprobs(th_old) +Ls = (q[:, None] * ((lpn - lqo).exp() * lm.mask) * Adv).sum() +gL = torch.autograd.grad(Ls, th)[0] +th2 = th_old.clone().requires_grad_(True) +gJ = torch.autograd.grad((lm.probs(th2) * R).sum(), th2)[0] +check("grad of surrogate at theta_old == grad J (first-order matching)", maxabs(gL, gJ), 1e-14) +# %% [markdown] +# ## 3. Finite-horizon trust-region bound (derived in the note): |J' - L| <= 2 eps alpha^2 T (T-1) +# alpha = max_s TV(pi'(.|s), pi(.|s)), eps = max_{s,a} |A_old(s,a)|, T = horizon. Pinsker: alpha^2 <= D_KL^max / 2. +# %% +eps = max(abs(A_old(s, a)) for s in states for a in range(V)) +print("eps = max|A| =", eps) +rows = [] +for d in (0.05, 0.1, 0.2, 0.4, 0.8, 1.6): + th = perturb(th_old, d, seed=3) + alpha = max(0.5 * float((lm.cond_probs(th, s) - lm.cond_probs(th_old, s)).abs().sum()) for s in states) + kl_max = max(float((lm.cond_probs(th_old, s) * (lm.cond_probs(th_old, s).log() - lm.cond_probs(th, s).log())).sum()) for s in states) + p = lm.probs(th); r, _ = ratios(th) + err = abs(float(p @ R - q @ R) - float((q[:, None] * r * Adv).sum())) + bound_tv = 2 * eps * alpha ** 2 * L * (L - 1) + bound_kl = eps * L * (L - 1) * kl_max + rows.append((d, alpha, kl_max, err, bound_tv, bound_kl)) + print(f"delta={d:4.2f} alpha={alpha:.4f} KLmax={kl_max:.4f} |J'-L|={err:.3e} TV-bound={bound_tv:.3e} KL-bound={bound_kl:.3e} slack={bound_tv/max(err,1e-300):.1f}x") + assert err <= bound_tv + 1e-15 and bound_tv <= bound_kl + 1e-15 and alpha ** 2 <= kl_max / 2 + 1e-15 +check("bound |J'-L| <= 2 eps a^2 T(T-1) holds at all 6 trust-region sizes (max err/bound)", max(r[3] / r[4] for r in rows), 1.0) +check("Pinsker chain a^2 <= KLmax/2 holds (max a^2/(KLmax/2))", max(r[1] ** 2 / (r[2] / 2) for r in rows), 1.0) +# %% [markdown] +# ## 4. PPO clip: f(r, A) = min(r A, clip(r, 1-eps_l, 1+eps_h) A) +# closed form A>0: A*min(r, 1+eps_h); A<0: A*max(r, 1-eps_l); pessimistic: f <= r A; gradient case table. +# %% +def f_naive(r, A, el, eh): + clipped = min(max(r, 1 - el), 1 + eh) + return min(r * A, clipped * A) +def f_closed(r, A, el, eh): + return A * min(r, 1 + eh) if A > 0 else A * max(r, 1 - el) +el, eh = 0.2, 0.28 +rg = np.concatenate([np.linspace(0.0, 3.0, 301), [0.8 + 1e-9, 1.28 - 1e-9]]) +worst_cf, worst_pess = 0.0, -1e9 +for A in (-2.0, -0.3, 0.3, 2.0): + for r in rg: + worst_cf = max(worst_cf, abs(f_naive(r, A, el, eh) - f_closed(r, A, el, eh))) + worst_pess = max(worst_pess, f_naive(r, A, el, eh) - r * A) +check("closed forms A*min(r,1+eh) / A*max(r,1-el) == min/clip definition", worst_cf, 1e-14) +check("pessimism: f(r,A) <= r*A everywhere (max of f - rA, must be <= 0)", max(0.0, worst_pess), 0.0) +# autograd gradient wrt log pi vs hand case table +def grad_autograd(r, A, el, eh): + lp = torch.tensor(math.log(r), requires_grad=True) # log pi_theta ; old log prob = 0 so r = exp(lp) + rr = lp.exp() + f = torch.minimum(rr * A, torch.clamp(rr, 1 - el, 1 + eh) * A) + return float(torch.autograd.grad(f, lp)[0]) +def grad_table(r, A, el, eh): + if A > 0: passes = r < 1 + eh + else: passes = r > 1 - el + return A * r if passes else 0.0 # d f / d log pi = A r when the token is live +worst, n_zero, n_pass = 0.0, 0, 0 +for A in (-2.0, -0.3, 0.3, 2.0): + for r in [x for x in rg if x > 0 and abs(x - (1 - el)) > 1e-6 and abs(x - (1 + eh)) > 1e-6]: + ga, gt = grad_autograd(r, A, el, eh), grad_table(r, A, el, eh) + worst = max(worst, abs(ga - gt)); n_zero += gt == 0.0; n_pass += gt != 0.0 +check(f"gradient case table == autograd on {n_zero+n_pass} (r,A) points ({n_zero} silenced, {n_pass} live)", worst, 1e-14) +# the 2x3 table printed explicitly +print("\n gradient wrt log pi of one token (eps_l=0.2, eps_h=0.28), '.' = zero") +print(" region A>0 A<0") +for name, r in (("r < 1-eps_l", 0.5), ("in [1-eps_l,1+eps_h]", 1.0), ("r > 1+eps_h", 2.0)): + print(f" {name:<20s}", " ".join(f"{(grad_autograd(r, A, el, eh)/(A*r)):4.0f}*A*r" if abs(grad_autograd(r, A, el, eh))>0 else " . " for A in (0.7, -0.7))) +# boundary tie behaviour in torch (documented, not relied upon) +print(" boundary tie r=1+eps_h, A>0:", grad_autograd(1 + eh, 1.0, el, eh), " r=1-eps_l, A<0:", grad_autograd(1 - el, -1.0, el, eh)) +# naive vs vectorised implementation +gen = torch.Generator().manual_seed(1) +rr, AA = torch.rand(10000, generator=gen) * 3, torch.randn(10000, generator=gen) +fn = torch.tensor([f_naive(float(a), float(b), el, eh) for a, b in zip(rr, AA)]) +fv = torch.where(AA > 0, AA * torch.clamp(rr, max=1 + eh), AA * torch.clamp(rr, min=1 - el)) +check("naive scalar loop == vectorised torch.where implementation", maxabs(fn, fv), 1e-14) +# %% [markdown] +# ## 5. The clip is a heuristic, not a trust region: ratios leave [1-eps,1+eps] during multi-step ascent +# Full-batch (exact-expectation) ascent on E_old[ sum_t f(r_t, A_t) ] with A = R - E[R] (outcome advantage), 600 steps. +# %% +R_ = R; b = float(q @ R_) +A_out = (R_ - b)[:, None].expand(-1, L) * lm.mask +th = th_old.clone().requires_grad_(True) +opt = torch.optim.SGD([th], lr=0.3) +lq = lm.token_logprobs(th_old) +hist = [] +for step in range(601): + lp = lm.token_logprobs(th) + r = ((lp - lq).exp() * lm.mask) + (1 - lm.mask) + f = torch.where(A_out > 0, A_out * torch.clamp(r, max=1 + 0.2), A_out * torch.clamp(r, min=1 - 0.2)) + obj = (q[:, None] * f * lm.mask).sum() + opt.zero_grad(); (-obj).backward(); opt.step() + if step in (0, 10, 50, 100, 200, 400, 600): + out_of = float((q[:, None] * (((r - 1).abs() > 0.2) & (lm.mask > 0))).sum() / (q * lm.lengths).sum()) + hist.append((step, float((r - 1).abs().detach().max()), out_of, float(obj.detach()))) +print("step max|r-1| prob-weighted token fraction outside [0.8,1.2] objective") +for h in hist: print(f"{h[0]:4d} {h[1]:8.3f} {h[2]:8.3f} {h[3]:+.4f}") +assert hist[-1][1] > 0.4 +print("final max|r-1| =", hist[-1][1], "(eps=0.2)") +print("done; checks:", len(CHECKS)) diff --git a/code/notebooks/_m1_src/0105-grpo-group-advantages.py b/code/notebooks/_m1_src/0105-grpo-group-advantages.py new file mode 100644 index 0000000000000000000000000000000000000000..f0678a02167c1afb65a2ad2ae217bf4482c712f5 --- /dev/null +++ b/code/notebooks/_m1_src/0105-grpo-group-advantages.py @@ -0,0 +1,200 @@ +# %% [markdown] +# # 0105 - GRPO: group-relative advantages as a Monte-Carlo baseline; std and length normalisation biases; Dr.GRPO +# Companion to `notes/0105 - GRPO and Dr GRPO.pdf`. Exact enumeration of ALL G-tuples of samples (fp64). +# %% +import sys, math +sys.path.insert(0, "..") +from _m1_lib import * +import numpy as np +lm = TinyLM(V=4, L=4, seed=0, scale=1.0) +theta = lm.theta0.clone() +R = default_reward(lm) +p = lm.probs(theta) +S = score_matrix(lm, theta) # [N,P] +N = lm.N +th = theta.clone().requires_grad_(True) +pr = (lm.probs(th) * R).sum() # success probability p(theta) +grad_p = torch.autograd.grad(pr, th)[0] +p_succ = float(pr.detach()) +print("success prob p =", p_succ, " ||grad p|| =", float(grad_p.norm())) +# %% [markdown] +# ## 1. Advantage definitions, RLOO equivalence, invariances +# %% +def adv_grpo(Rg, ddof=0, eps=0.0): + mu = Rg.mean(); sd = Rg.std(unbiased=bool(ddof)) if len(Rg) > 1 else torch.tensor(0.0) + return (Rg - mu) / (sd + eps) +def adv_center(Rg): return Rg - Rg.mean() +def adv_rloo(Rg): + G = len(Rg); return Rg - (Rg.sum() - Rg) / (G - 1) +gen = torch.Generator().manual_seed(0) +Rg = torch.rand(7, generator=gen) +G_ = len(Rg) +check("G/(G-1) * (R_i - mean) == RLOO advantage", maxabs(G_ / (G_ - 1) * adv_center(Rg), adv_rloo(Rg)), 1e-15) +check("GRPO advantage invariant to R -> a R + c (a>0)", maxabs(adv_grpo(Rg), adv_grpo(3.7 * Rg - 2.0)), 1e-13) +check("centred advantage scales with a: A(aR+c) = a A(R)", maxabs(adv_center(3.7 * Rg - 2.0), 3.7 * adv_center(Rg)), 1e-14) +check("GRPO advantage has mean 0 and (population) std 1", max(abs(float(adv_grpo(Rg).mean())), abs(float(adv_grpo(Rg).std(unbiased=False)) - 1)), 1e-14) +Rdead = torch.ones(5) +check("dead group (all rewards equal) has zero advantage (eps=1e-4 convention)", float(adv_grpo(Rdead, eps=1e-4).abs().max()), 0.0) +# %% [markdown] +# ## 2. EXACT expected gradient of the group estimators, by enumerating every G-tuple of responses +# estimator: g = (1/G) sum_i A_i * score(o_i) (outcome reward, one scalar advantage per response) +# compare: mean-centred (Dr.GRPO) -> (1 - 1/G) grad J ; std-normalised (GRPO) -> c_G(p) grad p with c_G NOT constant. +# %% +def exact_group_gradient(G, kind, Rr=None): + """Sum over all N^G tuples with product probability. kind in {'center','grpo','rloo'}.""" + Rr = R if Rr is None else Rr + assert G in (2, 3) + if G == 2: + R1, R2 = Rr[:, None].expand(N, N), Rr[None, :].expand(N, N) + P = p[:, None] * p[None, :] + A = _adv_batch(torch.stack([R1, R2]), kind) # [2, N, N] + g = (P * A[0]).sum(1) @ S + (P * A[1]).sum(0) @ S # sample 1 carries score(o1), sample 2 score(o2) + return g / 2 + else: + g = torch.zeros(S.shape[1]) + R2, R3 = Rr[:, None], Rr[None, :] + P23 = p[:, None] * p[None, :] + for i in range(N): + Rs = torch.stack([Rr[i].expand(N, N), R2.expand(N, N), R3.expand(N, N)]) + A = _adv_batch(Rs, kind) # [3, N, N] + g += p[i] * (P23 * A[0]).sum() * S[i] + g += p[i] * ((P23 * A[1]).sum(1) @ S) + g += p[i] * ((P23 * A[2]).sum(0) @ S) + return g / 3 +def _adv_batch(Rs, kind): + """Rs [G, ...] rewards of the group, vectorised over the trailing dims.""" + G = Rs.shape[0] + mu = Rs.mean(0, keepdim=True) + if kind == 'center': return Rs - mu + if kind == 'rloo': return Rs - (Rs.sum(0, keepdim=True) - Rs) / (G - 1) + sd = ((Rs - mu) ** 2).mean(0, keepdim=True).sqrt() # population std + safe = torch.where(sd > 0, sd, torch.ones_like(sd)) + return torch.where(sd > 0, (Rs - mu) / safe, torch.zeros_like(Rs)) +for G in (2, 3): + gc = exact_group_gradient(G, 'center') + gr = exact_group_gradient(G, 'rloo') + check(f"G={G}: mean-centred estimator == (1-1/G) grad J exactly (Dr.GRPO unbiasedness)", maxabs(gc, (1 - 1 / G) * grad_p), 1e-13) + check(f"G={G}: RLOO estimator == grad J exactly", maxabs(gr, grad_p), 1e-13) +# %% [markdown] +# ## 3. Std normalisation: for binary rewards the expected gradient is c_G(p) * grad p with an explicit c_G +# k successes among G (0 1/sqrt(p(1-p)) as G grows (G=256, relative error)", abs(c_G(256, p_succ) * math.sqrt(p_succ * (1 - p_succ)) - 1), 0.1) +check("c_G(p->0) = sqrt(G-1) (bounded up-weighting of hard prompts), G=16", abs(c_G(16, 1e-9) - math.sqrt(15)), 1e-6) +print(" difficulty weighting c_G(p) (G=16):") +for pp in (0.01, 0.05, 0.2, 0.5, 0.8, 0.95, 0.99): + print(f" p={pp:4.2f} GRPO c = {c_G(16, pp):6.3f} Dr.GRPO c = {1 - 1/16:.3f}") +# the infinite-G limit is the gradient of the variance-stabilising objective 2*arcsin(sqrt(p)) +th = theta.clone().requires_grad_(True) +pp_ = (lm.probs(th) * R).sum() +g_asin = torch.autograd.grad(2 * torch.asin(pp_.sqrt()), th)[0] +check("grad[2 asin(sqrt p)] == grad p / sqrt(p(1-p))", maxabs(g_asin, grad_p / math.sqrt(p_succ * (1 - p_succ))), 1e-14) +# %% [markdown] +# ## 3b. General principle: ANY group advantage A_1 = h(R_1; R_2..R_G) has expected gradient E[ f(R) score ] with f(r) = E_others[ h(r; R_2..R_G) ] +# i.e. it is exactly the policy gradient of a *reshaped reward* f(R). Check with a graded (3-valued) reward, G=3, std-normalised. +# %% +Rgr = R + (lm.lengths == 4).double() # rewards in {0,1,2} +g_graded = exact_group_gradient(3, 'grpo', Rr=Rgr) +vals = sorted(set(Rgr.tolist())) +f_of = {} +for r1 in vals: # f(r) = E over the other two samples of A_1(r; R_2, R_3) + R2, R3 = Rgr[:, None].expand(N, N), Rgr[None, :].expand(N, N) + P23 = p[:, None] * p[None, :] + A1 = _adv_batch(torch.stack([torch.full((N, N), r1), R2, R3]), 'grpo')[0] + f_of[r1] = float((P23 * A1).sum()) +f_vec = torch.tensor([f_of[float(x)] for x in Rgr]) +check("graded reward, G=3: E[GRPO gradient] == E[f(R) score] with f(r)=E[A_1|R_1=r]", maxabs(g_graded, (p[:, None] * f_vec[:, None] * S).sum(0)), 1e-13) +print(" reshaped reward f(r):", {k: round(v, 4) for k, v in f_of.items()}, "(true rewards:", vals, ")") +# %% [markdown] +# ## 4. Length normalisation: E[(R-b)/|o| * score] = grad E[(R-b)/|o|] (b held fixed) -- a different objective +# %% +b = float(p @ R) +lens = lm.lengths +g_unb = (p[:, None] * (R - b)[:, None] * S).sum(0) # = grad J +g_len = (p[:, None] * ((R - b) / lens)[:, None] * S).sum(0) # GRPO-style 1/|o| +th = theta.clone().requires_grad_(True) +Jlen = (lm.probs(th) * (R - b) / lens).sum() # E[(R-b)/|o|] with b constant +g_Jlen = torch.autograd.grad(Jlen, th)[0] +check("unbiased baseline estimator == grad J", maxabs(g_unb, grad_p), 1e-14) +check("1/|o|-normalised estimator == grad of E[(R-b)/|o|] (b fixed)", maxabs(g_len, g_Jlen), 1e-14) +check("constant normaliser 1/Lmax gives grad J / Lmax (Dr.GRPO)", maxabs((p[:, None] * (R - b)[:, None] * S).sum(0) / lm.L * lm.L, grad_p), 1e-14) +# effective per-sequence weight and which way the update pushes the expected length +Elen = (lm.probs(th) * lens).sum() +g_len_E = torch.autograd.grad(Elen, th)[0] # grad E[|o|] +print("\n length | P(|o|) mean weight on FAILURES (unbiased | 1/|o|) mean weight on SUCCESSES (unbiased | 1/|o|)") +for Lk in range(1, lm.L + 1): + m = lens == Lk + pm = float(p[m].sum()) + fm, sm = m & (R == 0), m & (R == 1) + wf = (-b, -b / Lk) ; ws = (1 - b, (1 - b) / Lk) + print(f" {Lk} | {pm:6.3f} {wf[0]:+.3f} | {wf[1]:+.3f} (mass {float(p[fm].sum()):.3f}) {ws[0]:+.3f} | {ws[1]:+.3f} (mass {float(p[sm].sum()):.3f})") +def part(wfun, sel): + return float(((p * sel * wfun)[:, None] * S).sum(0) @ g_len_E) +fail, succ = (R == 0).double(), (R == 1).double() +print(" first-order d E|o| contributions :") +print(f" failures : unbiased {part(-b*torch.ones(N), fail):+.5f} 1/|o|-normalised {part(-b/lens, fail):+.5f}") +print(f" successes: unbiased {part((1-b)*torch.ones(N), succ):+.5f} 1/|o|-normalised {part((1-b)/lens, succ):+.5f}") +dir_unb = float(g_unb @ g_len_E); dir_len = float(g_len @ g_len_E) +print(f"\n = {dir_unb:+.5f} = {dir_len:+.5f} (first-order change in expected length per unit step)") +# bias term = -b * grad E[1/|o|] + (grad E[R/|o|] - ... ) : decomposition check +th = theta.clone().requires_grad_(True) +gR_over = torch.autograd.grad((lm.probs(th) * R / lens).sum(), th)[0] +th = theta.clone().requires_grad_(True) +g_inv = torch.autograd.grad((lm.probs(th) / lens).sum(), th)[0] +check("decomposition g_len = grad E[R/|o|] - b grad E[1/|o|]", maxabs(g_len, gR_over - b * g_inv), 1e-14) +# %% [markdown] +# ## 5. Naive vs efficient loss reductions on a sampled group (gradient agreement) -- GRPO, DAPO-token, Dr.GRPO +# %% +Gs = 6 +gen = torch.Generator().manual_seed(42) +idx = torch.multinomial(p, Gs, replacement=True, generator=gen) +Rg = R[idx] +Ahat = adv_grpo(Rg, eps=1e-4); Actr = adv_center(Rg) +th = theta.clone().requires_grad_(True) +lp_tok = lm.token_logprobs(th)[idx] # [G, L] +m = lm.mask[idx] +lens_g = m.sum(1) +def surrogate_naive(lp_tok, A, mode): + tot = 0.0 + if mode == 'grpo': + for i in range(Gs): tot = tot + sum(A[i] * lp_tok[i, t] for t in range(int(lens_g[i]))) / lens_g[i] + return tot / Gs + if mode == 'dapo': + n = float(lens_g.sum()) + for i in range(Gs): tot = tot + sum(A[i] * lp_tok[i, t] for t in range(int(lens_g[i]))) + return tot / n + if mode == 'dr': + for i in range(Gs): tot = tot + sum(A[i] * lp_tok[i, t] for t in range(int(lens_g[i]))) + return tot / (Gs * lm.L) +def surrogate_vec(lp_tok, A, mode): + w = A[:, None] * m + if mode == 'grpo': return ((w * lp_tok).sum(1) / lens_g).mean() + if mode == 'dapo': return (w * lp_tok).sum() / m.sum() + if mode == 'dr': return (w * lp_tok).sum() / (Gs * lm.L) +for mode, A in (('grpo', Ahat), ('dapo', Ahat), ('dr', Actr)): + gn = torch.autograd.grad(surrogate_naive(lp_tok, A, mode), th, retain_graph=True)[0] + gv = torch.autograd.grad(surrogate_vec(lp_tok, A, mode), th, retain_graph=True)[0] + check(f"naive python loops == vectorised masked reduction ({mode})", maxabs(gn, gv), 1e-14) +print("done; checks:", len(CHECKS)) diff --git a/code/notebooks/_m1_src/0106-dapo-pathologies.py b/code/notebooks/_m1_src/0106-dapo-pathologies.py new file mode 100644 index 0000000000000000000000000000000000000000..b7c0396ae636e41ea058918141abcb089c8ceac3 --- /dev/null +++ b/code/notebooks/_m1_src/0106-dapo-pathologies.py @@ -0,0 +1,245 @@ +# %% [markdown] +# # 0106 - DAPO: each of its four techniques derived as the fix for a named pathology +# Companion to `notes/0106 - DAPO.pdf`. fp64; exact enumeration where possible, Monte Carlo (flagged) elsewhere. +# %% +import sys, math +sys.path.insert(0, "..") +from _m1_lib import * +import numpy as np +# %% [markdown] +# ## 1. Clip-Higher (pathology: entropy collapse / exploration bottleneck) +# 1a. Asymmetric clip gradient table (eps_lo = 0.2, eps_hi = 0.28 as in DAPO Sec. 4.1) vs autograd; 1b. absolute probability headroom. +# %% +el, eh = 0.2, 0.28 +def f_closed(r, A, el, eh): return A * min(r, 1 + eh) if A > 0 else A * max(r, 1 - el) +def grad_auto(r, A): + lp = torch.tensor(math.log(r), requires_grad=True); rr = lp.exp() + f = torch.minimum(rr * A, torch.clamp(rr, 1 - el, 1 + eh) * A) + return float(torch.autograd.grad(f, lp)[0]) +worst = 0.0; n = 0 +for A in (-1.5, -0.2, 0.2, 1.5): + for r in np.linspace(0.05, 3.0, 400): + if min(abs(r - (1 - el)), abs(r - (1 + eh))) < 1e-6: continue + live = (r < 1 + eh) if A > 0 else (r > 1 - el) + worst = max(worst, abs(grad_auto(r, A) - (A * r if live else 0.0))); n += 1 +check(f"asymmetric clip: autograd == table (A r on live set) on {n} points", worst, 1e-14) +print(" log-ratio limits: log(1+eps_hi)=%.4f log(1-eps_lo)=%.4f (symmetric eps=.2: +%.4f / %.4f)" % (math.log(1 + eh), math.log(1 - el), math.log(1.2), math.log(0.8))) +print(" absolute probability reachable by ONE clipped update of a token with pi_old = p (A > 0):") +print(" p cap eps=0.20 cap eps=0.28 headroom(0.20) headroom(0.28)") +for pp in (0.001, 0.01, 0.1, 0.5, 0.9): + c1, c2 = min(1.0, 1.2 * pp), min(1.0, 1.28 * pp) + print(f" {pp:5.3f} {c1:8.4f} {c2:8.4f} {c1-pp:8.4f} {c2-pp:8.4f}") +check("DAPO example: 0.01 -> 0.012 at eps=0.2", abs(min(1, 1.2 * 0.01) - 0.012), 1e-15) +check("DAPO example: 0.9 -> min(1, 1.08) = 1.0 (no effective cap)", abs(min(1, 1.2 * 0.9) - 1.0), 1e-15) +# %% [markdown] +# 1c. Entropy dynamics of a softmax policy: dH = -Cov_{a~pi}( log pi_a , dz_a ) (exact first order). PG: dz = eta*pi*A; NPG: dz = eta*A. +# %% +def entropy(z): + lp = torch.log_softmax(z, 0); return float(-(lp.exp() * lp).sum()) +def cov_formula(z, dz): + pi = torch.softmax(z, 0); lg = pi.log() + return float((pi * lg * dz).sum() - (pi * lg).sum() * (pi * dz).sum()) +gen = torch.Generator().manual_seed(3) +worst_rel = 0.0 +for trial in range(6): + K = 5 + z = torch.randn(K, generator=gen) * 1.5 + A = torch.randn(K, generator=gen) + pi = torch.softmax(z, 0); A = A - (pi * A).sum() # advantages must have zero pi-mean + for name, dz in (("PG", pi * A), ("NPG", A)): + eta = 1e-6 + dH_fd = (entropy(z + eta * dz) - entropy(z - eta * dz)) / (2 * eta) + dH_cov = -cov_formula(z, dz) + worst_rel = max(worst_rel, abs(dH_fd - dH_cov) / max(1e-12, abs(dH_cov))) +check("entropy change = -Cov(log pi, dz): finite difference vs formula (PG & NPG, 6 random policies)", worst_rel, 1e-6) +pi = torch.tensor([0.90, 0.05, 0.03, 0.02]); z = pi.log() +def dH_for(A): + A = torch.tensor(A); A = A - (pi * A).sum(); return -cov_formula(z, pi * A) +print(" dH/d eta for tabular PG: good action is the DOMINANT one (pi=.90): %+.5f good action is a RARE one (pi=.02): %+.5f" % + (dH_for([1., 0, 0, 0]), dH_for([0., 0, 0, 1.]))) +assert dH_for([1., 0, 0, 0]) < 0 < dH_for([0., 0, 0, 1.]) +check("positive-advantage dominant token lowers entropy; positive-advantage rare token raises it (signs)", 0.0, 0.0) +# %% [markdown] +# 1d. Exact multi-step dynamics on a 2-action bandit (rare correct action a, pi=0.01): how fast can p(a) grow per rollout iteration? +# Objective per inner step = sum_a pi_old(a) * f(r_a, A_a) (exact expectation), tabular logits, plain gradient ascent. +# %% +def run_bandit(p0, eps_hi, outer=60, inner=400, lr=0.4, el=0.2): + z = torch.log(torch.tensor([p0, 1 - p0])).clone() + traj = [p0] + for it in range(outer): + pi_old = torch.softmax(z, 0).detach() + A = torch.tensor([1.0, 0.0]) - pi_old[0] # A_a = 1 - p, A_b = -p (binary reward on action a) + zz = z.clone().requires_grad_(True) + for _ in range(inner): + pi = torch.softmax(zz, 0); r = pi / pi_old + f = torch.where(A > 0, A * torch.clamp(r, max=1 + eps_hi), A * torch.clamp(r, min=1 - el)) + obj = (pi_old * f).sum() + g = torch.autograd.grad(obj, zz)[0] + with torch.no_grad(): zz += lr * g + z = zz.detach() + traj.append(float(torch.softmax(z, 0)[0])) + return torch.tensor(traj) +t20, t28, tinf = run_bandit(0.01, 0.2), run_bandit(0.01, 0.28), run_bandit(0.01, 10.0) +def first_cross(t, v=0.5): + idx = (t >= v).nonzero() + return int(idx[0]) if len(idx) else None +print(" iterations for p(a) to reach 0.5 starting from 0.01: eps_hi=0.20 ->", first_cross(t20), " eps_hi=0.28 ->", first_cross(t28), " no upper clip ->", first_cross(tinf)) +g20 = (t20[1:6] / t20[:5]); g28 = (t28[1:6] / t28[:5]) +print(" per-iteration growth factor p'/p over first 5 iterations: eps=.20:", [round(float(x), 3) for x in g20], " eps=.28:", [round(float(x), 3) for x in g28]) +print(" growth in iteration 1 slightly EXCEEDS 1+eps (1.251 vs 1.2; 1.342 vs 1.28): the clip stops only the DIRECT push on the rare action; the unclipped negative-advantage action keeps moving mass to it") +assert first_cross(t28) <= first_cross(t20) +check("larger eps_hi never slows the rare action (iterations to 0.5: eps_hi=.28 <= eps_hi=.20)", 0.0, 0.0) +# %% [markdown] +# ## 2. Dynamic sampling (pathology: zero-advantage groups). P(dead group) = p^G + (1-p)^G +# %% +torch.manual_seed(8) # torch.distributions samplers use the global RNG +gen = torch.Generator().manual_seed(8) +for G in (4, 16): + for pp in (0.02, 0.3): + ks = torch.distributions.Binomial(G, torch.tensor(pp)).sample((400000,)) + emp = float(((ks == 0) | (ks == G)).double().mean()); ex = pp ** G + (1 - pp) ** G + se = math.sqrt(ex * (1 - ex) / 400000) + check(f"P(dead) MC vs p^G+(1-p)^G (G={G}, p={pp}) |diff|/SE", abs(emp - ex) / se, 4.0, kind="mc") +# expected live fraction for p ~ Beta(a,b): 1 - B(a+G,b)/B(a,b) - B(a,b+G)/B(a,b) +def lbeta(a, b): return math.lgamma(a) + math.lgamma(b) - math.lgamma(a + b) +def live_fraction(a, b, G): return 1 - math.exp(lbeta(a + G, b) - lbeta(a, b)) - math.exp(lbeta(a, b + G) - lbeta(a, b)) +xs, ws = np.polynomial.legendre.leggauss(400); xs = 0.5 * (xs + 1); ws = 0.5 * ws +def live_quad(a, b, G): + dens = np.exp(-lbeta(a, b)) * xs ** (a - 1) * (1 - xs) ** (b - 1) + return float((ws * dens * (1 - xs ** G - (1 - xs) ** G)).sum()) +for (a, b) in ((2, 5), (3, 3), (2, 8)): + check(f"expected live fraction closed form vs quadrature, Beta({a},{b}), G=16", abs(live_fraction(a, b, 16) - live_quad(a, b, 16)), 1e-10) +a, b, G = 2.0, 8.0, 16 +rho = live_fraction(a, b, G) +print(f" Beta({a:.0f},{b:.0f}) prompts (mean p={a/(a+b):.2f}), G={G}: live fraction rho = {rho:.3f} -> oversampling factor 1/rho = {1/rho:.2f}") +# MC: prompts drawn until m live ones are found; expectation m / rho +torch.manual_seed(2) +gen = torch.Generator().manual_seed(2) +m_need, reps = 64, 600 +draws = [] +beta = torch.distributions.Beta(torch.tensor(a), torch.tensor(b)) +for _ in range(reps): + live = 0; drawn = 0 + while live < m_need: + pp = beta.sample() + k = torch.distributions.Binomial(G, pp).sample() + drawn += 1; live += int(0 < k < G) + draws.append(drawn) +d = torch.tensor(draws, dtype=torch.float64) +check("MC: prompts drawn to collect 64 live groups vs m/rho (z-score)", abs(float(d.mean()) - m_need / rho) / (float(d.std()) / reps ** 0.5), 4.0, kind="mc") +# Unbiasedness of dynamic sampling (exact, no MC): prompts q_j with success prob p_j and weights w_j; per-prompt Bernoulli-bandit gradient g = p_hat(1-p_hat) +# (centred advantage x score, see 0110). Filtered batch: a prompt is accepted with probability P(live|q_j) (so accepted prompts have law w_j P(live_j)/rho) and its +# group contributes E[g | live, q_j] computed DIRECTLY from the binomial restricted to 0 0 else a * torch.clamp(r, min=1 - el) + tot = tot + term; ntok += 1 + return -tot / ntok +def dapo_vec(th): + lp_all = lm.token_logprobs(th) + idx = torch.cat(groups); Rg = R[idx].reshape(len(groups), G) + A = ((Rg - Rg.mean(1, keepdim=True)) / (Rg.std(1, unbiased=False, keepdim=True) + 1e-6)).reshape(-1) + r = (lp_all[idx] - lq[idx]).exp(); mk = lm.mask[idx] + f = torch.where(A[:, None] > 0, A[:, None] * torch.clamp(r, max=1 + eh), A[:, None] * torch.clamp(r, min=1 - el)) + return -(f * mk).sum() / mk.sum() +t1 = th_new.clone().requires_grad_(True); t2 = th_new.clone().requires_grad_(True) +check("naive triple loop == vectorised DAPO loss (value)", abs(float(dapo_naive(t1).detach()) - float(dapo_vec(t2).detach())), 1e-13) +g1 = torch.autograd.grad(dapo_naive(t1), t1)[0]; g2 = torch.autograd.grad(dapo_vec(t2), t2)[0] +check("naive triple loop == vectorised DAPO loss (gradient)", maxabs(g1, g2), 1e-13) +print("done; checks:", len(CHECKS)) diff --git a/code/notebooks/_m1_src/0107-cispo.py b/code/notebooks/_m1_src/0107-cispo.py new file mode 100644 index 0000000000000000000000000000000000000000..fc19277db7da583a32f3f3a0d1bc2d18bbc24162 --- /dev/null +++ b/code/notebooks/_m1_src/0107-cispo.py @@ -0,0 +1,342 @@ +# %% [markdown] +# # 0107 - CISPO (MiniMax-M1 Sec. 3.1): clip the importance weight, not the update +# Companion to `notes/0107 - CISPO.pdf`. fp64; exact enumeration on the 121-sequence toy LM. +# %% +import sys, math +sys.path.insert(0, "..") +from _m1_lib import * +import numpy as np +lm = TinyLM(V=4, L=4, seed=0, scale=1.0) +R = default_reward(lm) +th_old = lm.theta0.clone(); q = lm.probs(th_old); N, L = lm.N, lm.L +b = float(q @ R) +A_tok = (R - b)[:, None] * lm.mask # outcome advantage, same for all tokens of a sequence +lq = lm.token_logprobs(th_old) +Ttens = token_score_tensor(lm, th_old) # score of each token at THETA_OLD (used to build gradients analytically) +eh, el = 0.28, 0.2 +# %% [markdown] +# ## 1. Gradient weights. Every method has gradient sum_t w(r_t, A_t) * A_t * grad log pi_theta(o_t | s_t) +# PPO/GRPO (clip lo/hi): w = r * 1[live] live = (A>0 & r<1+eh) | (A<0 & r>1-el) +# CISPO: w = min(r, 1+eh) (A-independent, always live) [loss = sg(min(r,1+eh)) * A * log pi] +# token IS-PG (no clip): w = r +# We verify against autograd of the actual loss functions on the toy model at a perturbed theta. +# %% +def ratio_and_lp(th): + lp = lm.token_logprobs(th) + r = (lp - lq).exp() * lm.mask + (1 - lm.mask) + return r, lp +def loss_ppo(th): + r, lp = ratio_and_lp(th) + f = torch.where(A_tok > 0, A_tok * torch.clamp(r, max=1 + eh), A_tok * torch.clamp(r, min=1 - el)) + return -(q[:, None] * f * lm.mask).sum() +def loss_cispo(th, eps_hi=eh): + r, lp = ratio_and_lp(th) + rhat = torch.clamp(r, max=1 + eps_hi).detach() # sg( clip(r) ) + return -(q[:, None] * rhat * A_tok * lp * lm.mask).sum() +def loss_isPG(th): + r, lp = ratio_and_lp(th) + return -(q[:, None] * r.detach() * A_tok * lp * lm.mask).sum() +def weights(th, method): + r, _ = ratio_and_lp(th); r = r.detach() + if method == 'ppo': live = ((A_tok > 0) & (r < 1 + eh)) | ((A_tok < 0) & (r > 1 - el)); return r * live + if method == 'cispo': return torch.clamp(r, max=1 + eh) + if method == 'is': return r +def grad_from_weights(th, w): + # sum_{o,t} q(o) w A grad log pi_theta(o_t|s_t) with the score evaluated at theta (autograd through lp) + t = th.clone().requires_grad_(True) + lp = lm.token_logprobs(t) + return torch.autograd.grad((q[:, None] * w * A_tok * lp * lm.mask).sum(), t)[0] +th = perturb(th_old, 1.5, seed=11) +r_now, _ = ratio_and_lp(th) +print("ratio range over (o,t): min %.3f max %.3f" % (float(r_now[lm.mask > 0].min()), float(r_now[lm.mask > 0].max()))) +for name, lossf in (("ppo", loss_ppo), ("cispo", loss_cispo), ("is", loss_isPG)): + t = th.clone().requires_grad_(True) + g_auto = torch.autograd.grad(lossf(t), t)[0] + g_w = -grad_from_weights(th, weights(th, name)) + check(f"{name}: autograd of the loss == sum_t w(r,A) A grad log pi (weights table)", maxabs(g_auto, g_w), 1e-13) +# %% [markdown] +# ## 2. Exactly which tokens does PPO/GRPO silence and CISPO keep? +# %% +live = ((A_tok > 0) & (r_now < 1 + eh)) | ((A_tok < 0) & (r_now > 1 - el)) +sel = lm.mask > 0 +pw = q[:, None] * lm.mask # probability weight of each (o,t) token occurrence +tot = float(pw.sum()) +zeroed = sel & ~live +cells = {"A>0, r>1+eh (silenced by PPO)": sel & (A_tok > 0) & (r_now > 1 + eh), + "A<0, r<1-el (silenced by PPO)": sel & (A_tok < 0) & (r_now < 1 - el), + "A<0, r>1+eh (PPO weight r unbounded; CISPO caps at 1+eh)": sel & (A_tok < 0) & (r_now > 1 + eh)} +for k, m in cells.items(): print(f" {k:60s}: {int(m.sum()):4d} token slots, probability mass {float((pw*m).sum())/tot:.4f}") +Wc = weights(th, 'cispo'); Wp = weights(th, 'ppo') +silenced_by_ppo_kept_by_cispo = sel & (Wp == 0) & (Wc > 0) +check("{tokens with PPO weight 0 and CISPO weight>0} == cells 1 U 2 exactly (symmetric difference size)", float((silenced_by_ppo_kept_by_cispo ^ (cells["A>0, r>1+eh (silenced by PPO)"] | cells["A<0, r<1-el (silenced by PPO)"])).sum()), 0.0) +check("CISPO never silences a token (min weight over all (o,t) with A != 0)", abs(float(Wc[sel].min()) - float(r_now[sel].min())) if float(r_now[sel].min()) < 1 + eh else 0.0, 1e-15) +check("PPO silenced set == the MiniMax-M1 mask M_{i,t}=0 of Eq. 7 (symmetric difference size)", + float((zeroed ^ (((A_tok > 0) & (r_now > 1 + eh)) | ((A_tok < 0) & (r_now < 1 - el))) & sel).sum()), 0.0) +# weight table +print("\n gradient weight w(r,A) (loss gradient = w * A * grad log pi), eps_hi=0.28, eps_lo=0.2") +print(" r PPO(A>0) PPO(A<0) CISPO unclipped-IS") +for r in (0.3, 0.8, 1.0, 1.28, 2.0, 5.0, 20.0): + wp_pos = r if r < 1 + eh else 0.0; wp_neg = r if r > 1 - el else 0.0 + print(f" {r:5.2f} {wp_pos:7.2f} {wp_neg:7.2f} {min(r, 1+eh):6.2f} {r:7.2f}") +# %% [markdown] +# ## 3. Unified mask formulation (paper Eq. 6-7): J = sg(r_hat) A log pi * M ; with r_hat = r it reproduces PPO's gradient +# %% +def loss_unify(th, rhat_clip=None): + r, lp = ratio_and_lp(th) + M = (~(((A_tok > 0) & (r > 1 + eh)) | ((A_tok < 0) & (r < 1 - el)))).double() + rhat = r if rhat_clip is None else torch.clamp(r, max=rhat_clip) + return -(q[:, None] * rhat.detach() * A_tok * lp * M * lm.mask).sum() +t = th.clone().requires_grad_(True); g_u = torch.autograd.grad(loss_unify(t), t)[0] +t = th.clone().requires_grad_(True); g_p = torch.autograd.grad(loss_ppo(t), t)[0] +check("unified formulation with r_hat=r and PPO mask == PPO gradient", maxabs(g_u, g_p), 1e-13) +# without clipping CISPO reduces to the standard (token-level IS) policy gradient; at theta_old it is the on-policy gradient +t = th.clone().requires_grad_(True); g_c_inf = torch.autograd.grad(loss_cispo(t, eps_hi=1e9), t)[0] +t = th.clone().requires_grad_(True); g_is = torch.autograd.grad(loss_isPG(t), t)[0] +check("CISPO with eps_hi=inf == token-level IS policy gradient", maxabs(g_c_inf, g_is), 1e-14) +t = th_old.clone().requires_grad_(True); g_c0 = torch.autograd.grad(loss_cispo(t), t)[0] +t2 = th_old.clone().requires_grad_(True); gJ = torch.autograd.grad((lm.probs(t2) * R).sum(), t2)[0] +check("at theta_old CISPO == exact policy gradient grad J (r=1, nothing clipped)", maxabs(-g_c0, gJ), 1e-14) +# %% [markdown] +# ## 4. Bias of CISPO relative to the (token-level) IS gradient, and the bound ||bias|| <= sum_t E[ (r - 1 - eps)_+ |A| ||score|| ] +# %% +def cispo_bias(th, eps): + r, _ = ratio_and_lp(th); r = r.detach() + Tth = token_score_tensor(lm, th) # score at theta + diff = (torch.clamp(r, max=1 + eps) - r) # r_hat - r (<= 0) + return (q[:, None, None] * (diff * A_tok)[:, :, None] * Tth).sum((0, 1)), Tth +for eps_ in (0.05, 0.1, 0.28): + bias, Tth = cispo_bias(th, eps_) + r, _ = ratio_and_lp(th) + bound = float((q[:, None] * (r - 1 - eps_).clamp(min=0) * A_tok.abs() * Tth.norm(dim=2) * lm.mask).sum()) + print(f" eps_hi={eps_:4.2f}: ||bias|| = {float(bias.norm()):.4e} bound = {bound:.4e} ||token-IS gradient|| = {float((q[:,None,None]*(r*A_tok)[:,:,None]*Tth).sum((0,1)).norm()):.4e}") + assert float(bias.norm()) <= bound + 1e-15 +check("bias bound holds (3 values of eps_hi)", 0.0, 0.0) +# for comparison: how far each method's gradient is from the token-level IS gradient +t_ = th.clone().requires_grad_(True); gI = torch.autograd.grad(loss_isPG(t_), t_)[0] +t_ = th.clone().requires_grad_(True); gP = torch.autograd.grad(loss_ppo(t_), t_)[0] +t_ = th.clone().requires_grad_(True); gC = torch.autograd.grad(loss_cispo(t_), t_)[0] +print(f" ||g_PPO - g_IS|| = {float((gP-gI).norm()):.4e} ||g_CISPO - g_IS|| = {float((gC-gI).norm()):.4e} (eps_hi=0.28, eps_lo=0.2; ||g_IS|| = {float(gI.norm()):.4e})") +# distribution-free per-state bound E_old[(r-1-eps)_+] = sum_a (pi - (1+eps) pi_old)_+ <= TV(pi, pi_old) +worst = 0.0 +for s in lm.prefixes(): + po, pn = lm.cond_probs(th_old, s), lm.cond_probs(th, s) + lhs = float(((pn - 1.28 * po).clamp(min=0)).sum()); tv = 0.5 * float((pn - po).abs().sum()) + worst = max(worst, lhs - tv) +check("per-state E_old[(r-1-eps)_+] <= TV(pi,pi_old) (max violation, must be <= 0)", max(0.0, worst), 0.0) +# variance: gradient weights are bounded for CISPO, unbounded for PPO's live A<0 tokens +r, _ = ratio_and_lp(th) +print(f" max gradient weight over (o,t): PPO {float(weights(th,'ppo').max()):.3f} CISPO {float(weights(th,'cispo').max()):.3f} unclipped IS {float(weights(th,'is').max()):.3f}") +assert float(weights(th, 'cispo').max()) <= 1 + eh + 1e-15 +check("CISPO weights bounded by 1+eps_hi", max(0.0, float(weights(th, 'cispo').max()) - (1 + eh)), 0.0) +# %% [markdown] +# ## 5. Stop-gradient semantics: three look-alike implementations, three different gradients +# (a) correct: loss = - sg(min(r,1+eh)) * A * log pi -> grad = -A*min(r,1+eh) * grad log pi +# (b) forgot sg: loss = - min(r,1+eh) * A * log pi -> on the UNclipped set grad = -A * r * (1 + log pi) * grad log pi : an extra A r log pi term +# (c) ratio form: loss = - min(r,1+eh) * A (PPO-like) -> grad = -A * r * 1[r<1+eh] * grad log pi (zero beyond the clip!) +# %% +lp_old = torch.tensor(-2.3) +def three(lp_new, A, eps=0.28): + lp = torch.tensor(lp_new, requires_grad=True); r = (lp - lp_old).exp() + ga = torch.autograd.grad(-(torch.clamp(r, max=1 + eps).detach() * A * lp), lp, retain_graph=True)[0] + gb = torch.autograd.grad(-(torch.clamp(r, max=1 + eps) * A * lp), lp, retain_graph=True)[0] + gc = torch.autograd.grad(-(torch.clamp(r, max=1 + eps) * A), lp)[0] + return float(ga), float(gb), float(gc), float(r.detach()) +print(" lp_old=-2.3; gradient wrt log pi_theta") +print(" lp_new r A (a) CISPO (b) no sg (c) ratio-clip") +for lpn, A in ((-2.3, 1.0), (-1.5, 1.0), (-1.5, -1.0), (-3.0, 1.0), (-0.5, 1.0), (-0.5, -1.0)): + ga, gb, gc, rr = three(lpn, A) + print(f" {lpn:6.2f} {rr:6.2f} {A:+.0f} {ga:+9.3f} {gb:+9.3f} {gc:+9.3f}") + expect_a = -A * min(rr, 1.28) + assert abs(ga - expect_a) < 1e-12 + expect_c = -A * rr * (rr < 1.28) + assert abs(gc - expect_c) < 1e-12 + expect_b = -A * rr * (1 + lpn) if rr < 1.28 else -A * 1.28 # clamp kills the extra term beyond the clip + assert abs(gb - expect_b) < 1e-12 +check("(a) = -A min(r,1+eh); (b) = -A r (1+log pi) on the live set; (c) = -A r 1[r<1+eh] (6 test points, exact)", 0.0, 0.0) +ga, gb, gc, rr = three(-2.3, 1.0) +print(f" at r=1 (inside the clip), A=+1, log pi=-2.3: (a)={ga:+.3f} (b)={gb:+.3f} <- sign flipped by the spurious log-pi factor (1+log pi = -1.3)") +assert ga < 0 < gb +check("without sg a live token's gradient weight becomes r(1+log pi): wrong sign when pi < 1/e", 0.0, 0.0) +ga, gb, gc, rr = three(-1.5, 1.0) +print(f" at r={rr:.2f} > 1+eps: (a)={ga:+.3f} capped weight kept; (b)={gb:+.3f} identical (clamp zeroes the extra term); (c)={gc:+.3f} silenced") + +# %% [markdown] +# ## 6. The 'fork token' mechanism over K = 16 off-policy updates on one batch (MiniMax-M1 setting) +# One decision with three tokens: two common tokens and a RARE token w (pi_old = 0.01) with positive advantage. +# Exact expectation over the old distribution; plain SGD on the logits; K = 16 inner steps. +# %% +def fork(method, K=16, lr=30.0, eh_=0.28, el_=0.2, p_w=0.01): + pi_old = torch.tensor([0.60, 0.39 - 0.0, p_w]); pi_old = pi_old / pi_old.sum() + reward = torch.tensor([0.0, 0.0, 1.0]); A = reward - float(pi_old @ reward) # A_w > 0, A_common < 0 + z = pi_old.log().clone().requires_grad_(True) + traj, gw = [], [] + for k in range(K): + pi = torch.softmax(z, 0); r = (pi / pi_old) + if method == 'ppo': + f = torch.where(A > 0, A * torch.clamp(r, max=1 + eh_), A * torch.clamp(r, min=1 - el_)) + obj = (pi_old * f).sum() + else: + rhat = torch.clamp(r, max=1 + eh_).detach() + obj = (pi_old * rhat * A * pi.log()).sum() + g = torch.autograd.grad(obj, z)[0] + # direct contribution of token w's OWN term to the gradient on its own logit (diagnostic) + rd = r.detach(); wt = float((1 + eh_) if (method == 'cispo' and rd[2] > 1 + eh_) else (rd[2] if (method == 'cispo' or rd[2] < 1 + eh_) else 0.0)) + gw.append(wt) + with torch.no_grad(): z += lr * g + traj.append(float(torch.softmax(z.detach(), 0)[2])) + return traj, gw, float(r[2].detach()) +tp, gwp, rp = fork('ppo'); tc, gwc, rc = fork('cispo') +print(" after update #" + " ".join(f"{k+1:6d}" for k in range(0, 16, 3))) +print(" p_w PPO: " + " ".join(f"{tp[k]:6.3f}" for k in range(0, 16, 3))) +print(" p_w CISPO: " + " ".join(f"{tc[k]:6.3f}" for k in range(0, 16, 3))) +print(" weight on w's own term PPO: " + " ".join(f"{gwp[k]:6.2f}" for k in range(0, 16, 3))) +print(" weight on w's own term CISPO: " + " ".join(f"{gwc[k]:6.2f}" for k in range(0, 16, 3))) +n_zero_ppo = sum(1 for x in gwp if x == 0.0) +print(f" PPO: token w gets zero direct gradient in {n_zero_ppo} of 16 inner steps; CISPO: {sum(1 for x in gwc if x==0.0)} of 16") +assert n_zero_ppo >= 8 and all(x > 0 for x in gwc) +check("PPO silences the rare token for >= 8 of 16 inner steps, CISPO for none", 0.0, 0.0) +print(f" final p_w after 16 updates: PPO {tp[-1]:.3f} CISPO {tc[-1]:.3f}") +# %% [markdown] +# ## 7. Naive vs efficient implementation of the CISPO loss, token-level normalisation by total tokens +# %% +Gs = 8 +gen = torch.Generator().manual_seed(5) +idx = torch.multinomial(q, Gs, replacement=True, generator=gen) +Rg = R[idx]; Ag = Rg - Rg.mean() +t = perturb(th_old, 0.8, seed=2).clone().requires_grad_(True) +lp = lm.token_logprobs(t)[idx]; lq_ = lq[idx]; mk = lm.mask[idx] +def cispo_naive(lp, lq_): + tot, n = 0.0, 0 + for i in range(Gs): + for tt in range(int(mk[i].sum())): + r = math.exp(float((lp[i, tt] - lq_[i, tt]).detach())) + tot = tot + min(r, 1 + eh) * Ag[i] * lp[i, tt]; n += 1 + return -tot / n +def cispo_vec(lp, lq_): + r = (lp - lq_).exp().detach() + return -((torch.clamp(r, max=1 + eh) * Ag[:, None] * lp * mk).sum() / mk.sum()) +gn = torch.autograd.grad(cispo_naive(lp, lq_), t, retain_graph=True)[0] +gv = torch.autograd.grad(cispo_vec(lp, lq_), t, retain_graph=True)[0] +check("naive per-token loop == vectorised masked CISPO loss (gradient)", maxabs(gn, gv), 1e-14) +# %% [markdown] +# ## 8. Leanstral variant (Mistral technical report, Sec. 2.3): sampler ratio, masking of tool/prompt tokens, outcome broadcast +# Loss there: `L = -(1/sum_i|o_i|) sum_i sum_t sg(min(pi_theta/pi_sample, c)) * A_i * log pi_theta(a_it | s_it, T_i)`, `T_i` = tool calls and responses used as context only, +# one verifier outcome for every model token. Toy: two-turn tabular agent (4 actions per turn), turn-2 context = turn-1 action, feedback token = a fixed function of turn 1 (not scored). +# `R ~ Bern(T[y1, y2])`. Everything below is enumerated exactly over the 32 episodes (y1, y2, R). +# %% +import numpy as _np +_rng = _np.random.default_rng(123) +_A = 4 +_T = _rng.uniform(0.05, 0.95, (_A, _A)) +def _sm(x): e = _np.exp(x - x.max(-1, keepdims=True)); return e / e.sum(-1, keepdims=True) +def _unpack(th): return th[:_A], th[_A:].reshape(_A, _A) +th_cur = _rng.normal(0, 0.8, _A + _A * _A) +th_smp = th_cur + _rng.normal(0, 0.35, _A + _A * _A) # sampler = lagged + numerically perturbed copy +def _probs(th): a, b = _unpack(th); return _sm(a), _sm(b) +def _J(th): p1, p2 = _probs(th); return float((p1[:, None] * p2 * _T).sum()) +def _score(th, y1, y2): # (s1, s2): grads of log pi(y1), log pi(y2|y1) wrt the 20 logits + p1, p2 = _probs(th); s1 = _np.zeros(_A + _A * _A); s2 = _np.zeros(_A + _A * _A) + s1[:_A] = _np.eye(_A)[y1] - p1 + blk = _np.eye(_A)[y2] - p2[y1]; s2[_A + y1 * _A: _A + (y1 + 1) * _A] = blk + return s1, s2 +def _episodes(th_sample): # list of (prob under sampler, y1, y2, R) + p1, p2 = _probs(th_sample); out = [] + for y1 in range(_A): + for y2 in range(_A): + for r in (0, 1): + out.append((p1[y1] * p2[y1, y2] * (_T[y1, y2] if r else 1 - _T[y1, y2]), y1, y2, r)) + return out +def _fd_grad(th, h=1e-6): + g = _np.zeros_like(th) + for i in range(len(th)): + e = _np.zeros_like(th); e[i] = h; g[i] = (_J(th + e) - _J(th - e)) / (2 * h) + return g +g_true = _fd_grad(th_cur) +pc1, pc2 = _probs(th_cur); ps1, ps2 = _probs(th_smp) +def _rho(y1, y2): return pc1[y1] / ps1[y1], pc2[y1, y2] / ps2[y1, y2] +# (a) on-policy score-function identity +g_on = sum(p * r * sum(_score(th_cur, y1, y2)) for p, y1, y2, r in _episodes(th_cur)) +check("8a. E_pi[R (s1+s2)] = finite-difference grad J", float(_np.abs(g_on - g_true).max()), 1e-8) +# (b) sampler episodes, full sequence ratio rho1*rho2: exact +g_seq = sum(p * _rho(y1, y2)[0] * _rho(y1, y2)[1] * r * sum(_score(th_cur, y1, y2)) for p, y1, y2, r in _episodes(th_smp)) +check("8b. E_mu[rho1 rho2 R (s1+s2)] = grad J exactly (sampler ratio, full sequence weight)", float(_np.abs(g_seq - g_true).max()), 1e-9) +# (c) token-level ratios only (what the objective uses): biased by the dropped prefix ratio; vanishes when sampler = policy +def _tok_grad(th_sample, clip=None, baseline=0.0): + g = 0 + for p, y1, y2, r in _episodes(th_sample): + q1, q2 = _rho(y1, y2) if th_sample is th_smp else (1.0, 1.0) + if clip is not None: q1, q2 = min(q1, clip), min(q2, clip) + s1, s2 = _score(th_cur, y1, y2); g = g + p * (r - baseline) * (q1 * s1 + q2 * s2) + return g +g_tok = _tok_grad(th_smp) +bias_tok = float(_np.linalg.norm(g_tok - g_true)); gn = float(_np.linalg.norm(g_true)) +print(f" token-level sampler ratio, unclipped: ||bias|| = {bias_tok:.3e} (||grad J|| = {gn:.3e})") +check("8c. token-level weights are biased for a mismatched sampler (> 1e-4)", 0.0 if bias_tok > 1e-4 else 1.0, 0.0) +th_save = th_smp; th_smp = th_cur.copy() # sampler == policy: weights are 1 +check("8c. ... and exact when the sampler equals the policy", float(_np.abs(sum(p * r * sum(_score(th_cur, y1, y2)) for p, y1, y2, r in _episodes(th_cur)) - g_true).max()), 1e-8) +th_smp = th_save +# (d) a constant baseline does not bias the token-level estimator, even under mismatch (E_mu[rho_t s_t | context] = 0) +b0 = _J(th_cur) +g_b = _tok_grad(th_smp, baseline=b0) +db = _np.abs(g_tok - g_b - 0).max() +check("8d. constant baseline b adds exactly 0 to the token-level sampler-ratio gradient: max |E_mu[sum_t rho_t b s_t]| over 20 coordinates", float(db), 1e-14) +# (e) clip bias bound with the sampler as the base measure: sum_a (pi - c mu)_+ <= TV(pi, mu) at every state +cc = 1.28 +sts = [(pc1, ps1)] + [(pc2[y], ps2[y]) for y in range(_A)] +viol = max(float(_np.maximum(a - cc * b, 0).sum() - 0.5 * _np.abs(a - b).sum()) for a, b in sts) +check("8e. sum_a (pi - c mu)_+ <= TV(pi, mu) at all 5 states (c=1.28)", max(viol, 0.0), 0.0) +g_clip = _tok_grad(th_smp, clip=cc) +bound = 0.0 +for p, y1, y2, r in _episodes(th_smp): + q1, q2 = _rho(y1, y2); s1, s2 = _score(th_cur, y1, y2) + bound += p * abs(r) * (max(q1 - cc, 0) * _np.linalg.norm(s1) + max(q2 - cc, 0) * _np.linalg.norm(s2)) +bias_clip = float(_np.linalg.norm(g_clip - g_tok)) +print(f" clip c={cc}: ||g_clip - g_token-IS|| = {bias_clip:.3e} <= bound {bound:.3e}") +check("8e. clipping bias <= E_mu[sum_t (rho_t - c)_+ |A| ||s_t||]", max(bias_clip - bound, 0.0), 0.0) +# (f) outcome broadcast vs per-turn credit (on-policy, oracle baselines): both unbiased, different variance +eps_ = _episodes(th_cur) +V = sum(p * r for p, y1, y2, r in eps_) +Q = _np.array([sum(pc2[y1, y2] * _T[y1, y2] for y2 in range(_A)) for y1 in range(_A)]) +def _moment(coef1, coef2): + m1 = 0; m2 = 0 + for p, y1, y2, r in eps_: + s1, s2 = _score(th_cur, y1, y2); g = coef1(y1, y2, r) * s1 + coef2(y1, y2, r) * s2 + m1 = m1 + p * g; m2 += p * float(g @ g) + return m1, m2 +gb_mean, gb_m2 = _moment(lambda y1, y2, r: r - V, lambda y1, y2, r: r - V) +gt_mean, gt_m2 = _moment(lambda y1, y2, r: Q[y1] - V, lambda y1, y2, r: r - Q[y1]) +check("8f. broadcast estimator (R-V)(s1+s2) is unbiased for grad J", float(_np.abs(gb_mean - g_true).max()), 1e-8) +check("8f. per-turn estimator (Q-V) s1 + (R-Q) s2 is unbiased for grad J", float(_np.abs(gt_mean - g_true).max()), 1e-8) +v_b = gb_m2 - float(gb_mean @ gb_mean); v_t = gt_m2 - float(gt_mean @ gt_mean) +print(f" total variance (trace): broadcast {v_b:.4f}, per-turn {v_t:.4f}, ratio {v_b/v_t:.3f}") +# turn-1 decomposition: E[(R-V)^2 ||s1||^2] = E[(Q-V)^2 ||s1||^2] + E[Var(R|y1) ||s1||^2] +lhs = sum(p * (r - V) ** 2 * float(_score(th_cur, y1, y2)[0] @ _score(th_cur, y1, y2)[0]) for p, y1, y2, r in eps_) +t1 = sum(p * (Q[y1] - V) ** 2 * float(_score(th_cur, y1, y2)[0] @ _score(th_cur, y1, y2)[0]) for p, y1, y2, r in eps_) +varR = _np.array([sum(pc2[y1, y2] * (_T[y1, y2] + 0) for y2 in range(_A)) - Q[y1] ** 2 for y1 in range(_A)]) # Var(R|y1) = E[R^2|y1] - Q^2 (R binary: E R^2 = E R) +t2 = sum(p * varR[y1] * float(_score(th_cur, y1, y2)[0] @ _score(th_cur, y1, y2)[0]) for p, y1, y2, r in eps_) +check("8f. turn-1 second moment: E[(R-V)^2||s1||^2] = E[(Q-V)^2||s1||^2] + E[Var(R|y1)||s1||^2]", abs(lhs - t1 - t2), 1e-13) +print(f" turn-1: broadcast {lhs:.4f} = {t1:.4f} (credit) + {t2:.4f} (noise from turn 2)") +check("8f. martingale orthogonality: E[s1.s2] = 0 so E||s1+s2||^2 = E||s1||^2 + E||s2||^2", abs(sum(p * float(_score(th_cur, y1, y2)[0] @ _score(th_cur, y1, y2)[1]) for p, y1, y2, r in eps_)), 1e-15) +# (g) score second moment grows linearly with the number of policy tokens: L-step context-dependent chain, enumerated +Lmax = 5; Th = _rng.normal(0, 0.8, (Lmax, _A, _A)); P0 = _sm(_rng.normal(0, 0.8, _A)) +def chain_moments(L): + tot = 0.0; per = _np.zeros(L); cross = 0.0 + for seq in _np.ndindex(*([_A] * L)): + pr = P0[seq[0]]; sc = [] + for t_ in range(L): + prev = 0 if t_ == 0 else seq[t_ - 1] + pt = P0 if t_ == 0 else _sm(Th[t_, prev]) + if t_ > 0: pr *= pt[seq[t_]] + v = _np.zeros(L * _A * _A + _A) + v[:_A] = (_np.eye(_A)[seq[0]] - P0) if t_ == 0 else 0 + if t_ > 0: v[_A + t_ * _A * _A + prev * _A: _A + t_ * _A * _A + (prev + 1) * _A] = _np.eye(_A)[seq[t_]] - pt + sc.append(v) + S_ = sum(sc); tot += pr * float(S_ @ S_) + for t_ in range(L): per[t_] += pr * float(sc[t_] @ sc[t_]) + return tot, per +rows = [] +for L in range(1, Lmax + 1): + tot, per = chain_moments(L); rows.append((L, tot, per.sum())) + print(f" L={L}: E||sum_t s_t||^2 = {tot:.4f} sum_t E||s_t||^2 = {per.sum():.4f}") +check("8g. E||sum_t s_t||^2 = sum_t E||s_t||^2 for L = 1..5 (score terms are martingale differences)", max(abs(a - b) for _, a, b in rows), 1e-12) +print("done; checks:", len(CHECKS)) diff --git a/code/notebooks/_m1_src/0108-training-inference-mismatch.py b/code/notebooks/_m1_src/0108-training-inference-mismatch.py new file mode 100644 index 0000000000000000000000000000000000000000..50df00e48c68dc6d0e7ec0786e1b9fc45df3b6c8 --- /dev/null +++ b/code/notebooks/_m1_src/0108-training-inference-mismatch.py @@ -0,0 +1,267 @@ +# %% [markdown] +# # 0108 - Training-inference mismatch: sampler vs trainer log-probs, truncated IS, why an FP32 LM head helps +# Companion to `notes/0108 - Training-Inference Mismatch.pdf`. fp64 reference ("trainer" pi); the "sampler" mu uses logits rounded to bf16/fp16/fp32. +# %% +import sys, math +sys.path.insert(0, "..") +from _m1_lib import * +import numpy as np +# %% [markdown] +# ## 0. Floating-point facts: unit roundoff u = 2^-(p) with p significant bits; |round(x) - x| <= u |x| +# bf16: 8 significant bits (u=2^-8), fp16: 11 (u=2^-11), fp32: 24 (u=2^-24). +# %% +gen = torch.Generator().manual_seed(0) +x = torch.exp(torch.rand(2_000_000, generator=gen) * math.log(1e3 / 1e-3) + math.log(1e-3)) # log-uniform in [1e-3, 1e3] +for name, dt, u in (("bf16", torch.bfloat16, 2.0 ** -8), ("fp16", torch.float16, 2.0 ** -11), ("fp32", torch.float32, 2.0 ** -24)): + rel = ((x.to(dt).to(torch.float64) - x).abs() / x) + print(f" {name}: max relative rounding error {float(rel.max()):.3e} u = {u:.3e}") + check(f"{name}: max rel. rounding error <= u (=2^-{int(-math.log2(u))})", max(0.0, float(rel.max()) - u), 0.0) + assert float(rel.max()) > 0.5 * u +# %% [markdown] +# ## 1. The tiny LM with LARGE logits (like an LM head with high-magnitude activations); sampler mu = same network with bf16-rounded logits +# %% +lm = TinyLM(V=4, L=4, seed=0, scale=4.0) +theta = lm.theta0.clone() +R = default_reward(lm) +def seq_logprob_rounded(theta, dtype): + V, L = lm.V, lm.L + tok, pos = lm._logits_tables(theta) + prev = torch.cat([torch.full((lm.N, 1), V, dtype=torch.long), lm.tokens[:, :-1]], 1) + logits = tok[prev] + pos[None, :, :] + if dtype is not None: logits = logits.to(dtype).to(torch.float64) + lp = torch.log_softmax(logits, -1).gather(-1, lm.tokens[..., None]).squeeze(-1) * lm.mask + return lp.sum(1) +pi = seq_logprob_rounded(theta, None).exp() +mu = seq_logprob_rounded(theta, torch.bfloat16).exp() +check("trainer pi is normalised", abs(float(pi.sum()) - 1), 1e-14) +check("sampler mu is normalised (bf16 logits then softmax)", abs(float(mu.sum()) - 1), 1e-14) +b = float(pi @ R); A = R - b +S = score_matrix(lm, theta) # score of the TRAINER policy +g_true = (pi[:, None] * A[:, None] * S).sum(0) # grad J_pi +w = pi / mu +TVs = 0.5 * float((pi - mu).abs().sum()) +print(f" bf16 logits: TV(pi, mu) = {TVs:.5f} chi2(pi||mu) = {float((mu*(w-1)**2).sum()):.3e} max w = {float(w.max()):.4f}") +for nm, dt in (("fp16", torch.float16), ("fp32", torch.float32)): + m_ = seq_logprob_rounded(theta, dt).exp() + print(f" {nm} logits: TV(pi, mu) = {0.5*float((pi-m_).abs().sum()):.3e} chi2 = {float((m_*(pi/m_-1)**2).sum()):.3e}") +# %% [markdown] +# ## 2. Ignoring the mismatch is biased; IS repairs it exactly (arXiv:2510.26788 Eqs. 3, 5) +# %% +g_biased = (mu[:, None] * A[:, None] * S).sum(0) # samples from mu, trainer's score, no correction +bias_formula = ((mu - pi)[:, None] * A[:, None] * S).sum(0) # = E_pi[(mu/pi - 1) A s] +check("bias of the uncorrected estimator == sum_o (mu-pi) A s", maxabs(g_biased - g_true, bias_formula), 1e-14) +g_is = (mu[:, None] * w[:, None] * A[:, None] * S).sum(0) +check("sequence-level IS correction E_mu[(pi/mu) A s] == grad J_pi exactly", maxabs(g_is, g_true), 1e-14) +print(f" ||bias|| / ||grad J|| = {float((g_biased-g_true).norm()/g_true.norm()):.4f} (uncorrected)") +assert float((g_biased - g_true).norm()) > 1e-4 +# %% [markdown] +# ## 3. Truncated IS (TIS, Eq. 6) and masked IS (MIS, Eq. 7): exact bias and bounds +# TIS: E_mu[min(w,C) A s]; bias = - E_mu[(w-C)_+ A s] ||.|| <= M_A M_s sum_o (pi - C mu)_+ <= M_A M_s min(TV, P_pi(w>C)) +# MIS: E_mu[1(w<=C) w A s]; bias = - E_pi[1(w>C) A s] ||.|| <= M_A M_s P_pi(w>C) +# %% +MA, Ms = float(A.abs().max()), float(S.norm(dim=1).max()) +print(" C ||TIS bias|| bound1(sum mu (w-C)+ |A| ||s||) bound2(MA Ms sum(pi-C mu)+) MA Ms TV ||MIS bias|| MA Ms P_pi(w>C)") +for C in (1.0, 1.02, 1.05, 1.2, 2.0): + wt = torch.clamp(w, max=C) + g_tis = (mu[:, None] * wt[:, None] * A[:, None] * S).sum(0) + bias_tis = g_tis - g_true + formula = -((mu * (w - C).clamp(min=0))[:, None] * A[:, None] * S).sum(0) + check(f"TIS bias == -E_mu[(w-C)_+ A s] (C={C})", maxabs(bias_tis, formula), 1e-14) + b1 = float((mu * (w - C).clamp(min=0) * A.abs() * S.norm(dim=1)).sum()) + ex = float((pi - C * mu).clamp(min=0).sum()) + b2 = MA * Ms * ex + assert float(bias_tis.norm()) <= b1 + 1e-15 and b1 <= b2 + 1e-15 and ex <= TVs + 1e-15 + g_mis = (mu[:, None] * (w * (w <= C))[:, None] * A[:, None] * S).sum(0) + bias_mis = g_mis - g_true + mis_formula = -((pi * (w > C))[:, None] * A[:, None] * S).sum(0) + check(f"MIS bias == -E_pi[1(w>C) A s] (C={C})", maxabs(bias_mis, mis_formula), 1e-14) + Pw = float((pi * (w > C)).sum()) + assert float(bias_mis.norm()) <= MA * Ms * Pw + 1e-15 + print(f" {C:4.2f} {float(bias_tis.norm()):12.4e} {b1:12.4e} {b2:12.4e} {MA*Ms*TVs:10.4e} {float(bias_mis.norm()):12.4e} {MA*Ms*Pw:12.4e}") +check("TIS: 0 <= bias <= bound1 <= bound2 <= M_A M_s TV(pi,mu) for all C", 0.0, 0.0) +check("TIS at C=inf is exact (no truncation) -> bias = 0", float((((mu[:, None] * torch.clamp(w, max=1e12)[:, None] * A[:, None] * S).sum(0)) - g_true).norm()), 1e-13) +# TV of sequence distributions <= sum over positions of E[TV_t] (hybrid argument) +def tv_chain(theta, dtype): + tot = 0.0 + V, L = lm.V, lm.L + tok, pos = lm._logits_tables(theta) + def cond(prefix, rounder): + prev = V if len(prefix) == 0 else prefix[-1] + z = tok[prev] + pos[len(prefix)] + if rounder is not None: z = z.to(rounder).to(torch.float64) + return torch.softmax(z, 0) + def rec(prefix, prob): + nonlocal tot + if len(prefix) >= L: return + cp, cm = cond(prefix, None), cond(prefix, dtype) + tot += prob * 0.5 * float((cp - cm).abs().sum()) + for a in range(V - 1): rec(prefix + (a,), prob * float(cp[a])) + rec((), 1.0) + return tot +tvc = tv_chain(theta, torch.bfloat16) +print(f" TV(sequence) = {TVs:.4f} <= sum_t E_pi[TV_t] = {tvc:.4f}") +check("sequence TV <= sum_t E[TV_t] (max violation, must be <= 0)", max(0.0, TVs - tvc), 0.0) +# %% [markdown] +# ## 4. From logit rounding error to log-prob error: d log pi_a = e_a - sum_b pi_b e_b (first order); Var = s^2 (1 - 2 pi_a + sum pi_b^2) for iid logit errors +# %% +gen = torch.Generator().manual_seed(3) +z = torch.randn(7, generator=gen) * 5.0 +pi_z = torch.softmax(z, 0) +eps = torch.randn(7, generator=gen) * 1e-6 +dlp_exact = torch.log_softmax(z + eps, 0) - torch.log_softmax(z, 0) +dlp_lin = eps - (pi_z * eps).sum() +check("first-order log-prob error e_a - sum_b pi_b e_b (relative error of linearisation, e~1e-6)", float((dlp_exact - dlp_lin).abs().max() / dlp_lin.abs().max()), 1e-5) +s_ = 0.01 +E = torch.randn(400000, 7, generator=gen) * s_ +dlp = torch.log_softmax(z + E, -1) - torch.log_softmax(z, 0) +var_mc = dlp.var(0); var_f = s_ ** 2 * (1 - 2 * pi_z + (pi_z ** 2).sum()) +zs = float(((var_mc - var_f).abs() / (var_f * math.sqrt(2 / 400000))).max()) +check("Var(d log pi_a) = s^2 (1 - 2 pi_a + sum pi^2): max z-score over 7 tokens (MC, 400k draws)", zs, 4.0, kind="mc") +# %% [markdown] +# ## 4b. Where the logit error comes from: an LM-head matmul with bf16 inputs vs an fp32 head +# z_b = sum_i h_i W_bi. Rounding h and W to bf16 perturbs each product by a relative error <= u, so the logit error scales with the +# magnitude of the terms (not just of z). Outlier activations (a few huge hidden dims) enlarge that magnitude. +# %% +gen = torch.Generator().manual_seed(4) +d, Vv, Tn = 1024, 64, 512 +def head_errors(outlier): + h = torch.randn(Tn, d, generator=gen) + h[:, :8] *= outlier # 8 high-magnitude hidden dimensions + W = torch.randn(Vv, d, generator=gen) / math.sqrt(d) + z = h @ W.T # fp64 reference + zb = h.to(torch.bfloat16).to(torch.float64) @ W.to(torch.bfloat16).to(torch.float64).T # bf16-rounded inputs, exact accumulation + z32 = h.to(torch.float32).to(torch.float64) @ W.to(torch.float32).to(torch.float64).T # fp32-rounded inputs + return float(z.std()), float((zb - z).std()), float((z32 - z).std()) +print(" outlier x std(z) std(err bf16) std(err fp32) err_bf16/std(z) /u_bf16 ratio bf16/fp32") +for out in (1.0, 10.0, 100.0): + sz_, eb, e32 = head_errors(out) + print(f" {out:7.0f} {sz_:8.2f} {eb:12.3e} {e32:12.3e} {eb/sz_:10.3e} {eb/sz_/2**-8:6.3f} {eb/e32:9.1f}") +sz_, eb, e32 = head_errors(100.0) +check("bf16 head: logit error / std(z) between 0.05u and 1u (u=2^-8)", 0.0 if 0.05 * 2**-8 < eb / sz_ < 2**-8 else 1.0, 0.0) +check("fp32 head cuts the logit error by ~ u_bf16/u_fp32 = 65536 (log10 ratio within 1 decade)", abs(math.log10(eb / e32) - math.log10(2**16)), 1.0) +# %% [markdown] +# ## 5. Long horizons: positions are independent contexts, so everything is EXACT: +# E_mu[log w_T] = -sum_t KL(mu_t||pi_t), Var(log w_T) = sum_t Var_t, E_mu[w_T^2] = prod_t (1 + chi2_t). (V=64, logits ~ N(0, sigma_z^2)) +# %% +def per_position_stats(sigma_z, dtype, T=4096, V=64, seed=0): + g = torch.Generator().manual_seed(seed) + z = torch.randn(T, V, generator=g) * sigma_z + pi = torch.softmax(z, -1) + zr = z if dtype is None else z.to(dtype).to(torch.float64) + mu = torch.softmax(zr, -1) + lw = pi.log() - mu.log() # log(pi/mu) per token option + Elw = (mu * lw).sum(-1) # E_mu[log w_t] = -KL(mu||pi) + Vlw = (mu * lw ** 2).sum(-1) - Elw ** 2 + chi2 = (mu * (pi / mu - 1) ** 2).sum(-1) + return Elw, Vlw, chi2, pi, mu +print(" sigma_z head mean KL/token mean chi2/token T=100: ESS frac T=1000 T=4000 (ESS frac = 1/prod(1+chi2))") +rows = {} +for sz in (2.0, 5.0, 10.0, 20.0): + for name, dt in (("bf16", torch.bfloat16), ("fp16", torch.float16), ("fp32", torch.float32)): + Elw, Vlw, chi2, pi_t, mu_t = per_position_stats(sz, dt) + lESS = lambda T: float(-torch.log1p(chi2[:T]).sum()) + rows[(sz, name)] = (float((-Elw).mean()), float(chi2.mean()), lESS(100), lESS(1000), lESS(4000)) + print(f" {sz:5.1f} {name} {float((-Elw).mean()):14.3e} {float(chi2.mean()):14.3e} {math.exp(lESS(100)):10.3e} {math.exp(lESS(1000)):10.3e} {math.exp(lESS(4000)):10.3e}") +# why chi2 does not grow like sigma_z^2: to first order chi2_t = Var_{a~pi}(e_a) where e = round(z) - z (the *centred* logit error) +def first_order_check(sz, dtype, u, T=2048, V=64): + g_ = torch.Generator().manual_seed(0) + z_ = torch.randn(T, V, generator=g_) * sz + e_ = z_.to(dtype).to(torch.float64) - z_ + pi_ = torch.softmax(z_, -1); mu_ = torch.softmax(z_ + e_, -1) + chi2_ = (mu_ * (pi_ / mu_ - 1) ** 2).sum(-1) + var_e = (pi_ * e_ ** 2).sum(-1) - ((pi_ * e_).sum(-1)) ** 2 + bound = u ** 2 * (pi_ * z_ ** 2).sum(-1) + return float(chi2_.sum() / var_e.sum()), float((var_e / bound).max()), float(chi2_.mean()) +for sz in (2.0, 10.0, 20.0): + ratio, bnd, c2 = first_order_check(sz, torch.bfloat16, 2.0 ** -8) + print(f" sigma_z={sz:5.1f}: chi2/Var_pi(e) = {ratio:.4f} max Var_pi(e)/(u^2 E_pi z^2) = {bnd:.3f}") + check(f"first-order law chi2_t = Var_pi(logit rounding error) (sigma_z={sz}, mean ratio - 1)", abs(ratio - 1), 0.1) + check(f"bound Var_pi(e) <= u^2 E_pi[z^2] (sigma_z={sz}, max ratio - 1 must be <= 0)", max(0.0, bnd - 1), 0.0) +szs = np.array([2.0, 5.0, 10.0, 20.0]); chis = np.array([rows[(s, 'bf16')][1] for s in szs]) +slope = np.polyfit(np.log(szs), np.log(chis), 1)[0] +print(f" bf16 log-log slope of chi2/token vs logit scale: {slope:.3f} (between 1 and 2: the error grows like |z| but the centred error Var_pi(e) shrinks as pi becomes peaked)") +assert 1.0 < slope < 2.0 +ratio_fp32 = rows[(10.0, 'fp32')][1] / rows[(10.0, 'bf16')][1] +print(f" fp32/bf16 chi2 ratio at sigma_z=10: {ratio_fp32:.2e} (theory (u32/u16)^2 = {(2.0**-24/2.0**-8)**2:.2e})") +check("fp32 head reduces per-token chi^2 by ~ (u_fp32/u_bf16)^2 (|log10 ratio - log10 theory|)", abs(math.log10(ratio_fp32) - math.log10((2.0 ** -24 / 2.0 ** -8) ** 2)), 1.0) +# MC check of one exact statistic: Var(log w_T) over sampled sequences for T=64 +Elw, Vlw, chi2, pi_t, mu_t = per_position_stats(10.0, torch.bfloat16, T=64) +g = torch.Generator().manual_seed(9); n = 20000 +tokens = torch.stack([torch.multinomial(mu_t[t], n, replacement=True, generator=g) for t in range(64)], 1) # [n, T] +lw_seq = (pi_t.log().gather(1, tokens.T).T - mu_t.log().gather(1, tokens.T).T).sum(1) +exact_mean, exact_var = float(Elw.sum()), float(Vlw.sum()) +se_mean = math.sqrt(exact_var / n) +check("MC: E_mu[log w_64] == -sum KL (z-score)", abs(float(lw_seq.mean()) - exact_mean) / se_mean, 4.0, kind="mc") +check("MC: Var(log w_64) == sum_t Var_t (z-score; kurtosis ignored -> loose 6)", abs(float(lw_seq.var()) - exact_var) / (exact_var * math.sqrt(2 / n)), 6.0, kind="mc") +check("E_mu[w_T] = 1 exactly per position (sum_a pi_a = 1)", float((mu_t * (pi_t / mu_t)).sum(-1).sub(1).abs().max()), 1e-13) +# %% [markdown] +# ## 5b. Caveat: E_mu[w_T^2] = prod_t (1 + chi2_t) needs history-INDEPENDENT chi2_t. With state-dependent chi2 the exact statements are +# (i) the recursion E[w_T^2] = E[w_{T-1}^2 (1 + chi2_T(s_T))], (ii) the martingale identity E[ w_T^2 / prod_t (1 + chi2_t(s_t)) ] = 1, +# and E[prod_t (1 + chi2_t(s_t))] is NOT E[w_T^2]. Exact enumeration on a tiny autoregressive model (sampler mu = one weight vector, trainer pi = a perturbed one). +# %% +lmx = TinyLM(V=3, L=8, seed=4, scale=1.0) +tokx = lmx.theta0[:lmx.n_tok].reshape(lmx.V + 1, lmx.V); tokx[:, lmx.EOS] -= 2.0 +th_mu = lmx.theta0.clone(); th_pi = perturb(th_mu, 0.8, seed=9) +lmu, lpi = lmx.token_logprobs(th_mu), lmx.token_logprobs(th_pi) +mu_x = lmu.sum(1).exp() +chi_x = torch.zeros(lmx.N, lmx.L) # chi2_t(s_t) = chi2(pi(.|s_t) || mu(.|s_t)) at the realised prefix +for i, sq in enumerate(lmx.seqs): + for t in range(len(sq)): + cm, cp = lmx.cond_probs(th_mu, sq[:t]), lmx.cond_probs(th_pi, sq[:t]) + chi_x[i, t] = ((cp - cm) ** 2 / cm).sum() +w2_x = (lpi - lmu).sum(1).exp() ** 2 +prod_x = (1 + chi_x).prod(1) # masked positions have chi2 = 0 -> factor 1 +Ew2 = float((mu_x * w2_x).sum()); Eprod = float((mu_x * prod_x).sum()) +w2_prev = torch.cat([torch.zeros(lmx.N, 1), (lpi - lmu).cumsum(1)[:, :-1]], 1).exp() ** 2 # w_{t-1}^2 (w_0 = 1) +rec = float((mu_x * w2_prev[:, -1] * (1 + chi_x[:, -1])).sum()) +print(f" N={lmx.N}: E_mu[w^2] = {Ew2:.6f}; recursion E[w_(T-1)^2 (1+chi2_T)] = {rec:.6f}; E_mu[prod_t(1+chi2_t)] = {Eprod:.6f}; E_mu[w^2/prod] = {float((mu_x*w2_x/prod_x).sum()):.6f}") +check("recursion E[w_T^2] = E[w_(T-1)^2 (1+chi2_T(s_T))] (state-dependent chi2)", abs(Ew2 - rec), 1e-14) +check("martingale identity E[w_T^2 / prod_t (1+chi2_t(s_t))] = 1", abs(float((mu_x * w2_x / prod_x).sum()) - 1.0), 1e-13) +assert abs(Ew2 - Eprod) > 1e-3 # the naive product formula is genuinely wrong when chi2 depends on the history +check("E[prod(1+chi2_t)] != E[w_T^2] on a state-dependent model (relative gap, must exceed 1e-3)", max(0.0, 1e-3 - abs(Ew2 - Eprod) / Ew2), 0.0) +# %% [markdown] +# ## 6. Consequence for PPO/CISPO ratios and for a "probability correlation" diagnostic +# If the *sampler's* log-probs are used as pi_old, then at theta = theta_old the ratio is r = pi/mu != 1: tokens are clipped before any update. +# %% +for name, dt in (("bf16", torch.bfloat16), ("fp32", torch.float32)): + Elw, Vlw, chi2, pi_t, mu_t = per_position_stats(10.0, dt, T=2048) + gen = torch.Generator().manual_seed(1) + tok_ = torch.stack([torch.multinomial(mu_t[t], 64, replacement=True, generator=gen) for t in range(2048)], 0) # [T, 64] + r = (pi_t.gather(1, tok_) / mu_t.gather(1, tok_)) + out = float(((r - 1).abs() > 0.2).double().mean()) + p_tr, p_inf = pi_t.gather(1, tok_).reshape(-1), mu_t.gather(1, tok_).reshape(-1) + corr = float(torch.corrcoef(torch.stack([p_tr, p_inf]))[0, 1]) + print(f" {name} head: fraction of sampled tokens with |pi/mu - 1| > 0.2 at theta=theta_old: {out:.4f} Pearson corr(p_train, p_sampler) = {corr:.6f}") +# %% [markdown] +# ## 7. Naive vs stable weight computation under mismatch (log-space) +# %% +Elw, Vlw, chi2, pi_t, mu_t = per_position_stats(10.0, torch.bfloat16, T=4096) +tokens = torch.stack([torch.multinomial(mu_t[t], 1, replacement=True, generator=g) for t in range(4096)], 0).squeeze(1) +lr_ = (pi_t.log().gather(1, tokens[:, None]) - mu_t.log().gather(1, tokens[:, None])).squeeze(1) +logw = float(lr_.sum()) +naive32 = torch.tensor(1.0, dtype=torch.float32) +for v in lr_.exp().to(torch.float32): naive32 = naive32 * v +print(f" T=4096 bf16-mismatch sequence: log w = {logw:.2f}; naive fp32 running product = {float(naive32)}; exp(sum log) in fp64 = {math.exp(logw):.3e}") +check("naive fp32 product vs log-space: agree only when representable (log w within fp32 range)", 0.0 if (abs(logw) > 80 or abs(float(naive32) - math.exp(logw)) < 1e-4 * math.exp(logw)) else 1.0, 0.0) +# %% [markdown] +# ## 8. Naive vs efficient TIS-corrected gradient estimate (sampled from the bf16 sampler mu) +# naive: one backward per sample; efficient: one backward through the surrogate with DETACHED truncated weights. +# %% +lm = TinyLM(V=4, L=4, seed=0, scale=4.0) +theta = lm.theta0.clone(); R = default_reward(lm) +pi_ = seq_logprob_rounded(theta, None).exp(); mu_ = seq_logprob_rounded(theta, torch.bfloat16).exp() +bb = float(pi_ @ R) +gen = torch.Generator().manual_seed(6) +idx = torch.multinomial(mu_, 500, replacement=True, generator=gen) +Cc = 1.02 +w_ = (pi_ / mu_)[idx] +acc = torch.zeros_like(theta) +for j, i in enumerate(idx.tolist()): + t = theta.clone().requires_grad_(True) + acc += float(min(w_[j], Cc)) * float(R[i] - bb) * torch.autograd.grad(seq_logprob_rounded(t, None)[i], t)[0] +g_naive = acc / len(idx) +t = theta.clone().requires_grad_(True) +sur = (torch.clamp(w_, max=Cc).detach() * (R[idx] - bb) * seq_logprob_rounded(t, None)[idx]).mean() +g_eff = torch.autograd.grad(sur, t)[0] +check("TIS estimate: per-sample loop == single backward through detached-weight surrogate", maxabs(g_naive, g_eff), 1e-13) +print("done; checks:", len(CHECKS)) diff --git a/code/notebooks/_m1_src/0109-kl-estimators.py b/code/notebooks/_m1_src/0109-kl-estimators.py new file mode 100644 index 0000000000000000000000000000000000000000..69a7108d866c8f1f97a9d18d4a9b9363270664ee --- /dev/null +++ b/code/notebooks/_m1_src/0109-kl-estimators.py @@ -0,0 +1,204 @@ +# %% [markdown] +# # 0109 - KL regularisation estimators k1, k2, k3: value bias/variance, what their GRADIENTS optimise, why beta = 0 +# Companion to `notes/0109 - KL Estimators.pdf`. Samples x ~ q = pi_theta, p = pi_ref, r = p/q, KL(q||p) = E_q[-log r]. +# %% +import sys, math +sys.path.insert(0, "..") +from _m1_lib import * +import numpy as np +# %% [markdown] +# ## 1. Values. E_q[k1] = E_q[k3] = KL(q||p); E_q[k2] = KL + bias, bias = -sum_{n>=3} E[l^n]/n!, l = log r +# k1 = -log r, k2 = (log r)^2 / 2, k3 = (r-1) - log r (Schulman, "Approximating KL divergence") +# %% +def estimators(logr): + return -logr, 0.5 * logr ** 2, torch.expm1(logr) - logr +gen = torch.Generator().manual_seed(2) +for trial in range(3): + K = 6 + q = torch.softmax(torch.randn(K, generator=gen) * 1.2, 0); p = torch.softmax(torch.randn(K, generator=gen) * 1.2, 0) + logr = (p / q).log() + kl = float((q * (q / p).log()).sum()) + k1, k2, k3 = estimators(logr) + check(f"categorical #{trial}: E_q[k1] = KL(q||p)", abs(float((q * k1).sum()) - kl), 1e-14) + check(f"categorical #{trial}: E_q[k3] = KL(q||p)", abs(float((q * k3).sum()) - kl), 1e-14) + bias2 = float((q * k2).sum()) - kl + series = -sum(float((q * logr ** n).sum()) / math.factorial(n) for n in range(3, 40)) + check(f"categorical #{trial}: bias(k2) = -sum_(n>=3) E[l^n]/n! (series, 40 terms)", abs(bias2 - series), 1e-12) + # the master identity KL = sum_{n>=2} E[l^n]/n! (from E_q[e^l] = 1) + ms = sum(float((q * logr ** n).sum()) / math.factorial(n) for n in range(2, 40)) + check(f"categorical #{trial}: KL = sum_(n>=2) E_q[l^n]/n!", abs(ms - kl), 1e-12) + print(f" KL = {kl:.5f} E[k2] - KL = {bias2:+.5f} (leading term -E[l^3]/6 = {-float((q*logr**3).sum())/6:+.5f})") +# pointwise positivity +rg = torch.exp(torch.linspace(-12, 12, 100001)) +k1g, k2g, k3g = estimators(rg.log()) +check("k3 >= 0 and k2 >= 0 pointwise (min over a log-grid of r)", max(0.0, -float(min(k3g.min(), k2g.min()))), 0.0) +print(f" k1 < 0 on r > 1 (fraction of log-grid where k1<0: {float((k1g<0).double().mean()):.3f}) -> k1 is a noisy, sign-changing estimator") +# %% [markdown] +# ## 2. Gaussian closed forms (equal variance, shift mu => KL = mu^2/2). With s2 = mu^2: +# Var(k1) = s2, Var(k2) = s2^3/4 + s2^2/2, Var(k3) = e^{s2} - 1 - s2, bias(k2) = s2^2/8. Verified by Gauss-Hermite quadrature. +# %% +xs, ws = np.polynomial.hermite_e.hermegauss(120); ws = ws / ws.sum() +def gauss_moments(shift): + x = torch.tensor(xs); w = torch.tensor(ws) + logr = shift * x - 0.5 * shift ** 2 # q = N(0,1), p = N(shift,1): log p/q = shift*x - shift^2/2 (E_q[r] = 1) + k = estimators(logr) + kl = shift ** 2 / 2 + out = [] + for kk in k: + m = float((w * kk).sum()); v = float((w * kk ** 2).sum()) - m ** 2 + out.append((m, v)) + return kl, out +print(" KL est mean-KL (bias) std/KL closed-form std/KL") +for shift in (0.1, math.sqrt(2 * 0.05), 1.0): + kl, out = gauss_moments(shift) + s2 = shift ** 2 + cf = [s2, s2 ** 3 / 4 + s2 ** 2 / 2, math.exp(s2) - 1 - s2] + cb = [0.0, s2 ** 2 / 8, 0.0] + for i, nm in enumerate(("k1", "k2", "k3")): + m, v = out[i] + print(f" {kl:.4f} {nm} {m-kl:+.3e} {math.sqrt(v)/kl:9.4f} {math.sqrt(cf[i])/kl:9.4f}") + check(f"Gaussian shift={shift:.3f}: {nm} bias == closed form", abs((m - kl) - cb[i]), 1e-12) + check(f"Gaussian shift={shift:.3f}: {nm} variance == closed form", abs(v - cf[i]) / cf[i], 1e-10) +# compare with the table in Schulman's note (KL=0.005 and KL=0.5): std/KL = 20, 1.42, 1.42 and 2, 1.73, 1.7 ; bias(k2)/KL = 0.002, 0.25 +kl, out = gauss_moments(0.1) +check("matches Schulman's table at KL=0.005: std(k1)/KL ~ 20", abs(math.sqrt(out[0][1]) / kl - 20.0), 0.2) +kl, out = gauss_moments(1.0) +check("matches Schulman's table at KL=0.5: bias(k2)/KL = 0.25", abs((out[1][0] - kl) / kl - 0.25), 1e-9) +# %% [markdown] +# ## 3. Heavy tails: Var(k3) is infinite when chi^2(p||q) is; one token the reference loves and the policy nearly never samples +# %% +q = torch.tensor([0.001, 0.999]); p = torch.tensor([0.30, 0.70]) +logr = (p / q).log(); k1, k2, k3 = estimators(logr) +kl = float((q * (q / p).log()).sum()) +for nm, k in (("k1", k1), ("k2", k2), ("k3", k3)): + m = float((q * k).sum()); v = float((q * k ** 2).sum()) - m ** 2 + print(f" {nm}: mean {m:+.5f} std {math.sqrt(v):.5f} (KL = {kl:.5f}) max value {float(k.max()):.1f}") +chi2_pq = float((p ** 2 / q).sum() - 1) +print(f" chi2(p||q) = E_q[r^2]-1 = {chi2_pq:.1f} -> Var(k3) is dominated by one rare token with r = {float((p/q)[0]):.0f}") +check("E_q[r^2] - 1 = chi2(p||q) exactly", abs(float((q * (p / q) ** 2).sum()) - 1 - chi2_pq), 1e-9) +# %% [markdown] +# ## 4. GRADIENTS (on-policy, single-step categorical policy with logits theta; samples x ~ q_theta DETACHED, gradient flows through q_theta in r) +# k1-as-loss: E[grad k1] = E[grad log q] = 0 -> useless as a regulariser +# k2-as-loss: E[grad k2] = E[(log q - log p) grad log q] = grad KL(q||p) (the reverse KL we meant) +# k3-as-loss: E[grad k3] = E[(1 - r) grad log q] = -sum_x p grad log q = grad KL(p||q) (the FORWARD KL) +# k1-in-reward (detached coefficient): E[(log q - log p) grad log q] = grad KL(q||p) +# %% +gen = torch.Generator().manual_seed(7) +K = 6 +th_ref = torch.randn(K, generator=gen) * 1.0 +def grads(delta): + th = (th_ref + delta * torch.randn(K, generator=gen)).clone().requires_grad_(True) + q = torch.softmax(th, 0); p = torch.softmax(th_ref, 0) + lq, lp = torch.log_softmax(th, 0), torch.log_softmax(th_ref, 0) + qd = q.detach() + r = (lp - lq).exp() + # expectation over x ~ q (weights detached) of the gradient of each estimator + g = {} + g['k1'] = torch.autograd.grad((qd * (-(lp - lq))).sum(), th, retain_graph=True)[0] + g['k2'] = torch.autograd.grad((qd * 0.5 * (lp - lq) ** 2).sum(), th, retain_graph=True)[0] + g['k3'] = torch.autograd.grad((qd * (r - 1 - (lp - lq))).sum(), th, retain_graph=True)[0] + # k1 in reward: detached coefficient (log q - log p) times score + g['k1r'] = torch.autograd.grad((qd * (lq - lp).detach() * lq).sum(), th, retain_graph=True)[0] + g['rev'] = torch.autograd.grad((q * (lq - lp)).sum(), th, retain_graph=True)[0] # grad KL(q||p), exact + g['fwd'] = torch.autograd.grad((p * (lp - lq)).sum(), th, retain_graph=True)[0] # grad KL(p||q), exact + return g +g = grads(0.8) +check("E[grad k1] = 0 (k1 as a loss carries no regularising gradient)", float(g['k1'].abs().max()), 1e-14) +check("E[grad k2] == grad KL(q||p) (reverse KL)", maxabs(g['k2'], g['rev']), 1e-14) +check("k1-in-reward (detached coefficient) == grad KL(q||p)", maxabs(g['k1r'], g['rev']), 1e-14) +check("E[grad k3] == grad KL(p||q) (FORWARD KL, not the one k3 estimates)", maxabs(g['k3'], g['fwd']), 1e-14) +print(f" ||grad KL(q||p) - grad KL(p||q)|| / ||grad KL(q||p)|| = {float((g['rev']-g['fwd']).norm()/g['rev'].norm()):.3f} (different vectors)") +print(" relative gap between the two gradients as q -> p (first-order agreement, 'k3 is a first-order approximation'):") +prev = None +for delta in (0.4, 0.2, 0.1, 0.05, 0.025): + gen.manual_seed(7); _ = torch.randn(K, generator=gen) # same direction each time + gg = grads(delta) + gap = float((gg['rev'] - gg['fwd']).norm() / gg['rev'].norm()) + print(f" delta={delta:5.3f} gap = {gap:.4f}") + prev = gap +assert prev < 0.05 +check("gradient gap forward vs reverse KL vanishes as delta -> 0 (gap at delta=0.025 < 5%)", 0.0, 0.0) +# %% [markdown] +# ## 5. SEQUENCES: per-token k2/k3 'as loss' ignore that earlier tokens change the later contexts +# true: grad KL_seq(pi||ref) = E[ (sum_t l_t) * score(o) ] = E[ sum_t (sum_{t'>=t} l_t') s_t ] ("k1 in reward" with reward-to-go) +# per-token k2 loss, summed over t: E[ sum_t l_t s_t ] = true - sum_t E[ KL_t(s_t) * score(o_=t} l_t') s_t] == grad KL_seq", maxabs(g_rtg, g_true), 1e-14) +print(f" per-token k2-as-loss gradient vs true sequence-KL gradient: relative error {float((g_tok-g_true).norm()/g_true.norm()):.4f}") +# the missing term: sum_t E[ KL_t(s_t) * score(o_= 1e-4 (fp32)", float(((stable.double() - truth).abs() / truth)[:4].max()), 1e-3) +assert float(((naive.double() - truth).abs() / truth)[3]) > 0.1 +check("naive form is wrong by >10% at |log r| = 1e-4 (fp32)", 0.0, 0.0) +# The expm1 form removes the cancellation inside exp(x)-1 but NOT the one in expm1(x) - x ~ x^2/2: its worst-case relative error is ~ 2u/|x|. +# Worst case over a dense random grid per decade (fp32, both signs), vs the 2u/|x|_min law; and a series fallback for small |x|. +u32 = 2.0 ** -24 +gen_n = torch.Generator().manual_seed(11) +def k3_expm1(x): return torch.expm1(x) - x +def k3_hybrid(x): + ser = x * x / 2 * (1 + x / 3 + x * x / 12) # Taylor of expm1(x)-x, error ~ x^5/120 relative x^3/60 + return torch.where(x.abs() < 1e-2, ser, k3_expm1(x)) +print(" |x| decade worst rel.err expm1 form 2u/|x|min worst rel.err hybrid (series for |x|<1e-2)") +worst_ratio, worst_hybrid = 0.0, 0.0 +for lo in (1e-1, 1e-2, 1e-3, 1e-4, 1e-5, 1e-6): + hi = 10 * lo + xg = torch.exp(torch.rand(400_000, generator=gen_n, dtype=torch.float64) * math.log(10) + math.log(lo)).float() + xg = torch.cat([xg, -xg]); tr = torch.expm1(xg.double()) - xg.double() + re = float(((k3_expm1(xg).double() - tr).abs() / tr).max()); rh = float(((k3_hybrid(xg).double() - tr).abs() / tr).max()) + law = 2 * u32 / lo + worst_ratio = max(worst_ratio, re / law); worst_hybrid = max(worst_hybrid, rh) + print(f" [{lo:.0e},{hi:.0e}] {re:12.3e} {law:10.2e} {rh:12.3e}") +check("expm1 form: worst-case relative error <= 2u/|x| (max ratio to the law)", worst_ratio, 1.0) +check("hybrid (Taylor for |x|<1e-2, expm1 form above): worst relative error over |x| in [1e-6, 1)", worst_hybrid, 1e-4) +print("done; checks:", len(CHECKS)) diff --git a/code/notebooks/_m1_src/0110-reward-shaping-verifier.py b/code/notebooks/_m1_src/0110-reward-shaping-verifier.py new file mode 100644 index 0000000000000000000000000000000000000000..f6f730afc1d09716211a5f0a6dc31fd295bc7224 --- /dev/null +++ b/code/notebooks/_m1_src/0110-reward-shaping-verifier.py @@ -0,0 +1,188 @@ +# %% [markdown] +# # 0110 - Reward shaping for verifier rewards: binary rewards, dead groups, overlong penalties as reward, verifier errors +# Companion to `notes/0110 - Reward Shaping for Verifier Rewards.pdf`. fp64; exact enumeration on the 121-sequence toy LM. +# %% +import sys, math, itertools +sys.path.insert(0, "..") +from _m1_lib import * +import numpy as np +lm = TinyLM(V=4, L=4, seed=0, scale=1.0) +theta = lm.theta0.clone() +R = default_reward(lm) +p_seq = lm.probs(theta) +S = score_matrix(lm, theta) +N = lm.N +th = theta.clone().requires_grad_(True) +pr = (lm.probs(th) * R).sum() +grad_p = torch.autograd.grad(pr, th)[0] +p = float(pr.detach()) +# %% [markdown] +# ## 1. Binary reward: grad p = p (1-p) ( E[score | success] - E[score | failure] ) +# %% +m1 = (p_seq * R)[:, None] * S +m0 = (p_seq * (1 - R))[:, None] * S +E_s_succ = m1.sum(0) / p; E_s_fail = m0.sum(0) / (1 - p) +check("grad p = p (1-p) (E[s|succ] - E[s|fail])", maxabs(grad_p, p * (1 - p) * (E_s_succ - E_s_fail)), 1e-14) +check("grad p = p E[s | success] (rejection-sampling / expert-iteration form)", maxabs(grad_p, p * E_s_succ), 1e-14) +print(f" p = {p:.4f}; signal scale p(1-p) = {p*(1-p):.4f}") +# %% [markdown] +# ## 2. Dead groups. Binary bandit with logit parameter: the group estimators collapse to functions of p_hat = k/G +# centred (Dr.GRPO): g = p_hat (1 - p_hat) (plug-in variance of the rewards); std-normalised (GRPO): g = sqrt(p_hat (1 - p_hat)) +# %% +def bern_group(G, pp): + ks = np.arange(G + 1) + w = np.array([math.comb(G, k) * pp ** k * (1 - pp) ** (G - k) for k in ks]) + ph = ks / G + return ks, w, ph +def direct_estimator(G, k, pp, kind): + """(1/G) sum_i A_i * s_i with s = d log pi / d logit: success -> (1-p), failure -> -p (computed from the definitions)""" + Rg = torch.cat([torch.ones(k), torch.zeros(G - k)]) + mu = Rg.mean(); sd = Rg.std(unbiased=False) + A = Rg - mu if kind == 'centre' else (torch.zeros_like(Rg) if float(sd) == 0 else (Rg - mu) / sd) + s = torch.where(Rg > 0, torch.tensor(1 - pp), torch.tensor(-pp)) + return float((A * s).mean()) +worst = 0.0 +for G in (2, 4, 16): + for pp in (0.05, 0.3, 0.7): + ks, w, ph = bern_group(G, pp) + for k in ks: + worst = max(worst, abs(direct_estimator(G, int(k), pp, 'centre') - ph[k] * (1 - ph[k])), + abs(direct_estimator(G, int(k), pp, 'std') - math.sqrt(ph[k] * (1 - ph[k])))) +check("bandit: centred estimator == p_hat(1-p_hat), std-normalised == sqrt(p_hat(1-p_hat)) (all k, 9 settings)", worst, 1e-14) +# exact mean / variance / SNR of the estimator as a function of p +print(" G=16: p P(dead) E[g_centred] (1-1/G)p(1-p) SNR=E^2/Var E[g_std]/(p(1-p)) vs c_G(p)") +def c_G_formula(G, pp): # from note 0105 + bp = lambda n, k: math.comb(n, k) * pp ** k * (1 - pp) ** (n - k) + a1 = sum(bp(G - 1, K) * (math.sqrt((G - (1 + K)) / (1 + K)) if 1 + K < G else 0.0) for K in range(G)) + a0 = sum(bp(G - 1, K) * (-math.sqrt(K / (G - K)) if 1 <= K < G else 0.0) for K in range(G)) + return a1 - a0 +G = 16 +snr = {} +for pp in (0.01, 0.05, 0.2, 0.5, 0.8, 0.95, 0.99): + ks, w, ph = bern_group(G, pp) + g = ph * (1 - ph); gs = np.sqrt(ph * (1 - ph)) + mean, var = float((w * g).sum()), float((w * g ** 2).sum() - (w * g).sum() ** 2) + pdead = pp ** G + (1 - pp) ** G + mean_std = float((w * gs).sum()) + snr[pp] = mean ** 2 / var + print(f" {pp:5.2f} {pdead:7.4f} {mean:10.6f} {(1-1/G)*pp*(1-pp):10.6f} {snr[pp]:8.3f} {mean_std/(pp*(1-pp)):7.4f} vs {c_G_formula(G, pp):7.4f}") + check(f"E[g_centred] = (1-1/G) p(1-p) (p={pp})", abs(mean - (1 - 1 / G) * pp * (1 - pp)), 1e-15) + check(f"E[sqrt(p_hat(1-p_hat))] / (p(1-p)) == c_G(p) of note 0105 (p={pp})", abs(mean_std / (pp * (1 - pp)) - c_G_formula(G, pp)), 1e-12) + check(f"P(dead) = p^G + (1-p)^G = mass of k in {{0,G}} (p={pp})", abs(pdead - (w[0] + w[-1])), 1e-15) +assert snr[0.5] > snr[0.2] > snr[0.05] > snr[0.01] and snr[0.5] > snr[0.8] > snr[0.95] > snr[0.99] +check("SNR of the group estimator peaks at p = 1/2 and decays towards both ends (monotone on each side)", 0.0, 0.0) +# dead groups carry exactly zero gradient for every baseline estimator; none-baseline REINFORCE does not +Gs = 4 +dead_succ = [int(np.argmax(R.numpy()))] * 0 + [i for i in range(N) if R[i] == 1][:Gs] # an all-correct group (4 successful sequences) +dead_fail = [i for i in range(N) if R[i] == 0][:Gs] +def group_gradient(idx, kind): + Rg = R[idx] + if kind == 'centre': A = Rg - Rg.mean() + elif kind == 'std': + sd = Rg.std(unbiased=False); A = torch.zeros_like(Rg) if float(sd) == 0 else (Rg - Rg.mean()) / sd + elif kind == 'rloo': A = Rg - (Rg.sum() - Rg) / (len(Rg) - 1) + elif kind == 'plain01': A = Rg # REINFORCE, no baseline, R in {0,1} + elif kind == 'plainpm': A = 2 * Rg - 1 # REINFORCE, no baseline, R in {-1,+1} + return (A[:, None] * S[idx]).mean(0) +print(" ||group gradient|| for dead groups (4 samples):") +for kind in ('centre', 'std', 'rloo', 'plain01', 'plainpm'): + print(f" {kind:8s} all-correct: {float(group_gradient(dead_succ, kind).norm()):.4f} all-wrong: {float(group_gradient(dead_fail, kind).norm()):.4f}") +for kind in ('centre', 'std', 'rloo'): + check(f"dead groups have exactly zero gradient ({kind})", max(float(group_gradient(dead_succ, kind).abs().max()), float(group_gradient(dead_fail, kind).abs().max())), 0.0) +assert float(group_gradient(dead_succ, 'plain01').norm()) > 1e-3 and float(group_gradient(dead_fail, 'plainpm').norm()) > 1e-3 +check("without a baseline an all-correct (R in {0,1}) or all-wrong (R in {-1,1}) group still moves the policy", 0.0, 0.0) +# %% [markdown] +# ## 3. Reward scale / sign conventions +# %% +gen = torch.Generator().manual_seed(1) +idx = torch.multinomial(p_seq, 8, replacement=True, generator=gen) +for kind in ('std',): + g01 = group_gradient(idx, kind) + Rpm_idx = idx + Rg = 2 * R[idx] - 1 + sd = Rg.std(unbiased=False) + gpm = (((Rg - Rg.mean()) / sd)[:, None] * S[idx]).mean(0) if float(sd) > 0 else torch.zeros_like(g01) + check("GRPO (std-normalised): {0,1} and {-1,+1} rewards give the SAME gradient", maxabs(g01, gpm), 1e-13) +g01 = group_gradient(idx, 'centre') +Rg = 2 * R[idx] - 1 +gpm = ((Rg - Rg.mean())[:, None] * S[idx]).mean(0) +check("Dr.GRPO (centred): {-1,+1} rewards give exactly 2x the {0,1} gradient", maxabs(gpm, 2 * g01), 1e-13) +# %% [markdown] +# ## 4. Potential-based shaping (finite horizon, gamma = 1): r_t = Phi(s_{t+1}) - Phi(s_t), Phi(terminal) = 0 => shaped return = R - Phi(s_0) => same gradient +# %% +gen = torch.Generator().manual_seed(3) +Phi = {} +for pre in lm.prefixes(): Phi[pre] = float(torch.randn(1, generator=gen)) * 2.0 +for sq in lm.seqs: + Phi.setdefault(sq, 0.0) # terminal states: Phi = 0 +shaping = torch.zeros(lm.N) +for i, sq in enumerate(lm.seqs): + tot = 0.0 + for t in range(len(sq)): + a, b = sq[:t], sq[:t + 1] + tot += Phi[b] - Phi[a] + shaping[i] = tot +check("telescoping: sum_t [Phi(s_t+1) - Phi(s_t)] = -Phi(s_0) for every sequence (Phi(terminal)=0)", float((shaping + Phi[()]).abs().max()), 1e-14) +th = theta.clone().requires_grad_(True) +g_shaped = torch.autograd.grad((lm.probs(th) * (R + shaping)).sum(), th)[0] +check("potential-based shaping leaves grad J unchanged (exact)", maxabs(g_shaped, grad_p), 1e-14) +# a non-potential 'shaping' (terminal potential != 0, e.g. depending on length) changes the objective +Phi_bad = dict(Phi) +for sq in lm.seqs: Phi_bad[sq] = 0.7 * len(sq) +bad = torch.zeros(lm.N) +for i, sq in enumerate(lm.seqs): + bad[i] = sum(Phi_bad[sq[:t + 1]] - Phi_bad[sq[:t]] for t in range(len(sq))) +th = theta.clone().requires_grad_(True) +g_bad = torch.autograd.grad((lm.probs(th) * (R + bad)).sum(), th)[0] +print(f" terminal potential 0.7*|o|: ||grad - grad J|| = {float((g_bad-grad_p).norm()):.4f} (||grad J|| = {float(grad_p.norm()):.4f})") +assert float((g_bad - grad_p).norm()) > 1e-3 +check("a terminal potential (length-dependent) is NOT neutral: the gradient changes", 0.0, 0.0) +# %% [markdown] +# ## 5. Length penalties: as REWARD the objective is separable; length has no other route to the policy +# %% +lens = lm.lengths +lam = 0.05 +b = float(p_seq @ R) +g_rew = (p_seq[:, None] * (R - lam * lens - (b - lam * float(p_seq @ lens)))[:, None] * S).sum(0) # baselined REINFORCE with shaped reward +th = theta.clone().requires_grad_(True) +gJ = torch.autograd.grad((lm.probs(th) * R).sum(), th)[0] +th = theta.clone().requires_grad_(True) +gL = torch.autograd.grad((lm.probs(th) * lens).sum(), th)[0] +check("reward R - lam|o| : gradient = grad E[R] - lam grad E|o| (separable, exact)", maxabs(g_rew, gJ - lam * gL), 1e-14) +check("grad E|o| = E[|o| score] (length can only reach the policy through the score function)", maxabs(gL, (p_seq[:, None] * lens[:, None] * S).sum(0)), 1e-14) +# effective per-sequence rewards of the implicit length weighting of note 0105 vs an explicit penalty +R_eff_len = (R - b) / lens +print(" implicit reward of the 1/|o|-normalised GRPO loss: (R-b)/|o| -> not separable, depends on b and multiplies the reward by 1/|o|;") +print(" explicit penalty R - lam|o| is separable and its trade-off is the single number lam.") +# %% [markdown] +# ## 6. Verifier errors. Constant false-accept / false-reject rates only rescale the gradient; an exploitable false-accept class re-targets it. +# %% +aFA, aFR = 0.10, 0.05 +th = theta.clone().requires_grad_(True) +Pc = (lm.probs(th) * R).sum() +Jobs = aFA + (1 - aFR - aFA) * Pc # E[R_obs] when errors are independent of the response +Robs_expect = R * (1 - aFR) + (1 - R) * aFA # per-response expected observed reward +th2 = theta.clone().requires_grad_(True) +Jobs2 = (lm.probs(th2) * Robs_expect).sum() +g1 = torch.autograd.grad(Jobs, th)[0]; g2 = torch.autograd.grad(Jobs2, th2)[0] +check("E[R_obs] = aFA + (1-aFR-aFA) P(correct) (value)", abs(float(Jobs.detach()) - float(Jobs2.detach())), 1e-15) +check("constant error rates: grad E[R_obs] = (1-aFR-aFA) grad P(correct) (exact rescaling)", maxabs(g2, (1 - aFR - aFA) * grad_p), 1e-14) +# exploitable class: short sequences (length 2 ending in EOS) with an even token get accepted with prob 1 +expl = torch.tensor([1.0 if (len(sq) == 2 and sq[-1] == lm.EOS and sq[0] % 2 == 0) else 0.0 for sq in lm.seqs]) * (1 - R) +th = theta.clone().requires_grad_(True) +gE = torch.autograd.grad((lm.probs(th) * expl).sum(), th)[0] +P_E = float((p_seq * expl).sum()) +print(f" exploit class (always accepted although wrong): P = {P_E:.4f}; ||grad P(correct)|| = {float(grad_p.norm()):.4f}; ||grad P(exploit)|| = {float(gE.norm()):.4f}; cosine = {float(grad_p@gE/(grad_p.norm()*gE.norm())):+.3f}") +th = theta.clone().requires_grad_(True) +g_obs = torch.autograd.grad((lm.probs(th) * (R + expl)).sum(), th)[0] # E[R_obs], R_obs = 1 on correct OR exploit +check("observed-reward gradient (autograd) == grad P(correct) + grad P(exploit)", maxabs(g_obs, grad_p + gE), 1e-14) +print(" exploit share of the observed-reward gradient norm: %.2f" % float(gE.norm() / g_obs.norm())) +# %% [markdown] +# ## 7. Group-size / pass@k arithmetic: how many samples until a group is live; the cost of rare successes +# %% +print(" p P(live), G=8 G=16 G=64 expected prompts*samples per live group (G=16)") +for pp in (0.001, 0.01, 0.05, 0.2, 0.5): + live = [1 - pp ** G - (1 - pp) ** G for G in (8, 16, 64)] + print(f" {pp:5.3f} {live[0]:8.4f} {live[1]:8.4f} {live[2]:8.4f} {16/live[1]:10.1f}") +check("P(live) = 1 - p^G - (1-p)^G = pass@G - p^G (identity)", abs((1 - 0.2 ** 16 - 0.8 ** 16) - ((1 - 0.8 ** 16) - 0.2 ** 16)), 1e-15) +print("done; checks:", len(CHECKS)) diff --git a/code/notebooks/_m1_src/0209-variance-components-equivalence.py b/code/notebooks/_m1_src/0209-variance-components-equivalence.py new file mode 100644 index 0000000000000000000000000000000000000000..8022426907af790d7e4260a8f47be7b2f44bdbff --- /dev/null +++ b/code/notebooks/_m1_src/0209-variance-components-equivalence.py @@ -0,0 +1,238 @@ +# %% [markdown] +# # 0209 - Variance components and equivalence tests for evaluation +# Companion to `notes/0209 - Variance Components and Equivalence Tests.pdf`. CPU only, numpy/scipy, 1 thread. +# Running toy: a benchmark of `N = 244` problems, `S` independently trained models ("seeds") of one recipe, `m = 16` samples per (seed, problem). +# Per-(seed, problem) solve probability `p_sx = expit(mu_x + a_s + b_sx)`, `mu_x ~ N(0.3, 1.5^2)` (problem difficulty), `a_s ~ N(0, 0.25^2)` (seed), `b_sx ~ N(0, 0.5^2)` (seed x problem). +# %% +import os +os.environ["OMP_NUM_THREADS"] = "1"; os.environ["OPENBLAS_NUM_THREADS"] = "1"; os.environ["MKL_NUM_THREADS"] = "1" +import math, itertools +from fractions import Fraction as Fr +from math import comb +import numpy as np +from scipy import stats +from scipy.special import expit +CHECKS = [] +def check(name, err, tol, note=""): + CHECKS.append((name, float(err), float(tol))) + assert err <= tol, (name, float(err), tol) + print(f"[ok] {name:100s} err={float(err):.3e} (tol {tol:.1e}) {note}") +N, m = 244, 16 +M0, SX, SA, SB = 0.3, 1.5, 0.25, 0.5 +# %% [markdown] +# ## 1. Variance components: problems (A), seeds (B), seed x problem (C), generation (e) +# Exchangeable array `p_sx = mu + A_x + B_s + C_sx` (Hoeffding/ANOVA decomposition; components orthogonal). With outcomes `c_sx ~ Bin(m, p_sx)` and the score of one run `Y_s = (1/(N m)) sum_x c_sx`: +# `Var(Y_s) = sA2/N + sB2 + sC2/N + se2/(N m)`, `se2 = E p(1-p)`. Only `sB2` survives `N -> inf` and `m -> inf`. +# Population components by Gauss-Hermite integration of the logit-normal model (no simulation). +# %% +gh_x, gh_w = np.polynomial.hermite_e.hermegauss(60); gh_w = gh_w / gh_w.sum() # standard normal nodes/weights +MU = M0 + SX * gh_x; A_ = SA * gh_x; B_ = SB * gh_x +P3 = expit(MU[:, None, None] + A_[None, :, None] + B_[None, None, :]) # [x, s, b] +W3 = gh_w[:, None, None] * gh_w[None, :, None] * gh_w[None, None, :] +Ep = (P3 * W3).sum(); Ep2 = (P3 ** 2 * W3).sum() +Ax = (P3 * gh_w[None, :, None] * gh_w[None, None, :]).sum((1, 2)) # E_{s,b} p | x (weights over s,b sum to 1) +Bs = (P3 * gh_w[:, None, None] * gh_w[None, None, :]).sum((0, 2)) # E_{x,b} p | s +sA2 = (gh_w * (Ax - Ep) ** 2).sum(); sB2 = (gh_w * (Bs - Ep) ** 2).sum() +sT2 = Ep2 - Ep ** 2; sC2 = sT2 - sA2 - sB2; se2 = Ep - Ep2 +print(f" E p = {Ep:.4f}; sigma_A^2 = {sA2:.5f}, sigma_B^2 = {sB2:.6f} (sd {math.sqrt(sB2):.4f}), sigma_C^2 = {sC2:.5f}, sigma_e^2 = E p(1-p) = {se2:.5f}") +def var_Y(S, N=N, m=m): return sA2 / N + sB2 / S + sC2 / (N * S) + se2 / (N * S * m) # variance of a grand mean over S seeds +print(" per-term contribution to Var(Y_s), S=1: problems %.3e seed %.3e seed*problem %.3e generation %.3e" % (sA2 / N, sB2, sC2 / N, se2 / (N * m))) +check("1. components are non-negative and sum to Var(p)", abs(sA2 + sB2 + sC2 - sT2) + max(0, -min(sA2, sB2, sC2)), 1e-15) +# simulate replications: each replication redraws problems, seeds, samples +rng = np.random.default_rng(0) +def simulate(R, S, N=N, m=m): + mu = M0 + SX * rng.standard_normal((R, 1, N)); a = SA * rng.standard_normal((R, S, 1)); b = SB * rng.standard_normal((R, S, N)) + p = expit(mu + a + b) + c = rng.binomial(m, p) + return p, c +R1 = 6000 +p, c = simulate(R1, 1) +Y = c[:, 0, :].sum(1) / (N * m) +se_rel = math.sqrt(2 / (R1 - 1)) +check("1. Var(Y_s) over 6000 replications vs sA2/N + sB2 + sC2/N + se2/(Nm)", abs(Y.var(ddof=1) / var_Y(1) - 1), 4 * se_rel, f"emp {Y.var(ddof=1):.4e} vs {var_Y(1):.4e}") +# MoM / ANOVA estimators with S=4 seeds +S = 4; R2 = 4000 +p, c = simulate(R2, S) +ybar = c / m # cell means [R,S,N] +gm = ybar.mean((1, 2), keepdims=True) +MSx = S * ((ybar.mean(1) - gm[:, :, 0]) ** 2).sum(1) / (N - 1) +MSs = N * ((ybar.mean(2) - gm[:, :, 0]) ** 2).sum(1) / (S - 1) +res = ybar - ybar.mean(1, keepdims=True) - ybar.mean(2, keepdims=True) + gm +MSr = (res ** 2).sum((1, 2)) / ((S - 1) * (N - 1)) +pq = (c * (m - c) / (m * (m - 1))).mean((1, 2)) # unbiased for E p(1-p) +est = {"sigma_e^2": (pq, se2), "sigma_C^2": (MSr - pq / m, sC2), "sigma_A^2": ((MSx - MSr) / S, sA2), "sigma_B^2": ((MSs - MSr) / N, sB2)} +for k, (v, t) in est.items(): + z = (v.mean() - t) / (v.std(ddof=1) / math.sqrt(R2)) + check(f"1. ANOVA estimator of {k} is unbiased (4000 reps, S=4)", abs(z), 4.0, f"mean {v.mean():.6f} vs true {t:.6f} (z={z:+.2f})") +# %% [markdown] +# ## 2. Which term do you pay down with what? Two recipes, S seeds each, same problems +# `delta_hat = Ybar_A - Ybar_B`. Under H0 (same recipe) the shared problem effect A_x cancels: `Var(delta_hat) = (2/S) [ sB2 + sC2/N + se2/(N m) ]`. +# The seed term does not shrink with N or m: a floor on the minimum detectable effect. +# %% +def var_diff(S, N=N, m=m): return (2 / S) * (sB2 + sC2 / N + se2 / (N * m)) +R3 = 6000 +for S in (1, 3): + mu = M0 + SX * rng.standard_normal((R3, 1, N)) + d = [] + for _ in range(2): + a = SA * rng.standard_normal((R3, S, 1)); b = SB * rng.standard_normal((R3, S, N)) + cc = rng.binomial(m, expit(mu + a + b)); d.append(cc.sum((1, 2)) / (S * N * m)) + dd = d[0] - d[1] + check(f"2. Var(delta_hat) for S={S} seeds per recipe vs (2/S)[sB2 + sC2/N + se2/(Nm)]", abs(dd.var(ddof=1) / var_diff(S) - 1), 4 * math.sqrt(2 / (R3 - 1)), f"emp {dd.var(ddof=1):.3e} vs {var_diff(S):.3e}") +z = stats.norm.ppf(0.975) + stats.norm.ppf(0.8) +for S in (1, 3, 5): + print(f" S={S}: MDE(80% power, 5% two-sided) at N=244: {z*math.sqrt(var_diff(S))*100:.2f} pp; N->inf, m->inf floor: {z*math.sqrt(2*sB2/S)*100:.2f} pp") +check("2. floor is strictly positive: MDE(N=1e9, m=1e9, S=3) equals z sqrt(2 sB2 / 3)", abs(z * math.sqrt(var_diff(3, N=10**9, m=10**9)) - z * math.sqrt(2 * sB2 / 3)), 1e-6) +# %% [markdown] +# ## 3. Unbiased pass@k from n samples, and its exact variance +# `e(n,c,k) = 1 - C(n-c,k)/C(n,k)`; `c ~ Bin(n,p)` => `E e = 1 - (1-p)^k`. Exact variance by summing over `c = 0..n`. +# NAIVE plug-in `1 - (1 - c/n)^k` is biased. Special cases: `k = 1` gives `p(1-p)/n`; `k = n` gives `q(1-q)`, `q = 1-(1-p)^n`. +# %% +def e_nck(n, c, k): return 1 - Fr(comb(n - c, k), comb(n, k)) +def pk_moments(n, k, p): + p = Fr(p); pm = [Fr(comb(n, c)) * p ** c * (1 - p) ** (n - c) for c in range(n + 1)] + E1 = sum(w * e_nck(n, c, k) for c, w in enumerate(pm)); E2 = sum(w * e_nck(n, c, k) ** 2 for c, w in enumerate(pm)) + return E1, E2 - E1 ** 2, sum(w * (1 - (1 - Fr(c, n)) ** k) for c, w in enumerate(pm)) +worst = 0; worst_k1 = 0; worst_kn = 0 +for n, k, p in [(64, 8, Fr(1, 10)), (64, 32, Fr(1, 50)), (16, 4, Fr(3, 10)), (64, 1, Fr(1, 7)), (64, 64, Fr(1, 100)), (10, 10, Fr(1, 5))]: + E1, V, Enaive = pk_moments(n, k, p) + worst = max(worst, abs(float(E1 - (1 - (1 - p) ** k)))) + if k == 1: worst_k1 = max(worst_k1, abs(float(V - p * (1 - p) / n))) + if k == n: q = 1 - (1 - p) ** n; worst_kn = max(worst_kn, abs(float(V - q * (1 - q)))) +check("3. E[e(n,c,k)] = 1-(1-p)^k exactly (6 (n,k,p) triples, rational arithmetic)", worst, 1e-15) +check("3. k=1: Var = p(1-p)/n exactly", worst_k1, 1e-15) +check("3. k=n: Var = q(1-q) with q = 1-(1-p)^n exactly", worst_kn, 1e-15) +print(" n=64: p k true pass@k E[unbiased] E[naive plug-in] (bias) sd(unbiased) sd/true") +for p in (0.02, 0.1, 0.5): + for k in (1, 8, 32): + E1, V, En = pk_moments(64, k, Fr(p).limit_denominator(1000)); g = 1 - (1 - p) ** k + print(f" {p:5.2f} {k:4d} {g:.5f} {float(E1):.5f} {float(En):.5f} ({float(En)-g:+.5f}) {math.sqrt(float(V)):.5f} {math.sqrt(float(V))/g:.3f}") +E1, V, En = pk_moments(64, 32, Fr(1, 50)) +check("3. naive plug-in is biased low at (n=64,k=32,p=.02) by more than 0.07", 0.0 if (1 - (1 - 0.02) ** 32) - float(En) > 0.07 else 1.0, 0.0, f"bias {float(En)-(1-0.98**32):+.4f}") +# MC check of the exact variance (independent implementation via binomial draws) and of the problem-level decomposition +rng = np.random.default_rng(1) +n, k, p0 = 64, 8, 0.1 +cs = rng.binomial(n, p0, size=2_000_000) +lg = np.array([float(e_nck(n, c_, k)) for c_ in range(n + 1)]) +est = lg[cs] +Vex = float(pk_moments(n, k, Fr(1, 10))[1]) +check("3. exact Var vs 2e6 Monte Carlo draws (n=64,k=8,p=.1)", abs(est.var() / Vex - 1), 4 * math.sqrt(2 / 2e6)) +# across problems: p_x ~ Beta(1.4, 0.7); Var(mean_x e_x) = (1/N)[Var_x(g(p_x)) + E_x Var(e | p_x)] +a_, b_ = 1.4, 0.7; k = 8; n = 64 +xs = rng.beta(a_, b_, size=400_000) # for the population quantities +cs = rng.binomial(n, xs) +lgk = np.array([float(e_nck(n, c_, k)) for c_ in range(n + 1)]) +es = lgk[cs]; gx = 1 - (1 - xs) ** k +pred = (gx.var() + (es - gx).var()) / N +reps = np.array([lgk[rng.binomial(n, rng.beta(a_, b_, size=N))].mean() for _ in range(6000)]) +check("3. Var of the benchmark mean pass@8 (N=244, n=64) vs (1/N)[Var_x g + E Var(e|p)]", abs(reps.var(ddof=1) / pred - 1), 5 * math.sqrt(2 / 5999) + 0.01, f"emp {reps.var(ddof=1):.3e} vs {pred:.3e}") +# %% [markdown] +# ## 4. Paired sign-flip test on per-problem differences +# `d_x = e^A_x - e^B_x`, statistic `T = sum_x d_x`, null: each `d_x` symmetric about 0 independently => signs exchangeable: p = fraction of the `2^N` sign vectors with `|T'| >= |T|`. +# Binary `d_x in {-1,0,1}`: reduces to exact McNemar (note 0208). Exact enumeration for N<=18; Monte Carlo for N=244; size under the null. +# %% +def signflip_exact(d): + d = np.asarray(d, float); n = len(d) + signs = np.array(list(itertools.product([-1.0, 1.0], repeat=n))) + T = np.abs(signs @ d); return float((T >= abs(d.sum()) - 1e-12).mean()) +def mcnemar(b, c): + nd = b + c + return 1.0 if nd == 0 else min(1.0, 2 * sum(comb(nd, i) for i in range(min(b, c) + 1)) / 2 ** nd) +worst = 0 +for b_ in range(0, 8): + for c_ in range(0, 8): + if b_ + c_ == 0: continue + d = np.array([1] * b_ + [-1] * c_ + [0] * 3) + worst = max(worst, abs(signflip_exact(d) - mcnemar(b_, c_))) +check("4. sign-flip p on {-1,0,+1} equals exact McNemar for all b,c <= 7 (3 concordant zeros)", worst, 1e-12) +check("4. (5,0): two-sided sign-flip p = 0.0625", abs(signflip_exact([1] * 5) - 0.0625), 1e-15) +rng = np.random.default_rng(2) +def signflip_mc(d, B=20000): + d = np.asarray(d, float); s = rng.choice([-1.0, 1.0], size=(B, len(d))); T = np.abs(s @ d) + return (1 + (T >= abs(d.sum()) - 1e-12).sum()) / (B + 1) +d = rng.standard_normal(14) * 0.2 + 0.05 +check("4. Monte Carlo p-value (B=20000) vs exact enumeration at N=14", abs(signflip_mc(d) - signflip_exact(d)), 4 * 0.5 / math.sqrt(20000)) +# size under the null: A and B are two draws of the same system; per-problem pass@8 estimators from n=64 samples with problem-specific p_x +reject = 0; R = 1500; N_ = 60 +for r in range(R): + px = rng.beta(a_, b_, size=N_) + d = lgk[rng.binomial(n, px)] - lgk[rng.binomial(n, px)] + reject += signflip_mc(d, B=2000) <= 0.05 +size = reject / R +check("4. size of the sign-flip test under H0 (N=60 problems, shared p_x, 1500 benchmarks)", abs(size - 0.05) , 4 * math.sqrt(0.05 * 0.95 / R) + 0.01, f"size {size:.4f}") +# the null is symmetry, not mean zero: a skewed mean-zero d inflates/deflates size +reject = 0; R = 1500; N_ = 12 +for r in range(R): + d = np.where(rng.random(N_) < 0.1, 9.0, -1.0) # mean = 0.1*9 - 0.9 = 0, strongly skewed + reject += signflip_exact(d) <= 0.05 if N_ <= 12 else 0 +size_skew = reject / R +print(f" skewed mean-zero differences (N=12): size = {size_skew:.4f} (nominal 0.05): the test's null is symmetry") +check("4. skewed mean-zero null: size differs from 0.05 by more than 0.02 (the test is not a test of the mean)", 0.0 if abs(size_skew - 0.05) > 0.02 else 1.0, 0.0) +# %% [markdown] +# ## 5. Fresh seed on the same problems removes generation noise, not selection bias +# K = 8 candidate checkpoints with *identical* true population quality theta (null). On the fixed benchmark each has a quenched problem-specific advantage +# `beta_k ~ N(0, sC2/N)` (seed x problem) plus a fresh-generation error `g_k ~ N(0, se2/(N m))`. Select `k* = argmax (beta_k + g_k)`. +# (i) report the same estimate: bias `sigma_tot c_K`; (ii) re-sample generations on the SAME problems: bias `E beta_{k*} = (sigma_b^2/sigma_tot) c_K`; (iii) fresh problems: bias 0. +# `c_K = E max of K standard normals`. +# %% +sb2 = sC2 / N; sg2 = se2 / (N * m); st2 = sb2 + sg2; K = 8 +cK = float(np.mean(np.max(np.random.default_rng(3).standard_normal((2_000_000, K)), 1))) +from scipy import integrate +cK_exact = integrate.quad(lambda x: K * x * stats.norm.pdf(x) * stats.norm.cdf(x) ** (K - 1), -12, 12)[0] +check("5. c_8 = E max of 8 N(0,1): quadrature vs 2e6 Monte Carlo", abs(cK_exact - cK), 5 * 1.0 / math.sqrt(2e6) , f"c_8={cK_exact:.5f}") +rng = np.random.default_rng(4); R = 400_000 +beta = math.sqrt(sb2) * rng.standard_normal((R, K)); g1 = math.sqrt(sg2) * rng.standard_normal((R, K)); g2 = math.sqrt(sg2) * rng.standard_normal((R, K)) +win = np.argmax(beta + g1, axis=1); ix = np.arange(R) +b_same = (beta + g1)[ix, win]; b_regen = (beta + g2)[ix, win]; b_fresh = (math.sqrt(sb2) * rng.standard_normal(R) + math.sqrt(sg2) * rng.standard_normal(R)) +th_same, th_regen = math.sqrt(st2) * cK_exact, (sb2 / math.sqrt(st2)) * cK_exact +sd = math.sqrt(st2) +check("5(i). same-sample winner estimate bias vs sigma_tot c_K", abs(b_same.mean() - th_same), 4 * sd / math.sqrt(R), f"{b_same.mean()*100:.3f} pp vs {th_same*100:.3f} pp") +check("5(ii). fresh generations on the same problems: bias vs (sb2/sigma_tot) c_K", abs(b_regen.mean() - th_regen), 4 * sd / math.sqrt(R), f"{b_regen.mean()*100:.3f} pp vs {th_regen*100:.3f} pp (retained fraction {th_regen/th_same:.3f} = sb2/sigma_tot^2 = {sb2/st2:.3f})") +check("5(iii). fresh problems: bias 0", abs(b_fresh.mean()), 4 * sd / math.sqrt(R)) +check("5. retained fraction of the bias after regenerating = sb2/(sb2+sg2) (with m=16: generation is a minor term)", abs(th_regen / th_same - sb2 / st2), 1e-12) +# %% [markdown] +# ## 6. Equivalence (TOST) versus "no significant difference"; minimum detectable effect +# `delta_hat ~ N(delta, s^2)`. Equivalence margin `Delta`. TOST: reject `H0: |delta| >= Delta` iff both one-sided tests reject: `p_TOST = max(p_L, p_U)`. +# Duality: TOST at level alpha rejects iff the (1-2 alpha) CI lies inside `(-Delta, Delta)`. Power at `delta=0`: `2 Phi(Delta/s - z_{1-alpha}) - 1`. +# %% +al = 0.05; zA = stats.norm.ppf(1 - al) +def tost_p(dh, s, Dl): + p_upper = stats.norm.cdf((dh - Dl) / s) # H0: delta >= Delta + p_lower = stats.norm.sf((dh + Dl) / s) # H0: delta <= -Delta + return np.maximum(p_lower, p_upper) +rng = np.random.default_rng(5) +dh = rng.normal(0, 0.03, 100000); s_ = rng.uniform(0.005, 0.03, 100000); Dl = 0.02 +rej_p = tost_p(dh, s_, Dl) < al +rej_ci = (dh - zA * s_ > -Dl) & (dh + zA * s_ < Dl) +check("6. TOST p 90% CI inside (-Delta, Delta) (100000 random cases)", float((rej_p != rej_ci).sum()), 0.0) +# size at the boundary and power at delta = 0 +R = 400_000 +for dl in (Dl, -Dl): + sm = 0.008; x = rng.normal(dl, sm, R); r = (tost_p(x, sm, Dl) < al).mean() + check(f"6. size at delta={dl:+.2f} (boundary of H0), s=0.008: TOST rejection prob = alpha", abs(r - al), 4 * math.sqrt(al * (1 - al) / R), f"{r:.4f}") +sm = 0.008; x = rng.normal(0, sm, R); r = (tost_p(x, sm, Dl) < al).mean(); pw = 2 * stats.norm.cdf(Dl / sm - zA) - 1 +check("6. power at delta=0: 2 Phi(Delta/s - z) - 1 vs MC", abs(r - pw), 4 * math.sqrt(0.25 / R), f"{r:.4f} vs {pw:.4f}") +# difference test vs equivalence at a true effect larger than the margin: N=244, discordance d=0.1 +dpair = 0.1; sd_pair = math.sqrt(dpair); s244 = sd_pair / math.sqrt(244) +true_delta = 0.03 +x = rng.normal(true_delta, s244, R) +p_diff = 2 * stats.norm.sf(np.abs(x) / s244) +miss = (p_diff >= 0.05).mean(); fake_equiv = (tost_p(x, s244, Dl) < al).mean() +print(f" N=244, d=0.10 (s = {s244:.4f}), true delta=+0.03 > Delta=0.02: difference test misses it {miss*100:.1f}% of the time; TOST declares equivalence {fake_equiv*100:.2f}%") +check("6. 'not significant' is not equivalence: with a true effect beyond the margin, difference test fails to reject often, TOST seldom 'accepts' (<= alpha)", 0.0 if (miss > 0.4 and fake_equiv <= al + 4 * math.sqrt(al * 0.95 / R)) else 1.0, 0.0) +# MDE (paired, one run per benchmark) and required N, cross-checked against note 0208's table (normal approximation, d = 0.10, delta = 5/244) +Nn = 244; mde = (stats.norm.ppf(0.975) + stats.norm.ppf(0.8)) * sd_pair / math.sqrt(Nn) +x = rng.normal(mde, s244, R); pw = (2 * stats.norm.sf(np.abs(x) / s244) < 0.05).mean() +check("6. MDE = (z_{.975}+z_{.8}) sigma_d/sqrt(N) gives 80% power (Gaussian paired mean)", abs(pw - 0.8), 4 * math.sqrt(0.16 / R) + 0.0035, f"MDE = {mde*Nn:.2f} problems, power {pw:.4f} (0208 exact trinomial: 14.3)") +dlt = 5 / 244; d_ = 0.10 +N_norm = ((stats.norm.ppf(0.975) * math.sqrt(d_) + stats.norm.ppf(0.8) * math.sqrt(d_ - dlt ** 2)) / dlt) ** 2 +check("6. N for 80% power at delta=5/244, d=.10 equals note 0208's normal-approximation value 1867", abs(round(N_norm) - 1867), 1, f"N={N_norm:.1f}") +Req = (( stats.norm.ppf(1 - al) + stats.norm.ppf(0.9)) * sd_pair / Dl) ** 2 # TOST 80% power at delta=0: Delta/s - z = z_{0.9} +pw_req = 2 * stats.norm.cdf(Dl / (sd_pair / math.sqrt(Req)) - zA) - 1 +check("6. N for 80% TOST power at delta=0, Delta=0.02, d=.10: closed form reproduces 80% power", abs(pw_req - 0.8), 1e-9, f"N = {Req:.0f}") +print(f" (equivalence within 2 points needs N ~ {Req:.0f} problems at 10% discordance; detecting a 5-problem gain needs ~1867)") +# %% [markdown] +# ## Summary +# %% +for n_, e_, t_ in CHECKS: print(f"PASS {n_[:105]}") +print(f"\n{len(CHECKS)} checks, all passed") diff --git a/code/notebooks/_m1_src/0210-data-selection-contamination.py b/code/notebooks/_m1_src/0210-data-selection-contamination.py new file mode 100644 index 0000000000000000000000000000000000000000..8e0ff8a16b52aeebf0033f968593fe944fb46b8a --- /dev/null +++ b/code/notebooks/_m1_src/0210-data-selection-contamination.py @@ -0,0 +1,255 @@ +# %% [markdown] +# # 0210 - Data selection and contamination +# Companion to `notes/0210 - Data Selection and Contamination.pdf`. CPU only, numpy/scipy, 1 thread. +# 1. classifier-based harvesting (fastText-style) as a selection problem; 2. pass-rate band selection as importance weighting; +# 3. regression to the mean when the same samples select and evaluate; 4. MinHash / Jaccard estimation error, LSH, containment; 5. membership inference from NLL gaps. +# %% +import os +os.environ["OMP_NUM_THREADS"] = "1"; os.environ["OPENBLAS_NUM_THREADS"] = "1" +import math, itertools +from math import comb +import numpy as np +from scipy import stats, special +CHECKS = [] +def check(name, err, tol, note=""): + CHECKS.append((name, float(err), float(tol))) + assert err <= tol, (name, float(err), tol) + print(f"[ok] {name:104s} err={float(err):.3e} (tol {tol:.1e}) {note}") +Phibar = stats.norm.sf +# %% [markdown] +# ## 1. A harvesting classifier is a detector; what you get depends on prevalence and on what a wrong page costs +# Pool of `M` pages, a fraction `pi` relevant ("math"). Score `s ~ N(d', 1)` for relevant, `N(0, 1)` otherwise (binormal; AUC = Phi(d'/sqrt 2)). +# Threshold `t`: TPR = Phibar(t - d'), FPR = Phibar(t). Precision = pi TPR / (pi TPR + (1-pi) FPR) -- depends on `pi`, not only on AUC. +# Downstream value (assumed, to be *measured* by a training run): each selected relevant page gives `+g`, each irrelevant `-h` (dilution). +# Net gain `G(t) = M [pi TPR g - (1-pi) FPR h]`. `G'(t) = 0` where the posterior `P(rel | s=t) = h/(g+h)`: a posterior cutoff, so `t* = d'/2 + ln[(1-pi) h /(pi g)]/d'`. +# %% +rng = np.random.default_rng(0) +dprime, pi, M = 2.5, 0.01, 4_000_000 +rel = rng.random(M) < pi +s = rng.standard_normal(M) + dprime * rel +t = 2.0 +sel = s >= t +prec_mc = rel[sel].mean(); rec_mc = sel[rel].mean() +tpr, fpr = Phibar(t - dprime), Phibar(t) +prec_th = pi * tpr / (pi * tpr + (1 - pi) * fpr) +check("1. precision closed form vs Monte Carlo (4e6 pages)", abs(prec_mc - prec_th), 4 * math.sqrt(prec_th * (1 - prec_th) / sel.sum()), f"prec {prec_th:.4f}, recall {tpr:.4f}") +check("1. recall closed form vs Monte Carlo", abs(rec_mc - tpr), 4 * math.sqrt(tpr * (1 - tpr) / rel.sum())) +auc = stats.norm.cdf(dprime / math.sqrt(2)) +print(f" AUC={auc:.4f} (d'={dprime}); at a threshold with recall {tpr:.2f}: precision {prec_th:.3f} for pi={pi}; for pi=0.2 the same classifier gives {0.2*tpr/(0.2*tpr+0.8*fpr):.3f}") +check("1. same classifier, same threshold: precision at pi=0.2 exceeds precision at pi=0.01 by > 0.4", 0.0 if (0.2 * tpr / (0.2 * tpr + 0.8 * fpr)) - prec_th > 0.4 else 1.0, 0.0) +g_, h_ = 1.0, 1.0 +ts = np.linspace(0, 6, 60001) +G = pi * Phibar(ts - dprime) * g_ - (1 - pi) * Phibar(ts) * h_ +t_star = dprime / 2 + math.log((1 - pi) * h_ / (pi * g_)) / dprime +check("1. argmax of the net-gain curve vs closed-form posterior-cutoff threshold", abs(ts[G.argmax()] - t_star), 2e-4, f"t*={t_star:.4f}") +post = lambda t: pi * stats.norm.pdf(t - dprime) / (pi * stats.norm.pdf(t - dprime) + (1 - pi) * stats.norm.pdf(t)) +check("1. posterior P(rel | s=t*) = h/(g+h)", abs(post(t_star) - h_ / (g_ + h_)), 1e-12) +def f1(t): + tp = pi * Phibar(t - dprime); fp = (1 - pi) * Phibar(t); fn = pi - tp + return 2 * tp / (2 * tp + fp + fn) +t_f1 = ts[np.array([f1(x) for x in ts]).argmax()] +Gof = lambda t: pi * Phibar(t - dprime) * g_ - (1 - pi) * Phibar(t) * h_ +print(f" max-F1 threshold {t_f1:.3f} vs max-gain threshold {t_star:.3f}; gain/M at F1-opt {Gof(t_f1):.3e} vs gain-opt {Gof(t_star):.3e}") +check("1. the F1-optimal threshold is not the gain-optimal one (differ by > 0.2) and loses downstream gain", 0.0 if (abs(t_f1 - t_star) > 0.2 and Gof(t_f1) < Gof(t_star)) else 1.0, 0.0) +# seed coverage and iteration: positives split into 'covered' (d_in) and 'uncovered' (d_out) clusters; each round adds a fraction gamma of uncovered clusters to the seed +d_in, d_out, gamma, tt = 3.0, 0.5, 0.5, 2.0 +rec = []; cov = 0.3 +for r in range(1, 6): + rec.append(cov * Phibar(tt - d_in) + (1 - cov) * Phibar(tt - d_out)); cov = cov + gamma * (1 - cov) +inc = np.diff(rec) +print(" recall by round:", np.round(rec, 4), " increments:", np.round(inc, 4), " ratio of successive increments:", np.round(inc[1:] / inc[:-1], 3)) +check("1. iterative re-seeding: recall rises monotonically and increments shrink geometrically with ratio (1-gamma)", max(abs(inc[1:] / inc[:-1] - (1 - gamma))), 1e-12) +# %% [markdown] +# ## 2. Pass-rate band selection is importance weighting of the policy gradient +# Bernoulli problem with logit parameter, `n` samples, `c` successes, `p_hat = c/n`. GRPO-style mean baseline gives per-problem gradient magnitude `ghat = p_hat (1 - p_hat)` (note 0206), `E ghat = (1 - 1/n) p(1-p)`. +# Keeping the problem iff `p_hat` lies in a band `[lo, hi]` that is computed from the SAME samples multiplies the gradient by `1{p_hat in band}`: +# effective weight on a problem of true pass rate p: `w_same(p) = E[ghat 1{band}] / (p(1-p))`. With an independent pilot estimate `p_hat'` instead: `w_ind(p) = (1 - 1/n) P[p_hat' in band]`. +# %% +def bin_pmf(n, p): return np.array([comb(n, c) * p ** c * (1 - p) ** (n - c) for c in range(n + 1)]) +n = 8; lo, hi = 1 / 8, 6 / 8 +cs = np.arange(n + 1); ph = cs / n; inb = ((ph >= lo) & (ph <= hi)).astype(float); ghat = ph * (1 - ph) +rows = [] +for p in (0.02, 0.1, 0.3, 0.5, 0.7, 0.9, 0.98): + pm = bin_pmf(n, p) + w_same = (pm * ghat * inb).sum() / (p * (1 - p)); w_ind = (1 - 1 / n) * (pm * inb).sum() + check_unb = abs((pm * ghat).sum() - (1 - 1 / n) * p * (1 - p)) + assert check_unb < 1e-15 + rows.append((p, w_same, w_ind)); print(f" p={p:4.2f}: w_same={w_same:.4f} w_ind={w_ind:.4f} ratio={w_same/w_ind:.3f}") +check("2. E[ghat] = (1-1/n) p(1-p) exactly (7 values of p)", 0.0, 0.0) +check("2. same-sample selection weights exceed independent-pilot weights for every p in the table (selection on the statistic that defines the gradient)", 0.0 if all(w1 > w2 for _, w1, w2 in rows) else 1.0, 0.0) +# MC check of w_same by simulating samples +p = 0.1; cs_mc = rng.binomial(n, p, size=2_000_000); phm = cs_mc / n +est = (phm * (1 - phm) * ((phm >= lo) & (phm <= hi))).mean() / (p * (1 - p)); th = [r for r in rows if r[0] == p][0][1] +check("2. w_same(p=0.1) exact vs 2e6 Monte Carlo", abs(est / th - 1), 0.01) +# which problems does the band actually admit? contamination of the band by noise in p_hat (pool p ~ Beta(0.6,1.4), n = 8) +a_, b_ = 0.6, 1.4; band_p = (0.125, 0.75) +px = rng.beta(a_, b_, size=1_000_000); cx = rng.binomial(n, px); sel_ = (cx / n >= lo) & (cx / n <= hi) +frac_out = ((px[sel_] < band_p[0] - 1e-12) | (px[sel_] > band_p[1] + 1e-12)).mean() +# exact via beta-binomial +pmf_bb = np.array([comb(n, c) * special.beta(c + a_, n - c + b_) / special.beta(a_, b_) for c in range(n + 1)]) +print(f" fraction selected: {sel_.mean():.4f} (exact {pmf_bb[(cs>=1)&(cs<=6)].sum():.4f}); of the selected, true p outside [{band_p[0]},{band_p[1]}]: {frac_out:.3f}") +check("2. beta-binomial exact P[band] vs MC", abs(sel_.mean() - pmf_bb[(cs >= 1) & (cs <= 6)].sum()), 4 * math.sqrt(0.25 / 1e6)) +check("2. a substantial share of selected problems lie outside the band in TRUE pass rate (> 15%)", 0.0 if frac_out > 0.15 else 1.0, 0.0, f"{frac_out:.3f}") +# %% [markdown] +# ## 3. Regression to the mean: the same samples cannot select and evaluate +# Pool `p ~ Beta(a, b)`, `c | p ~ Bin(n, p)`. Posterior `p | c ~ Beta(a + c, b + n - c)`, mean `(a + c)/(a + b + n)`. +# Select the "unsolved" problems `c = 0`: observed rate 0, true mean `a/(a+b+n)` -- re-sampling them WITHOUT any training shows an apparent gain. +# %% +a_, b_, n = 0.6, 1.4, 8 +post_mean = lambda c: (a_ + c) / (a_ + b_ + n) +px = rng.beta(a_, b_, size=3_000_000); c1 = rng.binomial(n, px); c2 = rng.binomial(n, px) # c2: fresh samples, NO training between +for cc in (0, 1, 2): + selc = c1 == cc + emp = c2[selc].mean() / n; th = post_mean(cc) + check(f"3. E[fresh pass rate | first-pass count c={cc}] = (a+c)/(a+b+n)", abs(emp - th), 4 * math.sqrt(th * (1 - th) / (selc.sum() * n)), f"observed first-pass rate {cc/n:.3f} -> fresh {emp:.4f} (theory {th:.4f}); apparent 'gain' {emp - cc/n:+.4f} with no training") +# difference-in-differences with an untrained control from the same selection +tau = 0.03; sel0 = c1 == 0 +idx = np.flatnonzero(sel0); half = rng.random(idx.size) < 0.5 # random split of the selected problems into trained / untrained +p_after = np.where(half, np.minimum(px[idx] + tau * (1 - px[idx]), 1.0), px[idx]) # training raises p by tau (1-p) on the trained half only +c_after = rng.binomial(n, p_after) / n +naive = c_after[half].mean() - 0.0; control = c_after[~half].mean() - 0.0 +did = naive - control +tau_eff = (tau * (1 - px[idx[half]])).mean() +se = math.sqrt(c_after[half].var() / half.sum() / n + c_after[~half].var() / (~half).sum() / n) + 1e-9 +print(f" trained-half before/after = {naive:+.4f} (true effect {tau_eff:+.4f}); untrained control 'gain' = {control:+.4f}; DiD = {did:+.4f}") +check("3. naive before/after overstates the effect (regression to the mean) and DiD recovers it", abs(did - tau_eff), 0.003, f"naive {naive:+.4f}, DiD {did:+.4f}, true {tau_eff:+.4f}") +check("3. naive before/after is inflated by RTM: excess over truth exceeds 0.03", 0.0 if (naive - tau_eff) > 0.03 else 1.0, 0.0) +# split-sample selection: select with set 1, evaluate with independent set 2 -> E[fresh | selected] is what an untrained re-evaluation gives (no gain) +# %% [markdown] +# ## 4. MinHash: unbiased Jaccard with variance J(1-J)/k; LSH banding; containment is a different quantity +# `Jhat = (1/k) sum_i 1{minhash_i(A) = minhash_i(B)}`. For a uniformly random permutation `P[min equal] = J`, so `k Jhat ~ Bin(k, J)`: unbiased, `Var = J(1-J)/k`. +# %% +def jaccard(A, B): return len(A & B) / len(A | B) +U = 3000; rng = np.random.default_rng(7) +A = set(rng.choice(U, 400, replace=False).tolist()); share = 240 +B = set(list(A)[:share]) | set(rng.choice([u for u in range(U) if u not in A], 160, replace=False).tolist()) +J = jaccard(A, B) +def minhash_perm(S, perms): return np.array([min(perm[x] for x in S) for perm in perms]) +k = 128; reps = 3000 +perm_bank = np.array([rng.permutation(U) for _ in range(k)]) # one fresh set of k permutations per replication below +ests = [] +Al = np.array(sorted(A)); Bl = np.array(sorted(B)) +for r in range(reps): + perms = rng.permuted(np.tile(np.arange(U), (k, 1)), axis=1) + ests.append((perms[:, Al].min(1) == perms[:, Bl].min(1)).mean()) +ests = np.array(ests) +check("4. E[Jhat] = J (random permutations, k=128, 3000 replications)", abs(ests.mean() - J), 4 * math.sqrt(J * (1 - J) / k / reps), f"J={J:.4f}") +check("4. Var(Jhat) = J(1-J)/k", abs(ests.var(ddof=1) / (J * (1 - J) / k) - 1), 4 * math.sqrt(2 / (reps - 1)), f"emp {ests.var(ddof=1):.3e} vs {J*(1-J)/k:.3e}") +# practical universal hash h(x) = (a x + b) mod p (not truly random permutations) +P = 2_147_483_647 +def uh_est(A_, B_, k, rg): + a = rg.integers(1, P, k); b = rg.integers(0, P, k) + Aa = np.array(sorted(A_), dtype=np.int64); Bb = np.array(sorted(B_), dtype=np.int64) + ha = ((a[:, None] * Aa[None, :] + b[:, None]) % P).min(1); hb = ((a[:, None] * Bb[None, :] + b[:, None]) % P).min(1) + return (ha == hb).mean() +rg = np.random.default_rng(11) +est_uh = np.array([uh_est(A, B, 128, rg) for _ in range(3000)]) +# a linear hash is pairwise independent but NOT min-wise independent: a small bias is allowed and reported, not hidden +check("4. universal-hash MinHash (practical): |bias| < 0.01 (small but need not be zero; reported)", abs(est_uh.mean() - J), 0.01, f"bias {est_uh.mean()-J:+.4f} (MC s.e. {math.sqrt(J*(1-J)/128/3000):.4f})") +for eps in (0.05, 0.02, 0.01): + kreq = math.ceil(stats.norm.ppf(0.975) ** 2 * 0.25 / eps ** 2); print(f" worst-case (J=1/2) hashes for +-{eps} at 95%: k >= {kreq}") +kk = 385; cover = 0.0 +pm = stats.binom.pmf(np.arange(kk + 1), kk, 0.5); jh = np.arange(kk + 1) / kk +cover = pm[np.abs(jh - 0.5) <= 0.05].sum() +check("4. k=385 gives >= ~95% coverage of +-0.05 at J=1/2 (exact binomial)", 0.0 if cover > 0.94 else 1.0, 0.0, f"coverage {cover:.4f}") +# decision at a decontamination threshold: flag if Jhat >= 0.8, k = 128 +kk = 128 +for Jt in (0.7, 0.75, 0.8, 0.85): + pflag = stats.binom.sf(math.ceil(0.8 * kk) - 1, kk, Jt) + print(f" true J={Jt}: P[flag at Jhat>=0.8] = {pflag:.3f} (sd of Jhat {math.sqrt(Jt*(1-Jt)/kk):.3f})") +Jt = 0.75; mc = (rng.binomial(kk, Jt, 2_000_000) / kk >= 0.8).mean(); ex = stats.binom.sf(math.ceil(0.8 * kk) - 1, kk, Jt) +check("4. exact flag probability at J=0.75 vs Monte Carlo", abs(mc - ex), 4 * math.sqrt(ex * (1 - ex) / 2e6), f"P[flag]={ex:.3f}: a 'near-duplicate' just under the threshold is flagged often") +# LSH banding: b bands of r rows: P[candidate] = 1 - (1 - J^r)^b +b_, r_ = 20, 5 +for Jt in (0.3, 0.5, 0.6, 0.8): + th = 1 - (1 - Jt ** r_) ** b_ + bits = rng.random((400_000, b_, r_)) < Jt # each hash agrees w.p. J independently (random permutations) + mc = bits.all(2).any(1).mean() + check(f"4. LSH banding P[candidate] at J={Jt}: 1-(1-J^r)^b vs Monte Carlo (b=20, r=5)", abs(mc - th), 4 * math.sqrt(th * (1 - th) / 400_000) + 1e-4, f"{th:.4f}") +print(f" LSH threshold approx (1/b)^(1/r) = {(1/b_)**(1/r_):.3f}") +# containment: a benchmark item (40 shingles) inside a long web page (2000 shingles): J tiny, containment 1 +Aitem = set(range(40)); Bpage = set(range(2000)) +Jc = jaccard(Aitem, Bpage); cont = len(Aitem & Bpage) / len(Aitem) +inter_from_J = Jc * (len(Aitem) + len(Bpage)) / (1 + Jc) +check("4. |A n B| = J(|A|+|B|)/(1+J): containment recoverable from Jaccard and the two sizes", abs(inter_from_J - 40), 1e-9, f"J={Jc:.4f} (flag threshold 0.8 would miss it), containment={cont:.2f}") +# error amplification of the containment estimate via Jhat +kk = 128; Jh = rng.binomial(kk, Jc, 100_000) / kk +cont_hat = Jh * (40 + 2000) / (1 + Jh) / 40 +print(f" containment estimate from MinHash at k=128: mean {cont_hat.mean():.3f}, sd {cont_hat.std():.3f} (true 1.0): very noisy for tiny J") +check("4. containment from MinHash at k=128 for tiny J has relative sd > 0.5", 0.0 if cont_hat.std() > 0.5 else 1.0, 0.0, f"sd {cont_hat.std():.2f}") +# real text shingles: document with edits +rg = np.random.default_rng(5) +vocab = [f"w{i}" for i in range(3000)] +doc = list(rg.choice(vocab, 600)); doc2 = doc.copy() +for pos in rg.choice(600, 36, replace=False): doc2[pos] = rg.choice(vocab) +sh = lambda d, w=5: {" ".join(d[i:i + w]) for i in range(len(d) - w + 1)} +S1, S2 = sh(doc), sh(doc2); Jx = jaccard(S1, S2) +hs = lambda S: {int.from_bytes(__import__('hashlib').blake2b(x.encode(), digest_size=8).digest(), 'little') % P for x in S} +H1, H2 = hs(S1), hs(S2) +est = np.array([uh_est(H1, H2, 256, rg) for _ in range(400)]) +check("4. 5-word shingles of a 600-word doc with 6% word edits: MinHash(k=256) mean vs exact Jaccard", abs(est.mean() - Jx), 4 * math.sqrt(Jx * (1 - Jx) / 256 / 400) + 0.003, f"exact J={Jx:.4f}, est {est.mean():.4f}, sd {est.std():.4f}") +# %% [markdown] +# ## 5. Membership inference from NLL gaps +# Members have lower NLL: `NLL_member ~ N(mu - Delta, sigma^2)`, non-member `N(mu, sigma^2)`. AUC of the threshold test = `Phi(Delta / (sigma sqrt 2))`; TPR at FPR `f`: `Phi(Delta/sigma - z_{1-f})`. +# If examples differ in intrinsic difficulty `D_i ~ N(0, sD^2)` (added to every NLL), the raw-NLL AUC drops to `Phi(Delta/sqrt(2(sD^2+sigma^2)))`; a reference-calibrated score `NLL_target - NLL_ref` cancels `D_i`. +# %% +rng = np.random.default_rng(21); R_ = 400_000 +Delta, sig, sD, sref = 0.30, 0.25, 0.80, 0.25 +mem = rng.normal(-Delta, sig, R_); non = rng.normal(0, sig, R_) +def auc_of(pos_low, neg): + # P[score(member) < score(non-member)] with MC estimate by ranks on a subsample + x = pos_low[:200000]; y = neg[:200000] + r = stats.rankdata(np.concatenate([x, y])); return 1 - (r[:len(x)].sum() - len(x) * (len(x) + 1) / 2) / (len(x) * len(y)) +check("5. AUC = Phi(Delta/(sigma sqrt2)) (Gaussian NLL gap)", abs(auc_of(mem, non) - stats.norm.cdf(Delta / (sig * math.sqrt(2)))), 0.003, f"{stats.norm.cdf(Delta/(sig*math.sqrt(2))):.4f}") +thr = np.quantile(non, 0.01); tpr_mc = (mem <= thr).mean(); tpr_th = stats.norm.cdf(Delta / sig - stats.norm.ppf(0.99)) +check("5. TPR at 1% FPR = Phi(Delta/sigma - z_{0.99})", abs(tpr_mc - tpr_th), 0.004, f"{tpr_th:.4f}") +Dm = rng.normal(0, sD, R_); Dn = rng.normal(0, sD, R_) +raw_m = Dm + mem; raw_n = Dn + non +auc_raw_th = stats.norm.cdf(Delta / math.sqrt(2 * (sD ** 2 + sig ** 2))) +check("5. difficulty heterogeneity: raw-NLL AUC = Phi(Delta/sqrt(2(sD^2+sigma^2)))", abs(auc_of(raw_m, raw_n) - auc_raw_th), 0.003, f"{auc_raw_th:.4f}") +ref_m = Dm + rng.normal(0, sref, R_); ref_n = Dn + rng.normal(0, sref, R_) +cal_th = stats.norm.cdf(Delta / math.sqrt(2 * (sig ** 2 + sref ** 2))) +check("5. calibrated NLL - NLL_ref: AUC = Phi(Delta/sqrt(2(sigma^2+sref^2)))", abs(auc_of(raw_m - ref_m, raw_n - ref_n) - cal_th), 0.003, f"{cal_th:.4f}") +print(f" AUC: raw {auc_raw_th:.3f} -> calibrated {cal_th:.3f} (Delta={Delta}, sigma={sig}, sD={sD}, sref={sref})") +# a real (tiny) language model: character n-gram, memorisation grows with order; ground-truth membership known +V = 12; L = 120; ndoc = 2000; alpha = 0.1; rng = np.random.default_rng(33) +logits = rng.normal(0, 1.5, (V, V, V)) # order-2 Markov source +def gen_docs(nd, rg): + taus = np.exp(rg.uniform(math.log(0.4), math.log(2.5), nd)); X = np.zeros((nd, L), dtype=np.int64); X[:, :2] = rg.integers(0, V, (nd, 2)) + for t in range(2, L): + lg = logits[X[:, t - 2], X[:, t - 1]] / taus[:, None]; pr = np.exp(lg - lg.max(1, keepdims=True)); pr /= pr.sum(1, keepdims=True) + u = rg.random(nd)[:, None]; X[:, t] = (pr.cumsum(1) < u).sum(1).clip(max=V - 1) + return X, taus +Xm, tm = gen_docs(ndoc, rng); Xn, tn = gen_docs(ndoc, rng); Xr, _ = gen_docs(ndoc, rng) +def fit(X, order): + cnt = np.zeros((V ** order, V)) + for t in range(4, L): + ctx = np.zeros(len(X), dtype=np.int64) + for j in range(order, 0, -1): ctx = ctx * V + X[:, t - j] + np.add.at(cnt, (ctx, X[:, t]), 1) + return cnt +def nll(cnt, X, order): + out = np.zeros(len(X)) + for t in range(4, L): + ctx = np.zeros(len(X), dtype=np.int64) + for j in range(order, 0, -1): ctx = ctx * V + X[:, t - j] + c = cnt[ctx]; out -= np.log((c[np.arange(len(X)), X[:, t]] + alpha) / (c.sum(1) + alpha * V)) + return out / (L - 4) +def auc_small(low, high): # P[low < high] (+0.5 ties) + r = stats.rankdata(np.concatenate([low, high])); n1 = len(low); return 1 - (r[:n1].sum() - n1 * (n1 + 1) / 2) / (n1 * len(high)) +res = {} +for order in (1, 2, 3, 4): + cm = fit(Xm, order); cr = fit(Xr, order) + nm, nn = nll(cm, Xm, order), nll(cm, Xn, order) + rm, rn = nll(cr, Xm, order), nll(cr, Xn, order) + res[order] = (auc_small(nm, nn), auc_small(nm - rm, nn - rn), nn.mean() - nm.mean()) + print(f" order {order}: raw-NLL AUC = {res[order][0]:.3f}, calibrated AUC = {res[order][1]:.3f}, mean NLL gap (non - member) = {res[order][2]:.4f} nats/char") +check("5. no memorisation capacity at order 1-2 (<= true order): raw AUC within 0.04 of 0.5", max(abs(res[1][0] - 0.5), abs(res[2][0] - 0.5)), 0.04) +check("5. membership signal grows with model capacity: raw AUC order 4 > order 3 > order 2", 0.0 if res[4][0] > res[3][0] > res[2][0] else 1.0, 0.0) +check("5. reference calibration helps at the high-capacity order: calibrated AUC > raw AUC + 0.05 (order 4)", 0.0 if res[4][1] > res[4][0] + 0.05 else 1.0, 0.0, f"{res[4][0]:.3f} -> {res[4][1]:.3f}") +check("5. calibrated AUC increases with order and exceeds raw AUC at every order >= 2 (it detects even the small order-2 overfit that raw NLL hides)", 0.0 if (res[1][1] < res[2][1] < res[3][1] < res[4][1] and all(res[o][1] > res[o][0] for o in (2, 3, 4))) else 1.0, 0.0) +# %% [markdown] +# ## Summary +# %% +for n_, e_, t_ in CHECKS: print(f"PASS {n_[:110]}") +print(f"\n{len(CHECKS)} checks, all passed") diff --git a/code/notebooks/_m1_src/0307-aeneas-semantics.py b/code/notebooks/_m1_src/0307-aeneas-semantics.py new file mode 100644 index 0000000000000000000000000000000000000000..7aea27eaab6fef9892ef9fdca2e8bef7d1f0707e --- /dev/null +++ b/code/notebooks/_m1_src/0307-aeneas-semantics.py @@ -0,0 +1,203 @@ +# %% [markdown] +# # 0307 - Aeneas semantics: Rust (safe subset) -> LLBC -> pure Lean, the Result monad, and what a proof of `not P` certifies +# Companion to `notes/0307 - Aeneas Semantics.pdf`. Pure Python, exhaustive enumeration over `u8`; no Lean needed. +# The "Rust abstract machine" below is a small stateful interpreter (store, borrows, panics, fuel). The "translation" is a pure function +# into `Result` with explicit threading of borrowed values (backward functions). Every check compares them on the **whole** finite domain. +# This is a finite test of the refinement statement, not a proof of Aeneas' soundness theorem (that is a meta-theorem about the tool, see the note). +# %% +import os +os.environ["OMP_NUM_THREADS"] = "1" +import itertools, math +from dataclasses import dataclass +CHECKS = [] +def check(name, ok, detail=""): + CHECKS.append((name, bool(ok), detail)) + assert ok, (name, detail) + print(f"[ok] {name} {detail}") +# %% [markdown] +# ## 1. The Result monad: ok / fail(e) / div +# `Result a = ok a | fail Error | div`. `bind` propagates `fail` and `div`. Errors we use: `integerOverflow`, `arrayOutOfBounds`, `panic`. +# Monad laws are checked on a finite carrier (all values of `u8`, three error tags, `div`). +# %% +@dataclass(frozen=True) +class R: + tag: str # 'ok' | 'fail' | 'div' + v: object = None # value for ok, error name for fail +def ok(v): return R('ok', v) +def fail(e): return R('fail', e) +DIV = R('div') +def bind(m, f): + return f(m.v) if m.tag == 'ok' else m +U8 = range(256) +carrier = [ok(v) for v in U8] + [fail('integerOverflow'), fail('arrayOutOfBounds'), fail('panic'), DIV] +f = lambda x: ok(x + 1) if x < 255 else fail('integerOverflow') # a fallible step +g = lambda x: ok(x // 2) if x % 3 else fail('panic') +left = all(bind(ok(v), f) == f(v) for v in U8) +right = all(bind(m, ok) == m for m in carrier) +assoc = all(bind(bind(m, f), g) == bind(m, lambda x: bind(f(x), g)) for m in carrier) +check("1. monad laws on 260 values: left identity, right identity, associativity", left and right and assoc) +# %% [markdown] +# ## 2. Naive translation fails: integers without overflow +# Rust (debug / `overflow-checks`): `fn avg(a: u8, b: u8) -> u8 { (a + b) / 2 }`. The abstract machine panics iff `a + b > 255`. +# NAIVE translation: `a, b` as mathematical integers, no failure case. CORRECT translation: `let s <- a + b; s / 2` with checked `+` in `Result`. +# %% +def rust_avg(a, b): # abstract machine, checked arithmetic + s = a + b + if s > 255: return ('panic', 'integerOverflow') + return ('ret', s // 2) +def tr_naive_avg(a, b): return ok((a + b) // 2) # no Result +def u8_add(a, b): return ok(a + b) if a + b <= 255 else fail('integerOverflow') +def tr_avg(a, b): return bind(u8_add(a, b), lambda s: ok(s // 2)) +def obs(r): return ('ret', r.v) if r.tag == 'ok' else (('panic', r.v) if r.tag == 'fail' else ('div', None)) +DOM = [(a, b) for a in U8 for b in U8] +bad_naive = sum(1 for a, b in DOM if obs(tr_naive_avg(a, b)) != rust_avg(a, b)) +bad_good = sum(1 for a, b in DOM if obs(tr_avg(a, b)) != rust_avg(a, b)) +panics = sum(1 for a, b in DOM if rust_avg(a, b)[0] == 'panic') +check("2. naive translation disagrees with the machine on exactly the panicking inputs", bad_naive == panics == 255 * 256 // 2, f"disagree={bad_naive}, panics={panics}, 255*256/2={255*256//2}") +check("2. Result translation agrees with the machine on all 65536 inputs", bad_good == 0, f"disagree={bad_good}") +# %% [markdown] +# ## 3. Borrow elimination: `&mut` becomes "return the new value" (forward) and, when a borrow is returned, a backward function +# Machine: a store maps addresses to values; `&mut x` is an address. `fn bump(x: &mut u8) { *x += 1 }`. +# Translation: `bump (x : U8) : Result U8` returns the new value and the caller rebinds. +# %% +def machine_bump(store, addr): # returns (outcome, store') + v = store[addr] + 1 + if v > 255: return ('panic', 'integerOverflow'), store # store unchanged when the panic happens before the write + s2 = dict(store); s2[addr] = v + return ('ret', None), s2 +def tr_bump(x): return u8_add(x, 1) +good = 0 +for x in U8: + out, st = machine_bump({0: x}, 0) + r = tr_bump(x) + if out[0] == 'ret': good += (r.tag == 'ok' and r.v == st[0]) + else: good += (r.tag == 'fail' and r.v == out[1]) +check("3a. bump: machine (store, outcome) = translation (returned value, Result) for all u8", good == 256) +# Returned mutable borrow: fn get_mut(v: &mut Vec, i: usize) -> &mut u8 ; caller: *get_mut(&mut v, i) += k +def machine_incr_at(vec, i, k): + heap = {j: x for j, x in enumerate(vec)} # elements are addressable places + if i >= len(vec): return ('panic', 'arrayOutOfBounds'), vec + v = heap[i] + k + if v > 255: return ('panic', 'integerOverflow'), vec + heap[i] = v + return ('ret', None), [heap[j] for j in range(len(vec))] +def tr_get_mut(v, i): # forward value and BACKWARD function back : u8 -> Vec + if i >= len(v): return fail('arrayOutOfBounds') + back = lambda new, v=v, i=i: tuple(new if j == i else x for j, x in enumerate(v)) + return ok((v[i], back)) +def tr_incr_at(v, i, k): + return bind(tr_get_mut(v, i), lambda p: bind(u8_add(p[0], k), lambda new: ok(p[1](new)))) +cases = ties = 0 +for n in range(0, 4): + for vec in itertools.product([0, 1, 254, 255], repeat=n): + for i in range(0, 5): + for k in (0, 1, 2): + out, st = machine_incr_at(list(vec), i, k) + r = tr_incr_at(vec, i, k) + cases += 1 + if out[0] == 'ret': ties += (r.tag == 'ok' and list(r.v) == st) + else: ties += (r.tag == 'fail' and r.v == out[1]) +check("3b. returned &mut: machine vs forward value + backward function, vec len<=3, i<=4, k<=2", ties == cases, f"{ties}/{cases} cases") +# %% [markdown] +# ## 4. Loops become recursion; non-termination is `div` +# `fn spin(x: u8) { while x != 0 { } }`. The machine is run with a fuel bound F; `div` is what survives every F. +# `fn sum_to(n: u8) -> u8 { let mut s = 0; let mut i = 0; while i < n { i += 1; s += i } s }` : the accumulator overflows once n(n+1)/2 > 255, i.e. n >= 23. +# %% +def machine_spin(x, fuel): + for _ in range(fuel): + if x == 0: return ('ret', None) + return ('fuel', None) +def tr_spin(x): return ok(None) if x == 0 else DIV +agree = all(((machine_spin(x, F)[0] == 'ret') == (tr_spin(x).tag == 'ok')) and ((machine_spin(x, F)[0] == 'fuel') == (tr_spin(x).tag == 'div')) for x in U8 for F in (1, 10, 1000)) +check("4a. spin: 'div' corresponds to fuel exhaustion for every tested fuel", agree) +def machine_sum_to(n): + s = i = 0 + while i < n: + i += 1; s += i + if s > 255: return ('panic', 'integerOverflow') + return ('ret', s) +def tr_sum_loop(n, i=0, s=0): # the recursive function Aeneas would emit + if i < n: + i2 = i + 1 + r = u8_add(s, i2) + return bind(r, lambda s2: tr_sum_loop(n, i2, s2)) + return ok(s) +m = [machine_sum_to(n) for n in U8] +check("4b. sum_to: loop = recursive translation for all n in u8", all(obs(tr_sum_loop(n)) == m[n] for n in U8)) +first_bad = min(n for n in U8 if m[n][0] == 'panic') +check("4b. first panicking n is 23 (n(n+1)/2 = 276 > 255, 22*23/2 = 253 <= 255)", first_bad == 23 and 22 * 23 // 2 == 253 and 23 * 24 // 2 == 276, f"first_bad={first_bad}") +# %% [markdown] +# ## 5. A proof of `not P`: closed proposition versus negation under binders +# Property of `avg`: `P(a,b,r)`: `min(a,b) <= r <= max(a,b)`, evaluated on the translated function, with `Q(a,b) := exists r, avg a b = ok r /\ P(a,b,r)`. +# Closed statement S := forall a b, H a b -> Q a b (H trivially true here). Its negation not S <=> exists a b, H a b /\ not Q a b. +# Different, much stronger statement S' := forall a b, H a b -> not Q a b. Under binders, `not Q a b` for a free (a,b) is a claim about *every* instance. +# %% +def Q(a, b): + r = tr_avg(a, b) + return r.tag == 'ok' and min(a, b) <= r.v <= max(a, b) +H = lambda a, b: True +S = all((not H(a, b)) or Q(a, b) for a, b in DOM) +notS = any(H(a, b) and not Q(a, b) for a, b in DOM) +Sprime = all((not H(a, b)) or (not Q(a, b)) for a, b in DOM) +witnesses = [(a, b) for a, b in DOM if H(a, b) and not Q(a, b)] +check("5a. S is false and (not S) holds, certified by one witness", (not S) and notS and witnesses[0] == (1, 255), f"|W|={len(witnesses)}, first witness {witnesses[0]}") +check("5a. the 'under binders' strengthening S' (forall x, not Q x) is false: Q holds at (0,0)", (not Sprime) and Q(0, 0), f"|{{Q}}|={len(DOM)-len(witnesses)}") +check("5a. |W| = number of panicking inputs = 32640", len(witnesses) == 32640) +# With a precondition H a b := a + b <= 255 the closed statement is TRUE and its negation is not provable +H2 = lambda a, b: a + b <= 255 +check("5b. with H := a+b<=255, S_H holds (so no certificate for not S_H exists)", all((not H2(a, b)) or Q(a, b) for a, b in DOM)) +# Vacuity: if H is unsatisfiable, the under-binders form is provable and yet there is no witness at all +H3 = lambda a, b: a + b > 255 and a + b < 256 +check("5c. unsatisfiable H: S'_H = forall x, H x -> not Q x holds vacuously, but the witness set is empty (no bug evidence)", + all((not H3(a, b)) or (not Q(a, b)) for a, b in DOM) and not any(H3(a, b) for a, b in DOM)) +# And with H satisfiable and S'_H true (every H-input fails): S'_H implies the closed negation +H4 = lambda a, b: a + b >= 256 +check("5c. satisfiable H := a+b>=256: S'_H holds (every such input fails) and so implies not S_H", all((not H4(a, b)) or (not Q(a, b)) for a, b in DOM) and any(H4(a, b) for a, b in DOM)) +# %% [markdown] +# ## 6. Reachability: the certificate is about `avg`, not about the program that calls it +# Public caller: `fn mean_pct(a: u8, b: u8) -> u8 { avg(min(a, c), min(b, c)) }` for a clamp `c` (a property of the *caller*). +# Reach_c = {(a', b'): a', b' <= c}. Witnesses W from section 5 are real bugs of the program only if W intersect Reach_c is non-empty. +# %% +def count_formula(c): # pairs in [0,c]^2 with a+b >= 256 + return sum(max(0, min(c, 255) - max(0, 256 - a) + 1) for a in range(0, min(c, 255) + 1)) +rows = [] +for c in (50, 100, 127, 128, 150, 200, 255): + brute = sum(1 for a, b in witnesses if a <= c and b <= c) + rows.append((c, brute, count_formula(c))) + assert brute == count_formula(c) + print(f" clamp c={c:3d}: |W ∩ Reach_c| = {brute:6d} (closed form {count_formula(c)})") +check("6. |W ∩ Reach_c| equals the closed form for all tested c", all(b == f for _, b, f in rows)) +check("6. c <= 127: certificate holds for avg yet the program mean_pct never panics (false alarm)", dict((c, b) for c, b, _ in rows)[127] == 0 and dict((c, b) for c, b, _ in rows)[128] == 1, "c=127 -> 0, c=128 -> 1 (128+128=256)") +# end-to-end: run the caller on its whole input domain +def mean_pct(a, b, c): return tr_avg(min(a, c), min(b, c)) +for c in (100, 127, 128, 200): + panics_c = sum(1 for a, b in DOM if mean_pct(a, b, c).tag == 'fail') + print(f" mean_pct with clamp {c}: panicking inputs over all 65536 raw (a,b): {panics_c}") +check("6. the caller with c=127 never fails on any of 65536 raw inputs; with c=128 it fails (a,b >= 128)", all(mean_pct(a, b, 127).tag == 'ok' for a, b in DOM) and sum(1 for a, b in DOM if mean_pct(a, b, 128).tag == 'fail') == 128 * 128) +# %% [markdown] +# ## 7. Translation soundness: wrong translations give wrong certificates in both directions +# * U1 (too lax): unbounded integers. Then `S` is *provable*, the real program violates it: a missed bug. +# * U2 (wrapping, release-mode semantics): `Q` fails on the same inputs but as a wrong value, and panic-freedom becomes provable. "The bug" depends on which Rust semantics is modelled. +# * U3 (too strict): checks overflow on the *widened* sum `(a as u16 + b as u16)/2`, which is fine in Rust, but translates the u16 add as a u8 add: spurious `fail`, a false certificate. +# %% +def Q_with(trf): return lambda a, b: (lambda r: r.tag == 'ok' and min(a, b) <= r.v <= max(a, b))(trf(a, b)) +trU1 = tr_naive_avg +trU2 = lambda a, b: ok(((a + b) % 256) // 2) +def rust_wide(a, b): return ('ret', (a + b) // 2) # `((a as u16 + b as u16) / 2) as u8` never panics +trU3 = lambda a, b: bind(u8_add(a, b), lambda s: ok(s // 2)) # wrong: widened add checked at u8 +Wset = lambda q: sum(1 for a, b in DOM if not q(a, b)) +print(" failing sets |{not Q}|: truth(avg, checked) =", Wset(Q), ", U1 (unbounded) =", Wset(Q_with(trU1)), ", U2 (wrapping) =", Wset(Q_with(trU2))) +check("7a. U1: S is provable under the unbounded translation (no witness), but the program panics on 32640 inputs", Wset(Q_with(trU1)) == 0 and Wset(Q) == 32640) +pf_checked = all(tr_avg(a, b).tag == 'ok' for a, b in DOM); pf_wrap = all(trU2(a, b).tag == 'ok' for a, b in DOM) +same_set = {(a, b) for a, b in DOM if not Q_with(trU2)(a, b)} == set(witnesses) +check("7b. U2: wrapping gives the SAME 32640 witnesses for P but for a different reason (wrong value, not panic); panic-freedom is provable under U2 and false under checked semantics", + same_set and pf_wrap and not pf_checked, f"|W_wrap|={Wset(Q_with(trU2))}, panic-free: wrap={pf_wrap}, checked={pf_checked}") +# wide version of the program: truth never panics, so S_wide holds; the strict translation certifies a bug +Qwide_true = lambda a, b: min(a, b) <= rust_wide(a, b)[1] <= max(a, b) +check("7c. U3: program `avg_wide` is correct on all inputs; the over-strict translation yields 32640 spurious witnesses", all(Qwide_true(a, b) for a, b in DOM) and Wset(Q_with(trU3)) == 32640) +# %% [markdown] +# ## Summary +# %% +for n, ok_, d in CHECKS: print(("PASS " if ok_ else "FAIL ") + n) +assert all(c[1] for c in CHECKS) +print(f"\n{len(CHECKS)} checks, all passed") diff --git a/code/notes/0101 - Autoregressive Likelihood and SFT.pdf b/code/notes/0101 - Autoregressive Likelihood and SFT.pdf new file mode 100644 index 0000000000000000000000000000000000000000..77ca9f4f6457e73d292502f9389c63f201d93baa --- /dev/null +++ b/code/notes/0101 - Autoregressive Likelihood and SFT.pdf @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:081188786576e847b16e9bb894cf8e636670a33a6737a13f1ae8302f3ca875e8 +size 399498 diff --git a/code/notes/0102 - Policy Gradient and Baselines.pdf b/code/notes/0102 - Policy Gradient and Baselines.pdf new file mode 100644 index 0000000000000000000000000000000000000000..fcbccdaa31e63a128632457fae396c75fb74f7c6 --- /dev/null +++ b/code/notes/0102 - Policy Gradient and Baselines.pdf @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:ec961965e6eec8b0d5e48846ddc3050f1da42bbd79734095ce8fcb0b776682d6 +size 433120 diff --git a/code/notes/0103 - Importance Sampling and Off-Policy Updates.pdf b/code/notes/0103 - Importance Sampling and Off-Policy Updates.pdf new file mode 100644 index 0000000000000000000000000000000000000000..48b4b1444edc3c22adffe796d78cc13a7c3fc559 --- /dev/null +++ b/code/notes/0103 - Importance Sampling and Off-Policy Updates.pdf @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:1cba40ae0cef29c1d2a9ab9b790f3fd93526837249afa8820e546fccfd2852ce +size 384080 diff --git a/code/notes/0104 - Trust Regions and the PPO Clip.pdf b/code/notes/0104 - Trust Regions and the PPO Clip.pdf new file mode 100644 index 0000000000000000000000000000000000000000..2457bc45a5544c30f76d5d3eeb2b1c3e51597155 --- /dev/null +++ b/code/notes/0104 - Trust Regions and the PPO Clip.pdf @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:a46cbbd16bd9b646a11e3a378939fba646422fa953ca1923f6c295a0970cf282 +size 421906 diff --git a/code/notes/0105 - GRPO and Dr GRPO.pdf b/code/notes/0105 - GRPO and Dr GRPO.pdf new file mode 100644 index 0000000000000000000000000000000000000000..703ef4dc2e2aa0159615c4c64ba07c5017d9df86 --- /dev/null +++ b/code/notes/0105 - GRPO and Dr GRPO.pdf @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:ea0740f775be7b4ceec6eb6091f76d1879b5d6797e665fa9cd06209540d52324 +size 403562 diff --git a/code/notes/0106 - DAPO.pdf b/code/notes/0106 - DAPO.pdf new file mode 100644 index 0000000000000000000000000000000000000000..866823968b4bdd947c05b305f40d13572eeaec17 --- /dev/null +++ b/code/notes/0106 - DAPO.pdf @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:ae96dc8a306eb77d7aeb2ed98c73dd239e875cab99bd0b9b0e71828ff358314d +size 373162 diff --git a/code/notes/0107 - CISPO.pdf b/code/notes/0107 - CISPO.pdf new file mode 100644 index 0000000000000000000000000000000000000000..14fbc0cd885fdf307dab8fc4eb3c2792836ad1a6 --- /dev/null +++ b/code/notes/0107 - CISPO.pdf @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:0210a5197f302068ce5a5f54861f33d2b3fb3c482e63d03f1823da7ab0b2763c +size 462763 diff --git a/code/notes/0108 - Training-Inference Mismatch.pdf b/code/notes/0108 - Training-Inference Mismatch.pdf new file mode 100644 index 0000000000000000000000000000000000000000..8ec7b37a53121f2c707840e1e736074eb7f3356c --- /dev/null +++ b/code/notes/0108 - Training-Inference Mismatch.pdf @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:39198cc04c77c6c5d5fc23426c4106db8b150294e6c51ac1ba7beb728689bfaf +size 445401 diff --git a/code/notes/0109 - KL Estimators.pdf b/code/notes/0109 - KL Estimators.pdf new file mode 100644 index 0000000000000000000000000000000000000000..90e350110a159f0149e5c6d3c64a7b4fc51b0066 --- /dev/null +++ b/code/notes/0109 - KL Estimators.pdf @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:65f2fc017a9df5e3ab5f633f7f9f8a14c6ecd5fbde7e0d0f0473ccd9313350a8 +size 435676 diff --git a/code/notes/0110 - Reward Shaping for Verifier Rewards.pdf b/code/notes/0110 - Reward Shaping for Verifier Rewards.pdf new file mode 100644 index 0000000000000000000000000000000000000000..3d1f6b3a0f3878539577cb3cd867438e1e628d7d --- /dev/null +++ b/code/notes/0110 - Reward Shaping for Verifier Rewards.pdf @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:70103de329c582f76dce736873f8179beb5b2082263196e458dd2efaa03736b0 +size 352624 diff --git a/code/notes/0201 - Verifier in the Loop as a Binary-Reward MDP.pdf b/code/notes/0201 - Verifier in the Loop as a Binary-Reward MDP.pdf new file mode 100644 index 0000000000000000000000000000000000000000..2045660a5094ab65704bc21711b4e915a631255d --- /dev/null +++ b/code/notes/0201 - Verifier in the Loop as a Binary-Reward MDP.pdf @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:f80bcbdd6275c6cac0ca2947fbfb3fcd1e4a0894c7defd91f69bb62129d4e70e +size 360457 diff --git a/code/notes/0202 - Rejection Sampling and Expert Iteration as Policy Improvement.pdf b/code/notes/0202 - Rejection Sampling and Expert Iteration as Policy Improvement.pdf new file mode 100644 index 0000000000000000000000000000000000000000..ff311cce6474f5975023274fcb62fc04420a6d40 --- /dev/null +++ b/code/notes/0202 - Rejection Sampling and Expert Iteration as Policy Improvement.pdf @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:a7f5cfdc29a6b763b924f03871a92ca8423ae39d3d6b3a7f848af428c1348173 +size 313465 diff --git a/code/notes/0203 - pass@k Estimator, Variance and the pass@1 Trade-off.pdf b/code/notes/0203 - pass@k Estimator, Variance and the pass@1 Trade-off.pdf new file mode 100644 index 0000000000000000000000000000000000000000..0a8c67efd93d68651d96eb52515d9b6a333d277e --- /dev/null +++ b/code/notes/0203 - pass@k Estimator, Variance and the pass@1 Trade-off.pdf @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:f0585f443896d56a893a55eb83c60466576196bb483284668e84c2de7f1cd19d +size 370754 diff --git a/code/notes/0204 - Self-Correction as a Multi-Turn MDP.pdf b/code/notes/0204 - Self-Correction as a Multi-Turn MDP.pdf new file mode 100644 index 0000000000000000000000000000000000000000..3d8ae9d70976e928adaf31fa1bf66ea922a67d90 --- /dev/null +++ b/code/notes/0204 - Self-Correction as a Multi-Turn MDP.pdf @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:42b85f3fb5463741289cc6d1168685bbc615d402375b683cb9c47bc9f12444d1 +size 325376 diff --git a/code/notes/0205 - Model Averaging and Weight Interpolation.pdf b/code/notes/0205 - Model Averaging and Weight Interpolation.pdf new file mode 100644 index 0000000000000000000000000000000000000000..1230cdc84f22cfb5f49d643a4ce5655c4ea87ad4 --- /dev/null +++ b/code/notes/0205 - Model Averaging and Weight Interpolation.pdf @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:b7dc65b14e789730ebf9c3f59daeddacb4e49bf2e7d7173206b2d36e2fb8d15a +size 360596 diff --git a/code/notes/0206 - Curriculum by Pass-Rate Band.pdf b/code/notes/0206 - Curriculum by Pass-Rate Band.pdf new file mode 100644 index 0000000000000000000000000000000000000000..49e39c2ab5ef2191b9adb34aa91b1cf95f70c72a --- /dev/null +++ b/code/notes/0206 - Curriculum by Pass-Rate Band.pdf @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:1527a31efdcb6cb9997f8ff1531f3e7297ee0f690cf0ed050379c007942813fa +size 299607 diff --git a/code/notes/0207 - Test-Time Scaling Cost Model.pdf b/code/notes/0207 - Test-Time Scaling Cost Model.pdf new file mode 100644 index 0000000000000000000000000000000000000000..b8b6301fae6664e07a1cbfa0cb3a9bd4579167b5 --- /dev/null +++ b/code/notes/0207 - Test-Time Scaling Cost Model.pdf @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:5f9eb87cc2db0ee115a48573d335c374f568eebe6082d0e4b401a10e147811f6 +size 300240 diff --git a/code/notes/0208 - Statistics of Evaluation.pdf b/code/notes/0208 - Statistics of Evaluation.pdf new file mode 100644 index 0000000000000000000000000000000000000000..40b9a2984c278437b415ffeffabfc75603dffe2d --- /dev/null +++ b/code/notes/0208 - Statistics of Evaluation.pdf @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:ec909832e8312db2be1aa99e484ddaaebae7b17e5c034d90e32a16c0010176b2 +size 374783 diff --git a/code/notes/0209 - Variance Components and Equivalence Tests.pdf b/code/notes/0209 - Variance Components and Equivalence Tests.pdf new file mode 100644 index 0000000000000000000000000000000000000000..c7293f1a1c043c539163193d93bc3a6c1e30a2d5 --- /dev/null +++ b/code/notes/0209 - Variance Components and Equivalence Tests.pdf @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:5a2a35f3ffac9ab787d77c3c39dd7addc681642891359fd8fb1bde55587881e4 +size 389947 diff --git a/code/notes/0210 - Data Selection and Contamination.pdf b/code/notes/0210 - Data Selection and Contamination.pdf new file mode 100644 index 0000000000000000000000000000000000000000..143dc89dee00901ef20594eb726a28db5ee66043 --- /dev/null +++ b/code/notes/0210 - Data Selection and Contamination.pdf @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:637bb8ef20059a4eed4ed8b4176e6dc062aca78c8996b39fb1231988542d7403 +size 376892 diff --git a/code/notes/0301 - Curry-Howard.pdf b/code/notes/0301 - Curry-Howard.pdf new file mode 100644 index 0000000000000000000000000000000000000000..c62be926dfeda7660458017f4303ac7a2bb26156 --- /dev/null +++ b/code/notes/0301 - Curry-Howard.pdf @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:c1de65271a6e55ff4a0131e6bac141939667708c6b3dae6c93d6d3c84c612d4c +size 490280 diff --git a/code/notes/0302 - Dependent Types.pdf b/code/notes/0302 - Dependent Types.pdf new file mode 100644 index 0000000000000000000000000000000000000000..c36cd5b3b135df7f33b99a7bb75ec363c2a192af --- /dev/null +++ b/code/notes/0302 - Dependent Types.pdf @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:7a3a1000b8bd0a6c1c22e9cdd959ccd7047a25ed616f3f099e5a2a5c9150e842 +size 527709 diff --git a/code/notes/0303 - Hoare Logic and Refinement.pdf b/code/notes/0303 - Hoare Logic and Refinement.pdf new file mode 100644 index 0000000000000000000000000000000000000000..d04e0a3f8e1244c2d5cb5285d4f1618b222fc7c3 --- /dev/null +++ b/code/notes/0303 - Hoare Logic and Refinement.pdf @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:e51aae03ecf2b441b78318ffe868d9f075a366acef846a116eecb96ec8905398 +size 523919 diff --git a/code/notes/0304 - Failed Proof to Bug.pdf b/code/notes/0304 - Failed Proof to Bug.pdf new file mode 100644 index 0000000000000000000000000000000000000000..a6cd4fd6980fbf231f19960d5e70e0a32e43d93f --- /dev/null +++ b/code/notes/0304 - Failed Proof to Bug.pdf @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:2feaae5d5dac36d15eb6c3a2bd4e56b40f44b4c539e69930d8ee2b90f789269c +size 490914 diff --git a/code/notes/0305 - Spec Generation Risk.pdf b/code/notes/0305 - Spec Generation Risk.pdf new file mode 100644 index 0000000000000000000000000000000000000000..eeb01c457a9de95a89cbe7a96707ddaa49327156 --- /dev/null +++ b/code/notes/0305 - Spec Generation Risk.pdf @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:3a2b4f682350d895ca0bd04359048fd544f179b6df91c7f459bfc1e9f5e09666 +size 555009 diff --git a/code/notes/0306 - Verified Code Benchmarks.pdf b/code/notes/0306 - Verified Code Benchmarks.pdf new file mode 100644 index 0000000000000000000000000000000000000000..5696ac476cb6c48b9de4c803b6c7af1ac7af246b --- /dev/null +++ b/code/notes/0306 - Verified Code Benchmarks.pdf @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:d29c6c3f84c261ef7ec351a7ac3ad7990c850a2487763fc88d42b7f9ae877aed +size 570520 diff --git a/code/notes/0307 - Aeneas Semantics.pdf b/code/notes/0307 - Aeneas Semantics.pdf new file mode 100644 index 0000000000000000000000000000000000000000..fe756851e6a756616fb8540d48124b2a191a2dcf --- /dev/null +++ b/code/notes/0307 - Aeneas Semantics.pdf @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:d096ff5d99dc32c71ba7940007739b9a7e6de367bc993e21c86d8bedab90fe29 +size 433760 diff --git a/code/notes/src/.gitignore b/code/notes/src/.gitignore new file mode 100644 index 0000000000000000000000000000000000000000..567609b1234a9b8806c5a05da6c866e480aa148d --- /dev/null +++ b/code/notes/src/.gitignore @@ -0,0 +1 @@ +build/ diff --git a/code/notes/src/0101-autoregressive-likelihood-sft.tex b/code/notes/src/0101-autoregressive-likelihood-sft.tex new file mode 100644 index 0000000000000000000000000000000000000000..401282bf1019111d19f0e3420815f99558dba21b --- /dev/null +++ b/code/notes/src/0101-autoregressive-likelihood-sft.tex @@ -0,0 +1,110 @@ +\documentclass[11pt]{article} +\usepackage{common} +\begin{document} +\notehead{0101}{Autoregressive likelihood, token vs.\ sequence view, SFT $=$ MLE $=$ forward KL}{Policy-optimisation lineage, batch M1. Companion notebook: \texttt{notebooks/0101-autoregressive-likelihood-sft.ipynb}.} + +\begin{problem} +\question{What probability distribution over variable-length responses does a language model define, when is a response one object and when a chain of decisions, and what does SFT actually optimise?} +Every later note manipulates $\log\pi_\theta(o\mid q)$, ratios $\pi_\theta/\pi_{\theta'}$, KL divergences and score vectors $\nabla_\theta\log\pi_\theta$ of a \emph{variable-length} response. +Before any of that is meaningful we must (i) say exactly which probability distribution over responses a language model defines, and prove that it is one, (ii) say when we may treat a response as one object (sequence view) and when as a chain of decisions (token view), and (iii) identify what supervised fine-tuning (SFT) actually optimises, so that in note 0102 we can see precisely what RL changes. +\end{problem} + +\section{Notation} +\begin{center}\small +\begin{tabular}{@{}ll@{}}\toprule +symbol & meaning\\\midrule +$q$ & prompt (context that is never generated, only conditioned on)\\ +$o=(o_1,\dots,o_{|o|})$ & response, $o_t\in\mathcal V$; $|o|\le L$ is the length\\ +$o_{0}\pi(o)\le0$, with equality iff $\pi=p$ on $\mathrm{supp}\,p$. If the family contains $p$, the unique minimiser reproduces it. +\ver{$\mathrm{CE}=H+\KL$ to $0$ (fp64 identical); L-BFGS from a seeded random start drives $\KL(p\|\pi_\theta)$ to $5\times10^{-17}$ (round-off level) and recovers the teacher sequence distribution to $2\times10^{-9}$ (max abs.\ probability error).} + +\paragraph{Step 3 (token-level decomposition: teacher forcing).} Write $\log\frac{p(o)}{\pi(o)}=\sum_{t\le|o|}\log\frac{p(o_t\mid s_t)}{\pi(o_t\mid s_t)}$, take $\E_p$ and condition on $s_t$ (tower rule). The event $\{|o|\ge t\}$ (``the response has not ended before step $t$'') is determined by $o_{0$ (true for a softmax, whose support is all of $\mathcal O_L$), so $\nabla\pi_\theta=\pi_\theta\nabla\log\pi_\theta$ holds everywhere. Then +\begin{align} +\nabla_\theta J&=\sum_o R(o)\,\nabla_\theta\pi_\theta(o)=\sum_o\pi_\theta(o)\,R(o)\,\nabla_\theta\log\pi_\theta(o)=\E_{o\sim\pi_\theta}\big[R(o)\,s(o)\big]\label{eq:ld}\\ +&=\E_{o\sim\pi_\theta}\Big[R(o)\sum_{t=1}^{|o|}\nabla_\theta\log\pi_\theta(o_t\mid s_t)\Big]\quad\text{(using 0101, Eq.~(2)).}\nonumber +\end{align} +The right side is an expectation under the \emph{current} policy of a quantity we can compute from samples: this is REINFORCE (Williams 1992, as used in arXiv:2510.26788 Eq.~2). No derivative of $R$ appears. +\ver{three computations of $\nabla J$ agree: autograd of $\sum_o\pi R$ vs.\ Eq.~\eqref{eq:ld}: $1.4\times10^{-17}$; vs.\ central finite differences: $3.4\times10^{-11}$.} + +\subsection{The score has zero mean} +\begin{lemma}\label{lem:zero}For every state $s_t$: $\sum_a\pi_\theta(a\mid s_t)\nabla_\theta\log\pi_\theta(a\mid s_t)=0$, and hence $\E_{o\sim\pi_\theta}[s(o)]=0$.\end{lemma} +\emph{Proof.} $\sum_a\pi\nabla\log\pi=\sum_a\nabla\pi(a)=\nabla\sum_a\pi(a)=\nabla 1=0$. The sequence statement is the same computation on $\sum_o\pi(o)=1$. $\blacksquare$ +\ver{$\E_\pi[s]$ has max component $5.6\times10^{-17}$.} + +\subsection{Baselines: which ones are free} +\paragraph{Constant (or prompt-only) baseline.} For $b$ independent of the sampled $o$: $\E[(R-b)s]=\E[Rs]-b\,\E[s]=\nabla J$ by Lemma~\ref{lem:zero}. Any $b$, even a wrong one, leaves the expectation unchanged. + +\paragraph{State-dependent baseline.} Let $b_t=b_t(s_t)$ depend on the prefix only. Condition on $s_t$ (tower rule; the event $\{|o|\ge t\}$ that token $t$ exists is decided by $o_{0$: +\[V(b)\E R\iff\Cov(R,\|s\|^2)>0$. +\ver{Eq.~\eqref{eq:var} vs.\ direct variance: $\le8.9\times10^{-16}$; on the toy $b^\star=0.302$, $\E R=0.166$, $V(0)=0.690$, $V(\E R)=0.516$, $V(b^\star)=0.472$; here the successes carry the larger score norm ($\E[\|s\|^2\mid R{=}1]=4.36$ vs.\ $\E[\|s\|^2\mid R{=}0]=2.00$), which is why $b^\star>\E R$; the interval statement holds at 50 interior and 4 exterior points.} + +\subsection{Group baselines (preview of 0105)} +Draw $G$ i.i.d.\ responses $o_1,\dots,o_G$ for one prompt with rewards $R_i$. +\begin{itemize} +\item \emph{Leave-one-out (RLOO):} $b_i=\frac1{G-1}\sum_{j\ne i}R_j$ is independent of $o_i$, so $\E[(R_i-b_i)s_i]=\nabla J$. +\item \emph{Mean including self:} $\bar R=\frac1G\sum_jR_j$. Then $\E[(R_i-\bar R)s_i]=(1-\tfrac1G)\E[R_is_i]-\tfrac1G\sum_{j\ne i}\E[R_j]\E[s_i]=(1-\tfrac1G)\nabla J$ (independence and Lemma~\ref{lem:zero}). The estimator is unbiased \emph{up to the known scale} $(G-1)/G$, which a learning rate absorbs. Indeed $\frac{G}{G-1}(R_i-\bar R)=R_i-\frac1{G-1}\sum_{j\ne i}R_j$ (arXiv:2503.20783 App.~A). +\end{itemize} +\ver{exact enumeration of all $121^2$ ($G{=}2$) and $121^3$ ($G{=}3$) sample tuples: LOO error $\le2.1\times10^{-17}$; mean-including-self equals $\frac{G-1}{G}\nabla J$ to $\le1.0\times10^{-17}$.} + +\subsection{Implementation: naive vs.\ efficient} +Naive: a Python loop calling \texttt{backward} on $(R_i-b)\log\pi(o_i)$ once per sample. Efficient: because $\hat g=\nabla_\theta[\sg(A)\,\log\pi_\theta]$ where $\sg$ is stop-gradient, one backward through the \emph{surrogate} $\frac1n\sum_i\sg(A_i)\log\pi_\theta(o_i)$ gives the whole estimate. (The surrogate is not $J$; its value is meaningless, only its gradient is.) The stop-gradient on the advantage is essential; later notes extend this idea to the ratio. +\ver{loop vs.\ single backward: $8.6\times10^{-16}$; Monte Carlo estimate from $4\times10^5$ samples within $2.0$ standard errors (max over components) of the exact gradient.} + +\section{Boxed result} +\begin{result} +\[\boxed{\begin{aligned}\nabla_\theta J&=\E_{o\sim\pi_\theta}\Big[\sum_{t=1}^{|o|}\big(G_t-b_t(s_t)\big)\,\nabla_\theta\log\pi_\theta(o_t\mid s_t)\Big]\quad\text{for any }b_t=b_t(s_t),\\ +V(b)&=A_0-2bA_1+b^2A_2-\|\nabla J\|^2,\qquad b^\star=\frac{\E[R\|s\|^2]}{\E[\|s\|^2]},\qquad V(b)0$ wherever $p(o)f(o)\ne0$ (absolute continuity), +\begin{equation}\E_p[f]=\sum_op(o)f(o)=\sum_oq(o)\frac{p(o)}{q(o)}f(o)=\E_q\big[w\,f\big].\label{eq:is}\end{equation} +This is an algebraic identity, hence unbiased; the only assumption is the support condition. Taking $f\equiv1$: $\E_q[w]=1$. +\ver{$\E_q[wf]=\E_p[f]$ and $\E_q[w]=1$ hold to $0$ (fp64) on all 121 sequences.} + +\subsection{Variance of the weight} +\begin{align} +\Var_q[wf]&=\E_q[w^2f^2]-(\E_pf)^2=\E_p[w f^2]-(\E_pf)^2,\label{eq:isvar}\\ +\Var_q[w]&=\E_q[w^2]-1=\sum_o\frac{p^2}{q}-1=\sum_o\frac{(p-q)^2}{q}=\chi^2(p\|q)=\E_p[w]-1.\label{eq:chi2} +\end{align} +Eq.~\eqref{eq:chi2}: $\E_q[w^2]=\sum_o q\,p^2/q^2=\sum_op^2/q=\E_p[w]$, and expanding $(p-q)^2/q$ gives $p^2/q-2p+q$, whose sum is $\sum p^2/q-1$. Jensen links this to KL: $\KL(p\|q)=\E_p\log w\le\log\E_pw=\log(1+\chi^2)$, so large KL forces large $\chi^2$. +\ver{Eq.~\eqref{eq:isvar} and Eq.~\eqref{eq:chi2} hold to $\le10^{-13}$ on the toy ($\chi^2=0.0$ cancels exactly in fp64).} + +\paragraph{Effective sample size.} With $n$ i.i.d.\ draws from $q$, the law of large numbers gives $\sum_iw_i\approx n\E_qw=n$ and $\sum_iw_i^2\approx n\E_qw^2$, so +\begin{equation}\ESS\approx\frac{n}{1+\chi^2(p\|q)}.\label{eq:ess}\end{equation} +Self-normalised IS, $\hat\mu=\sum_iw_if_i/\sum_iw_i$, drops the need to know $q,p$ normalisers but is biased at order $1/n$ (ratio of two random sums). +\ver{MC: mean ESS at $n=256$ is $252.20$ vs.\ $n/(1+\chi^2)=252.17$ (rel.\ error $7\times10^{-5}$); SNIS bias $-0.0107\,(n{=}4)\to-0.0028\,(16)\to-0.0010\,(64)\to-0.0002\,(256)$ with standard errors $0.0016,\,0.0008,\,0.0008,\,0.0004$: the $O(1/n)$ shrinkage is statistically resolved from $n=4$ to $16$ only; the two largest-$n$ values are within $1.3\sigma$ of zero.} + +\subsection{Off-policy policy gradient} +With $b$ a baseline independent of the sample, 0102 gives $\nabla J(\theta)=\sum_op(o)(R-b)\nabla\log p(o)$. Insert $p=wq$: +\begin{equation}\nabla_\theta J(\theta)=\E_{o\sim\pi_{\theta_{\rm old}}}\Big[w(o)\,(R(o)-b)\,\nabla_\theta\log\pi_\theta(o)\Big],\qquad w(o)=\prod_{t=1}^{|o|}r_t(\theta).\label{eq:offpg}\end{equation} +The product form is exactly the chain rule of 0101, Eq.~(1). This is the \emph{sequence-level} estimator written in arXiv:2510.26788 Eq.~5. For an outcome reward $R$ the weight cannot be shortened: the reward depends on every token, so every token's ratio enters. +\ver{Eq.~\eqref{eq:offpg} equals the autograd gradient of $J(\theta)$ to $4\times10^{-17}$ at $\delta=0$ and $2.8\times10^{-17}$ at $\|\theta-\theta_{\rm old}\|=0.32$.} + +\subsection{Why the ratio explodes with length} +\paragraph{Exact recursion.} By the tower rule on the last token, +\begin{equation}\E_q[w_T^2]=\E_q\Big[w_{T-1}^2\,\E_{o_T\mid s_T}[r_T^2]\Big]=\E_q\Big[w_{T-1}^2\big(1+\chi_T^2(s_T)\big)\Big],\label{eq:rec}\end{equation} +using Eq.~\eqref{eq:chi2} at the single state $s_T$. If every state has $\chi^2_t(s)\ge c$ then $\E_q[w_T^2]\ge(1+c)^T$, i.e.\ $\ESS/n\lesssim(1+c)^{-T}$ (an \emph{upper} bound on the effective sample fraction). For i.i.d.\ token ratios it is an equality: +\begin{equation}\Var_q[w_T]=(1+\chi_1^2)^T-1.\label{eq:geom}\end{equation} +\ver{brute-force enumeration of all $3^T$ token strings equals $(1+\chi_1^2)^T$ to $\le4.3\times10^{-16}$ (relative) for $T=1,3,6,9$.} + +\paragraph{Mean one, typical value zero.} The mean $\E_q[w_T]=1$ is exactly preserved for every $T$ (the weights are a martingale). But for the typical sample, +\begin{equation}\E_q[\log w_T]=-\sum_{t\le T}\E_{s_t\sim q}\KL\big(\pi_{\rm old}(\cdot|s_t)\,\|\,\pi_\theta(\cdot|s_t)\big)\le0,\label{eq:typ}\end{equation} +which follows by taking $\E_q$ of $\sum_t\log r_t$ and the tower rule. If $\log r_t$ are approximately i.i.d.\ $\mathcal N(-\sigma^2/2,\sigma^2)$ (the Gaussian that makes $\E r=1$), then $\log w_T\sim\mathcal N(-T\sigma^2/2,T\sigma^2)$: the median weight is $e^{-T\sigma^2/2}\to0$, $\E w_T^2=e^{T\sigma^2}$, and $\ESS/n\approx e^{-T\sigma^2}$. The unit mean is carried by exponentially rare samples, and any finite batch sees only the ones with $w\approx0$ plus a few enormous outliers. +\ver{on the 8191-sequence toy: $\E_q[\log w_T]$ equals minus the tree-recursion sum of conditional KLs to $\le1.9\times10^{-16}$ (checked at $T=3,8$); $\chi^2_T$ grows monotonically ($\times13.3$ from $T=1$ to $12$), while $\E_q[w_T]=1\pm2\times10^{-16}$.} + +\paragraph{Magnitude.} Take a tiny per-token $\chi^2_1=0.01$ (our toy, i.i.d.\ tokens, so Eq.~\eqref{eq:geom} is an equality): $\ESS/n\approx(1.01)^{-T}$ gives $0.91$ at $T=10$, $0.37$ at $T=100$, $4.8\times10^{-5}$ at $T=1000$, $5\times10^{-18}$ at $T=4000$. With $10^{4}$-token reasoning traces even $\chi_1^2=10^{-3}$ gives $e^{-10}\approx4.5\times10^{-5}$. This is the argument behind abandoning the exact sequence weight in practice. + +\subsection{The token-level approximation (what PPO/GRPO do) and its error} +Replace $w(o)=\prod_sr_s$ by the \emph{single} token ratio of the token being differentiated: +\begin{equation}\hat g_{\rm tok}(\theta)=\E_q\Big[\sum_tr_t(\theta)\,(R-b)\,\nabla\log\pi_\theta(o_t\mid s_t)\Big]=\nabla_\theta\E_q\Big[\sum_tr_t(\theta)(R-b)\Big].\label{eq:tok}\end{equation} +The discarded factor for token $t$ is $\prod_{s\ne t}r_s$: the other tokens' ratios, i.e.\ (for $st$) the mismatch of the future. Both factors equal $1$ at $\theta=\theta_{\rm old}$, so Eq.~\eqref{eq:tok} reproduces the true gradient there; it is wrong at $\theta\ne\theta_{\rm old}$ at first order in $\|\theta-\theta_{\rm old}\|$ for the gradient and second order for the surrogate value (0104 turns this into a bound). Measured on the toy: +\begin{center}\small\begin{tabular}{@{}rcc@{}}\toprule +$\|\theta-\theta_{\rm old}\|$ & $\|\hat g_{\rm tok}-\nabla J\|$ & $|\Delta L-\Delta J|$\\\midrule +0.00 & $6\times10^{-17}$ & $3\times10^{-17}$\\ +0.02 & $2.9\times10^{-4}$ & $3.9\times10^{-7}$\\ +0.08 & $1.2\times10^{-3}$ & $6.2\times10^{-6}$\\ +0.32 & $4.7\times10^{-3}$ & $1.0\times10^{-4}$\\\bottomrule\end{tabular}\end{center} +\ver{log--log slopes $1.00$ (gradient) and $2.01$ (value); both errors vanish at $\theta_{\rm old}$ to $\le6\times10^{-17}$.} +GSPO (arXiv:2507.18071) argues from the same observation that a single-sample token weight cannot play the distribution-correcting role of Eq.~\eqref{eq:is} (Sec.~3, Eq.~4) and instead uses the length-normalised sequence ratio $s_i=(\pi_\theta/\pi_{\rm old})^{1/|y_i|}$ (Sec.~4.1, Eq.~7), whose log is the \emph{mean} (not sum) of token log-ratios, which keeps it near $1$ at the price of changing the objective. + +\subsection{Implementation: log-space, not a running product} +Naive: multiply token ratios. Stable: $\log w=\sum_t(\log\pi_\theta-\log\pi_{\rm old})_t$ and exponentiate once (or never, if clipping happens in log space). With $T=4000$, $\sigma=0.5$ the true $\log w=-521.5$; a float32 running product returns exactly $0$, while the log-space value is $3.4\times10^{-227}$ (finite in float64). Both agree to within $10^{-12}$ relative error at $T=50$. + +\section{Boxed result} +\begin{result} +\begin{align*} +&\E_p[f]=\E_q[wf],\qquad \Var_q[w]=\chi^2(p\|q),\qquad \ESS\approx\frac{n}{1+\chi^2}\\ +&\E_q[w_T^2]=\E_q\big[w_{T-1}^2\,(1+\chi_T^2(s_T))\big]\ \Longrightarrow\ \Var_q[w_T]=(1+\chi_1^2)^T-1\quad\text{(i.i.d.\ tokens)}\\ +&\nabla_\theta J(\theta)=\E_{o\sim\pi_{\rm old}}\Big[\prod_{t=1}^{|o|}r_t(\theta)\,(R-b)\,\nabla_\theta\log\pi_\theta(o)\Big] +\end{align*} +\end{result} + +\section{What breaks} +\begin{breaks}\small\begin{itemize} +\item \textbf{Support violations.} Eq.~\eqref{eq:is} needs $q>0$ wherever $p\,f\ne0$. Any sampler that truncates the vocabulary (top-$k$, top-$p$, min-$p$) has $\mu(a)=0$ for tokens the trainer's $\pi(a)>0$; those tokens can never be corrected. \emph{Violated:} absolute continuity (cf.\ 0108). +\item \textbf{Heavy tails / infinite variance.} $\chi^2=\infty$ if $p^2/q$ is not summable; with finite $\chi_1^2$ the variance still grows like $(1+\chi_1^2)^T$. +\item \textbf{ESS collapse.} Eq.~\eqref{eq:ess} is a first-order law-of-large-numbers statement; for $n\ll\ESS^{-1}$ the empirical ESS is far below the prediction because rare huge weights are unobserved. +\item \textbf{SNIS bias} is $O(1/n)$ and not bounded uniformly in $T$. +\item \textbf{Token-level surrogate is not the gradient.} Eq.~\eqref{eq:tok} drops $\prod_{s\ne t}r_s$; the error grows linearly in $\|\theta-\theta_{\rm old}\|$ (measured slope $1.00$). \emph{Violated:} $\pi_\theta$ stays close to $\pi_{\rm old}$ (the trust-region assumption of 0104). +\item \textbf{Stale advantages.} $R-b$ was computed at $\theta_{\rm old}$; for a learned $b$ or value function it is also stale. +\item \textbf{Numerics.} Running products underflow or overflow; clip in log space. +\end{itemize}\end{breaks} + +\nextnote{The weights above are harmless only if $\pi_\theta$ stays near $\pi_{\rm old}$. Note 0104 turns ``stay near'' into a theorem (a lower bound on improvement, TRPO), then derives the PPO clipped objective as a cheap pessimistic proxy for that bound and tabulates exactly which tokens it silences.} + +\section*{References} +{\small\begin{itemize}[leftmargin=1.2em] +\item Yu et al., \emph{DAPO}, arXiv:2503.14476, Sec.~4.1 (512 prompts $\times$16 samples, mini-batch 512, i.e.\ 16 updates per rollout step). +\item MiniMax, \emph{MiniMax-M1}, arXiv:2506.13585, Sec.~3.1 (Eq.~1: PPO ratio $r_{i,t}$; ``16 rounds of off-policy updates per generation batch''). +\item Zheng et al., \emph{Group Sequence Policy Optimization}, arXiv:2507.18071, Sec.~3 (Eq.~4, IS needs many samples; token weight is single-sample noise), Sec.~4.1 Eqs.~5--7. +\item Qi et al., arXiv:2510.26788, Sec.~2.1, Eq.~5 (sequence-level IS policy gradient). +\item The IS identity, $\chi^2$ variance formula, ESS, and the $(1+\chi^2)^T$ recursion are derived above from first principles. +\end{itemize}} +\end{document} diff --git a/code/notes/src/0104-trust-region-ppo-clip.tex b/code/notes/src/0104-trust-region-ppo-clip.tex new file mode 100644 index 0000000000000000000000000000000000000000..db0fe0f2aa29d0a81abeb9ae2c903ab21eeb6bee --- /dev/null +++ b/code/notes/src/0104-trust-region-ppo-clip.tex @@ -0,0 +1,106 @@ +\documentclass[11pt]{article} +\usepackage{common} +\begin{document} +\notehead{0104}{Trust regions $\to$ the PPO clipped surrogate (the clip as a pessimistic bound)}{Policy-optimisation lineage, batch M1. Companion notebook: \texttt{notebooks/0104-trust-region-ppo-clip.ipynb}.} + +\begin{problem} +\question{How far may a step go before the surrogate stops being trustworthy, and can a cheap first-order rule enforce it?} +Note 0103 left us with a surrogate that is exact only at $\theta=\theta_{\rm old}$ and whose error grows with the step. We need (i) a statement of how far a step may go before the surrogate stops being trustworthy, with a guarantee that improving the surrogate improves the true return; and (ii) something cheap enough to run with first-order optimisers on billions of parameters. Part (i) is the TRPO bound; part (ii) is PPO's clip. This note derives both, and tabulates exactly which tokens the clip silences. +\end{problem} + +\section{Notation} +\begin{center}\small\begin{tabular}{@{}ll@{}}\toprule +symbol & meaning\\\midrule +$J(\pi)$ & expected reward; $V_\pi(s)=\E[R\mid s]$ the value of prefix $s$ (terminal: $V=R$)\\ +$A_\pi(s,a)=V_\pi(s\oplus a)-V_\pi(s)$ & advantage (transitions are deterministic, so $Q_\pi(s,a)=V_\pi(s\oplus a)$)\\ +$d_t^\pi$ & distribution of the state $s_t$ after $t$ tokens under $\pi$ (EOS is absorbing)\\ +$T$ & horizon (max response length; $t=0,\dots,T-1$ are decision steps)\\ +$\alpha=\max_s\TV(\pi'(\cdot|s),\pi(\cdot|s))$ & worst-state total variation, $\TV=\tfrac12\|\cdot\|_1$\\ +$\varepsilon=\max_{s,a}|A_\pi(s,a)|$ & largest advantage magnitude (not to be confused with the clip $\epsilon$)\\ +$r_t=\pi_\theta(o_t|s_t)/\pi_{\rm old}(o_t|s_t)$ & token ratio\\ +$\epsilon_{\rm lo},\epsilon_{\rm hi}$ & clip half-widths ($\epsilon_{\rm lo}=\epsilon_{\rm hi}=\epsilon$ in PPO)\\ +$L_\pi(\pi')$ & local surrogate of Eq.~\eqref{eq:surr}\\\bottomrule\end{tabular}\end{center} + +\section{Derivation} +\subsection{Performance difference (exact)} +\begin{proposition}\label{prop:pd}$J(\pi')-J(\pi)=\E_{o\sim\pi'}\big[\sum_{t=0}^{T-1}A_\pi(s_t,o_t)\big]$ for any two policies.\end{proposition} +\emph{Proof.} Along any single trajectory the sum telescopes: $\sum_tA_\pi(s_t,o_t)=\sum_t[V_\pi(s_{t+1})-V_\pi(s_t)]=V_\pi(s_T)-V_\pi(s_0)=R(o)-J(\pi)$, because the terminal value is the reward and $V_\pi(s_0)=J(\pi)$. Take $\E_{\pi'}$. $\blacksquare$ (This is Eq.~1 of TRPO, arXiv:1502.05477, specialised to $\gamma=1$ and a deterministic transition.) +\ver{recursively computed $V_\pi$ gives $V(\emptyset)=J$ to $0$; $\E_{a\sim\pi}A=0$ at all 40 states to $1.5\times10^{-16}$; the telescoped sum equals $R(o)-J$ on every one of the 121 sequences to $1.1\times10^{-16}$; the lemma holds to $\le4.9\times10^{-17}$ at two perturbation sizes.} + +\subsection{Exact importance-sampled form and the surrogate} +$A_\pi(s_t,o_t)$ depends only on $o_{\le t}$, so summing out the future of a $\pi'$-trajectory leaves the marginal of the first $t+1$ tokens, whose ratio to the $\pi$-marginal is the \emph{prefix} weight $w_{1:t}=\prod_{s\le t}r_s$: +\[J(\pi')-J(\pi)=\E_{o\sim\pi}\Big[\sum_tw_{1:t}\,A_\pi(s_t,o_t)\Big].\] +\ver{exact to $\le4.4\times10^{-17}$ (both perturbation sizes).} +The local surrogate keeps only the \emph{current-token} ratio, i.e.\ it evaluates the new policy's action at states drawn from the \emph{old} policy's visitation: +\begin{equation}L_\pi(\pi')=J(\pi)+\E_{o\sim\pi}\Big[\sum_tr_t\,A_\pi(s_t,o_t)\Big]=J(\pi)+\sum_t\E_{s_t\sim d_t^\pi}\E_{a\sim\pi'(\cdot|s_t)}A_\pi(s_t,a)\label{eq:surr}\end{equation} +(TRPO Eq.~3). It differs from the exact form by the factor $w_{1:t-1}$, the state-distribution mismatch. Two properties: +\begin{itemize} +\item $L_\pi(\pi)=J(\pi)$. +\item \emph{First-order matching.} $\nabla L_\pi(\pi_\theta)|_{\theta_{\rm old}}=\nabla J|_{\theta_{\rm old}}$. Proof: differentiating the exact form at $\theta_{\rm old}$ (all $w=1$) gives the extra terms $\E[\sum_t\sum_{ss}A_t\mid s_{s+1}]]$, and $\sum_{t>s}A_t=R-V(s_{s+1})$ has zero conditional mean. $\blacksquare$ +\end{itemize} +\ver{surrogate gradient equals $\nabla J$ at $\theta_{\rm old}$ to $2.1\times10^{-17}$.} + +\subsection{A finite-horizon trust-region bound} +\begin{proposition}\label{prop:tr}$\;|J(\pi')-L_\pi(\pi')|\;\le\;2\varepsilon\,\alpha^2\,T(T-1)\;\le\;\varepsilon\,T(T-1)\,D_{\rm KL}^{\max}(\pi\|\pi')$, where $D_{\rm KL}^{\max}=\max_s\KL(\pi(\cdot|s)\|\pi'(\cdot|s))$.\end{proposition} +\emph{Proof.} Put $g(s)=\E_{a\sim\pi'}A_\pi(s,a)$. Subtract Eq.~\eqref{eq:surr} from Prop.~\ref{prop:pd}: $J'-L=\sum_{t=0}^{T-1}\big(\E_{d_t^{\pi'}}g-\E_{d_t^\pi}g\big)$. +(1) Since $\E_{a\sim\pi}A=0$, $g(s)=\sum_a(\pi'-\pi)(a|s)A(s,a)$, so $|g|\le2\alpha\varepsilon$. +(2) For bounded $g$, $|\E_Pg-\E_Qg|\le2\|g\|_\infty\TV(P,Q)$. +(3) Couple two trajectories that share the state and, at each step, draw actions from a maximal coupling of $\pi'(\cdot|s)$ and $\pi(\cdot|s)$; they disagree with probability $\le\alpha$ per step, and once they agree on the state they stay together until the first disagreement. Hence $\TV(d_t^{\pi'},d_t^{\pi})\le1-(1-\alpha)^t\le t\alpha$ (and $=0$ at $t=0$). +Combining, $|J'-L|\le\sum_{t=0}^{T-1}2(t\alpha)(2\alpha\varepsilon)=4\varepsilon\alpha^2\binom T2=2\varepsilon\alpha^2T(T-1)$. Pinsker, $\TV^2\le\frac12\KL$, gives $\alpha^2\le\frac12D_{\rm KL}^{\max}$. $\blacksquare$ + +This has the structure of TRPO's Theorem~1 (Eqs.~8--9, $C=4\varepsilon\gamma/(1-\gamma)^2$): quadratic in the policy distance, i.e.\ \emph{linear in KL}, and quadratic in the effective horizon. It implies $J(\pi')\ge L_\pi(\pi')-\varepsilon T(T-1)D_{\rm KL}^{\max}$ with equality at $\pi'=\pi$, so maximising the penalised surrogate improves $J$ monotonically (minorise--maximise). +\ver{the bound holds at all six step sizes ($\alpha=0.005\dots0.167$); actual error $\le2.9\times10^{-3}$ of the bound; it is conservative by $\approx340$--$390\times$; Pinsker chain $\alpha^2\le D^{\max}_{\rm KL}/2$ holds with $\max\alpha^2/(D/2)=0.90$.} +\paragraph{Why TRPO itself is not used.} Taking the bound literally gives a penalty coefficient $\varepsilon T(T-1)$, which for a $10^4$-token response is $\sim10^8\varepsilon$ and forces vanishing steps; TRPO therefore uses a hard constraint $D_{\rm KL}\le\delta$, which needs second-order machinery (a Fisher-vector product and conjugate gradients) that is impractical at LLM scale. + +\subsection{The PPO clip as a first-order pessimistic proxy} +PPO (arXiv:1707.06347 Eqs.~6--7) maximises $\E_t[f(r_t,\hat A_t)]$ with +\begin{equation}f(r,A)=\min\!\big(rA,\ \clip(r,1-\epsilon_{\rm lo},1+\epsilon_{\rm hi})\,A\big).\label{eq:ppo}\end{equation} +\paragraph{Closed form.} For $A>0$ the second argument is $A\min(r,1+\epsilon_{\rm hi})$ if $r\ge1-\epsilon_{\rm lo}$ and $Ar$ if smaller, and in every case the minimum with $rA$ equals $A\min(r,1+\epsilon_{\rm hi})$. For $A<0$ multiplying by $A$ reverses order, giving $A\max(r,1-\epsilon_{\rm lo})$: +\begin{equation}f(r,A)=\begin{cases}A\cdot\min(r,\,1+\epsilon_{\rm hi})&A>0\\ A\cdot\max(r,\,1-\epsilon_{\rm lo})&A<0.\end{cases}\label{eq:fclosed}\end{equation} +\paragraph{Pessimism.} $\min(x,y)\le x$ gives $f(r,A)\le rA$ pointwise: $L^{\rm CLIP}$ is a lower bound on the unclipped surrogate $L^{\rm CPI}=\E[r\hat A]$, and equals it on $r\in[1-\epsilon_{\rm lo},1+\epsilon_{\rm hi}]$. The gap is non-zero only where the ratio has moved \emph{in the direction the advantage wants}: ``we only ignore the change in probability ratio when it would make the objective improve, and we include it when it makes the objective worse'' (PPO Sec.~3). Also $f(1,A)=A=rA|_{r=1}$ and $\partial_rf|_{r=1}=A$, so PPO's gradient equals the policy gradient at $\theta_{\rm old}$. +\ver{closed forms equal the min/clip definition to $0$; $\max(f-rA)=0$ on 1212 grid points (never positive).} + +\paragraph{Gradient case table.} Since $\partial_{\log\pi}r=r$, the derivative of Eq.~\eqref{eq:fclosed} with respect to $\log\pi_\theta(o_t|s_t)$ is $A\,r$ on the \emph{live} set and $0$ elsewhere: +\begin{center}\small\begin{tabular}{@{}lcc@{}}\toprule +region of $r$ & $\hat A>0$ & $\hat A<0$\\\midrule +$r<1-\epsilon_{\rm lo}$ & $A\,r$ (live, restoring) & \textbf{0}\\ +$1-\epsilon_{\rm lo}\le r\le1+\epsilon_{\rm hi}$ & $A\,r$ & $A\,r$\\ +$r>1+\epsilon_{\rm hi}$ & \textbf{0} & $A\,r$ (live, restoring; $|{\rm weight}|=|A|r$ \emph{unbounded})\\\bottomrule\end{tabular}\end{center} +Silenced cells: $\{A>0,\,r>1+\epsilon_{\rm hi}\}\cup\{A<0,\,r<1-\epsilon_{\rm lo}\}$. This is the mask $M_{i,t}$ of MiniMax-M1 Eq.~7 (arXiv:2506.13585) and the object that 0107 compares against. +\ver{autograd equals the table on 1192 $(r,A)$ points (502 silenced, 690 live): $8.9\times10^{-16}$; scalar-loop implementation equals the vectorised \texttt{where/clamp} one to $0$; at the exact boundary PyTorch passes the gradient (live), a convention, not a theorem.} + +\subsection{The clip is not a trust region} +The table shows that clipping only removes the push \emph{past} the boundary; it applies no restoring force to a ratio that has overshot (an $A>0$ token with $r>1+\epsilon_{\rm hi}$ simply receives $0$), and parameters shared between tokens move ratios even for silenced tokens. On the toy, full-batch ascent on $L^{\rm CLIP}$ with $\epsilon=0.2$ leaves $\max|r-1|=0.25$ after 10 steps, $0.56$ after 100 and $0.81$ after 600, with $75\%$ of probability-weighted tokens outside $[0.8,1.2]$ (a deliberately long, large-step inner loop; practice uses $K\le16$ epochs on a batch). PPO therefore regularises by early termination of the incentive, not by a constraint; Prop.~\ref{prop:tr} says the correctness guarantee needs $\alpha$ small, which the clip does not enforce. + +\section{Boxed result} +\begin{result} +\begin{align*} +&J(\pi')-J(\pi)=\E_{o\sim\pi'}\sum_tA_\pi(s_t,o_t)\\ +&|J(\pi')-L_\pi(\pi')|\le2\varepsilon\alpha^2T(T-1)\le\varepsilon T(T-1)D_{\rm KL}^{\max}\\ +&f(r,A)=\min\big(rA,\clip(r,1-\epsilon_{\rm lo},1+\epsilon_{\rm hi})A\big)\le rA\\ +&\frac{\partial f}{\partial\log\pi}=A\,r\cdot\indb{(A>0\wedge r<1+\epsilon_{\rm hi})\ \vee\ (A<0\wedge r>1-\epsilon_{\rm lo})} +\end{align*} +\end{result} + +\section{What breaks} +\begin{breaks}\small\begin{itemize} +\item \textbf{Exact advantages assumed.} Prop.~\ref{prop:tr} uses the true $A_\pi$ and its worst case $\varepsilon$; GRPO/DAPO use a one-sample group baseline (0105), so the bound is only heuristic there. \emph{Violated:} $\hat A=A_\pi$. +\item \textbf{Worst-case, horizon-quadratic constant.} The bound is loose by $\sim350\times$ on a 4-token toy and by orders of magnitude more at long horizons ($T^2$); it motivates the shape (linear in KL), not the size, of $\epsilon$. +\item \textbf{Per-token $\epsilon$ is not sequence-level.} A clip range of $0.2$ on each of $10^4$ tokens allows a sequence ratio up to $1.2^{10^4}$ (0103). +\item \textbf{Clip $\ne$ constraint} (above): ratios drift outside the interval; no restoring force on silenced tokens. +\item \textbf{Silenced tokens are not random.} The silenced set is $\{A>0,r\ \text{large}\}\cup\{A<0,r\ \text{small}\}$: precisely the tokens that moved most in the favourable direction, which for rare, low-probability tokens happens after one update (0106, 0107). +\item \textbf{Unbounded weight on the live side.} For $A<0$, $r\gg1$ the gradient weight $|A|r$ is not bounded by $1+\epsilon$: the surrogate bounds the \emph{value}, not the gradient (0107 shows CISPO's way of bounding the weight). +\item \textbf{$r\ne1$ at $\theta=\theta_{\rm old}$} when sampler and trainer disagree (0108): tokens are clipped before any update. +\end{itemize}\end{breaks} + +\nextnote{So far the advantage $\hat A$ was assumed given. Without a value network the cheapest source is the other samples for the same prompt: note 0105 derives GRPO's group-relative advantage as a Monte-Carlo baseline and exactly quantifies the bias of its standard-deviation and length normalisations (Dr.\ GRPO).} + +\section*{References} +{\small\begin{itemize}[leftmargin=1.2em] +\item Schulman et al., \emph{Trust Region Policy Optimization}, arXiv:1502.05477 (equation numbers as in the copy held in the vault): Eq.~1 (performance difference), Eq.~3 ($L_\pi$ and its first-order matching), Theorem~1 / Eqs.~8--9 (bound), Algorithm~1. +\item Schulman et al., \emph{Proximal Policy Optimization Algorithms}, arXiv:1707.06347: Sec.~3, Eqs.~6--7 ($L^{\rm CPI}$, $L^{\rm CLIP}$) and the pessimism quote. +\item MiniMax, arXiv:2506.13585, Sec.~3.1, Eqs.~1 and 7 (PPO objective and its implicit mask $M_{i,t}$). +\item Yu et al., arXiv:2503.14476, Sec.~3.1, Eq.~10 (decoupled $\epsilon_{\rm lo},\epsilon_{\rm hi}$). +\item Prop.~\ref{prop:tr} is derived here with finite-horizon constants (my derivation, checked numerically); it is not a quotation of TRPO's theorem. +\end{itemize}} +\end{document} diff --git a/code/notes/src/0105-grpo-group-advantages.tex b/code/notes/src/0105-grpo-group-advantages.tex new file mode 100644 index 0000000000000000000000000000000000000000..2b6cda3845cc96c6e293c8eea94ac9130a8441c3 --- /dev/null +++ b/code/notes/src/0105-grpo-group-advantages.tex @@ -0,0 +1,96 @@ +\documentclass[11pt]{article} +\usepackage{common} +\begin{document} +\notehead{0105}{GRPO: group-relative advantages as a Monte-Carlo baseline; the bias of std and length normalisation; Dr.\ GRPO}{Policy-optimisation lineage, batch M1. Companion notebook: \texttt{notebooks/0105-grpo-group-advantages.ipynb}.} + +\begin{problem} +\question{Can the other samples of the same prompt replace the learned value network, and which normalisations change the objective being optimised?} +PPO (0104) needs an advantage $\hat A_t$ per token. The classical source is a learned value network $V_\psi$, a second model of the policy's size that must be trained to be accurate at every token even though the only reward arrives at the last one (DeepSeekMath, arXiv:2402.03300, Sec.~4.1.1). GRPO removes it by using the other samples of the same prompt as the baseline. The questions are: what exactly does that estimate, which normalisations in the published recipe change the objective being optimised, and what is the minimal change (Dr.\ GRPO, arXiv:2503.20783) that restores an unbiased policy gradient? +\end{problem} + +\section{Notation} +\begin{center}\small\begin{tabular}{@{}ll@{}}\toprule +symbol & meaning\\\midrule +$G$, $o_1..o_G\sim\pi_{\rm old}(\cdot|q)$ & group size and the i.i.d.\ group for one prompt\\ +$R_i=R(q,o_i)$, $\bar R=\frac1G\sum_jR_j$ & outcome rewards and their mean\\ +$\hat\sigma=\sqrt{\frac1G\sum_j(R_j-\bar R)^2}$ & population std of the group (see the convention note below)\\ +$\tilde A_i=R_i-\bar R$ & centred advantage (Dr.\ GRPO)\\ +$\hat A_i=\tilde A_i/\hat\sigma$ & GRPO advantage (same for all tokens of $o_i$; $0$ if $\hat\sigma=0$)\\ +$\hat A_i^{\rm RLOO}=R_i-\frac1{G-1}\sum_{j\ne i}R_j$ & leave-one-out advantage\\ +$p=P(R=1)$, $k=\sum_jR_j$ & success probability and success count (binary rewards)\\ +$s(o)=\nabla_\theta\log\pi_\theta(o)$ & sequence score;\ \ $L$: max length\\\bottomrule\end{tabular}\end{center} +\textbf{Std convention.} Code using the unbiased $\frac1{G-1}$ std (e.g.\ the \texttt{torch} default) has $\hat\sigma$ larger by $\sqrt{G/(G-1)}$, hence $\hat A$ and the factor $c_G$ of Sec.~2.3 smaller by $\sqrt{(G-1)/G}$; all formulas below use the population std. + +\section{Derivation} +\subsection{The group mean is a Monte-Carlo baseline} +By 0102 a baseline $b(q)$ that does not depend on the sampled response leaves $\nabla J$ unchanged; the best \emph{prompt-only} choice is $V(q)=\E[R\mid q]$, which the group mean $\bar R$ estimates with $G$ samples. Because $\bar R$ contains $R_i$ itself, +\[\E\big[(R_i-\bar R)\,s(o_i)\big]=(1-\tfrac1G)\,\nabla J,\qquad \tfrac{G}{G-1}(R_i-\bar R)=R_i-\tfrac1{G-1}\sum_{j\ne i}R_j=\hat A_i^{\rm RLOO}\] +(0102 Sec.~2.5; Dr.\ GRPO App.~A). So the centred advantage is the REINFORCE-leave-one-out estimator up to the known scale $(G-1)/G$ and gives an unbiased policy gradient (the scale is absorbed by the learning rate). The same baseline is used for all $t$: it ignores the state $s_t$ and is therefore not the minimum-variance choice of 0102, which is the price of having no critic. +\ver{exact enumeration of all $121^2$ and $121^3$ tuples: centred estimator $=(1-\frac1G)\nabla J$ and RLOO $=\nabla J$ to $\le4.9\times10^{-17}$; algebraic identity $\frac G{G-1}\tilde A=\hat A^{\rm RLOO}$ to $1.1\times10^{-16}$.} + +\subsection{GRPO as published} +With outcome supervision, $\hat A_{i,t}=(R_i-\bar R)/\hat\sigma$ for all $t$ (DeepSeekMath Sec.~4.1.2) and the objective is (Eq.~3, with $\beta\,D_{\rm KL}$ dropped, see 0109) +\begin{equation}\mathcal J_{\rm GRPO}=\E\Big[\frac1G\sum_{i=1}^G\frac1{|o_i|}\sum_{t=1}^{|o_i|}\min\big(r_{i,t}\hat A_{i,t},\clip(r_{i,t},1\!-\!\epsilon,1\!+\!\epsilon)\hat A_{i,t}\big)\Big].\label{eq:grpo}\end{equation} +Relative to the unbiased PPO objective with an MC baseline it has \emph{two} extra factors: $1/\hat\sigma$ and $1/|o_i|$ (Dr.\ GRPO Sec.~3.1). + +\subsection{Std normalisation: an exact formula for what it does} +\paragraph{Binary rewards (verifier).} With $k$ successes, $00$ this rewards \emph{long} responses (the reward-independent part pushes $E[1/|o|]$ down), and the effective per-sequence weights are $(R-b)/|o|$: correct responses ($R-b>0$) are reinforced less if long, incorrect ones ($R-b<0$) are penalised less if long (Dr.\ GRPO Sec.~3.1, Fig.~4). +\ver{Eq.~\eqref{eq:len} equals the autograd gradient of $\E[(R-b)/|o|]$ to $6.9\times10^{-18}$; decomposition equals $\nabla\E[R/|o|]-b\nabla\E[1/|o|]$ to $7.8\times10^{-18}$. First-order change of the expected length per unit step on the toy: from the failure part $+0.021\to+0.041$ (twice as much length growth when 1/|o|-normalised), from the success part $+0.105\to+0.031$. The \emph{net} sign therefore depends on how reward correlates with length; here successes are mostly long, so the net growth is smaller ($0.126\to0.072$). The robust statement is Eq.~\eqref{eq:len} (the objective changes), not a universal sign.} + +\subsection{Dr.\ GRPO: remove both factors} +Drop $1/\hat\sigma$; replace $\frac1{|o_i|}$ by a constant ($L_{\max}$, the generation budget; Listing~1 of arXiv:2503.20783): +\begin{equation}\mathcal L_{\rm Dr}=\frac1{G\,L_{\max}}\sum_{i=1}^G\sum_{t=1}^{|o_i|}\min\big(r_{i,t}\tilde A_i,\clip(r_{i,t},1-\epsilon,1+\epsilon)\tilde A_i\big),\qquad \E[\nabla\mathcal L_{\rm Dr}]\big|_{\theta_{\rm old}}=\frac{1-1/G}{L_{\max}}\nabla J.\label{eq:dr}\end{equation} +This is PPO (0104) with a Monte-Carlo baseline (Dr.\ GRPO Sec.~3.2 and App.~A). Any constant works; the choice only changes the effective learning rate. Note that many open-source PPO implementations normalise by the response length too (Dr.\ GRPO Table~2), so the length bias is not specific to GRPO. +\ver{$\frac1{L}\E[(R-b)s]=\nabla J/L$ exact; naive per-token python loops equal vectorised masked reductions for the three conventions (GRPO per-sequence mean, DAPO global token mean, Dr.\ GRPO constant) to $0$.} + +\section{Boxed result} +\begin{result} +\begin{align*} +&\E\Big[\tfrac1G\textstyle\sum_i\big(R_i-\bar R\big)s(o_i)\Big]=\big(1-\tfrac1G\big)\nabla J\qquad(\text{unbiased up to scale; RLOO: exactly }\nabla J)\\ +&\E[g_{\rm GRPO\text{-}std}]=c_G(p)\,\nabla p,\quad c_G=a_1-a_0\xrightarrow{G\to\infty}\tfrac1{\sqrt{p(1-p)}},\quad c_G(0^+)=c_G(1^-)=\sqrt{G-1}\\ +&\E\Big[\tfrac{R-b}{|o|}s\Big]=\nabla\,\E\Big[\tfrac{R-b}{|o|}\Big]\qquad(\text{sequence-mean: a different objective})\\ +&\mathcal L_{\rm Dr}=\tfrac1{GL_{\max}}\textstyle\sum_{i,t}\min(r\tilde A_i,\clip(r)\tilde A_i),\quad\tilde A_i=R_i-\bar R +\end{align*} +\end{result} + +\section{What breaks} +\begin{breaks}\small\begin{itemize} +\item \textbf{Prompt re-weighting} by $c_G(p)$ (std), and \textbf{reward reshaping} for graded rewards; violated assumption: the estimator is a plain policy gradient of $J$. +\item \textbf{Length term} of Eq.~\eqref{eq:len}; violated: the per-token weight is independent of the length of the response it sits in. +\item \textbf{Dead groups}: $\hat\sigma=0\Rightarrow\hat A=0$; zero gradient (0110) and, under group-mean normalisation, the $\epsilon$ in $1/(\hat\sigma+\epsilon)$ is a pure convention. +\item \textbf{Small $G$.} $G=2$ has $\hat A=\pm1$ exactly with the population std ($\pm1/\sqrt2$ with the unbiased std); baseline noise $\propto1/G$; the sampling cost to see a success is $\sim1/p$ (0110). +\item \textbf{Credit assignment.} One scalar advantage for every token (outcome supervision); a state-dependent baseline (0102) would be lower-variance but needs a critic. +\item \textbf{The clip uses $\hat A$'s sign only} (0104), so the std scale does not change the clipped set, but it does change relative prompt weights and, with a fixed learning rate, the effective step size. +\item \textbf{Constant $L_{\max}$} makes gradients scale with the realised length mass relative to the budget; very long budgets shrink the effective step (compensate with the learning rate or switch to DAPO's token-level global mean, 0106). +\end{itemize}\end{breaks} + +\nextnote{DAPO keeps the group advantage and fixes four named pathologies of GRPO in practice: entropy collapse (asymmetric clip), dead groups (dynamic sampling), the length weighting just analysed (token-level loss) and noisy truncation rewards. Note 0106 derives each fix and verifies its claim.} + +\section*{References} +{\small\begin{itemize}[leftmargin=1.2em] +\item Shao et al., \emph{DeepSeekMath}, arXiv:2402.03300: Sec.~4.1.1 Eq.~3 (GRPO objective), Eq.~4 (KL estimator), Sec.~4.1.2 (outcome-supervision advantage $\hat A_{i,t}=(r_i-\mathrm{mean})/\mathrm{std}$). +\item Liu et al., \emph{Understanding R1-Zero-Like Training}, arXiv:2503.20783: Sec.~3.1 (two biases), Sec.~3.2 (Dr.\ GRPO), Listing~1, Table~2, Appendix A Eqs.~4--6 (derivation, RLOO equivalence). +\item Yu et al., \emph{DAPO}, arXiv:2503.14476: Sec.~2.2, Eqs.~4--6 (GRPO as used there). +\item The exact $c_G(p)$ formula, the $\arcsin$ limit, the reward-reshaping lemma and Eq.~\eqref{eq:len} are derived here (verified by exhaustive enumeration), not taken from the papers. +\end{itemize}} +\end{document} diff --git a/code/notes/src/0106-dapo-pathologies.tex b/code/notes/src/0106-dapo-pathologies.tex new file mode 100644 index 0000000000000000000000000000000000000000..ef2ede37434247d8c13886dfda3d0ec9317fb3c2 --- /dev/null +++ b/code/notes/src/0106-dapo-pathologies.tex @@ -0,0 +1,99 @@ +\documentclass[11pt]{article} +\usepackage{common} +\begin{document} +\notehead{0106}{DAPO: clip-higher, dynamic sampling, token-level loss, overlong shaping}{Policy-optimisation lineage, batch M1. Companion notebook: \texttt{notebooks/0106-dapo-pathologies.ipynb}.} + +\begin{problem} +\question{What exactly goes wrong when GRPO is trained on long chains of thought, and which mechanism produces each failure?} +DAPO (arXiv:2503.14476) starts from GRPO (0105) and lists four things that went wrong when training a long-chain-of-thought model: the policy's entropy collapsed, many prompts produced no learning signal, long responses were under-weighted per token, and truncated responses were punished regardless of whether their reasoning was sound. Each of its techniques is a patch for exactly one of these. This note states the pathology, derives \emph{why} the mechanism produces it, derives the patch, and says honestly what the exact toy computations confirm and what rests on DAPO's empirical evidence alone. +\end{problem} + +\section{Notation} +\begin{center}\small\begin{tabular}{@{}ll@{}}\toprule +symbol & meaning\\\midrule +$\epsilon_{\rm lo}=0.2,\ \epsilon_{\rm hi}=0.28$ & decoupled clip half-widths (DAPO Sec.~4.1)\\ +$G=16$, $k$ & group size, number of correct responses in a group\\ +$\rho=\E_q[1-p_q^G-(1-p_q)^G]$ & fraction of groups that are \emph{live} ($00$ ($\log\pi_a>\E\log\pi$, $\Delta z_a>0$) makes the covariance positive and \emph{lowers} entropy; a rare action with $A>0$ makes it negative and \emph{raises} entropy. +\ver{finite-difference $\Delta H$ equals $-\Cov(\log\pi,\Delta z)$ to $6.7\times10^{-9}$ (relative; PG and NPG, six random policies); at $\pi=(.90,.05,.03,.02)$: good action dominant gives $\mathrm d H/\mathrm d\eta=-0.0357$, good action rare gives $+0.0064$.} + +\paragraph{Step 2: what the symmetric clip does to the rare tokens.} For $A>0$, $f=A\min(r,1+\epsilon)$ (0104 Eq.~(3)), so the \emph{direct} incentive to raise a token's probability $p=\pi_{\rm old}$ vanishes at $\pi_\theta=(1+\epsilon)p$: the absolute headroom is $\epsilon p$. For $\epsilon=0.2$ a token with $p=0.01$ can only be pushed to $0.012$, one with $p=0.9$ to $\min(1,1.08)=1$ (DAPO's own example). In log space the allowed move is the same ($\log1.2=0.182$) for all tokens, but in probability it is proportional to $p$: rare ``exploration'' tokens get headroom $\sim p\epsilon$ per rollout, dominant ones saturate at $1$. +\ver{cap table: $p=0.001\to0.0012$, $0.01\to0.0120$ ($\epsilon=.2$) / $0.0128$ ($\epsilon=.28$), $0.5\to0.60/0.64$, $0.9\to1.0$.} + +\paragraph{Step 3: the patch.} Decouple the bounds (Eq.~10): keep $\epsilon_{\rm lo}=0.2$, raise $\epsilon_{\rm hi}$ to $0.28$ ($\log1.28=0.247$ vs $0.182$): the headroom of a rare token grows by $0.28/0.2=1.4\times$. $\epsilon_{\rm lo}$ is left alone: for tabular softmax $\partial J/\partial z_a\propto\pi_a$, so a token pushed towards probability $\approx0$ receives almost no gradient and is essentially never sampled again; a large $\epsilon_{\rm lo}$ would allow that (DAPO: ``increasing it will suppress the probability of these tokens to 0, resulting in the collapse of the sampling space''). +\ver{asymmetric-clip gradient equals $Ar\cdot\mathbb 1[\text{live}]$ with $\epsilon_{\rm lo}=.2,\epsilon_{\rm hi}=.28$ on 1600 points to $8.9\times10^{-16}$.} + +\paragraph{What the exact toy shows (and does not).} On a two-action bandit with a rare rewarded action ($\pi=0.01$), exact-expectation inner ascent (400 steps per rollout iteration) needs $8$ rollout iterations to reach $\pi=0.5$ with $\epsilon_{\rm hi}=0.2$, $7$ with $0.28$, and $2$ with no upper clip: the upper clip is a strong brake, but widening it from $0.2$ to $0.28$ gains one iteration ($\approx12\%$). The first-iteration growth is $1.251$ ($\epsilon=.2$) and $1.342$ ($.28$), \emph{above} $1+\epsilon$: the clip removes only the direct push on the rare token; the unclipped, negative-advantage common action keeps moving probability mass through the softmax normalisation until \emph{its own} lower clip binds. The size of DAPO's entropy effect (Fig.~2, and $+2$ AIME points, $36\to38$, in Table~1) therefore comes from function approximation and finite samples, not from this tabular model, and is taken from their experiments, not re-derived here. + +\subsection{Dynamic sampling against zero-gradient groups} +\paragraph{Pathology (Sec.~3.2, Fig.~3b):} if all $G$ responses are correct (or all wrong) then $\hat A_i\equiv0$ (0105) and the group contributes nothing; as accuracy rises the share of such groups grows. +\paragraph{Step 1: how many.} With success probability $p$, $P(\text{dead})=p^G+(1-p)^G$. For $G=16$: $p=0.9\to0.185$, $0.95\to0.44$, $0.99\to0.85$, and $p=0.02\to0.72$. Over a prompt distribution $p\sim\mathrm{Beta}(a,b)$ the live fraction is +$\rho=1-\frac{B(a+G,b)}{B(a,b)}-\frac{B(a,b+G)}{B(a,b)}$; for $\mathrm{Beta}(2,8)$, $G=16$: $\rho=0.88$. +\ver{MC dead-fraction vs.\ $p^G+(1-p)^G$ within $\le2.1$ s.e.\ in four $(G,p)$ settings; closed-form $\rho$ vs.\ 400-point Gauss--Legendre quadrature to $2.7\times10^{-14}$ for three Beta priors.} +\paragraph{Step 2: why it hurts.} With a batch of $B$ prompts and a normaliser that counts every token (Eq.~\eqref{eq:dapo}), dead groups add $0$ to the numerator but their tokens stay in the denominator: the expected update is multiplied by $\rho$, and only $m=\rho B$ prompts carry signal, whose noise grows as $\sqrt m$ against a signal $\propto m$, so the signal-to-noise ratio scales as $\sqrt{m}$. +\paragraph{Step 3: the patch is unbiased.} Keep sampling prompts, discarding dead groups, until $B$ live groups exist. A dead group has $g=0$, hence $\E[g\,\mathbb 1_{\rm live}]=\E[g]$ and $\E[g\mid{\rm live}]=\E[g]/P(\text{live}\mid q)$; since prompt $q$ is accepted with probability $\propto P(\text{live}\mid q)$ the factors cancel: the summed expected gradient equals the unfiltered one (up to the global factor $1/\rho$ from dividing by $B$ accepted rather than $\rho B$ live groups). So dynamic sampling restores the magnitude and the $\sqrt B$ signal-to-noise without re-weighting prompts. Cost: $B/\rho$ prompts per batch. +\ver{exact (25-point discretised $\mathrm{Beta}(2,8)$ prompt law, $G=16$, conditional mean computed directly from the binomial restricted to $00$ a truncated sample is worth $b$, more than a wrong short answer (reward $0$, advantage $-b$), so the pressure \emph{between} those two kinds of failure is reversed. +\ver{identical to $0$ on the toy (truncated = hit the length limit without EOS).} +(ii) \emph{Soft overlong punishment} (Eq.~13) adds a length reward to the correctness reward: +\begin{equation}R_{\rm len}(y)=\begin{cases}0&|y|\le L_{\max}-L_{\rm cache}\\ \dfrac{(L_{\max}-L_{\rm cache})-|y|}{L_{\rm cache}}&L_{\max}-L_{\rm cache}<|y|\le L_{\max}\\ -1&|y|>L_{\max}.\end{cases}\label{eq:rlen}\end{equation} +It is continuous ($0$ at $L_{\max}-L_{\rm cache}$, $-1$ at $L_{\max}$) with slope exactly $1/L_{\rm cache}$, so the $-1$ cliff of a hard truncation penalty is spread over $4096$ tokens. Because $R_{\rm len}$ is a function of the sampled response, adding it is just a different reward: the policy gradient (0102) of $\E[R+R_{\rm len}]$ is the sum of the two separate gradients, with no new bias. +\ver{ramp values and slope exact; policy gradient of $\E[R+R_{\rm len}]$ equals the baselined estimator to $2.8\times10^{-17}$.} + +\paragraph{DAPO's own ablation} (Table~1, AIME~24 avg@32, cumulative, as reported by the authors, not reproduced here): naive GRPO $30$; $+$overlong filtering $36$; $+$clip-higher $38$; $+$soft overlong punishment $41$; $+$token-level loss $42$; $+$dynamic sampling $50$. + +\section{Boxed result} +\begin{result} +\begin{align*} +&\mathcal J_{\rm DAPO}=\tfrac1{\sum_i|o_i|}\textstyle\sum_{i,t}\min(r\hat A_i,\clip(r,1{-}\epsilon_{\rm lo},1{+}\epsilon_{\rm hi})\hat A_i)\ \text{ s.t. }0}0\text{ token vanishes at }\min\big(1,(1{+}\epsilon_{\rm hi})\,p\big)\ \ (\text{a soft cap})\\ +&P(\text{dead})=p^G+(1-p)^G\\ +&\rho=1-\tfrac{B(a+G,b)}{B(a,b)}-\tfrac{B(a,b+G)}{B(a,b)},\qquad\text{cost of a full batch: }B/\rho\\ +&\text{masking truncated samples}\equiv\text{reward }b\text{ for them};\qquad R_{\rm len}\text{ continuous, slope }1/L_{\rm cache} +\end{align*} +\end{result} + +\section{What breaks} +\begin{breaks}\small\begin{itemize} +\item \textbf{Clip-higher is a headroom argument, not a guarantee.} The cap applies only to the direct term (leak shown above); a larger $\epsilon_{\rm hi}$ allows larger per-token ratios, hence heavier IS-weight tails (0103) and, for $A<0$ tokens with $r>1+\epsilon_{\rm hi}$, unbounded gradient weight (0104, 0107). \emph{Violated:} PPO ratios stay near 1. +\item \textbf{Dynamic sampling} needs $\rho$ not too small (hard datasets: cost $1/\rho$), filters on accuracy only (shaping can make an all-correct group informative about length, yet it is still dropped), and changes the \emph{batch} composition. DAPO claims wall-clock is not dominated by this because generation time is dominated by long-tail samples (Sec.~3.2); that is a systems claim not tested here. +\item \textbf{Token-level mean} has $O(1/n)$ ratio bias, and gives long (possibly degenerate) responses proportionally more gradient mass; it does not by itself prevent length growth. +\item \textbf{Overlong shaping adds an objective.} The relative scale of $R_{\rm len}$ and $R_{\rm correct}\in\{\pm1\}$ decides the trade-off; masking instead gives zero pressure against length. +\item \textbf{No KL.} DAPO removes the KL term because the policy is allowed to move far from the initial model with a rule-based verifier (0109). +\end{itemize}\end{breaks} + +\nextnote{DAPO still zeroes out gradients of tokens beyond the clip (rare, high-ratio tokens when $A>0$). Note 0107 derives CISPO, which keeps every token by clipping the \emph{importance weight} instead of the update, and tabulates which tokens PPO/GRPO silence and CISPO retains.} + +\section*{References} +{\small\begin{itemize}[leftmargin=1.2em] +\item Yu et al., \emph{DAPO}, arXiv:2503.14476: Sec.~2.3 (no KL), 2.4 Eq.~7 (rule reward), Sec.~3 Eqs.~8--13, Sec.~3.1 (Clip-Higher; Figs.~2--3; the $0.01\to0.012$, $0.9\to1.08$ example), 3.2 (Dynamic Sampling, Fig.~3b, Fig.~6), 3.3 (Token-level loss, Fig.~4), 3.4 (Overlong shaping, Eq.~13, Fig.~5), 4.1 (hyper-parameters $\epsilon_{\rm lo}=0.2,\epsilon_{\rm hi}=0.28$, $L_{\max}=16384$, $L_{\rm cache}=4096$), Table~1. +\item Cui et al., \emph{The Entropy Mechanism of RL for Reasoning Language Models}, arXiv:2505.22617, Secs.~3.1--3.2 (entropy change $=-\mathrm{Cov}$). +\item Liu et al., arXiv:2503.20783, Sec.~3.1 (length bias) and Listing~1. +\item Derivations of Eq.~\eqref{eq:entcov}, $\rho$, the dynamic-sampling unbiasedness argument and the masking$\equiv$baseline-reward identity are mine (verified in the notebook where marked). +\end{itemize}} +\end{document} diff --git a/code/notes/src/0107-cispo.tex b/code/notes/src/0107-cispo.tex new file mode 100644 index 0000000000000000000000000000000000000000..1580d5a2c741c659ab1c46ea3713712d9f56fdda --- /dev/null +++ b/code/notes/src/0107-cispo.tex @@ -0,0 +1,132 @@ +\documentclass[11pt]{article} +\usepackage{common} +\begin{document} +\notehead{0107}{CISPO: clip the importance weight, not the update}{Policy-optimisation lineage, batch M1. Companion notebook: \texttt{notebooks/0107-cispo.ipynb}.} + +\begin{problem} +\question{Can an off-policy update keep the rare high-ratio tokens that clipping silences, while still bounding the weights?} +Off-policy updates on one batch (0103) need the ratio $r_{i,t}=\pi_\theta/\pi_{\rm old}$. PPO and GRPO handle large ratios by \emph{removing the token from the gradient} once it has moved past the clip range in the favourable direction (0104). MiniMax-M1 (arXiv:2506.13585, Sec.~3.1) found that this silences exactly the tokens that matter most for long-CoT learning: rare ``fork'' tokens (\emph{However, Recheck, Wait, Aha}) have low probability under the base model, hence a large ratio after the first update, and are ``clipped out after the first on-policy update, preventing them from contributing to subsequent off-policy gradient updates'' (they run 16 off-policy rounds per generation batch). DAPO's larger $\epsilon_{\rm hi}$ (0106) was ``less effective'' in their setup. CISPO (Clipped IS-weight Policy Optimization) is their fix; it is the RL objective Mistral names for Leanstral~1.5. The announcement gives no equations; the Leanstral technical report (Sec.~2.3) states the objective but not the truncation value $c$. Sections 1--6 below follow the MiniMax paper; the report's variant is derived in the subsection \emph{Leanstral variant}. +\end{problem} + +\section{Notation} +\begin{center}\small\begin{tabular}{@{}ll@{}}\toprule +symbol & meaning\\\midrule +$r_{i,t}=\pi_\theta(o_{i,t}|s_{i,t})/\pi_{\rm old}(o_{i,t}|s_{i,t})$ & token importance weight\\ +$\hat A_i$ & group advantage (0105), shared by all tokens of $o_i$\\ +$\sg(\cdot)$ & stop-gradient: value kept, derivative set to $0$\\ +$\hat r_{i,t}=\clip(r_{i,t},1-\epsilon^{IS}_{\rm low},1+\epsilon^{IS}_{\rm high})$ & clipped IS weight (Eq.~5)\\ +$w(r,A)$ & gradient weight: loss gradient $=w\cdot A\cdot\nabla\log\pi_\theta(o_t|s_t)$\\ +$M_{i,t}\in\{0,1\}$ & token mask (Eqs.~6--7)\\ +$\epsilon_{\rm hi},\epsilon_{\rm lo}$ & PPO clip bounds (DAPO values $0.28,\,0.2$ used for the tables)\\\bottomrule\end{tabular}\end{center} + +\section{Derivation} +\subsection{Start from the importance-weighted policy gradient} +0103 gave the exact off-policy gradient with the sequence weight; its token-level form, with the weights treated as constants, is the ``vanilla REINFORCE objective with corrected distribution for offline updates'' (M1 Eq.~3; M1 writes the per-sample normaliser $1/|o_i|$ there and the token-level $1/\sum_i|o_i|$ only in Eq.~4, which we use throughout so that Eqs.~\eqref{eq:reinf} and \eqref{eq:cispo} differ by the clip alone): +\begin{equation}\mathcal J_{\rm REINFORCE}=\E\Big[\frac1{\sum_i|o_i|}\sum_{i,t}\sg\big(r_{i,t}\big)\,\hat A_{i}\,\log\pi_\theta(o_{i,t}\mid s_{i,t})\Big],\qquad\nabla\mathcal J=\E\Big[\tfrac1{\sum|o_i|}\textstyle\sum_{i,t}r_{i,t}\hat A_i\nabla\log\pi_\theta\Big].\label{eq:reinf}\end{equation} +Since $r\nabla\log\pi=\nabla r$, this is also $\nabla$ of the unclipped surrogate $\E[r\hat A]$ of 0104. The stop-gradient is what makes the \emph{value} of the loss a different function from its gradient: only $\nabla\log\pi$ is differentiated. +\ver{at $\theta_{\rm old}$ ($r\equiv1$) the gradient of Eq.~\eqref{eq:reinf} equals the exact $\nabla J$ to $4.2\times10^{-17}$.} + +\subsection{PPO/GRPO is Eq.~(\ref{eq:reinf}) times a mask} +From 0104 the PPO gradient of one token is $A\,r\,\indb{\rm live}\,\nabla\log\pi$ with $\mathrm{live}=(A>0\wedge r<1+\epsilon_{\rm hi})\vee(A<0\wedge r>1-\epsilon_{\rm lo})$. That is Eq.~\eqref{eq:reinf} multiplied by the mask of M1 Eq.~7: $M=0$ if $A>0$ and $r>1+\epsilon_{\rm hi}$, $M=0$ if $A<0$ and $r<1-\epsilon_{\rm lo}$, else $M=1$ (M1 Eq.~6 is the unified form $\sg(\hat r)\hat A\log\pi\,M$). +\ver{unified form with $\hat r=r$ and this mask equals the autograd gradient of the PPO loss to $1.4\times10^{-17}$; the silenced set computed from the weights equals the mask-zero set exactly (symmetric difference $0$).} + +\subsection{CISPO: keep every token, clip its weight} +Rather than masking, clip the weight and stop its gradient (M1 Eqs.~4--5): +\begin{equation}\mathcal J_{\rm CISPO}=\E\Big[\frac1{\sum_i|o_i|}\sum_{i,t}\sg\big(\hat r_{i,t}\big)\hat A_i\log\pi_\theta(o_{i,t}\mid s_{i,t})\Big],\quad\hat r=\clip\big(r,1-\epsilon^{IS}_{\rm low},1+\epsilon^{IS}_{\rm high}\big).\label{eq:cispo}\end{equation} +MiniMax ``did not impose a lower bound on the IS weight by setting $\epsilon^{IS}_{\rm low}$ to a large value; instead, we only tuned $\epsilon^{IS}_{\rm high}$'', so $\hat r=\min(r,1+\epsilon_{\rm hi})$ and the gradient of each token is +\begin{equation}\nabla\mathcal J_{\rm CISPO}\ \ni\ \min(r_{i,t},\,1+\epsilon_{\rm hi})\ \hat A_i\ \nabla\log\pi_\theta(o_{i,t}\mid s_{i,t}).\label{eq:cgrad}\end{equation} +Without clipping ($\epsilon_{\rm hi}=\infty$) it \emph{is} Eq.~\eqref{eq:reinf}, the standard policy gradient. +\ver{$\epsilon_{\rm hi}{=}\infty$ gives the token-IS gradient exactly ($0$); autograd of the CISPO, PPO and IS losses equals $\sum_tw(r,A)A\nabla\log\pi$ built from the weight table to $\le1.4\times10^{-17}$.} + +\subsection{Gradient comparison table} +Weights $w(r,A)$ multiplying $A\nabla\log\pi$ (checked on the grid below; $\epsilon_{\rm hi}=0.28$, $\epsilon_{\rm lo}=0.2$): +\begin{center}\small\setlength{\tabcolsep}{4pt}\begin{tabular}{@{}lcccc@{}}\toprule +region & PPO/GRPO & CISPO & unclipped token IS & changed by CISPO?\\\midrule +$A>0,\ r<1+\epsilon_{\rm hi}$ & $r$ & $r$ & $r$ & no\\ +$A>0,\ r>1+\epsilon_{\rm hi}$ & \textbf{0} & $1+\epsilon_{\rm hi}$ & $r$ & \textbf{kept, capped}\\ +$A<0,\ r>1+\epsilon_{\rm hi}$ & $r$ (unbounded) & $1+\epsilon_{\rm hi}$ & $r$ & \textbf{bounded}\\ +$A<0,\ 1-\epsilon_{\rm lo}0,\ r<1-\epsilon_{\rm lo}$ & $r$ & $r$ & $r$ & no\\\bottomrule\end{tabular}\end{center} +\textbf{Exactly which tokens PPO/GRPO zero out and CISPO keeps:} $\{A>0,\ r>1+\epsilon_{\rm hi}\}\cup\{A<0,\ r<1-\epsilon_{\rm lo}\}$, the first set being the favourable-direction rare tokens of the problem statement. CISPO additionally caps the weight on $\{A<0,\ r>1+\epsilon_{\rm hi}\}$, where PPO's weight $r$ is unbounded. Numbers on the toy ($\|\theta-\theta_{\rm old}\|=1.5$, ratios in $[0.455,1.992]$): the three sets hold $7$, $63$ and $83$ token slots with probability mass $0.013$, $0.055$, $0.016$ respectively; maximum weight over all tokens: PPO $1.992$, CISPO $1.280$. +\ver{the set of tokens with PPO weight $0$ and CISPO weight $>0$ equals the union of the first two cells exactly; CISPO's smallest weight equals the smallest ratio (no token is ever silenced); CISPO weights never exceed $1+\epsilon_{\rm hi}$.} + +\paragraph{Observation (mine).} The weight $\min(r,C)$ in Eq.~\eqref{eq:cgrad} is the same function as the token-level \emph{truncated importance sampling} (TIS) weight used against the training--inference mismatch (arXiv:2510.26788 Eq.~6 gives its sequence-level form). The functional form is shared; the ratio is not: here $r=\pi_\theta/\pi_{\rm old}$ (policy lag), there $\rho=\pi_{\rm train}/\pi_{\rm sampler}$ (numerics, 0108). + +\subsection{Stop-gradient semantics} +The loss in Eq.~\eqref{eq:cispo} has gradient $\hat r\hat A\nabla\log\pi$ only because $\hat r$ is detached. Two look-alikes differ: +\begin{itemize} +\item \emph{Forgotten $\sg$:} $\nabla[\min(r,c)\,A\log\pi]=A\big(\indb{r0\wedge r<1+\epsilon_{\rm hi})\vee(A<0\wedge r>1-\epsilon_{\rm lo})}\\ +&\text{zeroed by PPO/GRPO, kept by CISPO: }\{A>0,r>1+\epsilon_{\rm hi}\}\cup\{A<0,r<1-\epsilon_{\rm lo}\}\\ +&\|\text{bias}\|\le\E\textstyle\sum_t(r_t-1-\epsilon_{\rm hi})_+|A_t|\,\|\nabla\log\pi_t\|\\ +&\phantom{\|\text{bias}\|}\le(\max|A|\max\|\nabla\log\pi\|)\textstyle\sum_t\E\,\TV_t +\end{align*} +\end{result} + +\section{What breaks} +\begin{breaks}\small\begin{itemize} +\item \textbf{Biased gradient} (bounded by Eq.~\eqref{eq:cbias}); \emph{violated:} unbiasedness of IS. It grows with how far $\pi_\theta$ moves in $K$ updates. +\item \textbf{No trust region.} Weights are bounded but the policy is not; with large $K$ or learning rate nothing stops drift (toy: $0.01\to0.86$). \emph{Violated:} the proximity assumption behind 0104's bound is not enforced at all. +\item \textbf{Still a per-token ratio.} The other tokens' ratios $\prod_{s\ne t}r_s$ are dropped exactly as in 0103's token-level approximation; CISPO does not solve long-horizon IS variance, it avoids discarding tokens. +\item \textbf{Mismatch contaminates $r$.} If the sampler and trainer disagree (0108), $r\ne1$ even at $\theta=\theta_{\rm old}$; large spurious ratios are now \emph{kept} with weight up to $1+\epsilon_{\rm hi}$ rather than dropped. +\item \textbf{Wrong stop-gradient} silently produces a different algorithm (sign errors on live tokens), and the loss value cannot reveal it. +\item \textbf{$A<0$ tokens with $r>1+\epsilon_{\rm hi}$} still get weight $1+\epsilon_{\rm hi}$ (strong push-down); PPO gives them weight $r$. Neither limits how often a token that is \emph{both} high-ratio and negative-advantage is penalised. +\item \textbf{Leanstral variant.} The sampler ratio is per token and still biased under mismatch (toy: $\|\mathrm{bias}\|=8\times10^{-3}$); one outcome on every token of a multi-turn trace inflates variance relative to per-turn credit (toy: $2.05\times$); $c$ is not reported, so nothing here fixes the clip level. \emph{Violated:} exactness of IS and the absence of cross-turn noise. +\item \textbf{Hyper-parameters are paper-specific}: M1 tunes only $\epsilon^{IS}_{\rm high}$; their value is not reported in the passages used here, and they combine CISPO with dynamic sampling and a length penalty (0106) and no KL (0109). +\end{itemize}\end{breaks} + +\nextnote{CISPO's $r$ assumes the sampler's distribution at $\theta_{\rm old}$ \emph{is} $\pi_{\theta_{\rm old}}$. In practice the inference engine and the training engine disagree numerically, and MiniMax needed an FP32 LM head before reward would grow at all. Note 0108 bounds that bias and the truncated-IS correction.} + +\section*{References} +{\small\begin{itemize}[leftmargin=1.2em] +\item MiniMax, \emph{MiniMax-M1}, arXiv:2506.13585: Sec.~3.1 (Eq.~1 PPO, Eq.~2 group advantage, ``Issues of Token Clipping'', Eq.~3 REINFORCE with $\sg$, Eqs.~4--5 CISPO, Eqs.~6--7 unified mask, no KL, dynamic sampling and length penalty from DAPO), Fig.~2. +\item Yu et al., arXiv:2503.14476, Sec.~3.1 (Clip-Higher) for the comparison point. +\item Qi et al., arXiv:2510.26788, Sec.~2.1, Eqs.~5--7 (sequence-level IS, TIS, MIS) for the observation on the shared weight form. +\item Mistral AI, \emph{Leanstral 1.5} announcement (vault note \texttt{leanstral-15-proof-abundance-for-all}): names mid-training, SFT and RL with CISPO; no equations given. +\item Mistral AI, Leanstral technical report (\texttt{LeanstralReport.pdf} in the \texttt{LeanstralSafeVerify} repository; vault notes \texttt{in-this-work-we-present-leanstral-a-series-of-generalist-code-agent-models-for}, \texttt{source-analysis-leanstral-technical-report}), Sec.~2.3: the objective of Eq.~\eqref{eq:lean}, outcome propagated to all model tokens, prompts and tool responses as context; truncation value $c$ not reported. Not on arXiv. +\item Notes 0103, 0108 and 0204 of this series for the sequence weight, the sampler--trainer mismatch and the multi-turn masking and credit used in the Leanstral-variant subsection. +\item Weight table, bias bound, stop-gradient analysis and the fork-token demonstration are derived/checked here. +\end{itemize}} +\end{document} diff --git a/code/notes/src/0108-training-inference-mismatch.tex b/code/notes/src/0108-training-inference-mismatch.tex new file mode 100644 index 0000000000000000000000000000000000000000..394b2fa4ebc00bc97ea02ab3851e450080b28ed9 --- /dev/null +++ b/code/notes/src/0108-training-inference-mismatch.tex @@ -0,0 +1,109 @@ +\documentclass[11pt]{article} +\usepackage{common} +\begin{document} +\notehead{0108}{Training--inference mismatch: truncated IS correction and why an FP32 LM head helps}{Policy-optimisation lineage, batch M1. Companion notebook: \texttt{notebooks/0108-training-inference-mismatch.ipynb}.} + +\begin{problem} +\question{What happens to the gradient when the sampling engine and the training engine do not compute the same distribution?} +RL systems sample with one engine (vLLM/SGLang: fused kernels, bf16) and differentiate with another (FSDP/Megatron). Both claim to compute the same $\pi_\theta(\cdot|s)$, yet ``these engines produce numerically different outputs'' (arXiv:2510.26788 Sec.~1). The sampler's distribution $\mu_\theta$ is therefore not the trainer's $\pi_\theta$ even at $\theta=\theta_{\rm old}$. Three consequences: (i) the policy gradient estimated from $\mu$-samples is biased; (ii) PPO/CISPO ratios $r=\pi_\theta/\pi_{\rm old}$ are contaminated if $\pi_{\rm old}$ is taken from the sampler; (iii) the optimised $\theta$ is optimal for $\pi$, not for the deployed $\mu$ (a ``deployment gap''). MiniMax-M1 reports the discrepancy ``prevented reward growth'' and fixed it by raising the LM output head to FP32, improving the train/inference probability correlation from $\approx0.9x$ to $\approx0.99x$ (arXiv:2506.13585 Sec.~3.2, Fig.~3: Pearson $0.987$ before, $0.997$ after, in the figure shown). This note bounds the bias and explains \emph{quantitatively} why high-precision logits matter. +\end{problem} + +\section{Notation} +\begin{center}\small\begin{tabular}{@{}ll@{}}\toprule +symbol & meaning\\\midrule +$\pi=\pi(\cdot|\theta)$, $\mu=\mu(\cdot|\theta)$ & trainer and sampler distributions of the \emph{same} weights\\ +$w=\pi(o)/\mu(o)$, $\rho_t=\pi(o_t|s_t)/\mu(o_t|s_t)$ & sequence / token mismatch weight\\ +$C\ge1$ & truncation level; TIS $=\min(w,C)$, MIS $=w\,\indb{w\le C}$\\ +$A=R-b$; $M_A,M_s$ & $\max|A|$ and $\max_o\|\nabla\log\pi(o)\|$\\ +$u=2^{-p}$ & unit roundoff, $p$ significant bits (bf16 8, fp16 11, fp32 24)\\ +$z,\ e=\mathrm{fl}(z)-z$ & logits and their rounding error, $|e_a|\le u|z_a|$\\ +$\chi^2_t=\chi^2(\pi_t\|\mu_t)$ & per-position chi-square divergence\\\bottomrule\end{tabular}\end{center} + +\section{Derivation} +\subsection{The mismatch biases the gradient; sequence-level IS repairs it} +With the correct objective $J(\theta)=\E_{o\sim\pi}R$ but samples from $\mu$, +\begin{equation}\E_{\mu}[A\,s_\pi]-\nabla J=\sum_o(\mu-\pi)(o)\,A(o)\,s_\pi(o)=\E_\pi\big[(\tfrac\mu\pi-1)A\,s_\pi\big]\neq0\label{eq:biasmu}\end{equation} +($s_\pi=\nabla\log\pi$; arXiv:2510.26788 Eq.~3). The IS identity of 0103 repairs it exactly: $\E_\mu[w\,A\,s_\pi]=\sum_o\mu\,\frac\pi\mu As_\pi=\nabla J$ (their Eq.~5). It inherits the variance problem of 0103: $\E_\mu[w^2]=1+\chi^2(\pi\|\mu)$, and for a sequence of $T$ positions the exact statement is the recursion of 0103, $1+\chi^2_{\rm seq}=\E_\mu[w_T^2]=\E_\mu\big[w_{T-1}^2\,(1+\chi_T^2(s_T))\big]$. This is \emph{not} $\E_\mu[\prod_t(1+\chi_t^2(s_t))]$ in general: $w_{T-1}^2$ and $\chi_T^2(s_T)$ are both functions of the prefix and are correlated. The correct product identity is a martingale statement, $\E_\mu\big[w_T^2\big/\prod_{t\le T}(1+\chi_t^2(s_t))\big]=1$; the plain product formula $\E_\mu[w_T^2]=\prod_t(1+\chi_t^2)$ holds when the per-position $\chi_t^2$ do not depend on the history (independent contexts, the setting of Sec.~2.4). +\ver{toy with large logits (bf16-rounded sampler): $\mathrm{TV}(\pi,\mu)=4.5\times10^{-4}$; Eq.~\eqref{eq:biasmu} holds to $1.9\times10^{-17}$; uncorrected bias is $0.11\%$ of $\|\nabla J\|$; the IS-corrected estimator equals $\nabla J$ to $6.9\times10^{-18}$. On a state-dependent toy ($|\mathcal V|=3$, $L=8$, 511 sequences) $\E_\mu[w^2]=1.1515$ equals the recursion to $7\times10^{-16}$ and $\E_\mu[w^2/\prod_t(1+\chi_t^2)]=1$ to $4\times10^{-16}$, whereas $\E_\mu[\prod_t(1+\chi_t^2)]=1.1417\ne1.1515$.} + +\subsection{Truncated and masked IS: exact bias, bounds} +Eq.~6--7 of arXiv:2510.26788 trade bias for variance with a cap $C\ge1$: +\begin{align} +\text{TIS: }&\E_\mu[\min(w,C)As]-\nabla J=-\E_\mu\big[(w-C)_+As\big],\label{eq:tis}\\ +\text{MIS: }&\E_\mu[w\,\indb{w\le C}As]-\nabla J=-\E_\pi\big[\indb{w>C}\,As\big].\label{eq:mis} +\end{align} +(Both follow by subtracting the unbiased $\E_\mu[wAs]$.) Norm bounds, using $\E_\mu[(w-C)_+]=\sum_o(\pi-C\mu)_+$: +\begin{align} +\|\text{TIS bias}\|&\le\E_\mu[(w{-}C)_+|A|\|s\|]\le M_AM_s\sum_o(\pi-C\mu)_+\le M_AM_s\min\big(\TV(\pi,\mu),\,P_\pi(w>C)\big),\nonumber\\ +\|\text{MIS bias}\|&\le M_AM_s\,P_\pi(w>C).\label{eq:tisbound} +\end{align} +Reasoning for the last step: for $C\ge1$, $(\pi-C\mu)_+\le(\pi-\mu)_+$ and $\sum(\pi-\mu)_+=\TV$; also $(\pi-C\mu)_+\le\pi\,\indb{\pi>C\mu}$, whose sum is $P_\pi(w>C)$. TIS therefore has bias at most the \emph{TV distance} and every weight is $\le C$, so its variance contribution is bounded; MIS throws away whole sequences with $w>C$. In both, $C\to\infty$ recovers Eq.~(5). +\paragraph{TV grows at most linearly with length.} Hybrid argument: let $H_t$ draw the first $t$ tokens from $\pi$ and the rest from $\mu$. $H_t$ and $H_{t-1}$ differ only in the conditional of token $t$, so $\TV(H_t,H_{t-1})=\E_{s_t\sim\pi}\TV_t$, and by the triangle inequality $\TV(\pi,\mu)\le\sum_{t\le T}\E_{s_t\sim\pi}[\TV_t]\le T\max_t\TV_t$. +\ver{TIS bias equals Eq.~\eqref{eq:tis} and MIS bias equals Eq.~\eqref{eq:mis} to $\le2.6\times10^{-17}$ at $C\in\{1,1.02,1.05,1.2,2\}$; chain $0\le\|\text{bias}\|\le$ bound1 $\le$ bound2 $\le M_AM_s\TV$ holds: at $C=1$: $1.1\times10^{-4}\le1.6\times10^{-4}\le2.2\times10^{-3}$; at $C=1.02$: $6.4\times10^{-8}\le6.6\times10^{-8}\le1.7\times10^{-6}$; for $C\ge1.05$ every weight is below $C$ (max $w=1.049$) and the bias is $0$. MIS at $C=1$ discards every sequence with $w>1$: bias $0.12$ vs.\ bound $4.06$. Sequence $\TV=4.5\times10^{-4}\le\sum_t\E_\pi\TV_t=6.9\times10^{-4}$.} + +\subsection{Where the mismatch comes from: rounding of the logits} +A format with $p$ significant bits rounds every real $x$ to $\mathrm{fl}(x)$ with $|\mathrm{fl}(x)-x|\le u|x|$, $u=2^{-p}$. The softmax then sees $z+e$ with $|e_a|\le u|z_a|$: \emph{the absolute error is proportional to the magnitude of the logit}. We need its effect on log-probabilities. +\paragraph{Step 1 (first order).} $\log\mathrm{softmax}(z)_a=z_a-\log\sum_be^{z_b}$, so $\Delta\log\pi_a=e_a-\sum_b\pi_be_b+O(e^2)$. For i.i.d.\ errors of variance $s^2$, +\begin{equation}\Var(\Delta\log\pi_a)=s^2\big[(1-\pi_a)^2+\textstyle\sum_{b\ne a}\pi_b^2\big]=s^2\big(1-2\pi_a+\sum_b\pi_b^2\big).\label{eq:lpvar}\end{equation} +\ver{linearisation error $5.2\times10^{-8}$ (relative) at $e\sim10^{-6}$; Eq.~\eqref{eq:lpvar} vs.\ $4\times10^5$ Monte-Carlo draws: max $z$-score $1.2$ over 7 tokens.} +\paragraph{Step 2 (divergence).} With $\delta_a=\log\pi_a-\log\mu_a\approx-(e_a-\bar e)$, $\bar e=\sum_b\pi_be_b$, the chi-square is to first order the $\pi$-variance of the logit errors: $\chi^2(\pi\|\mu)=\E_\mu[(\pi/\mu-1)^2]$ with $\pi/\mu-1=e^{\delta}-1=\delta+O(\delta^2)$, and $\mu=\pi\,(1+O(e))$, so $\chi^2=\E_\pi[\delta^2]+O(e^3)=\E_\pi[(e-\bar e)^2]+O(e^3)=\Var_\pi(e)+O(e^3)$. The bound $|e_a|\le u|z_a|$ then gives: +\begin{equation}\chi^2(\pi\|\mu)\ \approx\ \Var_{a\sim\pi}(e_a)\ \le\ \E_{a\sim\pi}[e_a^2]\ \le\ u^2\,\E_\pi[z^2].\label{eq:chi2e}\end{equation} +Errors are \emph{centred}, so a peaked distribution (one dominant token) suffers less than its logit scale suggests: on the toy the per-token $\chi^2$ grows only like $\sigma_z^{1.34}$ for bf16, not $\sigma_z^2$. +\ver{$\chi^2/\Var_\pi(e)=1.0000,\ 1.0005,\ 1.0021$ at $\sigma_z=2,10,20$; $\Var_\pi(e)/(u^2\E_\pi z^2)\le0.59,\ 0.71,\ 0.64$ (never above 1).} + +\subsection{Long sequences: exact scaling, bf16 vs fp16 vs fp32} +For positions with independent contexts, $\E_\mu[\log w_T]=-\sum_t\KL(\mu_t\|\pi_t)$, $\Var(\log w_T)=\sum_t\Var_t$ and $\E_\mu[w_T^2]=\prod_t(1+\chi_t^2)$, so $\ESS/n\approx\prod_t(1+\chi_t^2)^{-1}$ at length $T$ (0103, Eq.~(4)). With vocabulary $64$ and logits $\sim\mathcal N(0,\sigma_z^2)$, rounded to format $F$: +\begin{center}\small\begin{tabular}{@{}cccccc@{}}\toprule +$\sigma_z$ & format & $\chi^2$ / token & $\ESS/n$, $T{=}100$ & $T{=}1000$ & $T{=}4000$\\\midrule +10 & bf16 & $2.9\times10^{-4}$ & 0.977 & 0.760 & 0.309\\ +10 & fp16 & $4.3\times10^{-6}$ & 0.9996 & 0.996 & 0.983\\ +10 & fp32 & $6.5\times10^{-14}$ & 1 & 1 & 1\\ +20 & bf16 & $6.2\times10^{-4}$ & 0.958 & 0.560 & 0.083\\ +20 & fp32 & $1.3\times10^{-13}$ & 1 & 1 & 1\\\bottomrule\end{tabular}\end{center} +The fp32/bf16 ratio of $\chi^2$ is $2.2\times10^{-10}$ vs.\ the theoretical $(u_{32}/u_{16})^2=2.3\times10^{-10}$. So raising the precision of the logits does not make a small improvement, it removes the effect (an $\ESS$ of $31\%$ at $4000$ tokens becomes $1$), \emph{provided the logits are the dominant error source}. +\ver{MC with $2\times10^4$ sampled $64$-token sequences: $\E_\mu\log w_{64}$ within $0.41$ s.e.\ and $\Var\log w_{64}$ within $0.54$ s.e.\ of the exact sums; $\E_\mu w=1$ per position to $6.7\times10^{-16}$; measured max relative rounding errors $3.89\times10^{-3}$, $4.88\times10^{-4}$, $5.96\times10^{-8}$ against $u=3.91\times10^{-3},\,4.88\times10^{-4},\,5.96\times10^{-8}$.} + +\subsection{Why the \emph{LM head} is the place to spend precision} +Logits are $z_b=\sum_ih_iW_{bi}$ with $h$ the final hidden state. Rounding $h$ and $W$ to bf16 perturbs each product $h_iW_{bi}$ by a relative error $\lesssim2u$ (two rounded factors), so the logit error is proportional to $\sum_i|h_iW_{bi}|$, the magnitude of the \emph{terms}, which is at least $|z_b|$ (cancellation can only make it larger), and the logits feed an exponential. MiniMax localised the discrepancy layer by layer to ``high-magnitude activations in the LM head at the output layer''. In an emulation ($d=1024$, bf16-rounded inputs, exact accumulation) the logit error is $0.59\,u\cdot\mathrm{std}(z)$, \emph{independent of whether outlier hidden dimensions are present}, and grows in absolute terms with them ($\mathrm{std}(z)=1.0\to8.8$, error $2.4\times10^{-3}\to2.0\times10^{-2}$ as 8 hidden dims are scaled $100\times$); with fp32 inputs the error is $\approx6.2\times10^4$ times smaller, matching $u_{\rm bf16}/u_{\rm fp32}=65536$. +\ver{error$/\mathrm{std}(z)=2.3\times10^{-3}$ ($0.59u$) at outlier scale $1,10,100$; bf16/fp32 error ratio $6.6\times10^4,\,6.4\times10^4,\,6.2\times10^4$.} +\paragraph{Limits of this explanation.} Our model isolates the rounding of the logits. A real network also rounds every earlier layer, and kernels differ (fused attention, MoE routing, lightning attention). Qi et al.\ (arXiv:2510.26788) locate the root cause in BF16 rounding \emph{throughout} and fix it with FP16 ($u=2^{-11}$); Zhang et al.\ (arXiv:2602.01826) argue that mismatch grows with gradient noise during long runs so that IS alone can fail, and suppress it with an LR schedule. FP16 has a narrow range (max $65504$), and this project's own notes record an fp16 NaN on FunctionGemma (262K-vocabulary logits), where bf16 is required: the precision choice is model-dependent. On the toy, a bf16 head gives a probability correlation $0.99979$ and no token outside $|\pi/\mu-1|>0.2$; the larger discrepancy MiniMax measured ($0.987$) must therefore come from errors that accumulate through the network, which the FP32 head is reported to remove in their model, not something we can reproduce here. + +\subsection{Interaction with PPO/CISPO ratios} +Three policies appear: $\mu$ (generated the samples), $\pi_{\rm old}$ (trainer at $\theta_{\rm old}$), $\pi_\theta$. If $\pi_{\rm old}$ is taken from the sampler the ratio is $\pi_\theta/\mu$, equal to $\pi/\mu\ne1$ at $\theta=\theta_{\rm old}$, and tokens are clipped (0104) or reweighted (0107) before any update. Splitting +\[\frac{\pi_\theta}{\mu}=\underbrace{\frac{\pi_{\rm old}}{\mu}}_{\rho,\ \text{mismatch}}\cdot\underbrace{\frac{\pi_\theta}{\pi_{\rm old}}}_{r,\ \text{policy lag}}\] +lets one clip $r$ as usual and apply a truncated $\min(\rho,C)$ for the mismatch (the structure of the TIS patch on GRPO described in arXiv:2510.26788 Sec.~1). The extra forward pass for $\pi_{\rm old}$ is estimated there at $\approx25\%$ of the training cost, and they cite Liu et al.\ for the observation that token-level TIS can remain insufficient because its gradient is biased; sequence-level correction is unbiased but high-variance. + +\section{Boxed result} +\begin{result} +\begin{align*} +&\E_\mu[A\,s_\pi]-\nabla J=\E_\pi\big[(\tfrac\mu\pi-1)As_\pi\big],\qquad\E_\mu[wAs_\pi]=\nabla J\\ +&\text{TIS bias}=-\E_\mu[(w-C)_+As],\quad\|\cdot\|\le M_AM_s\min\big(\TV(\pi,\mu),P_\pi(w>C)\big)\\ +&\TV(\pi,\mu)\le\textstyle\sum_t\E_\pi\TV_t,\qquad|e_a|\le u|z_a|,\qquad\chi^2(\pi\|\mu)\approx\Var_\pi(e)\le u^2\E_\pi[z^2]\\ +&\E_\mu[w_T^2]=\textstyle\prod_t(1+\chi_t^2)\quad(\text{history-independent }\chi_t^2;\ \text{else the recursion of 0103})\\ +&\chi^2_{\rm fp32}/\chi^2_{\rm bf16}\approx(2^{-16})^2 +\end{align*} +\end{result} + +\section{What breaks} +\begin{breaks}\small\begin{itemize} +\item \textbf{Small-error assumption.} Eqs.~\eqref{eq:lpvar}--\eqref{eq:chi2e} are first order. Discrete failures are not small perturbations: a \emph{router flip} in a mixture-of-experts layer changes which experts run, an $O(1)$ jump in $\mu$ (the router-replay line of work, arXiv:2510.11370, targets this; vault summary only, not read in full here). +\item \textbf{Support violations.} Top-$k$/top-$p$ sampling gives $\mu=0$ where $\pi>0$: IS cannot correct it (0103), and $\TV(\pi,\mu)$ is then not small. +\item \textbf{TIS/MIS are biased} (Eq.~\eqref{eq:tisbound}) and the bias is \emph{systematic}: they remove exactly the tokens/sequences with the largest $\pi/\mu$, which are the tokens where the sampler under-weights the trainer's belief. +\item \textbf{Deployment gap remains.} Correcting $\nabla J_\pi$ does not make $\arg\max_\theta J_\pi=\arg\max_\theta J_\mu$ (Eq.~4 of arXiv:2510.26788); only removing the mismatch at the source does. +\item \textbf{FP32 head is one error source.} It removes the logit rounding (here $\chi^2\to10^{-10}$ of bf16) but not hidden-state errors; with all other layers in bf16 the residual mismatch can remain large. +\item \textbf{Diagnostics.} Pearson correlation of probabilities is dominated by high-probability tokens; monitor mean $|\Delta\log p|$, $\max|\Delta\log p|$, per-token $\chi^2$ and the fraction of tokens with $|\pi/\mu-1|>\epsilon$. +\item \textbf{Precision trade-off.} fp16 gains mantissa bits but loses range ($>65504$ overflows; project note: fp16 NaN on a 262K-vocabulary model). +\end{itemize}\end{breaks} + +\nextnote{Everything so far regularised the \emph{speed} of the policy (clips, caps) rather than its \emph{distance to the reference}. Note 0109 treats the KL penalty: the three Monte-Carlo estimators $k_1,k_2,k_3$, their bias and variance as \emph{values}, what their \emph{gradients} actually optimise, and why recent prover/reasoning recipes set $\beta=0$.} + +\section*{References} +{\small\begin{itemize}[leftmargin=1.2em] +\item Qi et al., \emph{Defeating the Training-Inference Mismatch via FP16}, arXiv:2510.26788: Sec.~1 (two-engine mismatch, deployment gap, $\approx25\%$ overhead), Sec.~2 Eqs.~3--4 (bias, deployment gap), Sec.~2.1 Eqs.~5--7 (IS, TIS, MIS). +\item MiniMax, arXiv:2506.13585, Sec.~3.2 ``Computational Precision Mismatch in Generation and Training'' and Fig.~3 (FP32 LM head; correlations $0.987319\to0.997135$ as printed in the figure). +\item Zhang et al., \emph{Beyond Precision}, arXiv:2602.01826 (cited at abstract level: mismatch as an optimisation problem, LR scheduling; the full text is now in the vault as \texttt{beyond-precision-training-inference-mismatch-is}, but no claim above depends on it). +\item Stabilizing MoE RL by aligning training and inference routers, arXiv:2510.11370 (cited from its abstract; the full text is now in the vault as \texttt{stabilizing-moe-reinforcement-learning-by-aligning}, but no claim above depends on it). +\item Mistral AI, Leanstral technical report (\texttt{LeanstralReport.pdf}, Sec.~2.3; not on arXiv): the CISPO ratio there is against $\pi_{\rm sample}$ and is stated to target trainer--generator mismatch; see 0107, subsection \emph{Leanstral variant}. +\item Rounding model, Eqs.~\eqref{eq:biasmu}--\eqref{eq:chi2e}, the hybrid TV bound and the long-horizon product are derived here and verified in the notebook; unit roundoffs $2^{-8},2^{-11},2^{-24}$ are checked numerically against \texttt{torch} casts. +\end{itemize}} +\end{document} diff --git a/code/notes/src/0109-kl-estimators.tex b/code/notes/src/0109-kl-estimators.tex new file mode 100644 index 0000000000000000000000000000000000000000..ef866583df3be2c823892c0e6c1daad8b65fbebf --- /dev/null +++ b/code/notes/src/0109-kl-estimators.tex @@ -0,0 +1,113 @@ +\documentclass[11pt]{article} +\usepackage{common} +\begin{document} +\notehead{0109}{KL regularisation estimators $k_1,k_2,k_3$; why recent verifier-RL sets $\beta=0$}{Policy-optimisation lineage, batch M1. Companion notebook: \texttt{notebooks/0109-kl-estimators.ipynb}.} + +\begin{problem} +\question{What does each KL estimator estimate as a value, what does it optimise as a loss, and should the penalty be there at all for an exact verifier?} +RLHF maximises $\E[R]-\beta\,\KL(\pi_\theta\|\pi_{\rm ref})$ so that the policy stays where a learned reward model is accurate. The KL over a space of sequences cannot be summed, so it is estimated from samples, and one of three Monte-Carlo estimators (Schulman's $k_1,k_2,k_3$) is used, either as a number or as a loss to differentiate. Three separate questions must not be conflated: (i) as a \emph{value}, how biased and how noisy is each estimator? (ii) as a \emph{loss}, what does differentiating it actually optimise? (iii) for RL with an exact verifier (theorem proving), should the penalty be there at all? Recent recipes answer (iii) with $\beta=0$; we say why, and what that choice costs. +\end{problem} + +\section{Notation} +\begin{center}\small\begin{tabular}{@{}ll@{}}\toprule +symbol & meaning\\\midrule +$q=\pi_\theta$ (sampling), $p=\pi_{\rm ref}$ & policy and frozen reference; samples $x\sim q$\\ +$r=p(x)/q(x)$, $\ell=\log r$ & ratio, log-ratio; $\E_q[r]=1$ if $\mathrm{supp}\,p\subseteq\mathrm{supp}\,q$\\ +$\KL(q\|p)=\E_q[-\ell]$ & reverse KL (the regulariser); $\KL(p\|q)$ the forward KL\\ +$k_1,\,k_2,\,k_3$ & $-\log r,\ \tfrac12(\log r)^2,\ (r-1)-\log r$ (Schulman's note)\\ +$\sigma^2$ & for Gaussians $q=\mathcal N(0,1)$, $p=\mathcal N(\sigma,1)$: $\KL=\sigma^2/2$\\ +$\lambda=-\ell=\log(q/p)$ & log-ratio in the \emph{policy} direction; $k_1=\lambda$, $k_2=\tfrac12\lambda^2$\\ +$s_t,\ \lambda_t$ & token score and token log-ratio $\lambda_t=\log(\pi_\theta/\pi_{\rm ref})(o_t|s_t)$\\\bottomrule\end{tabular}\end{center} +\textbf{Sign convention.} Secs.~2.1--2.2 (values) use $\ell=\log(p/q)=-\lambda$; Secs.~2.3--2.4 (gradients, sequences) use $\lambda=\log(q/p)$, so that $\nabla\lambda=+\nabla\log q$; in the boxed result the first line (a value) has $\ell$, the last line (gradients) has $\lambda$. + +\section{Derivation} +\subsection{Values: unbiasedness} +\begin{itemize} +\item $k_1$: $\E_q[-\log r]=\sum q\log\frac qp=\KL(q\|p)$. Unbiased by definition. +\item $k_3$: $\E_q[r-1]=\sum p-1=0$ (a control variate with coefficient $c=1$), so $\E_q[k_3]=\KL$. It is $\ge0$ pointwise since $\log x\le x-1$ (the vertical distance between $\log$ and its tangent at $1$, a Bregman divergence). +\item $k_2$: $\E_q[\tfrac12\ell^2]$ is the $f$-divergence with $f=\tfrac12\log^2$, and $f''(1)=1$ equals KL's, so $k_2$ and KL agree to second order near $p\approx q$ but not beyond. The exact bias: $\E_q[e^\ell]=\E_q[r]=1$ gives $\sum_{n\ge1}\E[\ell^n]/n!=0$, i.e. +\begin{equation}\KL(q\|p)=-\E[\ell]=\sum_{n\ge2}\frac{\E_q[\ell^n]}{n!},\qquad\E_q[k_2]-\KL=-\sum_{n\ge3}\frac{\E_q[\ell^n]}{n!}.\label{eq:k2bias}\end{equation} +\end{itemize} +\ver{on three random categorical pairs ($K=6$): $\E[k_1]=\E[k_3]=\KL$ to $\le2.9\times10^{-16}$; the series in Eq.~\eqref{eq:k2bias} (40 terms) reproduces both KL and the bias of $k_2$ to $\le2.8\times10^{-16}$; the sign of $k_2$'s bias is not fixed ($-0.019,\ +0.014,\ +0.155$ for KL $=0.106,\ 0.286,\ 0.612$); $k_3,k_2\ge0$ on a $10^5$-point log-grid of $r\in[e^{-12},e^{12}]$ while $k_1<0$ on half of it.} + +\subsection{Values: variance, in closed form for Gaussians} +Let $q=\mathcal N(0,1)$, $p=\mathcal N(\sigma,1)$. Then $\ell=m+\sigma Z$ with $m=-\sigma^2/2$, $Z\sim\mathcal N(0,1)$ under $q$, and $\KL=\sigma^2/2$. +\begin{align*} +\Var(k_1)&=\Var(\ell)=\sigma^2=2\KL.\\ +\E[k_2]&=\tfrac12\E\ell^2=\tfrac12(\sigma^2+\tfrac{\sigma^4}4),\ \text{so}\ \text{bias}(k_2)=\tfrac{\sigma^4}8=\tfrac12\KL^2.\\ +\Var(k_2)&=\tfrac14\Var(\ell^2)=\tfrac14(4m^2\sigma^2+2\sigma^4)=\tfrac{\sigma^6}4+\tfrac{\sigma^4}2.\\ +\Var(k_3)&=\Var(r)+\Var(\ell)-2\Cov(r,\ell)=(e^{\sigma^2}-1)+\sigma^2-2\sigma^2=e^{\sigma^2}-1-\sigma^2, +\end{align*} +using $\E_q[r^2]=e^{\sigma^2}$ and $\Cov_q(r,\ell)=\E_p[\ell]-\E_q[\ell]=\sigma^2$. Hence, relative to the true value, +\[\frac{\mathrm{std}(k_1)}{\KL}=\sqrt{\tfrac{2}{\KL}},\qquad\frac{\mathrm{std}(k_2)}{\KL}=\sqrt{2+2\KL},\qquad\frac{\mathrm{std}(k_3)}{\KL}=\frac{\sqrt{e^{2\KL}-1-2\KL}}{\KL},\qquad\frac{\text{bias}(k_2)}{\KL}=\frac{\KL}2.\] +For small KL, $k_2$ and $k_3\approx\ell^2/2$ have standard deviation $\approx\sqrt2\,\KL$ while $k_1$ has $\sqrt{2\KL}\gg\KL$: that is why $k_1$ is unusable as a value when the policy is near the reference. +\ver{Gauss--Hermite quadrature (120 nodes) matches every closed form: variances to $\le2.2\times10^{-12}$ (relative), biases to $\le1.2\times10^{-16}$ at $\KL=0.005,\,0.05,\,0.5$. At $\KL=0.005$: std/KL $=20.0,\,1.418,\,1.417$ for $k_1,k_2,k_3$ and bias$(k_2)/\KL=0.0025$; at $\KL=0.5$: $2.0,\,1.732,\,1.695$ and $0.25$ (Schulman's table gives $20,\,1.42,\,1.42$ and $2,\,1.73,\,1.7$, $0.25$).} + +\paragraph{Heavy tails of $k_3$.} $k_3\approx r$ for large $r$, so $\Var(k_3)\sim\E_q[r^2]=1+\chi^2(p\|q)$: infinite when the policy gives vanishing mass to something the reference likes. Example: $q=(0.001,0.999)$, $p=(0.3,0.7)$: $\KL=0.350$; std of $k_1=0.19$, $k_2=0.51$, $k_3=9.27$ ($r=300$ on the rare token, $\chi^2=89.5$). So $k_3$'s ``low variance'' holds near $q\approx p$ and fails exactly when the policy has drifted away from reference support. +\ver{$\E_q[r^2]-1=\chi^2(p\|q)=89.5$ exactly; stds as above.} + +\subsection{Gradients: what each estimator optimises when differentiated} +With $p$ frozen, $\nabla\lambda=\nabla\log q$ (note the sign: $\ell=\log p-\log q$ would give $\nabla\ell=-\nabla\log q$). Samples $x\sim q_\theta$ are drawn \emph{on-policy and detached}; the gradient flows through $q_\theta$ inside $r$ (``$k_n$ as loss''), or the estimator is used as a detached coefficient on the score (``$k_n$ in reward'' as in an RL reward penalty). +\begin{align*} +\nabla k_1&=\nabla\log q\ \Rightarrow\ \E_q[\nabla k_1]=0\quad\text{(Lemma 1 of 0102: no regularising gradient)}\\ +\nabla k_2&=\lambda\,\nabla\log q\ \Rightarrow\ \E_q[\nabla k_2]=\E_q[\lambda\nabla\log q]=\nabla\KL(q\|p)\\ +\nabla k_3&=(1-r)\nabla\log q\ \Rightarrow\ \E_q[\nabla k_3]=0-\textstyle\sum_xp\,\nabla\log q=\nabla\KL(p\|q) +\end{align*} +Derivations: $\nabla\KL(q\|p)=\sum\nabla q\,\lambda+\sum q\nabla\lambda=\E_q[\lambda\nabla\log q]+\E_q[\nabla\log q]=\E_q[\lambda\nabla\log q]$. For $k_3$: $\nabla r=-r\nabla\log q$, so $\nabla k_3=\nabla r\,(1-1/r)=(1-r)\nabla\log q$, and $\E_q[r\nabla\log q]=\sum p\nabla\log q$, while $\nabla\KL(p\|q)=-\sum p\nabla\log q$. +\textbf{Conclusions} (Liu et al., arXiv:2510.01555, framework in Sec.~4, gradient analysis in Secs.~5.1--5.3, off-policy correction in App.~G): (a) $k_1$ is an unbiased \emph{value} (noisy near $\pi_{\rm ref}$, Sec.~2.2) and a useless \emph{loss}; (b) $k_2$ as a loss and $k_1$ as a detached coefficient (reward penalty $\beta\,\mathrm{sg}(\log q/p)$) both give exactly the gradient of the reverse KL; (c) $k_3$ as a loss (GRPO's choice, DeepSeekMath Eqs.~3--4) has the gradient of the \emph{forward} KL $\KL(p\|q)$, not of the quantity it is an unbiased estimate of. The coefficient $1-r$ is the first-order Taylor expansion of the principled $-\log r$ at $r=1$, so the two gradients agree to first order and differ at second. +\ver{exact on-policy enumeration: $\E[\nabla k_1]=5.6\times10^{-17}$; $\E[\nabla k_2]$ and the detached-coefficient form equal $\nabla\KL(q\|p)$ to $5.6\times10^{-17}$; $\E[\nabla k_3]$ equals $\nabla\KL(p\|q)$ to $8.3\times10^{-17}$; the two gradients differ by $21.5\%$ in relative norm at one distance, and the gap halves as $q\to p$: $15.3\%,\,9.2\%,\,5.1\%,\,2.6\%,\,1.35\%$ for distance $0.4,\,0.2,\,0.1,\,0.05,\,0.025$ (first-order agreement).} +Off-policy (several updates per batch) all ``as loss'' forms additionally need the importance weight (0103); Liu et al.\ App.~G give the corrected form. The same gradient lens applies to the \emph{sequence} view below. + +\subsection{Sequences: per-token estimators ignore how earlier tokens move later contexts} +The sequence KL gradient is $\nabla\KL_{\rm seq}=\E[(\sum_t\lambda_t)\,s(o)]=\E\big[\sum_t(\sum_{t'\ge t}\lambda_{t'})\,s_t\big]$ (0102: reward-to-go; ``$k_1$ in reward''). A per-token loss $\sum_tk_2(\lambda_t)$ instead gives $\E[\sum_t\lambda_ts_t]$, which drops the dependence of the token-$t'$ log-ratio on tokens $t0$ the optimum of the regularised objective is $\pi^\star\propto\pi_{\rm ref}\,e^{R/\beta}$, which for $R$ the pass indicator keeps probability on every reference proof, including wrong ones. +\paragraph{What it costs, honestly.} Nothing in the objective then keeps the policy near its initialisation; drift, entropy collapse and repetition are controlled only by the other mechanisms in 0106--0108. ProRL (Liu et al., cited in arXiv:2510.01555 Sec.~2) is reported to keep a $k_2$-as-loss KL penalty and periodically reset the reference model, and to find KL important for long-run stability: so $\beta=0$ is a design choice with evidence on both sides, not a theorem. And a KL penalty is \emph{not} a defence against a flaw in the verifier: a verifier hole reachable by small steps from $\pi_{\rm ref}$ is still reachable under $\beta>0$. + +\section{Boxed result} +\begin{result} +\begin{align*} +&\E_q[k_1]=\E_q[k_3]=\KL(q\|p),\qquad\E_q[k_2]-\KL=-\textstyle\sum_{n\ge3}\E_q[\ell^n]/n!\quad(=\tfrac12\KL^2\text{ for Gaussians})\\ +&\text{std}/\KL\ \text{(Gaussian)}:\ \ k_1:\sqrt{2/\KL},\quad k_2:\sqrt{2+2\KL},\quad k_3:\sqrt{e^{2\KL}-1-2\KL}/\KL\\ +&\E_q[\nabla k_1]=0,\quad\E_q[\nabla k_2]=\nabla\KL(q\|p),\quad\E_q[\nabla k_3]=\nabla\KL(p\|q)\\ +&\E_q[\mathrm{sg}(\lambda)\nabla\log q]=\nabla\KL(q\|p),\qquad\nabla\KL_{\rm seq}-\E\textstyle\sum_t\lambda_ts_t=\sum_t\E[\KL_t(s_t)\nabla\log\pi(o_{0$); the centred one scales by $a$ and is shift-invariant (0105). So $\{0,1\}\to\{-1,+1\}$ changes nothing for GRPO and doubles every Dr.\ GRPO gradient. The \emph{relative} scale of two reward components (correctness vs.\ a length term) is what matters under std-normalisation. +\item \emph{Potential-based shaping (finite horizon, $\gamma=1$).} Let $r_t=\Phi(s_{t+1})-\Phi(s_t)$ with $\Phi(\text{terminal})=0$. Then $\sum_tr_t=\Phi(s_T)-\Phi(s_0)=-\Phi(q)$, a constant for the prompt, and $\E[(R-\Phi(q))s]=\nabla J$ (a prompt-only baseline, 0102). So \emph{any} dense shaping of this form leaves $\nabla J$ exactly unchanged; it only changes variance and credit assignment (with $\Phi=V_\pi$ it produces the advantages of 0104). The condition is essential: if $\Phi$ is nonzero at terminal states (for instance proportional to length), the shaped return is $R+\Phi(s_T)-\Phi(s_0)$ and the objective changes. +\end{itemize} +\ver{telescoping $\sum_t[\Phi(s_{t+1})-\Phi(s_t)]=-\Phi(\emptyset)$ on all 121 sequences ($4.4\times10^{-16}$); gradient of $\E[R+\text{shaping}]$ equals $\nabla J$ to $1.5\times10^{-16}$; with terminal potential $0.7|o|$ the gradient differs by $0.580$ ($\|\nabla J\|=0.183$); GRPO gradients with $\{0,1\}$ and $\{-1,+1\}$ rewards coincide ($0$), Dr.\ GRPO's are in ratio $2$ exactly.} +(The classical statement is potential-based shaping, Ng--Harada--Russell; it is not re-cited from the original here, only re-derived for the finite horizon.) + +\subsection{Overlong and length penalties: as reward, not as loss} +Length is a discrete function of the sampled response, so it reaches the policy only through the score function: $\nabla\E|o|=\E[|o|\,s]$. A \emph{reward} term $R_{\rm len}$ is therefore the only principled route, and it is separable: +\begin{equation}\nabla\E[R-\lambda|o|]=\nabla\E R-\lambda\nabla\E|o|,\qquad\nabla\E|o|=\E[|o|s].\label{eq:lenrew}\end{equation} +The alternatives are implicit rewards: sequence-mean normalisation ($1/|o|$, 0105 Eq.~(3)) optimises $\E[(R-b)/|o|]$, whose effective reward is not separable, depends on the baseline $b$, and has opposite length incentives for successes and failures; loss masking of truncated samples is the neutral reward $b$ (0106). DAPO's soft overlong punishment (0106, Eq.~(3)) is the clean version: continuous, slope $1/L_{\rm cache}$, range $[-1,0]$, added to the correctness reward $\pm1$ (arXiv:2503.14476 Eq.~13). +\ver{Eq.~\eqref{eq:lenrew} holds to $1.0\times10^{-17}$; $\nabla\E|o|=\E[|o|s]$ to $1.1\times10^{-16}$.} + +\subsection{Verifier errors} +\paragraph{Constant error rates only rescale.} If the verifier accepts a wrong response with probability $\alpha_{FA}$ and rejects a correct one with $\alpha_{FR}$, independently of the response, $\E[R_{\rm obs}\mid\text{response}]=R(1-\alpha_{FR})+(1-R)\alpha_{FA}$ and +\[\E R_{\rm obs}=\alpha_{FA}+(1-\alpha_{FR}-\alpha_{FA})\,p,\qquad\nabla\E R_{\rm obs}=(1-\alpha_{FR}-\alpha_{FA})\,\nabla p,\] +still an ascent direction iff $\alpha_{FA}+\alpha_{FR}<1$. +\ver{exact on the toy with $\alpha_{FA}=0.10$, $\alpha_{FR}=0.05$: value $0$, gradient $2.4\times10^{-17}$.} +\paragraph{Response-dependent false accepts re-target the objective.} If a class $\mathcal E$ of wrong responses is accepted with probability $a(o)$ (a verifier hole), the observed objective is $p+\sum_{o\in\mathcal E}a\,\pi(o)$ and the gradient is $\nabla p+\nabla P(\mathcal E)$ for $a\equiv1$: the policy follows whichever acceptance set is easier to reach, with no preference for the true one. On the toy we construct such a class (short responses with an even first token, $P=0.246$): $\|\nabla P(\mathcal E)\|=0.279>\|\nabla p\|=0.183$, the two gradients are nearly orthogonal (cosine $0.08$), and the exploit component alone has $0.81$ of the norm of the observed gradient. The numbers are artificial (we chose the class), the structure is not: the gradient is a \emph{sum}. A KL penalty does not remove it (0109): a hole reachable from the reference stays reachable. Mitigations are properties of the verifier and the data (independent re-checking, adversarial search for false accepts, treating detected exploit classes as an extra negative reward term), not of the RL objective. +\ver{observed-reward gradient (autograd) $=\nabla p+\nabla P(\mathcal E)$ to $2.8\times10^{-17}$.} + +\section{Boxed result} +\begin{result} +\begin{align*} +&\nabla p=p\,\E[s|R{=}1]=p(1-p)\big(\E[s|R{=}1]-\E[s|R{=}0]\big)\\ +&g_{\rm centred}=\hat p(1-\hat p),\qquad g_{\rm GRPO}=\sqrt{\hat p(1-\hat p)},\\ +&P(\text{dead})=p^G+(1-p)^G\ \Rightarrow\ \text{a dead group has gradient}\equiv0\\ +&r_t=\Phi(s_{t+1})-\Phi(s_t),\ \Phi(\text{term})=0\ \Rightarrow\ \nabla J\text{ unchanged}\\ +&\nabla\E[R-\lambda|o|]=\nabla\E R-\lambda\E[|o|s]\\ +&\nabla\E R_{\rm obs}=(1-\alpha_{FR}-\alpha_{FA})\nabla p\ \ (\text{constant rates})\\ +&\nabla\E R_{\rm obs}=\nabla p+\nabla P(\mathcal E)\ \ (\text{accepted exploit class }\mathcal E) +\end{align*} +\end{result} + +\section{What breaks} +\begin{breaks}\small\begin{itemize} +\item \textbf{Sparse reward.} For $p\ll1/G$ almost every group is dead; cost $G/P_{\rm live}$ samples per informative group ($1008$ at $p=0.001$, $G=16$). \emph{Violated:} enough successes per batch for a non-zero estimate. +\item \textbf{Non-stationarity.} $p_q$ changes during training; a prompt informative at step $0$ ($p\approx0.2$) becomes dead at $p\approx1$. +\item \textbf{Baselines make groups dead; their absence makes noise.} Without a baseline, ``dead'' groups still push the policy (norm $2.1$--$3.8$ above, $12$--$21\times\|\nabla J\|$): unbiased in expectation, but dominated by score noise. +\item \textbf{Shaping that is not potential-based changes the objective}, including every length term: intended, but the relative scale of $R_{\rm len}$ to the correctness reward sets the trade-off, and under std-normalisation only that ratio matters. +\item \textbf{Response-dependent verifier errors.} A time-out or resource limit that fails long or complex proofs is a false reject correlated with length (a length-correlated bias of exactly the kind 0105 warns about; stated as an inference, not measured here); a false accept correlated with a style of output is an exploit class. +\item \textbf{Correlated group members.} Eq.~\eqref{eq:dead} assumes i.i.d.\ samples; low-entropy policies produce near-identical groups, so dead groups are more frequent than $p^G+(1-p)^G$ (this is the entropy collapse of 0106). +\item \textbf{Binary $\ne$ graded.} Partial credit (fraction of sub-goals) turns the estimator into the reward-reshaping case of 0105 (std-normalisation compresses high rewards). +\end{itemize}\end{breaks} + +\nextnote{This closes the policy-optimisation lineage of batch M1 (0101--0110): likelihood $\to$ policy gradient $\to$ importance sampling $\to$ trust region/clip $\to$ GRPO/Dr.\ GRPO $\to$ DAPO $\to$ CISPO $\to$ mismatch correction $\to$ KL $\to$ verifier-reward shaping. The natural continuation is to put these objectives in front of a concrete verifier loop (group sizes, filter thresholds, length budget and the audit of false accepts); that work belongs to the verifier-focused notes, not to this batch.} + +\section*{References} +{\small\begin{itemize}[leftmargin=1.2em] +\item Yu et al., \emph{DAPO}, arXiv:2503.14476: Sec.~2.4 Eq.~7 (rule-based reward $\{+1,-1\}$), Sec.~3.2 (dynamic sampling and the zero-gradient problem), Sec.~3.4 Eq.~13 (soft overlong punishment). +\item Liu et al., arXiv:2503.20783, Sec.~3.1--3.2 and App.~A (advantage definitions, length bias). +\item MiniMax, arXiv:2506.13585, Sec.~3.1 (dynamic sampling and length penalty adopted from DAPO; Sec.~3.2 ``Early Truncation via Repetition Detection'': generation halted after 3,000 consecutive tokens with probability above $0.99$). +\item Eqs.~\eqref{eq:binary}--\eqref{eq:lenrew}, the potential-shaping argument and the verifier-error calculation are derived here and verified by exact enumeration. +\end{itemize}} +\end{document} diff --git a/code/notes/src/0201-verifier-binary-reward-mdp.tex b/code/notes/src/0201-verifier-binary-reward-mdp.tex new file mode 100644 index 0000000000000000000000000000000000000000..55877ff88740f856d41c8dd815cb9ff7814bfb75 --- /dev/null +++ b/code/notes/src/0201-verifier-binary-reward-mdp.tex @@ -0,0 +1,156 @@ +\documentclass[11pt]{article} +\usepackage{common} +\begin{document} +\notehead{0201}{Verifier-in-the-loop as a binary-reward MDP}{Batch M2, learning from a verifier. Companion notebook: \texttt{notebooks/0201-verifier-binary-reward-mdp.ipynb} (fp64, executed).} + +\begin{problem} +\question{What exactly is optimised when a proof is only accepted or rejected by the Lean kernel, and is that reward an unbiased signal of what we care about?} +A theorem prover is a language model $\pi_\theta$ that, given a formal statement $x$ (a Lean~4 theorem), writes a proof attempt $y$. We want to \emph{improve} $\pi_\theta$ using only the fact that a proof is either accepted or rejected by the Lean kernel. Three questions must be answered before any algorithm (rejection sampling, GRPO, \dots) can be written down: +\begin{enumerate}[nosep] +\item What is the Markov decision process, and what exactly is optimised? +\item Is the reward the compiler returns an \emph{unbiased} signal of what we care about, so that no learned reward model (and hence no reward-model error to over-optimise) sits in the loop? +\item Where can that claim fail, and what does the optimiser do when it fails? +\end{enumerate} +\end{problem} +\stepcounter{section}% the problem box counts as section 1: keeps every \S and notebook cross-reference stable + +\section{Notation} +\begin{center} +\begin{tabular}{@{}p{0.36\linewidth}p{0.60\linewidth}@{}} +\toprule +Symbol & Meaning \\ \midrule +$x\sim D$ & formal statement (prompt), drawn from a problem distribution $D$ \\ +$y=(y_1,\dots,y_T)$ & proof attempt, tokens $y_t\in\mathcal V$; $T$ may vary, EOS terminates \\ +$s_t=(x,y_{0$; terms with $\pi=0$ contribute $0$ to both sides). + +\subsection{Factorising over tokens} +Since $\log\pi_\theta(y|x)=\sum_{t=1}^T\log\pi_\theta(y_t|s_t)$, +\begin{equation}\label{eq:tok} +\nabla_\theta\log\pi_\theta(y|x)=\sum_{t=1}^T\nabla_\theta\log\pi_\theta(y_t|s_t). +\end{equation} +Because the only non-zero reward arrives at the end, the reward-to-go at every step is $G_t=\sum_{u\ge t}r_u=V(x,y)$. Hence +\[ +\nabla_\theta p_\theta(x)=\E\Bigl[\sum_{t=1}^T V(x,y)\,\nabla_\theta\log\pi_\theta(y_t|s_t)\Bigr]: +\] +\emph{every token of an accepted proof receives the same credit}, whether it was the key lemma or a filler \texttt{simp}. This is the credit-assignment problem in its purest form; notes 0204 and 0206 are about reducing its variance. + +\paragraph{Softmax score identity.} For a tabular softmax $\pi(a|s)=e^{z_a}/\sum_b e^{z_b}$ with logits $z=z(s)$, +\[ +\frac{\partial\log\pi(a|s)}{\partial z_b}=\ind[a=b]-\frac{e^{z_b}}{\sum_c e^{z_c}}=\ind[a=b]-\pi(b|s), +\] +i.e.\ $\nabla_z\log\pi(a|s)=e_a-\pi(\cdot|s)$. The notebook uses this hand formula for the ``naive'' gradient and compares it with autograd. + +\subsection{Baselines keep the gradient unbiased} +Differentiate the normalisation $\sum_y\pi_\theta(y|x)=1$ (true for every $\theta$): $0=\sum_y\nabla\pi_\theta(y|x)=\sum_y\pi_\theta(y|x)\nabla\log\pi_\theta(y|x)$, that is +\begin{equation}\label{eq:zero} +\E_{y\sim\pi_\theta}\bigl[\score(y|x)\bigr]=0. +\end{equation} +Hence for any $b=b(x)$ not depending on $y$, $\E[(V-b)\,\score]=\E[V\score]-b\cdot 0=\nabla p_\theta(x)$. (If $b$ depended on $y$ the factorisation $\E[b(y)\score]=b\,\E[\score]$ would fail; this is the whole unbiasedness condition, and notes 0204 and 0206 re-use it.) + +\subsection{Variance of the one-sample estimator and the optimal constant baseline} +Let $g=(V-b)\,s$ with $s=\score(y|x)$ (a vector). Then $\E g=\nabla p$ for every $b$ and +\[ +\Var[g]:=\E\|g\|^2-\|\E g\|^2=\E\bigl[(V-b)^2\|s\|^2\bigr]-\|\nabla p\|^2 . +\] +Set the derivative in $b$ to zero: $\partial_b\Var=-2\,\E[(V-b)\|s\|^2]=0$, so +\begin{equation}\label{eq:bstar} +b^\star=\frac{\E[V\,\|s\|^2]}{\E[\|s\|^2]},\qquad +\Var_{\min}=\E[V\|s\|^2]-\frac{\E[V\|s\|^2]^2}{\E\|s\|^2}-\|\nabla p\|^2, +\end{equation} +using $V^2=V$ for binary $V$. It is a \emph{score-norm-weighted} success rate. It lies near $p$ but is not equal to it. In the notebook: $p=0.3045$, $b^\star=0.3382$, and the exact variances are $0.599$ ($b=0$), $0.394$ ($b=p$), $0.392$ ($b=b^\star$): almost all of the gain comes from using \emph{any} sensible baseline. + +\subsection{Why the verifier reward is unbiased for what we want} +Define the error indicators. By definition $V-G=\ind_{A_+}-\ind_{A_-}$, hence, exactly, +\begin{equation}\label{eq:bias} +J_V(\theta)=J_G(\theta)+\E_x\,\pi_\theta(A_+|x)-\E_x\,\pi_\theta(A_-|x). +\end{equation} +Two cases give the two halves of the claim. +\begin{itemize}[nosep] +\item \emph{Soundness} ($A_+=\varnothing$): the verifier never accepts a non-proof. Then $V\le G$ pointwise, $J_V\le J_G$, the reward can only err on the \emph{pessimistic} side, and there is no region of output space where a high reward is unearned. +\item \emph{Independent timeouts}: if (with a sound verifier) rejection of a true proof happens with probability $\delta$ independently of $y$ \emph{and of $x$}, then $J_V=(1-\delta)J_G$ and $\nabla J_V=(1-\delta)\nabla J_G$: a positive rescaling of the true gradient, which a learning-rate schedule absorbs. The gradient \emph{direction} is unbiased. If $\delta=\delta(x)$ depends on the prompt (long statements time out more), the result is $\nabla J_V=\E_x[(1-\delta(x))\nabla p^G_\theta(x)]$: still $y$-independent, but a re-weighting of prompts, not a global rescaling. +\end{itemize} +Compare a learned reward model $r_\phi=G+\varepsilon(x,y)$, whose error $\varepsilon$ is a function of $y$ that the policy is free to exploit: $J_\phi-J_G=\E_\pi\varepsilon$. Choosing $y$ to maximise $G+\varepsilon$ selects \emph{for} large $\varepsilon$ (the winner's curse). For $n$ candidates, each correct w.p.\ $p$, and i.i.d.\ Gaussian error of scale $\sigma$, +\[ +\Pr[\text{RM pick correct}]=\int n\,p\,f_1(u)\bigl[p\,F_1(u)+(1-p)F_0(u)\bigr]^{n-1}du, +\qquad \text{verifier: }1-(1-p)^n, +\] +($f_j,F_j$: pdf/cdf of $\mathcal N(j,\sigma^2)$), and the score the RM reports for its pick exceeds the truth by an amount bounded by $\E\max_i\varepsilon_i\le\sigma\sqrt{2\ln n}$. Numbers from the notebook (quadrature matched by Monte Carlo): $n{=}16$, $p{=}0.1$: verifier $0.815$, RM with $\sigma=1$: $0.337$. The verifier loses \emph{only} when no sample is correct (coverage), never to noise. Gao et al.\ measure the same effect in RLHF: gold reward as a function of $d=\sqrt{\mathrm{KL}}$ follows $d(\alpha-\beta d)$ for best-of-$n$ and $d(\alpha-\beta\log d)$ for RL, rising then falling \cite{gao2022}. + +\subsection{What the optimiser does with a hole} +Take a tabular softmax over three output classes $k\in\{\text{wrong},\text{proof},\text{hole}\}$ with $V=(0,1,1)$, $G=(0,1,0)$. From $J=\sum_k\pi_kr_k$ and the softmax derivative, +\[ +\frac{\partial J}{\partial z_k}=\pi_k\Bigl(r_k-\sum_j\pi_jr_j\Bigr)=\pi_k(r_k-J). +\] +Gradient flow $\dot z_k=\pi_k(r_k-J)$ gives, for the two accepted classes (both $r=1$), +\[ +\frac{d}{dt}\bigl(z_{\text{proof}}-z_{\text{hole}}\bigr)=(\pi_{\text{proof}}-\pi_{\text{hole}})(1-J). +\] +Whichever accepted class is \emph{more reachable} gains on the other, truth plays no role. Starting from $\pi=(0.96,0.01,0.03)$ the notebook integrates this to $E[V]=0.99995$ while $E[G]=0.0008$ and $\pi(\text{hole})=0.9992$: the true-proof share of the accepted mass falls from $0.25$ to $0.001$. A sound reward is an exact reward only on the set the verifier actually checks. + +\section{Boxed result} +\begin{result} +\begin{align*} +J(\theta)&=\E_{x}\,\E_{y\sim\pi_\theta}V(x,y),\qquad +\nabla J=\E_{x,y}\Bigl[\bigl(V(x,y)-b(x)\bigr)\sum_{t=1}^{T}\nabla_\theta\log\pi_\theta(y_t|s_t)\Bigr]\ \ \text{for every } b(x),\\[3pt] +J_V&=J_G+\E\,\pi_\theta(A_+)-\E\,\pi_\theta(A_-),\qquad +b^\star=\frac{\E[V\|s\|^2]}{\E[\|s\|^2]} . +\end{align*} +With a sound verifier ($A_+=\varnothing$) and timeouts that are independent of both $y$ and $x$ ($A_-$ a fixed fraction $\delta$ of true proofs), $\nabla J_V=(1-\delta)\nabla J_G$. +\end{result} + +\section{What breaks} +\begin{breaks} +\begin{tabular}{@{}p{0.30\linewidth}p{0.30\linewidth}p{0.34\linewidth}@{}} +\toprule +Failure & Assumption violated & Consequence \\ \midrule +Verifier hole ($A_+\neq\varnothing$): \texttt{sorry}-like escapes, extra axioms, unsafe/\texttt{native} evaluation, kernel or elaborator bugs & Soundness, $V\le G$ & $J_V=J_G+\pi(A_+)$. The optimiser climbs toward the most reachable accepted output; $E[G]$ can collapse while $E[V]\to1$ (\S3.7) \\ +Statement mismatch: formal $x$ does not say what the informal problem says & $G$ measures the intended property & Perfect verification of the wrong theorem; outside the verifier's remit \\ +Timeouts correlated with $y$ (long or heavy proofs time out more) & $\delta$ independent of $y$ & $\nabla J_V=\nabla J_G-\nabla\pi(A_-)$: pressure toward cheap-to-check proofs \\ +Non-deterministic verifier (tactic randomness, load-dependent limits) & $V$ is a function of $(x,y)$ & Reward becomes random; still unbiased for $\E V$ but noisier and not reproducible \\ +Reward too sparse ($p_\theta\approx0$ on most $x$) & non-degenerate $p_\theta$ & $\nabla p=0$ at $p\in\{0,1\}$, see note 0206 \\ +Credit assignment over long $y$ & --- (not a bias, a variance) & all $T$ tokens share one scalar; $\E\|s\|^2$ is a sum of $T$ score terms and grows with $T$; cf.\ VinePPO \cite{vineppo} \\ +Reward-model substitution (for e.g.\ informal reasoning) & $\varepsilon\perp y$ & Winner's curse above; gold reward peaks then declines \cite{gao2022} \\ \bottomrule +\end{tabular} +\end{breaks} + +\nextnote{Note 0202 spends the binary reward in the cheapest possible way. Sampling then discarding unaccepted proofs and fine-tuning on the rest is the M-step of an EM algorithm whose E-step is ``sample and verify''. We show that, to first order, one such step \emph{is} the policy-gradient step derived here, and say when it can and cannot improve the policy.} + +\begin{thebibliography}{9} +\bibitem{gao2022} L.~Gao, J.~Schulman, J.~Hilton. Scaling Laws for Reward Model Overoptimization. arXiv:2210.10760, Sec.~1 (functional forms $R_{\mathrm{bon}}(d)=d(\alpha-\beta d)$, $R_{\mathrm{RL}}(d)=d(\alpha-\beta\log d)$). +\bibitem{rloo} A.~Ahmadian et al. Back to Basics: Revisiting REINFORCE Style Optimization. arXiv:2402.14740, Sec.~2.3 (leave-one-out baseline) and Eq.~8 (REINFORCE with baseline). +\bibitem{vineppo} A.~Kazemnejad et al. VinePPO: Refining Credit Assignment in RL Training of LLMs. arXiv:2410.01679 (value-network credit assignment; motivation). +\bibitem{gps} Y.~Lin et al. Goedel-Prover-V2. arXiv:2508.03613, Sec.~2.3--2.4 (Lean compiler as verifier; RL on verifier reward). +\bibitem{dsmath} Z.~Shao et al. DeepSeekMath. arXiv:2402.03300, Sec.~4.1 (GRPO with outcome reward). +\end{thebibliography} +\end{document} diff --git a/code/notes/src/0202-rejection-sampling-expert-iteration-em.tex b/code/notes/src/0202-rejection-sampling-expert-iteration-em.tex new file mode 100644 index 0000000000000000000000000000000000000000..7980623fcec13dfe6f93b1b643d6a5ee1096794d --- /dev/null +++ b/code/notes/src/0202-rejection-sampling-expert-iteration-em.tex @@ -0,0 +1,143 @@ +\documentclass[11pt]{article} +\usepackage{common} +\begin{document} +\notehead{0202}{Rejection sampling and expert iteration as policy improvement}{Batch M2. Companion notebook: \texttt{notebooks/0202-rejection-sampling-expert-iteration-em.ipynb} (fp64, executed).} + +\begin{problem} +\question{What objective does ``sample, keep the accepted, fine-tune, repeat'' optimise, how does it relate to the policy gradient, and when does it stop working?} +Note 0201 gave us a binary reward $V(x,y)$ and the policy gradient $\nabla J=\E[(V-b)\,\score]$. Running that gradient online needs a rollout engine inside the training loop, a baseline, clipping, and careful stability engineering. Rejection-sampling fine-tuning (RFT), STaR and expert iteration instead do something much simpler: \emph{sample from a frozen policy, keep what the verifier accepts, run ordinary supervised fine-tuning on the kept samples, repeat.} The question: what objective does that procedure optimise, how does it relate to the policy gradient, does it improve the policy monotonically, and when does it stop working? +\end{problem} +\stepcounter{section}% the problem box counts as section 1: keeps every \S and notebook cross-reference stable + +\section{Notation} +\begin{center} +\begin{tabular}{@{}p{0.36\linewidth}p{0.60\linewidth}@{}} +\toprule +Symbol & Meaning \\ \midrule +$\pi_t=\pi_{\theta_t}$ & frozen sampling policy at iteration $t$ \\ +$V(x,y)\in\{0,1\}$ & verifier, note 0201 \\ +$p_t(x)=\sum_y\pi_t(y|x)V(x,y)$ & per-problem solve probability under $\pi_t$ \\ +$q_t(y|x)=\pi_t(y|x)V(x,y)/p_t(x)$ & reward-weighted ("accepted") distribution, defined where $p_t(x)>0$ \\ +$O$ & binary optimality variable with $\Pr[O{=}1\,|\,x,y]=V(x,y)$ \\ +$\mathcal L(\theta,q)$ & evidence lower bound (ELBO) on $\log p_\theta(x)$ \\ +$F(\theta)=\frac1{|S|}\sum_{x\in S}\log p_\theta(x)$ & EM objective over the solvable set $S=\{x:\exists y,\,V(x,y)=1\}$ \\ +$L_t(\theta)=\E_x\E_{y\sim\pi_t}[V\log\pi_\theta]$ & pooled RFT objective \\ +$n,\ c$ & samples per problem, number accepted \\ \bottomrule +\end{tabular} +\end{center} + +\section{Derivation} +\subsection{The reward-weighted distribution and the RFT objective} +Pooled RFT draws $y\sim\pi_t(\cdot|x)$, keeps pairs with $V=1$ and minimises the token-level cross-entropy on them. In the infinite-sample limit its objective is +\[ +L_t(\theta)=\E_x\sum_y\pi_t(y|x)V(x,y)\log\pi_\theta(y|x)=\E_x\,p_t(x)\sum_y q_t(y|x)\log\pi_\theta(y|x), +\] +using $\pi_tV=p_tq_t$. Since $\sum_yq_t\log\pi_\theta=-H(q_t)-\KL(q_t\|\pi_\theta)$ and $H(q_t)$ does not depend on $\theta$, +\begin{equation}\label{eq:rft} +\argmax_\theta L_t(\theta)=\argmin_\theta\ \E_x\,p_t(x)\,\KL\bigl(q_t(\cdot|x)\,\|\,\pi_\theta(\cdot|x)\bigr). +\end{equation} +RFT is \emph{forward-KL projection of the policy onto the accepted distribution, weighted by $p_t(x)$}. + +\subsection{First-order equivalence with the policy gradient} +Differentiate $L_t$ at $\theta=\theta_t$: +\[ +\nabla_\theta L_t\big|_{\theta_t}=\E_x\sum_y\pi_t(y|x)V(x,y)\,\nabla_\theta\log\pi_\theta(y|x)\big|_{\theta_t}=\E_x\E_{y\sim\pi_t}\bigl[V\,\score\bigr]=\nabla_\theta J(\theta_t), +\] +by Eq.~(4) of note 0201. So an \emph{infinitesimal} RFT step is exactly a REINFORCE step with zero baseline; a finite RFT step (training to convergence on the fixed data) is a large, off-policy step along the same direction at its start. STaR writes the same policy gradient with an indicator reward as its Eqs.~(1)--(2) and remarks that ``the indicator function discards the gradient for all sampled rationales that do not lead to the correct answer'' \cite{star}; it then says STaR only \emph{approximates} $J$ (greedy decoding, which biases exploration, and several gradient steps on one batch), which is exactly the finite-step, off-policy caveat above. Notebook check: the autograd gradient of $L_t$ at $\theta_t$ matches $\nabla J$ to $3\times10^{-18}$. + +\subsection{The EM view} +Let $\Pr[O{=}1|x]=\sum_y\pi_\theta(y|x)V(x,y)=p_\theta(x)$. For any distribution $q(\cdot|x)$ over $y$ that puts mass wherever $V\pi_\theta>0$ (so that the importance weight $V\pi_\theta/q$ is defined and $\E_q[V\pi_\theta/q]=p_\theta(x)$), Jensen's inequality ($\log$ concave) gives +\begin{align} +\log p_\theta(x)&=\log\E_{y\sim q}\Bigl[\frac{V\,\pi_\theta}{q}\Bigr]\ \ge\ \E_{y\sim q}\Bigl[\log\frac{V\,\pi_\theta}{q}\Bigr]=\E_q[\log V]-\KL(q\|\pi_\theta)=:\mathcal L(\theta,q).\label{eq:elbo} +\end{align} +(For $q$ supported on $\{V=1\}$, $\log V=0$. If $q$ misses part of $\{V\pi_\theta>0\}$ the first equality becomes ``$\ge$'' and the bound $\log p_\theta\ge\mathcal L$ still holds. If $q$ puts mass on $\{V=0\}$ then $\mathcal L=-\infty$.) Write $q^\star_\theta=\pi_\theta V/p_\theta$. Then the gap is exactly a KL: +\begin{equation}\label{eq:gap} +\log p_\theta(x)-\mathcal L(\theta,q)=\KL(q\|q^\star_\theta). +\end{equation} +\emph{Proof.} $\KL(q\|q^\star_\theta)=\E_q[\log q-\log\pi_\theta-\log V+\log p_\theta]=-\mathcal L(\theta,q)+\log p_\theta$ (when $q$ is supported on $\{V=1\}$). $\square$ + +\textbf{E-step.} Maximise $\mathcal L(\theta_t,q)$ over $q$: by (\ref{eq:gap}) the maximiser is $q^\star_{\theta_t}=q_t$ and the bound is tight, $\mathcal L(\theta_t,q_t)=\log p_{\theta_t}(x)$. Sampling from $\pi_t$ and discarding rejects \emph{is} sampling from $q_t$ (rejection sampling). + +\textbf{M-step.} Maximise $\mathcal L(\theta,q_t)$ over $\theta$, equivalently minimise $\KL(q_t\|\pi_\theta)$, i.e.\ maximum likelihood on the accepted samples. Summing over problems with equal weights gives the exact EM M-step for the per-problem ELBOs, which is the form in which \cite{rest} derives it (their ELBO chain). Note that the objective ReST$^{\mathrm{EM}}$ actually states for non-negative rewards, their Eq.~(3), $\E_x\E_{y\sim p_t}[r\log p_\theta]$, and the loss in their Algorithm~1 pool all accepted samples, so they are the \emph{pooled} objective $L_t$ of the previous subsection (weight $p_t(x)$ per problem), not the equal-weight one. + +\subsection{Monotone improvement (what is and is not guaranteed)} +\begin{proposition} +If the M-step is solved exactly, $F(\theta_{t+1})\ge F(\theta_t)$. +\end{proposition} +\emph{Proof.} For each $x\in S$ (with $q_t(\cdot|x)\ll\pi_{\theta_{t+1}}(\cdot|x)$, which holds for the full-support softmax policies used here): +\[ +\log p_{\theta_{t+1}}(x)\ \overset{(\ref{eq:elbo})}{\ge}\ \mathcal L(\theta_{t+1},q_t)\qquad\text{and}\qquad \mathcal L(\theta_t,q_t)\ \overset{(\ref{eq:gap})}{=}\ \log p_{\theta_t}(x) +\] +hold pointwise. The middle step, $\mathcal L(\theta_{t+1},q_t)\ge\mathcal L(\theta_t,q_t)$, holds only \emph{summed} over $x\in S$, because the exact M-step maximises $\sum_{x\in S}\mathcal L(\theta,q_t(\cdot|x))$ over the shared $\theta$ and $\theta_t$ is a feasible point. Averaging the chain over $S$ gives $F(\theta_{t+1})\ge F(\theta_t)$. $\square$ + +This is the monotonicity statement ReST$^{\mathrm{EM}}$ quotes ($\mathcal L(\theta_{t+1},q_{t+1})\ge\mathcal L(\theta_t,q_{t+1})\ge\mathcal L(\theta_t,q_t)$ \cite{rest}); as item~2 below shows, it applies to the equal-weight M-step and not literally to the pooled objective their Eq.~(3) and Algorithm~1 optimise. Three things it does \emph{not} say: +\begin{enumerate}[nosep] +\item It is a statement about $F=\E_x\log p_\theta$, not about $J=\E_xp_\theta$ (pass@1). The gradients differ per problem by the factor $1/p$: $\nabla\log p=\nabla p/p$. EM weights hard problems more than the policy gradient does. +\item The pooled objective actually run in practice has weight $p_t(x)$ on each problem (\ref{eq:rft}); the guarantee holds for the equal-weight (per-problem normalised) version. +\item Pointwise improvement $p_{t+1}(x)\ge p_t(x)$ is not guaranteed when $\theta$ is shared across problems. +\end{enumerate} +In the notebook (log-linear policy, $d{=}5$ features, $K{=}12$ outputs, 8 prompts, M-step solved by L-BFGS to gradient norm $1.2\times10^{-9}$): $F$ rises monotonically from $-1.733$ to $-1.361$ in 12 iterations with zero decreases. With the \emph{pooled} (unnormalised) weights, the mean solve rate on solvable prompts rises from $0.26$ (after the first iteration) to $0.42$ (after the sixth), yet $F$ falls from $-1.45$ to $-2.35$ over the same span and in 20 of the 42 (prompt, iteration) pairs the prompt's own $p$ decreased: the update trades hard prompts for easy ones. + +\subsection{Full-capacity limit and the coverage theorem} +If the model class contains $q_t$ (tabular softmax), the exact M-step sets $\pi_{t+1}(\cdot|x)=q_t(\cdot|x)$ for every $x$ with $p_t(x)>0$. Then +\[ +p_{t+1}(x)=\sum_yq_t(y|x)V(x,y)=\frac{\sum_y\pi_tV^2}{p_t}=1 . +\] +One iteration solves every problem the current policy can solve with positive probability. For $p_t(x)=0$, $q_t$ is $0/0$: there is no accepted sample to imitate and the iteration leaves the problem untouched (notebook, six prompts: $p_t=(0.261,0.366,0.180,0.168,2\!\times\!10^{-4},0)\ \to\ p_{t+1}=(1,1,1,1,1,0)$). With finite $n$ the problem contributes at all only with probability $1-(1-p_t)^n$: the \emph{coverage frontier} is $p_t(x)\gtrsim1/n$. Any progress on $p_t=0$ problems comes from generalisation across problems, not from the algorithm. + +\subsection{The finite-sample estimator} +Fix $x$, draw $y_1,\dots,y_n\sim\pi_t$ i.i.d., $c=\sum_iV_i\sim\mathrm{Bin}(n,p)$. Condition on $c=k\ge1$ and on \emph{which} indices are correct: the correct samples are i.i.d.\ from $q_t$ (conditioning an i.i.d.\ $\pi_t$ sample on $V=1$). Therefore for any $f$, +\begin{equation}\label{eq:ratio} +\E\Bigl[\frac1c\sum_{i}V_if(y_i)\,\ind[c>0]\Bigr]=\Pr[c\ge1]\ \E_{q_t}f=\bigl(1-(1-p)^n\bigr)\,\E_{q_t}f, +\end{equation} +and the \emph{pooled} average satisfies $\E[\tfrac1n\sum_iV_if(y_i)]=p\,\E_{q_t}f$ by linearity. The filtered mean is exactly unbiased conditional on $c\ge1$; its only error is the missing event $c=0$. Verified by enumerating all $4^n$ tuples for $n\in\{1,2,3,5\}$: errors below $2.3\times10^{-15}$. + +\subsection{How the variants differ} +\begin{center} +\begin{tabular}{@{}p{0.40\linewidth}p{0.30\linewidth}p{0.22\linewidth}@{}} +\toprule +Procedure & Gradient on problem $x$ at $\theta_t$ & Per-problem weight on $\nabla p_x$ \\ \midrule +Pooled RFT / STaR / ReST$^{\mathrm{EM}}$ Eq.~(3) (\ref{eq:rft}) & $\nabla p_t(x)$ & $1$ \\ +Per-problem-normalised EM (exact M-step of the per-problem ELBOs; not what Eq.~(3) of \cite{rest} runs) & $\nabla\log p_t(x)=\nabla p_t/p_t$ (notebook check) & $1/p_t(x)$ \\ +Cap or dedupe per problem (RFT distinct-path filter \cite{rft}) & not derived here; changes the effective per-problem count & depends on cap \\ \bottomrule +\end{tabular} +\end{center} +RFT and STaR also retrain from the original base model each round \cite{rft,star}, which for a convex, exactly solved M-step is irrelevant but for finite training acts as a regulariser toward the base. Note 0203 adds the third objective, pass@$k$, whose weight is $k(1-p)^{k-1}$. + +\section{Boxed result} +\begin{result} +\begin{align*} +q_t(y|x)&=\frac{\pi_t(y|x)V(x,y)}{p_t(x)},\\ +\theta_{t+1}&=\argmax_\theta\ \E_{x,\,y\sim\pi_t}\bigl[V\log\pi_\theta\bigr]=\argmin_\theta\E_xp_t\,\KL(q_t\|\pi_\theta),\\ +\nabla_\theta L_t\big|_{\theta_t}&=\nabla_\theta J(\theta_t),\qquad +F(\theta_{t+1})\ge F(\theta_t)\quad\text{(exact M-step, equal weights)},\\ +\E\Bigl[\tfrac1c\textstyle\sum_iV_if(y_i);\,c>0\Bigr]&=\bigl(1-(1-p)^n\bigr)\E_{q_t}f,\\ +p_t(x)&=0\ \Rightarrow\ \text{no datum, no gradient (tabular: }p_{t+1}(x)=0). +\end{align*} +\end{result} + +\section{What breaks} +\begin{breaks} +\begin{tabular}{@{}p{0.29\linewidth}p{0.29\linewidth}p{0.36\linewidth}@{}} +\toprule +Failure & Assumption violated & Consequence \\ \midrule +Coverage: $np_t(x)\ll1$ & support of $\pi_t$ meets $\{V=1\}$ at $x$ & No accepted sample; EM is silent on $x$. Hardest problems never improve except by transfer \\ +Finite samples, many epochs & M-step is a population argmin & The empirical $\hat q$ has support on $k$ distinct proofs; fitting it drives $H(\pi)\to\le\ln k$ (diversity collapse; see 0203, 0205) \\ +Shared parameters & per-prompt independent M-step & $F$ monotone, pointwise $p$ not; 20/42 prompt-steps decreased in the notebook \\ +Pooled weights $\propto p_t(x)$ & equal-weight EM objective & Easy problems dominate the data; hard problems trade away \\ +Verifier holes & $V\le G$ (note 0201) & $q_t$ puts mass on false accepts and SFT \emph{distils the exploit} directly into the weights \\ +Inexact M-step (few steps, LR schedule) & exact argmax & Monotonicity proof does not apply; behaves as a damped policy-gradient step \\ +Only positives used & --- & Rejected samples carry gradient information the baseline form $(V-b)$ uses and RFT discards (variance, note 0206) \\ \bottomrule +\end{tabular} +\end{breaks} + +\nextnote{Both the EM objective and the policy gradient turn per-problem solve rates $p(x)$ into a training signal, but they weight problems differently ($1/p$ vs $1$). The metric we report, pass@$k$, is yet another per-problem transform, $1-(1-p)^k$. Note 0203 derives its unbiased estimator, its variance, and why optimising pass@1 and pass@$k$ pull in different directions.} + +\begin{thebibliography}{9} +\bibitem{rest} A.~Singh et al. Beyond Human Data: Scaling Self-Training for Problem-Solving with Language Models. arXiv:2312.06585, Sec.~3 (ELBO Eq.~2; E-step/M-step; monotone ELBO chain; ``EM with non-negative rewards'', Eq.~3, which is the pooled objective; Algorithm~1 and the ``Improve (M-step)'' loss $J(\theta)=\E_{(x,y)\sim\mathcal D_i}[r\log p_\theta]$). +\bibitem{rft} Z.~Yuan et al. Scaling Relationship on Learning Mathematical Reasoning with Large Language Models. arXiv:2308.01825, Sec.~3.3 (RFT; distinct-equation-list filter; Algorithm~1 is in the appendix). +\bibitem{star} E.~Zelikman et al. STaR: Bootstrapping Reasoning with Reasoning. arXiv:2203.14465, Sec.~3 (Eqs.~1--2: indicator reward and its gradient; retrain from the original model). +\bibitem{exit} T.~Anthony, Z.~Tian, D.~Barber. Thinking Fast and Slow with Deep Learning and Tree Search. arXiv:1705.08439 (expert iteration: planner = search, apprentice = imitation). +\bibitem{gps} Y.~Lin et al. Goedel-Prover-V2. arXiv:2508.03613, Sec.~2.4 (expert-iteration SFT stages S1--S3 before RL). +\end{thebibliography} +\end{document} diff --git a/code/notes/src/0203-pass-at-k.tex b/code/notes/src/0203-pass-at-k.tex new file mode 100644 index 0000000000000000000000000000000000000000..0795c567a05a70e57fd3905656ddcca18a7df2ba --- /dev/null +++ b/code/notes/src/0203-pass-at-k.tex @@ -0,0 +1,149 @@ +\documentclass[11pt]{article} +\usepackage{common} +\begin{document} +\notehead{0203}{pass@$k$: the unbiased estimator, its variance, and why pass@1 and pass@$k$ disagree}{Batch M2. Companion notebook: \texttt{notebooks/0203-pass-at-k.ipynb} (exact rational arithmetic and fp64, executed).} + +\begin{problem} +\question{How do we estimate pass@$k$ without bias from $n\ge k$ samples, and why is optimising pass@$k$ not the same as optimising pass@1?} +A prover is judged by pass@$k$: the probability that at least one of $k$ sampled proofs is accepted. We sample $n\ge k$ proofs per problem (a few hundred is typical), count the $c$ accepted ones, and need a number. The obvious guess $1-(1-c/n)^k$ is biased. And once pass@$k$ is a training or model-selection target it is \emph{not} the same target as pass@1: later-stage RL typically raises pass@1 while pass@$N$ for large $N$ falls \cite{gps,yue}. This note (i) derives the unbiased estimator and its variance, (ii) shows precisely where the two objectives differ, and (iii) derives an unbiased \emph{gradient} estimator for pass@$k$. +\end{problem} +\stepcounter{section}% the problem box counts as section 1: keeps every \S and notebook cross-reference stable + +\section{Notation} +\begin{center} +\begin{tabular}{@{}p{0.36\linewidth}p{0.60\linewidth}@{}} +\toprule +Symbol & Meaning \\ \midrule +$p=p_\theta(x)$ & per-problem pass@1 (note 0201) \\ +$n,\ k,\ c$ & samples drawn ($n\ge k$), attempts in the metric, number accepted; $c\sim\mathrm{Bin}(n,p)$ \\ +$\rho_{n,k}(c)=1-\binom{n-c}{k}\big/\binom nk$ & the estimator of pass@$k$ \\ +$J_k(\theta)=\E_x[1-(1-p_\theta(x))^k]$ & pass@$k$ objective ($J_1=J$) \\ +$w_k(p)=k(1-p)^{k-1}$ & per-problem weight of $J_k$ relative to $J_1$ \\ +$\rho_{-i}$ & unbiased pass@$(k{-}1)$ estimate from the $n-1$ samples other than $i$ \\ +$s_i=\nabla_\theta\log\pi_\theta(y_i|x)$ & score of sample $i$ \\ \bottomrule +\end{tabular} +\end{center} + +\section{Derivation} +\subsection{Definition} +Draw $k$ i.i.d.\ proofs from $\pi_\theta(\cdot|x)$. All fail with probability $(1-p)^k$, so +\begin{equation}\label{eq:passk} +\pk(x)=1-(1-p)^k,\qquad \pk=\E_x\bigl[1-(1-p_\theta(x))^k\bigr]=J_k . +\end{equation} +$p$ is unknown; we have only $c\sim\mathrm{Bin}(n,p)$. + +\subsection{The plug-in estimator is biased} +$\hat p=c/n$ is unbiased for $p$, but $u\mapsto(1-u)^k$ is convex, so by Jensen $\E(1-\hat p)^k\ge(1-p)^k$ and $1-(1-\hat p)^k$ \emph{under}estimates pass@$k$. For $k=2$ the bias is exact: $\E(1-\hat p)^2=(1-p)^2+\Var(\hat p)=(1-p)^2+p(1-p)/n$, so the estimate is low by $p(1-p)/n$. Notebook, $n{=}8$, $p{=}0.3$: bias $-0.026$ ($k{=}2$), $-0.075$ ($k{=}4$), $-0.104$ ($k{=}8$), as exact rationals. Codex notes the same underestimate (Appendix A, Fig.~13) \cite{codex}. + +\subsection{The unbiased estimator} +\begin{proposition} +For $n\ge k$, $\ \rho_{n,k}(c)=1-\binom{n-c}{k}/\binom nk$ satisfies $\E_{c\sim\mathrm{Bin}(n,p)}\rho_{n,k}(c)=1-(1-p)^k$ for all $p$. +\end{proposition} +\emph{Proof 1 (probabilistic).} Condition on $c$. Choose a uniformly random $k$-subset of the $n$ samples. It contains no correct sample with probability $\binom{n-c}{k}/\binom nk$ (hypergeometric). Unconditionally, a uniformly random $k$-subset of $n$ i.i.d.\ samples is itself $k$ i.i.d.\ samples, which are all wrong with probability $(1-p)^k$. By the tower property $\E_c[\binom{n-c}{k}/\binom nk]=(1-p)^k$. $\square$ + +\emph{Proof 2 (algebraic, nothing skipped).} First the identity +\[ +\binom nc\binom{n-c}{k}\Big/\binom nk=\frac{n!}{c!\,(n-c)!}\cdot\frac{(n-c)!}{k!\,(n-c-k)!}\cdot\frac{k!\,(n-k)!}{n!}=\frac{(n-k)!}{c!\,(n-c-k)!}=\binom{n-k}{c}. +\] +Then +\[ +\E_c\frac{\binom{n-c}{k}}{\binom nk}=\sum_{c=0}^{n-k}\binom nc\frac{\binom{n-c}{k}}{\binom nk}p^c(1-p)^{n-c}=\sum_{c=0}^{n-k}\binom{n-k}{c}p^c(1-p)^{n-c}=(1-p)^k\underbrace{\sum_{c=0}^{n-k}\binom{n-k}{c}p^c(1-p)^{n-k-c}}_{=1}, +\] +where terms $c>n-k$ vanish because $\binom{n-c}{k}=0$ there. $\square$ + +The notebook verifies this as an \emph{equality of rational numbers} for all $n\le40$, all $1\le k\le n$ and four values of $p$ (error exactly $0$), and the binomial identity for all $n<30$. + +\subsection{A numerically stable form} +Cancel factorials: +\[ +\frac{\binom{n-c}{k}}{\binom nk}=\frac{(n-c)!}{(n-c-k)!}\cdot\frac{(n-k)!}{n!}=\frac{(n-k)!/(n-k-c)!}{n!/(n-c)!}=\prod_{i=n-c+1}^{n}\frac{i-k}{i}=\prod_{i=n-c+1}^{n}\Bigl(1-\frac ki\Bigr), +\] +and the whole quantity is $0$ when $n-cn$} +An estimator $f(c)$ is unbiased for $g(p)$ iff $g(p)=\sum_{c=0}^nf(c)\binom ncp^c(1-p)^{n-c}$, a polynomial of degree at most $n$ (Kolmogorov, quoted as Theorem~8 of \cite{pkpo}). $1-(1-p)^k$ has degree $k$. The notebook projects it on the degree-$n$ Bernstein basis: residual $\le3\times10^{-15}$ for $k\le n$, and $4\times10^{-3}$ ($n{=}4,k{=}5$), $4\times10^{-2}$ ($n{=}4,k{=}8$) for $k>n$. + +\section{Boxed result} +\begin{result} +\begin{align*} +\pk&=\E_x\bigl[1-(1-p)^k\bigr],\qquad +\hatpk=\E_{\text{problems}}\Bigl[1-\tbinom{n-c}{k}\big/\tbinom nk\Bigr]\ \ (\text{unbiased, }n\ge k),\\ +&\tbinom{n-c}{k}\big/\tbinom nk=\prod_{i=n-c+1}^{n}\bigl(1-\tfrac ki\bigr),\\ +\Var\bigl(\rho_{n,k}\bigr)&=\sum_{j=1}^{k}\frac{\binom kj\binom{n-k}{k-j}}{\binom nk}\bigl[(1-p)^{2k-j}-(1-p)^{2k}\bigr]\ \simeq\ \frac{k^2p(1-p)^{2k-1}}{n},\\ +\nabla J_k&=\E_x\bigl[k(1-p)^{k-1}\nabla p\bigr],\\ +\hat g_k^{\mathrm{LOO}}&=\frac kn\sum_iV_i\bigl(1-\rho_{-i}\bigr)\nabla\log\pi_\theta(y_i|x)\quad(\text{unbiased for i.i.d.\ samples and }n\ge k). +\end{align*} +\end{result} + +\section{What breaks} +\begin{breaks} +\begin{tabular}{@{}p{0.29\linewidth}p{0.29\linewidth}p{0.36\linewidth}@{}} +\toprule +Failure & Assumption violated & Consequence \\ \midrule +Samples are not i.i.d.\ (shared prefix cache, fixed seed, beam/top-$k$ without replacement, deduplication) & $c\sim\mathrm{Bin}(n,p)$ & $\rho$ is no longer unbiased; deduplicated samples \emph{over}state diversity, correlated ones understate it \\ +$n0$ on prompts with moderate $p_x$ (large effect on pass@1) and can be $\le0$ on tail prompts where the useful modes are rare (small effect on pass@1, large on pass@$k$, $k\gg1$). +\item $\alpha^\star_k$ solves $\E_x[kp_x(1-p_x)^k\Delta_x]=0$. An \emph{interior} global optimum exists \emph{if} the derivative is positive at $\alpha=0$ (RL helps the base) and negative at $\alpha=1$ (RL has overshot): then neither end point can be the maximum. This is sufficient but not necessary. Counterexample: half of the prompts with $l=-3$, $\Delta=-1$ (declining) and half with $l=-8$, $\Delta=+10$ (late bloomers whose $p$ rises from $3\times10^{-4}$ to $0.88$), $k=8$. Then $\frac d{d\alpha}\mathrm{pass@}8=-0.115$ at $\alpha=0$ and $-0.062$ at $\alpha=1$, yet pass@8 is $0.162$ at $\alpha=0$, $0.568$ at $\alpha=1$ and $0.579$ at $\alpha^\star=0.80$. The path of pass@$k$ need not be unimodal, so the sign test at the end points only certifies the ``if'' direction (equivalence holds when pass@$k(\alpha)$ is unimodal). +\end{itemize} +Synthetic two-population model (65\% ``learned'' prompts with $l\sim\mathcal N(-0.5,1.5^2)$, $\Delta\sim\mathcal N(1.5,0.3^2)$; 35\% ``tail'' prompts with $l\sim\mathcal N(-3,0.5^2)$, $\Delta\sim\mathcal N(-1.2,0.3^2)$): $\alpha^\star_1=1$ ($0.278\to0.436$); $\alpha^\star_8=0.525$ (pass@8 $0.6717$ at $\alpha=0$, $0.6762$ at $\alpha^\star$, $0.6738$ at $\alpha=1$: a shallow optimum); $\alpha^\star_{32}=0$ ($0.900$ vs $0.787$ at $\alpha=1$). + +On 30 random log-linear ``SFT $\to$ RL'' families (96 prompts, 24 candidates, 6 features; SFT = 80 steps and RL = 400 further steps of pass@1 ascent, exact pass@$k$): pass@1 is non-decreasing in $\alpha$ in all 30; the end-point derivative (\ref{eq:dpk}) at $\alpha=1$ is negative for pass@8 and pass@64 in all 30 (interpolating back toward the base \emph{always} helps locally); the derivative at $\alpha=0$ is positive in 14/30 (pass@8) and 3/30 (pass@64), which are exactly the families with an interior optimum. Means: pass@1 $0.091\to0.135$ (base $\to$ RL), pass@64 $0.779\to0.466$. So in these toys averaging with the base \emph{recovers} diversity, but a strictly interior optimum requires that the RL stage still improves pass@$k$ at the base, which is what Goedel-Prover-V2 reports for its checkpoints (pass@$N$ first rises and then falls as $\alpha$ grows) \cite{gps}. We take that as the paper's empirical finding; nothing in this derivation proves it holds for their models. + +\subsection{Why a straight line in weight space is a sensible path} +Along $\theta_\alpha=\theta_0+\alpha d$ a loss $\ell$ satisfies, to second order, +\[ +\ell(\theta_\alpha)-\bigl[(1-\alpha)\ell(\theta_0)+\alpha\ell(\theta_1)\bigr]=\tfrac12\alpha(\alpha-1)\,\kappa,\qquad\kappa=d^\top Hd, +\] +exactly for a quadratic (the same algebra as (\ref{eq:err})). If $\kappa\ge0$ the interpolated model beats the chord: no barrier. The \emph{barrier} $\max_\alpha[\ell(\theta_\alpha)-\text{chord}]$ is the object studied as linear mode connectivity: Frankle et al.\ find that networks become stable to SGD noise, i.e.\ end in a linearly connected region, early in training \cite{lmc}; the soups paper observes that models fine-tuned independently from one pretrained initialisation are connected, with an error barrier mainly when fine-tuning with high learning rates (Sec.~3.3, takeaway 2; App.~J.1, Fig.~J.1) \cite{soups}. Notebook (tiny tanh MLP, 2-D spirals, fp64): two fine-tunes from one pretrained checkpoint have barrier $0.0000$; two networks trained from independent initialisations have barrier $+3.19$ (endpoint losses $\le10^{-3}$ in both cases). For the convex log-linear NLL, the barrier is non-positive by convexity (checked: $0$). + +For cross-entropy along linear logits the curvature is explicit: $L(\alpha)=-\E_x\log\mathrm{softmax}(z_\alpha)_{y^\star}$ gives +\begin{equation}\label{eq:curv} +\frac{d^2L}{d\alpha^2}=\E_x\Var_{y\sim\mathrm{softmax}(z_\alpha)}\bigl[\Delta z_y\bigr]\ \ge0 +\end{equation} +(second derivative of a log-partition function; notebook error $4\times10^{-16}$). Wortsman et al.\ give the general soup-versus-logit-ensemble approximation $L^{\mathrm{soup}}_\alpha-L^{\mathrm{ens}}_\alpha\approx\frac{\alpha(1-\alpha)}2\bigl[-\frac{d^2}{d\alpha^2}L^{\mathrm{soup}}_\alpha+\beta^2\E_x\Var_{Y\sim p_{\mathrm{softmax}}(\beta f(x;\theta_\alpha))}[\Delta f_Y(x)]\bigr]$ (their Eq.~1, Sec.~4): for logit-linear models $f^{\mathrm{ens}}=f^{\mathrm{soup}}$, both sides are $0$ and (\ref{eq:curv}) shows the two bracket terms cancel. The notebook confirms the soup loss equals the logit-ensemble loss to machine precision in that regime. Lin et al.\ argue, for RLHF, that averaging improves the alignment-forgetting Pareto front by increasing feature diversity on layers whose feature spaces overlap \cite{aligntax}; WiSE-FT interpolates zero-shot and fine-tuned weights to trade in-distribution accuracy against robustness \cite{wiseft}. + +\section{Boxed result} +\begin{result} +\begin{align*} +&z(\theta_\alpha)=(1-\alpha)z_0+\alpha z_1+\tfrac12\alpha(\alpha-1)\,d^\top H_zd+O(\|d\|^3),\qquad +\pi_\alpha\propto\pi_0^{1-\alpha}\pi_1^{\alpha}\ \ (\text{per token, logit-linear}),\\ +&\frac{d}{d\alpha}\E_\alpha[r]=\Cov_\alpha(g,r),\qquad +\frac{d}{d\alpha}\pk=\E_x\bigl[k(1-p_x)^{k-1}\Cov_\alpha(g_x,r_x)\bigr]\overset{\text{log-odds lin.}}{=}\E_x\bigl[kp_x(1-p_x)^k\Delta_x\bigr],\\ +&\ell(\theta_\alpha)-\text{chord}=\tfrac12\alpha(\alpha-1)\,d^\top Hd,\\ +&\alpha^\star_k\ \text{interior}\ \Leftarrow\ \tfrac{d}{d\alpha}\pk\big|_{0}>0\ \text{and}\ \tfrac{d}{d\alpha}\pk\big|_{1}<0\quad(\text{sufficient; iff if }\pk(\alpha)\text{ is unimodal}). +\end{align*} +\small (Covariances are those of the sequence score $\tilde g_\alpha$ for autoregressive policies; $\tilde g_\alpha=g-\E_\alpha g$ for a single categorical.) +\end{result} + +\section{What breaks} +\begin{breaks} +\begin{tabular}{@{}p{0.29\linewidth}p{0.29\linewidth}p{0.36\linewidth}@{}} +\toprule +Failure & Assumption violated & Consequence \\ \midrule +Large update $\|d\|$ (long RL, high LR) & small second-order term in (\ref{eq:err}) & Logit interpolation no longer predicts $z(\theta_\alpha)$; barrier $\propto\kappa$ \\ +Models from different pretraining or independent inits & same basin (linear mode connectivity) & Barrier is large ($+3.19$ in the toy): the average is worse than both parents \\ +High learning-rate fine-tunes & connectivity of fine-tunes & Error barrier between fine-tuned models reported at high LR \cite{soups} \\ +Averaging LoRA factors separately & averaging is linear in the \emph{update} $\Delta W=BA$ & $\bar B\bar A\ne\overline{BA}$; relative error $0.62$ for random rank-4 factors; merge then scale $\Delta W$ instead \\ +Interpolating in bf16 & update $\gg$ bf16 resolution (unit roundoff $2^{-8}$, spacing $2^{-7}$ to $2^{-8}$ of the weight) & Fine-tuning delta $10^{-3}\times$ weight scale: $69\%$ of weights are \emph{bit-identical} to the base in bf16; compute in fp32 and cast once \\ +No interior optimum & $\tfrac{d}{d\alpha}\pk|_0>0$ & If also $\pk(\alpha)$ is unimodal the base is the pass@$k$ optimum (14/30 and 3/30 families have an interior one for $k=8,64$); averaging then only trades pass@1 for pass@$k$ monotonically \\ +Choosing $\alpha$ on the reporting set & $\alpha$ selected on held-out data & Selection bias on the benchmark; select on a dev split (note 0208) \\ +Convention slip ($\alpha$ vs $1-\alpha$) & single definition & The two sections of the source report opposite conventions; state $\alpha$'s meaning in every table \\ \bottomrule +\end{tabular} +\end{breaks} + +\nextnote{Averaging repairs diversity after the fact. Note 0206 looks at the other lever, the training data: which problems carry gradient at all. With binary rewards the group-relative advantage is nonzero only when a problem is neither always nor never solved; we derive the signal as a function of the solve rate $p$, show it peaks at $p=\tfrac12$ and vanishes at $0$ and $1$, and connect it to the pass-rate band used for dynamic sampling.} + +\begin{thebibliography}{9} +\bibitem{gps} Y.~Lin et al. Goedel-Prover-V2. arXiv:2508.03613, Sec.~2.3 (``Model averaging for enhanced diversity''; $(1-\alpha)\theta_0+\alpha\theta$), Sec.~2.4 (averaging after SFT and after RL), Sec.~3.6 and Fig.~8 ($\alpha\in\{0.6,\dots,0.9\}$, pass@1 vs pass@$N$). +\bibitem{soups} M.~Wortsman et al. Model soups: averaging weights of multiple fine-tuned models improves accuracy without increasing inference time. arXiv:2203.05482, Sec.~4 (Eq.~1: soup vs.\ ensemble), Sec.~3.3 takeaway~2 and App.~J.1/Fig.~J.1 (error barriers between fine-tunes at high learning rates). +\bibitem{wiseft} M.~Wortsman et al. Robust fine-tuning of zero-shot models. arXiv:2109.01903, Eq.~(1) (WiSE-FT: weight-space ensemble $f(x,(1-\alpha)\theta_0+\alpha\theta_1)$ of zero-shot and fine-tuned weights). +\bibitem{lmc} J.~Frankle et al. Linear Mode Connectivity and the Lottery Ticket Hypothesis. arXiv:1912.05671, Sec.~3 (instability analysis of unpruned networks; networks become stable to SGD noise early in training). +\bibitem{aligntax} Y.~Lin et al. Mitigating the Alignment Tax of RLHF. arXiv:2309.06256, Sec.~5 (Proposition~5.1, proof in App.~F.2; feature diversity on layers whose feature spaces overlap; model averaging). +\bibitem{leash} F.~Wu et al. The Invisible Leash. arXiv:2507.14843 (support shrinkage under RLVR). +\end{thebibliography} +\end{document} diff --git a/code/notes/src/0206-curriculum-pass-rate-band.tex b/code/notes/src/0206-curriculum-pass-rate-band.tex new file mode 100644 index 0000000000000000000000000000000000000000..ec6f235e07da1d53a614593e25057dfd0ca02a26 --- /dev/null +++ b/code/notes/src/0206-curriculum-pass-rate-band.tex @@ -0,0 +1,135 @@ +\documentclass[11pt]{article} +\usepackage{common} +\begin{document} +\notehead{0206}{Curriculum by pass-rate band}{Batch M2. Companion notebook: \texttt{notebooks/0206-curriculum-pass-rate-band.ipynb} (exact enumeration, exact rationals and fp64, executed).} + +\begin{problem} +\question{How much gradient signal does a problem give as a function of its solve rate, and what does a pass-rate band do to the weight of each problem?} +With a binary verifier reward and group-relative advantages (GRPO, RLOO) a problem contributes gradient only if its group contains both successes and failures. A problem the model always solves, or never solves, teaches nothing, yet costs $G$ samples of generation and verification. DAPO drops groups with accuracy $0$ or $1$ and keeps sampling until the batch is full \cite{dapo}; Goedel-Prover-V2 further keeps only problems with pass rate in $(0,0.75]$, having observed that difficulty strongly affects RL \cite{gps}. We derive the gradient signal as a function of the solve rate $p$, its signal-to-noise ratio, the cost of filtering, and what a pass-rate band does to the effective weight of each problem. +\end{problem} +\stepcounter{section}% the problem box counts as section 1: keeps every \S and notebook cross-reference stable + +\section{Notation} +\begin{center} +\begin{tabular}{@{}p{0.36\linewidth}p{0.60\linewidth}@{}} +\toprule +Symbol & Meaning \\ \midrule +$p=p_\theta(x)=\sigma(z)$ & solve probability of a prompt; $z$ its logit \\ +$G,\ c,\ \hat p=c/G$ & group size, number of verified samples, empirical solve rate \\ +$r_i\in\{0,1\}$ & reward of sample $i$, $c=\sum_ir_i\sim\mathrm{Bin}(G,p)$ \\ +$s_i=\nabla\log\pi_\theta(y_i|x)$ & score; in the Bernoulli-logit model $s_i=r_i-p$ \\ +$\hat\sigma=\sqrt{\hat p(1-\hat p)}$ & empirical reward standard deviation \\ +$\hat g,\ \hat g_{\mathrm{std}}$ & group gradient with advantage $r_i-\hat p$, resp.\ $(r_i-\hat p)/\hat\sigma$ \\ +$q_{\mathrm{dead}}=p^G+(1-p)^G$ & probability that a group is uninformative ($c\in\{0,G\}$) \\ +$\tau$ & upper edge of the pass-rate band: keep iff $0<\hat p\le\tau$ \\ +$f$ & informative fraction of prompts \\ \bottomrule +\end{tabular} +\end{center} + +\section{Derivation} +\subsection{The variance of a binary reward and why $p\in\{0,1\}$ gives no gradient} +$r\sim\mathrm{Bern}(p)$ has $\E r=p$, $\E r^2=p$, so $\Var r=p-p^2=p(1-p)$. Differentiating, $\frac d{dp}[p-p^2]=1-2p=0$ at $p=\tfrac12$, second derivative $-2<0$: the maximum is $\tfrac14$ at $p=\tfrac12$ and the variance is $0$ at $p=0,1$. + +For any parameterisation the exact gradient is, by the baseline identity of note 0201, $\nabla p=\E[(r-p)s]$. If $p\in\{0,1\}$ then $r=p$ almost surely, so $\nabla p=0$: the true gradient vanishes, not merely its estimate. In general Cauchy--Schwarz gives +\begin{equation}\label{eq:cs} +\|\nabla p\|^2\le\E(r-p)^2\ \E\|s\|^2=p(1-p)\,\E\|s\|^2 . +\end{equation} +Notebook (300 prompts of a random log-linear family, exact autograd $\nabla p$): the ratio $\|\nabla p\|^2/[p(1-p)\E\|s\|^2]$ never exceeds $0.31$ ($\le1$ as required), and prompts with $p=0$ or $p=1$ have $\|\nabla p\|=0$ to $10^{-15}$. Mean $\|\nabla p\|$ rises from $0.08$ ($p<0.05$) through $0.22$ ($0.05$--$0.3$) to $0.34$ ($0.3$--$0.7$). The bound also shows that $p(1-p)$ is not the whole story: the score norm $\E\|s\|^2$ (larger for longer proofs) multiplies it. To first order one gradient step of size $\eta$ improves $p$ by $\eta\|\nabla p\|^2\le\eta\,p(1-p)\E\|s\|^2$. This is an \emph{upper} bound on the one-step gain. Bae et al.\ use the same Bernoulli variance as a ``learnability'' proxy in a different statement: for the KL-regularised optimum $\pi^\star\propto\pi_{\mathrm{init}}e^{r/\beta}$ they prove $D_{\mathrm{KL}}(\pi_{\mathrm{init}}\|\pi^\star)\ge p(1-p)/(2\beta^2)$ (their Proposition~3.1), which says that prompts with $p\in\{0,1\}$ have nothing to move toward and that the distance to the optimum is controlled from below by the reward variance \cite{odf}. It does not bound the improvement of a gradient step. + +\subsection{The group estimator in the Bernoulli-logit model} +Reduce the policy to the one parameter that matters, $z=\mathrm{logit}\,p$. A sample has $\log\pi(y_i)=r_i\log p+(1-r_i)\log(1-p)$, so $\partial_z\log\pi(y_i)=r_i-p$, and $\partial_zp=p(1-p)$. The group-relative estimator with advantage $A_i=r_i-\hat p$ is +\[ +\hat g=\frac1G\sum_i(r_i-\hat p)(r_i-p)=\frac1G\sum_i(r_i-\hat p)\,r_i=\frac1G\sum_ir_i-\hat p\cdot\frac1G\sum_ir_i=\hat p-\hat p^2=\hat p(1-\hat p). +\] +Step by step: $\sum_i(r_i-\hat p)=0$ removes the $-p$ term; $r_i^2=r_i$ gives the second equality. The notebook builds the GRPO surrogate $-\frac1G\sum_iA_i\log\pi(y_i)$ (advantage detached), differentiates with autograd for every $c\in\{0,\dots,8\}$ and four values of $p$, and matches $\hat p(1-\hat p)$ to $3\times10^{-17}$. + +\textbf{Expectation.} $\E\hat p=p$, $\E\hat p^2=\Var\hat p+p^2=p(1-p)/G+p^2$, hence +\begin{equation}\label{eq:Eg} +\E\hat g=p-\frac{p(1-p)}G-p^2=\Bigl(1-\frac1G\Bigr)p(1-p)=\Bigl(1-\frac1G\Bigr)\partial_zp , +\end{equation} +exactly the $\frac{G-1}G$ shrink of note 0204 (rational-arithmetic check, $G\in\{2,\dots,64\}$: difference exactly $0$). With the leave-one-out baseline the shrink disappears: $\E\hat g_{\mathrm{LOO}}=p(1-p)$. + +\textbf{Dead groups and SNR.} $\hat g=0$ iff $c\in\{0,G\}$, which happens with probability $q_{\mathrm{dead}}=p^G+(1-p)^G$. The exact variance is a binomial sum. For $G=8$: +\begin{center} +\begin{tabular}{@{}rrrrrr@{}} +\toprule +$p$ & $\E\hat g$ & $\mathrm{sd}\,\hat g$ & SNR $=\E/\mathrm{sd}$ & $q_{\mathrm{dead}}$ & informative \\ \midrule +$0.01$ & $0.0087$ & $0.0302$ & $0.29$ & $0.923$ & $0.077$ \\ +$0.05$ & $0.0416$ & $0.0612$ & $0.68$ & $0.663$ & $0.337$ \\ +$0.10$ & $0.0788$ & $0.0757$ & $1.04$ & $0.430$ & $0.570$ \\ +$0.25$ & $0.1641$ & $0.0738$ & $2.22$ & $0.100$ & $0.900$ \\ +$0.50$ & $0.2188$ & $0.0413$ & $5.29$ & $0.0078$ & $0.992$ \\ +$0.75$ & $0.1641$ & $0.0738$ & $2.22$ & $0.100$ & $0.900$ \\ +$0.90$ & $0.0788$ & $0.0757$ & $1.04$ & $0.430$ & $0.570$ \\ \bottomrule +\end{tabular} +\end{center} +Both $\E\hat g$ and the SNR peak at $p=\tfrac12$ and are \emph{symmetric under $p\to1-p$}: by magnitude alone, a problem solved $90\%$ of the time is exactly as (un)informative as one solved $10\%$ of the time. Monte-Carlo ($2\times10^6$ groups, $p=0.2$) matches the exact mean and the dead-group probability within $0.8$ and $0.4$ standard errors. + +\subsection{Standard-deviation normalisation changes the prompt weighting} +GRPO divides advantages by $\hat\sigma$. Then, for $\hat\sigma>0$, +\[ +\hat g_{\mathrm{std}}=\frac1G\sum_i\frac{r_i-\hat p}{\hat\sigma}(r_i-p)=\frac{\hat p(1-\hat p)}{\hat\sigma}=\hat\sigma , +\] +and $0$ otherwise. So the per-group gradient magnitude scales as $\sqrt{\hat p(1-\hat p)}$ instead of $\hat p(1-\hat p)$: a group with $c=1$ of $8$ gets $0.331/0.5=0.66$ of the gradient of a $c=4$ group, versus $0.109/0.25=0.44$ without normalisation, a factor $1.51=\hat\sigma(4)/\hat\sigma(1)$ of up-weighting for the extreme groups. In expectation the $p=0.02$ prompt has $0.109$ of the weight of a $p=0.5$ prompt with normalisation and $0.078$ without. Liu et al.\ identify exactly this: dividing by the group standard deviation gives ``questions with lower standard deviations (e.g., those that are too easy or too hard, with the outcome rewards being almost all 1 or 0) \ldots higher weights during policy updates'' (their ``question-level difficulty bias'') \cite{drgrpo}, and Goedel-Prover-V2 removes group normalisation following \cite{drgrpo} \cite{gps}. (Here $\hat\sigma$ is the population standard deviation, $\sqrt{\hat p(1-\hat p)}$. Implementations that use the unbiased sample standard deviation divide by $\hat s=\hat\sigma\sqrt{G/(G-1)}$ instead and give $\hat g_{\mathrm{std}}=\hat\sigma\sqrt{(G-1)/G}$, a constant factor $0.94$ at $G=8$ that changes no relative weight.) + +\subsection{Dynamic sampling: what filtering does and what it costs} +Because $\hat g=0$ on dead groups, discarding them changes no numerator. The DAPO batch estimator averages over kept groups, $\sum_x\hat g_x/\sum_x\ind[\text{kept}_x]$, a ratio estimator whose relative per-prompt weights equal those of the unfiltered one and whose bias is $O(1/B)$ in the number of prompts $B$ \cite{dapo}. What changes is the cost. With solve rates $p\sim\mathrm{Beta}(a,b)$, $\E p^G=B(a+G,b)/B(a,b)$ and $\E(1-p)^G=B(a,b+G)/B(a,b)$, so the informative fraction is +\begin{equation}\label{eq:f} +f=1-\frac{B(a+G,b)}{B(a,b)}-\frac{B(a,b+G)}{B(a,b)}, +\end{equation} +and a batch of $B$ informative groups costs $B/f$ prompts, i.e.\ $GB/f$ generations plus verifications. (Notebook: closed form vs Monte Carlo, $\le0.9$ standard errors.) Oversampling factors $1/f$: hard-heavy $\mathrm{Beta}(0.5,1)$: $1.56$ ($G{=}8$), $1.33$ ($G{=}16$), $1.21$ ($G{=}32$); uniform: $1.29$, $1.13$, $1.06$; easy-heavy $\mathrm{Beta}(2,0.5)$: $1.77$, $1.47$, $1.30$; late-training $\mathrm{Beta}(4,0.5)$: $2.31$, $1.77$, $1.48$. A larger $G$ lowers the dead fraction but raises per-prompt cost, a trade that depends on the population of solve rates. + +\subsection{A pass-rate band is a deliberate reweighting} +Selecting groups by their own $\hat p$ is not neutral. Keep a group iff $0<\hat p\le\tau$. Its expected gradient is $\E[\hat p(1-\hat p)\ind\{0<\hat p\le\tau\}]$, so relative to the exact gradient $p(1-p)$ of $p$ with respect to its logit the problem receives the weight multiplier +\begin{equation}\label{eq:band} +w_\tau(p)=\frac{\E[\hat p(1-\hat p)\,\ind\{0<\hat p\le\tau\}]}{p(1-p)} . +\end{equation} +For $\tau=1$ (no band) this is exactly the unfiltered shrink $1-\frac1G$ for every $p$ (error $3\times10^{-16}$); the table below should be read against that baseline value $0.875$ ($G=8$) or $0.9375$ ($G=16$). For $G=8$, $\tau=0.75$: +\begin{center} +\begin{tabular}{@{}lrrrrrrrr@{}} +\toprule +$p$ & $0.02$ & $0.10$ & $0.30$ & $0.50$ & $0.70$ & $0.80$ & $0.90$ & $0.97$ \\ \midrule +$w_{0.75}(p)$, $G{=}8$ & $0.875$ & $0.875$ & $0.874$ & $0.861$ & $0.772$ & $0.646$ & $0.410$ & $0.146$ \\ +$w_{0.75}(p)$, $G{=}16$ & $0.938$ & $0.938$ & $0.938$ & $0.931$ & $0.787$ & $0.518$ & $0.149$ & $0.007$ \\ \bottomrule +\end{tabular} +\end{center} +(simulation vs closed form at $p=0.7$: $4\times10^{-4}$). The band leaves low and mid $p$ alone and progressively removes easy problems. Magnitude alone (the symmetric table in Section 3.2) does not single out $0.75$: the asymmetric upper cut is an empirical design choice of Goedel-Prover-V2 \cite{gps}: Sec.~2.3 only says that ``question difficulty significantly impacts RL training'', and App.~E.1 gives the implementation (rollouts $n=8$, i.e.\ $G=8$ as in the tables here; an over-sample batch of three times the training batch; filter out inputs with pass rate $0$ or above $0.75$) without a derivation. The oversampling cap of $3\times$ bounds the usable $1/f$ of Eq.~(\ref{eq:f}) in their setting, and the upper cut lowers $f$ further. It is compatible with two further considerations from earlier notes: the pass@$k$ objective weights problems by $k(1-p)^{k-1}$, vanishing for easy problems (note 0203), and gradient steps on already-reliable problems sharpen the policy (note 0205's diversity loss). + +The selection issue: because $\hat p$ is computed from the same samples as the gradient, the band changes weights as in (\ref{eq:band}). Selecting prompts using an \emph{independent} pilot estimate $\tilde p$ (earlier epoch or separate samples) leaves the gradient of the selected prompts unbiased, since the selector is independent of the gradient samples, and gives full control over the weights. + +\section{Boxed result} +\begin{result} +\begin{align*} +&\Var r=p(1-p)\ \text{(max }\tfrac14\text{ at }p=\tfrac12,\ 0\text{ at }0,1),\qquad \|\nabla p\|^2\le p(1-p)\,\E\|s\|^2,\\ +&\hat g=\hat p(1-\hat p),\qquad \E\hat g=\bigl(1-\tfrac1G\bigr)p(1-p),\qquad \Pr[\hat g=0]=p^G+(1-p)^G,\qquad \hat g_{\mathrm{std}}=\hat\sigma=\sqrt{\hat p(1-\hat p)},\\ +&f=1-\tfrac{B(a+G,b)+B(a,b+G)}{B(a,b)},\qquad w_\tau(p)=\tfrac{\E[\hat p(1-\hat p)\ind\{0<\hat p\le\tau\}]}{p(1-p)} . +\end{align*} +\end{result} + +\section{What breaks} +\begin{breaks} +\begin{tabular}{@{}p{0.29\linewidth}p{0.29\linewidth}p{0.36\linewidth}@{}} +\toprule +Failure & Assumption violated & Consequence \\ \midrule +Small group size $G$ & $\hat p$ estimates the band & $q_{\mathrm{dead}}=0.43$ at $p=0.1$, $G=8$; $p=0.02$ prompts are almost never seen as informative; band membership is noisy \\ +Score norm varies across problems & gradient $\propto p(1-p)$ only & Bound (\ref{eq:cs}) has the factor $\E\|s\|^2$; long proofs carry more gradient per unit variance \\ +Non-binary or shaped rewards & Bernoulli reward & Variance is no longer $p(1-p)$; partial credit makes ``dead'' groups informative \\ +Selecting on the gradient samples' own $\hat p$ & selector independent of gradient & Band acts as a weight $w_\tau(p)$ (\ref{eq:band}); not noise but a bias relative to $\nabla J$ \\ +Std normalisation & uniform prompt weights & Extreme-$\hat p$ groups up-weighted by $1/\hat\sigma$ \cite{drgrpo} \\ +Non-stationary $p$ during training & static difficulty labels & Prompts migrate through the band; pass-rate labels go stale unless re-estimated \\ +Verifier holes (note 0201) & $V\le G$ & Hole-exploiting prompts look ``informative'' and easy prompts can be mis-scored \\ +Dynamic-sampling cost & roughly constant generation per batch & $1/f$ oversampling, up to $2.3\times$ late in training at $G{=}8$; variable batch time and extra verifier load \\ \bottomrule +\end{tabular} +\end{breaks} + +\nextnote{Note 0207 moves from training to inference. With a perfect verifier, best-of-$n$ success is $1-(1-p)^n$; we build a cost model for test-time compute, compare spending a budget on more samples against self-correction rounds, and show that early stopping with a perfect verifier makes the expected cost per problem $(1-(1-p)^n)/p$ rather than $n$.} + +\begin{thebibliography}{9} +\bibitem{dapo} Q.~Yu et al. DAPO: An Open-Source LLM Reinforcement Learning System at Scale. arXiv:2503.14476, Sec.~3.2 (Dynamic Sampling), Eq.~11 (constraint $0<|\{o_i:\text{is\_equivalent}\}|0.75$). +\bibitem{odf} Y.~Bae et al. Online Difficulty Filtering for Reasoning Oriented Reinforcement Learning. arXiv:2504.03380, Sec.~3 (Proposition~3.1: $D_{\mathrm{KL}}(\pi_{\mathrm{init}}\|\pi^\star)\ge p(1-p)/(2\beta^2)$, maximised at $p=\tfrac12$, zero at $p\in\{0,1\}$), Sec.~6.3 (Corollaries~6.2, 6.3: Gaussian and multinomial rewards). +\bibitem{drgrpo} Z.~Liu et al. Understanding R1-Zero-Like Training: A Critical Perspective. arXiv:2503.20783, Sec.~3.1 and Fig.~4 (response-level length bias and question-level difficulty bias from the std term). +\bibitem{dsmath} Z.~Shao et al. DeepSeekMath. arXiv:2402.03300, Sec.~4.1 (GRPO). +\bibitem{rloo} A.~Ahmadian et al. Back to Basics. arXiv:2402.14740, Sec.~2.3 (leave-one-out). +\end{thebibliography} +\end{document} diff --git a/code/notes/src/0207-test-time-scaling-cost-model.tex b/code/notes/src/0207-test-time-scaling-cost-model.tex new file mode 100644 index 0000000000000000000000000000000000000000..0fa77f4e6da4df6b0bb989286ff8a0f252928a91 --- /dev/null +++ b/code/notes/src/0207-test-time-scaling-cost-model.tex @@ -0,0 +1,120 @@ +\documentclass[11pt]{article} +\usepackage{common} +\begin{document} +\notehead{0207}{Test-time scaling: a cost model for samples versus self-correction}{Batch M2. Companion notebook: \texttt{notebooks/0207-test-time-scaling-cost-model.ipynb} (exact rationals, quadrature and Monte Carlo; executed).} + +\begin{problem} +\question{With a perfect verifier, how should inference compute be split between fresh samples and revisions, and when should sampling stop?} +At inference time a prover with a verifier can spend compute in two ways: more independent samples (pass@$n$), or longer sequential chains in which failed attempts are revised using compiler feedback (note 0204). With a perfect verifier the accounting changes qualitatively compared with chat-style best-of-$n$: nothing is lost to selection, and sampling can stop at the first success. This note builds the cost model: coverage versus $n$ over a population of problems, the expected cost with early stopping, the optimal stopping rule, the break-even between a fresh sample and a revision, and what an imperfect verifier would do instead. +\end{problem} +\stepcounter{section}% the problem box counts as section 1: keeps every \S and notebook cross-reference stable + +\section{Notation} +\begin{center} +\begin{tabular}{@{}p{0.36\linewidth}p{0.60\linewidth}@{}} +\toprule +Symbol & Meaning \\ \midrule +$p$ & solve probability of one sample on a given problem; across problems $p\sim\mathrm{Beta}(a,b)$ \\ +$n$ & number of attempts (budget cap); $N\sim\mathrm{Geom}(p)$ the index of the first success \\ +$\mathrm{cov}(n)=\E_p[1-(1-p)^n]$ & coverage (best-of-$n$ with a perfect verifier) \\ +$c$ & cost of one attempt (generation plus verification); $U$ value of a solved problem \\ +$h_j=\Pr[\text{success at attempt }j{+}1\mid j\text{ failures}]$ & posterior hazard \\ +$p_1,\ q$ & success of a fresh attempt; success of a revision given earlier failures \\ +$\rho$ & cost of a revision relative to a fresh attempt \\ +$\phi$ & false-accept rate of an imperfect verifier \\ \bottomrule +\end{tabular} +\end{center} + +\section{Derivation} +\subsection{Coverage with a perfect verifier} +A problem with solve probability $p$ is solved by $n$ attempts with probability $1-(1-p)^n$ and, because the verifier is exact, \emph{the solving attempt can be identified}: the selection loss of note 0201 is zero. Over a population with density $\propto p^{a-1}(1-p)^{b-1}$, +\begin{equation}\label{eq:cov} +\E_p(1-p)^n=\int_0^1\frac{p^{a-1}(1-p)^{b+n-1}}{B(a,b)}dp=\frac{B(a,b+n)}{B(a,b)},\qquad \mathrm{cov}(n)=1-\frac{B(a,b+n)}{B(a,b)} . +\end{equation} +Using $B(a,b+n)=\Gamma(a)\Gamma(b+n)/\Gamma(a+b+n)$ and $\Gamma(b+n)/\Gamma(a+b+n)\sim n^{-a}$, +\begin{equation}\label{eq:tail} +1-\mathrm{cov}(n)\ \sim\ \frac{\Gamma(a+b)}{\Gamma(b)}\,n^{-a}\qquad(n\to\infty). +\end{equation} +The uncovered fraction is a power law in $n$ whose exponent $a$ is the order of the density of problems near $p=0$: the heavier the tail of hard problems (small $a$), the slower the gain from more samples. Notebook: (\ref{eq:cov}) matches Monte Carlo ($2\times10^6$ problems) within $1.3$ standard errors for $n\in\{1,\dots,256\}$; the ratio of exact uncovered fraction to (\ref{eq:tail}) is $0.939$ at $n=10$, $0.9933$ at $100$ and $1.0000$ at $10^5$. Large Language Monkeys observes coverage rising log-linearly over four orders of magnitude and models it as $c\approx\exp(ak^b)$ (Fig.~5), and notes some curves do not follow it closely \cite{lm}. Fitting that law to Beta-mixture curves on $k\in[1,10^4]$ gives mean absolute errors $0.006$, $0.001$, $0.006$ (maxima $0.026,0.012,0.053$) for three Beta populations, comparable to the errors in that paper's fits; we do not claim the Beta model is the true generative process, only that a mixture of per-problem geometric laws reproduces the shape. + +\subsection{Early stopping: the expected cost with a perfect verifier} +Sample until the first verified success or $n$ attempts. Then the attempts used are $\min(N,n)$ and +\begin{equation}\label{eq:early} +\E\min(N,n)=\sum_{j=0}^{n-1}\Pr[N>j]=\sum_{j=0}^{n-1}(1-p)^j=\frac{1-(1-p)^n}{p}, +\end{equation} +exactly (rational arithmetic; also by direct enumeration of the truncated geometric distribution). Averaging over the population, $\sum_{j1.96$ & bootstrap (MC, $1500$ reps) \\ \midrule +5 & 0.0000 & 0.0625 & 0.059 \\ +6 & 0.0313 & 0.0313 & 0.029 \\ +8 & 0.0078 & 0.0703 & 0.063 \\ +10 & 0.0215 & 0.0215 & 0.031 \\ +20 & 0.0414 & 0.0414 & 0.044 \\ +60 & 0.0273 & 0.0519 & 0.053 \\ \bottomrule +\end{tabular} +\end{center} +The exact test never exceeds $0.05$ (maximum over tested $n_d$: $0.043$); the Wald test exceeds its nominal level at $n_d=5,8,60$ (up to $0.070$, $41\%$ above nominal). + +\subsection{Paired differences with several samples per problem} +Let $\Delta_x=\hat p^A_x-\hat p^B_x$. Then +\begin{equation}\label{eq:pair} +\Var\bar\Delta=\frac1N\bigl[\Var(\hat p^A)+\Var(\hat p^B)-2\Cov(\hat p^A,\hat p^B)\bigr]. +\end{equation} +Pairing removes the shared problem difficulty (the covariance term), which is large for two checkpoints of one model. Notebook (logit-shifted correlated systems, $N{=}244$, $m{=}16$): empirical $\Var\bar\Delta=9.98\times10^{-5}$ vs (\ref{eq:pair}) $9.94\times10^{-5}$ (0.4\%), while an unpaired analysis that ignores the covariance overstates the variance by $7.9\times$. A paired cluster bootstrap resamples problems and recomputes $\bar\Delta$ with A and B kept together. + +\subsection{Power for a $+5/244$ gain} +Let each problem contribute $X_i\in\{+1,0,-1\}$ (A-only, concordant, B-only) with probabilities $(p_b,1-d,p_c)$, $\delta=p_b-p_c$, $d=p_b+p_c$. Then $b-c=\sum_iX_i$ has mean $N\delta$ and variance $N(d-\delta^2)$ (since $\E X^2=d$). Under $H_0$ ($\delta=0$) the variance is $Nd$, so the normal-approximation test rejects when $|b-c|>z\sqrt{Nd}$, and the power is +\begin{equation}\label{eq:power} +1-\beta\approx\Phi\Bigl(\frac{\delta\sqrt N-z\sqrt d}{\sqrt{d-\delta^2}}\Bigr)\quad\Longrightarrow\quad +N\ \ge\ \frac{\bigl[z_{1-\alpha/2}\sqrt d+z_{1-\beta}\sqrt{d-\delta^2}\bigr]^2}{\delta^2}. +\end{equation} +The \emph{exact} power sums over the trinomial: $\sum_{n_d}\mathrm{Bin}(n_d;N,d)\sum_b\mathrm{Bin}(b;n_d,p_b/d)\,\ind\{p(b,n_d-b)<0.05\}$. Results for $\delta=5/244=0.0205$, $\alpha=0.05$: +\begin{center}\small +\begin{tabular}{@{}rrrrrr@{}} +\toprule +$d$ & power, exact & power, normal & $N_{80\%}$ exact & $N_{80\%}$ normal & MDE at $N{=}244$ \\ \midrule +$0.03$ & $0.31$ & $0.46$ & $584$ & $558$ & $7.3$ \\ +$0.06$ & $0.18$ & $0.26$ & $1184$ & $1119$ & $10.9$ \\ +$0.10$ & $0.12$ & $0.17$ & $1946$ & $1867$ & $14.3$ \\ +$0.15$ & $0.10$ & $0.13$ & $2887$ & $2801$ & $17.5$ \\ +$0.20$ & $0.09$ & $0.11$ & $3826$ & $3736$ & $20.1$ \\ \bottomrule +\end{tabular} +\end{center} +Power is at $N=244$; $N_{80\%}$ is the number of problems for $80\%$ power; MDE is the minimum detectable net gain (in problems) at $N=244$ and $80\%$ power. +(Monte Carlo check of the exact power at $d=0.10$, $4\times10^5$ simulated benchmarks: difference $2.8\times10^{-4}$.) A $+5/244$ gain is thus detected with probability at most $31\%$ even when the systems disagree on only $3\%$ of problems, and on $12\%$ for $10\%$ disagreement; detecting it at $80\%$ power would need roughly $2{,}000$ problems at $10\%$ discordance. The normal approximation overstates power (it ignores discreteness) and underestimates $N$ by $4$--$5\%$. The value of $d$ must be measured from the paired data. Note also that a single sample per problem inflates $d$: problems with $p_x\in(0,1)$ flip by chance in each system, so the discordance includes sampling noise; averaging $m$ samples removes the second term of (\ref{eq:var}) but not the first. + +\subsection{A reporting recipe} +(i) Single score: Wilson interval over problems (one verdict per problem) or cluster bootstrap with $m$ samples per problem; (ii) comparison: exact McNemar on per-problem pass@$k$ verdicts, plus a paired cluster-bootstrap interval for the pass@$k$ difference when $m>1$, reported together with $b$ and $c$; (iii) power or minimum detectable effect computed \emph{before} the run from a pilot estimate of $d$; (iv) for tables with many comparisons or many checkpoints (note 0205's $\alpha$ scan) a multiplicity correction, or a held-out set for selection. + +\section{Boxed result} +\begin{result} +\begin{align*} +\Var\hat\mu&=\tfrac1N\bigl[\Var p_x+\E p_x(1-p_x)/m\bigr],\qquad\text{design effect }1+(m-1)\rho,\\ +\text{Wilson: }p&=\frac{\hat p+z^2/2N\ \pm\ z\sqrt{\hat p(1-\hat p)/N+z^2/4N^2}}{1+z^2/N},\\ +p_{\mathrm{McNemar}}&=\min\Bigl\{1,\ 2\sum_{i\le\min(b,c)}\tbinom{n_d}i2^{-n_d}\Bigr\},\qquad +\Var\bar\Delta=\tfrac1N\bigl[\Var A+\Var B-2\Cov\bigr],\\ +N&\ge\frac{\bigl[z\sqrt d+z_{1-\beta}\sqrt{d-\delta^2}\bigr]^2}{\delta^2}. +\end{align*} +For $N=244$ no outcome with net gain $b-c=5$ has exact two-sided $p<0.05$ (best: $(5,0)$, $p=0.0625$). +\end{result} + +\section{What breaks} +\begin{breaks} +\begin{tabular}{@{}p{0.29\linewidth}p{0.29\linewidth}p{0.36\linewidth}@{}} +\toprule +Failure & Assumption violated & Consequence \\ \midrule +Treating $Nm$ samples as independent (naive bootstrap, pooled Wilson) & problem is the unit & CI coverage $0.585$--$0.602$ instead of $0.95$ in the notebook; SE too small by $\sqrt{1+(m-1)\rho}=2.4$ \\ +Related problems (same theorem family, near-duplicates, contaminated statements) & problems independent & Further clustering above the problem level; effective $N$ smaller than $244$ (Miller's clustered SE, Sec.~2.2) \cite{miller} \\ +Wald/bootstrap with few discordant pairs & normal approximation & Size up to $0.07$; $(5,0)$ declared significant when exact $p=0.0625$ \\ +Exact test read as a measure of effect & $p$ is about $H_0$ only & Exact McNemar is conservative (size $\le0.043$ tested); report $b$, $c$ and the interval, not only $p$ \\ +Checkpoint or $\alpha$ selected on the reported benchmark & selection independent of the evaluation & Optimistic by the maximum over candidates; use a held-out set (notes 0205, 0207) \\ +Benchmark validity itself & formal statements are the intended problems & miniF2F-Lean is reported to contain wrong formalisations and unprovable statements \cite{minif2fr}; ceiling $<244$ and scores partly measure statement quirks \\ +Skewed pass@$k$ near $1$ & symmetric sampling distribution & Percentile cluster bootstrap undercovers ($0.927$ at pass@8 with true value $0.962$) \\ +Single run per problem & no sampling noise in verdicts & Inflates $d$ and the variance; use $m>1$ and clustered inference \\ \bottomrule +\end{tabular} +\end{breaks} + +\nextnote{This closes batch M2. Reading the notes in sequence: 0201 defines the reward, 0202 spends it in SFT, 0203 measures and trades pass@$k$, 0204 extends to revisions, 0205 repairs diversity in weight space, 0206 chooses which problems to train on, 0207 prices inference, and this note says how much of any of it a 244-problem benchmark can support.} + +\begin{thebibliography}{9} +\bibitem{miller} E.~Miller. Adding Error Bars to Evals: A Statistical Approach to Language Model Evaluations. arXiv:2411.00640, Sec.~2.1 (CLT interval, Eq.~3), Sec.~2.2 (clusters of related questions, Eq.~4), Sec.~3 and 3.1 (variance decomposition; resampling and question-level means), Sec.~4.2 (paired analysis), Sec.~5 (power analysis). +\bibitem{minif2f} K.~Zheng, J.~M.~Han, S.~Polu. miniF2F: a cross-system benchmark for formal Olympiad-level mathematics. arXiv:2109.00110 (488 statements: 244 validation, 244 test in v1). +\bibitem{minif2fr} A.~Ospanov, F.~Farnia, R.~Yousefzadeh. miniF2F-Lean Revisited: Reviewing Limitations and Charting a Path Forward. arXiv:2511.03108, Introduction (wrong formalisations, unprovable theorems, formal--informal mismatches). +\bibitem{codex} M.~Chen et al. Evaluating Large Language Models Trained on Code. arXiv:2107.03374, Sec.~2.1 (unbiased pass@$k$ estimator, used per problem here). +\bibitem{gps} Y.~Lin et al. Goedel-Prover-V2. arXiv:2508.03613, Sec.~3 (miniF2F evaluation at pass@$N$). +\end{thebibliography} +\end{document} diff --git a/code/notes/src/0209-variance-components-equivalence.tex b/code/notes/src/0209-variance-components-equivalence.tex new file mode 100644 index 0000000000000000000000000000000000000000..3e307560c201c47ae6c8e0bc2a068d07b3e364c8 --- /dev/null +++ b/code/notes/src/0209-variance-components-equivalence.tex @@ -0,0 +1,126 @@ +\documentclass[11pt]{article} +\usepackage{common} +\begin{document} +\notehead{0209}{Variance components and equivalence tests for evaluation}{Batch M2, extension of 0208. Companion notebook: \texttt{notebooks/0209-variance-components-equivalence.ipynb} (rational arithmetic, Gauss--Hermite integration, Monte Carlo with stated tolerances; executed, 33 checks).} + +\begin{problem} +\question{When two training recipes score within a point of each other on a benchmark, which source of randomness explains the gap, can any amount of extra evaluation remove it, and what would it take to show the recipes are \emph{equivalent} rather than merely not shown to differ?} +Note 0208 treated problems and generated samples as the two random sources and gave exact paired tests for one pair of systems. A real comparison has a third source, \emph{which training run} produced the model, and a fourth hazard, \emph{which checkpoint} was reported. This note separates the sources by their variance components, shows which one survives more problems and more samples, derives the unbiased pass@$k$ estimator's exact variance (0203 gave the estimator), states the paired sign-flip test and its actual null, shows that re-sampling generations on the \emph{same} problems removes only part of a selection bias, and replaces ``no significant difference'' by a two one-sided test (TOST) with its minimum detectable effect (MDE). The answer: the training-seed term is a floor that neither $N$ nor $m$ lowers, regenerating on the same problems keeps the seed$\times$problem share of the selection bias, and equivalence needs a pre-set margin and roughly as many problems as detection does. +\end{problem} +\stepcounter{section}% the problem box counts as section 1: keeps every \S and notebook cross-reference stable + +\section{Notation} +\begin{center} +\begin{tabular}{@{}p{0.36\linewidth}p{0.60\linewidth}@{}} +\toprule +Symbol & Meaning \\ \midrule +$N=244,\ S,\ m=16$ & problems, independently trained models (seeds) of one recipe, samples per (seed, problem) \\ +$p_{sx}$ & solve probability of model $s$ on problem $x$; $c_{sx}\sim\mathrm{Bin}(m,p_{sx})$ \\ +$\sigma_A^2,\sigma_B^2,\sigma_C^2$ & variance components of $p_{sx}=\mu+A_x+B_s+C_{sx}$: problems, seeds, seed$\times$problem \\ +$\sigma_e^2=\E\,p(1-p)$ & generation (Bernoulli) noise per sample \\ +$Y_s=\frac1{Nm}\sum_xc_{sx}$ & score of run $s$ \\ +$e(n,c,k)=1-\binom{n-c}k/\binom nk$ & unbiased pass@$k$ from $c$ successes in $n$ samples (0203) \\ +$d_x,\ T=\sum_xd_x$ & per-problem paired difference and its sum \\ +$\Delta,\ \alpha$ & equivalence margin, level of each one-sided test \\ +$\mathrm{MDE}$ & smallest true difference detected with $80\%$ power at two-sided $5\%$ \\ \bottomrule +\end{tabular} +\end{center} + +\section{Derivation} +\subsection{Four sources of variance and a floor} +The array $(p_{sx})$ is exchangeable in $s$ and in $x$ (seeds are i.i.d., problems are i.i.d.), so it has an orthogonal ANOVA decomposition $p_{sx}=\mu+A_x+B_s+C_{sx}$ with $\Var A=\sigma_A^2$, $\Var B=\sigma_B^2$, $\Var C=\sigma_C^2$. The law of total variance applied to $c_{sx}\mid p_{sx}$ gives, for the score of \emph{one} run, +\begin{equation}\label{eq:var1} +\Var Y_s=\frac{\sigma_A^2}N+\sigma_B^2+\frac{\sigma_C^2}N+\frac{\sigma_e^2}{Nm}, +\end{equation} +and for the mean of $S$ seeds the second and third terms are divided by $S$ and the last by $NSm$. The problem term is the benchmark-sampling variance of 0208; the last is generation noise (it falls with $m$); the seed term $\sigma_B^2$ has no $N$ or $m$ in the denominator. \emph{Training-seed variance is the only term that no benchmark size and no number of samples reduces}; only more independently trained models reduce it, as $1/S$. + +\textbf{Estimation} (balanced crossed design, cell means $\bar y_{sx}=c_{sx}/m$): with $\mathrm{MS}_x,\mathrm{MS}_s,\mathrm{MS}_r$ the usual mean squares for problems, seeds and residual, and $\hat\sigma_e^2$ the mean of the unbiased within-cell estimate $c(m-c)/(m(m-1))$ of $p(1-p)$, +\[ +\hat\sigma_C^2=\mathrm{MS}_r-\hat\sigma_e^2/m,\qquad\hat\sigma_A^2=(\mathrm{MS}_x-\mathrm{MS}_r)/S,\qquad\hat\sigma_B^2=(\mathrm{MS}_s-\mathrm{MS}_r)/N . +\] +Each is unbiased because $\E\,\mathrm{MS}_r=\sigma_C^2+\sigma_e^2/m$, $\E\,\mathrm{MS}_x=\sigma_C^2+\sigma_e^2/m+S\sigma_A^2$, $\E\,\mathrm{MS}_s=\sigma_C^2+\sigma_e^2/m+N\sigma_B^2$. With only $S$ small the estimate of $\sigma_B^2$ is noisy (it rests on $S-1$ degrees of freedom): the honest way to know the seed term is to train several seeds. + +\textbf{Toy.} Logit-normal model $p_{sx}=\mathrm{expit}(\mu_x+a_s+b_{sx})$ with $\mu_x\sim\mathcal N(0.3,1.5^2)$, $a_s\sim\mathcal N(0,0.25^2)$, $b_{sx}\sim\mathcal N(0,0.5^2)$ (seed sizes chosen for illustration, not measured on any model). Gauss--Hermite integration gives $\E p=0.551$, $\sigma_A^2=0.0674$, $\sigma_B^2=1.80\times10^{-3}$ (sd $0.042$), $\sigma_C^2=8.5\times10^{-3}$, $\sigma_e^2=0.170$. At $N=244,m=16,S=1$ the four terms of (\ref{eq:var1}) are $2.76\times10^{-4}$ (problems), $1.80\times10^{-3}$ (seed), $3.5\times10^{-5}$ (seed$\times$problem), $4.3\times10^{-5}$ (generation): the seed term is $83\%$ of the total. +\ver{the empirical variance of $Y_s$ over $6000$ replications is $2.219\times10^{-3}$ against $2.155\times10^{-3}$ from (\ref{eq:var1}) ($3.0\%$, tolerance $4$ s.e.$=7.3\%$); the four ANOVA estimators averaged over $4000$ replications at $S=4$ are within $1.6$ s.e.\ of the Gauss--Hermite truth ($\hat\sigma_e^2$: $z=+0.95$, $\hat\sigma_C^2$: $-0.15$, $\hat\sigma_A^2$: $-0.66$, $\hat\sigma_B^2$: $-1.57$).} + +\subsection{Comparing two recipes: the floor on the MDE} +Take two recipes with $S$ seeds each, evaluated on the same problems, and $\hat\delta=\bar Y_A-\bar Y_B$. Under $H_0$ (same recipe) the shared problem effect $A_x$ cancels, so +\begin{equation}\label{eq:vardiff} +\Var\hat\delta=\frac2S\Bigl[\sigma_B^2+\frac{\sigma_C^2}N+\frac{\sigma_e^2}{Nm}\Bigr],\qquad \mathrm{MDE}=(z_{0.975}+z_{0.8})\sqrt{\Var\hat\delta}\ \xrightarrow{N,m\to\infty}\ 2.80\sqrt{2\sigma_B^2/S}. +\end{equation} +Pairing on problems removes $\sigma_A^2$; nothing on the benchmark side removes $\sigma_B^2$. In the toy the MDE at $N=244$ is $17.2$ points for $S=1$, $9.9$ for $S=3$, $7.7$ for $S=5$, against floors of $16.8$, $9.7$, $7.5$ points: with these (illustrative) seed sizes enlarging the benchmark is pointless and training another seed is not. The seed standard deviation is the number that decides this, and it has to be measured for the recipe at hand. \ver{$\Var\hat\delta$ over $6000$ replications: $3.803\times10^{-3}$ vs $3.757\times10^{-3}$ at $S=1$, $1.237\times10^{-3}$ vs $1.252\times10^{-3}$ at $S=3$ (both within $1.3\%$).} + +\subsection{Unbiased pass@$k$: exact moments} +With $c\sim\mathrm{Bin}(n,p)$ the estimator $e(n,c,k)=1-\binom{n-c}k\big/\binom nk$ has $\E\,e=1-(1-p)^k$ (0203), so it is unbiased for $\mathrm{pass@}k=1-(1-p)^k$. The \emph{naive} plug-in $1-(1-c/n)^k$ is not: by Jensen it is biased low (exact bias $-0.076$ at $n=64,k=32,p=0.02$; $-0.021$ at $k=8,p=0.1$; $-0.147$ at $n=k=8$, $p=0.1$ is the extreme). Its variance has no tidy closed form, but it is a finite sum, $\Var e=\sum_c\mathrm{Bin}(c;n,p)\,e(n,c,k)^2-[1-(1-p)^k]^2$, with two checkable limits: $k=1$ gives $p(1-p)/n$ and $k=n$ gives $e=\ind\{c>0\}$, so $\Var=q(1-q)$ with $q=1-(1-p)^n$. Relative spread at $n=64$: +\begin{center}\small +\begin{tabular}{@{}rrrr@{}} +\toprule +$p$ & $k=1$ & $k=8$ & $k=32$ \\ \midrule +$0.02$ & $0.875$ & $0.818$ & $0.681$ \\ +$0.10$ & $0.375$ & $0.257$ & $0.076$ \\ +$0.50$ & $0.125$ & $0.005$ & $0$ \\ \bottomrule +\end{tabular} +\end{center} +(entries are $\mathrm{sd}(e)/\mathrm{pass@}k$ from the exact sum). Pass@$k$ for hard problems (small $p$) is a high-variance quantity even with $64$ samples. Over the benchmark, with $p_x$ drawn from a population, $\Var\bar e=\frac1N[\Var_x(1-(1-p_x)^k)+\E_x\Var(e\mid p_x)]$, the same two terms as in (\ref{eq:var1}) for a single model. \ver{exact rational arithmetic: $\E e=1-(1-p)^k$ for six $(n,k,p)$, $k=1$ and $k=n$ variances exact; exact $\Var e$ vs $2\times10^6$ draws at $(64,8,0.1)$ within $0.1\%$; benchmark variance of pass@$8$ with $p_x\sim\mathrm{Beta}(1.4,0.7)$, $N=244$, $n=64$: $6.46\times10^{-5}$ empirical vs $6.32\times10^{-5}$ ($2.3\%$).} + +\subsection{The paired sign-flip test} +For per-problem differences $d_x=e^A_x-e^B_x$ let $T=\sum_xd_x$. If each $d_x$ is independent and symmetric about $0$, then multiplying any subset by $-1$ does not change the joint law, so the $2^N$ sign vectors are equally likely and +\[ +p=2^{-N}\,\#\{\epsilon\in\{\pm1\}^N:\ |\textstyle\sum\epsilon_xd_x|\ge|T|\}, +\] +exact for $N\le18$ by enumeration and by Monte Carlo (with the $+1$ correction in numerator and denominator) beyond. For $d_x\in\{-1,0,1\}$ the zeros contribute nothing and $p$ equals the exact McNemar $p$ of 0208; the best $(5,0)$ outcome again gives $0.0625$. Two cautions. The null is \emph{symmetry}, not mean zero: for the skewed mean-zero variable $d=+9$ w.p.\ $0.1$, $-1$ otherwise, at $N=12$ the size is $0.294$ against a nominal $0.05$. And the test is paired at the problem level, so it uses the problem as the unit but ignores the seed term of (\ref{eq:vardiff}): it answers ``on these problems, for these trained models''. \ver{sign-flip $=$ McNemar for all $b,c\le7$ (max difference $0$); Monte Carlo ($B=20000$) vs exact at $N=14$ within $7\times10^{-4}$; size under the null with shared $p_x$, $N=60$, pass@$8$ from $64$ samples: $0.058$ over $1500$ benchmarks (s.e.\ $0.006$).} + +\subsection{``Fresh seed on the same problems'' removes sampling noise, not selection bias} +Select the best of $K$ candidate checkpoints on a benchmark and report its score. Suppose all have identical true population quality $\theta$. On the fixed benchmark candidate $k$ has a quenched advantage $\beta_k\sim\mathcal N(0,\sigma_\beta^2)$ with $\sigma_\beta^2=\sigma_C^2/N$ (the seed$\times$problem component: which problems this checkpoint happens to get right) plus generation error $g_k\sim\mathcal N(0,\sigma_g^2)$, $\sigma_g^2=\sigma_e^2/(Nm)$. Select $k^\star=\arg\max_k(\beta_k+g_k)$ and write $\sigma_t^2=\sigma_\beta^2+\sigma_g^2$, $c_K=\E\max_{j\le K}Z_j$. Then +\begin{align*} +&\text{(i) same sample: }\ \E(\beta+g)_{k^\star}-\theta=\sigma_t\,c_K;\\ +&\text{(ii) fresh generations, same problems: }\ \E(\beta+g')_{k^\star}-\theta=\E\beta_{k^\star}=\frac{\sigma_\beta^2}{\sigma_t}c_K\qquad(\text{since }\E[\beta\mid\beta+g=y]=\tfrac{\sigma_\beta^2}{\sigma_t^2}y);\\ +&\text{(iii) fresh problems: }\ 0 . +\end{align*} +Re-sampling \emph{retains the fraction} $\sigma_\beta^2/\sigma_t^2$ of the bias. In the toy ($K=8$, $c_8=1.4236$, $\sigma_\beta^2=3.5\times10^{-5}$, $\sigma_g^2=4.3\times10^{-5}$) the selected score is inflated by $1.26$ points in (i), by $0.56$ points in (ii) ($44.5\%$ retained), and by $0$ in (iii). The retained share is exactly the seed$\times$problem variance of the \emph{benchmark mean}: it falls like $1/N$ only through $\sigma_C^2/N$ and is irreducible by regeneration. (``Fresh seed'' here means a new generation seed for the same checkpoint; retraining draws a new $a_s,b_{sx}$ and is a fresh-candidate evaluation, which does remove it.) \ver{$4\times10^5$ Monte Carlo selections: $1.262$ vs $1.260$ points in (i), $0.561$ vs $0.561$ in (ii), $0.000$ in (iii), each within $4$ s.e.; $c_8$ by quadrature agrees with Monte Carlo to $7.6\times10^{-4}$.} + +\subsection{Equivalence by TOST, and the MDE} +``No significant difference'' is the absence of rejection of $H_0:\delta=0$; it does not support $\delta\approx0$. To claim equivalence within a pre-registered margin $\Delta$, test $H_0:|\delta|\ge\Delta$ against $|\delta|<\Delta$ by two one-sided tests, $p_U=\Phi((\hat\delta-\Delta)/s)$ for $\delta\ge\Delta$ and $p_L=1-\Phi((\hat\delta+\Delta)/s)$ for $\delta\le-\Delta$, and reject when $\max(p_L,p_U)<\alpha$ \cite{schuirmann,lakens}. Facts used: (a) duality, TOST at level $\alpha$ rejects iff the $(1-2\alpha)$ confidence interval lies inside $(-\Delta,\Delta)$; (b) the size is $\alpha$, attained at $\delta=\pm\Delta$ (there $\hat\delta<\Delta-z_{1-\alpha}s$ has probability $\alpha$, and the other tail is negligible); (c) the power at $\delta=0$ is $2\Phi(\Delta/s-z_{1-\alpha})-1$, so $80\%$ power needs $\Delta/s=z_{0.95}+z_{0.90}=2.927$, i.e. +\[ +N\ \ge\ \Bigl(\frac{2.927\,\sigma_d}{\Delta}\Bigr)^2\quad(\text{paired per-problem standard deviation }\sigma_d),\qquad \mathrm{MDE}=(z_{0.975}+z_{0.80})\frac{\sigma_d}{\sqrt N}. +\] +With $10\%$ discordance ($\sigma_d^2=0.1$) at $N=244$: $s=0.0202$, MDE $=13.8$ problems ($5.7$ points; 0208's exact trinomial value is $14.3$). To \emph{demonstrate} equivalence within $\Delta=2$ points needs $N\approx2141$ problems; to detect a $5/244$ gain at $80\%$ power needs $1867$ (0208, normal approximation). If the true effect is $3$ points (outside the margin), the difference test misses it $68\%$ of the time while TOST ``accepts'' equivalence in $0.00\%$ of cases. Ordinary benchmarks of a few hundred problems can therefore neither show equivalence within a point or two nor a small gain, and the sensible output is the interval, with the seed term of (\ref{eq:vardiff}) added when recipes (not checkpoints) are compared. \ver{$100000$ random cases: TOST $p<0.05$ iff the $90\%$ CI is inside $(-\Delta,\Delta)$ in all; rejection probability at $\delta=\pm0.02$ is $0.0495$ and $0.0499$ over $4\times10^5$ draws; power at $\delta=0$ $0.6083$ vs $0.6075$; MDE gives power $0.7994$; $N_{80\%}=1866.8$ reproduces 0208's $1867$; $N=2141$ gives $80\%$ TOST power to $2\times10^{-16}$.} + +\section{Boxed result} +\begin{result} +\begin{align*} +\Var Y_s&=\tfrac{\sigma_A^2}N+\sigma_B^2+\tfrac{\sigma_C^2}N+\tfrac{\sigma_e^2}{Nm},\qquad +\Var\hat\delta=\tfrac2S\bigl[\sigma_B^2+\tfrac{\sigma_C^2}N+\tfrac{\sigma_e^2}{Nm}\bigr],\\ +\E\,e(n,c,k)&=1-(1-p)^k,\qquad \Var e=\textstyle\sum_c\mathrm{Bin}(c;n,p)\,e^2-[1-(1-p)^k]^2,\\ +\text{bias after regenerating}&=\tfrac{\sigma_\beta^2}{\sigma_\beta^2+\sigma_g^2}\times\text{bias before},\qquad +p_{\mathrm{TOST}}=\max\{p_L,p_U\},\quad N\ge\Bigl(\tfrac{2.927\,\sigma_d}{\Delta}\Bigr)^2 . +\end{align*} +Toy: seed term $=83\%$ of $\Var Y_s$; MDE floor $9.7$ points at $S=3$; $N\approx2141$ problems to show equivalence within $2$ points at $10\%$ discordance. +\end{result} + +\section{What breaks} +\begin{breaks} +\begin{tabular}{@{}p{0.27\linewidth}p{0.30\linewidth}p{0.37\linewidth}@{}} +\toprule +Failure & Assumption violated & Consequence \\ \midrule +One trained model per recipe, error bars from problems only & seed variance is negligible & the dominant term (here $83\%$) is missing; intervals far too narrow \\ +Seed variance read off $S=2$ or $3$ runs & $\hat\sigma_B^2$ well estimated & $S-1$ degrees of freedom; the floor itself is uncertain \\ +Plug-in $1-(1-c/n)^k$ & unbiased estimator & biased low, worst for large $k/n$ and small $p$ \\ +Pass@$k$ for hard problems with few samples & variance small & relative sd $0.7$--$0.9$ at $p=0.02$ even with $n=64$ \\ +Sign-flip test on skewed differences & symmetry, not mean zero & size $0.294$ at $N=12$ \\ +Re-sample generations to ``confirm'' a selected checkpoint & selection independent of the problems & $44.5\%$ of the bias remains; only fresh problems or fresh candidates remove it \\ +``$p>0.05$, so equivalent'' & absence of evidence is evidence & TOST is the only test of equivalence; margin must be fixed before the run \\ +Margin chosen after seeing the data & $\Delta$ pre-registered & size of TOST is no longer $\alpha$ \\ +Normal approximation at small discordance & many discordant pairs & use the exact tests of 0208; MDE from the normal formula is $3\%$ low at $d=0.10$ \\ \bottomrule +\end{tabular} +\end{breaks} + +\nextnote{0210 turns to the \emph{inputs} of training: which web pages, problems and samples enter the corpus, how a selection rule that uses the same samples it is later judged on produces the same kind of bias as the selection above, and how contamination is detected.} + +\begin{thebibliography}{9} +\bibitem{miller} E.~Miller. Adding Error Bars to Evals: A Statistical Approach to Language Model Evaluations. arXiv:2411.00640, Secs.~3--5 (variance decomposition, paired analysis, power). +\bibitem{codex} M.~Chen et al. Evaluating Large Language Models Trained on Code. arXiv:2107.03374, Sec.~2.1 (unbiased pass@$k$ estimator). +\bibitem{schuirmann} D.~J.~Schuirmann. A comparison of the two one-sided tests procedure and the power approach for assessing the equivalence of average bioavailability. J.\ Pharmacokinetics and Biopharmaceutics 15:657--680, 1987. \emph{Not on arXiv; not fetched; classical, cited for the TOST procedure.} +\bibitem{lakens} D.~Lakens. Equivalence tests: a practical primer for $t$ tests, correlations, and meta-analyses. Social Psychological and Personality Science 8(4):355--362, 2017. \emph{Not on arXiv; not fetched; cited for the duality with the $(1-2\alpha)$ interval and for margin pre-registration.} +\end{thebibliography} +\end{document} diff --git a/code/notes/src/0210-data-selection-contamination.tex b/code/notes/src/0210-data-selection-contamination.tex new file mode 100644 index 0000000000000000000000000000000000000000..0a23795dae73bd94425ccbeb05d7d44111702792 --- /dev/null +++ b/code/notes/src/0210-data-selection-contamination.tex @@ -0,0 +1,140 @@ +\documentclass[11pt]{article} +\usepackage{common} +\begin{document} +\notehead{0210}{Data selection and contamination}{Batch M2, after 0206 and 0209. Companion notebook: \texttt{notebooks/0210-data-selection-contamination.ipynb} (closed forms against Monte Carlo and exact enumeration, a character $n$-gram language model with known membership; executed, 37 checks).} + +\begin{problem} +\question{When data is chosen by a score computed from the same samples that are later used to judge it, which of its apparent properties are real, and how can we tell that an evaluation item was seen in training?} +Training data is selected twice: once at corpus scale (DeepSeekMath's fastText classifier recalls math pages from Common Crawl \cite{dsmath}) and once at problem scale (RL keeps problems whose pass rate lies in a band, 0206). Evaluation then asks two questions about the result: did the selected data help, and is the benchmark clean? Each question has a statistical trap. A classifier with a high AUC can still harvest mostly junk when the target is rare; a band computed from the same $G$ samples as the gradient reweights problems by the statistic that defines their gradient; ``unsolved problems improved'' can be pure regression to the mean; MinHash estimates a similarity, not containment, with an error that is a simple binomial; and negative log-likelihood (NLL) gaps detect membership only after intrinsic difficulty is cancelled. The answers below make each trap a formula and check it. +\end{problem} +\stepcounter{section}% the problem box counts as section 1: keeps every \S and notebook cross-reference stable + +\section{Notation} +\begin{center} +\begin{tabular}{@{}p{0.36\linewidth}p{0.60\linewidth}@{}} +\toprule +Symbol & Meaning \\ \midrule +$\pi,\ M$ & fraction of relevant pages in the pool, pool size \\ +$d',\ t$ & binormal separation of the classifier score, selection threshold \\ +$\mathrm{TPR},\mathrm{FPR}$ & $\bar\Phi(t-d')$, $\bar\Phi(t)$ with $\bar\Phi=1-\Phi$ \\ +$g,\ h$ & downstream gain of a selected relevant page, harm of a selected irrelevant one (to be measured) \\ +$n,\ c,\ \hat p=c/n$ & samples per problem, successes, observed pass rate; $\hat g=\hat p(1-\hat p)$ (0206) \\ +$J(A,B)=|A\cap B|/|A\cup B|$ & Jaccard similarity of shingle sets; $k$ = number of MinHash functions; $\hat J$ its estimate \\ +$b,\,r$ & LSH bands and rows per band ($k=br$) \\ +$\Delta,\ \sigma$ & NLL gap between non-members and members, within-group NLL standard deviation \\ \bottomrule +\end{tabular} +\end{center} + +\section{Derivation} +\subsection{Classifier harvesting is a detection problem with a prior} +DeepSeekMath trains a fastText classifier on $500{,}000$ OpenWebMath pages (positives) against $500{,}000$ Common Crawl pages (negatives), ranks the deduplicated crawl by classifier score and keeps the top pages; in the first iteration the volume kept was chosen by pre-training experiments on the top $40$B, $80$B, $120$B and $160$B tokens, and the first iteration kept $40$B. Four iterations later the corpus is $35.5$M pages and $120$B tokens, and in the fourth iteration $98\%$ of the data had already been collected in the third \cite{dsmath}. In the abstract, with a pool in which a fraction $\pi$ is relevant, a classifier score $s\sim\mathcal N(d',1)$ for relevant and $\mathcal N(0,1)$ for other pages, and a threshold $t$, +\[ +\mathrm{recall}=\bar\Phi(t-d'),\qquad +\mathrm{precision}=\frac{\pi\,\mathrm{TPR}}{\pi\,\mathrm{TPR}+(1-\pi)\,\mathrm{FPR}} . +\] +Precision depends on the prior $\pi$ and not only on the AUC $=\Phi(d'/\sqrt2)$. With $d'=2.5$ (AUC $0.962$) and $t=2$, recall is $0.69$, and precision is $0.235$ when $\pi=0.01$ but $0.884$ when $\pi=0.2$: the same classifier and threshold. The number that matters for web-scale harvesting ($\pi$ small) is therefore the precision at the chosen operating point, which is estimated by labelling a sample of the \emph{selected} pages; the training-set AUC says little. \ver{closed forms against $4\times10^6$ simulated pages: precision $0.2349$ (within $1.5\times10^{-4}$), recall $0.6915$ (within $2.7\times10^{-3}$).} + +\textbf{Which threshold?} Let a selected relevant page add $g$ and an irrelevant one subtract $h$ (dilution of the token budget). The expected net gain is $G(t)=M[\pi\,\mathrm{TPR}(t)\,g-(1-\pi)\,\mathrm{FPR}(t)\,h]$; $G'(t)=0$ where the marginal page breaks even, i.e.\ where the posterior $\Pr[\text{relevant}\mid s=t]=h/(g+h)$. For the binormal model this is +\[ +t^\star=\frac{d'}2+\frac1{d'}\ln\frac{(1-\pi)\,h}{\pi\,g}. +\] +It is a posterior cutoff: it moves with the prior and the harm ratio, not with the F1 score. With $\pi=0.01$, $g=h=1$: $t^\star=3.088$ against the max-F1 threshold $2.626$, and the gain per pool page at the F1 threshold is $2.2\times10^{-4}$ against $1.8\times10^{-3}$ at $t^\star$ (eight times lower). $g$ and $h$ are \emph{measured} by training, which is why DeepSeekMath chose the volume by pre-training experiments rather than by a classifier statistic. \ver{argmax of $G$ on a grid of $6\times10^4$ thresholds agrees with $t^\star$ to $4.8\times10^{-5}$; the posterior at $t^\star$ equals $h/(g+h)$ to $10^{-16}$.} + +\textbf{Why iterate.} Positives in a cluster that the seed does not cover score like negatives. If a fraction $\kappa_r$ of relevant pages lies in clusters covered by the seed at round $r$ (score separation $d_{\mathrm{in}}$, the others $d_{\mathrm{out}}$) then $\mathrm{recall}_r=\kappa_r\bar\Phi(t-d_{\mathrm{in}})+(1-\kappa_r)\bar\Phi(t-d_{\mathrm{out}})$, and adding a fraction $\gamma$ of the uncovered clusters per round gives $\kappa_{r+1}=\kappa_r+\gamma(1-\kappa_r)$: recall increments shrink with ratio exactly $1-\gamma$, which is the diminishing yield seen in the fourth round. DeepSeekMath's re-seeding step is the domain rule: a domain with more than $10\%$ of its pages already collected is treated as mathematical and its mathematical URLs are annotated by hand \cite{dsmath}. \ver{toy with $d_{\mathrm{in}}=3,d_{\mathrm{out}}=0.5,\gamma=0.5$: recall $0.299,0.570,0.706,0.774,0.808$; increment ratios $0.5,0.5,0.5$.} + +\subsection{Pass-rate band selection is importance weighting} +For a problem with true pass rate $p$ and $n$ samples, the mean-baseline gradient magnitude is $\hat g=\hat p(1-\hat p)$ with $\E\hat g=(1-\frac1n)p(1-p)$ (0206). Keeping the problem iff $\hat p\in[\mathrm{lo},\mathrm{hi}]$ multiplies its gradient by an indicator, so the effective weight of a problem is a function of its \emph{true} $p$: +\[ +w_{\mathrm{same}}(p)=\frac{\E[\hat g\,\ind\{\hat p\in\mathrm{band}\}]}{p(1-p)},\qquad +w_{\mathrm{ind}}(p)=\Bigl(1-\frac1n\Bigr)\Pr[\hat p'\in\mathrm{band}], +\] +where $w_{\mathrm{ind}}$ is the weight if the band is decided from an independent pilot estimate $\hat p'$ (the indicator is then independent of $\hat g$). Both are exact finite sums over $c\sim\mathrm{Bin}(n,p)$. The difference is $\Cov(\hat g,\ind)/(p(1-p))$: selecting on a statistic correlated with the gradient's size reweights \emph{upwards}. For $n=8$ and the band $\hat p\in[1/8,6/8]$: +\begin{center}\small +\begin{tabular}{@{}rrrr@{}} +\toprule +$p$ & $w_{\mathrm{same}}$ & $w_{\mathrm{ind}}$ & ratio \\ \midrule +$0.02$ & $0.875$ & $0.131$ & $6.7$ \\ +$0.10$ & $0.875$ & $0.498$ & $1.76$ \\ +$0.50$ & $0.861$ & $0.841$ & $1.02$ \\ +$0.90$ & $0.410$ & $0.164$ & $2.5$ \\ +$0.98$ & $0.100$ & $0.009$ & $11$ \\ \bottomrule +\end{tabular} +\end{center} +A problem with $p=0.02$ is kept only when it happens to produce one success in eight samples, and then contributes the full gradient of an $\hat p=1/8$ problem, so its effective weight is nearly that of a mid-band problem. The band is therefore soft in the true pass rate; with the pool $p\sim\mathrm{Beta}(0.6,1.4)$ the fraction kept is $0.623$ (beta-binomial exact $0.623$), and $20.9\%$ of the kept problems have true $p$ outside the nominal band $[0.125,0.75]$. Choosing the band from a separate pilot estimate removes the covariance term but not the noise in $\hat p'$. \ver{exact sums at seven values of $p$; $w_{\mathrm{same}}(0.1)$ against $2\times10^6$ draws within $0.1\%$; beta-binomial band probability within $2.2\times10^{-4}$ of Monte Carlo.} + +\subsection{Regression to the mean when the same samples select and evaluate} +Let $p\sim\mathrm{Beta}(a,b)$ and $c\mid p\sim\mathrm{Bin}(n,p)$. By conjugacy $p\mid c\sim\mathrm{Beta}(a+c,\,b+n-c)$, so +\[ +\E[p\mid c]=\frac{a+c}{a+b+n}. +\] +Select the problems with $c=0$ (``unsolved'') and re-sample them with \emph{no training in between}: the pass rate is $a/(a+b+n)>0$, an apparent gain from nothing. With $(a,b,n)=(0.6,1.4,8)$: $c=0\to0.060$, $c=1\to0.160$ (observed $0.125$), $c=2\to0.260$ (observed $0.250$). The effect is large for the selected extreme and fades toward the pool mean $a/(a+b)=0.30$. If a training effect $+\tau(1-p)$ is applied to a random half of the selected $c=0$ problems and not to the other half, the naive before/after change on the trained half is $+0.088$, the untrained control moves by $+0.060$ with no intervention, and the difference-in-differences recovers the true $+0.028$. Hence: (i) select on one sample set and evaluate on a fresh one; (ii) report a held-out, untrained control selected by the same rule; (iii) never read a before/after change on a subset chosen by the ``before'' score as a training effect. \ver{$3\times10^6$ simulated problems: fresh pass rate given $c=0,1,2$ equals $(a+c)/(a+b+n)$ within $9\times10^{-5},3.5\times10^{-4},1.4\times10^{-4}$; DiD $+0.0282$ against true $+0.0282$.} + +\subsection{MinHash and the Jaccard estimation error} +Let $h_1,\dots,h_k$ be independent uniformly random permutations of the shingle universe. For each, $\Pr[\min h_i(A)=\min h_i(B)]=J(A,B)$: the minimum over $A\cup B$ is attained at a uniformly random element of the union, and the two minima agree iff that element lies in $A\cap B$. Hence $k\hat J\sim\mathrm{Bin}(k,J)$, +\[ +\E\hat J=J,\qquad\Var\hat J=\frac{J(1-J)}k, +\] +and for a $\pm\epsilon$ interval at $95\%$ the worst case $J=\tfrac12$ needs $k\ge z^2/(4\epsilon^2)$: $385$ for $\epsilon=0.05$, $2401$ for $0.02$, $9604$ for $0.01$. At the common $k=128$ the standard deviation is $0.035$ at $J=0.8$, so a pair with $J=0.75$ is flagged by the rule $\hat J\ge0.8$ with probability $0.090$ and a pair with $J=0.85$ is missed with probability $0.064$; the threshold is a soft boundary, and the notebook shows the exact binomial flag probability. \ver{$3000$ replications of $k=128$ random permutations: $\E\hat J$ within $4.4\times10^{-4}$ of $J=0.4286$, $\Var\hat J$ within $3.1\%$ of $J(1-J)/k$; exact flag probability at $J=0.75$ against $2\times10^6$ draws within $1.5\times10^{-4}$.} A practical linear hash $(ax+b)\bmod p$ is pairwise independent but not min-wise independent; its measured bias here is $+0.0037$ (MC s.e.\ $0.0008$), small and not zero. + +\textbf{LSH banding.} Splitting $k=br$ hashes into $b$ bands of $r$ and calling two documents candidates if any band agrees in all $r$ rows gives $\Pr[\text{candidate}]=1-(1-J^r)^b$, an S-curve with knee near $(1/b)^{1/r}$; for $b=20,r=5$ it is $0.048,0.470,0.802,0.9996$ at $J=0.3,0.5,0.6,0.8$ and the knee is $0.549$. \ver{each value within Monte Carlo error of $4\times10^5$ simulated band draws.} + +\textbf{Jaccard is not containment.} A benchmark item of $40$ shingles inside a web page of $2000$ shingles has $J=0.02$ although the item is \emph{entirely} contained, so a threshold of $0.8$ never flags it; the exact $n$-gram test used by DeepSeekMath (any text segment containing a $10$-gram that exactly matches a benchmark substring is removed, with exact matching for benchmark texts shorter than $10$ but at least $3$ grams \cite{dsmath}) is a containment test. Containment follows from Jaccard and the sizes, $|A\cap B|=J(|A|+|B|)/(1+J)$, but the estimator amplifies noise when $J$ is tiny: at $k=128$ the containment estimate for this example has mean $0.997$ and standard deviation $0.60$. Use MinHash for near-duplicate documents and an exact (or containment-oriented) match for ``does this page contain the problem''. \ver{identity exact to $10^{-9}$; $10^5$ draws of $\hat J$ at $k=128$; on real shingles ($5$-word shingles of a $600$-word document with $6\%$ word edits) exact $J=0.5872$ and MinHash($k=256$) mean $0.5844$.} + +\subsection{Membership inference from NLL gaps} +Suppose member NLL is $\mathcal N(\mu-\Delta,\sigma^2)$ and non-member NLL is $\mathcal N(\mu,\sigma^2)$. The threshold test has +\[ +\mathrm{AUC}=\Phi\Bigl(\frac\Delta{\sigma\sqrt2}\Bigr),\qquad\mathrm{TPR}@\mathrm{FPR}{=}f=\Phi\Bigl(\frac\Delta\sigma-z_{1-f}\Bigr). +\] +With $\Delta=0.30,\sigma=0.25$ nats: AUC $0.802$, TPR $0.130$ at $1\%$ FPR. Real examples differ in intrinsic difficulty $D_i\sim\mathcal N(0,s_D^2)$ that adds to \emph{both} groups; the raw-NLL AUC becomes $\Phi(\Delta/\sqrt{2(s_D^2+\sigma^2)})$, which is $0.600$ for $s_D=0.8$. A reference model whose NLL shares $D_i$ (trained on other data from the same source, noise $s_{\mathrm{ref}}=0.25$) gives the calibrated score $\mathrm{NLL}_{\mathrm{target}}-\mathrm{NLL}_{\mathrm{ref}}$ with AUC $\Phi(\Delta/\sqrt{2(\sigma^2+s_{\mathrm{ref}}^2)})=0.726$. \ver{closed forms against $4\times10^5$ simulated pairs: $0.8019$, TPR $0.130$, $0.5999$, $0.7257$, each within $0.003$.} + +A concrete small model with known membership: a character $n$-gram language model (alphabet $12$, documents of $120$ characters drawn from an order-$2$ Markov source at a per-document temperature in $[0.4,2.5]$, $2000$ member, $2000$ non-member and $2000$ reference documents, add-$0.1$ smoothing): +\begin{center}\small +\begin{tabular}{@{}rrrr@{}} +\toprule +order & raw-NLL AUC & calibrated AUC & mean NLL gap (nats/char) \\ \midrule +1 & $0.506$ & $0.555$ & $0.0019$ \\ +2 & $0.510$ & $0.675$ & $0.0154$ \\ +3 & $0.559$ & $0.832$ & $0.1048$ \\ +4 & $0.715$ & $0.923$ & $0.4656$ \\ \bottomrule +\end{tabular} +\end{center} +Membership signal grows with capacity to memorise (order $4$ has $20736$ contexts for about $2.3\times10^5$ training characters). Raw NLL is nearly blind at orders $1$--$2$ because the per-document temperature moves NLL far more than memorisation does, while the reference-calibrated score already detects the small order-$2$ overfit (AUC $0.675$): calibration is what turns a noisy gap into a usable test, which is why membership tests without a reference model are weak evidence of absence. The converse caution: an AUC near $0.5$ does not show a benchmark is clean (the model may generalise from near-duplicates), and an AUC above $0.5$ does not show that this \emph{item} was seen. \ver{the four-order table (single run, one seed; no confidence intervals computed, AUC standard error roughly $0.01$ at these sizes by the usual rank-statistic estimate, not computed here).} + +\section{Boxed result} +\begin{result} +\begin{align*} +\mathrm{precision}&=\frac{\pi\,\mathrm{TPR}}{\pi\,\mathrm{TPR}+(1-\pi)\mathrm{FPR}},\qquad t^\star=\frac{d'}2+\frac1{d'}\ln\frac{(1-\pi)h}{\pi g},\\ +w_{\mathrm{same}}(p)&=\frac{\E[\hat g\,\ind\{\hat p\in\mathrm{band}\}]}{p(1-p)}\ \ge\ w_{\mathrm{ind}}(p)\ \text{(in the table)},\qquad \E[p\mid c]=\frac{a+c}{a+b+n},\\ +\E\hat J&=J,\ \ \Var\hat J=\frac{J(1-J)}k,\ \ |A\cap B|=\frac{J(|A|+|B|)}{1+J},\qquad +\mathrm{AUC}=\Phi\Bigl(\frac\Delta{\sqrt{2(\sigma^2+s^2)}}\Bigr), +\end{align*} +where $s^2=s_D^2$ for the raw NLL and $s^2=s_{\mathrm{ref}}^2$ for the reference-calibrated score. +\end{result} + +\section{What breaks} +\begin{breaks} +\begin{tabular}{@{}p{0.27\linewidth}p{0.30\linewidth}p{0.37\linewidth}@{}} +\toprule +Failure & Assumption violated & Consequence \\ \midrule +Classifier chosen by AUC or training-set accuracy & prior $\pi$ of the target pool equals that of the labelled set & precision at the operating point can be $0.2$ at AUC $0.96$ \\ +Threshold by F1 & $g=h$ and prior-balanced costs & loses eightfold gain in the example; gain must be measured by training \\ +Classifier trained on a narrow seed & positives all lie near the seed & recall limited by coverage; iteration helps with diminishing returns $(1-\gamma)^r$ \\ +Band from the same samples as the gradient & indicator independent of $\hat g$ & effective weight of rare-success problems is up to $11\times$ the independent-pilot weight; $21\%$ of kept problems lie outside the band \\ +``Unsolved problems improved'' & selection independent of the evaluation sample & fake $+0.06$ gain with no training; use a fresh sample and an untrained control \\ +Jaccard threshold as a contamination test & documents are near-duplicates & a fully contained item has $J=0.02$ and is missed; use exact $n$-gram containment \\ +Few MinHash functions near the threshold & $\hat J$ precise & sd $0.035$ at $k=128$; $9\%$ of pairs at $J=0.75$ flagged \\ +Linear hashes in place of permutations & min-wise independence & small bias ($+0.004$ here) \\ +Raw NLL gap as membership evidence & items of equal difficulty & AUC $0.51$ at order $2$ although a calibrated score reaches $0.68$ \\ +NLL test result read as proof for one item & population AUC speaks about individuals & item-level evidence needs a calibrated score and a multiple-testing correction \\ \bottomrule +\end{tabular} +\end{breaks} + +\nextnote{With selection, evaluation and decontamination in place, the remaining question for the pipeline is what the \emph{trainer} does with the selected data. 0107 returns to the CISPO objective and adds the variant used for tool-using proof agents, where the sampler's ratio, the masking of tool tokens and the broadcast of one outcome to all tokens decide the gradient's variance.} + +\begin{thebibliography}{9} +\bibitem{dsmath} Z.~Shao et al. DeepSeekMath. arXiv:2402.03300, Sec.~2.1 (fastText classifier on $500{,}000$ OpenWebMath positives and $500{,}000$ Common Crawl negatives; four iterations; $35.5$M pages, $120$B tokens; $98\%$ of the fourth-iteration data already collected in the third; domain rule with the $10\%$ criterion; $10$-gram exact-match decontamination). Read from the local copy \texttt{dsmath.txt}. +\bibitem{joulin} A.~Joulin, E.~Grave, P.~Bojanowski, T.~Mikolov. Bag of tricks for efficient text classification (fastText). arXiv:1607.01759. \emph{Cited as the classifier used in \cite{dsmath}; not read here.} +\bibitem{broder} A.~Z.~Broder. On the resemblance and containment of documents. Compression and Complexity of Sequences, 1997 (MinHash). \emph{Not on arXiv; not fetched; classical.} +\bibitem{shokri} R.~Shokri, M.~Stronati, C.~Song, V.~Shmatikov. Membership inference attacks against machine learning models. arXiv:1610.05820 (shadow models; the reference-model calibration used here is the same idea in its simplest form). \emph{Cited from memory; not re-read in this session.} +\end{thebibliography} +\end{document} diff --git a/code/notes/src/0301-curry-howard.tex b/code/notes/src/0301-curry-howard.tex new file mode 100644 index 0000000000000000000000000000000000000000..245e6d627081360c7065eae1679b1b0e71111995 --- /dev/null +++ b/code/notes/src/0301-curry-howard.tex @@ -0,0 +1,222 @@ +\documentclass[11pt]{article} +\usepackage{common} +\title{} +\begin{document} +\notehead{0301}{Curry--Howard: propositions as types, proofs as terms}{What it \emph{means} for the Lean kernel to accept a proof, and which axioms keep a proof-based reward honest. Companion notebook: \texttt{notebooks/\allowbreak 0301-curry-howard.ipynb}.} + +%% --------------------------------------------------------------------------------- +\begin{problem} +\question{When does the kernel accepting a term amount to an unforgeable certificate that a statement is proved, and relative to what?} +A reward for a prover must answer one question with no human in the loop: \emph{is this text a proof of this statement?} +A language model can emit text that \emph{looks} like a proof indefinitely; we need a checker whose acceptance is an +\emph{unforgeable} certificate. Curry--Howard turns ``is this a proof?'' into ``does this term have this type?'', which +is a decidable, mechanical question answered by a very small program (the kernel). Everything else in the Lean pipeline +(tactics, automation, an LLM) is allowed to be wrong, because its output is re-checked by that small program. +The price: the guarantee holds only \emph{relative to} the kernel, the axioms the term depends on, and the statement that was typed. +This note makes each of the three precise; notes 0302--0305 deal with the statement. + +%% --------------------------------------------------------------------------------- +\end{problem} +\stepcounter{section}% the problem box counts as section 1: keeps every \S and notebook cross-reference stable + +\section{Notation} +\begin{center}\small +\begin{tabular}{@{}lp{9.6cm}@{}}\toprule +Symbol & Meaning\\\midrule +$p,q,a,b$ & atomic propositions (atoms); $\bot$ is falsity\\ +$A,B,C$ & propositions = types, built from atoms and $\bot$ by $A\to B$\\ +$\Gamma$ & context: finite map from variables to types, $x_1{:}A_1,\dots,x_n{:}A_n$\\ +$\Gamma\vdash t:A$ & typing judgement: in context $\Gamma$ the term $t$ has type $A$\\ +$t[x{:=}u]$ & capture-avoiding substitution of $u$ for $x$ in $t$\\ +$t\rightsquigarrow t'$ & one reduction step ($\beta$ or commuting conversion, \S3.3)\\ +$\mathrm{abort}_A\,e$ & ex falso: from $e:\bot$ produce a term of any type $A$\\ +$\mathcal A_{\mathrm{ok}}$ & allowed axiom set $\{\texttt{propext},\texttt{Quot.sound},\texttt{Classical.choice}\}$\\ +$\mathrm{Ax}(c)$ & set of axioms in the dependency closure of constant $c$ (\texttt{\#print axioms})\\ +$R(c)$ & reward $=\mathbf 1[\text{kernel accepts }c\ \wedge\ \mathrm{Ax}(c)\subseteq\mathcal A_{\mathrm{ok}}]$\\ +$v\models A$ & classical truth of $A$ under valuation $v:\text{atoms}\to\{0,1\}$\\ +$w\Vdash A$ & Kripke forcing at world $w$ (\S3.4)\\\bottomrule +\end{tabular} +\end{center} + +%% --------------------------------------------------------------------------------- +\section{Derivation} +\subsection{The BHK reading: what a proof \emph{is}} +Take ``a proof of $A\to B$ is a construction that turns any proof of $A$ into a proof of $B$'' (Brouwer--Heyting--Kolmogorov) +as the \emph{definition}. A construction that turns inputs into outputs is a function, so: +\begin{center}\small\begin{tabular}{@{}ll@{}}\toprule +proposition & a proof of it is\\\midrule +$\bot$ & (nothing: there is no proof)\\ +$A\to B$ & a function from proofs of $A$ to proofs of $B$\\ +$A\wedge B$ & a pair (proof of $A$, proof of $B$)\\ +$A\vee B$ & a tagged proof of $A$ or of $B$\\ +$\forall x{:}X,\,P(x)$ & a function sending each $x$ to a proof of $P(x)$ (a dependent function, \S0302)\\ +$\exists x{:}X,\,P(x)$ & a pair (witness $x$, proof of $P(x)$)\\\bottomrule\end{tabular}\end{center} +We work in the smallest fragment that already shows every phenomenon: $A::=p\mid\bot\mid A\to B$. + +\subsection{Typing rules = natural-deduction rules} +Terms: $t::=x\mid\lambda x{:}A.\,t\mid t\,u\mid\mathrm{abort}_A\,t$. The four typing rules are +\[ +\frac{x{:}A\in\Gamma}{\Gamma\vdash x:A}\;(\mathrm{Var})\qquad +\frac{\Gamma,x{:}A\vdash t:B}{\Gamma\vdash\lambda x{:}A.\,t:A\to B}\;(\to\!\mathrm I)\qquad +\frac{\Gamma\vdash f:A\to B\quad\Gamma\vdash u:A}{\Gamma\vdash f\,u:B}\;(\to\!\mathrm E)\qquad +\frac{\Gamma\vdash e:\bot}{\Gamma\vdash\mathrm{abort}_A\,e:A}\;(\bot\mathrm E). +\] +Erase the terms and the rules are exactly the natural-deduction rules for implication and ex falso: assumption, implication +introduction (discharge the assumption $x{:}A$), modus ponens, and $\bot$-elimination. + +\begin{proposition}[Curry--Howard on derivations]\label{prop:ch} +$\Gamma\vdash_{\mathrm{ND}}A$ (a natural-deduction derivation with undischarged assumptions $\Gamma$) iff there is $t$ with $\Gamma\vdash t:A$. +\end{proposition} +\emph{Proof.} ($\Leftarrow$) Induction on the typing derivation: the last rule is one of the four rules; erasing terms gives the +corresponding ND rule applied to the ND derivations obtained from the premises by the induction hypothesis. +($\Rightarrow$) Induction on the ND derivation: assumption $\mapsto x$; $\to$I applied to a derivation with term $t$ $\mapsto\lambda x{:}A.\,t$; +$\to$E applied to derivations with terms $f,u$ $\mapsto f\,u$; $\bot$E $\mapsto\mathrm{abort}_A\,t$. Each step is the identity on rule shape. $\square$ + +So \emph{a type is provable iff it is inhabited}, and a proof is a term. Two examples, with the types the notebook's kernel +(\texttt{infer}) reads off: +$K=\lambda x{:}A.\lambda y{:}B.\,x : A\to B\to A$, and $S=\lambda f.\lambda g.\lambda x.\,(f\,x)(g\,x):(A\to B\to C)\to(A\to B)\to A\to C$. + +\subsection{Proof normalisation: a detour is a redex} +An introduction immediately followed by the matching elimination is a \emph{detour}: ``prove $B$ from a hypothesis $x{:}A$, close it up to $A\to B$, then +apply it to a proof $u$ of $A$.'' The shortcut is to plug $u$ in for the hypothesis. As terms: +\[ +(\lambda x{:}A.\,t)\,u\;\rightsquigarrow_\beta\;t[x{:=}u],\qquad +(\mathrm{abort}_{A\to B}\,e)\,u\;\rightsquigarrow\;\mathrm{abort}_B\,e\quad(\text{commuting conversion}). +\] +The second rule removes an elimination applied to an ex-falso proof of an implication; it is needed so that normal forms have a +clean shape (\S3.5). + +\begin{lemma}[Substitution]\label{lem:subst} +If $\Gamma,x{:}A\vdash t:B$ and $\Gamma\vdash u:A$ then $\Gamma\vdash t[x{:=}u]:B$. +\end{lemma} +\emph{Proof.} Induction on $t$ (bound variables renamed apart, the Barendregt convention). +\emph{Var $y$:} if $y=x$ then $B=A$ and $t[x{:=}u]=u:A$. If $y\neq x$ then $y{:}B\in\Gamma$ and $t[x{:=}u]=y$. +\emph{$\lambda y{:}C.\,s$:} $B=C\to D$ with $\Gamma,x{:}A,y{:}C\vdash s:D$. By weakening $\Gamma,y{:}C\vdash u:A$, so by IH $\Gamma,y{:}C\vdash s[x{:=}u]:D$ and $\to$I gives the claim. +\emph{$f\,s$} and \emph{$\mathrm{abort}$:} immediate from IH on subterms. $\square$ + +\begin{theorem}[Subject reduction]\label{thm:sr} +If $\Gamma\vdash t:A$ and $t\rightsquigarrow t'$ then $\Gamma\vdash t':A$. +\end{theorem} +\emph{Proof.} Induction on the derivation of $t\rightsquigarrow t'$. +\emph{$\beta$:} $t=(\lambda x{:}C.\,s)\,u$. Inversion of $\to$E and $\to$I gives $\Gamma,x{:}C\vdash s:A$ and $\Gamma\vdash u:C$; Lemma~\ref{lem:subst} gives $\Gamma\vdash s[x{:=}u]:A$. +\emph{Commuting conversion:} $t=(\mathrm{abort}_{C\to A}\,e)\,u$; inversion gives $\Gamma\vdash e:\bot$, so $\Gamma\vdash\mathrm{abort}_A\,e:A$ by $\bot$E. +\emph{Congruence} (reduction inside $\lambda$, application, abort): the premise type is unchanged by IH, so the same rule re-applies. $\square$ + +Subject reduction is what makes ``simplify the proof'' safe: normalising never changes \emph{which} proposition is proved. + +\subsection{Consistency: no closed term inhabits $\bot$} +\begin{lemma}[Closed normal forms]\label{lem:cnf} +Every closed $\rightsquigarrow$-normal typable term is a $\lambda$-abstraction. +\end{lemma} +\emph{Proof.} Induction on the term. A variable is not closed. $\mathrm{abort}_A\,e$: $e$ is closed and normal, so by IH it is a $\lambda$, hence has an arrow type, contradicting $e:\bot$; so this case cannot occur. An application $f\,u$ with $f,u$ closed and normal: by IH $f$ is a $\lambda$, so $f\,u$ is a $\beta$-redex, contradicting normality. Only $\lambda$ remains. $\square$ + +\begin{theorem}[Consistency]\label{thm:cons} +There is no $t$ with $\vdash t:\bot$. +\end{theorem} +\emph{Proof.} We \emph{assume} strong normalisation of $\rightsquigarrow$ on typable terms (Tait's reducibility argument, \cite{tait67}; we do not re-derive it, and the notebook only checks it by fuel on 3000 random terms). Normalise $t$ to $t'$; by Theorem~\ref{thm:sr}, $\vdash t':\bot$ with $t'$ closed and normal. By Lemma~\ref{lem:cnf}, $t'$ is a $\lambda$, so its type is an arrow, not $\bot$. Contradiction. $\square$ + +\paragraph{A semantic check (soundness) and its failure of converse.} +Classical truth: $v\models\bot$ never; $v\models A\to B$ iff $v\not\models A$ or $v\models B$. +\begin{theorem}[Classical soundness]\label{thm:sound} +If $\Gamma\vdash t:A$ and $v\models\Gamma$ (every type in $\Gamma$ is true under $v$) then $v\models A$. In particular every inhabited closed type is a tautology. +\end{theorem} +\emph{Proof.} Induction on the derivation. Var: $v\models\Gamma$ gives $v\models A$. $\to$I: if $v\models A$ then $v\models\Gamma,x{:}A$ so IH gives $v\models B$; hence $v\models A\to B$. $\to$E: $v\models A\to B$ and $v\models A$ give $v\models B$. $\bot$E: $v\models\bot$ is impossible, so the implication holds vacuously. $\square$ + +The converse is \emph{false}: Peirce's law $((a\to b)\to a)\to a$ is a tautology (if $v\models a$ the conclusion holds; if $v\not\models a$ then $v\models a\to b$ for every $b$, so the hypothesis $(a\to b)\to a$ would need $a$, so it is false, and the implication holds) yet it is uninhabited. To see this one uses Kripke semantics: worlds with a preorder $\le$, atoms monotone in $w$, and +\[ +w\Vdash A\to B\iff\forall w'\ge w:\ w'\Vdash A\Rightarrow w'\Vdash B,\qquad w\not\Vdash\bot. +\] +The same induction as Theorem~\ref{thm:sound} (using monotonicity: $w\Vdash A$ and $w\le w'$ imply $w'\Vdash A$) shows that inhabited types are forced at every world of every model. +Countermodel for Peirce: two worlds $w_0\le w_1$; $a$ true only at $w_1$; $b$ true nowhere. At $w_1$: $w_1\Vdash a$, $w_1\not\Vdash a\to b$. At $w_0$: $w_0\not\Vdash a\to b$ (witness $w_1$). Hence $w_0\Vdash(a\to b)\to a$ vacuously, but $w_0\not\Vdash a$, so $w_0\not\Vdash$ Peirce. + +\subsection{Search procedures (what the notebook implements)} +\emph{Naive.} Every normal term of atomic type has shape $x\,n_1\cdots n_k$ or $\mathrm{abort}_A(x\,n_1\cdots n_k)$ with $x$ a variable; every normal term of arrow type is (after $\eta$-expansion) a $\lambda$. Enumerate by depth with backtracking. This answers only ``found within depth $D$''. +\emph{Efficient.} Dyckhoff's contraction-free calculus G4ip \cite{dyckhoff92} replaces the implication-left rule by four rules ($L0{\to}$: from $p$ and $p\to B$ use $B$; $L{\to}{\to}$: from $(C\to D)\to B$ prove $D$ using $C$ and $D\to B$, then continue with $B$; $L\bot{\to}$: drop $\bot\to B$; $L\bot$: close the goal from $\bot$). Each premise is smaller in a well-founded measure, so search \emph{terminates}; a failed search is therefore a proof of non-provability. The proof term is extracted rule by rule ($L{\to}{\to}$ uses $g:=\lambda d.\,f\,(\lambda\_.\,d)$) and re-checked by the independent \texttt{infer}. + +\subsection{What the Lean kernel checks} +Lean's logic is the Calculus of Inductive Constructions \cite{mathlib20}: dependent function types $\Pi x{:}A.\,B(x)$ generalise $A\to B$ (note 0302), the bottom universe $\Prop$ is \emph{impredicative} (a $\Pi$ into $\Prop$ is a $\Prop$, whatever the domain) and \emph{proof-irrelevant} (any two proofs of the same proposition are definitionally equal, notebook cell ``proof irrelevance''). +The Lean 4 kernel \cite{lean4lean} implements the judgements $\Gamma\vdash t:A$ and definitional equality $\Gamma\vdash t\equiv t'$ (by weak-head normalisation); it also adds quotient types and, via inductive declarations, \texttt{False}, \texttt{Eq}, \texttt{And}, \texttt{Exists}, \dots A declaration $\texttt{theorem}\ c : T := v$ is accepted iff $\vdash T:\Sort\,u$ and $\vdash v:T$ (and then $c$ is added to the environment). Three consequences: +\begin{enumerate}[nosep] +\item \textbf{Tactics are untrusted.} \texttt{by omega} or \texttt{by intro; exact} is a program in the \emph{elaborator} that builds an $\mathrm{Expr}$; the kernel never sees the script. In the notebook \texttt{by omega} elaborates to a 64-line term, \texttt{by intro $\langle a,b\rangle$; exact $\langle b,a\rangle$} to \texttt{fun p q h => match h with | $\langle a,b\rangle$ => $\langle b,a\rangle$}. A buggy tactic can fail; it cannot make an ill-typed term pass. +\item \textbf{The trusted base is small and explicit}: the kernel, the axioms in $\mathrm{Ax}(c)$, and (for the \emph{statement}) the definitions the statement mentions. +\item \textbf{The theorem is only as good as the typed statement}: the kernel checks $v:T$ for the $T$ it was given, not for the $T$ in the author's head. +\end{enumerate} + +\subsection{The three standard axioms, and why they are admissible} +\texttt{propext}: $(a\leftrightarrow b)\to a=b$ for propositions. \texttt{Quot.sound}: related elements have equal images in a quotient (it yields function extensionality: \texttt{t\_funext} uses exactly \{\texttt{Quot.sound}\} in the notebook). \texttt{Classical.choice}: $\mathrm{Nonempty}\ \alpha\to\alpha$; together with \texttt{propext} \emph{and function extensionality} (hence \texttt{Quot.sound}) it yields excluded middle by Diaconescu's argument (our \texttt{peirce\_law} term uses \texttt{Classical.em}, and \texttt{\#print axioms} reports all three: $\{\texttt{Classical.choice},\texttt{propext},\texttt{Quot.sound}\}$). +Their consistency is a theorem for the \emph{Lean~3} theory only: Carneiro showed Lean consistent relative to ZFC with $n$ inaccessible cardinals for all $n<\omega$ (\cite{lean4lean} \S1, ref.\ [6]). For Lean~4 the same paper says that nested inductive types and $\eta$ for structures ``significantly impact the theory'', so that ``the soundness proof from [6] is no longer directly applicable'' (\S1): consistency of Lean~4 with these axioms is therefore the assumption (K) below, supported by Lean4Lean's ongoing formalisation, not a result we can cite. Peirce's law is thereby \emph{provable in Lean} though uninhabited in the pure $\to,\bot$ calculus: Lean is not the minimal calculus, it is that calculus plus these axioms. The cost is computational: the term for Peirce contains \texttt{Classical.choice}, which has no reduction rule, so it is a proof but not a program. + +\subsection{The reward and the three ways to forge it} +Let $R(c)=\mathbf 1[\vdash c\text{ accepted by the kernel}\wedge\mathrm{Ax}(c)\subseteq\mathcal A_{\mathrm{ok}}]$. +\begin{theorem}[Gated reward is sound]\label{thm:reward} +If the kernel is sound and $\mathcal A_{\mathrm{ok}}$ is consistent, then $R(c)=1$ for a constant $c$ of type $T$ implies $T$ holds in every model of CIC${}+\mathcal A_{\mathrm{ok}}$. +\end{theorem} +\emph{Proof.} $R(c)=1$ means $\vdash v:T$ in CIC extended by the axioms in $\mathrm{Ax}(c)\subseteq\mathcal A_{\mathrm{ok}}$. Soundness of the kernel (assumption K) turns a derivation into truth in every model of that theory. $\square$ + +\begin{proposition}[Exploits collapse the reward]\label{prop:exploit} +(i) \texttt{sorryAx}: its type is $\Pi(\alpha{:}\Sort\,u),\ \mathrm{Bool}\to\alpha$ (notebook: \texttt{\#print sorryAx}), so $\texttt{sorryAx}\ T\ b:T$ for \emph{every} $T$, including \texttt{False}. (ii) A user \texttt{axiom evil : False} gives $\texttt{evil.elim}:T$ for every $T$. (iii) \texttt{native\_decide} introduces the axiom \texttt{Lean.ofReduceBool}$:\forall a\,b{:}\mathrm{Bool},\ \texttt{reduceBool}\ a=b\to a=b$: the kernel accepts $\texttt{reduceBool}\ a=b$ by \emph{running compiled code}, so correctness now also depends on the compiler/evaluator and on every definition's \emph{compiled} behaviour matching its \emph{logical} definition (a gap that \texttt{implemented\_by}/\texttt{unsafe} can open). +With any of (i)--(ii) allowed, $R(c)=1$ for every statement, so $P(R{=}1\mid T\text{ false})=P(R{=}1\mid T\text{ true})=1$: the likelihood ratio is $1$ and $R$ carries zero information about truth. +\end{proposition} +\emph{Proof.} (i), (ii): direct from the types. The information statement is Bayes' rule with equal likelihoods. $\square$ + +\emph{Measured} (notebook, Lean 4.9.0-rc1 + Mathlib pin of this repo): for the three false statements \texttt{(1:Nat)=2}, \texttt{$\forall n,\,n<3$}, \texttt{False} the proof \texttt{sorry} compiles with a warning and has $\mathrm{Ax}=\{\texttt{sorryAx}\}$: accepted by a compile-only gate in 3/3 cases, rejected by the axiom gate in 3/3. This is rule~3 of the strict tier (\texttt{align/verify/lean/strict.py}; \texttt{docs/LEAN\_VERIFIER.md} \S2). + +%% --------------------------------------------------------------------------------- +\section{Boxed result} +\begin{result} +\textbf{R1 (Curry--Howard).} $\Gamma\vdash_{\mathrm{ND}}A\iff\exists t.\ \Gamma\vdash t:A$ (Prop.~\ref{prop:ch}); \textbf{R2 (subject reduction)} $\Gamma\vdash t:A,\ t\rightsquigarrow t'\Rightarrow\Gamma\vdash t':A$ (Thm.~\ref{thm:sr}); +\textbf{R3 (consistency and soundness)} no closed $t:\bot$; inhabited $\Rightarrow$ classically valid and Kripke-valid, converse false (Peirce); +\textbf{R4 (gated reward)} $R(c)=1\Rightarrow T$ true in every model of CIC$+\mathcal A_{\mathrm{ok}}$; allowing \texttt{sorryAx}, a user axiom of type \texttt{False}, or (trust-widening) \texttt{Lean.ofReduceBool} voids this. +\end{result} + +\paragraph{Numerical verification (notebook 0301; all cells executed, outputs saved).} +\begin{center}\small +\begin{tabular}{@{}P{7.1cm}P{3.2cm}P{5.2cm}@{}}\toprule +Identity / claim & measured & value\\\midrule +R2: type preserved by every reduction step (3000 random typed terms, 1205 with a redex; 1431 $\beta$ and 420 commuting-conversion steps, so both cases of Thm.~\ref{thm:sr} are exercised) & violations over 1851 steps & 0\\ +Naive depth-14 search vs G4ip verdict on all 3873 formulas ($\le4$ arrows, atoms $a,b,\bot$) & disagreements & 0\\ +Every G4ip proof term re-checked by independent \texttt{infer}, and its normal form too & failures over 1775 proofs & 0\\ +R3: provable $\Rightarrow$ tautology (3873 formulas) & violations & 0\\ +R3: provable $\Rightarrow$ forced at every world of 29 chain models & violations & 0\\ +R3: classically valid but uninhabited (incl.\ Peirce, DNE) & count among 2098 uninhabited & 32\\ +R3: uninhabited types refuted by a chain model ($\le3$ worlds) & count & 2098/2098\\ +R4: \texttt{\#print axioms} gate on 9 theorems & accepted set & constructive, \texttt{omega}, \texttt{decide}, \texttt{funext}, \texttt{simp}, Peirce\\ +R4: rejected set & & \texttt{native\_decide}, \texttt{sorry}, user axiom\\ +Speed: G4ip vs naive on the 3873 formulas & $\times$ & 42 (0.02 s vs 1.05 s)\\\bottomrule +\end{tabular} +\end{center} +(``max abs error'' is not meaningful for exact symbolic identities; the table reports counts of violations, all zero.) +A side observation from the same run: \texttt{2\^{}10 = 1024 := by decide} reports $\mathrm{Ax}=\{\texttt{propext}\}$ on this pin; it is inside $\mathcal A_{\mathrm{ok}}$, but shows that ``\texttt{decide} uses no axioms'' is not a safe belief, which is why the gate reads the real dependency closure rather than assuming it. + +%% --------------------------------------------------------------------------------- +\section{What breaks} +\begin{center}\small +\begin{tabular}{@{}P{3.6cm}P{6.3cm}P{5.6cm}@{}}\toprule +Failure & Mechanism & Assumption violated\\\midrule +Kernel soundness bug & Lean 4's kernel has had soundness bugs; Lean4Lean found one \emph{by failing to prove a theorem}: an overflow check in \texttt{Expr.data} used \texttt{panic!}, which continues with $0$ for \texttt{UInt64}, switching on an optimisation in \texttt{hasLooseBVars} \cite{lean4lean} \S4.1 & (K) kernel soundness. Mitigation: external checkers (Lean4Lean, Comparator \cite{comparator})\\ +\texttt{sorry}/\texttt{admit} & \texttt{sorryAx} inhabits every type & $\mathrm{Ax}(c)\subseteq\mathcal A_{\mathrm{ok}}$; compile warnings are not a gate\\ +User \texttt{axiom}, \texttt{opaque} with a false type, \texttt{unsafe} & enlarges the theory & same; also lexical bans can be evaded, so read $\mathrm{Ax}(c)$ from the environment\\ +\texttt{native\_decide} & trusts compiled evaluation (\texttt{Lean.ofReduceBool}) & the kernel is the only evaluator trusted\\ +Meta-programming / environment edits & meta-programs run inside the elaborator with access to the environment; a lexical ban cannot enumerate every route, and \texttt{debug.skipKernelTC}-style switches exist upstream (not an option on this pin) & the elaborator is untrusted and the kernel \emph{does} check; so re-check with kernel-forced-on\\ +Wrong \emph{statement} typed & kernel certifies $v:T$ for the given $T$ & the statement means what the author intended (0302, 0305)\\ +Classical axioms & proof is not a program (\texttt{Classical.choice} has no reduction) & only matters if the proof is meant to be run\\\bottomrule +\end{tabular} +\end{center} +The audit literature agrees on the category: the \emph{kernel only checks that a proof establishes a formal statement; it does not verify that the statement faithfully encodes the intended informal problem}, and mechanically certified faults include ``counterexamples, vacuous theorems, and unsound axioms'' (\cite{faults26}, abstract). + +\nextnote{0302 asks what a Lean \emph{statement} actually is: dependent function types, \texttt{Prop} versus \texttt{Type}, decidability, and ten miniF2F statements decoded binder by binder.} + +%% --------------------------------------------------------------------------------- +\begin{small} +\begin{thebibliography}{99} +\bibitem{lean4lean} M.~Carneiro, \emph{Lean4Lean: Verifying a Typechecker for Lean, in Lean}, arXiv:2403.14064. \S1 (soundness bugs in Lean~4's kernel; relative consistency of the Lean~3 theory with ZFC$+n$ inaccessibles, ref.\ [6]; that proof is ``no longer directly applicable'' to Lean~4); \S4.1 (``Bit tricks and soundness bugs'', \texttt{looseBVarRange}, \texttt{panic!}); \S5 (``The global environment'': the \texttt{VDecl} declaration forms \texttt{block}, \texttt{axiom}, \texttt{opaque}, \dots). +\bibitem{mathlib20} The mathlib Community, \emph{The Lean Mathematical Library}, arXiv:1910.09336. \S2 (CIC, universe hierarchy, impredicative and proof-irrelevant \texttt{Prop}; the axioms \texttt{choice} and \texttt{propext}, quotients); \S4.2 (the \texttt{decidable} class). Describes Lean~3; Lean~4 keeps the same logic. +\bibitem{faults26} P.~S.~Ammanamanchi, S.~Bhat, S.~Biderman, \emph{Faults in Our Formal Benchmarking}, arXiv:2606.29493 (ICML 2026), abstract. +\bibitem{comparator} Lean FRO, \texttt{leanprover/comparator}. Not an arXiv paper and the repository was not fetched; as described in \cite{faults26} it is ``a trusted evaluator that checks proofs against a conservative set of axioms and disallows potentially unsafe tactics like \texttt{native\_decide}'' (the specific default axiom list and the sandboxing are not verified here). +\bibitem{tait67} W.~W.~Tait, ``Intensional interpretations of functionals of finite type I'', J.\ Symbolic Logic 32 (1967). Strong normalisation by reducibility. \emph{Not on arXiv; not fetched; cited only for the named theorem, which this note assumes.} +\bibitem{dyckhoff92} R.~Dyckhoff, ``Contraction-free sequent calculi for intuitionistic logic'', J.\ Symbolic Logic 57 (1992). \emph{Not on arXiv; not fetched; the notebook cross-checks G4ip against naive search and Kripke models rather than relying on the citation.} +\bibitem{howard80} W.~A.~Howard, ``The formulae-as-types notion of construction'' (1980); H.~B.~Curry, R.~Feys, \emph{Combinatory Logic} I (1958). \emph{Not on arXiv; not fetched; historical attribution of Prop.~\ref{prop:ch}, whose proof is given above.} +\bibitem{house} This repository: \texttt{align/docs/LEAN\_VERIFIER.md} \S2 (strict tier, axiom audit), \texttt{align/verify/lean/strict.py} (rule 3). +\end{thebibliography} +\end{small} +\end{document} diff --git a/code/notes/src/0302-dependent-types.tex b/code/notes/src/0302-dependent-types.tex new file mode 100644 index 0000000000000000000000000000000000000000..92c8d182e40c1143c1e4be7dbe521ff6ba07127a --- /dev/null +++ b/code/notes/src/0302-dependent-types.tex @@ -0,0 +1,142 @@ +\documentclass[11pt]{article} +\usepackage{common} +\begin{document} +\notehead{0302}{Dependent type theory: the minimum for reading Mathlib statements}{Pi types, \texttt{Prop} versus \texttt{Type}, decidability, and ten miniF2F statements decoded binder by binder. Companion notebook: \texttt{notebooks/\allowbreak 0302-dependent-types.ipynb}.} + +\begin{problem} +\question{What must we be able to read in a Lean statement to know what the reward actually measures?} +Note 0301 showed that the kernel certifies $v:T$ for the type $T$ \emph{as written}. Whether the reward measures what we want therefore +depends on reading $T$ correctly: which variables are bound, which symbols are hypotheses, what \texttt{/}, \texttt{-}, \texttt{$\uparrow$} and \texttt{$\forall n\le9$} really mean, +and whether Lean can \emph{evaluate} the statement or only prove it. This note gives the smallest dependent-type vocabulary needed to read a +statement such as those of miniF2F \cite{minif2f} and to see which hazards are in the statement and not in the proof. +\end{problem} +\stepcounter{section}% the problem box counts as section 1: keeps every \S and notebook cross-reference stable + +\section{Notation} +\begin{center}\small +\begin{tabular}{@{}lP{10.3cm}@{}}\toprule +Symbol & Meaning\\\midrule +$\Pi x{:}A.\,B(x)$ & dependent function type (Lean: \texttt{(x : A) $\to$ B x}, \texttt{$\forall$ x : A, B x})\\ +$A\to B$ & $\Pi x{:}A.\,B$ with $x\notin\FV(B)$ (non-dependent)\\ +$\Sort\,u$ & universe at level $u\in\{0,1,2,\dots\}$; $\Prop=\Sort\,0$, $\Type\,n=\Sort(n{+}1)$, $\Type=\Sort\,1$\\ +$\imax(u,v)$ & $0$ if $v=0$, else $\max(u,v)$\\ +$P:\Prop$ & a proposition; a term $h:P$ is a proof of it (0301)\\ +\texttt{Decidable}\,$P$ & a \emph{datum} (\texttt{isTrue h} or \texttt{isFalse h}) deciding $P:\Prop$; \texttt{decide}\,$P:\bool$\\ +$\uparrow$ & an inserted coercion (e.g.\ $\N\to\R$)\\ +$a\mathbin{-}b$ on $\N$ & truncated subtraction $\max(a-b,0)$; $a\mathbin{/}b$ on $\N$: floor; on $\Z$: Euclidean (\S3.4)\\\bottomrule +\end{tabular} +\end{center} + +\section{Derivation} +\subsection{From $A\to B$ to $\Pi x{:}A.\,B(x)$} +In 0301 a proof of $A\to B$ is a function from proofs of $A$ to proofs of $B$. For $\forall x{:}X.\,P(x)$ the \emph{type} of the result depends on the argument: a proof of $P(3)$ and a proof of $P(4)$ +are proofs of different propositions. A function whose result type depends on its argument has type $\Pi x{:}X.\,P(x)$. Rules (with $\Gamma$ as in 0301): +\begin{gather*} +\frac{\Gamma\vdash A:\Sort\,u\quad\Gamma,x{:}A\vdash B:\Sort\,v}{\Gamma\vdash\Pi x{:}A.\,B:\Sort\,\imax(u,v)}\ (\Pi\text{F})\qquad +\frac{\Gamma,x{:}A\vdash t:B}{\Gamma\vdash\lambda x{:}A.\,t:\Pi x{:}A.\,B}\ (\Pi\text{I})\\[4pt] +\frac{\Gamma\vdash f:\Pi x{:}A.\,B\quad\Gamma\vdash a:A}{\Gamma\vdash f\,a:B[x{:=}a]}\ (\Pi\text{E}) +\end{gather*} +with $(\lambda x.t)\,a\rightsquigarrow t[x{:=}a]$. Setting $x\notin\FV(B)$ recovers 0301's rules, so $\to$ is a special case. This is precisely rule \texttt{t-all} of the formalised kernel (Lean4Lean \S2.2: $\Gamma\vdash(\forall x{:}\alpha.\beta):U_{\imax(\ell_1,\ell_2)}$) \cite{lean4lean}. + +\subsection{Universes and why $\imax$ is the right rule} +Every type lives in a sort, and $\Sort\,u:\Sort(u{+}1)$ (Lean4Lean \S2.1: ``the type of \texttt{sort u} is \texttt{sort (succ u)}''). What should $\Pi x{:}A.\,B$ live in? Two constraints. +\begin{enumerate}[nosep] +\item \emph{Predicativity of data}: if $A:\Sort\,u$ and $B:\Sort\,v$ with $v>0$, the function type must be at least as large as both (a smaller universe would let a type contain a type that quantifies over it; this is the design constraint behind Girard's paradox, which we do not re-derive). The least choice is $\max(u,v)$. +\item \emph{Impredicativity of propositions}: for a statement $\forall x{:}A.\,P(x)$ with $P(x):\Prop$ we want the result to be a $\Prop$ \emph{even if $A$ is huge} (e.g.\ $A=\Prop$ itself: $\forall p{:}\Prop,\ p\to p$). Otherwise ``for all sets $S$, \dots'' would be a $\Type$, not a proposition, and could not be an argument of $\land$, $\to$ or a hypothesis. +\end{enumerate} +Combining the two gives exactly $\imax(u,v)=0$ if $v=0$ else $\max(u,v)$. Consistency of the impredicative bottom universe together with proof irrelevance is the classical Calculus-of-Constructions result \cite{mathlib20} \S2. + +\emph{Worked checks (notebook cell 1; the kernel agrees on all ten).} +$\forall n{:}\N,\ n=n$: $u=1$ ($\N:\Type$), $v=0$, so $\imax=0$: a $\Prop$. +$(\alpha{:}\Type)\to\alpha\to\alpha$: $\Type:\Type\,1=\Sort\,2$ so $u=2$, $v=1$, so $\Sort\,2=\Type\,1$. +$\forall p{:}\Prop,\ p\to p$: $u=1$, $v=0$, so a $\Prop$ (impredicative). + +\subsection{A theorem statement is a Pi telescope; hypotheses are non-dependent binders} +\[\texttt{theorem}\ c\ (x_1{:}A_1)\cdots(x_n{:}A_n)\ (h_1{:}H_1)\cdots(h_m{:}H_m)\ :\ G\] +\[\text{means}\qquad c:\ \Pi x_1{:}A_1\cdots\Pi x_n{:}A_n.\ H_1\to\cdots\to H_m\to G.\] +A proof is $\lambda x_1\dots x_n\,h_1\dots h_m.\,t$ with $t:G$; the tactic \texttt{intro} is $\Pi$I. +The $h_i$ are \emph{non-dependent} binders (their names are not free in later types) so \texttt{\#check} prints them as arrows, with names dropped: + +\begin{lstlisting} +mathd_algebra_478 : ∀ (b h v : ℝ), 0 < b ∧ 0 < h ∧ 0 < v → v = 1 / 3 * (b * h) → b = 30 → h = 13 / 2 → v = 65 +\end{lstlisting} +Binder-predicate notation $\forall n\le 9,\ Q$ is sugar for $\forall n,\ n\le9\to Q$ (so it has \emph{two} binders: $n$ and an anonymous proof). The sort of the whole statement is $\imax$ of the pieces, hence $\Prop$ (the notebook's \texttt{\#sort\_of} reports \texttt{sort=Prop} for all ten). + +\subsection{Elaboration: what the symbols mean} +The elaborator inserts information the surface text does not show. Four rules cover almost every surprise. +\paragraph{(a) Numerals and unification.} A literal \texttt{2} is \texttt{OfNat ?$\alpha$ 2}; $?\alpha$ is fixed by unification with the surrounding term, and defaults to $\N$ if nothing constrains it. In \texttt{v = 1/3 * (b * h)} with $v{:}\R$, the \texttt{1/3} is real, $=1/3$; had it been elaborated in $\N$ it would be $0$. \emph{Elaboration order matters}: \texttt{$\forall$ n < 40, Nat.Prime (n*n+n+41)} fails to find a \texttt{Decidable} instance (notebook; our diagnosis of the cause is that $n{:}?\alpha$ is still a metavariable when synthesis is attempted, the observed fact is the failure), while \texttt{$\forall$ n < (40:$\N$), \dots} succeeds. +\paragraph{(b) Coercions.} A $\uparrow$ is inserted when a term of type $A$ is used where $B$ is expected (\texttt{aime\_1983\_p1}: $x,y,z,w:\N$ but \texttt{Real.log} needs $\R$). $\uparrow(a-b)\ne\uparrow a-\uparrow b$ in general because $-$ is evaluated \emph{before} casting. +\paragraph{(c) Truncated subtraction on $\N$.} \texttt{Nat.sub} is defined by $n-0=n$, $n-(m{+}1)=\mathrm{pred}(n-m)$ with $\mathrm{pred}\,0=0$. Then $5-7=\mathrm{pred}(\mathrm{pred}(5-5))=\mathrm{pred}(\mathrm{pred}\,0)=0$. +\begin{lemma}\label{lem:tsub} For $a,b\in\N$: $(a-b)+b=\max(a,b)$.\end{lemma} +\emph{Proof.} If $b\le a$ write $a=d+b$; induction on $b$ gives $a-b=d$, so $(a-b)+b=a=\max(a,b)$. If $a true | isFalse \_ => false}$.\end{definition} +\begin{lemma}[\texttt{of\_decide\_eq\_true}]\label{lem:dec} $\texttt{decide}\ p=\texttt{true}\to p$.\end{lemma} +\emph{Proof.} Case on the instance. If \texttt{isTrue h}, then $p$ holds by $h$. If \texttt{isFalse h}, then $\texttt{decide}\,p=\texttt{false}$, and \texttt{false = true} is absurd (\texttt{Bool.noConfusion}). $\square$\\ +So \texttt{by decide} elaborates to \texttt{of\_decide\_eq\_true (Eq.refl true)}: the kernel checks $\texttt{decide}\,p\equiv\texttt{true}$ \emph{by evaluation} (notebook: \texttt{\#print two\_plus\_two}). Closure: $\wedge,\vee,\neg,\to$ preserve decidability (combine the two data); a \emph{bounded} quantifier $\forall n0$ (a \emph{finite} sum: the infinite one equals $1$ and would give only $\ge0$). 200 random $(k,a)$: $|(A,B)|\ge2^{1-k}$ always; residual $3.7\times10^{-15}$.\\ +\texttt{mathd\_\-algebra\_\-209} & $\forall(\sigma{:}\R\simeq\R),\ \sigma.\mathrm{invFun}\,2=10\to\sigma.\mathrm{invFun}\,10=1\to\sigma.\mathrm{invFun}\,1=2\to\sigma.\mathrm{toFun}(\sigma.\mathrm{toFun}\,10)=1$ & \texttt{$\sigma$.2} is the structure field \texttt{invFun} (informal $\sigma^{-1}$). \texttt{Equiv} bundles two functions \emph{and} two proofs; proof irrelevance makes equal functions give equal \texttt{Equiv}. Permutation $\{10{\mapsto}2,1{\mapsto}10,2{\mapsto}1\}$ checked.\\ +\texttt{mathd\_\-numbertheory\_\-1124} & $\forall n\le9,\ 18\mid374\cdot10+n\to n=4$ & Binder-predicate sugar (binders: $n$, $n\le9$, hypothesis). \texttt{$\mid$} is $\exists c,\ \cdots=18c$ (decidable via \texttt{mod}); the statement is a bounded $\forall$, hence decidable; only $n=4$ works.\\ +\texttt{imo\_\-1983\_\-p6} & $\forall(a\,b\,c{:}\R),\ 00\end{cases}$; so every $\forall$-statement is a $\Prop$.\\ +\textbf{R2.} $\texttt{theorem}\,c\,(\vec x{:}\vec A)(\vec h{:}\vec H):G$ is the type $\Pi\vec x{:}\vec A.\,\vec H\to G$; hypotheses are non-dependent binders.\\ +\textbf{R3.} $\texttt{decide}\,p=\texttt{true}\Rightarrow p$ (Lemma~\ref{lem:dec}); bounded quantifiers decidable, $\forall n{:}\N$ and $=_\R$ not.\\ +\textbf{R4.} $(a-b)+b=\max(a,b)$ on $\N$; $\Z$-division is Euclidean ($a=bq+r$, $0\le r<|b|$); $x/0=0$ on $\R$. +\end{result} + +\paragraph{Numerical verification (notebook 0302, all cells executed).} +\begin{center}\small\begin{tabular}{@{}P{7.4cm}P{2.8cm}P{4.4cm}@{}}\toprule +Identity & agreement & max abs error\\\midrule +R1: $\imax$ rule vs the kernel's sort of 10 Pi types & 10/10 & 0 (exact)\\ +R3: Python brute force vs Lean \texttt{decide} on 12 bounded statements & 12/12 & 0\\ +R4: Lean \texttt{\#eval} vs Python model of $\N$/$\Z$ on 8 expressions; Lean $7/(-2)=-3$ and $(-7)/2=-4$ vs floor & 8/8; Python \texttt{//} differs on $7/(-2)$ as predicted & 0\\ +Ten miniF2F statements: hypotheses satisfiable, conclusion verified & 10/10 & exact for 8; $0$ (aime, fp64), $3.7\times10^{-15}$ (imo\_1969\_p2), $E/a^4\ge6.6\times10^{-8}$ (imo\_1983\_p6)\\ +Kernel/Lean elaboration of all ten, sort $=\Prop$ & 10/10 & ---\\ +R4: \texttt{a - b + b = max a b} (Python on $61^2$ pairs; Lean proof); Euclidean law and $a/0=0$ on $\Z$ (1464-point grid, Lean); $x/0=0$ on $\R$ (Lean proof) & all & 0\\\bottomrule\end{tabular}\end{center} + +\section{What breaks} +\begin{center}\small\begin{tabular}{@{}P{3.2cm}P{6.2cm}P{5.2cm}@{}}\toprule +Failure & Mechanism & Assumption violated\\\midrule +Wrong default type & \texttt{1/3} elaborated in $\N$ is $0$; a statement ``true'' for the wrong reason & the author's numeral type equals the elaborator's (check \texttt{pp.numericTypes})\\ +Truncated \texttt{-} / floor \texttt{/} & $\N$ arithmetic silently differs from $\Z$/$\R$ (Lemma~\ref{lem:tsub}) & informal ``$a-b$'' is real subtraction\\ +Total division $x/0=0$ & dropping \texttt{x $\ne$ 0} can make a false statement true or a true one false & the informal statement excludes division by 0\\ +Vacuous hypotheses & contradictory hypotheses make any conclusion provable (0305); $0\le w$ on $\N$ is harmless but shows how informal ``positive'' can degrade & hypotheses are satisfiable; check by witness\\ +\texttt{autoImplicit} & a typo \texttt{xx} becomes a fresh universally quantified variable: \texttt{@t\_typo : $\forall$ \{xx : $\N$\} (x : $\N$), x+1 > xx} (notebook); with it off, \texttt{unknown identifier} & every free identifier is intended; the benchmark audit recommends disabling \texttt{autoImplicit} \cite{faults26}\\ +Over-/under-statement & statements simplified, solution given away, or unprovable: miniF2F has formal/informal mismatches in over half of 488 problems and 16 unprovable statements \cite{minif2frev} & formal $\approx$ informal\\ +Using \texttt{decide} on large closed props & kernel evaluation is slow/exhausts recursion; statement with $\R$ has no instance & statement is small and over a decidable fragment\\\bottomrule\end{tabular}\end{center} + +\nextnote{0303 specifies \emph{code}, not mathematics: pre/postconditions as Pi/implication types, verification conditions, loop invariants, and a verified linear search in Lean~4.} + +\begin{small} +\begin{thebibliography}{99} +\bibitem{lean4lean} M.~Carneiro, \emph{Lean4Lean}, arXiv:2403.14064, \S2.1 (universe levels, \texttt{imax a b = if b = 0 then 0 else max a b}), \S2.2 (rule \texttt{t-all}). +\bibitem{mathlib20} The mathlib Community, \emph{The Lean Mathematical Library}, arXiv:1910.09336, \S2 (CIC; impredicative, proof-irrelevant \texttt{Prop}; universe hierarchy), \S4.2 (the \texttt{decidable} class; $\mathrm{decidable}\ p\simeq\Sigma b,\mathrm{reflect}\ b\ p$; classical instances via choice). Lean~3 text; the logic is shared. +\bibitem{minif2f} K.~Zheng, J.~M.~Han, S.~Polu, \emph{miniF2F: a cross-system benchmark for formal Olympiad-level mathematics}, arXiv:2109.00110 (488 problems, 244 valid / 244 test). \emph{Not fetched; only the problem count, confirmed in \cite{minif2frev}, is used.} +\bibitem{minif2frev} M.~Ospanov, F.~Farnia, R.~Yousefzadeh, \emph{miniF2F-Lean Revisited}, arXiv:2511.03108, \S2.1--2.2 (informal and formal statement errors); the ``Unprovable statements'' paragraph, inside \S2.2, reports 16 unprovable problems across the test and validation sets; abstract: formal/informal discrepancies for more than half of the problems. +\bibitem{faults26} P.~S.~Ammanamanchi, S.~Bhat, S.~Biderman, \emph{Faults in Our Formal Benchmarking}, arXiv:2606.29493: taxonomy of dataset defects; release standards (disable \texttt{autoImplicit}). +\bibitem{cc88} T.~Coquand, G.~Huet, ``The calculus of constructions'' (1988); J.-Y.~Girard (1972) for the paradox that forces the predicativity constraint. \emph{Not on arXiv; not fetched; cited for the named results; the $\imax$ rule is derived above from the two constraints and checked against the kernel.} +\end{thebibliography} +\end{small} +\end{document} diff --git a/code/notes/src/0303-hoare-refinement.tex b/code/notes/src/0303-hoare-refinement.tex new file mode 100644 index 0000000000000000000000000000000000000000..499e341600824a7d94a1ef38614db8bc89384643 --- /dev/null +++ b/code/notes/src/0303-hoare-refinement.tex @@ -0,0 +1,175 @@ +\documentclass[11pt]{article} +\usepackage{common} +\begin{document} +\notehead{0303}{Hoare logic and refinement: specifying and verifying code}{Pre/postconditions, verification conditions, loop invariants, and a verified linear search in Lean~4. Companion notebook: \texttt{notebooks/\allowbreak 0303-hoare-refinement.ipynb}.} + +\begin{problem} +\question{How do we turn ``the code does what it should'' into finitely many statements a prover can check, loops included?} +Notes 0301--0302 certify mathematical statements. A \emph{program} is not a statement: it is an object that does something. To ask a prover to +verify code we must (i) turn ``what the code is supposed to do'' into a \emph{statement} (a specification), (ii) reduce ``the code meets the spec'' +to statements about \emph{finitely many} points of the program (verification conditions), and (iii) handle loops, whose behaviour is an unbounded +sequence of steps, with one finite object: an \emph{invariant}. Hoare logic does (i)--(iii). Refinement is the converse direction: derive code from a spec +so that correctness holds by construction. Every benchmark in note 0306 is, in the end, a Hoare-triple generator and checker. +\end{problem} +\stepcounter{section}% the problem box counts as section 1: keeps every \S and notebook cross-reference stable + +\section{Notation} +\begin{center}\small\begin{tabular}{@{}lP{10.4cm}@{}}\toprule +Symbol & Meaning\\\midrule +$\sigma$ & state: map from program variables to $\Z$\\ +$\sem{e}\sigma$, $\sigma\models B$ & value of expression $e$ in $\sigma$; Boolean assertion $B$ true in $\sigma$\\ +$\langle S,\sigma\rangle\Downarrow\sigma'$ & big-step evaluation: running $S$ from $\sigma$ ends in $\sigma'$\\ +$\hoare PSQ$ & Hoare triple: \emph{partial} correctness $=$ every terminating run from a $P$-state ends in a $Q$-state\\ +$[P]\,S\,[Q]$ & \emph{total} correctness $=$ additionally every run from a $P$-state terminates\\ +$Q[e/x]$ & $Q$ with $e$ substituted for $x$\\ +$\wpc(S,Q)$ & weakest precondition: $\{\sigma\mid\exists\sigma'.\ \langle S,\sigma\rangle\Downarrow\sigma'\wedge\sigma'\models Q\}$ (total)\\ +$I$, $V$ & loop invariant; variant (a $\Z$-valued term, $\ge0$ and strictly decreasing)\\ +$S\sqsubseteq T$ & $T$ refines $S$: $\wpc(S,Q)\subseteq\wpc(T,Q)$ for all $Q$\\ +$x{:}[\mathit{pre},\mathit{post}]$ & specification statement: ``from a $\mathit{pre}$ state, change $x$ so that $\mathit{post}$ holds''\\\bottomrule\end{tabular}\end{center} + +\section{Derivation} +\subsection{Language and semantics} +$S::=\texttt{skip}\mid x:=e\mid S_1;S_2\mid\texttt{if }b\texttt{ then }S_1\texttt{ else }S_2\mid\texttt{while }b\texttt{ do }c$. Evaluation: +\[ +\langle x{:=}e,\sigma\rangle\Downarrow\sigma[x{\mapsto}\sem e\sigma],\qquad +\frac{\sigma\not\models b}{\langle\texttt{while }b\texttt{ do }c,\sigma\rangle\Downarrow\sigma},\qquad +\frac{\sigma\models b\ \ \langle c,\sigma\rangle\Downarrow\sigma'\ \ \langle\texttt{while }b\texttt{ do }c,\sigma'\rangle\Downarrow\sigma''}{\langle\texttt{while }b\texttt{ do }c,\sigma\rangle\Downarrow\sigma''} +\] +(and the obvious rules for \texttt{skip}, sequence, conditional). The language is deterministic: $\Downarrow$ is a partial function. A loop that never ends has no $\sigma''$. + +\subsection{The assignment axiom, derived} +\begin{lemma}[Substitution]\label{lem:sub} $\sigma\models Q[e/x]\iff\sigma[x{\mapsto}\sem e\sigma]\models Q$.\end{lemma} +\emph{Proof.} Induction on $Q$; the atomic case is induction on terms: $\sem{t[e/x]}\sigma=\sem t{\sigma[x\mapsto\sem e\sigma]}$ because a variable $y\neq x$ evaluates identically in both, and $x$ evaluates to $\sem e\sigma$ on the right and to $\sem{e}\sigma$ on the left. Connectives commute with substitution. $\square$ + +\begin{proposition}[Assignment]\label{prop:asg} $\wpc(x{:=}e,Q)=Q[e/x]$, hence $\hoare{Q[e/x]}{x:=e}{Q}$.\end{proposition} +\emph{Proof.} $x{:=}e$ always terminates in $\sigma[x\mapsto\sem e\sigma]$; apply Lemma~\ref{lem:sub}. $\square$\\ +(The easy-to-get-wrong direction is the arrow: the \emph{postcondition} is rewritten, working backwards. The ``forward'' pattern $\{P\}x{:=}e\{P\wedge x=e\}$ is unsound when $x$ occurs in $e$.) + +\subsection{Sequence and conditional} +$\wpc(S_1;S_2,Q)=\wpc(S_1,\wpc(S_2,Q))$: from $\sigma$, $S_1$ ends in a unique $\sigma_1$ and then $S_2$ must end in $Q$, which is $\sigma_1\in\wpc(S_2,Q)$, which is $\sigma\in\wpc(S_1,\wpc(S_2,Q))$. For \texttt{if}: $\wpc=(b\wedge\wpc(S_1,Q))\vee(\neg b\wedge\wpc(S_2,Q))$ by splitting on $\sigma\models b$. The notebook checks both by running 300 random programs on 49 states each: $\wpc$ computed by these syntactic rules coincides with the set obtained by \emph{executing}: 0 mismatches over 14,700 pairs. + +\subsection{Loops: the weakest precondition is a least fixed point} +Let $W=\texttt{while }b\texttt{ do }c$. Unfolding once: $\wpc(W,Q)=(\neg b\wedge Q)\vee(b\wedge\wpc(c,\wpc(W,Q)))$. This is an equation, not a definition, so it does not give a formula. Define the iterates +\[ +W_0=\emptyset,\qquad W_{k+1}=(\neg b\wedge Q)\vee\bigl(b\wedge\wpc(c,W_k)\bigr),\qquad\wpc(W,Q)=\bigcup_kW_k. +\] +$W_k$ is ``terminates in $Q$ after $ false) 10 0} returns \texttt{9}, \texttt{\#eval decide (Post \dots)} returns \texttt{false} (the correct loop returns \texttt{10}, \texttt{true}). FVAPPS \cite{fvapps} \S III.E observes the practical corollary: solutions written with imperative \texttt{Id.run do} loops hide the inductive structure the proof needs, so verifiable code is written as recursion whose shape mirrors its invariant. + +\subsection{Integers: when the spec lives in $\Z$ and the code in $\mathrm{int}_{w}$} +Binary search computes $\mathit{mid}=(lo+hi)/2$ with VC $lo\le hi\Rightarrow lo\le\mathit{mid}\le hi$. This is valid over $\Z$. Over $w$-bit unsigned words, $lo+hi$ wraps to $lo+hi-2^w$ when $lo+hi\ge2^w$, so $\mathit{mid}=\lfloor(lo+hi-2^w)/2\rfloor40\%$ of specs against $<4\%$ proved (App.~C.5, Table~5) -- the asymmetry in numbers. + +\subsection{Random testing: what a negative result is worth} +Let tests be i.i.d.\ draws $X_1,X_2,\dots\sim\mathcal D$ and $p=\Pr[\neg\varphi(X)]>0$. Then +\[ +\Pr[\text{no failure in }N\text{ tests}]=(1-p)^N,\qquad T=\min\{t:\neg\varphi(X_t)\}\sim\mathrm{Geometric}(p),\ \ \mathbb E[T]=1/p. +\] +\emph{Derivation.} Independence gives $\Pr[\varphi(X_1)\wedge\cdots\wedge\varphi(X_N)]=\prod_t\Pr[\varphi(X_t)]=(1-p)^N$; $\Pr[T=t]=(1-p)^{t-1}p$; $\mathbb E[T]=\sum_{t\ge1}t(1-p)^{t-1}p=1/p$ (differentiate $\sum(1-p)^t=1/p$). Requiring $(1-p)^N\le\delta$: +\[ +N\ \ge\ \frac{\ln\delta}{\ln(1-p)}\ \approx\ \frac{\ln(1/\delta)}{p}\qquad(p\ll1;\ \text{error: }\ln(1-p)=-p-p^2/2-\cdots,\ \text{relative error }O(p)). +\] +Consequences. A $1$-in-$1024$ bug needs $N=4714$ tests for $\delta=0.01$; a $1$-in-$2^{32}$ bug (a single bad 32-bit input) needs $N\approx2.97\times10^{10}$ for $\delta=10^{-3}$: no budget finds it, a proof or a solver does. A negative test run is therefore a statement about $p$: with $N$ clean tests the one-sided $1-\delta$ upper confidence bound is $p\le1-\delta^{1/N}\approx\ln(1/\delta)/N$ (the ``rule of three'' $3/N$ at $\delta=0.05$; with a uniform prior the posterior bound is $1-\delta^{1/(N+1)}$); it is \emph{never} $p=0$. +\textbf{Where $p$ comes from matters.} Uniform sampling detects a needle anywhere with probability $1-(1-1/K)^N\approx N/K$. If bugs cluster at small inputs (the \emph{small-scope hypothesis}, an empirical claim, not a theorem \cite{jackson}), enumeration $0,1,\dots,N-1$ detects iff $x_0 false) 10 (searchBadF (fun _ => false) 10 20 0) := by decide +theorem searchBadF_not_correct : + ¬ (∀ (P : Nat → Bool) (len : Nat), Post P len (searchBadF P len len 0)) := fun h => by + have := h (fun _ => false) 10 + revert this + decide +#print axioms searchBadF_not_correct -- does not depend on any axioms +\end{lstlisting} +This is Theorem~\ref{thm:asym}(ii) executed: the counterexample \emph{is} a proof of $\neg S$, with an empty axiom set. (For well-founded recursion \texttt{decide} cannot unfold; \texttt{\#eval} still confirms, but is evidence, not a kernel certificate.) + +\subsection{A real example: a soundness bug found by a failed proof} +Carneiro's Lean4Lean \cite{lean4lean} \S4.1: to verify \texttt{inferLambda} one must prove that \texttt{cheapBetaReduce} preserves definitional equality; that needs \texttt{hasLooseBVars} (defined as \texttt{looseBVarRange > 0}); \texttt{looseBVarRange} is read from a 20-bit field of a 64-bit \texttt{e.data}. The proof could not be completed because ``what happens on overflow?'' had no good answer: the overflow check used \texttt{panic!}, which \emph{continues with the default value}, $0$ for \texttt{UInt64}, ``the worst possible answer in this case, since it effectively turns on all of the optimizations''. The author describes it as ``a bug that was found entirely theoretically, by failing to prove a theorem and working backwards to an actual exploitable soundness bug'', reported and fixed. In the language of this note: failed proof $\to$ locate the unproved side-condition $\to$ ask what input falsifies it $\to$ reachable? yes $\to$ evaluate $\to$ confirmed. The paper also records the sobering part: the fix (making \texttt{Expr.data} opaque) ``at most masks the performance''. + +\section{Boxed result} +\begin{result} +\textbf{R1.} For $S=\forall x.\varphi(x)$, $\varphi$ decidable: proof $\Rightarrow$ true; counterexample $c\Rightarrow\vdash\neg S$ (certain); no proof and no counterexample $\Rightarrow$ nothing (G\"odel; finite budgets).\\ +\textbf{R2.} $\Pr[\text{miss after }N]=(1-p)^N$; $\mathbb E[T]=1/p$; $N\ge\ln\delta/\ln(1-p)\approx\ln(1/\delta)/p$.\\ +\textbf{R3.} Shrinking with a strictly decreasing well-founded measure terminates in a \emph{local} minimum (not necessarily global).\\ +\textbf{R4.} Bug witness $\iff P(\sigma_0)\wedge\mathrm{run}(S,\sigma_0)\not\models Q$; a violated VC proves something only if its state is reachable (preserve: $I$ is not invariant; exit: $Q$ fails in a real run), and a witness is certified by evaluation; \texttt{decide} turns a witness into a kernel-checked $\neg S$.\\ +\textbf{R5.} \texttt{omega}'s failure message is a genuine countermodel only if the goal has no abstracted non-linear atoms \emph{and} the procedure is complete; on this pin it is not (Pugh's system), so the message is a lead. +\end{result} + +\paragraph{Numerical verification (notebook 0304, all cells executed).} +\begin{center}\small\begin{tabular}{@{}P{7.6cm}P{4.3cm}P{3.6cm}@{}}\toprule +Identity & measured vs predicted & error\\\midrule +R2: exact $p$ by enumeration (19,531 lists; 19,205 failing) & $p=0.498514$ & exact\\ +R2: mean tests to failure, 20,000 runs & $1.992\pm0.010$ vs $1/p=2.006$ & $1.43$ s.e.\\ +R2: $P(\text{miss after }N)$, $N\in\{1,5,10,20,40,80\}$ & vs $(1-p)^N$ & worst $|z|=1.59$\\ +R2 (needle $p=1/1024$): mean tests; $N\in\{256,\dots,4096\}$ & $1015.6\pm18.3$ vs $1024$; worst $|z|=0.89$ & $<1$ s.e.\\ +B: enumeration vs random on clustered needle & $0.9823$ vs theory $0.9824$ & $1\times10^{-4}$\\ +R3: shrinks always fail and are locally minimal; global minimum reached & 2000/2000; $401/2000$ global & --- (reported, not asserted)\\ +R4: Lean \texttt{searchBadF\_not\_correct} & kernel-checked, axioms $\emptyset$ & ---\\ +R1: \texttt{slim\_check} on a true goal & ``Success'' + \texttt{sorry}; $\mathrm{Ax}=\{\texttt{sorryAx}\}$ & gate rejects\\ +R4/Lemma~\ref{lem:vc}: weakened VC, exhaustive scope ($P\in2^{4},\,len\le4,\,n\le len$) & 111 VC counterexamples, 0 reachable (Python $=$ Lean count $111$); 129 reachable states, 0 violate & 0\\ +R4: exhaustive scope, Python vs Lean \texttt{\#eval}: buggy loop violations & $15=15$; correct loop $0=0$ & 0\\ +R4/Lemma~\ref{lem:vc}(b,c): sum program, 4 scenarios (weak $I$; wrong $Q$; buggy code; too-strong $I$), VC violations classified by reachability & exit viol.\ 504/0 reachable, 7/7, preserve 28/7, 2/2 & lemma holds in all 4\\ +R5: \texttt{omega} on $n\le n\cdot n$; exhaustive $n<10^5$ & fails; no counterexample exists & spurious\\ +R5: \texttt{omega} on Pugh's system (no atoms); real polytope non-empty, no integer point in its bounding box & fails with ``possible counterexample'' & spurious (incomplete)\\\bottomrule\end{tabular}\end{center} + +\section{What breaks} +\begin{center}\small\begin{tabular}{@{}P{3.6cm}P{6.4cm}P{5.5cm}@{}}\toprule +Failure & Mechanism & Assumption violated\\\midrule +Treating ``no counterexample'' as proof & $(1-p)^N$ never reaches $0$; \texttt{slim\_check} returns \texttt{sorry} & $p$ is large enough for the budget\\ +Counterexample to the statement, not the code & $a-b+b=a$ fails at $(0,2)$; the spec forgot $b\le a$ & the statement equals the intended spec\\ +Spurious VC counterexample & weak invariant: unreachable state (111/111 unreachable in the scope) & the VC's quantified state is reachable\\ +Abstraction / incompleteness in the tool & \texttt{omega} atomises $n\cdot n$; and (no dark/grey shadows) fails on integer-infeasible but real-feasible linear systems & the goal is in the fragment \emph{and} the procedure is complete for it\\ +Non-uniform bug distributions & uniform tests miss boundary bugs by factors of $>300$ (B) & the generator matches where bugs live\\ +Generator coverage & \texttt{slim\_check} discards samples that violate guards and samples functions and large structures shallowly & the generator reaches the failing region\\ +Local-minimum shrinking & reported counterexample not minimal & shrinker explores enough neighbours\\ +Non-determinism / environment & a ``bug'' that depends on time, hash seeds, floating point order & the code under test is a deterministic function of its inputs\\ +\texttt{decide} limits & wf-recursive or large functions cannot be kernel-evaluated; \texttt{native\_decide} would widen the trusted base (0301) & the computation is kernel-feasible\\\bottomrule\end{tabular}\end{center} + +\nextnote{0305: the opposite failure. When a proof \emph{succeeds} but the statement was wrong: LLM-written specs, spec validation by tests and mutation, and the ``proof of the wrong theorem'' (statement rewrite) finding in this repository.} + +\begin{small} +\begin{thebibliography}{99} +\bibitem{lean4lean} M.~Carneiro, \emph{Lean4Lean}, arXiv:2403.14064, \S4.1 ``Bit tricks and soundness bugs'' (quotations above are from this section). +\bibitem{verina} Z.~Ye et al., \emph{VERINA}, arXiv:2505.23135, \S4.1 (soundness/completeness of $\hat P,\hat Q$; evaluator of Fig.~3: prove, then \texttt{decide}, then \texttt{plausible}; outcomes ``R holds / does not hold / might hold / unknown''), Appendix~C.5 (Lean implementation; Table~5: proofs $<4\%$ vs testing $>40\%$ on generated specs; the 20 manually inspected disagreements were all correct). +\bibitem{fvapps} Q.~Dougherty, R.~Mehta, \emph{FVAPPS}, arXiv:2502.05714, \S II-B/C: stage 5 filters theorem statements with Lean's property-based testing framework Plausible (``probably true''); 1083 of 4715 samples pass all five stages. +\bibitem{godel31} K.~G\"odel (1931), first incompleteness theorem. \emph{Not on arXiv; not fetched; classical, cited for the named theorem.} +\bibitem{presburger} M.~Presburger (1929), decidability of linear integer arithmetic. \emph{Not on arXiv; not fetched; classical.} +\bibitem{pugh91} W.~Pugh, ``The Omega test'', CACM 1991. \emph{Not on arXiv; not fetched; classical.} +\bibitem{jackson} D.~Jackson, \emph{Software Abstractions} (Alloy; the small-scope hypothesis) and K.~Claessen, J.~Hughes, ``QuickCheck'', ICFP 2000. \emph{Not on arXiv; not fetched. The small-scope hypothesis is empirical; the notebook only quantifies what it buys if true.} +\end{thebibliography} +\end{small} +\end{document} diff --git a/code/notes/src/0305-spec-risk.tex b/code/notes/src/0305-spec-risk.tex new file mode 100644 index 0000000000000000000000000000000000000000..99193104a6c16efa825d8b6e3cdeed9aa83ce943 --- /dev/null +++ b/code/notes/src/0305-spec-risk.tex @@ -0,0 +1,155 @@ +\documentclass[11pt]{article} +\usepackage{common} +\begin{document} +\notehead{0305}{Spec generation risk: the proof of the wrong theorem}{An LLM-written specification can be wrong in ways the kernel cannot see. Spec validation by tests and mutation, the Bayes risk of accepted proofs, and this repository's statement-rewrite finding. Companion notebook: \texttt{notebooks/}\allowbreak\texttt{0305-spec-risk.ipynb}.} + +\begin{problem} +\question{When the statement itself is model output, how can ``the proof checks'' and ``the code is correct'' come apart, and how do we detect it?} +Notes 0301--0304 establish what a checked proof guarantees: the statement \emph{as typed} is true. In verifiable code generation the statement itself is model output (a postcondition, +a theorem about the function) or is mediated by a harness (a rewritten or swapped statement). Then ``the proof checks'' and ``the code is correct'' come apart, and the gap is invisible to the kernel and to the reward. +We need (i) a precise notion of \emph{how} a spec can be wrong, (ii) cheap tests that expose it, (iii) a number for the damage, and (iv) gates that keep the statement honest. +\end{problem} +\stepcounter{section}% the problem box counts as section 1: keeps every \S and notebook cross-reference stable + +\section{Notation} +\begin{center}\small\begin{tabular}{@{}lP{10.5cm}@{}}\toprule +Symbol & Meaning\\\midrule +$P,Q$ & ground-truth pre- and post-condition ($P(x)$, $Q(x,y)$); $\hat P,\hat Q$ generated ones\\ +$\phi=\{f\mid\forall x.\,P(x)\Rightarrow Q(x,f\,x)\}$, $\hat\phi$ & programs meeting the true / generated spec\\ +sound, complete & $\hat Q$ sound: $\forall x\,y.\ P(x)\wedge\hat Q(x,y)\Rightarrow Q(x,y)$; complete: $\forall x\,y.\ P(x)\wedge Q(x,y)\Rightarrow\hat Q(x,y)$ (VERINA \S4.1)\\ +$\mathrm{Pos},\mathrm{Neg}$ & positive tests $\{(x,y):P\wedge Q\}$; negative tests $\{(x,y):P\wedge\neg Q\}$\\ +$\kappa(M)$ & fraction of input/output pairs on which mutant $M$ differs from the truth\\ +$u$ & probability a generated spec is wrong; $a_c,a_w$ prover success on correct / wrong specs; $r=a_w/a_c$\\\bottomrule\end{tabular}\end{center} + +\section{Derivation} +\subsection{Two kinds of ``wrong spec'', and the one that hides} +Work at the level of relations $\hat Q\subseteq X\times Y$. Soundness says $\hat Q\cap P\subseteq Q$ (the spec accepts only correct outputs); completeness says $Q\cap P\subseteq\hat Q$ (it accepts all of them). An unsound spec admits wrong programs (too weak); an incomplete one rejects correct programs (too strong). \emph{Program level:} $\hat\phi\subseteq\phi$ is the claim we care about. +\begin{lemma}[Relation vs program level]\label{lem:rel} +If $\hat Q$ is sound then $\hat\phi\subseteq\phi$. Conversely, if $\hat\phi\subseteq\phi$ and $\hat Q$ is \emph{total on $P$} ($\forall x.\ P(x)\Rightarrow\exists y.\ \hat Q(x,y)$) then $\hat Q$ is sound. +\end{lemma} +\emph{Proof.} First part: for $f\in\hat\phi$ and $P(x)$ we have $\hat Q(x,f\,x)$, hence $Q(x,f\,x)$ by soundness. Second: let $P(x_0)\wedge\hat Q(x_0,y_0)$. Build $f$ with $f(x_0)=y_0$ and, for $x\neq x_0$ with $P(x)$, $f(x)$ any $y$ with $\hat Q(x,y)$ (totality); elsewhere arbitrary. Then $f\in\hat\phi\subseteq\phi$, so $Q(x_0,y_0)$. $\square$ + +The totality hypothesis is not decoration. If $\hat Q(x_0,\cdot)=\emptyset$ for some $x_0$ with $P(x_0)$, the spec is \emph{unrealisable}: $\hat\phi=\emptyset$, so $\hat\phi\subseteq\phi$ holds vacuously while no implementation can ever be proved correct. The failure then shows up as an unprovable theorem, which (note 0304) is indistinguishable from a weak prover: \textbf{an unrealisable spec is blamed on the model}. Test: $\forall x.\,P(x)\Rightarrow\exists y.\,\hat Q(x,y)$ over a scope. + +\subsection{Spec validation by tests (the VERINA reduction)} +\begin{proposition}[Soundness and completeness are decided by negative and positive tests]\label{prop:tests} +(a) $\hat Q$ is complete iff $\hat Q(x,y)$ holds on every positive pair. (b) $\hat Q$ is sound iff $\hat Q(x,y)$ is false on every negative pair $(P(x),\neg Q(x,y))$. (c) Pre-conditions: $\hat P$ sound ($P\Rightarrow\hat P$) iff $\hat P$ holds on every $x$ with $P(x)$; complete ($\hat P\Rightarrow P$) iff $\hat P$ fails on every $x$ with $\neg P(x)$. +\end{proposition} +\emph{Proof.} (a) completeness is $\forall(x,y).\,(P\wedge Q)\Rightarrow\hat Q$, which is exactly ``$\hat Q$ on all positives''. (b) soundness is $\forall(x,y).\,(P\wedge\hat Q)\Rightarrow Q$, equivalently $\forall(x,y).\,(P\wedge\neg Q)\Rightarrow\neg\hat Q$. (c) identical with $x$ alone. $\square$\\ +This is the reduction implemented by VERINA's evaluator: ground-truth specs pass all positive tests and fail all negative ones by construction, so on a \emph{finite} suite \cite{verina} \S4.1 and Appendix~C.5 test ``$\hat Q$ sound'' on negative tests and ``$\hat Q$ complete'' on positive tests. Their worked example is a pre-condition: truth \texttt{k < s.size}; \texttt{k < s.size - 1} is refuted as unsound by the positive test \texttt{(s=[1,2,3,4,5],k=4)}, and \texttt{k < s.size + 1} as incomplete by the negative test \texttt{k=5}. A finite suite is \emph{one-sided}: a violated test certifies the defect; a passed suite yields only ``might hold''. + +\paragraph{How good is a random suite?} If a defect is witnessed by a fraction $\kappa$ of the candidate negative (or positive) pairs, $m$ independent random tests miss it with probability $(1-\kappa)^m$ (0304 R2), so $m\ge\ln\delta/\ln(1-\kappa)$ tests are needed. In the notebook's toy domain (40 lists over $\{0,1,2\}$, length $\le3$; 1560 negative pairs; $|\mathrm{Pos}|=40$): +\begin{center}\small\begin{tabular}{@{}lrrrr@{}}\toprule +Candidate postcondition & sound & complete & accepted negatives & rejected positives\\\midrule +Q0 \; sorted $\wedge$ perm (truth) & yes & yes & 0 & 0\\ +S1 \; sorted only & \textbf{no} & yes & 760 ($\kappa=.487$) & 0\\ +S2 \; sorted $\wedge$ perm $\wedge$ len$\le2$ & yes & \textbf{no} & 0 & 27\\ +S3 \; sorted, same length, same sum & \textbf{no} & yes & 22 ($\kappa=.0141$) & 0\\ +S4 \; strictly sorted $\wedge$ perm & yes & \textbf{no} & 0 & 24\\ +S5 \; sorted $\wedge$ $y=\mathrm{sorted}(x)$ (leaks) & yes & yes & 0 & 0\\ +S6 \; perm only & \textbf{no} & yes & 72 & 0\\ +S7 \; sorted, same length, $y\subseteq x$ & \textbf{no} & yes & 120 & 0\\\bottomrule\end{tabular}\end{center} +(Here $\kappa$ is the fraction of the 1560 \emph{negative} pairs a defect is witnessed on, because a soundness defect is exposed by drawing from the negatives; the mutation table below draws from all 1600 pairs, so the same predicate S1 $=$ ``drop perm'' has $\kappa=760/1600=.475$ there.) S1 is exposed by about $\ln(0.01)/\ln(1-0.487)\approx7$ random negatives; the subtle S3 needs $\approx325$. The definition-based verdict (all 1600 pairs) and the test-based one agree on all 8 candidates. S5 is \emph{correct} yet useless as a spec: it \emph{is} the implementation (\S3.5). +\emph{Programs passing wrong specs.} Seven candidate programs: the empty list, dedup-sort, clamp-then-sort, ``sort then zero the first element'' all satisfy S1 (4 wrong programs accepted); the identity and the descending sort satisfy S6 (2 accepted); none satisfies Q0. This is the proof of the wrong theorem in miniature: ``verified against S1'' is true of programs that are plainly wrong. + +\subsection{Mutation testing of a spec} +Mutate the truth $Q_0=\mathrm{sorted}(y)\wedge\mathrm{perm}(x,y)$: drop an atom; replace $\le$ by $<$ or $\ge$; replace perm by same-length, same-sum, subset; add a redundant conjunct. A mutant is \emph{killed} by a suite iff some test distinguishes it from $Q_0$. For uniform tests, $\Pr[\text{kill}]=1-(1-\kappa(M))^m$; $\kappa=0$ means an \emph{equivalent mutant}, unkillable by any suite. +\begin{center}\small\begin{tabular}{@{}lrrr@{}}\toprule +Mutant & $\kappa$ & $\Pr[\text{kill}],m{=}10$ theory & Monte Carlo (20,000 suites)\\\midrule +drop sorted & .0450 & .3690 & .3715\\ +drop perm & .4750 & .9984 & .9986\\ +$\le\to<$ & .0150 & .1403 & .1448\\ +$\le\to\ge$ & .0375 & .3176 & .3192\\ +perm $\to$ same length & .1837 & .8687 & .8689\\ +perm $\to$ same sum & .0600 & .4614 & .4608\\ +perm $\to$ subset & .2231 & .9199 & .9199\\ +add \texttt{len(y)<=2} & .0169 & .1565 & .1540\\ +add redundant \texttt{len(x)=len(y)} & 0 & 0 & 0 (equivalent)\\ +perm $\to$ $y=\mathrm{sorted}(x)$ & 0 & 0 & 0 (equivalent)\\\bottomrule\end{tabular}\end{center} +The exhaustive suite kills all 8 non-equivalent mutants; the Monte Carlo worst $|z|$ is $1.84$. The practical reading: a spec whose mutants survive a test suite is not pinned down by that suite. Clover \cite{clover} uses the same idea at the level of consistency between code, annotation and docstring and reports acceptance up to 87\% of correct instances with no false positives on adversarial ones, and found 6 incorrect programs in a human-written dataset (abstract). + +\subsection{Six mechanisms of ``proof of the wrong theorem'' (with Lean evidence)} +\paragraph{(a) Weak spec.} +\begin{lstlisting} +def badSort (xs : List ℕ) : List ℕ := [] +theorem badSort_weak_spec (xs : List ℕ) : List.Sorted (· ≤ ·) (badSort xs) := List.sorted_nil +theorem badSort_not_perm : ¬ (∀ xs : List ℕ, List.Perm xs (badSort xs)) -- proved via length_eq +\end{lstlisting} +The weak-spec theorem holds with no axioms; the refutation of the full spec uses $\{\texttt{propext}\}$. VERINA reports concrete LLM failures of this kind: an \emph{unsound} postcondition that does not exclude leading zeros, and one that mishandles $n=0$ in the ``smallest single-digit prime factor'' task, so that it rejects a valid positive test and accepts a negative test \cite{verina} Appendix~E, Figs.~22--23. +\paragraph{(b) Leaking spec.} A computable spec that restates the recursion is proved by \texttt{rfl}-level reasoning (notebook B2: \texttt{leak\_proof}); a relational spec (\texttt{IsFib}) forces a real induction (\texttt{myfib\_isfib}). CLEVER \cite{clever} Fig.~2--3 uses non-computable specs for exactly this reason, and strengthens the protocol: the model must also \emph{prove its generated spec equivalent to a held-out ground-truth spec} (QED~1) before the implementation proof (QED~2) counts. +\paragraph{(c) Vacuous hypotheses.} +\begin{lstlisting} +theorem vac_stmt (n : ℕ) (h₀ : n < 5) (h₁ : n > 7) : n = 12345 := by omega +\end{lstlisting} +compiles. A satisfiability probe \texttt{decide (∃ n < 100, n < 5 ∧ n > 7)} is \texttt{false}; for the consistent pair (\texttt{n < 10}) it is \texttt{true}. Audits of miniF2F and its relatives report ``vacuous theorems'' among their 398 mechanically certified issues \cite{faults26}; ProofGate's audit adds a negation-counterexample probe \cite{proofgate}. +\paragraph{(d) Statement rewrite (our finding).} In this repository's P2 pipeline (\texttt{docs/P2.md}, \S8c item 18) verification ported Goedel's \texttt{problem\_check}, which \emph{swaps the original statement in} and re-runs the candidate's tactics; ``a completion whose displayed theorem is false or different is ACCEPTED whenever its tactics prove the original''. The notebook reproduces it against four gates on \texttt{mathd\_algebra\_478} (reference conclusion \texttt{v = 65}): +\begin{center}\footnotesize\begin{tabular}{@{}P{3.7cm}P{1.5cm}P{2.1cm}P{3.3cm}P{3.3cm}@{}}\toprule +Candidate (displayed statement) & compile-only & swap & strict & defeq\\\midrule +C0 honest & accept & accept & accept & accept\\ +C1 displays \texttt{v = 66}, tactics prove 65 & reject & \textbf{accept} & reject (\texttt{statement\_\allowbreak mismatch}) & reject (\texttt{compile\_\allowbreak error})\\ +C2 weakened to \texttt{v ≤ 65} & \textbf{accept} & \textbf{accept} & reject (\texttt{statement\_\allowbreak mismatch}) & reject (\texttt{bridge\_\allowbreak failed})\\ +C3 extra false hypothesis \texttt{v < 0} & \textbf{accept} & reject (tactics name \texttt{h₄}) & reject & reject (\texttt{bridge\_\allowbreak failed})\\ +C4 binders renamed $b\to b_1$ & accept & accept & reject (\texttt{statement\_\allowbreak mismatch}) & accept\\\bottomrule\end{tabular}\end{center} +Compile-only credits the \emph{easier theorems} C2 and C3 (C3 is vacuous). The swap harness credits C1 and C2 (the displayed statement is not what was proved). The strict tier (token-equal signature \emph{and} the kernel bridge: the kernel must accept the candidate as a term of the \emph{type of the reference}; \texttt{docs/LEAN\_VERIFIER.md} \S2) rejects all four rewrites; the defeq mode accepts only the genuine $\alpha$-renaming. In training the finding is mitigated: \texttt{build\_sft\_data.py} now drops statement-unfaithful samples; the evaluation keeps Goedel's rule so numbers stay comparable (an explicit, documented trade-off). +\paragraph{(e) Silent elaboration.} \texttt{theorem t (x : ℕ) : x + 1 > xx} elaborates to \texttt{∀ \{xx : ℕ\} (x : ℕ), x + 1 > xx} under \texttt{autoImplicit}, and is an error with it off (0302). Numeral types and truncated subtraction (0302) are the same class. +\paragraph{(f) Unrealisable specs.} \S3.1: no program can meet it; every attempt fails and the blame lands on the prover. + +\subsection{How much damage: the Bayes risk of an accepted proof} +Suppose a generated spec is wrong with probability $u$; the prover succeeds with probability $a_c$ on a correct spec and $a_w$ on a wrong one. Then +\[ +\Pr[\text{wrong}\mid\text{accepted}]=\frac{u\,a_w}{u\,a_w+(1-u)\,a_c}=\frac{u\,r}{u\,r+1-u},\qquad r=\frac{a_w}{a_c}. +\] +\emph{Proof.} Bayes' rule with $\Pr[\text{accepted}\mid\text{wrong}]=a_w$, $\Pr[\text{accepted}\mid\text{correct}]=a_c$. $\square$\\ +$r$ is an empirical ratio, not a theorem. For \emph{too-weak} wrong specs $r\ge1$ is plausible (if a program meets $s$ and $s\Rightarrow s'$ it meets $s'$, so $s'$ is semantically easier), but that is a statement about programs: a real prover need not find a proof of the weaker statement more often. And \emph{too-strong} (incomplete) wrong specs are harder, with $r<1$; $a_w$ is an average over both kinds, so $r\ge1$ holds only if weak specs dominate the wrong ones. Anchor: VERINA's best model produces a spec that is both sound and complete in $52.3\%$ of tasks \cite{verina} (abstract), i.e.\ $u\approx0.477$ if every non-passing spec is wrong (an upper estimate: some are ``unknown''). With $r=5$: $\Pr=0.82$; with $r=20$: $0.948$. Monte Carlo (200,000 draws per row): $0.8198$ vs $0.8202$; $0.9480$ vs $0.9480$; worst $|z|=1.72$ over 7 settings. +\begin{proposition}[Degenerate optimum]\label{prop:degen} +Let the reward be $\mathbf 1[\text{accepted proof}]$, the policy choose the specification $s$ as well as the program and proof, and let the prover's acceptance probability for the specification \texttt{True} be $a_{\mathtt{True}}$ (a proof is \texttt{trivial}). Then \texttt{True} maximises the expected reward over specifications whenever $a_{\mathtt{True}}\ge a(s)$ for all $s$; in particular when $a_{\mathtt{True}}=1$. +\end{proposition} +\emph{Proof.} Expected reward for spec $s$ is $a(s)\le1$; for $s=\mathtt{True}$ it is $a_{\mathtt{True}}$. $\square$\\ +(The point is that the semantic ordering $s\Rightarrow s'$ gives \emph{program}-level monotonicity ($f\models s\Rightarrow f\models s'$) but not monotonicity of a learned prover's success; the proposition therefore rests on \texttt{True} being trivially provable, which is an assumption we state, not a consequence of the lattice.)\\ +This is the formal reason that reward for ``verified'' must be tied to a \emph{fixed} statement (the strict bridge), or to a spec the model must prove equivalent to a held-out one (CLEVER), or to tests that bound $\hat Q$ from both sides (VERINA). + +\section{Boxed result} +\begin{result} +\textbf{R1.} $\hat Q$ complete $\iff$ true on all positives; sound $\iff$ false on all negatives; relation-level soundness $\Rightarrow$ program-level, converse if $\hat Q$ is total on $P$ (unrealisable specs break it).\\ +\textbf{R2.} A defect with witness fraction $\kappa$ survives $m$ uniform tests with probability $(1-\kappa)^m$; $\kappa=0$ is an equivalent mutant.\\ +\textbf{R3.} $\Pr[\text{wrong}\mid\text{accepted}]=u r/(u r+1-u)$, $r=a_w/a_c\ge1$.\\ +\textbf{R4.} With a policy-chosen spec the reward optimum is the weakest spec. Statement-integrity gates form a \emph{partial} order, not a chain: compile-only and swap are incomparable (C1 passes only swap, C3 only compile-only); defeq (bridge) is stricter than both on these candidates; strict (tokens $+$ bridge $+$ axioms) is stricter than defeq. +\end{result} + +\paragraph{Numerical verification (notebook 0305, all cells executed).} +\begin{center}\small\begin{tabular}{@{}P{8.2cm}P{4.1cm}P{3.2cm}@{}}\toprule +Identity & result & error\\\midrule +R1: definition vs test-based verdict, 8 candidates $\times$ 1600 pairs & 8/8 identical & 0\\ +R2: kill probability $1-(1-\kappa)^{10}$, 10 mutants $\times$ 20,000 suites & worst $|z|=1.84$ & $\le1.8$ s.e.\\ +R2: equivalent mutants detected as $\kappa=0$ & 2 of 10 & 0\\ +R3: Bayes formula, 7 settings $\times$ 200,000 draws & worst $|z|=1.72$ & $\le1.7$ s.e.\\ +(a) Lean: \texttt{badSort} passes weak spec; fails perm & axioms $\emptyset$ / $\{\texttt{propext}\}$ & ---\\ +(c) vacuity probe: Lean \texttt{decide} vs Python & \texttt{false}/\texttt{true} both agree & 0\\ +(d) four gates $\times$ five candidates (real \texttt{StrictVerifier}) & table above, as predicted 20/20; accepted sets compile $\{$C0,C2,C3,C4$\}$, swap $\{$C0,C1,C2,C4$\}$, defeq $\{$C0,C4$\}$, strict $\{$C0$\}$ & incomparable pair asserted\\\bottomrule\end{tabular}\end{center} + +\section{What breaks} +\begin{center}\small\begin{tabular}{@{}P{3.5cm}P{6.4cm}P{5.5cm}@{}}\toprule +Failure & Mechanism & Assumption violated\\\midrule +Tests as proof & a passing suite gives only ``might hold''; defects with small $\kappa$ need many tests & the suite covers the defect region (VERINA's suites reach 100\% code coverage of the ground truth, not of $\hat Q$)\\ +Equivalent mutants & unkillable, inflate the denominator of a mutation score & mutants are non-equivalent (check $\kappa>0$ exhaustively where possible)\\ +Tests derived from the same model & an LLM that writes spec \emph{and} tests shares the misconception & independence of spec and tests\\ +Bounded scope & exhaustive checks only cover the scope & small-scope hypothesis (0304)\\ +Judge LLM audits & recall-oriented semantic audits are noisy: the paper's LLM-assisted semantic audit has precision $0.30$ and recall $0.82$ for Claude Sonnet~4.5 with thinking (\cite{faults26}, Table~6; GPT-5.2 with thinking: $0.24$/$0.91$) & the auditor is calibrated\\ +Spec leakage & S5-like specs are correct but make the proof trivial and prove nothing about reasoning & the spec is not the implementation\\ +Harness trust & swap harness and compile-only gate credit different theorems (table (d)) & the proved statement equals the reference\\ +Formal $\ne$ informal & even with a perfect bridge, the \emph{reference} may mis-state the problem: over half of the 488 miniF2F statements have a formal/informal mismatch, 16 are unprovable \cite{minif2frev} & the reference statement is faithful\\\bottomrule\end{tabular}\end{center} + +\nextnote{0306 surveys the benchmarks that operationalise all of this (VERINA, CLEVER, FVAPPS, DafnyBench, miniCodeProps): what each measures, how it validates its specs, and how their numbers should (and should not) be compared.} + +\begin{small} +\begin{thebibliography}{99} +\bibitem{verina} Z.~Ye et al., \emph{VERINA}, arXiv:2505.23135: abstract (72.6\% code, 52.3\% spec soundness+completeness, 4.9\% proof); \S3.2 (``Preventing trivial code generation''); \S4.1 (definitions, Fig.~3 evaluator); \S4.2 (proof of the \emph{ground-truth} spec prevents definitionally equivalent code/spec); Appendix~C.5 (Table~5); Appendix~E (Figs.~22--23, unsound/incomplete LLM postconditions). +\bibitem{clever} A.~Thakur et al., \emph{CLEVER}, arXiv:2505.13938: abstract; Fig.~1 (two QEDs: spec isomorphism then implementation); Fig.~2--3 and Appendix~A.1 (spec leakage, non-computable specs). +\bibitem{clover} C.~Sun et al., \emph{Clover: Closed-Loop Verifiable Code Generation}, arXiv:2310.17807, abstract (consistency checks among code, docstrings, annotations; 87\% acceptance, no false positives on adversarial instances). +\bibitem{minif2frev} M.~Ospanov, F.~Farnia, R.~Yousefzadeh, \emph{miniF2F-Lean Revisited}, arXiv:2511.03108 (formal/informal mismatches in over half of the 488 problems; 16 unprovable statements). +\bibitem{faults26} P.~S.~Ammanamanchi, S.~Bhat, S.~Biderman, \emph{Faults in Our Formal Benchmarking}, arXiv:2606.29493 (ICML 2026), abstract (4,833 findings; 398 mechanically certified: counterexamples, vacuous theorems, unsound axioms); Table~6 (semantic-audit precision/recall). +\bibitem{proofgate} ProofGate: a reproducible audit of faithfulness, alignment and vacuity in state-of-the-art Lean theorem provers (ICML 2026 AI-for-Math workshop poster, \texttt{icml.cc/virtual/2026/82433}); four-check audit incl.\ a negation-counterexample probe. Workshop poster, not arXiv; read via the vault note. +\bibitem{house} This repository: \texttt{align/docs/P2.md} \S8c item 18 (statement rewrite), \texttt{align/docs/LEAN\_VERIFIER.md} \S2 (strict tier, statement bridge) and \S6 item 3 (statement fidelity). +\end{thebibliography} +\end{small} +\end{document} diff --git a/code/notes/src/0306-benchmarks-landscape.tex b/code/notes/src/0306-benchmarks-landscape.tex new file mode 100644 index 0000000000000000000000000000000000000000..6aaebcfad874a8fc8cc12da1e722c063d0909208 --- /dev/null +++ b/code/notes/src/0306-benchmarks-landscape.tex @@ -0,0 +1,140 @@ +\documentclass[11pt]{article} +\usepackage{common} +\begin{document} +\notehead{0306}{Verified-code benchmarks: what each measures and how to read the numbers}{VERINA, CLEVER, FVAPPS, DafnyBench, miniCodeProps, from their papers: which obligations each discharges by machine and which it assumes, and the arithmetic (pass@$k$, intervals, composite bounds) a headline number needs. Companion notebook: \texttt{notebooks/}\allowbreak\texttt{0306-benchmarks-landscape.ipynb}.} + +\begin{problem} +\question{What does each verified-code benchmark's headline number actually certify, and how do we turn it into a claim with an error bar?} +Five benchmarks report ``verified code generation'' numbers from $0.6\%$ to $68\%$. They are not comparable on their face: they ask for different things (write a proof; write a spec; write the code), take the specification from different places (given, hidden, generated and tested), use different checkers (Lean kernel, Dafny/Z3, tests plus property tests), and report different statistics. We need (i) a common frame that says what a ``success'' certifies, and (ii) the arithmetic that turns a published number into a claim with an error bar. Notes 0301--0305 supply the ingredients; this note applies them. +\end{problem} +\stepcounter{section}% the problem box counts as section 1: keeps every \S and notebook cross-reference stable + +\section{Notation} +\begin{center}\small\begin{tabular}{@{}lP{10.6cm}@{}}\toprule +Symbol & Meaning\\\midrule +$\nu,\ \pi,\ (P,Q),\ \rho$ & natural-language description; implementation; specification (pre, post); proof\\ +CodeGen, SpecGen, ProofGen & produce $\pi$; produce $(P,Q)$; produce $\rho$ (VERINA's three foundational tasks \cite{verina} \S4.1)\\ +$O_1,\dots,O_4$ & obligations: $O_1$ code behaves as intended; $O_2$ $(\hat P,\hat Q)$ is sound and complete for the intent; $O_3$ $\pi\models(P,Q)$; $O_4$ the formal statement is faithful to $\nu$\\ +$n,c,k$ & samples per task, correct among them, budget in $\mathrm{pass@}k$\\ +$\hat p=s/N$ & observed success rate on $N$ tasks; $z=1.96$\\ +$P_A,P_B,P_C$ & stage success probabilities of a composite task\\\bottomrule\end{tabular}\end{center} + +\section{Derivation} +\subsection{A common frame: which obligations does a success certify?} +A fully verified program carries four obligations. A benchmark can \emph{discharge} an $O_i$ by proof (kernel/verifier), by tests (evidence only), or merely \emph{assume} it from a human author. +\begin{center}\footnotesize\begin{longtable}{@{}P{2.1cm}P{1.8cm}P{2.1cm}P{3.0cm}P{3.2cm}P{2.5cm}@{}}\toprule +Benchmark & Size, language & Task (output) & Spec comes from & Spec validation ($O_2,O_4$) & Checker and trust\\\midrule\endhead +\textbf{VERINA} \cite{verina} & 189 tasks, Lean~4 (MBPP, LiveCodeBench, LeetCode); 46 with reference proofs & CodeGen, SpecGen, ProofGen, and compositions & hand-written ground truth $(P,Q)$; model-generated in SpecGen & $\hat P,\hat Q$ vs truth: prove, else \texttt{decide}/\texttt{plausible} on positive/negative tests (median 5 / 12 per task, 100\% coverage of reference code); verdict \emph{holds / does not hold / might hold / unknown}; error bars for unknown & Lean kernel for proofs; \texttt{\#guard} unit tests for code; testing for specs (\emph{evidence})\\ +\textbf{CLEVER} \cite{clever} & 161 of HumanEval's 164, Lean~4 & (1) spec + proof of \emph{isomorphism} to a hidden spec; (2) implementation + proof & hidden, hand-written, non-computable ground truth & $O_2$ \emph{proved}: generated spec $\Leftrightarrow\ \psi^*$ (QED~1); no tests, no LLM annotations & Lean kernel for both QEDs; success needs both\\ +\textbf{FVAPPS} \cite{fvapps} & 4715 samples, Lean~4.12 (APPS: Codeforces, LeetCode); 2089 ``guarded'', 1083 ``guarded and plausible'' & implement the \texttt{def} and prove each \texttt{theorem} stated with \texttt{sorry} & \emph{properties} extracted from tests by an LLM (property-based tests turned into theorems), not a full spec & unit tests as \texttt{\#guard\_\allowbreak msgs} on a Python-translated solution (tier \emph{guarded}); Lean property-based testing (Plausible, tier \emph{plausible}) & Lean kernel for proofs; spec quality is \emph{tested}, partial\\ +\textbf{DafnyBench} \cite{dafnybench} & 782 ground-truth Dafny programs ($\sim$53k LOC) & \texttt{fill\_\allowbreak hints}: reinsert \texttt{assert}/\texttt{invariant} removed from a verified program & given \texttt{requires}/\texttt{ensures} (human, from GitHub, Clover, dafny-synthesis) & none beyond: success requires the same \texttt{requires}/\texttt{ensures} be kept and no \texttt{assume false}/\texttt{\{:verify false\}} & Dafny verifier (SMT-backed): trust base includes the solver\\ +\textbf{miniCodeProps} \cite{minicp} & 201 properties, Lean~4 (95 TIP functions, Haskell$\to$Lean) & ProofGen only (code and property given) & TIP: human-written properties of lists, trees, heaps, sorting & none needed: statements fixed; split easy (medley 86) / medium+hard (termination+sorting 115) & Lean kernel\\\bottomrule\end{longtable}\end{center} + +\textbf{Reading the frame.} Obligation by obligation: +\begin{center}\footnotesize\begin{tabular}{@{}lcccc@{}}\toprule + & $O_1$ code intent & $O_2$ spec quality & $O_3$ code $\models$ spec & $O_4$ statement faithful\\\midrule +VERINA ProofGen & tests (given) & assumed (given) & \textbf{proved} & assumed\\ +VERINA SpecGen & --- & \textbf{tested} / proved if lucky & --- & tested\\ +VERINA CodeGen & \textbf{tested} & assumed & --- & ---\\ +CLEVER & --- & \textbf{proved} (iso) & \textbf{proved} & assumed (hand-written)\\ +FVAPPS & tested (tier) & \textbf{tested} (partial) & \textbf{proved} (properties only) & assumed (LLM-written)\\ +DafnyBench & --- & assumed & \textbf{proved} (solver) & assumed\\ +miniCodeProps & --- & assumed & \textbf{proved} & assumed\\\bottomrule\end{tabular}\end{center} +Only CLEVER makes the model prove its spec against a held-out truth; everything else either assumes the statement or tests it. This is the 0305 lattice (R4 there): a number from a benchmark that assumes $O_2$ and $O_4$ says nothing about spec generation risk. + +\paragraph{Three concrete caveats visible in the papers themselves.} (i) FVAPPS's displayed sample (Fig.~2) includes the theorem \texttt{solve\_\allowbreak elections\_\allowbreak nonnegative : solve\_elections n voters >= 0}, whose conclusion holds for every \texttt{Nat}: it carries no information (derived from the figure; the paper does not comment). Proof success on property-theorems is therefore an \emph{upper-biased} proxy for correctness. (ii) CLEVER documents that computable specs ``leak'' the implementation so that a copy plus a trivial proof suffices \cite{clever} Fig.~2--3. (iii) DafnyBench notes that Clover's own specifications sometimes repeat the implementation (their Appendix F) \cite{dafnybench} \S1. + +\subsection{Reading a number, part 1: pass@$k$} +Given $n$ samples with $c$ correct, a uniformly random $k$-subset has no correct member with probability $\binom{n-c}{k}/\binom nk$, so the per-task estimator is $1-\binom{n-c}{k}/\binom nk$, and the benchmark score is its mean over tasks (Chen et al.\ \cite{codex} \S2.1, Eq.~(1)). +\begin{proposition}[Unbiased]\label{prop:unb} Let $k\le n$ and let the $n$ samples for a task be i.i.d.\ with success probability $p$ (so $c\sim\mathrm{Bin}(n,p)$). Then $\mathbb E\bigl[1-\binom{n-c}{k}/\binom nk\bigr]=1-(1-p)^k$, with the convention that the ratio is $0$ when $n-c= 0}; we have not measured them (open question). + +\section{Boxed result} +\begin{result} +\textbf{R1.} Each benchmark certifies a different subset of $\{O_1,\dots,O_4\}$ (matrix above); only CLEVER proves $O_2$ against a hidden truth.\\ +\textbf{R2.} $\mathrm{pass@}k=1-\binom{n-c}{k}\big/\binom nk=1-\prod_{i=n-c+1}^{n}(1-k/i)$; unbiased: $\mathbb E=1-(1-p)^k$.\\ +\textbf{R3.} Wilson: $p_\pm=\Bigl(\hat p+\tfrac{z^2}{2N}\pm z\sqrt{\tfrac{\hat p(1-\hat p)}N+\tfrac{z^2}{4N^2}}\Bigr)\Big/(1+\tfrac{z^2}N)$.\\ +\textbf{R4.} $\max(0,\sum P_j-(m-1))\le P(\text{all})\le\min P_j$.\\ +\textbf{R5.} No-op baseline of DafnyBench: $26.9\%$ as reported (Table~3, $\approx210/782$); the \S3.1 counts give $208/782=26.6\%$. +\end{result} + +\paragraph{Numerical verification (notebook 0306, all cells executed).} +\begin{center}\small\begin{tabular}{@{}P{8.3cm}P{4.3cm}P{3cm}@{}}\toprule +Identity / claim & size & max abs error / result\\\midrule +R2: stable product vs exact rational (\texttt{Fraction}) & 303 $(n,c,k)$ triples & $6.1\times10^{-16}$\\ +R2: naive float64 factorial ratio at $n=200$ & --- & \texttt{nan} (exact $0.6602$)\\ +R2: $\mathbb E[\mathrm{pass@}k]=1-(1-p)^k$, exact binomial sum & 6 settings & $6.7\times10^{-16}$\\ +R2: plug-in bias $\mathbb E[1-(1-c/n)^k]-(1-(1-p)^k)$ & 6 settings & $-0.019$ to $-0.072$\\ +R3: Wilson endpoints solve $(\hat p-p)^2=z^2p(1-p)/N$ & 5 cases & residual $<10^{-12}$\\ +R3: Wilson coverage vs Wald, 6 $(N,p)$ cells $\times$ 40,000 & Wilson $\ge0.946$; Wald $0.552$ at $(161,.005)$ & ---\\ +R4: Fr\'echet bounds on random joint laws ($m=2,3$) & 20,000 laws & 0 violations; upper bound attained ($0.0491$ vs $0.049$)\\ +R4: lower Fr\'echet bound attained by an explicit coupling & 6 marginal vectors & exact\\ +R5 and paper arithmetic (22 checks: sums, ratios of Tables, DafnyBench Table~3 $\pm$ equal one binomial s.e., $208$ vs $26.9\%$) & 22 & 21 consistent; 1 flagged\\\bottomrule\end{tabular}\end{center} +The flagged item: FVAPPS reports Gemini ``74 (18.5\%)'' of 406 theorems; $74/406=18.2\%$ (its abstract says $18\%$). The DafnyBench source counts sum correctly ($556+62+164=782$), the $\pm$ in its Table~3 equal one binomial standard error at $N=782$ (e.g.\ $1.7$ for $67.8\%$), and all nine miniCodeProps rates match their counts (e.g.\ $65/86=75.6\%$, $70/201=34.8\%$). + +\section{What breaks} +\begin{center}\small\begin{tabular}{@{}P{3.6cm}P{6.5cm}P{5.4cm}@{}}\toprule +Failure & Mechanism & Assumption violated\\\midrule +Cross-benchmark comparison & different tasks, specs, $k$, attempts, timeouts, Lean versions & ``same unit''; compare only within a protocol\\ +Assumed statements & DafnyBench, miniCodeProps, FVAPPS take $O_2,O_4$ from humans/LLMs; formal statements of related benchmarks contain defects (miniF2F: mismatch in over half of 488 \cite{minif2frev}) & the statement is faithful (0305)\\ +Test-based spec grading & VERINA: testing certifies $>40\%$ of generated specs while proofs certify $<4\%$ (App.~C.5); a \emph{might hold} is not a proof & the suite separates good from bad specs\\ +Partial properties & FVAPPS theorems can be trivial or partial (e.g.\ $\ge0$ on \texttt{Nat}) & proved properties $\Rightarrow$ correctness\\ +Leakage & computable specs copy the implementation (CLEVER Fig.~2--3); Clover specs sometimes repeat the code (DafnyBench \S1) & spec is implementation-agnostic\\ +Contamination & DafnyBench scrapes public GitHub (before the end of 2023) and lists repos to exclude; HumanEval is widely seen in training & test items unseen\\ +Small $N$, clustering & intervals of $\pm3$--$7$ points; FVAPPS clustering unmodelled & i.i.d.\ tasks\\ +Prompt/tuning & DafnyBench: fixed temperature, 4096-token limit, prompts ``not very systematically tuned'' \cite{dafnybench} \S5.3 & headline = capability\\ +Version skew & our pin is Lean~4.9.0-rc1; FVAPPS (4.12), VERINA (uses \texttt{plausible}, newer tactics) may not compile unchanged & statements elaborate on our pin (\textbf{not tested here})\\ +Null baseline & DafnyBench ``No LLM'' $=26.9\%$ (Table~3) & the metric starts at 0\\\bottomrule\end{tabular}\end{center} + +\paragraph{What to carry forward.} For an RL or SFT loop whose reward is a kernel-checked proof: use a benchmark whose statement is fixed (miniCodeProps, VERINA ProofGen, DafnyBench analogue) so that the strict bridge of 0305 applies; use CLEVER-style spec proofs when the model must produce the spec; report Wilson intervals and a null baseline; never mix $k$. + +\nextnote{End of the formal-methods primer (0301--0306). The loop back to the training notes (0101--0205): a verifier is a reward only to the extent that 0301's gate, 0305's statement bridge and 0306's statistics hold.} + +\begin{small} +\begin{thebibliography}{99} +\bibitem{verina} Z.~Ye et al., \emph{VERINA: Benchmarking Verifiable Code Generation}, arXiv:2505.23135 (ICLR 2026): abstract (189 tasks; 72.6\%, 52.3\%, 4.9\%); \S3.1 (data format; 46 reference proofs; test medians), \S4.1 (tasks, metrics, Fig.~3 evaluator), Appendix~C.5 and Table~5, Appendix~C.3 (2-shot prompts; 5 samples per task for pass@1). +\bibitem{clever} A.~Thakur et al., \emph{CLEVER: A Curated Benchmark for Formally Verified Code Generation}, arXiv:2505.13938 (NeurIPS 2025 D\&B): abstract (161 problems); \S1 (few-shot LLMs solve ``up to 1/161'' end-to-end), Fig.~1 (two QEDs), Fig.~2--3 (spec leakage; non-computable specs). The 54.0\% for a specialised prover is reported by a different paper (Goedel-Code-Prover, via the vault summary) and is not used above. +\bibitem{fvapps} Q.~Dougherty, R.~Mehta, \emph{Proving the Coding Interview}, arXiv:2502.05714: abstract (4715; 1083; Sonnet 30\%, Gemini 18\%); \S II-B (pipeline; Lean 4.12.0), II-C (tiers 4715/2089/1083); \S III-A/D/E (baselines, 406 theorems of 100 samples, \texttt{Id.run do}); Fig.~2 (sample 23, theorem \texttt{solve\_\allowbreak elections\_\allowbreak nonnegative}). +\bibitem{dafnybench} C.~Loughridge et al., \emph{DafnyBench}, arXiv:2406.08467: abstract (over 750 programs, 53,000 lines, 68\%); \S1 (Clover specs sometimes repeat the implementation, App.~F); \S3.1 (782 programs; 113/23/72 verify with no hints); \S3.2 (task and success criteria); \S4.1 (10 attempts, temperature 0.3, 4096 tokens); \S4.2--4.3 and Table~3 (Claude~3 Opus $67.8\pm1.7\%$; ``No LLM'' $26.9\%$; first try $\sim54\%$; plateau $\sim65\%$); \S5.3 (fixed temperature, 4096 tokens, prompts not systematically tuned). +\bibitem{minicp} E.~Lohn, S.~Welleck, \emph{miniCodeProps}, arXiv:2406.11915: abstract (201 properties); Table~1 (composition); Table~3 (GPT-4o pass@32 34.8\%/37.3\% with refinement ($k{=}16,T{=}2$); ntp-ctx-1.3B 35.8\%). +\bibitem{codex} M.~Chen et al., \emph{Evaluating Large Language Models Trained on Code}, arXiv:2107.03374: \S2.1, Eq.~(1) (unbiased pass@$k$), Fig.~3 (stable implementation), Appendix~A (bias of $1-(1-\hat p)^k$). +\bibitem{minif2frev} M.~Ospanov et al., \emph{miniF2F-Lean Revisited}, arXiv:2511.03108. +\bibitem{wilson27} E.~B.~Wilson, ``Probable inference, the law of succession, and statistical inference'', JASA 1927; M.~Fr\'echet (1935) for the bounds; derived above. \emph{Not on arXiv; not fetched; both results are re-derived in this note and checked numerically.} +\end{thebibliography} +\end{small} +\end{document} diff --git a/code/notes/src/0307-aeneas-semantics.tex b/code/notes/src/0307-aeneas-semantics.tex new file mode 100644 index 0000000000000000000000000000000000000000..cf1065e9e01fc7ec12635c796a70da305d864505 --- /dev/null +++ b/code/notes/src/0307-aeneas-semantics.tex @@ -0,0 +1,120 @@ +\documentclass[11pt]{article} +\usepackage{common} +\begin{document} +\notehead{0307}{Aeneas semantics: what a proof of $\neg P$ about translated Rust certifies}{Formal-methods series (0301--0306). Rust (safe subset) $\to$ LLBC $\to$ pure Lean, the \texttt{Result} monad, and the exact logical content of a bug certificate. Companion notebook: \texttt{notebooks/0307-aeneas-semantics.ipynb} (abstract machine versus translation, exhaustive over \texttt{u8}; executed, 20 checks).} + +\begin{problem} +\question{If a prover proves the negation of a property of the Lean function Aeneas produced from a Rust function, what has it shown about the Rust program?} +Note 0304 showed that a counterexample is a proof of $\neg S$ for a \emph{closed} statement $S$ about a Lean function. The bug-finding pipeline we want to evaluate applies this to Rust: translate with Charon and Aeneas, state an intended property, try to prove it, and on failure try to prove its negation (the Leanstral~1.5 funnel, vendor-reported, recorded in \cite{lit}). Three things sit between ``the kernel accepted $\neg S$'' and ``the Rust program has a bug'': (i) the translation must \emph{mean} what the Rust program means, including panics, overflow and divergence; (ii) the negation must be of the right closed proposition, because $\neg\forall x.\,(H\to P)$ and $\forall x.\,(H\to\neg P)$ are different claims; (iii) the witnessing input must be reachable from a caller that exists. The answers: a certificate is a proof about the \emph{translated model}; it becomes a bug report exactly modulo (i) the adequacy of the translation and its trusted base and (iii) reachability, and (ii) fixes which statement was proved. +\end{problem} +\stepcounter{section}% the problem box counts as section 1: keeps every \S and notebook cross-reference stable + +\section{Notation} +\begin{center} +\begin{tabular}{@{}p{0.36\linewidth}p{0.60\linewidth}@{}} +\toprule +Symbol & Meaning \\ \midrule +$\mathcal M\sem{f}(x)$ & observable outcome of running Rust function $f$ on input $x$ in the abstract machine: $\mathsf{ret}\,v$, $\mathsf{panic}\,e$, or $\mathsf{diverge}$ \\ +$\mathcal T(f)\,x$ & the Lean term Aeneas emits for $f$, of type \texttt{Result} $\alpha$ \\ +$\mathsf{Result}\;\alpha$ & $\mathsf{ok}\,a\ |\ \mathsf{fail}\,e\ |\ \mathsf{div}$ (success, panic with an error tag, non-termination) \\ +$H,\ P$ & precondition and postcondition predicates; $Q(x):=\exists r.\ \mathcal T(f)\,x=\mathsf{ok}\,r\wedge P(x,r)$ \\ +$S_H$ & closed statement $\forall x.\ H(x)\to Q(x)$ \\ +$W_H=\{x: H(x)\wedge\neg Q(x)\}$ & witness set of $\neg S_H$ \\ +$\mathrm{Reach}(g)$ & inputs actually passed to $f$ by the rest of the program $g$ (public entry points) \\ +$\mathrm{TCB}$ & what must be trusted besides the Lean kernel: Charon, Aeneas, the Rust semantics modelled, the property's formalisation \\ \bottomrule +\end{tabular} +\end{center} + +\section{Derivation} +\subsection{The pipeline and what each stage removes} +Charon extracts from a Rust crate its MIR-derived, typed intermediate form LLBC; Aeneas translates LLBC to a \emph{pure} Lean 4 program and a types file \cite{ho22,rustlean}. Each Rust function becomes a Lean function returning \texttt{Result} $\alpha$ whose three constructors distinguish return, panic and non-termination \cite{rustlean}. Rust's ownership and borrow discipline is used as a \emph{semantic input}: Aeneas removes the heap and aliasing from the model by (a) passing and returning values instead of mutating places, and (b) for a function that returns a mutable borrow, also returning a \emph{backward function} that the caller invokes when the borrow ends to write the final value back to the owner \cite{ho22,scaling}. Loops become recursive functions; traits become structures. Unsafe code, raw pointers, interior mutability and concurrency are outside the supported subset and must be modelled by hand \cite{scaling}; so a certificate says nothing about code that the front end did not translate faithfully. + +A worked instance (simplified from ML-KEM in \cite{scaling}): \texttt{fn reduce(x: \&mut u64) \{ let q = (*x * R) >> 32; *x = *x - q * Q; \}} becomes a function \texttt{reduce (x : U64) : Result U64} whose body is a chain of monadic binds, one for every fallible operation (\texttt{x*R}, the shift, \texttt{q*Q}, \texttt{x-z}); the mutable borrow is gone and the new value is returned. Overflow in any step is a \texttt{fail}. + +\subsection{The Result monad and why panics must be in the type} +Define +\[ +\mathsf{bind}(\mathsf{ok}\,a,f)=f\,a,\qquad \mathsf{bind}(\mathsf{fail}\,e,f)=\mathsf{fail}\,e,\qquad\mathsf{bind}(\mathsf{div},f)=\mathsf{div}. +\] +The monad laws (left identity, right identity, associativity) hold by case analysis on the first argument, so the translation is free to re-associate sequences of fallible steps. \ver{checked on all $256$ values plus three error tags and $\mathsf{div}$: left identity, right identity and associativity hold.} + +\textbf{The naive translation fails.} Take the running example (the same one is used through the note) +\[ +\texttt{fn avg(a: u8, b: u8) -> u8 \{ (a + b) / 2 \}} +\] +With overflow checks on, the machine panics iff $a+b>255$. A translation that reads $a,b$ as mathematical integers and returns $\mathsf{ok}\,((a+b)/2)$ is total and ``clean'', and it disagrees with the machine on exactly the panicking inputs: $\#\{(a,b):a+b\ge256\}=\sum_{a=1}^{255}a=\tfrac{255\cdot256}2=32640$ of the $65536$ pairs. The correct translation is $\mathcal T(\mathtt{avg})(a,b)=\mathsf{bind}(\mathrm{add}_{u8}(a,b),\ \lambda s.\ \mathsf{ok}(s/2))$ with $\mathrm{add}_{u8}(a,b)=\mathsf{ok}(a+b)$ if $a+b\le255$ else $\mathsf{fail}\ \mathsf{integerOverflow}$. \ver{the naive translation disagrees with the machine on $32640=255\cdot256/2$ inputs, the \texttt{Result} translation on $0$ of $65536$.} + +\subsection{Borrow elimination as a commuting diagram} +For \texttt{fn bump(x: \&mut u8) \{ *x += 1 \}} the machine updates a store $\sigma(x)$; the translation is $\mathtt{bump}(x)=\mathrm{add}_{u8}(x,1)$ and the caller rebinds. The claim is the diagram: $\mathcal M$ run on $\sigma=\{x\mapsto v\}$ ends with $\sigma'(x)=v'$ iff $\mathcal T(\mathtt{bump})(v)=\mathsf{ok}\,v'$, and panics iff it is $\mathsf{fail}$ (with the same error tag; the store is unchanged because the write comes after the check). For a function returning a borrow into a vector, $\mathtt{get\_mut}(v,i)=\mathsf{ok}(v[i],\ \mathsf{back})$ with $\mathsf{back}:u8\to\mathrm{Vec}$ the update $v[i:=\cdot]$, or $\mathsf{fail}\ \mathsf{arrayOutOfBounds}$; a caller that does \texttt{*get\_mut(\&mut v,i) += k} translates to $\mathsf{bind}(\mathtt{get\_mut}\,v\,i,\ \lambda(a,\mathsf{back}).\ \mathsf{bind}(\mathrm{add}_{u8}(a,k),\ \lambda n.\ \mathsf{ok}(\mathsf{back}\,n)))$. The order of the binds fixes \emph{which} panic is reported first. \ver{bump: store and outcome equal the translation for all $256$ inputs; returned-borrow case: forward value, backward function and error tag agree with the machine on $1275/1275$ cases (vectors of length $\le3$ over $\{0,1,254,255\}$, $i\le4$, $k\le2$).} + +\subsection{Loops and divergence} +A \texttt{while} loop becomes a recursive function; whether it terminates is not decided by the translation. The \texttt{div} constructor models the machine observation ``does not terminate'': for \texttt{while x != 0 \{\}} the machine returns normally iff $x=0$, and for every fuel bound $F$ the fuel-limited machine runs out of fuel iff the translation is $\mathsf{div}$ \ver{agreement on all $256$ inputs and $F\in\{1,10,1000\}$}. A loop with a checked accumulator, \texttt{sum\_to(n)}, panics first at $n=23$ ($22\cdot23/2=253\le255<276=23\cdot24/2$); the recursive translation and the machine agree for every \texttt{u8} input \ver{256 of 256; first panic at $n=23$}. A proof of a property about a possibly-diverging function must either prove termination or accept that $\mathsf{div}$ is not $\mathsf{ok}$: in $Q$ above $\mathsf{div}$ counts as a violation. + +\subsection{What a proof of $\neg P$ proves} +\begin{definition}[Adequacy of the translation] +$\mathcal T$ is \emph{adequate} for $f$ if for every $x$: $\mathcal M\sem f(x)=\mathsf{ret}\,v\Rightarrow\mathcal T(f)\,x=\mathsf{ok}\,v$; $\mathcal M\sem f(x)=\mathsf{panic}\,e\Rightarrow\mathcal T(f)\,x=\mathsf{fail}\,e$; $\mathcal M\sem f(x)=\mathsf{diverge}\Rightarrow\mathcal T(f)\,x=\mathsf{div}$ (and conversely, so the three cases are exhaustive and exclusive). +\end{definition} +Adequacy is the content of the translation's correctness theorem; it is a property of the \emph{toolchain} (and of the Rust semantics it formalises), not something the Lean kernel checks for the individual program. + +\begin{proposition}[Closed negation] +Let $S_H=\forall x.\,H(x)\to Q(x)$. (a) $\neg S_H\iff\exists x.\,H(x)\wedge\neg Q(x)$ (classically; constructively only the implication $\Leftarrow$, and a witness is extracted only if $H,Q$ are decidable). (b) $\forall x.\,H(x)\to\neg Q(x)$ is strictly stronger than $\neg S_H$ whenever $H$ is satisfiable, and when $H$ is \emph{un}satisfiable it is provable vacuously and carries no evidence. (c) Under adequacy, any $x\in W_H$ is an input on which the Rust function panics, diverges or returns a value violating $P$. +\end{proposition} +\emph{Proof.} (a) De Morgan for the quantifier and the implication. (b) Instantiating a witness of $H$ in $\forall x.H\to\neg Q$ gives $\neg Q$ there, hence $\neg S_H$; the converse fails when $H$ also holds at some $x$ with $Q(x)$ (take $H\equiv\top$ and any function with one good input). If $\neg\exists x.H(x)$ the universal statement is vacuous. (c) By adequacy $\mathcal T(f)\,x$ is $\mathsf{ok}\,r$ with $P(x,r)$ false or is $\mathsf{fail}/\mathsf{div}$ exactly when the machine outcome is the corresponding one. $\square$ + +\emph{Why the distinction matters here.} A statement ``$\neg P\ x$ for the $x$ in scope'' written under binders is a claim about \emph{every} $x$; a prover that is asked for it either fails (it is false at every good input) or ``succeeds'' vacuously. The right target for a bug search is the closed $\neg\forall$ form (equivalently, an $\exists$ with a witness). For \texttt{avg} with $H\equiv\top$ and $P=$ ``$\min(a,b)\le r\le\max(a,b)$'': $|W|=32640$, the closed statement $S$ is false (first witness $(1,255)$), and the under-binders form $\forall x.\neg Q(x)$ is also false since $Q(0,0)$ holds ($|\{Q\}|=32896$). With $H:=a+b\le255$, $S_H$ is true and no certificate exists. With the unsatisfiable $H:=a+b>255\wedge a+b<256$ the under-binders form holds vacuously and $W=\emptyset$; with the satisfiable $H:=a+b\ge256$ it holds for a real reason and implies $\neg S_H$. \ver{all six statements verified by exhaustion over the $65536$ inputs.} + +\subsection{Reachability: a certificate about $f$ is not a bug in the program} +$W_H$ is a set of inputs to $f$ in isolation. The program has a bug only if some $x\in W_H$ can be produced by a public entry point: $W_H\cap\mathrm{Reach}\ne\emptyset$. Example: \texttt{fn mean\_pct(a,b) \{ avg(min(a,c), min(b,c)) \}}. The reachable inputs of \texttt{avg} are $[0,c]^2$ and +\[ +|W\cap[0,c]^2|=\sum_{a=0}^{c}\max\{0,\ \min(c,255)-\max(0,256-a)+1\}, +\] +which is $0$ for $c\le127$ and $1$ at $c=128$ (the pair $(128,128)$). So the same certificate is a false alarm for $c\le127$ and a real bug for $c\ge128$. \ver{closed form equals brute force at $c\in\{50,100,127,128,150,200,255\}$ ($0,0,0,1,1035,10585,32640$); running \texttt{mean\_pct} on all $65536$ raw input pairs gives $0$ panics for $c=127$, $16384=128^2$ for $c=128$, $29560$ for $c=200$.} The Leanstral report is recorded as attributing most of its false flags to properties that were ``too strong (for instance failing because of a state that was unreachable from the public interface) or malformed'' (my arithmetic from its counts: $11/47=23\%$ of flags genuine, $5/47\approx11\%$ per distinct bug); this section is the mechanism for the first class. A certificate gate therefore has to include a reachability or precondition check and a concrete replay of the witness through the \emph{Rust} program. + +\subsection{Translation soundness fails in both directions} +Adequacy has two halves, and each failure gives a different wrong certificate. +\begin{enumerate}[nosep] +\item \emph{Too lax} (unbounded integers; a missed panic). $S$ is provable in the model although the program panics on $32640$ inputs. No certificate for the real bug exists, because the model has no such witness. +\item \emph{A different semantics} (wrapping, as release builds do by default). The witnesses for $P$ are the \emph{same} $32640$ inputs but now because the value is wrong, not because of a panic, and panic-freedom becomes \emph{provable}. ``The bug'' depends on which Rust semantics the translation models; Aeneas models checked arithmetic (failure on overflow) \cite{scaling}. +\item \emph{Too strict} (a spurious \texttt{fail}). For \texttt{((a as u16 + b as u16) / 2) as u8}, which never panics in Rust, a translation that checks the widened add at \texttt{u8} width produces $32640$ spurious witnesses: a ``proof of the negation'' of a true property of the real program. +\end{enumerate} +\ver{exhaustive: (1) $|W|=0$ in the model vs $32640$ in the machine; (2) same witness set, panic-free true under wrapping and false under checked semantics; (3) real program correct on all $65536$ inputs, strict translation fails on $32640$.} The notebook compares machine and translation on a finite domain, which is a \emph{test} of adequacy for these programs. It is not a proof of the toolchain's soundness theorem, and the kernel's acceptance of $\neg S$ says nothing about it. + +\subsection{The trusted base of a bug certificate} +A certificate for ``the Rust program has a bug'' needs: the Lean kernel (small), the Lean statement faithfully formalising the intended $H,P$ (spec risk, 0305), Charon's extraction being faithful to rustc's MIR, Aeneas' translation being adequate for the features used (the ICFP paper presents the translation and its semantic justification \cite{ho22}; I have not re-verified a mechanised end-to-end proof for the current tool version, and I treat the toolchain as trusted code), the Rust semantics chosen (overflow, evaluation order of panics), and reachability. Only the first item is checked by the kernel; the rest is why the final step in 0304's protocol is a concrete replay of the witness against the original Rust. + +\section{Boxed result} +\begin{result} +\begin{align*} +&\mathsf{Result}\,\alpha=\mathsf{ok}\,\alpha\,|\,\mathsf{fail}\,e\,|\,\mathsf{div};\quad \mathsf{bind}\text{ propagates }\mathsf{fail},\mathsf{div};\quad\&\mathsf{mut}\ \leadsto\ \text{returned value (+ backward fn).}\\ +&\neg S_H\iff\exists x.\,H(x)\wedge\neg Q(x);\qquad \forall x.\,H\to\neg Q\ \text{is stronger (and vacuous if $H$ unsatisfiable)}.\\ +&\text{real bug}\iff \underbrace{\vdash\neg S_H}_{\text{kernel}}\ \wedge\ \underbrace{\mathcal T\text{ adequate}}_{\text{toolchain}}\ \wedge\ \underbrace{W_H\cap\mathrm{Reach}\neq\emptyset}_{\text{program}}\ \wedge\ \underbrace{H,P\text{ the intended spec}}_{\text{0305}}. +\end{align*} +Running example: $|W|=32640$ of $65536$; $|W\cap[0,c]^2|=0$ for $c\le127$, $1$ at $c=128$. +\end{result} + +\section{What breaks} +\begin{breaks} +\begin{tabular}{@{}p{0.27\linewidth}p{0.30\linewidth}p{0.37\linewidth}@{}} +\toprule +Failure & Assumption violated & Consequence \\ \midrule +Negation stated under binders & the proved statement is the closed $\neg\forall$ & unprovable, or vacuously provable when $H$ is unsatisfiable \\ +Witness not reachable & $W\cap\mathrm{Reach}\ne\emptyset$ & false alarm: certificate about $f$, not about the program (the dominant flagged-but-not-genuine class in the funnel) \\ +Translation too lax (missed overflow) & adequacy, panic half & $S$ provable in the model, real panic exists; the pipeline reports ``correct'' \\ +Translation too strict & adequacy, converse half & spurious certificate of $\neg S$ for a correct program \\ +Different arithmetic semantics & the machine modelled is the one deployed & wrong-value versus panic; release versus debug builds disagree \\ +Unsupported features (unsafe, interior mutability, concurrency) & the function lies in the safe subset & model written by hand; the certificate is about the hand model \cite{scaling} \\ +Divergence read as success & $\mathsf{div}\ne\mathsf{ok}$ & termination must be proved or the property must tolerate $\mathsf{div}$ \\ +Property formalises the wrong intent & $H,P$ capture the spec & bug in the spec, not the code (0305) \\ +Exhaustive agreement mistaken for adequacy & finite test $\ne$ theorem & only the programs and domains tested are covered \\ \bottomrule +\end{tabular} +\end{breaks} + +\nextnote{0305 asks the converse question: when the proof of $S$ \emph{succeeds}, how do we know $S$ is the right statement? The same $(H,P)$ that makes a certificate meaningful here is what spec-risk analysis has to validate.} + +\begin{thebibliography}{9} +\bibitem{ho22} S.~Ho, J.~Protzenko. Aeneas: Rust verification by functional translation. Proc.\ ACM Program.\ Lang.\ 6(ICFP):711--741, 2022 (as cited in \cite{rustlean}; not re-fetched here). +\bibitem{rustlean} A Rust-to-Lean verification pipeline with AI provers, arXiv:2605.30106, Sec.~2.1 (Charon $\to$ LLBC $\to$ Lean; \texttt{Result} with \texttt{ok}, \texttt{fail}, \texttt{div}; safe-Rust subset); vault note \texttt{a-rust-to-lean-verification-pipeline-with-ai-provers}. +\bibitem{scaling} Scaling verification of cryptographic software with Aeneas, Rust, and Lean, arXiv:2609.15648, Sec.~3 (the \texttt{reduce} example, monadic binds for overflow, backward functions for returned borrows, loops as recursion, unsupported features); vault note \texttt{scaling-verification-of-cryptographic-software-with-aeneas-rust-and-lean}. +\bibitem{lit} \texttt{docs/research/BUGFINDING\_LIT\_REVIEW.md}, Sec.~3.1 (Leanstral~1.5 funnel: 57 repositories, 47 flags, 11 genuine, 5 distinct bugs; vendor-reported, method for ``genuine'' not described). +\end{thebibliography} +\end{document} diff --git a/code/notes/src/build.sh b/code/notes/src/build.sh new file mode 100644 index 0000000000000000000000000000000000000000..32d80c5ead23db2762f1d446598091d9989a0dc5 --- /dev/null +++ b/code/notes/src/build.sh @@ -0,0 +1,9 @@ +#!/usr/bin/env bash +# usage: build.sh 0301-curry-howard "0301 - Curry-Howard" (run from anywhere) +set -e +source /apps/paper-tools/env.sh >/dev/null 2>&1 +cd "$(dirname "$0")" +mkdir -p build +TEXINPUTS=".:" latexmk -g -pdf -interaction=nonstopmode -halt-on-error -outdir=build "$1.tex" >build/"$1".console.log 2>&1 || { tail -40 build/"$1".console.log; exit 1; } +cp build/"$1.pdf" "../$2.pdf" +echo "built ../$2.pdf" diff --git a/code/notes/src/build_all.sh b/code/notes/src/build_all.sh new file mode 100644 index 0000000000000000000000000000000000000000..9e2c964a46d530fd6f6df93e80b6473764c5a1b2 --- /dev/null +++ b/code/notes/src/build_all.sh @@ -0,0 +1,19 @@ +#!/usr/bin/env bash +# usage: build_all.sh -> rebuild all 24 note PDFs (names "NNNN - Title.pdf" in ../) +# build_all.sh 0107 0203 -> rebuild only those +set -o pipefail +source /apps/paper-tools/env.sh >/dev/null 2>&1 +cd "$(dirname "$0")" +mkdir -p build/all +sel=("$@"); fail=0; n=0 +for tex in 0[123][0-9][0-9]-*.tex; do + id=${tex%%-*} + if [ ${#sel[@]} -gt 0 ] && [[ ! " ${sel[*]} " == *" $id "* ]]; then continue; fi + slug=${tex%.tex} + existing=$(ls ../"$id"\ -\ *.pdf 2>/dev/null | head -1) + if [ -z "$existing" ]; then echo "SKIP $id: no existing ../$id - Title.pdf to take the name from"; continue; fi + if TEXINPUTS=".:${TEXINPUTS:-}" latexmk -pdf -interaction=nonstopmode -halt-on-error -outdir=build/all "$tex" >build/all/$slug.console.log 2>&1; then + cp "build/all/$slug.pdf" "$existing"; n=$((n+1)); echo "built $existing" + else echo "FAIL $id (see build/all/$slug.console.log)"; tail -15 build/all/$slug.console.log; fail=$((fail+1)); fi +done +echo "built=$n failed=$fail"; [ $fail -eq 0 ] diff --git a/code/notes/src/build_m1.sh b/code/notes/src/build_m1.sh new file mode 100644 index 0000000000000000000000000000000000000000..6713c7782163d1df64b8b9710746adfa04f9e6c5 --- /dev/null +++ b/code/notes/src/build_m1.sh @@ -0,0 +1,9 @@ +#!/usr/bin/env bash +# usage: ./build_m1.sh 0101-slug "0101 - Title" +set -eo pipefail +source /apps/paper-tools/env.sh >/dev/null 2>&1 +cd "$(dirname "$0")" +mkdir -p build/m1 +TEXINPUTS=".:$TEXINPUTS" latexmk -pdf -interaction=nonstopmode -halt-on-error -outdir=build/m1 "$1.tex" >build/m1/$1.console.log 2>&1 || { tail -40 build/m1/$1.console.log; exit 1; } +cp "build/m1/$1.pdf" "../$2.pdf" +echo "built ../$2.pdf" diff --git a/code/notes/src/common.sty b/code/notes/src/common.sty new file mode 100644 index 0000000000000000000000000000000000000000..8ef540f2f5adde7392bb27cddb396392a1baa70f --- /dev/null +++ b/code/notes/src/common.sty @@ -0,0 +1,127 @@ +% common.sty -- single style for all 24 notes (series 01xx, 02xx, 03xx). +% Replaces common-m1/-m2/-m3 (kept as thin shims for old build logs; see STYLE_ALIGNMENT.md). +\ProvidesPackage{common} +\RequirePackage[margin=2.2cm]{geometry} +\RequirePackage[utf8]{inputenc} +\RequirePackage[T1]{fontenc} +\RequirePackage{amsmath,amssymb,amsthm,mathtools,bm} +\RequirePackage{booktabs,array,enumitem,longtable} +\RequirePackage[dvipsnames]{xcolor} +\RequirePackage[most]{tcolorbox} +\RequirePackage{listings} +\RequirePackage[hidelinks]{hyperref} +\RequirePackage{microtype} +\newcolumntype{P}[1]{>{\raggedright\arraybackslash}p{#1}} +\setlength{\emergencystretch}{3em} +\setlength{\parindent}{0pt} +\setlength{\parskip}{0.55em} +\setlist{itemsep=2pt,topsep=3pt} +\renewcommand{\arraystretch}{1.15} +% ---- theorem-like environments ------------------------------------------ +\newtheorem{lemma}{Lemma} +\newtheorem{proposition}{Proposition} +\newtheorem{theorem}{Theorem} +\theoremstyle{definition} +\newtheorem{definition}{Definition} +\newtheorem{example}{Example} +\theoremstyle{remark} +\newtheorem*{remark}{Remark} +% ---- the house skeleton: problem -> notation -> derivation -> result -> what breaks -> next +\newtcolorbox{problem}{colback=gray!6,colframe=gray!60,title={The problem}, + fonttitle=\bfseries\small,boxrule=0.5pt,arc=1pt} +\newcommand{\question}[1]{\textbf{The question.} #1\par\smallskip} +\newtcolorbox{result}[1][Boxed result]{colback=Cerulean!6,colframe=Cerulean!70!black, + boxrule=0.9pt,arc=2pt,title={#1},fonttitle=\bfseries\small} +\newtcolorbox{breaks}[1][]{colback=BrickRed!5,colframe=BrickRed!70!black, + boxrule=0.6pt,arc=2pt,title={Failure modes and the assumption each one violates}, + fonttitle=\bfseries\small,#1} +\newcommand{\nextnote}[1]{\begin{tcolorbox}[colback=blue!4,colframe=blue!50!black, + title={Next},fonttitle=\bfseries\small,boxrule=0.5pt,arc=1pt]#1\end{tcolorbox}} +\newcommand{\notehead}[3]{% + {\raggedright\Large\bfseries Note #1 \,--\, #2}\\[2pt] + {\raggedright\small\itshape #3\par}\vspace{2pt}\hrule\vspace{4pt}} +\newcommand{\nbref}[1]{\texttt{notebooks/#1}} +\newcommand{\ver}[1]{\textcolor{green!40!black}{\small[\textsf{verified}: #1]}} +% ---- shared notation ------------------------------------------------------ +\DeclareMathOperator*{\argmax}{arg\,max} +\DeclareMathOperator*{\argmin}{arg\,min} +\DeclareMathOperator{\Var}{Var} +\DeclareMathOperator{\Cov}{Cov} +\DeclareMathOperator{\Bern}{Bern} +\DeclareMathOperator{\Bin}{Bin} +\DeclareMathOperator{\KL}{KL} +\DeclareMathOperator{\clip}{clip} +\DeclareMathOperator{\sg}{sg} +\DeclareMathOperator{\TV}{TV} +\DeclareMathOperator{\FV}{FV} +\DeclareMathOperator{\imax}{imax} +\newcommand{\ESS}{\mathrm{ESS}} +\newcommand{\E}{\mathbb{E}} +\renewcommand{\Pr}{\mathbb{P}} +\newcommand{\R}{\mathbb{R}} +\newcommand{\N}{\mathbb{N}} +\newcommand{\Z}{\mathbb{Z}} +\newcommand{\ind}{\mathbb{1}} % bare indicator symbol (02xx notes) +\newcommand{\indb}[1]{\mathbb{1}\!\left[#1\right]} % bracketed indicator (01xx notes) +\newcommand{\pa}{\pi_\theta} +\newcommand{\pth}{\pi_\theta} +\newcommand{\pold}{\pi_{\theta_{\mathrm{old}}}} +\newcommand{\score}{\nabla_\theta\log\pi_\theta} +\newcommand{\given}{\,\middle|\,} +\newcommand{\pk}{\mathrm{pass@}k} +\newcommand{\hatpk}{\widehat{\mathrm{pass@}k}} +% ---- formal-methods notation and Lean listings (03xx notes) --------------- +\newcommand{\Prop}{\mathsf{Prop}} +\newcommand{\Type}{\mathsf{Type}} +\newcommand{\Sort}{\mathsf{Sort}} +\newcommand{\bool}{\mathsf{Bool}} +\newcommand{\wpc}{\mathrm{wp}} +\newcommand{\hoare}[3]{\{#1\}\;#2\;\{#3\}} +\newcommand{\sem}[1]{[\![#1]\!]} +\newcommand{\lean}[1]{\texttt{#1}} +% Lean listings (unicode-safe: declare the few glyphs we use) +\lstdefinelanguage{Lean}{ + morekeywords={theorem,lemma,def,example,by,fun,match,with,if,then,else,let,in, + fun,intro,exact,apply,induction,cases,simp,omega,decide,rfl,have,show,from, + inductive,structure,class,instance,where,open,import,namespace,axiom,partial, + termination_by,decreasing_by,at,using,rw,unfold,split,rcases,obtain}, + sensitive=true, morecomment=[l]{--}, morestring=[b]"} +\lstset{language=Lean,basicstyle=\ttfamily\footnotesize,breaklines=true, + columns=fullflexible,keepspaces=true,frame=single,rulecolor=\color{black!25}, + keywordstyle=\color{Blue}\bfseries,commentstyle=\color{OliveGreen}\itshape, + xleftmargin=0.5em,xrightmargin=0.5em,aboveskip=0.4em,belowskip=0.2em, + literate={→}{{$\to$}}1 {←}{{$\leftarrow$}}1 {↔}{{$\leftrightarrow$}}1 + {∀}{{$\forall$}}1 {∃}{{$\exists$}}1 {¬}{{$\neg$}}1 {∧}{{$\wedge$}}1 {∨}{{$\vee$}}1 + {≤}{{$\le$}}1 {≥}{{$\ge$}}1 {≠}{{$\ne$}}1 {ℕ}{{$\mathbb{N}$}}1 {ℤ}{{$\mathbb{Z}$}}1 + {ℝ}{{$\mathbb{R}$}}1 {λ}{{$\lambda$}}1 {⟨}{{$\langle$}}1 {⟩}{{$\rangle$}}1 + {α}{{$\alpha$}}1 {β}{{$\beta$}}1 {σ}{{$\sigma$}}1 {₀}{{$_0$}}1 {₁}{{$_1$}}1 + {₂}{{$_2$}}1 {₃}{{$_3$}}1 {∑}{{$\sum$}}1 {∣}{{$\mid$}}1 {×}{{$\times$}}1 + {⊥}{{$\bot$}}1 {∈}{{$\in$}}1 {∘}{{$\circ$}}1 {↑}{{$\uparrow$}}1 {·}{{$\cdot$}}1 + {≡}{{$\equiv$}}1 {⊢}{{$\vdash$}}1 {∅}{{$\emptyset$}}1 {∩}{{$\cap$}}1 {π}{{$\pi$}}1 + {⁻}{{$^-$}}1 {¹}{{$^1$}}1 {²}{{$^2$}}1 {ι}{{$\iota$}}1 {τ}{{$\tau$}}1 {Σ}{{$\Sigma$}}1 {Π}{{$\Pi$}}1 {⇒}{{$\Rightarrow$}}1 {⟹}{{$\Longrightarrow$}}1 {▸}{{$\triangleright$}}1 {⁅}{{[}}1 {≈}{{$\approx$}}1 {∘}{{$\circ$}}1} +% unicode that may appear in running text / \texttt +\DeclareUnicodeCharacter{2264}{\ensuremath{\le}} +\DeclareUnicodeCharacter{2265}{\ensuremath{\ge}} +\DeclareUnicodeCharacter{2260}{\ensuremath{\ne}} +\DeclareUnicodeCharacter{2115}{\ensuremath{\mathbb{N}}} +\DeclareUnicodeCharacter{2124}{\ensuremath{\mathbb{Z}}} +\DeclareUnicodeCharacter{211D}{\ensuremath{\mathbb{R}}} +\DeclareUnicodeCharacter{2080}{\ensuremath{_0}} +\DeclareUnicodeCharacter{2081}{\ensuremath{_1}} +\DeclareUnicodeCharacter{2082}{\ensuremath{_2}} +\DeclareUnicodeCharacter{2083}{\ensuremath{_3}} +\DeclareUnicodeCharacter{2084}{\ensuremath{_4}} +\DeclareUnicodeCharacter{00AC}{\ensuremath{\neg}} +\DeclareUnicodeCharacter{2200}{\ensuremath{\forall}} +\DeclareUnicodeCharacter{2203}{\ensuremath{\exists}} +\DeclareUnicodeCharacter{00B7}{\ensuremath{\cdot}} +\DeclareUnicodeCharacter{27E8}{\ensuremath{\langle}} +\DeclareUnicodeCharacter{27E9}{\ensuremath{\rangle}} +\DeclareUnicodeCharacter{2192}{\ensuremath{\to}} +\DeclareUnicodeCharacter{21D2}{\ensuremath{\Rightarrow}} +\DeclareUnicodeCharacter{2227}{\ensuremath{\wedge}} +\DeclareUnicodeCharacter{2228}{\ensuremath{\vee}} +\DeclareUnicodeCharacter{2208}{\ensuremath{\in}} +\DeclareUnicodeCharacter{03BB}{\ensuremath{\lambda}} +\DeclareUnicodeCharacter{03C3}{\ensuremath{\sigma}} +\DeclareUnicodeCharacter{2223}{\ensuremath{\mid}} diff --git a/code/scripts/p2/backend_agreement.py b/code/scripts/p2/backend_agreement.py new file mode 100644 index 0000000000000000000000000000000000000000..f7ee370045bb4db3c2bbb5e6f27afa0dd7342b7b --- /dev/null +++ b/code/scripts/p2/backend_agreement.py @@ -0,0 +1,385 @@ +#!/usr/bin/env python +"""Agreement + throughput: our strict verifier vs the Kimina Lean server pre-filter. + + hostguard.py run --cores 4 --mem-gb 24 --label agree --wait -- \\ + .venv/bin/python scripts/p2/backend_agreement.py --out .tmp/kimina_agreement/run1 + +Item sets (all verified by BOTH backends on the same (formal statement, extracted Lean code)): + kg known-good proofs of the ceiling test (150 Lean-Workbook-proofs rows saved by the registered + ceiling run + 30 restated), statements derived exactly as `lean_ceiling_test.py` does + adv the 61 frozen adversarial cases of `lean_ceiling_cases.py` (3 bombs run on a 1 GiB / 30 s + pool for ours and on a second, small Kimina server for the candidate) + sample N random code-bearing records (finish_reason != length) of a P2 generation file + +Phases (resumable, JSONL per phase): `ours` (StrictVerifier, the authority), `control` (OUR REPL pool, +compile-only: separates "Kimina server overhead" from "strict-tier overhead"), `kimina` (compile-only +through the server). They run strictly one after another: never two heavy jobs at once. The server(s) +must already be running (toolchains/work/kimina-run/start.sh); this script never starts or stops them. + +Semantics of the comparison. Kimina is a PRE-FILTER: its verdict is `reject` / `maybe` / `error`, never +an accept. The "gated" outcome (accept iff strict accepts AND the pre-filter did not reject) is computed +from the two per-item records; the only accept that can exist is the strict verifier's. +""" + +from __future__ import annotations + +import argparse +import importlib.util +import json +import os +import random +import statistics +import sys +import threading +import time +from concurrent.futures import ThreadPoolExecutor +from pathlib import Path + +REPO = Path(__file__).resolve().parents[2] +sys.path.insert(0, str(REPO)) + +MAIN_REPO = Path("/apps/development/rlhf/align") # data lives in the main checkout's .tmp, not in worktrees +DEFAULT_GENS = MAIN_REPO / ".tmp/p2/r1/baseline/gen_minif2f_c40960.jsonl" +DEFAULT_PROBLEMS = MAIN_REPO / ".tmp/p2/problems/minif2f.jsonl" +DEFAULT_KG_RAW = MAIN_REPO / ".tmp/lean_ceiling/registered/known_good_raw.jsonl" +CORES = 4 + + +def _load(name: str, path: Path): + spec = importlib.util.spec_from_file_location(name, path) + mod = importlib.util.module_from_spec(spec) + sys.modules[name] = mod + spec.loader.exec_module(mod) + return mod + + +LC = _load("lean_ceiling_test", REPO / "scripts" / "lean_ceiling_test.py") +CASES = LC.C + + +def pctl(xs, q): + s = sorted(xs) + return s[min(len(s) - 1, int(q * len(s)))] if s else float("nan") + + +# --------------------------------------------------------------------------- items + + +def build_items(args) -> list[dict]: + from align.verify.lean.reward import extract_lean_code + + items: list[dict] = [] + # known-good + cands = [] + for line in open(args.kg_raw): + r = json.loads(line) + cands.append((r["problem_id"], "lean-workbook-proofs", r["full_proof"])) + for rs in CASES.RESTATED: + cands.append((rs["id"], "restated", CASES.HEADER + rs["code"])) + for pid, src, code in cands: + ds = LC.derive_statement(code) + if ds is None: + continue + items.append(dict(id=f"kg:{pid}", set="kg", sub=src, statement=ds[0], code=code, pool="main")) + # adversarial + for c in CASES.ADVERSARIAL: + stmt = CASES.HEADER + CASES.STATEMENTS[c["statement"]] + comp = c["completion"] if c["completion"] is not None else LC.as_completion( + (CASES.HEADER if c["raw_header"] is None else c["raw_header"]) + c["code"]) + items.append(dict(id=f"adv:{c['id']}", set="adv", sub=c["category"], statement=stmt, + code=extract_lean_code(comp), pool=c["pool"])) + # sampled generations + probs = {} + for line in open(args.problems): + r = json.loads(line) + probs[r["problem_id"]] = r["formal_statement"] + pop = [] + for line in open(args.generations): + r = json.loads(line) + if r["finish_reason"] == "length" or not (r["lean_code"] or "").strip() or r["problem_id"] not in probs: + continue + pop.append(r) + rng = random.Random(args.seed) + for r in rng.sample(pop, min(args.sample, len(pop))): + items.append(dict(id=f"gen:{r['problem_id']}#{r['sample_idx']}", set="sample", sub="minif2f", + statement=probs[r["problem_id"]], code=r["lean_code"], pool="main")) + items.append(dict(id="__meta__", set="meta", population=len(pop))) + return items + + +def done_ids(path: Path) -> dict: + out = {} + if path.exists(): + for line in open(path): + try: + r = json.loads(line) + out[r["id"]] = r + except json.JSONDecodeError: + pass + return out + + +class Sink: + def __init__(self, path: Path): + self.f = open(path, "a") + self.lock = threading.Lock() + + def write(self, rec: dict): + with self.lock: + self.f.write(json.dumps(rec, ensure_ascii=False, default=str) + "\n") + self.f.flush() + + +# --------------------------------------------------------------------------- phases + + +def phase_ours(items, out: Path, args) -> dict: + from align.verify.lean import ReplLimits, ReplPool, StrictConfig, StrictVerifier, find_toolchain + + tc = find_toolchain() + sink = Sink(out / "ours.jsonl") + have = done_ids(out / "ours.jsonl") + walls = {} + for pool_name, lim, cfg in (("main", ReplLimits(), StrictConfig(proof_timeout_s=args.timeout)), + ("bomb", ReplLimits(anon_cap_gb=1.0), StrictConfig(proof_timeout_s=30))): + todo = [it for it in items if it["set"] != "meta" and it["pool"] == pool_name and it["id"] not in have] + if not todo: + continue + n = args.workers if pool_name == "main" else 1 + pool = ReplPool.from_toolchain(tc, n_workers=n, limits=lim, preflight=True, + scratch_dir=str(tc.root / "tmp" / f"agree_{pool_name}")) + try: + ver = StrictVerifier(pool, cfg) + t0 = time.monotonic() + + def one(w, it): + r = ver._verify_on(w, (it["statement"], it["code"])) + sink.write(dict(id=it["id"], set=it["set"], accepted=bool(r.accepted), reasons=list(r.reasons), + stage=r.stage, elapsed_s=r.elapsed_s, undecided=bool(r.undecided))) + + pool.map(one, todo) + walls[pool_name] = time.monotonic() - t0 + finally: + pool.close() + return {"walls": walls, "workers": args.workers} + + +def phase_control(items, out: Path, args) -> dict: + """OUR REPL pool, compile-only (no strictness): the engine-level control for the Kimina server.""" + from align.verify.lean import ReplLimits, ReplPool, find_toolchain + + tc = find_toolchain() + sink = Sink(out / "control.jsonl") + have = done_ids(out / "control.jsonl") + todo = [it for it in items if it["set"] in ("kg", "sample") and it["id"] not in have] + if not todo: + return {} + pool = ReplPool.from_toolchain(tc, n_workers=args.workers, limits=ReplLimits(), preflight=True, + scratch_dir=str(tc.root / "tmp" / "agree_control")) + try: + t0 = time.monotonic() + + def one(w, it): + t = time.monotonic() + r = LC.compile_only(w, it["statement"], it["code"], args.timeout) if it["code"] else {"ok": False, "why": "no_code"} + sink.write(dict(id=it["id"], set=it["set"], ok=bool(r.get("ok")), clean=bool(r.get("clean")), why=r.get("why", ""), + elapsed_s=time.monotonic() - t)) + + pool.map(one, todo) + return {"wall": time.monotonic() - t0, "workers": args.workers} + finally: + pool.close() + + +def phase_kimina(items, out: Path, args) -> dict: + from align.verify_backends.kimina_server import KiminaClient, KiminaPrefilter + + sink = Sink(out / "kimina.jsonl") + have = done_ids(out / "kimina.jsonl") + walls = {} + for pool_name, url, tmo, workers in (("main", args.kimina_url, args.timeout, args.workers), + ("bomb", args.kimina_bomb_url, 30, 1)): + todo = [it for it in items if it["set"] != "meta" and it["pool"] == pool_name and it["id"] not in have] + if not todo: + continue + cl = KiminaClient(url) + if not cl.healthy(): + raise SystemExit(f"Kimina server at {url} is not healthy; start it first") + pf = KiminaPrefilter(cl, tmo) + + def one(it): + t = time.monotonic() + v = pf.check_code(it["code"]) + sink.write(dict(id=it["id"], set=it["set"], verdict=v.verdict, reasons=list(v.reasons), detail=v.detail, + elapsed_s=time.monotonic() - t, server_s=v.elapsed_s)) + + t0 = time.monotonic() + with ThreadPoolExecutor(workers) as ex: + list(ex.map(one, todo)) + walls[pool_name] = time.monotonic() - t0 + return {"walls": walls, "workers": args.workers} + + +# --------------------------------------------------------------------------- report + + +def stats(xs): + return {"n": len(xs), "sum_s": round(sum(xs), 1), "mean": round(statistics.mean(xs), 3) if xs else None, + "p50": round(pctl(xs, .5), 3), "p90": round(pctl(xs, .9), 3), "p99": round(pctl(xs, .99), 3), + "max": round(max(xs), 3) if xs else None} + + +def report(items, out: Path, phase_meta: dict) -> dict: + ours, ctl, kim = done_ids(out / "ours.jsonl"), done_ids(out / "control.jsonl"), done_ids(out / "kimina.jsonl") + by_id = {it["id"]: it for it in items if it["set"] != "meta"} + rep: dict = {"phase_meta": phase_meta, "matrix": {}, "disagreements": [], "n_items": {}} + + def okind(r): # ours: accept / reject / undecided + return "accept" if r["accepted"] else ("undecided" if r["undecided"] else "reject") + + for s in ("kg", "adv", "sample", "ALL"): + ids = [i for i in by_id if (s == "ALL" or by_id[i]["set"] == s) and i in ours and i in kim] + m: dict = {} + for i in ids: + m.setdefault(okind(ours[i]), {}).setdefault(kim[i]["verdict"], 0) + m[okind(ours[i])][kim[i]["verdict"]] += 1 + rep["matrix"][s] = {"n": len(ids), "rows(ours)xcols(kimina)": m} + # ceiling truth + kg_ids = [i for i in by_id if by_id[i]["set"] == "kg" and i in ours and i in kim] + adv_ids = [i for i in by_id if by_id[i]["set"] == "adv" and i in ours and i in kim] + rep["ceiling"] = { + "kg_ours_accept": sum(ours[i]["accepted"] for i in kg_ids), "kg_n": len(kg_ids), + "kg_kimina_maybe": sum(kim[i]["verdict"] == "maybe" for i in kg_ids), + "kg_kimina_reject": sum(kim[i]["verdict"] == "reject" for i in kg_ids), + "adv_ours_accept": sum(ours[i]["accepted"] for i in adv_ids), "adv_n": len(adv_ids), + "adv_kimina_maybe(compile-only would pass these on)": sum(kim[i]["verdict"] == "maybe" for i in adv_ids), + "adv_kimina_reject": sum(kim[i]["verdict"] == "reject" for i in adv_ids), + "gated_adv_accepts": sum(ours[i]["accepted"] and kim[i]["verdict"] != "reject" for i in adv_ids), + } + # disagreements: everything that is not (ours accept & kimina maybe) or (ours reject & kimina reject) + for i in by_id: + if i not in ours or i not in kim: + continue + o, k = ours[i], kim[i] + ok = okind(o) + kv = k["verdict"] + if (ok == "accept" and kv == "maybe") or (ok == "reject" and kv == "reject"): + continue + if ok == "accept" and kv == "reject": + klass = "PREFILTER_FALSE_REJECT (recall loss)" + elif ok == "reject" and kv == "maybe": + klass = "STRICT_ONLY_REJECT (expected: policy/audit layer)" + elif ok == "accept" and kv == "error": + klass = "PREFILTER_UNDECIDED_STRICT_ACCEPT" + elif ok == "undecided" or kv == "error": + klass = "UNDECIDED_ON_ONE_SIDE" + else: + klass = "OTHER" + rep["disagreements"].append(dict(id=i, set=by_id[i]["set"], klass=klass, ours=ok, ours_reasons=o["reasons"], + ours_s=round(o["elapsed_s"], 2), kimina=kv, kimina_reasons=k["reasons"], + kimina_detail=k.get("detail"), kimina_s=round(k["elapsed_s"], 2), + control=({"ok": ctl[i]["ok"], "clean": ctl[i]["clean"], "why": ctl[i]["why"]} if i in ctl else None))) + from collections import Counter + + rep["disagreement_classes"] = dict(Counter(d["klass"] for d in rep["disagreements"])) + # throughput over the sample set (+ kg) : same items, three engines + for name, rows, key in (("ours_strict", ours, "elapsed_s"), ("ours_compile_only", ctl, "elapsed_s"), ("kimina_compile_only", kim, "elapsed_s")): + for s in ("sample", "kg"): + xs = [rows[i][key] for i in by_id if by_id[i]["set"] == s and i in rows] + rep.setdefault("latency_s", {}).setdefault(name, {})[s] = stats(xs) + # busy-time throughput on the SAME items (kg + sample): n / sum(per-item latency) = proofs per second per + # worker-core, independent of the heavy tail's wall-clock packing and of which extra items a phase ran + same = [i for i in by_id if by_id[i]["set"] in ("sample", "kg") and i in ours and i in kim and i in ctl] + rep["busy_throughput_proofs_per_s_per_core"] = { + "n": len(same), + **{nm: round(len(same) / sum(rows[i]["elapsed_s"] for i in same), 4) + for nm, rows in (("ours_strict", ours), ("ours_compile_only", ctl), ("kimina_compile_only", kim))}} + # gated pipeline, from the per-item records (never re-run) + samp = [i for i in by_id if by_id[i]["set"] in ("sample", "kg") and i in ours and i in kim] + if samp: + rep["gated"] = { + "n": len(samp), + "strict_accepts": sum(ours[i]["accepted"] for i in samp), + "gated_accepts": sum(ours[i]["accepted"] and kim[i]["verdict"] != "reject" for i in samp), + "prefilter_rejects": sum(kim[i]["verdict"] == "reject" for i in samp), + "strict_calls_saved": sum(kim[i]["verdict"] == "reject" for i in samp), + "sum_strict_s_saved": round(sum(ours[i]["elapsed_s"] for i in samp if kim[i]["verdict"] == "reject"), 1), + "sum_kimina_s_spent": round(sum(kim[i]["elapsed_s"] for i in samp), 1), + "sum_strict_s_total": round(sum(ours[i]["elapsed_s"] for i in samp), 1), + "sum_strict_s_on_survivors": round(sum(ours[i]["elapsed_s"] for i in samp if kim[i]["verdict"] != "reject"), 1), + } + # agreement on the sample set between the two COMPILE-ONLY engines (our REPL vs Kimina) + both = [i for i in by_id if i in ctl and i in kim and by_id[i]["set"] in ("sample", "kg")] + if both: + agree = sum((ctl[i]["clean"]) == (kim[i]["verdict"] == "maybe") for i in both if kim[i]["verdict"] != "error" and ctl[i]["why"] != "timeout") + n2 = sum(1 for i in both if kim[i]["verdict"] != "error" and ctl[i]["why"] != "timeout") + rep["engine_control"] = {"n_decided_by_both": n2, "clean_agree": agree, + "diffs": [dict(id=i, ours_compile_clean=ctl[i]["clean"], ours_why=ctl[i]["why"], kimina=kim[i]["verdict"], kimina_reasons=kim[i]["reasons"]) + for i in both if kim[i]["verdict"] != "error" and ctl[i]["why"] != "timeout" and ctl[i]["clean"] != (kim[i]["verdict"] == "maybe")]} + return rep + + +def throughput(rep, items, out: Path, phase_meta: dict) -> None: + by_set = {} + for it in items: + if it["set"] != "meta": + by_set.setdefault(it["pool"], []).append(it["id"]) + tp = {} + for name, f, wkey in (("ours_strict", "ours.jsonl", "ours"), ("ours_compile_only", "control.jsonl", "control"), ("kimina_compile_only", "kimina.jsonl", "kimina")): + m = phase_meta.get(wkey) or {} + wall = m.get("wall") if "wall" in m else (m.get("walls") or {}).get("main") + n_main = sum(1 for r in done_ids(out / f).values() if by_set.get("main") and r["id"] in set(by_set["main"])) + if wall: + tp[name] = {"n": n_main, "wall_s": round(wall, 1), "proofs_per_s": round(n_main / wall, 4), + "proofs_per_s_per_core": round(n_main / wall / CORES, 4), "cores": CORES} + rep["throughput"] = tp + + +def main() -> int: + ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) + ap.add_argument("--out", required=True) + ap.add_argument("--phases", default="ours,control,kimina") + ap.add_argument("--workers", type=int, default=CORES) + ap.add_argument("--timeout", type=float, default=180.0) + ap.add_argument("--sample", type=int, default=500) + ap.add_argument("--seed", type=int, default=20261019) + ap.add_argument("--generations", default=str(DEFAULT_GENS)) + ap.add_argument("--problems", default=str(DEFAULT_PROBLEMS)) + ap.add_argument("--kg-raw", default=str(DEFAULT_KG_RAW)) + ap.add_argument("--kimina-url", default="http://127.0.0.1:18000") + ap.add_argument("--kimina-bomb-url", default="http://127.0.0.1:18001") + ap.add_argument("--sets", default="kg,adv,sample", help="comma list; smoke runs use e.g. --sets sample --sample 6") + ap.add_argument("--report-only", action="store_true") + args = ap.parse_args() + if args.workers > CORES: + print(f"host rule: at most {CORES} cores/workers", file=sys.stderr) + return 2 + out = Path(args.out) + out.mkdir(parents=True, exist_ok=True) + items = [i for i in build_items(args) if i["set"] == "meta" or i["set"] in args.sets.split(",")] + meta_it = [i for i in items if i["set"] == "meta"][0] + (out / "items_manifest.json").write_text(json.dumps( + {"n": len(items) - 1, "population_code_bearing": meta_it["population"], "seed": args.seed, + "sets": {s: sum(1 for i in items if i["set"] == s) for s in ("kg", "adv", "sample")}, + "ids": [i["id"] for i in items if i["set"] != "meta"]}, indent=1)) + meta_path = out / "phase_meta.json" + phase_meta = json.loads(meta_path.read_text()) if meta_path.exists() else {} + if not args.report_only: + for ph, fn in (("ours", phase_ours), ("control", phase_control), ("kimina", phase_kimina)): + if ph in args.phases.split(","): + print(f"== phase {ph} ==", flush=True) + t = time.monotonic() + m = fn(items, out, args) + if m: + phase_meta[ph] = m + meta_path.write_text(json.dumps(phase_meta, indent=1)) + print(f" done in {time.monotonic() - t:.0f}s", flush=True) + rep = report(items, out, phase_meta) + throughput(rep, items, out, phase_meta) + (out / "report.json").write_text(json.dumps(rep, indent=1, default=str)) + print(json.dumps({k: rep[k] for k in ("matrix", "ceiling", "disagreement_classes", "throughput", "gated") if k in rep}, indent=1)) + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/code/scripts/p2/build_problems.py b/code/scripts/p2/build_problems.py new file mode 100644 index 0000000000000000000000000000000000000000..ed954118f1b2dd435b9f3c48ad2c189a194448ad --- /dev/null +++ b/code/scripts/p2/build_problems.py @@ -0,0 +1,519 @@ +#!/usr/bin/env python +"""P2 problem sets: evaluation sets (miniF2F, MOBench) and a decontaminated training pool. + +Everything is written as Problem JSONL (docs/P2.md, "Contract"): + + {"problem_id", "source", "split", "formal_statement", "informal_prefix", "statement_sha"} + +Three phases, run in this order (each is idempotent and resumable by re-running): + + eval Goedel's dataset/minif2f.jsonl (244) and dataset/MOBench.jsonl (360) + -> minif2f.jsonl, mobench.jsonl (the verifier-supported subset) and + mobench_unsupported.json (listed, never silently dropped). + pool-sample Stream the PINNED revision of Goedel-LM/Goedel-Pset-v1 (MIT), apply cheap + filters + decontamination against both eval sets, rank by a seeded hash and + keep the top `--candidates` -> pool_candidates.jsonl. + Needs pyarrow, which is NOT an align dependency: run this phase under + .venv-rollout (`align.data.ingest_parquet` raises IngestUnavailable otherwise). + check Run the strict verifier's own reference elaboration (`theorem _align_ref + := by sorry` under the statement's header, exactly what StrictVerifier does + before it looks at a candidate) on the candidates in rank order and keep the + first N that elaborate -> train_pool.jsonl + problems_manifest.json. + Needs the Lean toolchain; takes the heavy-job lock when --workers > 2. + +Why each filter exists (every one is counted in problems_manifest.json): + * unparseable / unsupported shape: the strict verifier scores 0 for ANY candidate against a + reference it cannot parse (`unsupported_statement`); such a problem is unwinnable. + * `:= by` count != 1: Goedel's prompt builder does `split(":= by")[0] + ":= by sorry"`, which + silently TRUNCATES a statement with an earlier `:= by`. + * contamination, independent layers against miniF2F+MOBench: (1) signature sha, (2) + alpha-renaming-invariant signature sha, (3) normalised theorem-name / problem-id match, and + on the ranked candidates (4) overlap of the informal text: any 13-word window, or at least + three distinct 8-word windows. miniF2F and MOBench are + drawn from public olympiad problems that NuminaMath (the source of Goedel-Pset) also + contains, so equal informal problems with DIFFERENT formalisations are the realistic leak. + * elaboration: a statement that does not elaborate under the pinned Mathlib is a wasted + sample budget and a guaranteed 0. + + .venv-rollout/bin/python scripts/p2/build_problems.py --phase pool-sample --out-dir D + .venv/bin/python scripts/p2/build_problems.py --phase eval --out-dir D + .venv/bin/python scripts/p2/build_problems.py --phase check --out-dir D +""" + +from __future__ import annotations + +import argparse +import heapq +import importlib.util +import json +import logging +import os +import re +import sys +import time +import urllib.request +from collections import Counter +from pathlib import Path + +REPO = Path(__file__).resolve().parents[2] +sys.path.insert(0, str(REPO)) + + +def _load_common(): + spec = importlib.util.spec_from_file_location("p2_common", Path(__file__).with_name("p2_common.py")) + mod = importlib.util.module_from_spec(spec) + sys.modules["p2_common"] = mod + spec.loader.exec_module(mod) + return mod + + +C = sys.modules.get("p2_common") or _load_common() +log = logging.getLogger("p2.build_problems") + +GOEDEL_REPO = Path("/apps/development/rlhf/reference-repos/Goedel-Prover-V2") +POOL_REPO = "Goedel-LM/Goedel-Pset-v1" +#: Pinned revision (HF commit sha, last modified 2025-04-18). Licence: the dataset card has NO +#: `license:` metadata; the repo's LICENSE file says "licensed under the MIT License". The +#: informal problems are "from Numina" (README; AI-MO/NuminaMath-CoT and -1.5 are both Apache-2.0 +#: on the hub, the README does not say which one). +POOL_REVISION = "79fc4fbe87b4c694a941bc63ad92dc38d39d41db" +POOL_LICENSE = "MIT (LICENSE file at the pinned revision; no license field in the dataset card metadata); informal problems from Numina (Apache-2.0)" +NGRAM = 13 +#: Second informal layer: LaTeX formatting differences break long windows (an IMO 1965 problem +#: shared 7 distinct 8-grams with a pool problem but no 13-gram), so >= SHORT_HITS distinct +#: 8-gram matches also count. A single 8-gram is common phrasing ("what is the smallest +#: possible value of the") and is not enough. +SHORT_NGRAM = 8 +SHORT_HITS = 3 +MAX_STATEMENT_CHARS = 4000 +DEFAULT_SEED = 20261012 + + +# =========================================================================== eval sets + + +def build_eval_problems(minif2f_path, mobench_path): + """-> (minif2f, mobench_supported, mobench_excluded[(id, reason)], counts).""" + minif2f, mob, excluded = [], [], [] + for r in C.iter_jsonl(minif2f_path): + minif2f.append(_eval_problem(r["name"], "minif2f", r["split"], r["formal_statement"], r.get("informal_prefix"))) + seen = set() + for r in C.iter_jsonl(mobench_path): + pid = r["problem_id"] + if pid in seen: + raise C.ContractError(f"duplicate MOBench problem_id {pid!r}") + seen.add(pid) + # `formal_statement` in this file is the bare theorem; `lean4_code` is header + the + # definitions it needs + docstring + theorem, i.e. what the verifier has to be given. + try: + mob.append(_eval_problem(pid, "mobench", r["split"], r["lean4_code"].lstrip(), r.get("informal_prefix"))) + except C.ContractError as e: + excluded.append((pid, str(e))) + ids = [p["problem_id"] for p in minif2f] + if len(set(ids)) != len(ids): + raise C.ContractError("duplicate miniF2F problem_id") + reasons = Counter(re.sub(r"found \d+", "found N", re.sub(r"\d+$", "", why)) for _, why in excluded) + counts = { + "minif2f": len(minif2f), + "mobench_total": len(mob) + len(excluded), + "mobench_supported": len(mob), + "mobench_excluded": len(excluded), + "mobench_excluded_reasons": dict(reasons), + } + return minif2f, mob, excluded, counts + + +def _eval_problem(pid, source, split, raw_statement, informal): + stmt = C.normalize_formal_statement(raw_statement) + return { + "problem_id": pid, + "source": source, + "split": split, + "formal_statement": stmt, + "informal_prefix": informal, + "statement_sha": C.statement_sha(stmt), # raises ContractError if the verifier can't use it + } + + +# =========================================================================== decontamination + + +class Decontaminator: + """Layered contamination test against a set of evaluation problems.""" + + def __init__(self, eval_problems, ngram: int = NGRAM, short_ngram: int = SHORT_NGRAM, short_hits: int = SHORT_HITS): + self.ngram, self.short_ngram, self.short_hits = ngram, short_ngram, short_hits + self.short_grams: set[tuple[str, ...]] = set() + self.sha: set[str] = set() + self.alpha: set[str] = set() + self.names: set[str] = set() + self.grams: set[tuple[str, ...]] = set() + for p in eval_problems: + st = p["formal_statement"] + self.sha.add(p["statement_sha"]) + self.alpha.add(C.alpha_sha(st)) + self.names.add(C.norm_name(p["problem_id"])) + self.names.add(C.norm_name(C.statement_key(st)[0])) + if p.get("informal_prefix"): + toks = C.informal_tokens(p["informal_prefix"]) + self.grams |= C.ngrams(toks, ngram) + self.short_grams |= C.ngrams(toks, short_ngram) + # Substring layer for names long enough that a substring hit is not noise. + long_names = sorted((n for n in self.names if len(n) >= 8), key=len, reverse=True) + self._name_re = re.compile("|".join(re.escape(n) for n in long_names)) if long_names else None + + def check_statement(self, statement: str, problem_id: str) -> str | None: + """Cheap layers (1-3). Returns the first reason or None.""" + name = C.statement_key(statement)[0] + if C.statement_sha(statement) in self.sha: + return "statement_sha" + if C.alpha_sha(statement) in self.alpha: + return "alpha_sha" + for n in (C.norm_name(name), C.norm_name(problem_id)): + if n in self.names or (self._name_re is not None and self._name_re.search(n)): + return "name" + return None + + def check_informal(self, informal: str | None) -> str | None: + """'informal_13gram' / 'informal_8gram3' / None.""" + if not informal or not self.grams: + return None + toks = C.informal_tokens(informal) + if not self.grams.isdisjoint(C.ngrams(toks, self.ngram)): + return "informal_13gram" + if len(self.short_grams & C.ngrams(toks, self.short_ngram)) >= self.short_hits: + return "informal_8gram3" + return None + + +# =========================================================================== pool sampling + + +def rank_hash(seed: int, problem_id: str) -> str: + return C.sha256_text(f"{seed}|{problem_id}") + + +def sample_pool(rows, decon: Decontaminator, n_candidates: int, seed: int): + """Stream `rows` ({problem_id, formal_statement, informal_statement}) -> (ranked candidates, + counts). Memory is O(n_candidates): a bounded max-heap on the seeded rank hash.""" + counts: Counter = Counter() + heap: list = [] # (-rank_int, tiebreak, rec): root is the WORST (largest hash) kept + for r in rows: + counts["rows"] += 1 + pid, raw = r["problem_id"], r["formal_statement"] + if len(raw) > MAX_STATEMENT_CHARS: + counts["drop_too_long"] += 1 + continue + try: + stmt = C.normalize_formal_statement(raw) + except C.ContractError: + counts["drop_not_single_by_sorry"] += 1 + continue + try: + _name, header, _sig = C.statement_key(stmt) + except C.ContractError: + counts["drop_unsupported_shape"] += 1 + continue + if header.rstrip() != C.GOEDEL_HEADER.rstrip(): + counts["info_nonstandard_header"] += 1 # kept: problem_check re-inserts the header + why = decon.check_statement(stmt, pid) + if why is not None: + counts[f"contaminated_{why}"] += 1 + continue + counts["eligible"] += 1 + h = rank_hash(seed, pid) + rec = { + "problem_id": pid, + "source": "goedel-pset-v1", + "split": "train", + "formal_statement": stmt, + "informal_prefix": r.get("informal_statement"), + "statement_sha": C.statement_sha(stmt), + "rank_hash": h, + } + item = (-int(h, 16), pid, rec) + if len(heap) < n_candidates: + heapq.heappush(heap, item) + elif item > heap[0]: + heapq.heapreplace(heap, item) + ranked = [t[2] for t in sorted(heap, key=lambda t: -t[0])] # ascending rank hash + counts["ranked_kept"] = len(ranked) + return ranked, counts + + +def finalize_candidates(ranked, decon: Decontaminator): + """Rank-order post-filters that need the candidate list: informal-text overlap and + duplicate statements (the same statement under two problem_ids keeps the better rank).""" + counts: Counter = Counter() + out, seen = [], set() + for rec in ranked: + why = decon.check_informal(rec["informal_prefix"]) + if why is not None: + counts[f"contaminated_{why}_among_ranked"] += 1 + continue + if rec["statement_sha"] in seen: + counts["duplicate_statement_among_ranked"] += 1 + continue + seen.add(rec["statement_sha"]) + out.append(rec) + counts["candidates"] = len(out) + return out, counts + + +def download_pool_parquets(dest: Path) -> list[dict]: + """Fetch the pinned parquet shards (about 830 MB, once) and verify each against the LFS oid + the hub reports for that exact revision. Returns [{path, sha256, size}].""" + from align.data import hub + + dest.mkdir(parents=True, exist_ok=True) + entries = [ + e for e in hub.list_repo_files(POOL_REPO, POOL_REVISION, "data") if e.get("type") == "file" and e["path"].endswith(".parquet") + ] + if not entries: + raise RuntimeError(f"no parquet files under {POOL_REPO}@{POOL_REVISION}/data") + out = [] + for e in sorted(entries, key=lambda e: e["path"]): + want = (e.get("lfs") or {}).get("oid") + target = dest / Path(e["path"]).name + if not (target.exists() and target.stat().st_size == e["size"] and (want is None or C.sha256_file(target) == want)): + tmp = target.with_name(target.name + f".tmp{os.getpid()}") + try: + url = hub.resolve_url(POOL_REPO, e["path"], POOL_REVISION) + log.info("downloading %s", url) + with urllib.request.urlopen(url, timeout=120) as resp, open(tmp, "wb") as f: + while True: + b = resp.read(4 << 20) + if not b: + break + f.write(b) + if want is not None and C.sha256_file(tmp) != want: + raise RuntimeError(f"{e['path']}: sha256 differs from the hub's LFS oid {want}") + os.replace(tmp, target) + finally: + if tmp.exists(): + tmp.unlink() + out.append({"path": str(target), "sha256": C.sha256_file(target), "size": target.stat().st_size, "lfs_oid": want}) + return out + + +# =========================================================================== elaboration check + + +def check_elaborates(worker, statement: str, timeout_s: float) -> tuple[bool, str]: + """Does `statement` give a valid reference for StrictVerifier? Mirrors `_run`: header env + (imports, audit prelude, header opens), then `theorem _align_ref := by sorry`.""" + from align.verify.lean.repl_pool import ReplError, ReplProtocolError + from align.verify.lean.strict import AUDIT_PRELUDE, REF_NAME, parse_statement + + ref = parse_statement(statement) + if not ref.ok: + return False, f"unsupported_statement: {ref.problem}" + try: + env = worker.header_env(ref.header_text, AUDIT_PRELUDE) + except ReplProtocolError as e: # the header itself (imports / opens) does not elaborate + return False, "header_error: " + " ".join(str(e).split())[:200] + except ReplError as e: + return False, e.code + try: + resp = worker.command(f"theorem {REF_NAME} {ref.sig_text} := by\n sorry", env, timeout_s) + except ReplError as e: + return False, e.code + msgs = resp.get("messages", []) if isinstance(resp, dict) else [] + errs = [m for m in msgs if isinstance(m, dict) and m.get("severity") == "error"] + if errs or "env" not in resp: + return False, "elab_error: " + " ".join(str(errs[0].get("data", resp))[:200].split()) if errs else "no_env" + return True, "ok" + + +SELF_TEST_GOOD = C.GOEDEL_HEADER + "theorem selftest_good (x : ℕ) (h : x = 2) : x + x = 4 := by sorry" +SELF_TEST_BAD = C.GOEDEL_HEADER + 'theorem selftest_bad (x : ℕ) : x + "a" = 4 := by sorry' + + +def run_check(candidates, n_target: int, pool, timeout_s: float, chunk: int = 64): + """Elaborate candidates in rank order; stop once `n_target` passed. Returns + (selected, log_rows, counts). Selection depends only on rank order and verdicts, never on + chunking: the failures counted are those ranked BEFORE the n_target-th pass.""" + ok_pass, ok_fail = pool.map(lambda w, s: check_elaborates(w, s, timeout_s), [SELF_TEST_GOOD, SELF_TEST_BAD]) + for what, res, want in (("known-good", ok_pass, True), ("known-bad", ok_fail, False)): + if isinstance(res, BaseException): + raise res + if res[0] is not want: + raise RuntimeError(f"elaboration instrument failed its ceiling test: {what} statement gave {res}") + log_rows, selected = [], [] + i = 0 + while i < len(candidates) and len(selected) < n_target: + batch = candidates[i : i + chunk] + t0 = time.monotonic() + res = pool.map(lambda w, rec: check_elaborates(w, rec["formal_statement"], timeout_s), batch) + for rec, r in zip(batch, res): + if isinstance(r, BaseException): + raise r # a programming error or a dead pool must stop the build, not become a "failure" + log_rows.append({"problem_id": rec["problem_id"], "ok": r[0], "why": r[1]}) + i += len(batch) + log.info("checked %d/%d candidates, %d ok (%.0fs/chunk)", i, len(candidates), sum(1 for x in log_rows if x["ok"]), time.monotonic() - t0) + selected = [] + for rec, row in zip(candidates, log_rows): + if row["ok"]: + selected.append(rec) + if len(selected) == n_target: + break + if len(selected) < n_target: + raise RuntimeError( + f"only {len(selected)} of {n_target} requested statements elaborate among {len(candidates)} candidates; " + "re-run pool-sample with a larger --candidates" + ) + cutoff = candidates.index(selected[-1]) + 1 + considered = log_rows[:cutoff] + counts = { + "checked_in_rank_order": cutoff, + "elaborated": sum(1 for x in considered if x["ok"]), + "failed_to_elaborate": sum(1 for x in considered if not x["ok"]), + "failure_reasons": dict(Counter(re.sub(r":.*", "", x["why"]) for x in considered if not x["ok"])), + } + return selected, log_rows[:cutoff], counts + + +# =========================================================================== CLI + + +def _paths(out_dir: Path): + return { + "minif2f": out_dir / "minif2f.jsonl", + "mobench": out_dir / "mobench.jsonl", + "mob_excl": out_dir / "mobench_unsupported.json", + "cand": out_dir / "pool_candidates.jsonl", + "cand_stats": out_dir / "pool_sample_stats.json", + "pool": out_dir / "train_pool.jsonl", + "elab_log": out_dir / "elaboration_log.jsonl", + "eval_elab": out_dir / "eval_elaboration.json", + "manifest": out_dir / "problems_manifest.json", + } + + +def _read_json(p: Path): + return json.loads(p.read_text()) if p.exists() else {} + + +def phase_eval(a, P): + ds = GOEDEL_REPO / "dataset" + minif2f, mob, excl, counts = build_eval_problems(ds / "minif2f.jsonl", ds / "MOBench.jsonl") + C.write_jsonl_atomic(P["minif2f"], minif2f) + C.write_jsonl_atomic(P["mobench"], mob) + P["mob_excl"].write_text(json.dumps([{"problem_id": i, "reason": r} for i, r in excl], indent=1, ensure_ascii=False)) + log.info("eval: %s", counts) + return {"counts": counts, "files": {k: C.sha256_file(P[k]) for k in ("minif2f", "mobench", "mob_excl")}, + "inputs": {f: C.sha256_file(ds / f) for f in ("minif2f.jsonl", "MOBench.jsonl")}} + + +def phase_pool_sample(a, P): + from align.data.ingest_parquet import iter_parquet_rows + + for k in ("minif2f", "mobench"): + if not P[k].exists(): + raise SystemExit(f"{P[k]} missing: run --phase eval first (decontamination needs both eval sets)") + evalp = list(C.iter_jsonl(P["minif2f"])) + list(C.iter_jsonl(P["mobench"])) + decon = Decontaminator(evalp) + files = download_pool_parquets(Path(os.environ.get("HF_HOME", "/apps/development/rlhf/hf_cache")) / "align_streams" / "p2" / POOL_REPO.replace("/", "--") / POOL_REVISION) + + def rows(): + for f in files: + log.info("streaming %s", f["path"]) + yield from iter_parquet_rows(f["path"], columns=["problem_id", "formal_statement", "informal_statement"], prefer_ingested=False) + + ranked, counts = sample_pool(rows(), decon, a.candidates, a.seed) + cands, c2 = finalize_candidates(ranked, decon) + counts.update(c2) + C.write_jsonl_atomic(P["cand"], cands) + stats = {"dataset": POOL_REPO, "revision": POOL_REVISION, "license": POOL_LICENSE, "seed": a.seed, "n_target_candidates": a.candidates, + "ngram": NGRAM, "short_ngram": SHORT_NGRAM, "short_hits": SHORT_HITS, "parquet_files": files, "counts": dict(counts), "candidates_sha256": C.sha256_file(P["cand"]), + "eval_decontam_sets": {"statement_sha": len(decon.sha), "alpha_sha": len(decon.alpha), "names": len(decon.names), "informal_13grams": len(decon.grams), "informal_8grams": len(decon.short_grams)}} + P["cand_stats"].write_text(json.dumps(stats, indent=1)) + log.info("pool-sample: %s", dict(counts)) + return stats + + +def phase_check(a, P): + from align.utils import guard + from align.verify.lean import ReplPool, find_toolchain + from align.verify.lean.repl_pool import ReplLimits + + if not 1 <= a.workers <= 8: + raise SystemExit("--workers must be in [1, 8]") + for k in ("cand", "minif2f", "mobench"): + if not P[k].exists(): + raise SystemExit(f"{P[k]} missing: run the earlier phases first") + if a.workers > 2: + log.info("waiting for the heavy-job lock (Lean pool with %d workers)", a.workers) + guard.acquire_heavy_job_lock("p2-build-problems", blocking=True) + guard.preflight_host_check(strict=True) + tc = find_toolchain() + pool = ReplPool.from_toolchain(tc, n_workers=a.workers, limits=ReplLimits(), preflight=True, + scratch_dir=str(Path(os.environ.get("TMPDIR", str(REPO / ".tmp"))) / "p2_build_pool")) + cands = list(C.iter_jsonl(P["cand"])) + selected, log_rows, counts = run_check(cands, a.n, pool, a.elab_timeout) + # Eval sets: elaboration is REPORTED, not filtered (dropping eval problems would change the benchmark). + ev = {} + for k in ("minif2f", "mobench"): + probs = list(C.iter_jsonl(P[k])) + res = pool.map(lambda w, rec: check_elaborates(w, rec["formal_statement"], a.elab_timeout), probs) + bad = [] + for rec, r in zip(probs, res): + if isinstance(r, BaseException): + raise r + if not r[0]: + bad.append({"problem_id": rec["problem_id"], "why": r[1]}) + ev[k] = {"n": len(probs), "fail_to_elaborate": len(bad), "failures": bad} + P["eval_elab"].write_text(json.dumps(ev, indent=1, ensure_ascii=False)) + C.write_jsonl_atomic(P["elab_log"], log_rows) + final = [{k: r[k] for k in C.PROBLEM_FIELDS} for r in selected] + for r in final: + C.validate_problem(r) + C.write_jsonl_atomic(P["pool"], final) + stats = _read_json(P["cand_stats"]) + manifest = { + "phase_eval": _read_json(P["manifest"]).get("phase_eval"), + "pool": { + "dataset": POOL_REPO, "revision": POOL_REVISION, "license": POOL_LICENSE, "seed": stats.get("seed"), + "requested_n": a.n, "selected": len(final), + "source_rows": stats["counts"].get("rows"), "sampling_counts": stats["counts"], "elaboration": counts, + "elaboration_timeout_s": a.elab_timeout, "decontamination_sets": stats["eval_decontam_sets"], + "train_pool_sha256": C.sha256_file(P["pool"]), "candidates_sha256": stats["candidates_sha256"], + }, + "eval": {"minif2f_sha256": C.sha256_file(P["minif2f"]), "mobench_sha256": C.sha256_file(P["mobench"]), "elaboration": {k: {"n": v["n"], "fail_to_elaborate": v["fail_to_elaborate"]} for k, v in ev.items()}}, + "verifier_pins": tc.pins, + } + P["manifest"].write_text(json.dumps(manifest, indent=1, ensure_ascii=False)) + log.info("pool: selected %d; manifest %s", len(final), P["manifest"]) + return manifest + + +def main(argv=None) -> int: + ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) + ap.add_argument("--phase", required=True, choices=["eval", "pool-sample", "check"]) + ap.add_argument("--out-dir", required=True) + ap.add_argument("--n", type=int, default=2000, help="training pool size") + ap.add_argument("--seed", type=int, default=DEFAULT_SEED) + ap.add_argument("--candidates", type=int, default=5000, help="ranked candidates kept for the elaboration check") + ap.add_argument("--workers", type=int, default=8) + ap.add_argument("--elab-timeout", type=float, default=60.0) + a = ap.parse_args(argv) + logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(name)s: %(message)s") + out = Path(a.out_dir).resolve() + if not str(out).startswith("/apps/"): + raise SystemExit(f"--out-dir {out} must be on /apps (never / or /tmp)") + out.mkdir(parents=True, exist_ok=True) + P = _paths(out) + if a.phase == "eval": + res = phase_eval(a, P) + m = _read_json(P["manifest"]) + m["phase_eval"] = res + P["manifest"].write_text(json.dumps(m, indent=1, ensure_ascii=False)) + elif a.phase == "pool-sample": + phase_pool_sample(a, P) + else: + phase_check(a, P) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/code/scripts/p2/build_sft_data.py b/code/scripts/p2/build_sft_data.py new file mode 100644 index 0000000000000000000000000000000000000000..fab6383ad0753ebe0bf583d78657e831c3b88e68 --- /dev/null +++ b/code/scripts/p2/build_sft_data.py @@ -0,0 +1,482 @@ +#!/usr/bin/env python +"""Build the P2 SFT shard set from Generation + Verification JSONL (expert iteration). + + .venv/bin/python scripts/p2/build_sft_data.py \\ + --generations gens_r0.jsonl gens_r1.jsonl --verifications ver_r0.jsonl ver_r1.jsonl \\ + --problems train_pool.jsonl --eval-problems minif2f.jsonl mobench.jsonl \\ + --cap-tokens 16384 --out-dir DATA_DIR + +Writes `DATA_DIR/shards/sft/train/sft-train-NNNN.*` through `align.data.store.ShardWriter` (the +layout `align.train.stage_smoke.resolve_split_dir` discovers, so `scripts/sft.py` reads it +unchanged), `DATA_DIR/manifest.json` (counts by source / round / kind, token totals, hashes) +and `DATA_DIR/selection.jsonl` (which sample became which row, and why others did not). + +What is trained + (a) kind "first": accepted round-0 samples; + (b) kind "correction": accepted round>0 samples whose prompt, AS GENERATED, already carries + the failed attempt and its Lean errors. +Rows are `prompt_ids + completion_ids` EXACTLY as sampled: nothing is re-tokenised. Re-encoding +text would change the token sequence the policy was sampled from (BPE is not a bijection on +text), and the training signal would no longer be the sampled distribution. The loss mask is the +store's `prompt_len` extra: `collate_sft` labels positions [prompt_len, L) and nothing before, +which is the completion exactly; `readback_check` re-opens the finished shards and asserts it +for every row. + +Defects each rule prevents + * eval leakage: `--problems` must be the TRAINING pool; any generation for a problem that is not + in it, or whose statement sha is in an `--eval-problems` file, RAISES. + * stale verdicts: a verification record whose `code_sha256` differs from the generation's code + raises (the generation file was regenerated under the same keys). + * cap mixing: only `--cap-tokens` is used (required, never defaulted); samples at another cap + are counted and skipped, so a 40960-cap reference run cannot leak into the 16384 training set. + * too-easy problems (8/8 at round 0) carry no learning signal and crowd the mix: excluded unless + `--keep-easy`. + * a correction example whose prompt does NOT contain the failed attempt would teach "write the + answer to an unrelated error message": dropped and counted (`correction_prompt_incomplete`); + one whose parent was in fact ACCEPTED is dropped (`correction_parent_not_failed`). + * length-truncated or EOS-less completions would teach never to stop: dropped; `--allow-no-eos` + is the explicit override. + * an ACCEPTED sample whose displayed theorem is not the problem's statement (`problem_check` + swaps the original in before verification, so tactics that prove the original are credited even + when the model wrote a different, possibly false, theorem above them) would teach the model to + state the wrong theorem: dropped and counted (`drop_statement_not_faithful`). The evaluation + keeps Goedel's crediting rule (comparability); only the TRAINING selection is stricter. + * duplicates: distinct proofs (whitespace/comment-insensitive hash) are preferred; duplicates + only fill the cap with `--fill-duplicates`. +""" + +from __future__ import annotations + +import argparse +import importlib.util +import json +import logging +import sys +from collections import Counter, defaultdict +from pathlib import Path +from typing import Optional + +REPO = Path(__file__).resolve().parents[2] +sys.path.insert(0, str(REPO)) + + +def _load_common(): + spec = importlib.util.spec_from_file_location("p2_common", Path(__file__).with_name("p2_common.py")) + mod = importlib.util.module_from_spec(spec) + sys.modules["p2_common"] = mod + spec.loader.exec_module(mod) + return mod + + +C = sys.modules.get("p2_common") or _load_common() +log = logging.getLogger("p2.sft_data") + +BUILD_VERSION = 2 # `assert_build_is_current` floor (cfg.gates.min_build_version); see docs/P2.md +ROWS_PER_SHARD = 2000 +EASY_MIN_SAMPLES = 8 + + +class SelectionError(C.ContractError): + pass + + +def _norm_ws(s: str) -> str: + return " ".join(s.split()) + + +# =========================================================================== pass 1: index + + +class Index: + """Light per-sample metadata (no token lists): the full records are re-read by byte offset in + pass 2, so memory is O(samples), not O(tokens).""" + + def __init__(self) -> None: + self.loc: dict[tuple, tuple[int, int]] = {} # key -> (file index, byte offset) + self.meta: dict[tuple, dict] = {} + self.files: list[str] = [] + + +def scan_generations(gen_paths, cap_tokens: int, counts: Counter) -> Index: + idx = Index() + for fi, path in enumerate(gen_paths): + idx.files.append(str(path)) + with open(path, "rb") as f: + off = 0 + for lineno, raw in enumerate(f, 1): + here, off = off, off + len(raw) + if not raw.strip(): + continue + try: + rec = json.loads(raw) + except json.JSONDecodeError as e: + raise C.ContractError(f"{path}:{lineno}: malformed JSON ({e.msg})") from e + C.validate_generation(rec) + key = C.gen_key(rec) + if key in idx.loc: + raise C.ContractError(f"{path}:{lineno}: duplicate generation key {key}") + counts["generations_read"] += 1 + if rec["cap_tokens"] != cap_tokens: + counts["skipped_other_cap"] += 1 + continue + idx.loc[key] = (fi, here) + idx.meta[key] = { + "finish_reason": rec["finish_reason"], + "code_sha": C.code_sha(rec["lean_code"]), + "proof_hash": C.proof_hash(rec["lean_code"]) if rec["lean_code"] and rec["lean_code"].strip() else None, + "n_prompt": len(rec["prompt_ids"]), + "n_completion": len(rec["completion_ids"]), + "last_token": rec["completion_ids"][-1], + "parent": rec["parent"], + "model_rev": rec["model_rev"], + } + return idx + + +def read_at(idx: Index, key: tuple) -> dict: + fi, off = idx.loc[key] + with open(idx.files[fi], "rb") as f: + f.seek(off) + return json.loads(f.readline()) + + +def load_verifications(paths, idx: Index, cap_tokens: int, counts: Counter) -> dict[tuple, dict]: + ver: dict[tuple, dict] = {} + for p in paths: + for rec in C.iter_jsonl(p): + key = C.gen_key(rec) + if key[3] != cap_tokens: + counts["verifications_other_cap"] += 1 + continue + if key in ver: + raise C.ContractError(f"{p}: duplicate verification key {key} across files") + if key not in idx.meta: + raise C.ContractError(f"{p}: verification {key} has no generation in the given generation files") + m = idx.meta[key] + if rec["code_sha256"] != m["code_sha"]: + raise C.ContractError( + f"{p}: stale verification for {key}: code_sha256 {rec['code_sha256']} != generation's {m['code_sha']}" + ) + ver[key] = rec + missing = [k for k in idx.meta if k not in ver] + if missing: + raise C.ContractError(f"{len(missing)} generations have no verification record (first: {missing[0]}); verify first") + return ver + + +# =========================================================================== selection + + +def eos_ids_for(model_path: str) -> set[int]: + """EOS token ids from the model's config.json / generation_config.json.""" + out: set[int] = set() + for name in ("config.json", "generation_config.json"): + p = Path(model_path) / name + if p.exists(): + v = json.loads(p.read_text()).get("eos_token_id") + if isinstance(v, int): + out.add(v) + elif isinstance(v, list): + out |= {int(x) for x in v} + if not out: + raise SelectionError(f"no eos_token_id found under {model_path}; pass --eos-ids") + return out + + +def select(idx: Index, ver: dict[tuple, dict], problems: dict[str, dict], a, counts: Counter, eos: set[int]): + """-> (selected [(key, kind)], per_problem stats, dropped log).""" + by_problem: dict[str, list[tuple]] = defaultdict(list) + for key in idx.meta: + by_problem[key[0]].append(key) + selected: list[tuple[tuple, str]] = [] + pstats: dict[str, dict] = {} + dropped: list[dict] = [] + + for pid in sorted(by_problem): + keys = sorted(by_problem[pid], key=lambda k: (k[2], k[1])) + r0 = [k for k in keys if k[2] == 0] + n0, c0 = len(r0), sum(1 for k in r0 if ver[k]["accepted"]) + st = {"n_round0": n0, "accepted_round0": c0, "selected": 0, "easy_excluded": False} + pstats[pid] = st + if n0 >= EASY_MIN_SAMPLES and c0 == n0 and not a.keep_easy: + st["easy_excluded"] = True + counts["easy_problems_excluded"] += 1 + counts["easy_samples_excluded"] += c0 + continue + corr, first = [], [] + for k in keys: + if not ver[k]["accepted"]: + continue + m = idx.meta[k] + why = _reject_sample(k, m, a, eos) + if why is None and not C.displayed_statement_matches(problems[pid]["formal_statement"], read_at(idx, k)["completion_text"]): + why = "statement_not_faithful" + if why is not None: + counts[f"drop_{why}"] += 1 + dropped.append({"key": list(k), "why": why}) + continue + if k[2] == 0: + first.append(k) + else: + why = _check_correction(idx, ver, k) + if why is not None: + counts[f"drop_{why}"] += 1 + dropped.append({"key": list(k), "why": why}) + continue + corr.append(k) + if getattr(a, "rank", "order") == "shortest": + # expert-iteration keep rule: among accepted samples prefer the shortest completion (ties: key order) + first.sort(key=lambda k: (idx.meta[k]["n_completion"], k)) + corr.sort(key=lambda k: (idx.meta[k]["n_completion"], k)) + chosen: list[tuple[tuple, str]] = [] + seen_hash: set[str] = set() + + def room(kind: str, kind_limit: int) -> bool: + return len(chosen) < a.max_per_problem and sum(1 for _, kd in chosen if kd == kind) < kind_limit + + # Corrections first (rarer, and the only source of the self-correction skill), then + # first attempts; within a kind, distinct proofs before any duplicate. + for kind, cands, kind_limit in (("correction", corr, a.max_correction_per_problem), ("first", first, a.max_per_problem)): + for k in cands: + h = idx.meta[k]["proof_hash"] + if room(kind, kind_limit) and h not in seen_hash: + seen_hash.add(h) + chosen.append((k, kind)) + if a.fill_duplicates: + for k in cands: + if room(kind, kind_limit) and (k, kind) not in chosen: + chosen.append((k, kind)) + n_acc = len(corr) + len(first) + counts["accepted_candidates"] += n_acc + counts["not_selected_cap_or_duplicate"] += n_acc - len(chosen) + st["selected"] = len(chosen) + selected += chosen + return selected, pstats, dropped + + +def _reject_sample(k: tuple, m: dict, a, eos: set[int]) -> Optional[str]: + if m["finish_reason"] != "stop": + return "finish_reason_not_stop" # an accepted length-capped sample cannot exist; belt and braces + if m["proof_hash"] is None: + return "accepted_without_code" + if not a.allow_no_eos and m["last_token"] not in eos: + return "completion_lacks_eos" + if a.max_seq_len is not None and m["n_prompt"] + m["n_completion"] > a.max_seq_len: + return "over_max_seq_len" + return None + + +def _check_correction(idx: Index, ver: dict[tuple, dict], key: tuple) -> Optional[str]: + m = idx.meta[key] + par = m["parent"] + pkey = (par["problem_id"], int(par["sample_idx"]), int(par["round"]), key[3]) + if pkey not in idx.meta: + raise SelectionError(f"correction {key} names parent {pkey} which is not among the generations at this cap") + if ver[pkey]["accepted"]: + return "correction_parent_not_failed" + child, parent = read_at(idx, key), read_at(idx, pkey) + prompt = _norm_ws(child["prompt_text"]) + tail = _norm_ws(parent["completion_text"])[-200:] + if not tail or tail not in prompt or "rror" not in prompt: + return "correction_prompt_incomplete" + return None + + +# =========================================================================== write + + +def write_shards(out_dir: Path, idx: Index, selected, problems: dict[str, dict], held: set[str]): + from align.data.store import ShardWriter + + dirs = {"train": out_dir / "shards" / "sft" / "train", "held": out_dir / "shards" / "sft" / "held"} + writers: dict[str, ShardWriter] = {} + nrows: Counter = Counter() + receipts: dict[str, list] = defaultdict(list) + sel_log = [] + + def writer(split: str) -> ShardWriter: + w = writers.get(split) + if w is None or w.n_rows >= ROWS_PER_SHARD: + if w is not None: + receipts[split].append(w.finalize()) + w = writers[split] = ShardWriter(dirs[split], f"sft-{split}-{len(receipts[split]):04d}") + return w + + toks = Counter() + by: Counter = Counter() + for key, kind in selected: + rec = read_at(idx, key) + pid = key[0] + split = "held" if pid in held else "train" + ids = rec["prompt_ids"] + rec["completion_ids"] + src = problems[pid]["source"] + writer(split).add_row( + ids, + problem_id=pid, + subject=src, + boundary_positions=[], + prompt_len=len(rec["prompt_ids"]), + kind=kind, + round=key[2], + sample_idx=key[1], + cap_tokens=key[3], + n_completion=len(rec["completion_ids"]), + proof_sha=idx.meta[key]["proof_hash"], + model_rev=rec["model_rev"], + ) + nrows[split] += 1 + toks[f"{split}_prompt_tokens"] += len(rec["prompt_ids"]) + toks[f"{split}_completion_tokens"] += len(rec["completion_ids"]) + by[f"{split}/{src}/round{key[2]}/{kind}"] += 1 + sel_log.append({"key": list(key), "kind": kind, "split": split}) + for split, w in writers.items(): + receipts[split].append(w.finalize()) + return receipts, nrows, toks, by, sel_log + + +def readback_check(out_dir: Path, idx: Index, selected, held: set[str]) -> int: + """Re-open the written shards and assert, for EVERY row, that the stored tokens equal the + sampled prompt+completion and that the loss mask (as `collate_sft` builds it) covers exactly + the completion positions. Returns the number of rows checked.""" + import numpy as np + + from align.data.loader import collate_sft + from align.data.store import ShardDataset + + n = 0 + for split in ("train", "held"): + d = out_dir / "shards" / "sft" / split + want = [(k, kd) for k, kd in selected if (k[0] in held) == (split == "held")] + if not want: + continue + ds = ShardDataset(d) + if len(ds) != len(want): + raise SelectionError(f"{split}: {len(ds)} stored rows != {len(want)} selected") + for i, (key, _kind) in enumerate(want): + rec = read_at(idx, key) + row = ds[i] + p = len(rec["prompt_ids"]) + if not np.array_equal(row.input_ids, np.asarray(rec["prompt_ids"] + rec["completion_ids"], dtype=row.input_ids.dtype)): + raise SelectionError(f"{key}: stored tokens differ from the sampled prompt+completion") + if int(row.extras["prompt_len"]) != p: + raise SelectionError(f"{key}: prompt_len {row.extras['prompt_len']} != {p}") + lab = collate_sft([row], pad_token_id=0).labels[0].numpy() + if not (np.all(lab[:p] == -100) and np.array_equal(lab[p:], row.input_ids[p:].astype(lab.dtype))): + raise SelectionError(f"{key}: loss mask is not exactly the completion span") + n += 1 + return n + + +# =========================================================================== CLI + + +def main(argv=None) -> int: + ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) + ap.add_argument("--generations", nargs="+", required=True) + ap.add_argument("--verifications", nargs="+", required=True) + ap.add_argument("--problems", nargs="+", required=True, help="TRAINING pool Problem JSONL (split must be train)") + ap.add_argument("--eval-problems", nargs="+", required=True, help="evaluation Problem JSONL; none of their statements may appear in --problems") + ap.add_argument("--cap-tokens", type=int, required=True, help="only samples generated at this cap are used (no default on purpose)") + ap.add_argument("--out-dir", required=True) + ap.add_argument("--max-per-problem", type=int, default=4) + ap.add_argument("--max-correction-per-problem", type=int, default=1, help="slots reserved for correction examples inside --max-per-problem") + ap.add_argument("--keep-easy", action="store_true", help="keep problems solved n/n at round 0 (n >= 8)") + ap.add_argument("--rank", choices=("order", "shortest"), default="order", + help="which accepted samples win the per-problem cap: sampling order (default) or shortest completion first") + ap.add_argument("--fill-duplicates", action="store_true", help="fill the cap with duplicate proofs when distinct ones run out") + ap.add_argument("--max-seq-len", type=int, default=None, help="drop (never truncate) rows longer than this") + ap.add_argument("--allow-no-eos", action="store_true") + ap.add_argument("--eos-ids", type=int, nargs="*", default=None) + ap.add_argument("--held-frac", type=float, default=0.0, help="fraction of PROBLEMS (not rows) routed to a held-out split") + ap.add_argument("--seed", type=int, default=0) + a = ap.parse_args(argv) + logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(name)s: %(message)s") + out = Path(a.out_dir).resolve() + if not str(out).startswith("/apps/"): + raise SystemExit(f"--out-dir {out} must be on /apps") + if out.exists() and any(out.iterdir()): + raise SystemExit(f"{out} is not empty: refusing to mix shards from two builds") + if a.max_correction_per_problem > a.max_per_problem or a.max_per_problem < 1: + raise SystemExit("need 1 <= max-correction-per-problem <= max-per-problem") + + problems: dict[str, dict] = {} + for p in a.problems: + problems.update(C.load_problems(p)) + bad = [pid for pid, r in problems.items() if r["split"] != "train"] + if bad: + raise SelectionError(f"{len(bad)} problems in --problems are not split=train (first: {bad[0]})") + eval_sha: set[str] = set() + for p in a.eval_problems: + eval_sha |= {r["statement_sha"] for r in C.load_problems(p).values()} + leaked = [pid for pid, r in problems.items() if r["statement_sha"] in eval_sha] + if leaked: + raise SelectionError(f"{len(leaked)} training problems share a statement with an eval set (first: {leaked[0]})") + + counts: Counter = Counter() + idx = scan_generations(a.generations, a.cap_tokens, counts) + if not idx.meta: + raise SelectionError(f"no generations at cap_tokens={a.cap_tokens}") + foreign = sorted({k[0] for k in idx.meta} - set(problems)) + if foreign: + raise SelectionError(f"{len(foreign)} generated problems are not in the training pool (first: {foreign[0]}): refusing to train on them") + ver = load_verifications(a.verifications, idx, a.cap_tokens, counts) + revs = {m["model_rev"] for m in idx.meta.values()} + model_paths = {read_at(idx, next(iter(idx.loc)))["model_path"]} + eos = set(a.eos_ids) if a.eos_ids else eos_ids_for(next(iter(model_paths))) + selected, pstats, dropped = select(idx, ver, problems, a, counts, eos) + if not selected: + raise SelectionError("nothing selected: check the verification file (all rejected?) and the filters; counts=" + json.dumps(dict(counts))) + + import hashlib + + held: set[str] = set() + if a.held_frac > 0: + for pid in {k[0] for k, _ in selected}: + if int(hashlib.sha256(f"{a.seed}|held|{pid}".encode()).hexdigest(), 16) % 10_000 < a.held_frac * 10_000: + held.add(pid) + + out.mkdir(parents=True, exist_ok=True) + try: + receipts, nrows, toks, by, sel_log = write_shards(out, idx, selected, problems, held) + n_checked = readback_check(out, idx, selected, held) + except BaseException: + import shutil + + shutil.rmtree(out / "shards", ignore_errors=True) # a half-written shard set looks usable + raise + total = sum(nrows.values()) + sum_receipt = sum(r.n_rows for rs in receipts.values() for r in rs) + if not (total == len(selected) == sum_receipt == n_checked): + raise SelectionError(f"row accounting differs: selected {len(selected)}, written {total}, receipts {sum_receipt}, read back {n_checked}") + (out / "selection.jsonl").write_text("".join(json.dumps(r) + "\n" for r in sel_log)) + (out / "dropped.jsonl").write_text("".join(json.dumps(r) + "\n" for r in dropped)) + manifest = { + "build_kind": "p2_sft", + "build_version": BUILD_VERSION, + "pins_verified": True, # the row accounting above reconciled; there are no corpus pins in P2 + "pin_violations": [], + "cap_tokens": a.cap_tokens, + "params": {k: getattr(a, k) for k in ("max_per_problem", "max_correction_per_problem", "keep_easy", "fill_duplicates", + "max_seq_len", "allow_no_eos", "held_frac", "seed", "rank")}, + "eos_ids": sorted(eos), + "model_revs": sorted(revs), + "rows": {"total": total, **{s: nrows[s] for s in ("train", "held")}}, + "rows_by_split_source_round_kind": dict(sorted(by.items())), + "tokens": dict(toks), + "problems": {"with_rows": len({k[0] for k, _ in selected}), "in_pool": len(problems), "seen_in_generations": len(pstats), + "easy_excluded": counts["easy_problems_excluded"], "held_problems": len(held)}, + "counts": dict(counts), + "inputs": {"generations": {str(p): C.sha256_file(p) for p in a.generations}, + "verifications": {str(p): C.sha256_file(p) for p in a.verifications}, + "problems": {str(p): C.sha256_file(p) for p in a.problems}, + "eval_problems": {str(p): C.sha256_file(p) for p in a.eval_problems}}, + "shards": {s: [r.to_json() for r in rs] for s, rs in receipts.items()}, + "readback_rows_checked": n_checked, + } + (out / "manifest.json").write_text(json.dumps(manifest, indent=1)) + print(json.dumps({"rows": manifest["rows"], "tokens": manifest["tokens"], "counts": manifest["counts"]})) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/code/scripts/p2/build_valid.py b/code/scripts/p2/build_valid.py new file mode 100644 index 0000000000000000000000000000000000000000..ff28d9c1480871fa59c35be9eac50ae89496cf43 --- /dev/null +++ b/code/scripts/p2/build_valid.py @@ -0,0 +1,170 @@ +"""Build a miniF2F VALIDATION problem file for model selection. + +WHY. Round selection, the weight-averaging coefficient and the self-distillation stop rule are +all tuned by looking at scores. Tuning them on miniF2F-test makes the final test number partly +a tuning artefact (adaptive overfitting to 244 problems). miniF2F-valid (244 different problems, +same distribution) is the standard dev split; every development decision is taken on it and the +test split is touched only by the pre-registered final evaluation. + +Source: cat-searcher/minif2f-lean4 `valid.jsonl`, pinned revision below. Statements are rewritten +into the SAME envelope as our test file (Goedel's header, `:= by sorry`), so prompts and the +verifier see identical structure. Decontamination against miniF2F-test, MOBench and the training +pool is by signature sha (statement_sha), renaming-invariant sha (alpha_sha), theorem name and +informal text (build_problems' 13-gram / 8-gram layers); any overlap is dropped and counted. Elaboration under the pinned Mathlib is checked with <= 2 +Lean workers (not a heavy job), and failures are dropped and listed. +""" + +from __future__ import annotations + +import argparse +import hashlib +import importlib.util +import json +import os +import sys +from pathlib import Path + +REPO = Path(__file__).resolve().parents[2] +sys.path.insert(0, str(REPO)) + + +def _load_sibling(name: str): + """Load scripts/p2/.py by path (the repo idiom; keeps the dependency firewall's import scan clean). + A cached module is reused only if it IS this sibling file, and a failed import is not left cached.""" + path = Path(__file__).with_name(f"{name}.py").resolve() + cached = sys.modules.get(name) + if cached is not None and Path(getattr(cached, "__file__", "") or "").resolve() == path: + return cached + spec = importlib.util.spec_from_file_location(name, path) + mod = importlib.util.module_from_spec(spec) + sys.modules[name] = mod + try: + spec.loader.exec_module(mod) + except BaseException: + sys.modules.pop(name, None) + raise + return mod + + +C = _load_sibling("p2_common") + +SRC_REPO = "cat-searcher/minif2f-lean4" +SRC_REV = "70a1249ce240667f6bcdd1ccd62f847f0e065d57" +HEADER = "import Mathlib\nimport Aesop\n\nset_option maxHeartbeats 0\n\nopen BigOperators Real Nat Topology Rat\n\n" + + +def to_problem(row: dict) -> dict: + stmt = row["formal_statement"].rstrip() + if stmt.endswith(":= sorry"): + stmt = stmt[: -len(":= sorry")] + ":= by sorry" + elif not stmt.endswith(":= by sorry"): + raise C.ContractError(f"{row['id']}: statement does not end in a sorry proof") + informal = (row.get("informal_stmt") or "").strip() + prefix = f"/-- {informal}-/\n" if informal else "" + full = HEADER + prefix + stmt + return { + "problem_id": row["id"], + "source": "minif2f", + "split": "valid", + "formal_statement": full, + "informal_prefix": prefix or None, + "statement_sha": C.statement_sha(full), + } + + +def elaborate(pool, kept: list, timeout_s: float) -> tuple[list, list]: + """Elaboration check with the grader ceiling-tested FIRST (known-good must pass, known-bad must + fail), as build_problems.run_check does. Without it a broken toolchain or header would mark every + statement as an elaboration failure and silently produce an empty dev split. `pool.map` returns + exceptions in place; any exception stops the build instead of becoming a "failure" row.""" + bp = _load_sibling("build_problems") + probe = pool.map(lambda w, s: bp.check_elaborates(w, s, timeout_s), [bp.SELF_TEST_GOOD, bp.SELF_TEST_BAD]) + for what, res, want in (("known-good", probe[0], True), ("known-bad", probe[1], False)): + if isinstance(res, BaseException): + raise res + if res[0] is not want: + raise RuntimeError(f"elaboration instrument failed its ceiling test: {what} statement gave {res}") + res = pool.map(lambda w, rec: bp.check_elaborates(w, rec["formal_statement"], timeout_s), kept) + good, bad = [], [] + for p, r in zip(kept, res): + if isinstance(r, BaseException): + raise r + ok, why = r + if ok: + good.append(p) + else: + bad.append({"id": p["problem_id"], "reason": why}) + return good, bad + + +def main(argv=None) -> int: + ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) + ap.add_argument("--src", required=True, help="the pinned valid.jsonl") + ap.add_argument("--problems-dir", required=True, help="dir holding minif2f.jsonl, mobench.jsonl, train_pool.jsonl") + ap.add_argument("--out", required=True) + ap.add_argument("--workers", type=int, default=2) + ap.add_argument("--elab-timeout", type=float, default=60.0) + ap.add_argument("--no-elab", action="store_true", help="skip the Lean elaboration check (tests)") + a = ap.parse_args(argv) + if not 1 <= a.workers <= 2: + raise SystemExit("--workers must be 1 or 2 (this is not a heavy job)") + + pdir = Path(a.problems_dir) + shas, alphas, names, guarded = set(), set(), set(), [] + for f in ("minif2f.jsonl", "mobench.jsonl", "train_pool.jsonl"): + for r in C.iter_jsonl(pdir / f): + shas.add(r["statement_sha"]) + alphas.add(C.alpha_sha(r["formal_statement"])) + names.add(r["problem_id"]) + guarded.append(r) + # informal-text layers (13-gram / three 8-grams): "same informal problem, different formalisation" is the realistic leak, and + # build_problems applies it to the pool against test+MOBench only, never against this dev split + informal = _load_sibling("build_problems").Decontaminator(guarded) + + kept, dropped = [], [] + for row in C.iter_jsonl(a.src): + try: + p = to_problem(row) + except C.ContractError as e: + dropped.append({"id": row.get("id"), "reason": f"unsupported: {e}"}) + continue + if p["statement_sha"] in shas or C.alpha_sha(p["formal_statement"]) in alphas or p["problem_id"] in names: + dropped.append({"id": p["problem_id"], "reason": "overlaps test/MOBench/pool"}) + continue + why = informal.check_informal(p["informal_prefix"]) + if why is not None: + dropped.append({"id": p["problem_id"], "reason": f"informal text overlaps test/MOBench/pool ({why})"}) + continue + kept.append(p) + + elab_bad = [] + if not a.no_elab and kept: + from align.utils import guard + from align.verify.lean import ReplPool, find_toolchain + from align.verify.lean.repl_pool import ReplLimits + + guard.preflight_host_check(strict=True) + # scratch on /apps (repo .tmp), never $TMPDIR: /apps/CLAUDE.md rule 5 (no writes to / or /tmp) + pool = ReplPool.from_toolchain(find_toolchain(), n_workers=a.workers, limits=ReplLimits(), preflight=True, + scratch_dir=str(REPO / ".tmp" / "p2_valid_pool")) + try: + kept, elab_bad = elaborate(pool, kept, a.elab_timeout) + finally: + pool.close() + + # split first, then its manifest, both atomic. The manifest records the split's sha256, so a kill + # between the two writes (new split, old manifest) is DETECTABLE by any reader, never silent. + C.write_jsonl_atomic(a.out, kept) + manifest = {"source": SRC_REPO, "revision": SRC_REV, "kept": len(kept), "dropped": dropped, + "elaboration_failures": elab_bad, "header": HEADER, + "split_sha256": hashlib.sha256(Path(a.out).read_bytes()).hexdigest()} + mpath = Path(a.out + ".manifest.json") + mtmp = mpath.with_name(mpath.name + f".tmp{os.getpid()}") + mtmp.write_text(json.dumps(manifest, indent=1)) + os.replace(mtmp, mpath) + print(json.dumps({"kept": len(kept), "dropped": len(dropped), "elab_failed": len(elab_bad)})) + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/code/scripts/p2/check_qwen3_snapshot.py b/code/scripts/p2/check_qwen3_snapshot.py new file mode 100644 index 0000000000000000000000000000000000000000..4c3f5e51c5d909edcec0b10ea5eae1114ac93600 --- /dev/null +++ b/code/scripts/p2/check_qwen3_snapshot.py @@ -0,0 +1,106 @@ +#!/usr/bin/env python +"""CPU-only compatibility check of a HF snapshot against align's Qwen3 loader (no weights are loaded). + + .venv/bin/python scripts/p2/check_qwen3_snapshot.py SNAPSHOT_DIR [--json] + + 1. config.json parses through `align.model.qwen3_checkpoint.read_qwen3_config` (it REFUSES every variant align does + not implement: sliding window, scaled RoPE, biases, tied head, non-silu, ...); + 2. the safetensors index's `weight_map` keys equal EXACTLY the key set `hf_to_align` maps (no missing, no extra); + 3. every shard exists and its header-declared tensor extents fit the file (a truncated download fails here); + 4. every tensor's shape (read from the shard HEADER, 8 bytes + JSON, no tensor data) equals the shape the config implies; + 5. dtypes are reported (bf16 expected). +Exit 0 compatible, 1 not. Memory: a few MB. Threads: 1. +""" + +from __future__ import annotations + +import argparse +import json +import os +import struct +import sys +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parents[2])) + +from align.model.checkpoint import INDEX_NAME # noqa: E402 +from align.model.qwen3_checkpoint import _expected_shapes, _table, read_qwen3_config # noqa: E402 + + +def read_header(path: str) -> dict: + """The safetensors header dict, plus `__file_ok__`: whether the declared extents fit the file.""" + size = os.path.getsize(path) + with open(path, "rb") as f: + (n,) = struct.unpack(" 100 * 1024 * 1024 or 8 + n > size: + raise ValueError(f"{path}: implausible safetensors header length {n}") + h = json.loads(f.read(n)) + end = max((v["data_offsets"][1] for k, v in h.items() if k != "__metadata__"), default=0) + h["__file_ok__"] = 8 + n + end == size + h["__declared_bytes__"] = 8 + n + end + h["__file_bytes__"] = size + return h + + +def check(snapshot: str) -> dict: + snap = Path(snapshot) + rep: dict = {"snapshot": str(snap), "problems": []} + P = rep["problems"] + cfg = read_qwen3_config(str(snap)) + rep["config"] = {k: getattr(cfg, k) for k in ("n_layers", "hidden_size", "intermediate_size", "n_heads", "n_kv_heads", "head_dim", + "vocab_size", "rope_theta", "rms_norm_eps", "max_position_embeddings", "tie_word_embeddings")} + want = set(_table(cfg)) + shapes = _expected_shapes(cfg) + index = snap / INDEX_NAME + if not index.is_file(): + P.append(f"no {INDEX_NAME}") + return rep + wm = json.loads(index.read_text())["weight_map"] + rep["index"] = {"tensors": len(wm), "shards": sorted(set(wm.values()))} + missing, extra = sorted(want - set(wm)), sorted(set(wm) - want) + if missing: + P.append(f"{len(missing)} key(s) hf_to_align needs are absent from the index: {missing[:5]}") + if extra: + P.append(f"{len(extra)} index key(s) hf_to_align cannot map: {extra[:5]}") + dtypes: dict = {} + seen = 0 + for shard in rep["index"]["shards"]: + sp = snap / shard + if not sp.is_file(): + P.append(f"shard {shard} named by the index is absent") + continue + h = read_header(str(sp)) + if not h["__file_ok__"]: + P.append(f"shard {shard}: header declares {h['__declared_bytes__']} bytes, file has {h['__file_bytes__']} (truncated or padded)") + for k, v in h.items(): + if k.startswith("__"): + continue + seen += 1 + dtypes[v["dtype"]] = dtypes.get(v["dtype"], 0) + 1 + if wm.get(k) != shard: + P.append(f"{k}: in {shard} but the index says {wm.get(k)}") + if k in shapes and tuple(v["shape"]) != shapes[k]: + P.append(f"{k}: shape {tuple(v['shape'])} != {shapes[k]} implied by the config") + rep["tensors_seen_in_shard_headers"], rep["dtypes"] = seen, dtypes + if seen != len(wm): + P.append(f"shard headers carry {seen} tensors, the index lists {len(wm)}") + rep["compatible"] = not P + return rep + + +def main(argv=None) -> int: + ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) + ap.add_argument("snapshot") + ap.add_argument("--json", action="store_true") + a = ap.parse_args(argv) + try: + rep = check(a.snapshot) + except Exception as e: # noqa: BLE001 -- a refusal from the config parser is the answer + rep = {"snapshot": a.snapshot, "compatible": False, "problems": [f"{type(e).__name__}: {e}"]} + print(json.dumps(rep, indent=1, default=str) if a.json else + ("COMPATIBLE" if rep.get("compatible") else "NOT COMPATIBLE") + "\n" + "\n".join(f" - {p}" for p in rep["problems"])) + return 0 if rep.get("compatible") else 1 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/code/scripts/p2/check_seams.py b/code/scripts/p2/check_seams.py new file mode 100644 index 0000000000000000000000000000000000000000..bd781f3e8b93a12bef977c1a07c660224c2384eb --- /dev/null +++ b/code/scripts/p2/check_seams.py @@ -0,0 +1,154 @@ +#!/usr/bin/env python +"""P2 integrator: assert the seams between the independently built pieces, on real files. + + .venv/bin/python scripts/p2/check_seams.py keys --generations G.jsonl --verifications V.jsonl + .venv/bin/python scripts/p2/check_seams.py sft --generations G0 G1 --verifications V0 V1 --shards D/shards + +Each check exists because the pieces were written by different hands against a prose contract, +and each failure below is silent when only one side is looked at: + + keys generation -> verification. A verification file whose key set differs from the + generation file's (a sample never verified, a verdict for a sample that is not on disk, a + verdict at another cap or round) scores a different experiment than the one generated. + Also checks `code_sha256` against the generation on disk (a stale verdict). + sft verification -> trainer input. The tokens the trainer takes loss on must be EXACTLY the + `completion_ids` vLLM sampled for a sample the verifier ACCEPTED, and the context must be + exactly its `prompt_ids`. This re-derives the labels through the same `ShardDataset` + + `collate_sft` the trainer uses and does not trust `build_sft_data.py`'s own read-back + (an independent implementation of the same property is the only thing that catches a + shared misunderstanding). + +Exit status 0 only if every check passes; a report JSON is printed either way. +""" + +from __future__ import annotations + +import argparse +import importlib.util +import json +import sys +from pathlib import Path + +REPO = Path(__file__).resolve().parents[2] +sys.path.insert(0, str(REPO)) + + +def _common(): + if "p2_common" in sys.modules: + return sys.modules["p2_common"] + spec = importlib.util.spec_from_file_location("p2_common", Path(__file__).with_name("p2_common.py")) + mod = importlib.util.module_from_spec(spec) + sys.modules["p2_common"] = mod + spec.loader.exec_module(mod) + return mod + + +class SeamError(AssertionError): + pass + + +def _load(paths, key_fields): + out: dict[tuple, dict] = {} + for p in paths: + for rec in _common().iter_jsonl(p): + k = tuple(rec[f] for f in key_fields) + if k in out: + raise SeamError(f"duplicate key {k} in {p}") + out[k] = rec + return out + + +def check_keys(gen_paths, ver_paths) -> dict: + """Generation key set == verification key set, and every verdict is for the code on disk.""" + C = _common() + gens = _load(gen_paths, C.KEY_FIELDS) + vers = _load(ver_paths, C.KEY_FIELDS) + missing = sorted(set(gens) - set(vers)) + extra = sorted(set(vers) - set(gens)) + if missing or extra: + raise SeamError(f"key sets differ: {len(missing)} generated but unverified (e.g. {missing[:2]}), " + f"{len(extra)} verified but not generated (e.g. {extra[:2]})") + stale = [k for k, g in gens.items() if vers[k]["code_sha256"] != C.code_sha(g["lean_code"])] + if stale: + raise SeamError(f"{len(stale)} verdicts were computed for different code than the generation holds, e.g. {stale[:2]}") + # a length-capped sample is never a proof, whatever the verdict file says + bad = [k for k, g in gens.items() if g["finish_reason"] == "length" and vers[k]["accepted"]] + if bad: + raise SeamError(f"{len(bad)} length-truncated samples are marked accepted, e.g. {bad[:2]}") + rounds: dict = {} + for k, v in vers.items(): + r = rounds.setdefault(k[2], [0, 0]) + r[0] += 1 + r[1] += int(bool(v["accepted"])) + return {"keys": len(gens), "by_round": {str(r): {"samples": a, "accepted": b} for r, (a, b) in sorted(rounds.items())}} + + +def check_sft(gen_paths, ver_paths, shards_dir) -> dict: + """Every stored row's loss tokens == completion_ids of an ACCEPTED generation, context == prompt_ids.""" + import numpy as np + + from align.data.loader import collate_sft + from align.data.store import ShardDataset + + C = _common() + gens = _load(gen_paths, C.KEY_FIELDS) + vers = _load(ver_paths, C.KEY_FIELDS) + rows = 0 + kinds: dict = {} + seen: set = set() + for split in ("train", "held"): + d = Path(shards_dir) / "sft" / split + if not d.is_dir(): + continue + ds = ShardDataset(d) + for i in range(len(ds)): + row = ds[i] + ex = row.extras + key = (row.problem_id, int(ex["sample_idx"]), int(ex["round"]), int(ex["cap_tokens"])) + if key not in gens: + raise SeamError(f"{split}[{i}] {key}: no such generation") + if not (key in vers and vers[key]["accepted"]): + raise SeamError(f"{split}[{i}] {key}: trained on a sample the verifier did not accept") + if key in seen: + raise SeamError(f"{key}: stored twice") + seen.add(key) + g = gens[key] + p = int(ex["prompt_len"]) + ids = np.asarray(row.input_ids) + if p != len(g["prompt_ids"]) or not np.array_equal(ids[:p], np.asarray(g["prompt_ids"])): + raise SeamError(f"{key}: stored context is not the sampled prompt_ids") + lab = collate_sft([row], pad_token_id=0).labels[0].numpy() + trained = lab[lab != -100] + if not np.array_equal(trained, np.asarray(g["completion_ids"], dtype=trained.dtype)): + raise SeamError(f"{key}: tokens with loss ({len(trained)}) != completion_ids ({len(g['completion_ids'])})") + if not np.all(lab[:p] == -100): + raise SeamError(f"{key}: loss leaks into the prompt") + kinds[str(ex["kind"])] = kinds.get(str(ex["kind"]), 0) + 1 + rows += 1 + if rows == 0: + raise SeamError("no SFT rows were found; a vacuous pass") + accepted = sum(1 for v in vers.values() if v["accepted"]) + return {"rows": rows, "by_kind": kinds, "accepted_samples": accepted, "accepted_not_used": accepted - rows} + + +def main(argv=None) -> int: + ap = argparse.ArgumentParser(description=__doc__.split("\n")[0]) + sub = ap.add_subparsers(dest="cmd", required=True) + for name in ("keys", "sft"): + s = sub.add_parser(name) + s.add_argument("--generations", nargs="+", required=True) + s.add_argument("--verifications", nargs="+", required=True) + if name == "sft": + s.add_argument("--shards", required=True) + a = ap.parse_args(argv) + try: + rep = check_keys(a.generations, a.verifications) if a.cmd == "keys" else check_sft(a.generations, a.verifications, a.shards) + rep["passed"] = True + except SeamError as e: + rep = {"passed": False, "error": str(e)} + print(json.dumps(rep, indent=2)) + return 0 if rep["passed"] else 1 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/code/scripts/p2/contrasts.py b/code/scripts/p2/contrasts.py new file mode 100644 index 0000000000000000000000000000000000000000..9597e48c4ea95d01c7fc0930db2196da32a90713 --- /dev/null +++ b/code/scripts/p2/contrasts.py @@ -0,0 +1,743 @@ +#!/usr/bin/env python +"""Declarative driver for the SOTA-plan mechanism contrasts (docs/SOTA_PLAN.md section 3). + + .venv/bin/python scripts/p2/contrasts.py --contrast K1 K2 --out-dir RUN \\ + --kimina-model SNAPSHOT --pool train_pool.jsonl --eval-problems minif2f_valid.jsonl \\ + --protected-problems minif2f.jsonl mobench.jsonl --mean-completion-tokens 6000 --token-budget 20000000 --dry-run + +A contrast is DATA (`CONTRASTS` below): which base model, which arms, what is matched. For every (arm, seed) the +driver emits the exact command lines of the real tools -- `expert_iteration.py` (EI-SFT arms: it owns generation, +verification, SFT, export, eval and the per-round report), `scripts/grpo.py` (RL arms), `pipeline_gen_verify.py` +(eval of exported/alpha-scaled models), `qwen3_export_check.py` (alpha exports), `eval_report.py` (the paired +comparison) -- and a manifest with the matched-budget bookkeeping. + + K0 A/A null identical recipe, two seeds (Goedel-8B) + K1 EI-SFT vs RL matched generated tokens and prompt set (+ optional PKPO arm) (Kimina-Distill-8B) + K2 mutation control verified mutations vs temperature-matched (T=1.2) own samples (Kimina-Distill-8B) + K4 alpha curves LoRA delta scale in {0.5, 0.7, 1.0}, NO retraining, per EI arm (arms of K1/K2) + K6 source quality classifier-selected vs random statements, matched tokens (Kimina-Distill-8B) + +GUARDS (refused before anything is printed or run). The scored benchmark is the first --eval-problems file and +must be a DEVELOPMENT split: `expert_iteration.split_problems` is reused verbatim (fails closed on a missing / +empty / split-less file, refuses any `test` split, requires a TEST benchmark in --protected-problems). There is +NO --allow-test-selection here: the registered final evaluation does not go through this driver. Every EI +command carries --protected-problems so build_sft_data excludes them from training data. + +MATCHED BUDGETS. Matched = generated completion tokens per arm (the plan's "matched generated tokens"). Planned: + EI rounds x selected_problems x n x mean_completion_tokens + RL optimizer_steps x prompts_per_step x G x mean_completion_tokens +`--token-budget T` fixes T per arm and DERIVES the knob (EI --max-problems, RL optimizer_steps). The mean completion +length is an input (`--mean-completion-tokens`, no default: a guessed length would silently unmatch the arms), and +`--measure` recomputes the EI arms' tokens from the generation files (sum of len(completion_ids)) and refuses a +mismatch above --token-tolerance. RL tokens are not measurable from files that exist today: that arm is reported +"unverified", never "matched". + +PENDING FLAGS. Some contrasts need switches the underlying tools do not have yet (K2's T=1.2 needs +`--gen-temperature` in expert_iteration.py and `--temperature` in lean_gen.py; both are frozen/other-lane files). +A step lists `requires_flags`; --dry-run prints them as BLOCKED and a real run refuses (exit 2) until the flag +exists in the target script's argparse. Nothing is faked. + +THIS DRIVER NEVER TAKES THE HEAVY LOCK: every heavy child takes it itself, one at a time, in series. Steps are +resumable (`OUT/steps/.done.json` stores the step identity hash; a changed step with an old marker is refused, +exit 3). Exit codes: 0 ok, 2 refused (guards / pending flags / bad arguments), 3 a step failed / config mismatch, +5 measured tokens not matched, 143 signal. +""" + +from __future__ import annotations + +import argparse +import hashlib +import importlib.util +import json +import os +import re +import shlex +import sys +import time +import tomllib +from dataclasses import dataclass, field +from pathlib import Path +from typing import Optional + +REPO = Path(__file__).resolve().parents[2] +sys.path.insert(0, str(REPO)) + + +def _load(name: str, fname: str): + if name in sys.modules: + return sys.modules[name] + spec = importlib.util.spec_from_file_location(name, Path(__file__).with_name(fname)) + mod = importlib.util.module_from_spec(spec) + sys.modules[name] = mod + spec.loader.exec_module(mod) + return mod + + +C = _load("p2_common", "p2_common.py") +EI = _load("p2_expert_iteration", "expert_iteration.py") + +PY = EI.PY +SCRIPTS = EI.SCRIPTS +EXIT_OK, EXIT_REFUSED, EXIT_FAIL, EXIT_UNMATCHED, EXIT_SIGNAL = 0, 2, 3, 5, 143 +ALPHA_GRID = (0.5, 0.7, 1.0) +TEMPERATURE_CONTROL = 1.2 + + +class ContrastError(RuntimeError): + """A refusal: bad arguments, a guard, a pending flag.""" + + +# =============================================================================== declarative contracts + + +@dataclass(frozen=True) +class ArmSpec: + name: str + kind: str # "ei" | "rl" + pool: str = "pool" # which pool argument feeds it: pool | mutated_pool | classifier_pool | random_pool + temperature: Optional[float] = None # generation temperature override (None = the tool's default, 1.0) + optional_flag: Optional[str] = None # arm exists only when this CLI option is set (e.g. pkpo_config) + + +@dataclass(frozen=True) +class ContrastSpec: + cid: str + question: str + base: str # "goedel" | "kimina" + arms: tuple # ArmSpec... + matched_on: tuple + baseline_arm: str + candidate_arm: str + n_seeds: Optional[int] = None # exact seed count required (K0: 2); None = any >= 1 + budget_matched: bool = True # arms are matched on a token budget + + +CONTRASTS: dict[str, ContrastSpec] = {c.cid: c for c in ( + ContrastSpec("K0", "A/A null: the distribution of paired differences under an identical recipe", "goedel", + (ArmSpec("ei", "ei"),), ("everything",), "ei", "ei", n_seeds=2, budget_matched=False), + ContrastSpec("K1", "EI-SFT vs RL (+ optional PKPO arm) at matched generated tokens and prompt set", "kimina", + (ArmSpec("ei_sft", "ei"), ArmSpec("rl", "rl"), ArmSpec("rl_pkpo", "rl", optional_flag="pkpo_config")), + ("generated tokens", "prompt set"), "ei_sft", "rl"), + ContrastSpec("K2", "Is mutation the active ingredient? verified mutations vs T=1.2 own samples on unmutated statements", + "kimina", + (ArmSpec("temp_matched", "ei", pool="pool", temperature=TEMPERATURE_CONTROL), + ArmSpec("mutated", "ei", pool="mutated_pool", temperature=TEMPERATURE_CONTROL)), + ("rows", "generated tokens", "temperature"), "temp_matched", "mutated"), + ContrastSpec("K4", "Averaging / scale: LoRA delta scale curve, no retraining", "kimina", (), ("none: reported as curves",), + "alpha1.0", "alpha0.5", budget_matched=False), + ContrastSpec("K6", "Source-quality ablation: classifier-selected vs random statements", "kimina", + (ArmSpec("random", "ei", pool="random_pool"), ArmSpec("classifier", "ei", pool="classifier_pool")), + ("generated tokens",), "random", "classifier"), +)} +POOL_OPTION = {"pool": "--pool", "mutated_pool": "--mutated-pool", "classifier_pool": "--classifier-pool", "random_pool": "--random-pool"} + + +# =============================================================================== pure helpers + + +def planned_tokens_ei(rounds: int, problems: int, n: int, mean_tokens: float) -> float: + return float(rounds) * problems * n * mean_tokens + + +def planned_tokens_rl(steps: int, prompts_per_step: int, group: int, mean_tokens: float) -> float: + return float(steps) * prompts_per_step * group * mean_tokens + + +def derive_ei_problems(budget: float, rounds: int, n: int, mean_tokens: float) -> int: + """Largest --max-problems whose planned EI tokens do not exceed `budget`.""" + k = int(budget // (rounds * n * mean_tokens)) + if k < 1: + raise ContrastError(f"--token-budget {budget:g} is below one problem x {n} samples x {rounds} round(s) x {mean_tokens:g} tokens") + return k + + +def derive_rl_steps(budget: float, prompts_per_step: int, group: int, mean_tokens: float) -> int: + """optimizer_steps whose planned RL tokens are CLOSEST to `budget` (at least 1).""" + return max(1, round(budget / (prompts_per_step * group * mean_tokens))) + + +def token_match(a: float, b: float, tol: float) -> bool: + return abs(a - b) <= tol * max(a, b) + + +def measured_generated_tokens(gen_paths) -> int: + """Sum of completion token counts over Generation JSONL file(s): what the arm actually decoded.""" + total = 0 + for p in gen_paths: + for rec in C.iter_jsonl(p): + total += len(rec["completion_ids"]) + return total + + +def set_toml_keys(text: str, edits: dict) -> str: + """Section-aware `key = value` replacement keeping any trailing comment. `edits` maps (section, key) -> python value; + section "" is the top level. A key that is not present is an error (an override that silently does not apply is the + failure this exists to prevent).""" + def fmt(v): + return json.dumps(v) if isinstance(v, str) else (str(v).lower() if isinstance(v, bool) else repr(v)) + + pending = dict(edits) + out, section = [], "" + for line in text.splitlines(keepends=True): + m = re.match(r"^\s*\[([^\]]+)\]", line) + if m: + section = m.group(1).strip() + else: + km = re.match(r"^(\s*)([A-Za-z0-9_]+)(\s*=\s*)([^#\n]*?)([ \t]*)(#.*)?(\r?\n?)$", line) + if km and (section, km.group(2)) in pending: + v = pending.pop((section, km.group(2))) + line = f"{km.group(1)}{km.group(2)}{km.group(3)}{fmt(v)}{km.group(5) or ' '}{km.group(6) or ''}{km.group(7)}" + if not km.group(6): + line = line.rstrip() + km.group(7) + out.append(line) + if pending: + raise ContrastError(f"TOML template lacks key(s) {sorted(pending)}; refusing to emit a config that silently ignores them") + return "".join(out) + + +def argparse_flags(script: Path) -> set: + """Long options a script's argparse declares, read from its source (no import, no side effects).""" + try: + src = Path(script).read_text() + except OSError: + return set() + flags = set(re.findall(r"""add_argument\(\s*["'](--[A-Za-z0-9\-]+)["']""", src)) + flags |= set(re.findall(r"""\bg\(\s*["'](--[A-Za-z0-9\-]+)["']""", src)) # expert_iteration's `g = ap.add_argument` + return flags + + +# =============================================================================== plan + + +@dataclass +class Step: + sid: str + contrast: str + arm: str + seed: Optional[int] + kind: str # write_config | ei | rl | alpha_export | eval | report + argv: list = field(default_factory=list) + env: dict = field(default_factory=dict) + writes: dict = field(default_factory=dict) # path -> text, materialised before the command + outputs: list = field(default_factory=list) + requires_flags: list = field(default_factory=list) # [(script path, "--flag")] + note: str = "" + + def identity(self) -> str: + return json.dumps({"sid": self.sid, "argv": self.argv, "env": sorted(self.env.items()), "writes": self.writes}, sort_keys=True, default=str) + + +@dataclass +class Plan: + steps: list + arms: list # per (contrast, arm, seed): tokens + layout + contrasts: list + guard: dict + prerequisites: list + + +def build_cfg_for_ei(args, spec: ContrastSpec, arm: ArmSpec, seed: int, base: str, pool: str, max_problems: Optional[int], run_dir: Path): + return EI.Cfg( + run_dir=str(run_dir), pool=pool, eval_problems=list(args.eval_problems), base_model=base, rounds=args.rounds, name=f"{spec.cid}-{arm.name}-s{seed}", + max_problems=max_problems, seed=seed, n=args.n, gen_cap=args.gen_cap, tp=args.tp, workers=args.workers, + train_config=str(run_dir.parent / f"{arm.name}_s{seed}.sft.toml"), nproc=args.nproc, eval_n=args.eval_n, eval_cap=args.eval_cap, + eval_seed=args.eval_seed, protected_problems=list(args.protected_problems), band_lo=args.band_lo, band_hi=args.band_hi, + include_zero=args.include_zero, seed_ver=list(args.seed_ver), avg_weight=None, min_gain=-10**6, patience=10**6, max_regress=10**6) + + +def ei_argv(cfg, args, arm: ArmSpec) -> list: + """The expert_iteration.py command line for one EI arm/seed. Stop rule disabled (a contrast arm runs its rounds).""" + a = [PY, str(SCRIPTS / "p2" / "expert_iteration.py"), "--run-dir", cfg.run_dir, "--pool", cfg.pool, "--eval-problems", *cfg.eval_problems, + "--base-model", cfg.base_model, "--rounds", str(cfg.rounds), "--name", cfg.name, "--seed", str(cfg.seed), "--n", str(cfg.n), + "--gen-cap", str(cfg.gen_cap), "--tp", str(cfg.tp), "--workers", str(cfg.workers), "--nproc", str(cfg.nproc), + "--band-lo", str(cfg.band_lo), "--band-hi", str(cfg.band_hi), "--train-config", cfg.train_config, + "--eval-n", str(cfg.eval_n), "--eval-cap", str(cfg.eval_cap), "--eval-seed", str(cfg.eval_seed), + "--protected-problems", *cfg.protected_problems, + # K-contrast arms run every round: the pre-registered stop rule is for single development runs. + "--min-gain", "-1000000", "--patience", "1000000", "--max-regress", "1000000"] + if cfg.max_problems is not None: + a += ["--max-problems", str(cfg.max_problems)] + if cfg.include_zero: + a.append("--include-zero") + if cfg.seed_ver: + a += ["--seed-ver", *cfg.seed_ver] + if args.baseline_run: + a += ["--baseline-run", args.baseline_run] + if arm.temperature is not None: + a += ["--gen-temperature", str(arm.temperature)] + return a + + +def sft_config_text(args, seed: int) -> str: + """configs/p2_sft.toml with [train].seed = seed: EI has no train-seed flag, the config is the one honest carrier.""" + text = Path(args.sft_template).read_text() + return set_toml_keys(text, {("train", "seed"): seed}) + + +def rl_template(path: str) -> tuple[str, dict]: + text = Path(path).read_text() + d = tomllib.loads(text) + try: + info = {"prompts_per_step": int(d["rollout"]["prompts_per_step"]), "group": int(d["grpo"]["num_samples_per_prompt_rollout"]), + "max_response_len": int(d["rollout"]["max_response_len"])} + except KeyError as e: + raise ContrastError(f"RL config template {path} lacks {e}") from e + return text, info + + +def pgv_eval_argv(args, run_dir: Path, model: str, problems: str, gen_out: Path, ver_out: Path, tag: str, base_cfg) -> list: + ga = ["--problems", problems, "--out", gen_out, "--model", model, *EI.model_rev_args(base_cfg, model, tag), "--n", args.eval_n, + "--cap-tokens", args.eval_cap, "--round", 0, "--tp", args.tp, "--seed", args.eval_seed] + return [PY, str(SCRIPTS / "p2" / "pipeline_gen_verify.py"), "--run-dir", str(run_dir), "--gen-args", " ".join(map(str, ga)), + "--problems", problems, "--verify-out", str(ver_out), "--workers", str(args.workers)] + + +def split_guard(args) -> dict: + """Refuse a test-split scored file. Reuses expert_iteration.split_problems with the test escape hatch OFF.""" + cfg = EI.Cfg(run_dir=str(args.out_dir), pool=args.pool, eval_problems=list(args.eval_problems), base_model="-", rounds=1, + protected_problems=list(args.protected_problems), allow_test_selection=False) + err = EI.split_problems(cfg) + if err: + raise ContrastError(f"split guard: {err}") + return {"scored": str(args.eval_problems[0]), "splits": sorted(EI.scored_splits(str(args.eval_problems[0]))), + "protected": [str(p) for p in args.protected_problems], "allow_test_selection": False} + + +def check_args(args) -> None: + if not args.eval_problems: + raise ContrastError("--eval-problems is required (first = the scored development benchmark)") + if not args.protected_problems: + raise ContrastError("--protected-problems is required: the test benchmarks must reach build_sft_data's exclusion set") + if args.rounds < 1 or args.n < 1: + raise ContrastError("--rounds and --n must be >= 1") + wanted = [c for c in args.contrast] + unknown = [c for c in wanted if c not in CONTRASTS] + if unknown: + raise ContrastError(f"unknown contrast(s) {unknown}; known: {sorted(CONTRASTS)}") + for cid in wanted: + spec = CONTRASTS[cid] + if spec.n_seeds is not None and len(args.seeds_for(cid)) != spec.n_seeds: + raise ContrastError(f"{cid} needs exactly {spec.n_seeds} seeds, got {args.seeds_for(cid)}") + if len(set(args.seeds_for(cid))) != len(args.seeds_for(cid)): + raise ContrastError(f"{cid}: duplicate seeds {args.seeds_for(cid)}") + if spec.budget_matched and not (args.token_budget or args.max_problems): + raise ContrastError(f"{cid} is matched on generated tokens: pass --token-budget (or an explicit --max-problems)") + if spec.budget_matched and not args.mean_completion_tokens: + raise ContrastError("--mean-completion-tokens is required (the planned-token arithmetic needs a measured mean; no default)") + base = args.goedel_model if spec.base == "goedel" else args.kimina_model + if not base and cid != "K4": + raise ContrastError(f"{cid} needs --{spec.base}-model") + for arm in spec.arms: + if arm.optional_flag and not getattr(args, arm.optional_flag): + continue + if arm.kind == "ei": + if not getattr(args, arm.pool): + raise ContrastError(f"{cid}/{arm.name} needs {POOL_OPTION[arm.pool]}") + elif not args.rl_config and arm.name == "rl": + raise ContrastError(f"{cid}/rl needs --rl-config (template TOML for scripts/grpo.py)") + + +def build_plan(args) -> Plan: + check_args(args) + guard = split_guard(args) + out = Path(args.out_dir) + steps: list[Step] = [] + arms: list[dict] = [] + ei_cfgs: dict = {} # (cid, arm, seed) -> Cfg + prereq: list = [] + eval_file = str(args.eval_problems[0]) + + for cid in args.contrast: + spec = CONTRASTS[cid] + if cid == "K4": + continue + seeds = args.seeds_for(cid) + base = args.goedel_model if spec.base == "goedel" else args.kimina_model + mean = args.mean_completion_tokens + # ---- the per-arm token budget T (and the EI max-problems that realises it) + max_problems = args.max_problems + if spec.cid == "K0": + T = planned_tokens_ei(args.rounds, max_problems, args.n, mean) if (max_problems and mean) else None + elif max_problems is not None: + T = planned_tokens_ei(args.rounds, max_problems, args.n, mean) + else: + max_problems = derive_ei_problems(args.token_budget, args.rounds, args.n, mean) + T = planned_tokens_ei(args.rounds, max_problems, args.n, mean) + + for arm in spec.arms: + if arm.optional_flag and not getattr(args, arm.optional_flag): + continue + for seed in seeds: + aid = f"{cid}/{arm.name}/s{seed}" + run_dir = out / cid / arm.name / f"s{seed}" / "run" + if arm.kind == "ei": + pool = getattr(args, arm.pool) + cfg = build_cfg_for_ei(args, spec, arm, seed, base, pool, max_problems, run_dir) + ei_cfgs[(cid, arm.name, seed)] = cfg + cfg_path = Path(cfg.train_config) + steps.append(Step(f"{cid}.{arm.name}.s{seed}.config", cid, arm.name, seed, "write_config", + writes={str(cfg_path): sft_config_text(args, seed)}, outputs=[str(cfg_path)], + note=f"p2_sft.toml with train.seed={seed} (EI has no train-seed flag)")) + reqs = [] + if arm.temperature is not None: + reqs.append((str(SCRIPTS / "p2" / "expert_iteration.py"), "--gen-temperature")) + reqs.append((str(REPO / "extras" / "vllm_rollout" / "lean_gen.py"), "--temperature")) + L = EI.Layout(cfg, cfg.rounds) + steps.append(Step(f"{cid}.{arm.name}.s{seed}.ei", cid, arm.name, seed, "ei", argv=ei_argv(cfg, args, arm), + env={"PYTHONPATH": str(REPO)}, requires_flags=reqs, outputs=[str(L.manifest)], + note="rounds x (select, gen_verify, measure, sft_data, train, export, eval, report)")) + gens = [str(EI.Layout(cfg, r).gen / f"gen_r{k}.jsonl") for r in range(cfg.start_round, cfg.rounds + 1) for k in ((0, 1) if cfg.corrections else (0,))] + arms.append({"id": aid, "contrast": cid, "arm": arm.name, "seed": seed, "kind": "ei", "run_dir": str(run_dir), + "pool": pool, "max_problems": max_problems, "temperature": arm.temperature or 1.0, + "tokens_planned": T, "tokens_measured": None, "gen_files": gens, + "eval_files": [str(x) for x in L.eval_files()], "model_out": str(L.model_out), "rounds": cfg.rounds}) + else: + tmpl = args.pkpo_config if arm.name == "rl_pkpo" else args.rl_config + text, info = rl_template(tmpl) + steps_n = derive_rl_steps(T, info["prompts_per_step"], info["group"], mean) + t_rl = planned_tokens_rl(steps_n, info["prompts_per_step"], info["group"], mean) + # SAME prompt set as the EI arm of the same seed: its round-1 selection (EI writes it). + ei_cfg = ei_cfgs.get((cid, "ei_sft", seed)) + band = str(EI.Layout(ei_cfg, ei_cfg.start_round).sel / "problems.jsonl") if ei_cfg else None + if band is None: + raise ContrastError(f"{aid}: the RL arm needs an EI-SFT arm of the same seed to share its prompt set") + rl_dir = run_dir.parent / "rl_run" + cfg_path = run_dir.parent / f"{arm.name}.grpo.toml" + edits = {("", "sft_checkpoint"): str(resolve_snapshot_for_rl(base)), ("", "run_dir"): str(rl_dir), + ("train", "optimizer_steps"): steps_n, ("train", "seed"): seed, ("data", "band_file"): band} + steps.append(Step(f"{cid}.{arm.name}.s{seed}.config", cid, arm.name, seed, "write_config", + writes={str(cfg_path): set_toml_keys(text, edits)}, outputs=[str(cfg_path)], + note=f"optimizer_steps={steps_n} so planned RL tokens {t_rl:.3g} ~ EI {T:.3g}; same prompt set (EI round-1 selection)")) + steps.append(Step(f"{cid}.{arm.name}.s{seed}.rl", cid, arm.name, seed, "rl", + argv=[PY, "-m", "torch.distributed.run", "--standalone", f"--nproc_per_node={args.nproc}", str(SCRIPTS / "grpo.py"), str(cfg_path)], + env={"PYTHONPATH": str(REPO)}, outputs=[str(rl_dir)], + note="scripts/grpo.py takes the heavy lock itself; the config is a DRAFT (docs/ROADMAP.md P4) until pre-registered")) + rl_eval_gen, rl_eval_ver = run_dir.parent / "eval" / "gen.jsonl", run_dir.parent / "eval" / "ver.jsonl" + model = f"@latest_ckpt:{rl_dir}/checkpoints" + ecfg = ei_cfg + steps.append(Step(f"{cid}.{arm.name}.s{seed}.eval", cid, arm.name, seed, "eval", + argv=pgv_eval_argv(args, run_dir.parent / "eval" / "run", model, eval_file, rl_eval_gen, rl_eval_ver, f"{cid}-{arm.name}-s{seed}", ecfg), + env={"PYTHONPATH": str(REPO)}, outputs=[str(rl_eval_gen), str(rl_eval_ver)], + note="eval of the RL arm's final checkpoint at the same n / cap / seed as the EI arms")) + arms.append({"id": aid, "contrast": cid, "arm": arm.name, "seed": seed, "kind": "rl", "run_dir": str(rl_dir), + "tokens_planned": t_rl, "tokens_measured": None, "optimizer_steps": steps_n, "band_file": band, + "eval_files": [str(rl_eval_gen), str(rl_eval_ver)], "prompts_per_step": info["prompts_per_step"], "group": info["group"]}) + + # ---- the paired report(s): candidate vs baseline, per seed (same seed = same selection seeds) + if spec.cid == "K0": + s0, s1 = seeds + pairs = [(s0, "ei", s1, "ei")] + else: + pairs = [(s, spec.baseline_arm, s, spec.candidate_arm) for s in seeds] + if spec.cid == "K1" and args.pkpo_config: + pairs += [(s, spec.baseline_arm, s, "rl_pkpo") for s in seeds] + for sa, a, sb, b in pairs: + ea, eb = _arm_eval(arms, cid, a, sa), _arm_eval(arms, cid, b, sb) + name_a, name_b = f"{a}_s{sa}", f"{b}_s{sb}" + rdir = out / cid / "reports" / f"{name_a}__vs__{name_b}" + argv = [PY, str(SCRIPTS / "p2" / "eval_report.py"), "--problems", *map(str, args.eval_problems), "--run", f"{name_a}:{ea[0]}:{ea[1]}", + "--run", f"{name_b}:{eb[0]}:{eb[1]}", "--baseline", name_a, "--candidate", name_b, "--out-dir", str(rdir)] + steps.append(Step(f"{cid}.report.{name_a}.vs.{name_b}", cid, f"{a}|{b}", sa, "report", argv=argv, env={"PYTHONPATH": str(REPO)}, + outputs=[str(rdir / "report.json")], note="paired comparison on the dev benchmark (same n / cap / eval seed)")) + + # ---- K4: alpha curves over the EI arms of the selected source contrasts (no retraining) + if "K4" in args.contrast: + sources = [c for c in args.k4_from if c in CONTRASTS and c != "K4"] + if not sources: + raise ContrastError("K4 needs EI arms: pass --k4-from with K1/K2/K6 and include them in --contrast (or K0)") + for cid in sources: + if cid not in args.contrast: + raise ContrastError(f"--k4-from {cid} requires {cid} in --contrast (its arm layout defines the checkpoints)") + for (cid, arm_name, seed), cfg in ei_cfgs.items(): + if cid not in sources: + continue + Lf = EI.Layout(cfg, cfg.rounds) + final_in = EI.model_in(cfg, cfg.rounds) + lora_alpha = cfg.lora_alpha + names = {"1.0": f"{arm_name}_s{seed}@a1.0"} + runs = [f"{arm_name}_s{seed}@a1.0:{Lf.eval_files()[0]}:{Lf.eval_files()[1]}"] + for k in ALPHA_GRID: + if k == 1.0: + continue + tag = f"a{k}" + adir = out / "K4" / cid / arm_name / f"s{seed}" / tag + steps.append(Step(f"K4.{cid}.{arm_name}.s{seed}.{tag}.export", "K4", f"{cid}/{arm_name}", seed, "alpha_export", + argv=[PY, str(SCRIPTS / "qwen3_export_check.py"), "run", "--ckpt", EI.LATEST + str(Lf.sft), "--out-dir", str(adir / "export"), + "--work", str(adir / "export_check"), "--r", str(cfg.lora_r), "--alpha", f"{lora_alpha * k:g}", "--keep"], + env={"PYTHONPATH": str(REPO), "P2_EXPORT_BASE_SNAPSHOT": final_in}, outputs=[str(adir / "export" / "config.json")], + note=f"merge at delta scale {k}: lora_alpha {lora_alpha:g} x {k} = {lora_alpha * k:g} (scaling = alpha / r); no retraining")) + gen_o, ver_o = adir / "eval" / "gen.jsonl", adir / "eval" / "ver.jsonl" + steps.append(Step(f"K4.{cid}.{arm_name}.s{seed}.{tag}.eval", "K4", f"{cid}/{arm_name}", seed, "eval", + argv=pgv_eval_argv(args, adir / "eval" / "run", str(adir / "export"), eval_file, gen_o, ver_o, f"K4-{cid}-{arm_name}-s{seed}-{tag}", cfg), + env={"PYTHONPATH": str(REPO)}, outputs=[str(gen_o), str(ver_o)], + note=f"dev eval at delta scale {k}, same n / cap / seed as the arm's own round-{cfg.rounds} eval (that eval IS the k=1.0 point)")) + runs.append(f"{arm_name}_s{seed}@{tag}:{gen_o}:{ver_o}") + rdir = out / "K4" / cid / arm_name / f"s{seed}" / "report" + argv = [PY, str(SCRIPTS / "p2" / "eval_report.py"), "--problems", *map(str, args.eval_problems)] + for r in runs: + argv += ["--run", r] + argv += ["--baseline", runs[0].split(":")[0], "--candidate", runs[1].split(":")[0], "--out-dir", str(rdir)] + steps.append(Step(f"K4.{cid}.{arm_name}.s{seed}.report", "K4", f"{cid}/{arm_name}", seed, "report", argv=argv, env={"PYTHONPATH": str(REPO)}, + outputs=[str(rdir / "report.json")], note="alpha curve: runs at 0.5 / 0.7 / 1.0; the report pairs 1.0 vs the first scaled run, read the rest from report.json runs")) + arms.append({"id": f"K4/{cid}/{arm_name}/s{seed}", "contrast": "K4", "arm": f"{cid}/{arm_name}", "seed": seed, "kind": "alpha_curve", + "scales": list(ALPHA_GRID), "lora_alpha_trained": lora_alpha, "tokens_planned": None}) + rl_arms = [a for a in arms if a["kind"] == "rl"] + if rl_arms: + prereq.append("K4 emits EI arms only: RL arms are not LoRA-adapter checkpoints in this repo, so the alpha sweep does not apply to them") + + # ---- pending flags (named so a human sees what is blocked before a GPU hour is spent) + seen = set() + for s in steps: + for script, flag in s.requires_flags: + if (script, flag) not in seen: + seen.add((script, flag)) + prereq.append(f"{flag} in {Path(script).relative_to(REPO)}: {'present' if flag in argparse_flags(Path(script)) else 'MISSING (pending)'}") + contr = [{"id": c, "question": CONTRASTS[c].question, "base": CONTRASTS[c].base, "matched_on": list(CONTRASTS[c].matched_on), + "arms": [a.name for a in CONTRASTS[c].arms if not a.optional_flag or getattr(args, a.optional_flag)], "seeds": args.seeds_for(c)} + for c in args.contrast] + return Plan(steps=steps, arms=arms, contrasts=contr, guard=guard, prerequisites=prereq) + + +def resolve_snapshot_for_rl(base: str) -> str: + return EI.resolve_snapshot(base) + + +def _arm_eval(arms, cid, arm, seed): + for a in arms: + if a["contrast"] == cid and a["arm"] == arm and a["seed"] == seed: + return a["eval_files"][0], a["eval_files"][1] + raise ContrastError(f"no arm {cid}/{arm}/s{seed} to compare") + + +def blocked_steps(plan: Plan) -> list: + out = [] + for s in plan.steps: + for script, flag in s.requires_flags: + if flag not in argparse_flags(Path(script)): + out.append((s.sid, str(Path(script).name), flag)) + return out + + +def budget_table(plan: Plan, tol: float) -> list: + """Per contrast: planned tokens per arm and whether the arms are matched within `tol` (planned, not measured).""" + by: dict = {} + for a in plan.arms: + if a.get("tokens_planned") is not None and a["contrast"] != "K4": + by.setdefault(a["contrast"], []).append(a) + rows = [] + for cid, items in by.items(): + if not CONTRASTS[cid].budget_matched: + rows.append({"contrast": cid, "matched": "n/a (identical recipe)", "arms": {a["id"]: a["tokens_planned"] for a in items}}) + continue + vals = [a["tokens_planned"] for a in items] + rows.append({"contrast": cid, "matched": "planned: yes" if token_match(min(vals), max(vals), tol) else "planned: NO", + "tolerance": tol, "arms": {a["id"]: a["tokens_planned"] for a in items}}) + return rows + + +def manifest_of(plan: Plan, args) -> dict: + return {"created": time.strftime("%Y-%m-%d %H:%M:%S"), "driver": "scripts/p2/contrasts.py", "out_dir": str(args.out_dir), + "contrasts": plan.contrasts, "guard": plan.guard, "prerequisites": plan.prerequisites, + "inputs": {"mean_completion_tokens": args.mean_completion_tokens, "token_budget": args.token_budget, "rounds": args.rounds, "n": args.n, + "eval": {"n": args.eval_n, "cap": args.eval_cap, "seed": args.eval_seed}, "gen_cap": args.gen_cap}, + "budget": budget_table(plan, args.token_tolerance), "arms": plan.arms, + "steps": [{"id": s.sid, "kind": s.kind, "contrast": s.contrast, "arm": s.arm, "seed": s.seed, "argv": s.argv, "env": s.env, + "outputs": s.outputs, "requires_flags": [[str(Path(a).name), f] for a, f in s.requires_flags], "note": s.note} for s in plan.steps], + "plan_sha": hashlib.sha256("".join(s.identity() for s in plan.steps).encode()).hexdigest()} + + +# =============================================================================== running + + +def display(argv) -> str: + return " ".join(shlex.quote(str(a)) for a in argv) + + +def dry_run(plan: Plan, args, say=print) -> int: + say(f"# dry run: nothing is executed or written. out dir {args.out_dir}") + say(f"# guard: scored {plan.guard['scored']} splits={plan.guard['splits']}; test escape hatch OFF; protected {plan.guard['protected']}") + for row in budget_table(plan, args.token_tolerance): + say(f"# budget {row['contrast']}: {row['matched']} " + " ".join(f"{k}={v:.4g}" for k, v in row["arms"].items())) + for p in plan.prerequisites: + say(f"# prerequisite: {p}") + blocked = {b[0] for b in blocked_steps(plan)} + for s in plan.steps: + say(f"\n[{'BLOCKED: pending flag' if s.sid in blocked else 'ready'}] {s.sid} ({s.kind}) # {s.note}") + for path in s.writes: + say(f" # writes {path}") + if s.argv: + env = " ".join(f"{k}={shlex.quote(v)}" for k, v in sorted(s.env.items())) + say(f" {env + ' ' if env else ''}{display(s.argv)}") + return EXIT_OK + + +def _resolve_ckpt(argv: list) -> list: + """Resolve `@latest_ckpt:DIR` at run time (the checkpoint does not exist when the plan is built). An RL run's + DIR holds micro_* directly; an EI arm's DIR is the sft root with per-run subdirs (EI.latest_checkpoint).""" + out = [] + for a in argv: + a = str(a) + if a.startswith(EI.LATEST): + d = Path(a[len(EI.LATEST):]) + if "/rl_run/" in a: + cands = sorted((c for c in d.glob("micro_*") if c.is_dir() and c.name.split("_")[-1].isdigit()), key=lambda p: int(p.name.split("_")[-1])) + found = cands[-1] if cands else None + else: + found = EI.latest_checkpoint(d) + if found is None: + raise ContrastError(f"no completed checkpoint under {d}") + a = str(found) + out.append(a) + return out + + +def run_plan(plan: Plan, args, runner, say=print) -> int: + blocked = blocked_steps(plan) + if blocked: + for sid, script, flag in blocked: + print(f"refused: step {sid} needs {flag} in {script}, which does not exist yet", file=sys.stderr) + return EXIT_REFUSED + out = Path(args.out_dir) + (out / "steps").mkdir(parents=True, exist_ok=True) + man = manifest_of(plan, args) + mpath = out / "contrasts_manifest.json" + tmp = mpath.with_suffix(".json.tmp") + tmp.write_text(json.dumps(man, indent=1, sort_keys=True, default=str)) + os.replace(tmp, mpath) + for s in plan.steps: + marker = out / "steps" / f"{s.sid}.done.json" + ident = hashlib.sha256(s.identity().encode()).hexdigest() + if marker.exists(): + if json.loads(marker.read_text())["identity"] != ident: + print(f"refused: step {s.sid} finished under a different configuration; delete {marker} and its outputs to redo it", file=sys.stderr) + return EXIT_FAIL + say(f"skip (done): {s.sid}") + continue + for path, text in s.writes.items(): + Path(path).parent.mkdir(parents=True, exist_ok=True) + Path(path).write_text(text) + if s.argv: + try: + argv = _resolve_ckpt(s.argv) + except ContrastError as e: + print(f"step {s.sid}: {e}", file=sys.stderr) + return EXIT_FAIL + say(f"run: {s.sid}") + rc = runner.run(argv, s.env, out / "logs" / f"{s.sid}.log") + if rc == EXIT_SIGNAL: + return EXIT_SIGNAL + if rc != 0: + print(f"step {s.sid} failed (exit {rc}); re-run the same command to resume", file=sys.stderr) + return EXIT_FAIL + missing = [o for o in s.outputs if not Path(o).exists()] + if missing: + print(f"step {s.sid} exited 0 but did not produce {missing}", file=sys.stderr) + return EXIT_FAIL + marker.write_text(json.dumps({"sid": s.sid, "identity": ident, "finished": time.strftime("%F %T")})) + return EXIT_OK + + +def measure(plan: Plan, args, say=print) -> int: + """Recompute EI arms' generated tokens from the generation files and check the matching tolerance.""" + bad = False + by: dict = {} + for a in plan.arms: + if a["kind"] == "ei": + files = [f for f in a["gen_files"] if Path(f).exists()] + a["tokens_measured"] = measured_generated_tokens(files) if files else None + by.setdefault(a["contrast"], []).append(a) + elif a["kind"] == "rl": + by.setdefault(a["contrast"], []).append(a) + for cid, items in by.items(): + if not CONTRASTS[cid].budget_matched: + continue + for a in items: + say(f"{a['id']}: planned {a['tokens_planned']:.4g} measured {a['tokens_measured'] if a['tokens_measured'] is not None else 'n/a'}") + ei = [a for a in items if a["kind"] == "ei"] + if any(a["tokens_measured"] is None for a in ei): + say(f"{cid}: not all EI arms have generation files yet") + continue + vals = [a["tokens_measured"] for a in ei] + ok = token_match(min(vals), max(vals), args.token_tolerance) + say(f"{cid}: EI arms {'MATCHED' if ok else 'NOT MATCHED'} on measured tokens (tol {args.token_tolerance:.0%})" + + ("; the RL arm(s) are UNVERIFIED (no token log to measure)" if len(ei) != len(items) else "")) + bad |= not ok + return EXIT_UNMATCHED if bad else EXIT_OK + + +# =============================================================================== CLI + + +class _Args(argparse.Namespace): + def seeds_for(self, cid: str) -> list: + if self.seeds is not None: + return list(self.seeds) + return [0, 1] if CONTRASTS[cid].n_seeds == 2 else [0, 1, 2] + + +def parse_args(argv=None) -> _Args: + ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) + g = ap.add_argument + g("--contrast", nargs="+", required=True, help="K0 K1 K2 K4 K6") + g("--out-dir", required=True) + g("--goedel-model", default=None, help="Goedel-Prover-V2-8B snapshot (K0)") + g("--kimina-model", default=None, help="Kimina-Prover-Distill-8B snapshot (K1/K2/K6)") + g("--pool", default=None, help="training pool Problem JSONL (unmutated statements)") + g("--mutated-pool", default=None, help="K2: verified mutations of the pool (Problem JSONL)") + g("--classifier-pool", default=None, help="K6: classifier-selected statements (Problem JSONL)") + g("--random-pool", default=None, help="K6: random statements, matched in count to --classifier-pool") + g("--eval-problems", nargs="+", default=[], help="the FIRST is the scored DEVELOPMENT benchmark") + g("--protected-problems", nargs="*", default=[], help="test benchmarks excluded from training data") + g("--seeds", type=int, nargs="+", default=None, help="default 0 1 2 (K0: 0 1)") + g("--rounds", type=int, default=1) + g("--n", type=int, default=8) + g("--max-problems", type=int, default=None) + g("--token-budget", type=float, default=None, help="generated completion tokens per arm (derives max-problems and RL steps)") + g("--mean-completion-tokens", type=float, default=None, help="measured mean completion length; no default") + g("--token-tolerance", type=float, default=0.05) + g("--rl-config", default=str(REPO / "configs" / "p4_cispo_lean.toml")) + g("--pkpo-config", default=None, help="K1: template TOML for the optional PKPO arm") + g("--sft-template", default=str(REPO / "configs" / "p2_sft.toml")) + g("--k4-from", nargs="+", default=["K1", "K2"], help="contrasts whose EI arms K4 sweeps (they must be in --contrast)") + g("--seed-ver", nargs="*", default=[]) + g("--baseline-run", default=None) + g("--band-lo", type=float, default=0.0) + g("--band-hi", type=float, default=0.75) + g("--include-zero", action="store_true") + g("--gen-cap", type=int, default=16384) + g("--eval-n", type=int, default=8) + g("--eval-cap", type=int, default=30000) + g("--eval-seed", type=int, default=0) + g("--tp", type=int, default=4) + g("--workers", type=int, default=8) + g("--nproc", type=int, default=4) + g("--dry-run", action="store_true") + g("--print-manifest", action="store_true", help="print the manifest JSON (implies no execution)") + g("--measure", action="store_true", help="recompute generated tokens of finished EI arms and check the match") + return ap.parse_args(argv, namespace=_Args()) + + +def main(argv=None, runner=None) -> int: + args = parse_args(argv) + try: + plan = build_plan(args) + except (ContrastError, C.ContractError, ValueError) as e: + print(f"refused: {e}", file=sys.stderr) + return EXIT_REFUSED + if args.print_manifest: + print(json.dumps(manifest_of(plan, args), indent=1, sort_keys=True, default=str)) + return EXIT_OK + if args.dry_run: + return dry_run(plan, args) + if args.measure: + rc = measure(plan, args) + man = Path(args.out_dir) / "contrasts_manifest.json" + if man.parent.is_dir(): + m = manifest_of(plan, args) + man.write_text(json.dumps(m, indent=1, sort_keys=True, default=str)) + return rc + return run_plan(plan, args, runner or EI.SubprocessRunner()) + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/code/scripts/p2/eta.py b/code/scripts/p2/eta.py new file mode 100644 index 0000000000000000000000000000000000000000..f86ce656f924cba5b778bbc6fb8ddcdab76d15ed --- /dev/null +++ b/code/scripts/p2/eta.py @@ -0,0 +1,149 @@ +#!/usr/bin/env python +"""ETA model for the real P2 run, computed from MEASURED inputs (docs/P2.md section 6). + + .venv/bin/python scripts/p2/eta.py # markdown table, low / central / high + .venv/bin/python scripts/p2/eta.py --json + +Every input carries its source. Where an input is not measured for the quantity it is applied to +(pool completion length, pool verify time, 40,960-cap length), it is a BRACKET between two measured +anchors and the table says so; nothing here is fitted. All stages run strictly in series (one heavy +job at a time, /apps/CLAUDE.md), so the total is a sum. + + generate(P, n, T) = P * n * T / r_gen (tokens / aggregate tok/s) + verify(P, n, f, t) = P * n * f * t (t = wall s per code-bearing sample at 8 workers) + corrections = U * n * k_fail * f_fb samples, U = P * (1 - solved@n) (lean_gen corrects only failed samples of + problems with NO accepted sample), each generated and verified like a round-0 sample + sft(rows, L, e) = rows * L * e / (r_tr * eff) + saves * 3.3 min +""" + +from __future__ import annotations + +import argparse +import json + +# ---- measured inputs (value, source) --------------------------------------------------------------------- +M = { + # generation, vLLM TP=4 (P2-B; .tmp/p2/P2-B/probe_16k_seqs256_summary.json, probe_16k_summary.json) + "r_gen": {"low": 1043.0, "central": 1363.0, "high": 1602.0, + "src": "e2e 1,043 tok/s (256 samples, 64 problems); >=64 running 1,363 (256 seqs) / 1,602 (128 seqs)"}, + "T16_minif2f": {"low": 4996.0, "central": 4996.0, "high": 5066.0, + "src": "mean completion tokens at cap 16,384, miniF2F, 256 / 128 samples; 8.2% / 7.8% hit the cap"}, + "frac_len16": {"central": 0.082, "src": "finish_reason=length at cap 16,384 (21/256)"}, + # verification (this session: B's 256 real Goedel samples through verify_generations, 8 workers) + "f_code_minif2f": {"central": 235 / 256, "src": "256 samples: 21 no_code (length), 235 sent to Lean"}, + "t_v_minif2f": {"central": 722 / 235, "src": "722 s wall for 235 code-bearing samples at 8 workers, 7 timeouts (180 s) inside; mean per-proof 13.9 s, median 2.5 s"}, + "t_v_easy": {"central": 9 / 51, "src": "tiny loop: 51 short pool proofs in 9 s at 8 workers (the 16 SHORTEST pool statements)"}, + "acc_minif2f": {"central": 165 / 256, "src": "accepted/samples at cap 16,384, 64 miniF2F problems x 4"}, + "solved4_minif2f": {"central": 52 / 64, "src": "solved@4 on those 64 problems"}, + "ffb_minif2f": {"central": 62 / 91, "src": "compile_error share of failures (62/91); the rest: no_code 21, timeout 6, banned 2"}, + # training (P2-A; configs/p2_sft.toml header, scripts/qwen3_sft_probe.py) + "r_tr": {"central": 2152.0, "src": "padded tokens/s on 4 GPUs at 16,384-token micro-batches (18,432: 2,146)"}, + "save_min": {"central": 3.3, "src": "one checkpoint (16 GB merged + adapter/optimizer), measured twice (3.3 min; tiny loop 3.1 / 3.2)"}, +} + + +def hours(s: float) -> float: + return s / 3600.0 + + +def compute() -> dict: + P_pool, n, P_mf = 2000, 8, 244 + rg = {k: M["r_gen"][k] for k in ("low", "central", "high")} # low = slowest tok/s + out = {} + + def gen_h(samples, T, r): + return hours(samples * T / r) + + # scenario columns: low = optimistic time, central, high = pessimistic time + def col(lo, ce, hi): + return {"low": lo, "central": ce, "high": hi} + + # --- evaluation (miniF2F, n=8 -> 1,952 samples) + S_mf = P_mf * n + T16 = M["T16_minif2f"] + gen16 = col(gen_h(S_mf, T16["low"], rg["high"]), gen_h(S_mf, T16["central"], rg["central"]), gen_h(S_mf, T16["high"], rg["low"])) + t_v = M["t_v_minif2f"]["central"] + ver16 = hours(S_mf * M["f_code_minif2f"]["central"] * t_v) + out["eval_c16384_gen"] = gen16 + out["eval_c16384_verify"] = col(ver16 * 0.6, ver16, ver16 * 1.4) + # --- 40,960 cap: lengths NOT measured at scale: bracket T16 (nobody continues) .. T16 + frac_len * (40,960 - 16,384 - prompt) + T40 = col(T16["central"], T16["central"] + M["frac_len16"]["central"] * 12000.0, T16["central"] + M["frac_len16"]["central"] * 24000.0) + out["eval_c40960_gen"] = col(gen_h(S_mf, T40["low"], rg["high"]), gen_h(S_mf, T40["central"], rg["central"]), gen_h(S_mf, T40["high"], rg["low"])) + ver40 = hours(S_mf * 0.99 * t_v) + out["eval_c40960_verify"] = col(ver40 * 0.6, ver40, ver40 * 1.4) + # --- pool round 0 (2,000 x 8 = 16,000 samples). T and t_v for POOL statements are bracketed, not measured. + S_pool = P_pool * n + Tp = col(1817.0, 3000.0, T16["central"]) # tiny loop (shortest statements, cap 4,096; 116,283 tok / 64) .. miniF2F + out["pool_r0_gen"] = col(gen_h(S_pool, Tp["low"], rg["high"]), gen_h(S_pool, Tp["central"], rg["central"]), gen_h(S_pool, Tp["high"], rg["low"])) + tvp = col(M["t_v_easy"]["central"], 1.0, t_v) + fcp = 0.9 + out["pool_r0_verify"] = col(*(hours(S_pool * fcp * tvp[k]) for k in ("low", "central", "high"))) + # --- correction round 1: failed samples of unsolved problems only + u = col(0.10, 0.20, 0.30) # unsolved@8 fraction of the pool: miniF2F unsolved@4 is 19%; pool statements are mostly easier + kfail = 8 * (1 - 0.0) # all 8 samples of an unsolved problem failed + ffb = M["ffb_minif2f"]["central"] + Nc = col(*(P_pool * u[k] * kfail * ffb for k in ("low", "central", "high"))) + out["corr_samples"] = Nc + Tc = col(T16["central"], T16["central"], T16["central"]) # unsolved problems are hard: miniF2F-like lengths + out["corr_gen"] = col(gen_h(Nc["low"], Tc["low"], rg["high"]), gen_h(Nc["central"], Tc["central"], rg["central"]), gen_h(Nc["high"], Tc["high"], rg["low"])) + out["corr_verify"] = col(*(hours(Nc[k] * 0.9 * (t_v if k != "low" else tvp["central"])) for k in ("low", "central", "high"))) + # --- SFT: rows x tokens x 2 epochs + rows = col(1000.0, 2500.0, 4500.0) + L = col(2800.0, 3300.0, 4000.0) # prompt ~0.25k + accepted completion mean 2.7k (measured); corrections are longer + eff = 0.9 + ep = 2 + sft = {} + for k in ("low", "central", "high"): + tok = rows[k] * L[k] * ep + steps = tok / eff / 18432 / 8 + sft[k] = hours(tok / (M["r_tr"]["central"] * eff)) + (steps * 2 / 40) * M["save_min"]["central"] / 60.0 + out["sft_rows"] = rows + out["sft_train"] = sft + out["sft_export"] = col(0.05, 0.1, 0.2) + # --- post-SFT re-evaluation at the primary cap + out["post_c16384_gen"] = out["eval_c16384_gen"] + out["post_c16384_verify"] = out["eval_c16384_verify"] + totals = {} + for k in ("low", "central", "high"): + totals[k] = { + "baseline_16k": out["eval_c16384_gen"][k] + out["eval_c16384_verify"][k], + "baseline_40k": out["eval_c40960_gen"][k] + out["eval_c40960_verify"][k], + "pool_r0": out["pool_r0_gen"][k] + out["pool_r0_verify"][k], + "correction": out["corr_gen"][k] + out["corr_verify"][k], + "sft": out["sft_train"][k] + out["sft_export"][k], + "reeval_16k": out["post_c16384_gen"][k] + out["post_c16384_verify"][k], + } + totals[k]["total_16k_only"] = sum(totals[k][x] for x in ("baseline_16k", "pool_r0", "correction", "sft", "reeval_16k")) + totals[k]["total_with_40k_baseline"] = totals[k]["total_16k_only"] + totals[k]["baseline_40k"] + out["totals"] = totals + out["T40_tokens"] = T40 + out["T_pool_tokens"] = Tp + out["t_v_pool"] = tvp + out["unsolved_frac"] = u + return out + + +def markdown(o: dict) -> str: + rows = [ + ("baseline miniF2F, cap 16,384, n=8: generate", "eval_c16384_gen"), (" verify", "eval_c16384_verify"), + ("baseline miniF2F, cap 40,960 (reference): generate", "eval_c40960_gen"), (" verify", "eval_c40960_verify"), + ("pool round 0 (2,000 x 8 = 16,000): generate", "pool_r0_gen"), (" verify (--feedback)", "pool_r0_verify"), + ("correction round 1: generate", "corr_gen"), (" verify", "corr_verify"), + ("SFT (2 epochs, incl. saves)", "sft_train"), (" merge + export", "sft_export"), + ("post-SFT miniF2F, cap 16,384, n=8: generate", "post_c16384_gen"), (" verify", "post_c16384_verify"), + ] + t = ["| stage | low (h) | central (h) | high (h) |", "|---|---|---|---|"] + for name, key in rows: + t.append(f"| {name} | {o[key]['low']:.2f} | {o[key]['central']:.2f} | {o[key]['high']:.2f} |") + tt = o["totals"] + t.append(f"| **total, 16k baseline + loop + 16k re-eval** | **{tt['low']['total_16k_only']:.1f}** | **{tt['central']['total_16k_only']:.1f}** | **{tt['high']['total_16k_only']:.1f}** |") + t.append(f"| **total including the 40,960 baseline** | **{tt['low']['total_with_40k_baseline']:.1f}** | **{tt['central']['total_with_40k_baseline']:.1f}** | **{tt['high']['total_with_40k_baseline']:.1f}** |") + return "\n".join(t) + + +if __name__ == "__main__": + ap = argparse.ArgumentParser() + ap.add_argument("--json", action="store_true") + a = ap.parse_args() + o = compute() + print(json.dumps(o, indent=1) if a.json else markdown(o)) diff --git a/code/scripts/p2/eval_report.py b/code/scripts/p2/eval_report.py new file mode 100644 index 0000000000000000000000000000000000000000..f068c24aea95d0e07e85d0296854054d8f2e6ec9 --- /dev/null +++ b/code/scripts/p2/eval_report.py @@ -0,0 +1,924 @@ +#!/usr/bin/env python +"""P2 evaluation report: pass@k, Wilson / bootstrap CIs, paired McNemar, correction lift. + +`--config sota` runs the separately registered SOTA analysis instead (docs/PREREG_SOTA.md): co-primary class A and +class B on miniF2F-test, unbiased pass@32 from n = 64, paired sign-flip test, Holm over the two, secondaries, exploratory +outputs, protocol-tuple tags, and the rewritten-statement count. Every report that touches a test split is appended to +an append-only, hash-chained look log. The P2 primary below is untouched. + + .venv/bin/python scripts/p2/eval_report.py \\ + --problems minif2f.jsonl mobench.jsonl \\ + --run base:gens_base.jsonl:ver_base.jsonl --run sft:gens_sft.jsonl:ver_sft.jsonl \\ + --baseline base --candidate sft --out-dir REPORT_DIR + +`--run NAME:GEN[,GEN..]:VER[,VER..]`. Writes `report.json` and `report.md`. Every table row names +its problem set, its token cap and the verifier budget; nothing is pooled across caps. + +Pre-registered endpoints (docs/P2.md, written before any data): PRIMARY = miniF2F solved@8 at cap +16384, baseline vs post-SFT, paired exact McNemar, alpha = 0.05. Everything else in the report is +labelled SECONDARY and is NOT multiplicity-corrected. + +What this script refuses to do (each is a number that would be about a different experiment): + * score a run with a missing verification, a verification with no generation, a stale verdict + (`code_sha256` mismatch), or a problem with a different number of round-0 samples than the + rest (unless --allow-ragged-n): a missing sample is not a failure of the model; + * report pass@k for k > n; + * compare two runs whose verifier pins / config / budget differ (--allow-verifier-mismatch + to override; the mismatch is then printed in the report header); + * compare two runs that do not cover exactly the same problems; + * compare two runs generated with different sampling settings or prompted differently (--allow-design-mismatch); + * label a comparison PRIMARY unless it is the registered test: miniF2F at cap 16384, k = 8, ALL 244 problems, baseline = the + registered model revision, temperature 1.0 / top_p 0.95 / no top_k (anything else is printed as secondary, with the reason). +""" + +from __future__ import annotations + +import argparse +import getpass +import hashlib +import importlib.util +import json +import os +import re +import socket +import sys +from collections import Counter, defaultdict +from datetime import datetime, timezone +from pathlib import Path +from typing import Optional + +REPO = Path(__file__).resolve().parents[2] +sys.path.insert(0, str(REPO)) + + +def _load_common(): + spec = importlib.util.spec_from_file_location("p2_common", Path(__file__).with_name("p2_common.py")) + mod = importlib.util.module_from_spec(spec) + sys.modules["p2_common"] = mod + spec.loader.exec_module(mod) + return mod + + +C = sys.modules.get("p2_common") or _load_common() + +from align.eval import paired as PD # noqa: E402 +from align.eval import proof_stats as S # noqa: E402 + +#: What docs/P2.md section 1 registers. `n_problems`, `baseline_model_rev` and the sampling settings are part of the +#: registration: a comparison that differs in any of them is a DIFFERENT experiment and must not carry the PRIMARY label +#: (a 64-problem subset run labelled PRIMARY would print a confirmatory-looking p-value for a test nobody registered). +PRIMARY = {"source": "minif2f", "cap": 16384, "k": 8, "n_problems": 244, + "baseline_model_rev": "dfd02e6271a58375dfbf3ece0175277cf6b6a89a", + "sampling": {"temperature": 1.0, "top_p": 0.95, "top_k": [0, -1]}} +DEFAULT_KS = (1, 2, 4, 8, 16, 32) + + +DOC = REPO / "docs" / "P2.md" +_PREREG_BLOCK = re.compile(r"\n(.*?)", re.S) +_PREREG_RECORDED = re.compile(r"PREREG_SHA256:\s*`([0-9a-f]{64})`") + + +def prereg_status(doc: Path = DOC) -> dict: + """Recompute the hash of the pre-registered block of docs/P2.md and compare it with the + value recorded next to it. A report whose analysis plan was edited after registration says so + in its header instead of silently presenting the new plan as the registered one.""" + if not Path(doc).exists(): + return {"status": "missing", "doc": str(doc)} + text = Path(doc).read_text() + m = _PREREG_BLOCK.search(text) + rec = _PREREG_RECORDED.search(text) + if not m: + return {"status": "no_block", "doc": str(doc)} + h = hashlib.sha256(m.group(1).encode()).hexdigest() + if not rec: + return {"status": "unrecorded", "computed": h} + return {"status": "verified" if rec.group(1) == h else "MODIFIED_AFTER_REGISTRATION", "recorded": rec.group(1), "computed": h} + + +class Sample: + __slots__ = ("accepted", "reasons", "finish", "n_tok", "parent", "budget", "verify_s", "eff_max", "sampling", "model_rev", "prompt_sha", + "fidelity") + + def __init__(self, accepted, reasons, finish, n_tok, parent, budget, verify_s, eff_max, sampling=None, model_rev=None, prompt_sha=None, + fidelity=None): + self.accepted, self.reasons, self.finish = accepted, reasons, finish + self.n_tok, self.parent, self.budget, self.verify_s = n_tok, parent, budget, verify_s + self.eff_max = eff_max # tokens the sampler was really allowed (== cap unless the context window clipped it) + self.sampling = sampling # (temperature, top_p, top_k) as recorded; None = not recorded + self.model_rev, self.prompt_sha = model_rev, prompt_sha + #: SOTA configuration only: for an ACCEPTED sample, "faithful" | "rewritten" | "unreadable" (the displayed theorem + #: compared with the reference statement); None = not checked (rejected sample, or no statements were supplied). + self.fidelity = fidelity + + +def _sampling_of(rec: dict) -> tuple: + """(temperature, top_p, top_k) exactly as recorded; a missing key stays None (unknown is not 'default').""" + sp = rec.get("sampling") or {} + return (sp.get("temperature"), sp.get("top_p"), sp.get("top_k")) + + +def verifier_signature(v: dict) -> str: + cfg = dict(v.get("config", {})) + return hashlib.sha256(json.dumps({"pins": v.get("pins"), "config": cfg}, sort_keys=True).encode()).hexdigest()[:16] + + +# =========================================================================== loading + + +def _fidelity(statements: Optional[dict], pid: str, completion_text: str) -> Optional[str]: + if statements is None or pid not in statements: + return None + shown = C.displayed_signature(completion_text) + if shown is None: + return "unreadable" + return "faithful" if shown == C.statement_key(statements[pid])[2] else "rewritten" + + +def load_run(gen_paths, ver_paths, statements: Optional[dict] = None) -> tuple[dict[tuple, Sample], dict]: + """`statements` ({problem_id: formal_statement}) switches on the statement-fidelity check of accepted samples (SOTA configuration).""" + ver: dict[tuple, dict] = {} + sigs: Counter = Counter() + for p in ver_paths: + for rec in C.iter_jsonl(p): + key = C.gen_key(rec) + if key in ver: + raise C.ContractError(f"{p}: duplicate verification key {key}") + ver[key] = rec + sigs[verifier_signature(rec["verifier"])] += 1 + samples: dict[tuple, Sample] = {} + for p in gen_paths: + with open(p, encoding="utf-8") as f: + for lineno, line in enumerate(f, 1): + if not line.strip(): + continue + rec = json.loads(line) + C.validate_generation(rec) + key = C.gen_key(rec) + if key in samples: + raise C.ContractError(f"{p}:{lineno}: duplicate generation key {key}") + v = ver.get(key) + if v is None: + raise C.ContractError(f"{p}:{lineno}: generation {key} has no verification record") + if v["code_sha256"] != C.code_sha(rec["lean_code"]): + raise C.ContractError(f"{p}:{lineno}: stale verification for {key} (code_sha256 mismatch)") + par = rec["parent"] + samples[key] = Sample(bool(v["accepted"]), list(v["reasons"]), rec["finish_reason"], len(rec["completion_ids"]), + None if par is None else (par["problem_id"], int(par["sample_idx"]), int(par["round"]), key[3]), + v["budget_s"], v["verify_s"], C.effective_max_tokens(rec), + _sampling_of(rec), rec["model_rev"], hashlib.sha256(json.dumps(rec["prompt_ids"]).encode()).hexdigest()[:16], + _fidelity(statements, rec["problem_id"], rec["completion_text"]) if v["accepted"] else None) + orphan = set(ver) - set(samples) + if orphan: + raise C.ContractError(f"{len(orphan)} verification records have no generation (first: {sorted(orphan)[0]})") + meta = {"verifier_signatures": dict(sigs), "n_samples": len(samples), + "budgets": sorted({s.budget for s in samples.values()}), + "verifier": next(iter(ver.values()))["verifier"] if ver else None} + return samples, meta + + +# =========================================================================== metrics + + +def round0_by_problem(samples, problem_ids, cap: int, allow_ragged: bool) -> dict[str, list[bool]]: + """problem_id -> accepted flags of round-0 samples ordered by sample_idx (contiguous 0..n-1).""" + per: dict[str, dict[int, bool]] = defaultdict(dict) + for (pid, si, rd, c), s in samples.items(): + if rd == 0 and c == cap and pid in problem_ids: + per[pid][si] = s.accepted + missing = sorted(set(problem_ids) - set(per)) + if missing: + raise C.ContractError(f"incomplete run at cap {cap}: {len(missing)} problems have no round-0 sample (first: {missing[0]})") + out = {} + for pid, d in per.items(): + if sorted(d) != list(range(len(d))): + raise C.ContractError(f"{pid}: round-0 sample_idx not contiguous from 0 ({sorted(d)[:12]}...)") + out[pid] = [d[i] for i in range(len(d))] + ns = {len(v) for v in out.values()} + if len(ns) > 1 and not allow_ragged: + raise C.ContractError(f"problems have different numbers of round-0 samples at cap {cap}: {sorted(ns)} (--allow-ragged-n to override)") + return out + + +def run_metrics(samples, problem_ids, cap: int, ks, seed: int, n_boot: int, allow_ragged: bool = False) -> dict: + flags = round0_by_problem(samples, set(problem_ids), cap, allow_ragged) + pids = sorted(flags) + n_min = min(len(v) for v in flags.values()) + counts = [(len(flags[p]), sum(flags[p])) for p in pids] + out: dict = {"cap_tokens": cap, "n_problems": len(pids), "n_per_problem_min": n_min, + "n_per_problem_max": max(len(v) for v in flags.values()), "n_samples_round0": sum(n for n, _ in counts)} + tot_s, tot_c = out["n_samples_round0"], sum(c for _, c in counts) + per_problem_p1 = [c / n for n, c in counts] + lo, hi = S.bootstrap_mean_ci(per_problem_p1, seed, n_boot) + nlo, nhi = S.wilson_interval(tot_c, tot_s) + out["pass@1"] = {"value": tot_c / tot_s, "ci95_cluster_bootstrap": [lo, hi], + "ci95_wilson_pooled_samples_ANTICONSERVATIVE": [nlo, nhi]} + out["pass@k"] = {} + out["solved@k"] = {} + for k in ks: + if k > n_min: + out["pass@k"][str(k)] = None # explicit: not computable, not zero + out["solved@k"][str(k)] = None + continue + vals = [S.pass_at_k(n, c, k) for n, c in counts] + blo, bhi = S.bootstrap_mean_ci(vals, seed + k, n_boot) + solved = sum(1 for p in pids if S.solved_at_k(flags[p], k)) + wlo, whi = S.wilson_interval(solved, len(pids)) + out["pass@k"][str(k)] = {"value": sum(vals) / len(vals), "ci95_cluster_bootstrap": [blo, bhi]} + out["solved@k"][str(k)] = {"solved": solved, "n": len(pids), "fraction": solved / len(pids), "ci95_wilson": [wlo, whi]} + r0 = [s for (pid, si, rd, c), s in samples.items() if rd == 0 and c == cap and pid in set(pids)] + out["completion"] = { + "length_capped_fraction": sum(1 for s in r0 if s.finish == "length") / len(r0), + "mean_completion_tokens": sum(s.n_tok for s in r0) / len(r0), + "no_code_fraction": sum(1 for s in r0 if s.reasons == ["no_code"]) / len(r0), + "reason_histogram": dict(Counter(r for s in r0 if not s.accepted for r in s.reasons[:1]).most_common(12)), + "verify_s_mean": sum(s.verify_s for s in r0) / len(r0), + "context_clipped_fraction": sum(1 for s in r0 if s.eff_max < cap) / len(r0), + "mean_effective_max_tokens": sum(s.eff_max for s in r0) / len(r0), + } + out["design"] = {"model_revs": sorted({s.model_rev for s in r0}), "sampling": sorted({tuple(s.sampling or ()) for s in r0}, key=str)} + out["_flags"] = flags # stripped before JSON + return out + + +def primary_blockers(src: str, cap: int, k: int, base_metrics: dict) -> list[str]: + """Why a comparison of this shape may NOT carry the PRIMARY label ([] = it is the registered test).""" + if (src, cap, k) != (PRIMARY["source"], PRIMARY["cap"], PRIMARY["k"]): + return ["not the registered (set, cap, k)"] + why = [] + if base_metrics["n_problems"] != PRIMARY["n_problems"]: + why.append(f"{base_metrics['n_problems']} problems scored, the registered set has {PRIMARY['n_problems']}") + revs = base_metrics["design"]["model_revs"] + if revs != [PRIMARY["baseline_model_rev"]]: + why.append(f"baseline model_rev {revs} is not the registered {PRIMARY['baseline_model_rev']}") + want = PRIMARY["sampling"] + for t, p, kk in base_metrics["design"]["sampling"] or [()]: + if (t, p) != (want["temperature"], want["top_p"]) or kk not in want["top_k"]: + why.append(f"baseline sampling (temperature, top_p, top_k) = {(t, p, kk)} is not the registered {(want['temperature'], want['top_p'], want['top_k'])}") + return why + + +PRIMARY_ALPHA = 0.05 + + +def registered_decision(m: dict) -> str: + """The pre-registered decision rule, applied mechanically so the wording is not chosen after the data: + success = p < alpha AND the candidate solves more problems; p < alpha with the candidate lower is a + significant degradation; anything else is "no detectable improvement" (never "no effect").""" + sig = m["p_value_exact_two_sided"] < PRIMARY_ALPHA + if sig and m["solved_b"] > m["solved_a"]: + return "SUCCESS: significant improvement" + if sig and m["solved_b"] < m["solved_a"]: + return "significant DEGRADATION" + return "no detectable improvement" + + +def design_mismatches(base: dict[tuple, Sample], cand: dict[tuple, Sample]) -> list[str]: + """Differences between the two systems' GENERATION that no verifier signature can show: sampling + settings and the round-0 prompts. A paired test of two runs that were not asked the same question + with the same sampler measures the harness, not the model.""" + out = [] + sb = {tuple(s.sampling or ()) for s in base.values()} + sc = {tuple(s.sampling or ()) for s in cand.values()} + if len(sb) != 1 or len(sc) != 1 or sb != sc: + out.append(f"sampling settings differ or are mixed: baseline {sorted(sb, key=str)} vs candidate {sorted(sc, key=str)}") + + common = sorted(k for k in set(base) & set(cand) if k[2] == 0) + bad = sorted({k[0] for k in common if base[k].prompt_sha != cand[k].prompt_sha}) + if bad: + out.append(f"{len(bad)} problems were prompted differently in the two runs at the same sample keys (first: {bad[0]})") + return out + + +def compare(base: dict, cand: dict, k: int, seed: int, n_boot: int) -> dict: + fb, fc = base["_flags"], cand["_flags"] + if set(fb) != set(fc): + raise C.ContractError("the two runs cover different problem sets") + if min(base["n_per_problem_min"], cand["n_per_problem_min"]) < k: + return {"k": k, "available": False, "why": f"needs >= {k} samples per problem in both runs"} + sa = {p: S.solved_at_k(fb[p], k) for p in fb} + sb = {p: S.solved_at_k(fc[p], k) for p in fb} + m = S.mcnemar_exact(sa, sb) + pids = sorted(fb) + va = [S.pass_at_k(len(fb[p]), sum(fb[p]), k) for p in pids] + vb = [S.pass_at_k(len(fc[p]), sum(fc[p]), k) for p in pids] + dlo, dhi = S.paired_bootstrap_diff_ci(va, vb, seed + 7 * k, n_boot) + return {"k": k, "available": True, "mcnemar": m.to_json(), "delta_solved": m.solved_b - m.solved_a, + "delta_solved_fraction": (m.solved_b - m.solved_a) / m.n_problems, + "delta_pass@k": sum(vb) / len(vb) - sum(va) / len(va), "delta_pass@k_ci95_paired_bootstrap": [dlo, dhi]} + + +def correction_lift(samples, problem_ids, cap: int) -> Optional[dict]: + """Per-round table: how many unsolved problems did correction rounds convert, at what cost. + + A problem is solved through round r when ANY sample of rounds <= r was accepted. The lift is + confounded with compute (corrections are extra samples): the table reports samples and + completion tokens spent per round so the comparison against `pass@(n0 + m)` can be made.""" + pids = set(problem_ids) + rounds = sorted({rd for (pid, si, rd, c) in samples if c == cap and pid in pids}) + if len(rounds) < 2: + return None + acc_by_round: dict[int, set[str]] = defaultdict(set) + gen_by_round: Counter = Counter() + tok_by_round: Counter = Counter() + acc_samples: Counter = Counter() + for (pid, si, rd, c), s in samples.items(): + if c != cap or pid not in pids: + continue + gen_by_round[rd] += 1 + tok_by_round[rd] += s.n_tok + if s.accepted: + acc_by_round[rd].add(pid) + acc_samples[rd] += 1 + if rd > 0: + par = s.parent + if par is None or par not in samples: + raise C.ContractError(f"correction sample {(pid, si, rd, c)} has no parent among the generations") + if samples[par].accepted: + raise C.ContractError(f"correction sample {(pid, si, rd, c)} corrects an ACCEPTED parent {par}") + n = len(pids) + solved = set(acc_by_round[0]) + rows = [{"round": 0, "samples": gen_by_round[0], "completion_tokens": tok_by_round[0], "accepted_samples": acc_samples[0], + "newly_solved_problems": len(solved), "solved_through_round": len(solved), "solved_fraction": len(solved) / n}] + unsolved_after0 = n - len(solved) + for r in rounds[1:]: + new = acc_by_round[r] - solved + solved |= acc_by_round[r] + lo, hi = S.wilson_interval(len(solved) - rows[0]["solved_through_round"], unsolved_after0) if unsolved_after0 else (0.0, 0.0) + rows.append({"round": r, "samples": gen_by_round[r], "completion_tokens": tok_by_round[r], "accepted_samples": acc_samples[r], + "sample_success_rate": acc_samples[r] / gen_by_round[r] if gen_by_round[r] else None, + "newly_solved_problems": len(new), "solved_through_round": len(solved), "solved_fraction": len(solved) / n, + "conversion_of_round0_unsolved": (len(solved) - rows[0]["solved_through_round"]) / unsolved_after0 if unsolved_after0 else None, + "conversion_ci95_wilson": [lo, hi]}) + return {"cap_tokens": cap, "n_problems": n, "rows": rows, + "note": "lift is extra-sample compute, not free: compare solved_through_round with pass@(n0 + corrections per problem) of round 0 when n0 allows"} + + +# =========================================================================== report + + +def build_report(problems_by_source: dict[str, dict], runs: dict[str, tuple], baseline: Optional[str], candidate: Optional[str], + ks, seed: int, n_boot: int, allow_ragged: bool, allow_verifier_mismatch: bool, allow_design_mismatch: bool = False) -> dict: + rep: dict = {"preregistered_primary": dict(PRIMARY), "prereg": prereg_status(), "runs": {}, "comparisons": [], "correction_lift": {}} + metrics: dict[str, dict] = {} + for name, (samples, meta) in runs.items(): + caps = sorted({c for (_, _, _, c) in samples}) + rep["runs"][name] = {"meta": meta, "tables": {}} + metrics[name] = {} + for src, probs in problems_by_source.items(): + for cap in caps: + covered = {pid for (pid, _, _, c) in samples if c == cap and pid in probs} + if not covered: + continue + m = run_metrics(samples, set(probs), cap, ks, seed, n_boot, allow_ragged) + metrics[name][(src, cap)] = m + rep["runs"][name]["tables"][f"{src}@{cap}"] = {k: v for k, v in m.items() if not k.startswith("_")} + lift = correction_lift(samples, set(probs), cap) + if lift: + rep["correction_lift"][f"{name}/{src}@{cap}"] = lift + if baseline and candidate: + sig_b = {verifier_signature(runs[baseline][1]["verifier"])} if runs[baseline][1]["verifier"] else set() + sig_c = {verifier_signature(runs[candidate][1]["verifier"])} if runs[candidate][1]["verifier"] else set() + if sig_b != sig_c or runs[baseline][1]["budgets"] != runs[candidate][1]["budgets"]: + msg = f"verifier differs: baseline {sig_b} budgets {runs[baseline][1]['budgets']} vs candidate {sig_c} budgets {runs[candidate][1]['budgets']}" + if not allow_verifier_mismatch: + raise C.ContractError(msg + " (--allow-verifier-mismatch to compare anyway)") + rep["verifier_mismatch"] = msg + design = design_mismatches(runs[baseline][0], runs[candidate][0]) + if design: + if not allow_design_mismatch: + raise C.ContractError("; ".join(design) + " (--allow-design-mismatch to compare anyway)") + rep["design_mismatch"] = design + for (src, cap), mb in sorted(metrics[baseline].items()): + mc = metrics[candidate].get((src, cap)) + if mc is None: + continue + for k in ks: + cmpd = compare(mb, mc, k, seed, n_boot) + blockers = primary_blockers(src, cap, k, mb) + cmpd.update({"source": src, "cap_tokens": cap, "baseline": baseline, "candidate": candidate, + "endpoint": "PRIMARY" if not blockers else "secondary"}) + if not blockers and cmpd["available"]: + cmpd["decision"] = registered_decision(cmpd["mcnemar"]) + if blockers and blockers != ["not the registered (set, cap, k)"]: + cmpd["not_primary_because"] = blockers # primary-shaped but not the registered test + rep["comparisons"].append(cmpd) + if not any(c["endpoint"] == "PRIMARY" and c["available"] for c in rep["comparisons"]): + rep["primary_missing"] = "the pre-registered primary comparison (miniF2F, cap 16384, k=8) could not be computed from these runs" + near = [c for c in rep["comparisons"] if c.get("not_primary_because")] + if near: + rep["primary_missing"] += "; the primary-shaped comparison below is NOT the registered test: " + "; ".join(near[0]["not_primary_because"]) + return rep + + +def render_md(rep: dict) -> str: + L = ["# P2 evaluation report", "", + f"Pre-registered primary endpoint: {PRIMARY['source']} solved@{PRIMARY['k']} at cap {PRIMARY['cap']}, paired exact McNemar, alpha 0.05. All other rows are secondary and not multiplicity-corrected.", ""] + pr = rep.get("prereg", {}) + L += [f"Pre-registration (docs/P2.md): {pr.get('status')}" + (f" (sha256 {pr.get('computed')})" if pr.get("computed") else ""), ""] + if pr.get("status") != "verified": + L += ["WARNING: the registered analysis plan could not be verified; treat every p-value below as exploratory.", ""] + if rep.get("verifier_mismatch"): + L += [f"WARNING: {rep['verifier_mismatch']}", ""] + if rep.get("design_mismatch"): + L += [f"WARNING: generation design differs between the runs: {'; '.join(rep['design_mismatch'])}", ""] + if rep.get("primary_missing"): + L += [f"WARNING: {rep['primary_missing']}", ""] + pct = lambda x: "n/a" if x is None else f"{100 * x:.1f}%" + for name, r in rep["runs"].items(): + L += [f"## Run `{name}`", "", f"verifier budgets (s): {r['meta']['budgets']}; samples: {r['meta']['n_samples']}", "", + "| set @ cap | problems | n/prob | pass@1 [95% cluster-bootstrap] | " + " | ".join(f"pass@{k}" for k in DEFAULT_KS) + " | length-capped | mean tok | mean effective max_tokens |", + "|" + "---|" * (7 + len(DEFAULT_KS))] + for tab, t in r["tables"].items(): + p1 = t["pass@1"] + cells = [] + for k in DEFAULT_KS: + v = t["pass@k"].get(str(k)) + cells.append("n/a" if v is None else f"{pct(v['value'])} ({t['solved@k'][str(k)]['solved']}/{t['n_problems']})") + L.append(f"| {tab} | {t['n_problems']} | {t['n_per_problem_min']} | {pct(p1['value'])} [{pct(p1['ci95_cluster_bootstrap'][0])}, {pct(p1['ci95_cluster_bootstrap'][1])}] | " + + " | ".join(cells) + f" | {pct(t['completion']['length_capped_fraction'])} | {t['completion']['mean_completion_tokens']:.0f} | {t['completion']['mean_effective_max_tokens']:.0f} |") + L.append("") + if rep["comparisons"]: + L += ["## Paired comparison (McNemar exact on solved@k)", "", + "| endpoint | set @ cap | k | solved base | solved cand | only base | only cand | discordant | p (exact, 2-sided) | delta pass@k [95% paired bootstrap] |", "|" + "---|" * 10] + for c in rep["comparisons"]: + if not c["available"]: + L.append(f"| {c['endpoint']} | {c['source']} @ {c['cap_tokens']} | {c['k']} | n/a | n/a | | | | | {c['why']} |") + continue + m = c["mcnemar"] + L.append(f"| {c['endpoint']} | {c['source']} @ {c['cap_tokens']} | {c['k']} | {m['solved_a']} | {m['solved_b']} | {m['only_a']} | {m['only_b']} | " + f"{m['n_discordant']} | {m['p_value_exact_two_sided']:.4f} | {100 * c['delta_pass@k']:+.1f} pp [{100 * c['delta_pass@k_ci95_paired_bootstrap'][0]:+.1f}, {100 * c['delta_pass@k_ci95_paired_bootstrap'][1]:+.1f}] |") + L.append("") + for c in rep["comparisons"]: + if c.get("decision"): + L += [f"**Pre-registered decision (primary, alpha {PRIMARY_ALPHA}): {c['decision']}.** A null result means no gain of roughly 6 pp or more was detectable, not that there is no effect (docs/P2.md section 1).", ""] + if rep["correction_lift"]: + L += ["## Correction-round lift", "", "| run / set @ cap | round | samples | completion tokens | accepted samples | newly solved problems | solved through round | solved % | conversion of round-0 unsolved [95% Wilson] |", "|" + "---|" * 9] + for name, t in rep["correction_lift"].items(): + for r in t["rows"]: + conv = r.get("conversion_of_round0_unsolved") + ci = r.get("conversion_ci95_wilson") + L.append(f"| {name} | {r['round']} | {r['samples']} | {r['completion_tokens']} | {r['accepted_samples']} | {r['newly_solved_problems']} | " + f"{r['solved_through_round']} | {pct(r['solved_fraction'])} | " + ("" if conv is None else f"{pct(conv)} [{pct(ci[0])}, {pct(ci[1])}]") + " |") + L += ["", "Lift costs extra samples: compare against round-0 pass@k at the same total samples per problem before calling it a gain.", ""] + return "\n".join(L) + + +# =========================================================================== SOTA configuration (docs/PREREG_SOTA.md) +# +# A SEPARATE registration from the P2 primary above (which is left exactly as it was). Co-primary endpoints, both on +# miniF2F-test: A = class A (single pass, cap 40k), B = class B (attempt + up to 2 Lean-feedback repairs, 40k total +# completion tokens). Estimand: mean over problems of the unbiased pass@32 from n = 64 samples. Test: paired sign-flip +# permutation over problems, two-sided alpha = 0.05, Holm over {A, B}. Secondaries: exact McNemar on solved@32 of the +# first 32 samples, mean pass@1, ProofNet-test class A. Everything else is exploratory. + +SOTA = { + "name": "sota-v1", + "alpha": 0.05, + "coprimary": {"source": "minif2f", "split": "test", "n_problems": 244, "cap": 40960, "n": 64, "k": 32}, + "class_b": {"max_repairs": 2, "ledger_tokens": 40960}, + "secondary": {"mcnemar_first_n": 32, "proofnet": {"source": "proofnet", "split": "test", "k": 32}}, + "verifier_budget_s": 300.0, + "n_flips": 100_000, + "exploratory_ks": (1, 8, 16, 32), +} + +LOOK_LOG_DEFAULT = REPO / "docs" / "evidence" / "test_split_looks.jsonl" + + +def _prereg_hash_module(): + spec = importlib.util.spec_from_file_location("p2_prereg_hash", Path(__file__).with_name("prereg_hash.py")) + mod = importlib.util.module_from_spec(spec) + sys.modules["p2_prereg_hash"] = mod + spec.loader.exec_module(mod) + return mod + + +def prereg_overall_hash() -> Optional[str]: + """Current sha256 of (registration text + analysis code), as printed by scripts/p2/prereg_hash.py; None if an input is missing.""" + try: + return _prereg_hash_module().compute()["sha256"] + except FileNotFoundError: + return None + + +def verifier_hash(v: Optional[dict]) -> Optional[str]: + """Full sha256 of the verifier identity (pins + config, canonical JSON; `workers` is deliberately excluded, as in + `verifier_signature`). This is the value the frozen-verifier rule compares.""" + if not v: + return None + return hashlib.sha256(json.dumps({"pins": v.get("pins"), "config": dict(v.get("config", {}))}, sort_keys=True).encode()).hexdigest() + + +def class_a_counts(samples, pids, cap: int) -> tuple[dict, dict]: + """-> ({pid: (n, c)}, {pid: flags}) from round-0 samples at `cap` (ragged n is refused).""" + flags = round0_by_problem(samples, set(pids), cap, allow_ragged=False) + return {p: (len(f), sum(f)) for p, f in flags.items()}, flags + + +def class_b_counts(samples, pids, cap: int, max_repairs: int, ledger: int, allow_unrepaired: bool = False) -> Optional[dict]: + """Class B: {pid: (n_trajectories, c_solved)}; None if the run has no repair rounds at all. + + A trajectory is the chain round-0 sample -> its repair -> its repair (<= `max_repairs`). It is solved iff some element + is accepted AND the cumulative completion tokens up to and including that element are <= `ledger` (the 40k TOTAL). + A failed sample with ledger left but no repair record is a missing record, not a failure: refused unless `allow_unrepaired`.""" + pset = set(pids) + if not any(rd > 0 and c == cap and pid in pset for (pid, _, rd, c) in samples): + return None + child: dict[tuple, tuple] = {} + for key, s in samples.items(): + if key[2] > 0 and key[3] == cap and key[0] in pset: + if s.parent in child: + raise C.ContractError(f"two repairs claim the same parent {s.parent}") + child[s.parent] = key + out = {} + for pid in sorted(pset): + n = solved = 0 + for key in sorted(k for k in samples if k[0] == pid and k[2] == 0 and k[3] == cap): + n += 1 + cum, cur, ok = 0, key, False + for _ in range(max_repairs + 1): + s = samples[cur] + cum += s.n_tok + if s.accepted: + ok = cum <= ledger + break + if cum >= ledger or cur[2] >= max_repairs: + break + nxt = child.get(cur) + if nxt is None: + if not allow_unrepaired: + raise C.ContractError(f"{cur} failed with ledger left but has no repair record (--allow-unrepaired to count it as a failure)") + break + cur = nxt + solved += ok + out[pid] = (n, solved) + return out + + +def sota_contrast(cb: dict, cc: dict, k: int, seed: int, n_boot: int, n_flips: int) -> dict: + """Paired contrast of the unbiased pass@k estimand; `cb`/`cc` are {pid: (n, c)}. Sign-flip p + problem bootstrap CI.""" + if set(cb) != set(cc): + raise C.ContractError("the two runs cover different problem sets") + pids = sorted(cb) + if min(min(n for n, _ in cb.values()), min(n for n, _ in cc.values())) < k: + return {"available": False, "k": k, "why": f"needs >= {k} samples per problem in both runs"} + va = PD.pass_at_k_array([cb[p][0] for p in pids], [cb[p][1] for p in pids], k) + vb = PD.pass_at_k_array([cc[p][0] for p in pids], [cc[p][1] for p in pids], k) + d = vb - va + t = PD.sign_flip_test(d, n_flips=n_flips, seed=seed) + lo, hi = PD.paired_bootstrap_ci(d, seed + 11 * k, n_boot) + return {"available": True, "k": k, "n_problems": len(pids), "n_per_problem": sorted({cb[p][0] for p in pids} | {cc[p][0] for p in pids}), + "mean_baseline": float(va.mean()), "mean_candidate": float(vb.mean()), "delta": float(d.mean()), + "delta_ci95_paired_bootstrap": [lo, hi], "p_sign_flip": t["p"], "sign_flip_method": t["method"], "sign_flip_n": t["n_flips"], + "n_changed_problems": t["n_nonzero"], "null_quantiles_of_delta": t["null_quantiles"]} + + +def _fidelity_summary(samples, pids, cap: int) -> dict: + pset = set(pids) + acc = [(key, s) for key, s in samples.items() if key[3] == cap and key[0] in pset and s.accepted] + cnt = Counter(s.fidelity or "unchecked" for _, s in acc) + bad = [(k, s) for k, s in acc if s.fidelity in ("rewritten", "unreadable")] + return {"accepted_samples": len(acc), "faithful": cnt["faithful"], "rewritten": cnt["rewritten"], "unreadable": cnt["unreadable"], + "unchecked": cnt["unchecked"], + "rewritten_statement_count": cnt["rewritten"] + cnt["unreadable"], + "problems_with_rewritten_accept": len({k[0] for k, _ in bad}), + "in_repair_rounds": sum(1 for k, _ in bad if k[2] > 0)} + + +def _strict_class_a(samples, pids, cap: int) -> dict: + """Class-A (n, c) with rewritten/unreadable accepts counted as failures (exploratory fidelity sensitivity).""" + per: dict[str, dict[int, bool]] = defaultdict(dict) + for (pid, si, rd, c), s in samples.items(): + if rd == 0 and c == cap and pid in pids: + per[pid][si] = s.accepted and s.fidelity == "faithful" + return {p: (len(d), sum(d.values())) for p, d in per.items()} + + +def append_look_log(path, entry: dict) -> dict: + """Append ONE hash-chained line to the test-split look log (append-only: O_APPEND, flock, fsync; each line carries the + sha256 of the previous line's content, so a deletion or edit is detectable by `verify_look_log`).""" + import fcntl + + path = Path(path) + path.parent.mkdir(parents=True, exist_ok=True) + with open(path, "a+b") as f: + fcntl.flock(f, fcntl.LOCK_EX) + f.seek(0) + lines = [l for l in f.read().splitlines() if l.strip()] + prev = json.loads(lines[-1])["entry_sha256"] if lines else "0" * 64 + rec = dict(entry, prev_sha256=prev) + rec["entry_sha256"] = hashlib.sha256(json.dumps(rec, sort_keys=True).encode()).hexdigest() + f.write((json.dumps(rec, sort_keys=True) + "\n").encode()) + f.flush() + os.fsync(f.fileno()) + return rec + + +def verify_look_log(path) -> dict: + """Recompute the chain. -> {ok, n_entries, error}.""" + prev, n = "0" * 64, 0 + for i, line in enumerate(Path(path).read_text().splitlines(), 1): + if not line.strip(): + continue + rec = json.loads(line) + got = rec.pop("entry_sha256", None) + if rec.get("prev_sha256") != prev: + return {"ok": False, "n_entries": n, "error": f"line {i}: chain broken (prev_sha256 does not match the previous entry)"} + if hashlib.sha256(json.dumps(rec, sort_keys=True).encode()).hexdigest() != got: + return {"ok": False, "n_entries": n, "error": f"line {i}: entry content does not match its hash"} + prev, n = got, n + 1 + return {"ok": True, "n_entries": n, "error": None} + + +def look_entry(config: str, splits: list, report_json: Path, report_md: Path, inputs: dict) -> dict: + try: + who = getpass.getuser() + except Exception: # noqa: BLE001 (no passwd entry in some containers) + who = os.environ.get("USER", "unknown") + return {"who": {"user": who, "host": socket.gethostname(), "pid": os.getpid(), "argv": list(sys.argv)}, + "when_utc": datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%S.%fZ"), + "what": {"config": config, "splits": sorted(splits), "report_json": str(report_json), "report_md": str(report_md), + "report_json_sha256": C.sha256_file(report_json), "report_md_sha256": C.sha256_file(report_md)}, + "inputs": inputs} + + +def build_sota_report(problems_by_source: dict, runs: dict, baseline: str, candidate: str, seed: int, n_boot: int, + frozen_verifier_hash: Optional[str] = None, registered_hash: Optional[str] = None, + allow_verifier_mismatch: bool = False, allow_design_mismatch: bool = False, allow_unrepaired: bool = False, + n_flips: Optional[int] = None) -> dict: + cfg, alpha = SOTA, SOTA["alpha"] + n_flips = n_flips or cfg["n_flips"] + cp, cb_cfg = cfg["coprimary"], cfg["class_b"] + sb, sc = runs[baseline][0], runs[candidate][0] + mb_, mc_ = runs[baseline][1], runs[candidate][1] + sig_b = {verifier_signature(mb_["verifier"])} if mb_["verifier"] else set() + sig_c = {verifier_signature(mc_["verifier"])} if mc_["verifier"] else set() + rep: dict = {"configuration": cfg["name"], "registered": {k: v for k, v in cfg.items()}, "baseline": baseline, "candidate": candidate, + "seed": seed} + if sig_b != sig_c or mb_["budgets"] != mc_["budgets"]: + msg = f"verifier differs: baseline {sig_b} budgets {mb_['budgets']} vs candidate {sig_c} budgets {mc_['budgets']}" + if not allow_verifier_mismatch: + raise C.ContractError(msg + " (--allow-verifier-mismatch to compare anyway)") + rep["verifier_mismatch"] = msg + design = design_mismatches(sb, sc) + if design: + if not allow_design_mismatch: + raise C.ContractError("; ".join(design) + " (--allow-design-mismatch to compare anyway)") + rep["design_mismatch"] = design + + cur = prereg_overall_hash() + rep["prereg"] = {"current_overall_sha256": cur, "registered_overall_sha256": registered_hash, + "status": "unregistered" if not registered_hash else ("verified" if registered_hash == cur else "MODIFIED_AFTER_REGISTRATION")} + + def subset(src, split): + return {pid: r for pid, r in problems_by_source.get(src, {}).items() if r.get("split") == split} + + test = subset(cp["source"], cp["split"]) + pids = set(test) + if not pids: + raise C.ContractError(f"no {cp['source']}/{cp['split']} problems supplied: the co-primary set is empty") + prob_set_hash = hashlib.sha256(json.dumps(sorted((p, test[p]["statement_sha"]) for p in test)).encode()).hexdigest() + + # ---- blockers: what stops a number from carrying the CONFIRMATORY label (never an exception: the numbers are still shown) + blockers = [] + if len(pids) != cp["n_problems"]: + blockers.append(f"{len(pids)} {cp['source']}/{cp['split']} problems scored, the registered set has {cp['n_problems']}") + for name, (_, meta) in runs.items(): + if name not in (baseline, candidate): + continue + if len(meta["verifier_signatures"]) != 1: + blockers.append(f"run {name}: records were verified under {len(meta['verifier_signatures'])} different verifier configurations") + if meta["budgets"] != [cfg["verifier_budget_s"]]: + blockers.append(f"run {name}: verifier budgets {meta['budgets']} are not the registered [{cfg['verifier_budget_s']}]") + vh = verifier_hash(meta["verifier"]) + if frozen_verifier_hash is None: + blockers.append("no frozen verifier hash supplied (--frozen-verifier-hash): the frozen-verifier rule cannot be checked") + elif vh != frozen_verifier_hash: + blockers.append(f"run {name}: verifier hash {vh} is not the frozen {frozen_verifier_hash}") + blockers = list(dict.fromkeys(blockers)) + if rep["prereg"]["status"] != "verified": + blockers.append(f"pre-registration hash status: {rep['prereg']['status']}") + if rep.get("verifier_mismatch"): + blockers.append("verifier mismatch overridden") + if rep.get("design_mismatch"): + blockers.append("generation design mismatch overridden") + if allow_unrepaired: + blockers.append("--allow-unrepaired: failed samples without a repair record were counted as failures") + + fid = {name: _fidelity_summary(runs[name][0], pids, cp["cap"]) for name in (baseline, candidate)} + if any(f["unchecked"] for f in fid.values()): + blockers.append("statement fidelity (rewritten-statement count) was not checked for every accepted sample") + tok = {name: sum(s.n_tok for (pid, _, _, c), s in runs[name][0].items() if pid in pids and c == cp["cap"]) for name in (baseline, candidate)} + + def tag(klass: str, source: str, split: str, n_prob: int, n: int, k: int, ledger=None) -> dict: + return {"class": klass, "benchmark": f"{source}/{split}", "n_problems": n_prob, "problem_set_sha256": prob_set_hash if source == cp["source"] else None, + "n": n, "k": k, "cap_tokens": cp["cap"], "ledger_tokens": ledger, + "lean_mathlib_pins": (mb_["verifier"] or {}).get("pins"), "verifier_hash": verifier_hash(mb_["verifier"]), + "verifier_budget_s": mb_["budgets"], "verifier_config": (mb_["verifier"] or {}).get("config"), + "rewritten_statement_count": {"baseline": fid[baseline]["rewritten_statement_count"], "candidate": fid[candidate]["rewritten_statement_count"]}, + "completion_tokens_total": {"baseline": tok[baseline], "candidate": tok[candidate]}, "statement_version": "as supplied in the problems file (see problem_set_sha256)"} + + # ---- co-primary A + co: dict = {} + ca_b, fl_b = class_a_counts(sb, pids, cp["cap"]) + ca_c, fl_c = class_a_counts(sc, pids, cp["cap"]) + for nm, cn in (("baseline", ca_b), ("candidate", ca_c)): + ns = {n for n, _ in cn.values()} + if ns != {cp["n"]}: + blockers.append(f"{nm}: n per problem is {sorted(ns)}, the registered n is {cp['n']}") + A = sota_contrast(ca_b, ca_c, cp["k"], seed, n_boot, n_flips) + A["protocol"] = tag("A", cp["source"], cp["split"], len(pids), cp["n"], cp["k"]) + co["A"] = A + # ---- co-primary B + bb = class_b_counts(sb, pids, cp["cap"], cb_cfg["max_repairs"], cb_cfg["ledger_tokens"], allow_unrepaired) + bc = class_b_counts(sc, pids, cp["cap"], cb_cfg["max_repairs"], cb_cfg["ledger_tokens"], allow_unrepaired) + if bb is None or bc is None: + B = {"available": False, "k": cp["k"], "why": "no Lean-feedback repair rounds in the " + ("baseline" if bb is None else "candidate") + " run"} + else: + B = sota_contrast(bb, bc, cp["k"], seed + 1, n_boot, n_flips) + if B["available"] and B["n_per_problem"] != [cp["n"]]: + blockers.append(f"class B: trajectories per problem {B['n_per_problem']}, registered {cp['n']}") + B["protocol"] = tag("B", cp["source"], cp["split"], len(pids), cp["n"], cp["k"], cb_cfg["ledger_tokens"]) + co["B"] = B + pA = A["p_sign_flip"] if A["available"] else 1.0 + pB = B["p_sign_flip"] if B["available"] else 1.0 # an uncomputed co-primary counts as p = 1: Holm is over the registered two + h = PD.holm([pA, pB], alpha) + for key, nm, adj, rej in zip(("A", "B"), ("A", "B"), h["adjusted"], h["reject"]): + e = co[key] + e["p_holm"], e["reject_holm"] = adj, bool(rej) + if e["available"]: + e["decision"] = ("SUCCESS: significant improvement" if rej and e["delta"] > 0 else + "significant DEGRADATION" if rej and e["delta"] < 0 else "no detectable improvement") + co["holm"] = {"alpha": alpha, "m": 2, "p_in": [pA, pB], **{k: v for k, v in h.items() if k != "alpha"}} + rep["coprimary"] = co + rep["confirmatory_blockers"] = blockers + rep["label"] = "CONFIRMATORY" if not blockers and A["available"] and B["available"] else "NOT CONFIRMATORY" + rep["fidelity"] = fid + + # ---- secondary (not multiplicity-corrected, stated as such) + sec: dict = {} + n1 = cfg["secondary"]["mcnemar_first_n"] + if min(len(f) for f in fl_b.values()) >= n1 and min(len(f) for f in fl_c.values()) >= n1: + m = S.mcnemar_exact({p: S.solved_at_k(fl_b[p], n1) for p in fl_b}, {p: S.solved_at_k(fl_c[p], n1) for p in fl_c}) + sec["mcnemar_first_32"] = dict(m.to_json(), definition=f"solved@{n1} = any of the FIRST {n1} samples (by sample_idx), class A") + else: + sec["mcnemar_first_32"] = {"available": False, "why": f"needs >= {n1} samples per problem"} + sec["mean_pass@1"] = sota_contrast(ca_b, ca_c, 1, seed + 2, n_boot, n_flips) + pn = cfg["secondary"]["proofnet"] + pset = set(subset(pn["source"], pn["split"])) + if pset and all(any(k[0] == p and k[2] == 0 and k[3] == cp["cap"] for k in sb) for p in pset) and all(any(k[0] == p and k[2] == 0 and k[3] == cp["cap"] for k in sc) for p in pset): + pb_, _ = class_a_counts(sb, pset, cp["cap"]) + pc_, _ = class_a_counts(sc, pset, cp["cap"]) + sec["proofnet_class_A"] = sota_contrast(pb_, pc_, pn["k"], seed + 3, n_boot, n_flips) + sec["proofnet_class_A"]["protocol"] = tag("A", pn["source"], pn["split"], len(pset), min(n for n, _ in pb_.values()), pn["k"]) + else: + sec["proofnet_class_A"] = {"available": False, "why": "no complete proofnet/test class-A samples in both runs"} + rep["secondary"] = sec + + # ---- exploratory (no multiplicity control, no decision attached) + exp: dict = {"fidelity_strict_class_A": None, "other_sets": {}, "class_A_other_k": {}} + sa_b, sa_c = _strict_class_a(sb, pids, cp["cap"]), _strict_class_a(sc, pids, cp["cap"]) + exp["fidelity_strict_class_A"] = dict(sota_contrast(sa_b, sa_c, cp["k"], seed + 4, n_boot, n_flips), + definition="class A with rewritten/unreadable accepted samples counted as failures") + for k in cfg["exploratory_ks"]: + if k != cp["k"]: + exp["class_A_other_k"][str(k)] = sota_contrast(ca_b, ca_c, k, seed + 5 + k, n_boot, n_flips) + for src, probs in sorted(problems_by_source.items()): + for split in sorted({r.get("split") for r in probs.values()}): + if (src, split) in ((cp["source"], cp["split"]), (pn["source"], pn["split"])): + continue + ids = set(subset(src, split)) + try: + xb, _ = class_a_counts(sb, ids, cp["cap"]) + xc, _ = class_a_counts(sc, ids, cp["cap"]) + except C.ContractError as e: + exp["other_sets"][f"{src}/{split}"] = {"available": False, "why": str(e)} + continue + exp["other_sets"][f"{src}/{split}"] = {str(k): sota_contrast(xb, xc, k, seed + 9 + k, n_boot, n_flips) for k in cfg["exploratory_ks"]} + rep["exploratory"] = exp + return rep + + +def render_sota_md(rep: dict) -> str: + pct = lambda x: "n/a" if x is None else f"{100 * x:.2f}" + L = [f"# SOTA evaluation report ({rep['configuration']})", "", + f"**{rep['label']}.** Baseline `{rep['baseline']}`, candidate `{rep['candidate']}`. Registered in docs/PREREG_SOTA.md: co-primary A and B on miniF2F-test, " + "unbiased pass@32 from n = 64, paired sign-flip test, two-sided alpha 0.05, Holm over {A, B}.", ""] + pr = rep["prereg"] + L += [f"Pre-registration hash: {pr['status']} (current {pr['current_overall_sha256']}, registered {pr['registered_overall_sha256']}).", ""] + if rep["confirmatory_blockers"]: + L += ["WARNING: not confirmatory because:"] + [f"* {b}" for b in rep["confirmatory_blockers"]] + [""] + for w in ("verifier_mismatch", "design_mismatch"): + if rep.get(w): + L += [f"WARNING: {w}: {rep[w]}", ""] + L += ["## Co-primary (Holm over A and B)", "", + "| class | k | n/prob | pass@k base % | pass@k cand % | delta pp [95% problem bootstrap] | p (sign-flip) | p (Holm) | decision |", "|" + "---|" * 9] + for key in ("A", "B"): + e = rep["coprimary"][key] + if not e["available"]: + L.append(f"| {key} | {e['k']} | | | | | | | UNAVAILABLE: {e['why']} |") + continue + L.append(f"| {key} | {e['k']} | {e['n_per_problem']} | {pct(e['mean_baseline'])} | {pct(e['mean_candidate'])} | {pct(e['delta']).rjust(1)} " + f"[{pct(e['delta_ci95_paired_bootstrap'][0])}, {pct(e['delta_ci95_paired_bootstrap'][1])}] | {e['p_sign_flip']:.5f} ({e['sign_flip_method']}) | " + f"{e['p_holm']:.5f} | {e['decision']} |") + L += ["", "Rewritten-statement count (accepted samples whose displayed theorem differs from the reference statement; unreadable included):", ""] + for name, f in rep["fidelity"].items(): + L.append(f"* `{name}`: {f['rewritten_statement_count']} of {f['accepted_samples']} accepted samples " + f"(rewritten {f['rewritten']}, unreadable {f['unreadable']}, unchecked {f['unchecked']}; {f['problems_with_rewritten_accept']} problems; {f['in_repair_rounds']} in repair rounds)") + L += ["", "Protocol tuple (class A):", "", "```json", json.dumps(rep["coprimary"]["A"]["protocol"], indent=1, default=str), "```", "", + "## Secondary (not multiplicity-corrected)", ""] + m = rep["secondary"]["mcnemar_first_32"] + if m.get("n_problems"): + L.append(f"* McNemar exact, solved@32 of the first 32 samples, class A: solved {m['solved_a']} vs {m['solved_b']}, only-base {m['only_a']}, only-cand {m['only_b']}, p = {m['p_value_exact_two_sided']:.4f}") + else: + L.append(f"* McNemar first 32: unavailable ({m.get('why')})") + for key, nm in (("mean_pass@1", "mean pass@1 (class A)"), ("proofnet_class_A", "ProofNet-test class A pass@32")): + e = rep["secondary"][key] + L.append(f"* {nm}: " + (f"{pct(e['mean_baseline'])} -> {pct(e['mean_candidate'])}, delta {pct(e['delta'])} pp [{pct(e['delta_ci95_paired_bootstrap'][0])}, {pct(e['delta_ci95_paired_bootstrap'][1])}], p = {e['p_sign_flip']:.4f}" + if e.get("available") else f"unavailable ({e.get('why')})")) + L += ["", "## Exploratory (descriptive; no decision attached)", ""] + e = rep["exploratory"]["fidelity_strict_class_A"] + if e.get("available"): + L.append(f"* class A pass@32 with rewritten accepts counted as failures: {pct(e['mean_baseline'])} -> {pct(e['mean_candidate'])}, delta {pct(e['delta'])} pp, p = {e['p_sign_flip']:.4f}") + for k, e in rep["exploratory"]["class_A_other_k"].items(): + if e.get("available"): + L.append(f"* miniF2F-test class A pass@{k}: delta {pct(e['delta'])} pp [{pct(e['delta_ci95_paired_bootstrap'][0])}, {pct(e['delta_ci95_paired_bootstrap'][1])}], p = {e['p_sign_flip']:.4f}") + for name, ks in rep["exploratory"]["other_sets"].items(): + for k, e in (ks.items() if "available" not in ks else []): + if e.get("available"): + L.append(f"* {name} class A pass@{k}: delta {pct(e['delta'])} pp, p = {e['p_sign_flip']:.4f}") + if "available" in ks: + L.append(f"* {name}: unavailable ({ks['why']})") + return "\n".join(L) + "\n" + + +def main(argv=None) -> int: + ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) + ap.add_argument("--config", choices=("p2", "sota"), default="p2", + help="p2 = the registered P2 primary (default, unchanged); sota = the separately registered SOTA analysis (docs/PREREG_SOTA.md)") + ap.add_argument("--verify-prereg", action="store_true", help="print the pre-registration hash status and exit") + ap.add_argument("--problems", nargs="+") + ap.add_argument("--run", action="append", help="NAME:GEN[,GEN..]:VER[,VER..]") + ap.add_argument("--baseline") + ap.add_argument("--candidate") + ap.add_argument("--out-dir") + ap.add_argument("--ks", type=int, nargs="+", default=list(DEFAULT_KS)) + ap.add_argument("--seed", type=int, default=0) + ap.add_argument("--bootstrap", type=int, default=10_000) + ap.add_argument("--allow-ragged-n", action="store_true") + ap.add_argument("--allow-verifier-mismatch", action="store_true") + ap.add_argument("--allow-design-mismatch", action="store_true", help="compare runs whose sampling settings or round-0 prompts differ (recorded in the report)") + ap.add_argument("--frozen-verifier-hash", help="sota: sha256 of the frozen verifier (pins + config); a run verified under anything else is not confirmatory") + ap.add_argument("--registered-hash", help="sota: the overall hash from scripts/p2/prereg_hash.py that was time-stamped") + ap.add_argument("--n-flips", type=int, default=SOTA["n_flips"], help="sota: Monte Carlo sign flips when more than 20 problems differ") + ap.add_argument("--allow-unrepaired", action="store_true", help="sota: count failed samples with no repair record as class-B failures (recorded)") + ap.add_argument("--look-log", default=os.environ.get("P2_LOOK_LOG", str(LOOK_LOG_DEFAULT)), + help="append-only log of every test-split report (default docs/evidence/test_split_looks.jsonl, or $P2_LOOK_LOG)") + a = ap.parse_args(argv) + if a.verify_prereg: + st = prereg_status() + print(json.dumps(st)) + return 0 if st["status"] == "verified" else 1 + if not (a.problems and a.run and a.out_dir): + ap.error("--problems, --run and --out-dir are required") + problems_by_source: dict[str, dict] = defaultdict(dict) + for p in a.problems: + for pid, rec in C.load_problems(p).items(): + problems_by_source[rec["source"]][pid] = rec + statements = {pid: rec["formal_statement"] for probs in problems_by_source.values() for pid, rec in probs.items()} if a.config == "sota" else None + runs = {} + for spec in a.run: + name, gens, vers = spec.split(":") + if name in runs: + raise SystemExit(f"duplicate run name {name}") + runs[name] = load_run(gens.split(","), vers.split(","), statements) + if bool(a.baseline) != bool(a.candidate): + raise SystemExit("--baseline and --candidate go together") + for n in (a.baseline, a.candidate): + if n and n not in runs: + raise SystemExit(f"unknown run {n}") + if a.config == "sota": + if not (a.baseline and a.candidate): + raise SystemExit("--config sota needs --baseline and --candidate") + rep = build_sota_report(dict(problems_by_source), runs, a.baseline, a.candidate, a.seed, a.bootstrap, a.frozen_verifier_hash, a.registered_hash, + a.allow_verifier_mismatch, a.allow_design_mismatch, a.allow_unrepaired, a.n_flips) + md = render_sota_md(rep) + else: + rep = build_report(dict(problems_by_source), runs, a.baseline, a.candidate, tuple(a.ks), a.seed, a.bootstrap, a.allow_ragged_n, a.allow_verifier_mismatch, a.allow_design_mismatch) + md = render_md(rep) + out = Path(a.out_dir) + out.mkdir(parents=True, exist_ok=True) + (out / "report.json").write_text(json.dumps(rep, indent=1, default=str)) + (out / "report.md").write_text(md) + splits = sorted({f"{src}/{r.get('split')}" for src, probs in problems_by_source.items() for r in probs.values() if r.get("split") == "test"}) + if splits: # a test-split look: recorded BEFORE the numbers are printed + inputs = {"problems": {p: C.sha256_file(p) for p in a.problems}, + "runs": {n: {"gen": {g: C.sha256_file(g) for g in spec.split(":")[1].split(",")}, "ver": {v: C.sha256_file(v) for v in spec.split(":")[2].split(",")}} + for n, spec in ((sp.split(":")[0], sp) for sp in a.run)}} + append_look_log(a.look_log, look_entry(a.config, splits, out / "report.json", out / "report.md", inputs)) + print(md) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/code/scripts/p2/expert_iteration.py b/code/scripts/p2/expert_iteration.py new file mode 100644 index 0000000000000000000000000000000000000000..ccb900d756423ad426a438e187cb21d2bf09d64c --- /dev/null +++ b/code/scripts/p2/expert_iteration.py @@ -0,0 +1,1028 @@ +#!/usr/bin/env python +"""Resumable multi-round expert iteration (self-distillation) for the P2 prover. + + .venv/bin/python scripts/p2/expert_iteration.py --run-dir RUN --pool train_pool.jsonl \\ + --eval-problems minif2f_valid.jsonl --protected-problems minif2f.jsonl mobench.jsonl \\ + --base-model SNAPSHOT_DIR --rounds 3 --dry-run + +The FIRST --eval-problems file is scored and drives the stop rule, so it must be a development split +(miniF2F-valid); a test split is refused unless --allow-test-selection (one pre-registered run only). + +One round = eleven stages (docs/SELF_DISTILLATION.md has the recipe and the reasoning): + + select pass-rate band selection from the previous round's measurements (in-process) + gen_verify scripts/p2/pipeline_gen_verify.py: sample n proofs per problem, Lean-verify (GPU+CPU) + gen_verify_corr [--corrections] one Lean-feedback correction round for unsolved problems (GPU+CPU) + measure per-problem pass rates after this round (in-process) + sft_data scripts/p2/build_sft_data.py with the keep rules (CPU) + train scripts/sft.py + configs/p2_sft.toml (LoRA), 4 ranks (GPU) + export scripts/qwen3_export_check.py: merge LoRA into the round's input model (GPU) + average [--avg-weight] align.model.weight_average: (1-w)*anchor + w*merged (CPU) + eval pipeline_gen_verify on miniF2F at --eval-n / --eval-cap (GPU+CPU) + report scripts/p2/eval_report.py against the baseline run (CPU) + manifest round_manifest.json, paired McNemar vs previous round, stop decision (in-process) + +THIS DRIVER NEVER TAKES THE HEAVY LOCK AND NEVER RUNS ANYTHING ITSELF: every heavy child +(pipeline_gen_verify, sft.py, qwen3_export_check.py) takes the repo lock itself, one at a time, in +series. `--dry-run` prints the exact command lines and touches nothing. + +IDEMPOTENT AND RESUMABLE PER STAGE. `RUN/round_NN/stages/.done.json` is written only after a stage +finished and its declared outputs exist. A marker stores a CHAIN HASH = H(previous stage's chain hash, +this stage's command line / parameters); a re-run skips a stage whose marker matches. If the +configuration changed so that a finished stage's hash no longer matches, the driver REFUSES (exit 3) +rather than silently mixing two configurations; `--redo ROUND:STAGE` deletes that stage's markers AND +artefacts (checkpoints, samples, exports, eval files) and every later one's, so nothing produced under the old +configuration is resumed. A crash leaves no marker, so the stage re-runs (the children are themselves resumable: +generation and verification are keyed append-only, training resumes from checkpoints, and a stage's +partial outputs are removed first where a child refuses to overwrite them). + +Exit codes: 0 finished (or stopped by the pre-registered rule), 3 a stage failed / config mismatch, +4 stopped by a data condition (no problems in band, no training rows), 143 stopped by a signal. +""" + +from __future__ import annotations + +import argparse +import hashlib +import importlib.util +import json +import os +import shlex +import shutil +import signal +import subprocess +import sys +import time +from dataclasses import dataclass, field +from pathlib import Path +from typing import Callable, Optional + +REPO = Path(__file__).resolve().parents[2] +sys.path.insert(0, str(REPO)) + + +def _load_common(): + spec = importlib.util.spec_from_file_location("p2_common", Path(__file__).with_name("p2_common.py")) + mod = importlib.util.module_from_spec(spec) + sys.modules["p2_common"] = mod + spec.loader.exec_module(mod) + return mod + + +C = sys.modules.get("p2_common") or _load_common() + +PY = str(REPO / ".venv" / "bin" / "python") +SCRIPTS = REPO / "scripts" +STAGE_ORDER = ["select", "gen_verify", "gen_verify_corr", "measure", "sft_data", "train", "export", "average", "eval", "report", "manifest"] +EXIT_OK, EXIT_FAIL, EXIT_DATA_STOP, EXIT_SIGNAL = 0, 3, 4, 143 + + +class DriverError(RuntimeError): + pass + + +class DataStop(Exception): + """A data condition that makes continuing meaningless (not a failure).""" + + +# =============================================================================== pure logic + + +def pass_rate_table(ver_paths, cap_tokens: int) -> dict[str, dict]: + """Per-problem pass rate from verification records: round-0 samples at `cap_tokens` only + (correction rounds are a different task; other caps are a different experiment).""" + acc: dict[str, list[int]] = {} + seen = set() + for p in ver_paths: + for rec in C.iter_jsonl(p): + if int(rec["round"]) != 0 or int(rec["cap_tokens"]) != cap_tokens: + continue + key = C.gen_key(rec) + if key in seen: + raise C.ContractError(f"{p}: duplicate verification key {key}") + seen.add(key) + a = acc.setdefault(rec["problem_id"], [0, 0]) + a[0] += int(bool(rec["accepted"])) + a[1] += 1 + return {pid: {"p": c / n, "c": c, "n": n} for pid, (c, n) in acc.items()} + + +def _h(seed: int, pid: str) -> str: + return hashlib.sha256(f"{seed}|{pid}".encode()).hexdigest() + + +def select_by_band(table: dict[str, dict], pool_ids: list[str], lo: float, hi: float, *, include_zero: bool = False, + max_zero: Optional[int] = None, max_problems: Optional[int] = None, seed: int = 0): + """Problems whose measured pass rate p satisfies lo < p <= hi (the band where a sample both + succeeds sometimes and fails sometimes; Goedel-V2 uses (0, 0.75], GAR drops p = 0 and p > 0.5). + + * p > hi -> "too_easy" (nothing to learn, and it crowds the mix) + * 0 < p <= lo -> "too_hard" (only when lo > 0) + * p == 0 -> "unsolved" (kept only with include_zero, at most max_zero, hash-ordered) + * never measured -> "unmeasured" (kept: it must be measured before it can be excluded) + Priority when --max-problems truncates: in-band, then unmeasured, then unsolved; inside a group + by a seeded hash of the problem id (deterministic, independent of file order). + Returns (selected ids in priority order, {pid: reason}, counts).""" + if not (0.0 <= lo < hi <= 1.0): + raise ValueError(f"need 0 <= lo < hi <= 1, got lo={lo} hi={hi}") + groups = {"in_band": [], "unmeasured": [], "unsolved": []} + why: dict[str, str] = {} + for pid in pool_ids: + m = table.get(pid) + if m is None: + groups["unmeasured"].append(pid) + why[pid] = "unmeasured" + elif m["p"] > hi: + why[pid] = "too_easy" + elif m["p"] == 0.0: + groups["unsolved"].append(pid) + why[pid] = "unsolved" + elif m["p"] <= lo: + why[pid] = "too_hard" + else: + groups["in_band"].append(pid) + why[pid] = "in_band" + for g in groups.values(): + g.sort(key=lambda p: _h(seed, p)) + if not include_zero: + for pid in groups["unsolved"]: + why[pid] = "unsolved_excluded" + groups["unsolved"] = [] + elif max_zero is not None: + for pid in groups["unsolved"][max_zero:]: + why[pid] = "unsolved_over_cap" + groups["unsolved"] = groups["unsolved"][:max_zero] + order = groups["in_band"] + groups["unmeasured"] + groups["unsolved"] + if max_problems is not None: + for pid in order[max_problems:]: + why[pid] = "over_max_problems" + order = order[:max_problems] + counts: dict[str, int] = {} + for r in why.values(): + counts[r] = counts.get(r, 0) + 1 + return order, why, counts + + +def keep_rule_args(cfg) -> list[str]: + """The recipe's keep rules, as build_sft_data.py flags: + * at most `max_per_problem` proofs per problem, DISTINCT (whitespace/comment-insensitive hash); + * `rank`: shortest completion first (default) or sampling order; + * problems solved n/n at round 0 contribute nothing (unless keep_easy); + * duplicates fill the cap only with fill_duplicates; + * at most `max_correction_per_problem` self-correction rows (only if corrections are on); + * rows longer than max_seq_len are dropped, never truncated.""" + a = ["--max-per-problem", str(cfg.max_per_problem), "--max-correction-per-problem", str(cfg.max_correction_per_problem), + "--rank", cfg.rank, "--max-seq-len", str(cfg.max_seq_len)] + if cfg.keep_easy: + a.append("--keep-easy") + if cfg.fill_duplicates: + a.append("--fill-duplicates") + return a + + +def solved_map(ver_path, problem_ids, n_expected: int, cap_tokens: int) -> dict[str, bool]: + """problem -> solved@n from a COMPLETE miniF2F-style verification file (round 0, one cap).""" + got: dict[str, list[bool]] = {} + for rec in C.iter_jsonl(ver_path): + if int(rec["round"]) == 0 and int(rec["cap_tokens"]) == cap_tokens: + got.setdefault(rec["problem_id"], []).append(bool(rec["accepted"])) + missing = [p for p in problem_ids if len(got.get(p, [])) < n_expected] + if missing: + raise DriverError(f"{ver_path}: {len(missing)} problems have fewer than {n_expected} verified samples at cap {cap_tokens} " + f"(first: {missing[0]}); a missing sample is not a failure of the model") + return {p: any(got[p][:n_expected]) for p in problem_ids} + + +def eval_summary(ver_path, problem_ids, n: int, cap_tokens: int) -> dict: + sm = solved_map(ver_path, problem_ids, n, cap_tokens) + c = tot = 0 + for rec in C.iter_jsonl(ver_path): + if int(rec["round"]) == 0 and int(rec["cap_tokens"]) == cap_tokens and rec["problem_id"] in sm: + c += int(bool(rec["accepted"])) + tot += 1 + return {"n_problems": len(sm), "n_samples_per_problem": n, "cap_tokens": cap_tokens, "solved_at_n": sum(sm.values()), + "solved_fraction": sum(sm.values()) / len(sm), "mean_pass_at_1": c / tot if tot else None, "samples": tot} + + +def stop_decision(history: list[dict], *, rounds_done: int, max_rounds: int, min_gain: int = 2, patience: int = 2, + max_regress: int = 3, target_solved: Optional[int] = None) -> dict: + """The pre-registered stopping rule (docs/SELF_DISTILLATION.md section 5). `history` is one entry per + finished round, oldest first: {"round", "n_eval", "cap", "solved"}; only entries with the same + (n_eval, cap) as the newest are comparable (the final round may use a bigger n). + + 1. target_reached : newest solved >= target_solved + 2. regression : newest solved <= best-so-far - max_regress (keep the best round's model) + 3. plateau : each of the last `patience` rounds failed to beat the best BEFORE it by >= min_gain problems + 4. max_rounds : rounds_done >= max_rounds + otherwise continue. Gains are in problems on the SAME problem set, n and cap (1 problem = 0.41 pp on miniF2F-test).""" + if not history: + return {"stop": False, "reason": "no_history", "best_round": None} + last = history[-1] + comp = [h for h in history if (h["n_eval"], h["cap"]) == (last["n_eval"], last["cap"])] + best = max(comp, key=lambda h: (h["solved"], -h["round"])) + if target_solved is not None and last["solved"] >= target_solved: + return {"stop": True, "reason": "target_reached", "best_round": best["round"]} + prior_best = max((h["solved"] for h in comp[:-1]), default=None) + if prior_best is not None and last["solved"] <= prior_best - max_regress: + return {"stop": True, "reason": "regression", "best_round": best["round"]} + if len(comp) >= patience + 1: + flat = 0 + for i in range(len(comp) - patience, len(comp)): + before = max(h["solved"] for h in comp[:i]) + if comp[i]["solved"] - before < min_gain: + flat += 1 + if flat == patience: + return {"stop": True, "reason": "plateau", "best_round": best["round"]} + if rounds_done >= max_rounds: + return {"stop": True, "reason": "max_rounds", "best_round": best["round"]} + return {"stop": False, "reason": "continue", "best_round": best["round"]} + + +# =============================================================================== config and layout + + +@dataclass +class Cfg: + run_dir: str + pool: str + eval_problems: list + base_model: str + rounds: int = 3 + start_round: int = 1 + name: str = "ei" + # selection + band_lo: float = 0.0 + band_hi: float = 0.75 + include_zero: bool = False + max_zero: Optional[int] = None + max_problems: Optional[int] = None + seed_ver: list = field(default_factory=list) + seed: int = 0 + # generation + n: int = 8 + gen_cap: int = 16384 + tp: int = 4 + workers: int = 8 + corrections: bool = False + gen_temperature: Optional[float] = None # None: lean_gen's own default (1.0); not passed, so old stage markers stay valid + # keep rules + max_per_problem: int = 4 + max_correction_per_problem: int = 1 + rank: str = "shortest" + keep_easy: bool = False + fill_duplicates: bool = False + max_seq_len: int = 18432 + min_rows: int = 50 + # training + train_config: str = "configs/p2_sft.toml" + nproc: int = 4 + lr: Optional[float] = None + epochs: Optional[int] = None + lora_r: int = 64 + lora_alpha: float = 128.0 + # averaging + avg_weight: Optional[float] = None + avg_anchor: str = "original" + prune_export: bool = False + # evaluation + eval_n: int = 8 + final_eval_n: Optional[int] = None + eval_cap: int = 30000 + eval_seed: int = 0 + baseline_run: Optional[str] = None + # stopping rule + min_gain: int = 2 + patience: int = 2 + max_regress: int = 3 + target_solved: Optional[int] = None + allow_test_selection: bool = False + protected_problems: list = field(default_factory=list) + redo: list = field(default_factory=list) + dry: bool = False + + def eval_n_for(self, r: int) -> int: + return self.final_eval_n if (self.final_eval_n and r == self.rounds) else self.eval_n + + +def resolve_snapshot(path: str) -> str: + """A HF `snapshots/` parent resolves to its (last) hash dir; a dir with config.json is itself.""" + p = Path(path) + if (p / "config.json").is_file(): + return str(p) + kids = sorted(d for d in p.iterdir() if d.is_dir()) if p.is_dir() else [] + return str(kids[-1]) if kids else str(p) + + +def minif2f_file(cfg: Cfg) -> str: + """The eval problem file that holds the benchmark we score: the first --eval-problems entry.""" + return str(cfg.eval_problems[0]) + + +class Layout: + def __init__(self, cfg: Cfg, r: int): + self.cfg, self.r = cfg, r + self.root = Path(cfg.run_dir) / f"round_{r:02d}" + self.stages = self.root / "stages" + self.logs = self.root / "logs" + self.sel = self.root / "select" + self.gen = self.root / "gen" + self.data = self.root / "sft_data" + self.sft = self.root / "sft" + self.export = self.root / "export" + self.export_work = self.root / "export_check" + self.avg = self.root / "avg" + self.eval = self.root / "eval" + self.report = self.root / "report" + self.pass_rates = self.root / "pass_rates.json" + self.manifest = self.root / "round_manifest.json" + + @property + def model_out(self) -> Path: + return self.avg if self.cfg.avg_weight is not None else self.export + + def eval_files(self): + n = self.cfg.eval_n_for(self.r) + tag = f"n{n}_c{self.cfg.eval_cap}" + return self.eval / f"gen_{tag}.jsonl", self.eval / f"ver_{tag}.jsonl" + + +def model_in(cfg: Cfg, r: int) -> str: + """The model round r samples from and trains on: the base for the first round, else round r-1's output.""" + if r <= 1: + return resolve_snapshot(cfg.base_model) + return str(Layout(cfg, r - 1).model_out) + + +def model_rev_args(cfg: Cfg, model: str, tag: str) -> list[str]: + """lean_gen infers a revision from a HF `snapshots/` path; anything else needs --model-rev.""" + if os.path.basename(os.path.dirname(os.path.realpath(model))) == "snapshots": + return [] + return ["--model-rev", f"{cfg.name}-{tag}"] + + +# =============================================================================== stages + + +@dataclass +class Cmd: + argv: list + env: dict = field(default_factory=dict) + + +@dataclass +class Stage: + name: str + r: int + cmds: list = field(default_factory=list) # list[Cmd] + fn: Optional[Callable] = None # in-process + desc: str = "" + params: dict = field(default_factory=dict) # hashed with the commands (in-process stages: their only identity) + outputs: list = field(default_factory=list) + pre_clean: list = field(default_factory=list) + chain: str = "" + + def identity(self) -> str: + return json.dumps({"name": self.name, "r": self.r, "cmds": [[c.argv, sorted(c.env.items())] for c in self.cmds], + "params": self.params}, sort_keys=True, default=str) + + +LATEST = "@latest_ckpt:" + + +def latest_checkpoint(sft_dir: Path) -> Optional[Path]: + """Highest micro_N checkpoint under `sft_dir/*/checkpoints` (the layout scripts/sft.py writes).""" + cands = sorted(Path(sft_dir).glob("*/checkpoints/micro_*"), key=lambda p: int(p.name.split("_")[-1]) if p.name.split("_")[-1].isdigit() else -1) + cands = [c for c in cands if (c / "optimizer_rank0.pt").exists()] + return cands[-1] if cands else None + + +def _sha_or_none(p) -> Optional[str]: + return C.sha256_file(p) if os.path.isfile(p) else None + + +def build_stages(cfg: Cfg, r: int) -> list: + L = Layout(cfg, r) + gen_model = model_in(cfg, r) + mrev = model_rev_args(cfg, gen_model, f"r{r - 1}") + pool_ids = None # read lazily inside the stage + prev_table = Layout(cfg, r - 1).pass_rates if r > cfg.start_round else None + ev_gen, ev_ver = L.eval_files() + en = cfg.eval_n_for(r) + gen_r0, ver_r0 = L.gen / "gen_r0.jsonl", L.gen / "ver_r0.jsonl" + gen_r1, ver_r1 = L.gen / "gen_r1.jsonl", L.gen / "ver_r1.jsonl" + sel_problems = L.sel / "problems.jsonl" + seed = cfg.seed + 1000 * r + env_py = {"PYTHONPATH": str(REPO)} + + def pgv(run_dir, gen_args, problems, ver_out, feedback=False): + argv = [PY, str(SCRIPTS / "p2" / "pipeline_gen_verify.py"), "--run-dir", str(run_dir), "--gen-args", " ".join(map(str, gen_args)), + "--problems", str(problems), "--verify-out", str(ver_out), "--workers", str(cfg.workers)] + if feedback: + argv.append("--feedback") + return Cmd(argv, dict(env_py)) + + st: list[Stage] = [] + + # ---- select + st.append(Stage("select", r, fn=lambda ctx: _do_select(ctx), params={ + "pool_sha": _sha_or_none(cfg.pool), "seed_ver": [_sha_or_none(p) for p in cfg.seed_ver] if r == cfg.start_round else "prev_round", + "band": [cfg.band_lo, cfg.band_hi], "include_zero": cfg.include_zero, "max_zero": cfg.max_zero, "max_problems": cfg.max_problems, + "seed": seed, "gen_cap": cfg.gen_cap}, + desc=f"band ({cfg.band_lo}, {cfg.band_hi}] on pass rates from " + + (f"{[str(p) for p in cfg.seed_ver] or 'nothing (round 1 measures the whole pool)'}" if r == cfg.start_round else str(prev_table)) + + f" -> {sel_problems}", + outputs=[sel_problems, L.sel / "selection.json"])) + + # ---- gen_verify (round-0 attempts) + ga = ["--problems", sel_problems, "--out", gen_r0, "--model", gen_model, *mrev, "--n", cfg.n, "--cap-tokens", cfg.gen_cap, + "--round", 0, "--tp", cfg.tp, "--seed", seed] + if cfg.gen_temperature is not None: + ga += ["--temperature", cfg.gen_temperature] # part of the argv, hence of the stage identity and chain hash + st.append(Stage("gen_verify", r, cmds=[pgv(L.gen / "run_r0", ga, sel_problems, ver_r0, feedback=cfg.corrections)], outputs=[gen_r0, ver_r0])) + + # ---- gen_verify_corr + if cfg.corrections: + gc = ["--problems", sel_problems, "--out", gen_r1, "--model", gen_model, *mrev, "--n", 1, "--cap-tokens", cfg.gen_cap, + "--round", 1, "--tp", cfg.tp, "--seed", seed, "--corrections-from", ver_r0, gen_r0] + if cfg.gen_temperature is not None: + gc += ["--temperature", cfg.gen_temperature] + st.append(Stage("gen_verify_corr", r, cmds=[pgv(L.gen / "run_r1", gc, sel_problems, ver_r1)], outputs=[gen_r1, ver_r1])) + + # ---- measure + st.append(Stage("measure", r, fn=lambda ctx: _do_measure(ctx), params={"gen_cap": cfg.gen_cap}, + desc=f"pass rates of this round's samples merged into the carried table -> {L.pass_rates}", outputs=[L.pass_rates])) + + # ---- sft_data + gens = [gen_r0] + ([gen_r1] if cfg.corrections else []) + vers = [ver_r0] + ([ver_r1] if cfg.corrections else []) + sd = [PY, str(SCRIPTS / "p2" / "build_sft_data.py"), "--generations", *map(str, gens), "--verifications", *map(str, vers), + "--problems", str(sel_problems), "--eval-problems", *map(str, list(cfg.eval_problems) + list(cfg.protected_problems)), + "--cap-tokens", str(cfg.gen_cap), "--out-dir", str(L.data), *keep_rule_args(cfg), "--seed", str(cfg.seed)] + st.append(Stage("sft_data", r, cmds=[Cmd(sd, dict(env_py))], outputs=[L.data / "manifest.json"], pre_clean=[L.data], + params={"min_rows": cfg.min_rows})) + + # ---- train + tr = [PY, "-m", "torch.distributed.run", "--standalone", f"--nproc_per_node={cfg.nproc}", str(SCRIPTS / "sft.py"), str(cfg.train_config), + f"--data.shard_dir={L.data / 'shards'}", f"--data.manifest_path={L.data / 'manifest.json'}", f"--paths.checkpoint_dir={L.sft}", + f"--paths.model_snapshot={gen_model}"] + if cfg.lr is not None: + tr.append(f"--optimizer.lr={cfg.lr}") + if cfg.epochs is not None: + tr.append(f"--train.epochs={cfg.epochs}") + st.append(Stage("train", r, cmds=[Cmd(tr, dict(env_py))], outputs=[L.sft])) + + # ---- export (merge LoRA into THIS round's input model; the script's base is overridden by env) + ex = [PY, str(SCRIPTS / "qwen3_export_check.py"), "run", "--ckpt", LATEST + str(L.sft), "--out-dir", str(L.export), "--work", str(L.export_work), + "--r", str(cfg.lora_r), "--alpha", str(cfg.lora_alpha), "--keep"] + st.append(Stage("export", r, cmds=[Cmd(ex, {**env_py, "P2_EXPORT_BASE_SNAPSHOT": gen_model})], outputs=[L.export / "config.json"], pre_clean=[L.export])) + + # ---- average + if cfg.avg_weight is not None: + anchor = resolve_snapshot(cfg.base_model) if cfg.avg_anchor == "original" else gen_model + av = [PY, "-m", "align.model.weight_average", "--base", anchor, "--ft", str(L.export), "--ft-weight", str(cfg.avg_weight), "--out", str(L.avg)] + st.append(Stage("average", r, cmds=[Cmd(av, dict(env_py))], outputs=[L.avg / "weight_average.json"], pre_clean=[L.avg, Path(str(L.avg) + ".partial")])) + + # ---- eval + ea = ["--problems", minif2f_file(cfg), "--out", ev_gen, "--model", L.model_out, *model_rev_args(cfg, str(L.model_out), f"r{r}"), "--n", en, + "--cap-tokens", cfg.eval_cap, "--round", 0, "--tp", cfg.tp, "--seed", cfg.eval_seed] + st.append(Stage("eval", r, cmds=[pgv(L.eval / "run", ea, minif2f_file(cfg), ev_ver)], outputs=[ev_gen, ev_ver])) + + # ---- report + base_spec = cfg.baseline_run + if base_spec is None and r > cfg.start_round: + pg, pv = Layout(cfg, r - 1).eval_files() + base_spec = f"prev:{pg}:{pv}" + if base_spec is not None: + rp = [PY, str(SCRIPTS / "p2" / "eval_report.py"), "--problems", *map(str, cfg.eval_problems), "--run", base_spec, + "--run", f"r{r}:{ev_gen}:{ev_ver}", "--baseline", base_spec.split(":")[0], "--candidate", f"r{r}", "--out-dir", str(L.report)] + st.append(Stage("report", r, cmds=[Cmd(rp, dict(env_py))], outputs=[L.report / "report.json"], pre_clean=[L.report])) + + # ---- manifest + st.append(Stage("manifest", r, fn=lambda ctx: _do_manifest(ctx), desc=f"{L.manifest}: counts, eval, paired McNemar vs previous round, stop decision", + params={"stop": [cfg.min_gain, cfg.patience, cfg.max_regress, cfg.target_solved, cfg.rounds], "eval_n": en}, outputs=[L.manifest])) + return st + + +def chain_stages(cfg: Cfg, upto_round: int) -> dict[int, list]: + """Build every stage of rounds start..upto_round and assign chain hashes (round r+1's first stage chains + off round r's last stage).""" + out: dict[int, list] = {} + prev = hashlib.sha256(json.dumps({"base": resolve_snapshot(cfg.base_model), "name": cfg.name}).encode()).hexdigest() + for r in range(cfg.start_round, upto_round + 1): + stages = build_stages(cfg, r) + for s in stages: + prev = hashlib.sha256((prev + s.identity()).encode()).hexdigest() + s.chain = prev + out[r] = stages + return out + + +# =============================================================================== in-process stages + + +@dataclass +class Ctx: + cfg: Cfg + r: int + L: Layout + + +def _write_json(path: Path, obj) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + tmp = path.with_suffix(path.suffix + ".tmp") + tmp.write_text(json.dumps(obj, indent=1, sort_keys=True, default=str)) + os.replace(tmp, path) + + +def _do_select(ctx: Ctx) -> dict: + cfg, L = ctx.cfg, ctx.L + pool = C.load_problems(cfg.pool) + if ctx.r == cfg.start_round: + table = pass_rate_table(cfg.seed_ver, cfg.gen_cap) if cfg.seed_ver else {} + src = [str(p) for p in cfg.seed_ver] + else: + table = json.loads(Layout(cfg, ctx.r - 1).pass_rates.read_text())["table"] + src = [str(Layout(cfg, ctx.r - 1).pass_rates)] + order, why, counts = select_by_band(table, list(pool), cfg.band_lo, cfg.band_hi, include_zero=cfg.include_zero, max_zero=cfg.max_zero, + max_problems=cfg.max_problems, seed=cfg.seed + 1000 * ctx.r) + if not order: + raise DataStop(f"round {ctx.r}: no problems in band ({counts}); the pool is exhausted for this model") + L.sel.mkdir(parents=True, exist_ok=True) + C.write_jsonl_atomic(L.sel / "problems.jsonl", [pool[p] for p in order]) + _write_json(L.sel / "selection.json", {"round": ctx.r, "band": [cfg.band_lo, cfg.band_hi], "source": src, "counts": counts, + "selected": len(order), "pool": len(pool), "reasons": why}) + return {"selected": len(order), "counts": counts} + + +def _do_measure(ctx: Ctx) -> dict: + cfg, L = ctx.cfg, ctx.L + prev = {} if ctx.r == cfg.start_round else json.loads(Layout(cfg, ctx.r - 1).pass_rates.read_text())["table"] + if ctx.r == cfg.start_round and cfg.seed_ver: + prev = pass_rate_table(cfg.seed_ver, cfg.gen_cap) + new = pass_rate_table([L.gen / "ver_r0.jsonl"], cfg.gen_cap) + table = dict(prev) + for pid, m in new.items(): + table[pid] = {**m, "round": ctx.r} + # a problem teaches something only if some sample was accepted and (unless --keep-easy) not all n>=8 were + # (build_sft_data.EASY_MIN_SAMPLES): with none, sft_data would raise "nothing selected" -- say so as a data stop instead + trainable = sum(1 for m in new.values() if m["c"] >= 1 and (cfg.keep_easy or m["n"] < 8 or m["c"] < m["n"])) + _write_json(L.pass_rates, {"round": ctx.r, "n_measured_this_round": len(new), "n_trainable": trainable, "table": table}) + if trainable == 0: + raise DataStop(f"round {ctx.r}: none of the {len(new)} sampled problems has both an accepted proof and a failure " + f"(all unsolved, or all solved {cfg.n}/{cfg.n}); there is nothing to distil") + return {"measured": len(new), "trainable": trainable} + + +def _do_manifest(ctx: Ctx) -> dict: + cfg, L, r = ctx.cfg, ctx.L, ctx.r + from align.eval import proof_stats as S + + ids = list(C.load_problems(minif2f_file(cfg))) + n_ev = cfg.eval_n_for(r) + _, ev_ver = L.eval_files() + ev = eval_summary(ev_ver, ids, n_ev, cfg.eval_cap) + smap = solved_map(ev_ver, ids, n_ev, cfg.eval_cap) + paired = {} + if r > cfg.start_round: + pm = json.loads(Layout(cfg, r - 1).manifest.read_text()) + if (pm["eval"]["n_samples_per_problem"], pm["eval"]["cap_tokens"]) == (n_ev, cfg.eval_cap): + pg, pv = Layout(cfg, r - 1).eval_files() + m = S.mcnemar_exact(solved_map(pv, ids, n_ev, cfg.eval_cap), smap) + paired["vs_previous_round"] = {"only_prev": m.only_a, "only_this": m.only_b, "p_value_exact_two_sided": m.p_value} + else: + paired["vs_previous_round"] = "not comparable (eval n/cap differ)" + sel = json.loads((L.sel / "selection.json").read_text()) + dm = json.loads((L.data / "manifest.json").read_text()) + rows = dm["rows"]["total"] + ckpt = latest_checkpoint(L.sft) + avg = json.loads((L.avg / "weight_average.json").read_text()) if cfg.avg_weight is not None else None + hist = [] + for k in range(cfg.start_round, r): + pm = json.loads(Layout(cfg, k).manifest.read_text()) + hist.append({"round": k, "n_eval": pm["eval"]["n_samples_per_problem"], "cap": pm["eval"]["cap_tokens"], "solved": pm["eval"]["solved_at_n"]}) + hist.append({"round": r, "n_eval": n_ev, "cap": cfg.eval_cap, "solved": ev["solved_at_n"]}) + dec = stop_decision(hist, rounds_done=r, max_rounds=cfg.rounds, min_gain=cfg.min_gain, patience=cfg.patience, max_regress=cfg.max_regress, + target_solved=cfg.target_solved) + markers = {} + for f in sorted(L.stages.glob("*.done.json")): + mk = json.loads(f.read_text()) + markers[mk["stage"]] = {"seconds": mk.get("seconds"), "finished": mk.get("finished"), "chain": mk["chain"][:12]} + man = {"round": r, "run_dir": cfg.run_dir, "model_in": model_in(cfg, r), "model_out": str(L.model_out), + "selection": {"selected": sel["selected"], "pool": sel["pool"], "counts": sel["counts"], "band": sel["band"]}, + "sft_data": {"rows": rows, "problems_with_rows": dm["problems"]["with_rows"], "tokens": dm.get("tokens"), "params": dm["params"]}, + "train": {"checkpoint": str(ckpt) if ckpt else None, "lr": cfg.lr, "epochs": cfg.epochs}, + "average": avg and {k: avg[k] for k in ("ft_weight", "tensors", "shards", "peak_buffer_bytes")}, + "eval": ev, "paired": paired, "history": hist, "stop": dec, "stages": markers, + "params": {k: v for k, v in cfg.__dict__.items() if k not in ("seed_ver", "redo", "dry")}, "written": time.strftime("%Y-%m-%d %H:%M:%S")} + _write_json(L.manifest, man) + return {"stop": dec} + + +# =============================================================================== running + + +class SubprocessRunner: + """Runs one Cmd; its stdout/stderr go to `log`. SIGTERM/SIGINT are forwarded to the child's process group.""" + + def __init__(self): + self.sig = None + + def run(self, argv: list, env: dict, log: Path) -> int: + log.parent.mkdir(parents=True, exist_ok=True) + full = {**os.environ, **env} + with open(log, "ab") as lf: + lf.write(f"\n=== {time.strftime('%F %T')} {shlex.join(argv)}\n".encode()) + lf.flush() + p = subprocess.Popen(argv, stdout=lf, stderr=subprocess.STDOUT, env=full, start_new_session=True, cwd=str(REPO)) + old = {s: signal.signal(s, lambda sg, _f: setattr(self, "sig", sg)) for s in (signal.SIGTERM, signal.SIGINT)} + try: + while p.poll() is None: + if self.sig is not None: + os.killpg(p.pid, signal.SIGTERM) + try: + p.wait(timeout=120) + except subprocess.TimeoutExpired: + os.killpg(p.pid, signal.SIGKILL) + p.wait() + return EXIT_SIGNAL + time.sleep(1.0) + finally: + for s, h in old.items(): + signal.signal(s, h) + return p.returncode + + +def expand_argv(argv: list) -> list: + out = [] + for a in argv: + if isinstance(a, str) and a.startswith(LATEST): + ck = latest_checkpoint(Path(a[len(LATEST):])) + if ck is None: + raise DriverError(f"no checkpoint with optimizer_rank0.pt under {a[len(LATEST):]}: the train stage left nothing to export") + out.append(str(ck)) + else: + out.append(str(a)) + return out + + +def display_argv(argv: list) -> str: + parts = [] + for a in argv: + if isinstance(a, str) and a.startswith(LATEST): + d = a[len(LATEST):] + parts.append(f'"$(ls -d {d}/*/checkpoints/micro_???????? | sort -V | tail -1)"') + else: + parts.append(shlex.quote(str(a))) + return " ".join(parts) + + +def marker_path(L: Layout, name: str) -> Path: + return L.stages / f"{name}.done.json" + + +def stage_status(L: Layout, s: Stage) -> str: + """done | pending | stale (marker exists for a different configuration).""" + mp = marker_path(L, s.name) + if not mp.exists(): + return "pending" + mk = json.loads(mp.read_text()) + if mk.get("chain") != s.chain: + return "stale" + if not all(Path(o).exists() for o in s.outputs): + return "pending" # marker present, outputs gone: redo + return "done" + + +def stage_artefacts(L: Layout, name: str) -> list: + """Every path stage `name` leaves on disk, including the pipeline run dirs and the export scratch that the + stage's declared outputs do not name. These are what a re-run would silently REUSE: sft.py resumes the newest + checkpoint, lean_gen resumes any gen/ver key whose model_rev LABEL (constant for an exported model) matches.""" + g = L.gen + return { + "select": [L.sel], + "gen_verify": [g / "gen_r0.jsonl", g / "ver_r0.jsonl", g / "run_r0"], + "gen_verify_corr": [g / "gen_r1.jsonl", g / "ver_r1.jsonl", g / "run_r1"], + "measure": [L.pass_rates], + "sft_data": [L.data], + "train": [L.sft], + "export": [L.export, L.export_work], + "average": [L.avg, Path(str(L.avg) + ".partial")], + "eval": [L.eval], + "report": [L.report], + "manifest": [L.manifest], + }[name] + + +def _remove(p: Path) -> None: + if p.is_dir() and not p.is_symlink(): + shutil.rmtree(p) + elif p.exists() or p.is_symlink(): + p.unlink() + + +def apply_redo(cfg: Cfg, redo: list) -> None: + """Discard the stage, every later stage of the round, and (when the redone stage can change the round's output + model or pass-rate table) every stage of every later round: their markers AND their artefacts. Markers alone are not + enough: the children resume from whatever is on disk (newest checkpoint, keyed gen/ver rows), so a redo that left + them would 'finish' instantly on the previous configuration's model and samples.""" + for spec in redo: + rs, name = spec.split(":") + r = int(rs) + if name not in STAGE_ORDER: + raise DriverError(f"--redo: unknown stage {name!r}; stages: {STAGE_ORDER}") + L = Layout(cfg, r) + for later in STAGE_ORDER[STAGE_ORDER.index(name):]: + marker_path(L, later).unlink(missing_ok=True) + for p in stage_artefacts(L, later): + _remove(p) + # round r+1 reads round r's model (export/average) and pass_rates (measure); eval/report/manifest feed nothing downstream + feeds_next = STAGE_ORDER.index(name) <= STAGE_ORDER.index("average") + for rr in range(r + 1, cfg.rounds + 1): # later rounds chained off this one are stale too + Lr = Layout(cfg, rr) + for f in Lr.stages.glob("*.done.json") if Lr.stages.exists() else []: + f.unlink() + if feeds_next: + for later in STAGE_ORDER: + for p in stage_artefacts(Lr, later): + _remove(p) + + +def generation_incomplete(gen_path, problems_path, n: int, cap_tokens: int) -> Optional[str]: + """Why `gen_path` is not a complete round-0 sample set (None = complete). + + Two independent proofs, both required (audit U12-2 follow-up): every planned key (problem x sample_idx < n at the + cap) is in the file, AND lean_gen's last manifest session says `complete` (an interrupted or crashed session + can leave a file that looks finished to a reader that only checks that it exists).""" + gen_path = Path(gen_path) + want = set() + with open(problems_path, encoding="utf-8") as f: + for ln in f: + if ln.strip(): + pid = json.loads(ln)["problem_id"] + want.update((pid, i, 0, cap_tokens) for i in range(n)) + have = set() + with open(gen_path, "rb") as f: + for raw in f: + if not raw.endswith(b"\n") or not raw.strip(): + continue # torn tail of a killed writer + r = json.loads(raw) + have.add((r["problem_id"], int(r["sample_idx"]), int(r["round"]), int(r["cap_tokens"]))) + missing = sorted(want - have) + if missing: + return f"{gen_path} is missing {len(missing)} of {len(want)} planned samples (first: {missing[:3]})" + man = Path(str(gen_path) + ".manifest.json") + if not man.exists(): + return f"{man} does not exist: cannot confirm lean_gen finished" + sessions = json.loads(man.read_text()).get("sessions") or [] + if not sessions or sessions[-1].get("status") != "complete": + return f"lean_gen's last manifest session is {sessions[-1].get('status') if sessions else None!r}, not 'complete' ({man})" + return None + + +def run_stage(cfg: Cfg, s: Stage, runner, say=print) -> str: + L = Layout(cfg, s.r) + status = stage_status(L, s) + if status == "done": + say(f"[round {s.r}] {s.name}: done (skipped)") + return "skipped" + if status == "stale": + raise DriverError(f"[round {s.r}] {s.name}: a finished marker exists for a DIFFERENT configuration (command line or upstream " + f"stage changed). Refusing to mix them. Re-run with --redo {s.r}:{s.name} to discard it and every later stage " + f"of this round, or restore the old arguments.") + t0 = time.time() + say(f"[round {s.r}] {s.name}: running") + L.stages.mkdir(parents=True, exist_ok=True) + for p in s.pre_clean: + if Path(p).exists(): + shutil.rmtree(p) if Path(p).is_dir() else Path(p).unlink() + if s.fn is not None: + s.fn(Ctx(cfg, s.r, L)) + for i, c in enumerate(s.cmds): + rc = runner.run(expand_argv(c.argv), c.env, L.logs / f"{s.name}{'' if len(s.cmds) == 1 else f'_{i}'}.log") + if rc == EXIT_SIGNAL: + raise KeyboardInterrupt(f"{s.name} stopped by a signal") + if rc != 0: + raise DriverError(f"[round {s.r}] {s.name}: command {i} exited {rc} (log: {L.logs}); no marker written, re-run resumes it") + missing = [str(o) for o in s.outputs if not Path(o).exists()] + if missing: + raise DriverError(f"[round {s.r}] {s.name}: finished but declared outputs are missing: {missing}") + if s.name == "gen_verify": + why = generation_incomplete(L.gen / "gen_r0.jsonl", L.sel / "problems.jsonl", cfg.n, cfg.gen_cap) + if why: + raise DriverError(f"[round {s.r}] gen_verify: generation is not complete, no marker written: {why}") + if s.name == "sft_data": + rows = json.loads((L.data / "manifest.json").read_text())["rows"]["total"] + if rows < cfg.min_rows: + raise DataStop(f"round {s.r}: only {rows} SFT rows (< --min-rows {cfg.min_rows}); the selected problems yielded too few accepted proofs") + if s.name == "average" and cfg.prune_export and L.export.exists(): + shutil.rmtree(L.export) # the averaged model is the round's output; the 16 GB export has served its purpose + _write_json(marker_path(L, s.name), {"stage": s.name, "round": s.r, "chain": s.chain, "finished": time.strftime("%F %T"), + "seconds": round(time.time() - t0, 1), "identity": json.loads(s.identity())}) + return "ran" + + +def run_all(cfg: Cfg, runner, say=print) -> int: + chains = chain_stages(cfg, cfg.rounds) + if cfg.redo: + apply_redo(cfg, cfg.redo) + try: + for r in range(cfg.start_round, cfg.rounds + 1): + err = split_problems(cfg) # files can change while a round runs: check again before each round + if err: + say(f"[round {r}] REFUSED: {err}") + return 3 + for s in chains[r]: + run_stage(cfg, s, runner, say) + man = json.loads(Layout(cfg, r).manifest.read_text()) + dec = man["stop"] + say(f"[round {r}] solved@{man['eval']['n_samples_per_problem']} = {man['eval']['solved_at_n']}/{man['eval']['n_problems']} " + f"(cap {man['eval']['cap_tokens']}); decision: {dec['reason']}") + if dec["stop"]: + say(f"STOP after round {r}: {dec['reason']} (best round: {dec['best_round']})") + return EXIT_OK + except DataStop as e: + say(f"STOP (data): {e}") + return EXIT_DATA_STOP + except KeyboardInterrupt as e: + say(f"interrupted: {e}") + return EXIT_SIGNAL + except (DriverError, C.ContractError) as e: + say(f"FAILED: {e}") + return EXIT_FAIL + return EXIT_OK + + +def dry_run(cfg: Cfg, say=print) -> int: + chains = chain_stages(cfg, cfg.rounds) + say(f"# dry run: nothing is executed or written. run dir {cfg.run_dir}; rounds {cfg.start_round}..{cfg.rounds}") + for r in range(cfg.start_round, cfg.rounds + 1): + L = Layout(cfg, r) + say(f"\n## round {r}: model in {model_in(cfg, r)} -> out {L.model_out}") + for s in chains[r]: + say(f"[{stage_status(L, s)}] {s.name}") + if s.fn is not None: + say(f" # in-process: {s.desc}") + for c in s.cmds: + env = " ".join(f"{k}={shlex.quote(v)}" for k, v in sorted(c.env.items())) + say(f" {('env ' + env + ' ') if env else ''}{display_argv(c.argv)}") + return EXIT_OK + + +# =============================================================================== CLI + + +def scored_splits(path: str) -> set: + """The `split` values of the scored benchmark file. FAILS CLOSED: a missing, unreadable or + malformed file, an empty file, or any record without a `split` raises ValueError -- an unknown + split must never pass as "not test".""" + try: + with open(path, encoding="utf-8") as f: + rows = [json.loads(line) for line in f if line.strip()] + except (OSError, ValueError) as e: + raise ValueError(f"cannot read the scored benchmark {path}: {e}") from e + if not rows: + raise ValueError(f"the scored benchmark {path} is empty") + missing = sum(1 for r in rows if not isinstance(r, dict) or not r.get("split")) + if missing: + raise ValueError(f"{missing} record(s) in {path} have no `split`: cannot prove it is not a test split") + return {r["split"] for r in rows} + + +def split_problems(cfg: Cfg) -> Optional[str]: + """Split hygiene; returns an error message or None. The scored benchmark drives the stop rule, the + best-round choice and the averaging check, so scoring a TEST split makes every later test number + partly a selection artefact. Development runs score miniF2F-valid. --allow-test-selection is an + escape hatch for one non-adaptive diagnostic round; the FINAL evaluation does not use this driver at + all (docs/SOTA_PLAN.md: frozen checkpoint -> pipeline_gen_verify.py -> eval_report.py). + Decontamination: every protected test benchmark must reach build_sft_data's exclusion set.""" + try: + splits = scored_splits(str(cfg.eval_problems[0])) + except ValueError as e: + return str(e) + if "test" in splits: + if not cfg.allow_test_selection: + return (f"--eval-problems[0] {cfg.eval_problems[0]} is a TEST split: rounds, the stop rule and averaging would " + "be selected on test. Score miniF2F-valid (scripts/p2/build_valid.py)") + if cfg.rounds != 1 or cfg.target_solved is not None: + return "--allow-test-selection is for ONE non-adaptive round: needs --rounds 1 and no --target-solved" + guarded = set() + for p in list(cfg.eval_problems) + list(cfg.protected_problems): + try: + guarded |= scored_splits(str(p)) + except ValueError as e: + return str(e) + if "test" not in guarded: + return ("no TEST benchmark reaches the SFT decontamination gate: pass the protected test files " + "(miniF2F-test, MOBench) via --protected-problems") + return pool_informal_overlap(cfg) + + +def pool_informal_overlap(cfg: Cfg) -> Optional[str]: + """build_sft_data excludes by exact statement_sha only, so 'the same informal problem, formalised differently' (the + realistic leak, build_problems' informal 13-gram / 8-gram layers) must be absent from the pool already. It is applied + to the pool against test+MOBench by build_problems and to the dev split by build_valid; this re-checks the files + actually handed to this driver, so a stale pool or dev file cannot inflate the dev pass rate that drives the stop rule.""" + if not Path(cfg.pool).is_file(): + return None # the missing pool is reported where it is read + spec = importlib.util.spec_from_file_location("p2_build_problems_for_ei", Path(__file__).with_name("build_problems.py")) + bp = importlib.util.module_from_spec(spec) + sys.modules[spec.name] = bp + try: + spec.loader.exec_module(bp) + finally: + sys.modules.pop(spec.name, None) + guarded = [] + for p in list(cfg.eval_problems) + list(cfg.protected_problems): + guarded.extend(C.iter_jsonl(p)) + decon = bp.Decontaminator(guarded) + hits = [(r["problem_id"], why) for r in C.iter_jsonl(cfg.pool) if (why := decon.check_informal(r.get("informal_prefix"))) is not None] + if hits: + return (f"{len(hits)} --pool problem(s) share informal statement text with --eval-problems/--protected-problems " + f"(first: {hits[:3]}): training on them leaks the dev/test problems. Drop them from the pool, or rebuild the dev " + "split with scripts/p2/build_valid.py (it drops valid problems that overlap the pool)") + return None + + +def test_guard(ap, cfg: Cfg) -> None: + err = split_problems(cfg) + if err: + ap.error(err) + + +def parse_args(argv=None) -> Cfg: + ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) + g = ap.add_argument + g("--run-dir", required=True) + g("--pool", required=True, help="training pool Problem JSONL (split=train)") + g("--eval-problems", nargs="+", required=True, help="eval Problem JSONL; the FIRST is the scored benchmark (miniF2F)") + g("--base-model", required=True, help="HF snapshot dir (or its snapshots/ parent) of the starting model") + g("--rounds", type=int, default=3) + g("--start-round", type=int, default=1) + g("--name", default="ei") + g("--seed-ver", nargs="*", default=[], help="verification JSONL(s) measured at --gen-cap that seed the pass-rate table of round 1") + g("--band-lo", type=float, default=0.0) + g("--band-hi", type=float, default=0.75) + g("--include-zero", action="store_true", help="also resample problems with measured pass rate 0") + g("--max-zero", type=int, default=None) + g("--max-problems", type=int, default=None) + g("--seed", type=int, default=0) + g("--n", type=int, default=8, help="samples per selected problem per round") + g("--gen-cap", type=int, default=16384) + g("--gen-temperature", type=float, default=None, + help="lean_gen --temperature for the generation stages (omit: lean_gen's default 1.0, argv unchanged)") + g("--tp", type=int, default=4) + g("--workers", type=int, default=8) + g("--corrections", action="store_true", help="also generate+train one Lean-feedback correction round") + g("--max-per-problem", type=int, default=4) + g("--max-correction-per-problem", type=int, default=1) + g("--rank", choices=("order", "shortest"), default="shortest") + g("--keep-easy", action="store_true") + g("--fill-duplicates", action="store_true") + g("--max-seq-len", type=int, default=18432) + g("--min-rows", type=int, default=50) + g("--train-config", default="configs/p2_sft.toml") + g("--nproc", type=int, default=4) + g("--lr", type=float, default=None) + g("--epochs", type=int, default=None) + g("--lora-r", type=int, default=64) + g("--lora-alpha", type=float, default=128.0) + g("--avg-weight", type=float, default=None, help="w in out=(1-w)*anchor+w*merged; omit for no averaging") + g("--avg-anchor", choices=("original", "start"), default="original") + g("--prune-export", action="store_true", help="delete the 16 GB merged export once the averaged model exists") + g("--eval-n", type=int, default=8) + g("--final-eval-n", type=int, default=None, help="n for the last round only (e.g. 32)") + g("--eval-cap", type=int, default=30000) + g("--eval-seed", type=int, default=0) + g("--baseline-run", default=None, help="NAME:GEN:VER of the baseline eval (same n and cap)") + g("--min-gain", type=int, default=2) + g("--patience", type=int, default=2) + g("--max-regress", type=int, default=3) + g("--target-solved", type=int, default=None) + g("--allow-test-selection", action="store_true", + help="escape hatch: permit a TEST-split scored benchmark for ONE non-adaptive round (--rounds 1, no --target-solved); " + "the final evaluation does not use this driver") + g("--protected-problems", nargs="*", default=[], + help="benchmark JSONL(s) never scored here but excluded from training data (miniF2F-test, MOBench, ...)") + g("--redo", action="append", default=[], metavar="ROUND:STAGE") + g("--dry-run", action="store_true") + a = ap.parse_args(argv) + d = vars(a) + d["dry"] = d.pop("dry_run") + cfg = Cfg(**d) + if cfg.avg_weight is not None and not (0.0 < cfg.avg_weight <= 1.0): + ap.error("--avg-weight must be in (0, 1]") + test_guard(ap, cfg) + if cfg.eval_cap < 30000: + print(f"note: --eval-cap {cfg.eval_cap} < 30000: not comparable to the published pass@32 protocol", file=sys.stderr) + return cfg + + +def main(argv=None, runner=None) -> int: + cfg = parse_args(argv) + if cfg.dry: + return dry_run(cfg) + return run_all(cfg, runner or SubprocessRunner()) + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/code/scripts/p2/k5_levers.py b/code/scripts/p2/k5_levers.py new file mode 100644 index 0000000000000000000000000000000000000000..3eb1607076dbaecc2995b1751846dd3da94cb4dc --- /dev/null +++ b/code/scripts/p2/k5_levers.py @@ -0,0 +1,1061 @@ +#!/usr/bin/env python +"""K5: inference-time levers at MATCHED total generated tokens per problem (SOTA plan v3.1 section 3; docs/K5_LEVERS.md). + +Three arms on the same base model (Goedel-Prover-V2-8B) and the same dev problems: + + restart independent whole-proof attempts (class A samples). + correct Goedel-format Lean-feedback correction (class B): attempt + up to 2 repairs per trajectory, cumulative + token ledger, repairs of EVERY failed root (so each trajectory has a complete cost record). + agentic (class C, exploratory) proof-bank-conditioned attempt, then R = 4 rounds in total of: APOLLO-style subgoal + repair (split the failing proof at its failing `have`s, prove each subgoal as its own problem, reassemble, + re-verify) alternating with compiler-feedback correction. Retrieval is from a decontaminated bank of + verified proofs of OTHER training problems. + +Matching. The unit of every arm is a TRAJECTORY (a restart sample; a root with its repair chain; an agentic chain with +its subgoal sub-generations) with a token cost: the completion tokens spent up to the end of the round in which it first +succeeded, or all of its tokens if it never did. Arms are generated generously (a pool of units per problem) and then +REPLAYED at a per-problem budget B_p: units are drawn in a random order (permutation-averaged, deterministic seed) and +run until the budget is spent or one succeeds; a unit that does not fit in the remaining budget ends the run (a real +run cannot know a unit's cost in advance, so it is clipped and fails). The estimate per problem is the fraction of +orderings that solve it, directly comparable across arms and testable with the registered paired sign-flip test. +`--budget restart_k:8` sets B_p to 8 mean restart attempts of that problem (the pass@8-equivalent spend); +`--budget fixed:N` is a scalar. A problem whose pool cannot fill the budget is reported `underfilled`, and +`status` says how many more units to generate: the budget equaliser never silently lets one arm spend less. + +Subcommands (`k5_levers.py -h`): + plan print every stage (the exact command lines) for a configuration; starts nothing + run execute the stages with a resumable done-marker per stage (each inside `hostguard.py run`) + prep merge dev problems, build + decontaminate the proof bank, attach retrieved examples (CPU) + route agentic round k: choose subgoal vs feedback per failed parent, extract subgoals through Lean (CPU) + assemble agentic round k: splice proven subgoals back, write assembled candidates (CPU) + ledger per-arm unit tables from generation + verification files + report matched-budget comparison, paired tests, Holm over the K5 family + status under-filled problems per arm and how many units each needs + estimate box-hour estimate from measured rates +""" + +from __future__ import annotations + +import argparse +import dataclasses +import hashlib +import importlib.util +import json +import math +import os +import re +import shlex +import subprocess +import sys +import time +from collections import Counter, defaultdict +from dataclasses import dataclass, field +from pathlib import Path +from typing import Iterable, Optional + +import numpy as np + +REPO = Path(__file__).resolve().parents[2] +sys.path.insert(0, str(REPO)) + + +def _load(name: str, rel: str): + if name in sys.modules: + return sys.modules[name] + spec = importlib.util.spec_from_file_location(name, REPO / rel) + mod = importlib.util.module_from_spec(spec) + sys.modules[name] = mod + spec.loader.exec_module(mod) + return mod + + +C = _load("p2_common", "scripts/p2/p2_common.py") +LP = _load("k5_lean_prompts", "extras/vllm_rollout/lean_prompts.py") + +# a git worktree has no venvs of its own: ALIGN_VENV_ROOT (default: the main checkout) holds .venv and .venv-rollout +_VR = Path(os.environ.get("ALIGN_VENV_ROOT", REPO if (REPO / ".venv").exists() else "/apps/development/rlhf/align")) +PYR = str(_VR / ".venv-rollout" / "bin" / "python") +PY = str(_VR / ".venv" / "bin" / "python") +DEFAULT_MODEL = ( + "/apps/development/rlhf/hf_cache/hub/models--Goedel-LM--Goedel-Prover-V2-8B/snapshots/" + "dfd02e6271a58375dfbf3ece0175277cf6b6a89a" +) +ARMS = ("restart", "correct", "agentic") +ASM_BASE = 1_000_000 # sample_idx offset of assembled (subgoal-repaired) nodes: never collides with lean_gen's parent*n+i + + +# =========================================================================== 1. ledger and the budget equaliser + + +@dataclass(frozen=True) +class Unit: + """One trajectory of an arm. `tokens` is the cost the unit charges when it is run: the completion tokens of every + node up to the end of the round of its first success, or `tokens_total` when it never succeeded.""" + + problem_id: str + arm: str + unit_id: str + tokens: int + solved: bool + tokens_total: int + by_kind: tuple = () # ((kind, tokens), ...) over the charged span + + +def node_tokens(g: dict) -> int: + t = g.get("trajectory") + if t and "completion_tokens" in t: + return int(t["completion_tokens"]) + ids = g.get("completion_ids") + if ids is None: + raise ValueError(f"{g.get('problem_id')}: generation record without completion_ids or a trajectory ledger") + return len(ids) + + +def node_kind(g: dict) -> str: + t = g.get("trajectory") or {} + return t.get("kind") or ("attempt" if int(g["round"]) == 0 else "repair") + + +def root_id(g: dict) -> str: + t = g.get("trajectory") + if t and "root_trajectory_id" in t: + return t["root_trajectory_id"] + if int(g["round"]) != 0: + raise ValueError(f"{g['problem_id']}: a round-{g['round']} record without a trajectory ledger cannot be assigned to a root") + return f"{g['problem_id']}|{int(g['sample_idx'])}" + + +def _join(gens: Iterable[dict], vers: Iterable[dict]) -> list[tuple[dict, dict]]: + vmap = {} + for v in vers: + vmap[C.gen_key(v)] = v + out, seen = [], set() + for g in gens: + k = C.gen_key(g) + if k in seen: + raise ValueError(f"duplicate generation key {k} across the input files") + seen.add(k) + v = vmap.get(k) + if v is None: + raise ValueError(f"generation {k} has no verification record; verify every node before building the ledger") + out.append((g, v)) + return out + + +def units_restart(gens: Iterable[dict], vers: Iterable[dict], arm: str = "restart") -> dict[str, list[Unit]]: + """Each round-0 sample is its own unit.""" + out: dict[str, list[Unit]] = defaultdict(list) + for g, v in _join(gens, vers): + if int(g["round"]) != 0: + continue + n = node_tokens(g) + out[g["problem_id"]].append(Unit(g["problem_id"], arm, root_id(g), n, bool(v["accepted"]), n, ((node_kind(g), n),))) + return dict(out) + + +def units_chains(gens: Iterable[dict], vers: Iterable[dict], arm: str) -> dict[str, list[Unit]]: + """One unit per root trajectory (class B and agentic). Nodes are grouped by `trajectory.root_trajectory_id`; the + cost through round r is the sum of ALL nodes of rounds <= r (branches included), so nothing generated is free.""" + groups: dict[str, list[tuple[dict, dict]]] = defaultdict(list) + for g, v in _join(gens, vers): + groups[root_id(g)].append((g, v)) + out: dict[str, list[Unit]] = defaultdict(list) + for rid, nodes in groups.items(): + if not any(int(g["round"]) == 0 for g, _ in nodes): + raise ValueError(f"{rid}: no round-0 node among the input files (the lineage is partial); pass every round's files") + pid = nodes[0][0]["problem_id"] + solved_round = min((int(g["round"]) for g, v in nodes if v["accepted"]), default=None) + charged = [(g, v) for g, v in nodes if solved_round is None or int(g["round"]) <= solved_round] + by_kind: Counter = Counter() + for g, _ in charged: + by_kind[node_kind(g)] += node_tokens(g) + total = sum(node_tokens(g) for g, _ in nodes) + out[pid].append(Unit(pid, arm, rid, sum(by_kind.values()), solved_round is not None, total, tuple(sorted(by_kind.items())))) + return dict(out) + + +def _seed_for(seed: int, pid: str) -> int: + return int.from_bytes(hashlib.sha256(f"{seed}|{pid}".encode()).digest()[:8], "big") % (2**32) + + +@dataclass +class Replay: + p_solve: float # fraction of orderings in which the problem is solved within the budget + spent: float # mean tokens spent over the orderings + underfill: float # fraction of orderings that ran out of units with a mean unit of budget still unspent + n_units: int + + +def replay_budget(units: list[Unit], budget: float, n_perm: int = 256, seed: int = 0, pid: str = "") -> Replay: + """Permutation-averaged solve probability of `units` at `budget` generated tokens (the equaliser's arithmetic). + + One ordering: units are run in order; a unit whose cost exceeds the remaining budget ends the ordering unsolved + (it would be clipped); a solved unit within budget ends it solved; an unsolved one that fits spends its tokens + and the next is tried. Spent tokens are what the ordering actually used.""" + if not units: + return Replay(0.0, 0.0, 1.0, 0) + rng = np.random.default_rng(_seed_for(seed, pid)) + cost = np.array([u.tokens for u in units], dtype=np.float64) + solved = np.array([u.solved for u in units], dtype=bool) + mean_cost = float(cost.mean()) + wins = under = 0 + spent_sum = 0.0 + n = len(units) + for _ in range(n_perm): + order = rng.permutation(n) + spent, won, exhausted = 0.0, False, True + for i in order: + if spent + cost[i] > budget: + exhausted = False + break + spent += cost[i] + if solved[i]: + won, exhausted = True, False + break + wins += won + spent_sum += spent + if exhausted and not won and budget - spent >= mean_cost: + under += 1 + return Replay(wins / n_perm, spent_sum / n_perm, under / n_perm, n) + + +def budget_table(spec: str, restart: dict[str, list[Unit]], problems: Iterable[str]) -> dict[str, float]: + """Per-problem budget B_p. `restart_k:K` = K x the mean tokens of that problem's restart attempts (the spend of a + pass@K run); `fixed:N` = N tokens everywhere.""" + kind, _, arg = spec.partition(":") + out = {} + if kind == "fixed": + return {p: float(arg) for p in problems} + if kind != "restart_k": + raise ValueError(f"unknown budget spec {spec!r} (restart_k:K or fixed:N)") + k = float(arg) + for p in problems: + us = restart.get(p) + if not us: + raise ValueError(f"problem {p}: no restart units to derive its budget from") + out[p] = k * sum(u.tokens_total for u in us) / len(us) + return out + + +def equalise(arms: dict[str, dict[str, list[Unit]]], budgets: dict[str, float], n_perm: int = 256, seed: int = 0, + fractions: tuple = (0.25, 0.5, 1.0)) -> dict: + """Replay every arm at every budget fraction. -> {arm: {fraction: {problem: Replay}}} plus summaries.""" + res: dict = {} + for arm, by_p in arms.items(): + res[arm] = {} + for f in fractions: + res[arm][f] = {p: replay_budget(by_p.get(p, []), b * f, n_perm, seed, p) for p, b in budgets.items()} + return res + + +def arm_summary(per_problem: dict[str, Replay], budgets: dict[str, float], fraction: float = 1.0) -> dict: + n = len(per_problem) + tot_b = sum(budgets[p] * fraction for p in per_problem) + return { + "n_problems": n, + "mean_p_solve": sum(r.p_solve for r in per_problem.values()) / max(n, 1), + "tokens_spent_per_problem": sum(r.spent for r in per_problem.values()) / max(n, 1), + "budget_per_problem": tot_b / max(n, 1), + "spend_ratio": sum(r.spent for r in per_problem.values()) / tot_b if tot_b else 0.0, + "underfilled_problems": sum(1 for r in per_problem.values() if r.underfill > 0.05), + "no_units": sum(1 for r in per_problem.values() if r.n_units == 0), + } + + +def units_needed(units: dict[str, list[Unit]], budgets: dict[str, float], per_problem: dict[str, Replay]) -> dict[str, int]: + """Additional units per under-filled problem: enough mean-cost units to cover the unspent budget, +1 slack.""" + need = {} + for p, r in per_problem.items(): + if r.underfill <= 0.05: + continue + us = units.get(p) or [] + mean = (sum(u.tokens for u in us) / len(us)) if us else max(budgets[p] / 4.0, 1.0) + need[p] = max(1, math.ceil(max(budgets[p] - r.spent, 0.0) / max(mean, 1.0))) + 1 + return need + + +# =========================================================================== 2. proof bank + decontamination + + +@dataclass +class BankEntry: + problem_id: str + statement: str # `theorem ... := by` (no header, no `sorry`) + proof: str # text after that `:= by`, verbatim + informal: Optional[str] + tokens: int + code_sha: str + source_statement: str = "" # the full formal statement (for decontamination) + + +def split_example(lean_code: str) -> Optional[tuple[str, str]]: + """(`theorem ... := by`, body) of a verified `lean_code`, or None. Comments are masked for the search only.""" + from align.rl.envs.subgoal_repair import mask + + mk = mask(lean_code) + m = re.search(r"\btheorem\b[\s\S]*?:=\s*by", mk) + if m is None: + return None + return lean_code[m.start():m.end()], lean_code[m.end():] + + +def eval_items_from_problems(rows: Iterable[dict]): + from align.data.decontam import EvalItem + + return [EvalItem(f"{r.get('split', '')}:{r['problem_id']}", r.get("source", "eval"), r["formal_statement"], r.get("informal_prefix")) + for r in rows] + + +def decontaminate_bank(candidates: list[BankEntry], eval_rows: list[dict], ancestry: Optional[dict] = None) -> tuple[list[BankEntry], list[dict]]: + """Drop every candidate that hits any evaluation / query problem under the repo's layered detector + (`align.data.decontam`: normalised-text, signature and alpha hashes, MinHash-LSH, token n-gram containment, informal + text n-grams), or whose problem id IS an evaluation id, or whose ancestry (mutation parent) does. Returns + (kept, dropped-with-reason). Over-flagging is the policy: a false positive costs one bank entry.""" + from align.data.decontam import ContaminationLedger, Decontaminator + + dec = Decontaminator(eval_items_from_problems(eval_rows)) + eval_ids = {r["problem_id"] for r in eval_rows} + led = ContaminationLedger() + for c in candidates: + led.add(c.problem_id, dec.check(c.source_statement or c.statement, c.informal), (ancestry or {}).get(c.problem_id)) + kept, dropped = [], [] + for c in candidates: + if c.problem_id in eval_ids: + dropped.append({"problem_id": c.problem_id, "why": "is_an_eval_problem"}) + continue + bad, why = led.resolve(c.problem_id) + if bad: + dropped.append({"problem_id": c.problem_id, "why": why, "hits": [dataclasses.asdict(h) for h in led.direct.get(c.problem_id, [])[:3]]}) + continue + kept.append(c) + return kept, dropped + + +def bank_candidates(problems: dict[str, dict], gens: Iterable[dict], vers: Iterable[dict], max_chars: int = 2500) -> list[BankEntry]: + """The shortest accepted proof per problem, as an example (statement + body).""" + best: dict[str, BankEntry] = {} + for g, v in _join(gens, vers): + if not v["accepted"] or not g.get("lean_code") or g["problem_id"] not in problems: + continue + ex = split_example(g["lean_code"]) + if ex is None or len(ex[0]) + len(ex[1]) > max_chars: + continue + pr = problems[g["problem_id"]] + e = BankEntry(g["problem_id"], ex[0], ex[1], pr.get("informal_prefix"), node_tokens(g), C.code_sha(g["lean_code"]), pr["formal_statement"]) + cur = best.get(g["problem_id"]) + if cur is None or len(e.proof) < len(cur.proof): + best[g["problem_id"]] = e + return [best[k] for k in sorted(best)] + + +class BankIndex: + """TF-IDF over statement tokens (identifiers kept: lemma names are the signal). Cosine retrieval, shorter proof breaks + ties. Pure numpy; the bank is thousands of entries.""" + + def __init__(self, entries: list[BankEntry]): + from align.data.decontam import lean_tokens + + self._tok = lambda s: lean_tokens(s, alpha=False) + self.entries = entries + docs = [Counter(self._tok(e.source_statement or e.statement)) for e in entries] + df = Counter(t for d in docs for t in d) + self.vocab = {t: i for i, t in enumerate(sorted(df))} + n = max(len(entries), 1) + self.idf = np.array([math.log((1 + n) / (1 + df[t])) + 1.0 for t in sorted(df)]) if df else np.zeros(0) + self.mat = np.zeros((len(entries), len(self.vocab)), dtype=np.float32) + for i, d in enumerate(docs): + self.mat[i] = self._vec(d) + + def _vec(self, counts: Counter) -> np.ndarray: + v = np.zeros(len(self.vocab), dtype=np.float32) + for t, c in counts.items(): + j = self.vocab.get(t) + if j is not None: + v[j] = (1 + math.log(c)) * self.idf[j] + nrm = np.linalg.norm(v) + return v / nrm if nrm else v + + def retrieve(self, statement: str, k: int = 3, exclude_ids: Iterable[str] = (), min_sim: float = 0.05) -> list[BankEntry]: + if not self.entries: + return [] + q = self._vec(Counter(self._tok(statement))) + sims = self.mat @ q + ex = set(exclude_ids) + order = sorted(range(len(self.entries)), key=lambda i: (-float(sims[i]), len(self.entries[i].proof))) + out = [] + for i in order: + if sims[i] < min_sim or self.entries[i].problem_id in ex: + continue + out.append(self.entries[i]) + if len(out) == k: + break + return out + + +def bank_examples(entries: list[BankEntry], max_chars: int = 6000) -> list[dict]: + out, used = [], 0 + for e in entries: + used += len(e.statement) + len(e.proof) + if used > max_chars: + break + out.append({"statement": e.statement, "proof": e.proof, "source": e.problem_id}) + return out + + +def attach_bank(problems: list[dict], index: BankIndex, k: int = 3) -> list[dict]: + """Problem rows with `bank_examples` (the `goedel_bank` prompt format reads them). A problem is never given its own + bank entry (`exclude_ids`).""" + out = [] + for p in problems: + ex = bank_examples(index.retrieve(p["formal_statement"], k, exclude_ids=[p["problem_id"]])) + out.append(dict(p, bank_examples=ex)) + return out + + +# =========================================================================== 3. agentic rounds (route / assemble) + + +def parent_info(g: dict, v: dict) -> Optional[dict]: + """Failed node with Lean feedback, or None (accepted, truncated, no code, no compile errors).""" + if v["accepted"] or not g.get("lean_code"): + return None + if not isinstance(v.get("repl_errors"), list) or not v["repl_errors"] or not isinstance(v.get("repl_code"), str): + return None + return {"gen": g, "ver": v} + + +def route_for_round(routes: str, round_k: int) -> str: + """`routes` = comma list for rounds 1..R-1, each `subgoal` (subgoal repair, falling back to feedback when nothing can + be extracted) or `feedback`. A design choice, not tuned: alternating heterogeneous moves.""" + rs = [x.strip() for x in routes.split(",")] + if not 1 <= round_k <= len(rs) or any(x not in ("subgoal", "feedback") for x in rs): + raise ValueError(f"routes {routes!r} has no valid entry for round {round_k}") + return rs[round_k - 1] + + +def sg_problem_id(parent_g: dict, j: int) -> str: + return f"{parent_g['problem_id']}::sg{int(parent_g['sample_idx'])}r{int(parent_g['round'])}g{j}" + + +def make_asm_nodes(plan, parent_g: dict, parent_v: dict, bodies: dict[int, str], sub_tokens: int, n_proven: int, round_k: int, cap: int): + """(gen record, ver record or None). The assembled candidate is a synthetic generation: its text is the code, its + completion_ids a single placeholder, and the real cost (the subgoal samples) sits in `trajectory.completion_tokens`. + The history `messages` are the parent's, so a following Goedel correction prompt reads the whole story. When the + candidate equals the parent's code (no subgoal proven) its verdict is copied, not re-verified.""" + from align.rl.envs.subgoal_repair import reassemble + + code = reassemble(plan, bodies, restore_unproven=True) + pt = parent_g.get("trajectory") or {} + prior = int(pt.get("cum_completion_tokens", node_tokens(parent_g))) + sidx = ASM_BASE + int(parent_g["sample_idx"]) + rid = root_id(parent_g) + gen = { + "problem_id": parent_g["problem_id"], "sample_idx": sidx, "round": round_k, "cap_tokens": cap, + "prompt_ids": [0], "completion_ids": [0], "prompt_text": "", "completion_text": f"```lean4\n{code}\n```", + "finish_reason": "stop", "lean_code": code, "lean_code_error": None, "messages": parent_g["messages"], + "model_path": parent_g["model_path"], "model_rev": parent_g["model_rev"], + "sampling": dict(parent_g["sampling"], max_tokens=1), + "context_clipped": False, + "parent": {"problem_id": parent_g["problem_id"], "sample_idx": int(parent_g["sample_idx"]), "round": int(parent_g["round"])}, + "trajectory": { + "root_trajectory_id": rid, "problem_id": parent_g["problem_id"], + "root_sample_idx": int(pt.get("root_sample_idx", parent_g["sample_idx"])), "round": round_k, "kind": "assembled", + "parent_key": [parent_g["problem_id"], int(parent_g["sample_idx"]), int(parent_g["round"])], + "completion_tokens": int(sub_tokens), "prior_completion_tokens": prior, "cum_completion_tokens": prior + int(sub_tokens), + "truncated": False, "budget_clipped": False, "partial": False, + }, + "subgoal": {"gaps": len(plan.gaps), "proven": n_proven}, + } + ver = None + if code == parent_g["lean_code"]: + ver = dict(parent_v, sample_idx=sidx, round=round_k, cap_tokens=cap, code_sha256=C.code_sha(code)) + return gen, ver + + +# =========================================================================== 4. stages (the exact command lines) + + +@dataclass +class Cfg: + run_dir: str + problems: list[str] # dev problem files (miniF2F-valid, ProofNet-valid, ...) + model: str = DEFAULT_MODEL + seed: int = 0 + cap: int = 40960 + traj_cap: int = 40000 # class B cumulative cap + n_restart: int = 32 + n_agentic: int = 8 + rounds: int = 4 # agentic rounds in total (1 attempt + 3 repairs) + routes: str = "subgoal,feedback,subgoal" + sub_n: int = 4 # samples per subgoal + sub_cap: int = 16384 + bank_k: int = 3 + budget: str = "restart_k:8" + workers: int = 8 + gpu_mem_util: float = 0.82 + bank_problems: Optional[str] = None + bank_gens: Optional[str] = None + bank_vers: Optional[str] = None + eval_problems: list = field(default_factory=list) # every evaluation set the bank must be clean against + hostguard: bool = True + gpu_caps: str = "--cores 24 --mem-gb 100" + cpu_caps: str = "--cores 16 --mem-gb 64" + + def p(self, *parts) -> str: + return str(Path(self.run_dir, *parts)) + + +@dataclass +class Stage: + name: str + argv: list + gpu: bool + outputs: list # a stage is done iff its marker exists; outputs are listed for `plan` and cleanup + skip_if_empty: Optional[str] = None # an input file that, when it exists with no rows, makes the stage a no-op + + def marker(self, cfg: Cfg) -> Path: + return Path(cfg.p("done", self.name)) + + +def _hg(cfg: Cfg, st: Stage) -> list: + if not cfg.hostguard: + return st.argv + caps = (cfg.gpu_caps if st.gpu else cfg.cpu_caps).split() + return [PY, str(REPO / "scripts/hostguard.py"), "run", "--wait", *caps, "--label", f"k5-{st.name}", "--", *st.argv] + + +def _gen_args(cfg: Cfg, problems: str, out: str, n: int, rnd: int, cap: int, label: str, extra: list = ()) -> str: + a = ["--model", cfg.model, "--problems", problems, "--out", out, "--n", str(n), "--cap-tokens", str(cap), "--round", str(rnd), + "--tp", "4", "--seed", str(cfg.seed), "--max-num-seqs", "128", "--gpu-mem-util", str(cfg.gpu_mem_util), "--label", label, *extra] + return " ".join(shlex.quote(x) for x in a) + + +def _pgv(cfg: Cfg, name: str, problems: str, gen_out: str, ver_out: str, n: int, rnd: int, cap: int, extra: list = (), feedback: bool = True, + wait_marker: Optional[str] = None) -> Stage: + run = cfg.p("pipe", name) + argv = [PY, str(REPO / "scripts/p2/pipeline_gen_verify.py"), "--run-dir", run, "--problems", problems, "--verify-out", ver_out, + "--workers", str(cfg.workers), "--label", f"k5-{name}", "--gen-args", _gen_args(cfg, problems, gen_out, n, rnd, cap, f"k5-{name}", list(extra))] + if feedback: + argv.append("--feedback") + return Stage(name, argv, True, [gen_out, ver_out]) + + +def _self(cfg: Cfg, name: str, sub: str, *args: str, gpu: bool = False) -> Stage: + return Stage(name, [PY, str(REPO / "scripts/p2/k5_levers.py"), sub, *args], gpu, []) + + +def build_stages(cfg: Cfg) -> list[Stage]: + """Every stage of K5, in order. Pure: nothing is executed or created.""" + dev, dev_bank = cfg.p("dev.jsonl"), cfg.p("dev_bank.jsonl") + ss: list[Stage] = [] + prep = ["--run-dir", cfg.run_dir, "--problems", *cfg.problems, "--bank-k", str(cfg.bank_k)] + if cfg.bank_problems: + prep += ["--bank-problems", cfg.bank_problems, "--bank-gens", cfg.bank_gens, "--bank-vers", cfg.bank_vers] + if cfg.eval_problems: + prep += ["--eval-problems", *cfg.eval_problems] + ss.append(_self(cfg, "prep", "prep", *prep)) + # ---- restart (class A samples; also the roots of the correction arm) + R0g, R0v = cfg.p("restart/gen_r0.jsonl"), cfg.p("restart/ver_r0.jsonl") + ss.append(_pgv(cfg, "restart_r0", dev, R0g, R0v, cfg.n_restart, 0, cfg.cap)) + # ---- correct (class B): rounds 1, 2; every failed root is repaired (--repair-solved) so each trajectory is fully costed + prev = [(R0v, R0g)] + for r in (1, 2): + g, v = cfg.p(f"correct/gen_r{r}.jsonl"), cfg.p(f"correct/ver_r{r}.jsonl") + extra = ["--trajectory-cap-tokens", str(cfg.traj_cap), "--repair-solved"] + for pv, pg in prev: + extra += ["--corrections-from", pv, pg] + ss.append(_pgv(cfg, f"correct_r{r}", dev, g, v, 1, r, cfg.cap, extra)) + prev = [(v, g)] + prev + # ---- agentic (class C) + A0g, A0v = cfg.p("agentic/gen_r0.jsonl"), cfg.p("agentic/ver_r0.jsonl") + ss.append(_pgv(cfg, "agentic_r0", dev_bank, A0g, A0v, cfg.n_agentic, 0, cfg.cap, ["--prompt-format", "goedel_bank"])) + last = [(A0v, A0g)] # the (ver, gen) pairs of the previous round + for r in range(1, cfg.rounds): + mode = route_for_round(cfg.routes, r) + rd = cfg.p(f"agentic/round{r}") + route_args = ["--run-dir", cfg.run_dir, "--round", str(r), "--mode", mode, "--workers", "2", "--bank-k", str(cfg.bank_k), "--sub-n", str(cfg.sub_n)] + for v, g in last: + route_args += ["--pairs", v, g] + ss.append(_self(cfg, f"agentic_route_r{r}", "route", *route_args)) + fg, fv = cfg.p(f"agentic/gen_r{r}.jsonl"), cfg.p(f"agentic/ver_r{r}.jsonl") + fbg, fbv = f"{rd}/fb_parents.gen.jsonl", f"{rd}/fb_parents.ver.jsonl" + nxt = [(fv, fg)] + if mode == "subgoal": + sgp, sgg, sgv = f"{rd}/sg_problems.jsonl", f"{rd}/sg_gen.jsonl", f"{rd}/sg_ver.jsonl" + st = _pgv(cfg, f"agentic_sg_r{r}", sgp, sgg, sgv, cfg.sub_n, 0, cfg.sub_cap, ["--prompt-format", "goedel_bank"], feedback=False) + st.skip_if_empty = sgp + ss.append(st) + extra = ["--trajectory-cap-tokens", str(cfg.traj_cap), "--repair-solved", "--corrections-from", fbv, fbg] + st = _pgv(cfg, f"agentic_fb_r{r}", dev_bank, fg, fv, 1, r, cfg.cap, extra) + st.skip_if_empty = fbg + ss.append(st) + if mode == "subgoal": + ag, av = f"{rd}/asm.gen.jsonl", f"{rd}/asm.ver.jsonl" + ss.append(_self(cfg, f"agentic_asm_r{r}", "assemble", "--run-dir", cfg.run_dir, "--round", str(r), "--cap", str(cfg.cap))) + st = Stage(f"agentic_asmverify_r{r}", [PY, str(REPO / "scripts/p2/verify_generations.py"), "--generations", ag, "--problems", dev, + "--out", av, "--workers", str(cfg.workers), "--budget-s", "300", "--feedback"], False, [av]) + st.skip_if_empty = ag + ss.append(st) + nxt.append((av, ag)) + last = nxt + ss.append(_self(cfg, "report", "report", "--run-dir", cfg.run_dir, "--budget", cfg.budget, "--rounds", str(cfg.rounds))) + return ss + + +class Runner: + def run(self, argv: list, log: Path) -> int: + log.parent.mkdir(parents=True, exist_ok=True) + with open(log, "ab") as lf: + return subprocess.call(argv, stdout=lf, stderr=subprocess.STDOUT, cwd=str(REPO)) + + +def run_stages(cfg: Cfg, stages: list[Stage], runner, say=print, only: Optional[set] = None) -> int: + for st in stages: + if only and st.name not in only: + continue + mk = st.marker(cfg) + if mk.exists(): + say(f"skip {st.name} (done)") + continue + if st.skip_if_empty and Path(st.skip_if_empty).exists() and not Path(st.skip_if_empty).read_text().strip(): + say(f"skip {st.name} (its input {st.skip_if_empty} is empty)") + mk.parent.mkdir(parents=True, exist_ok=True) + mk.write_text("empty") + continue + say(f"START {st.name}") + rc = runner.run(_hg(cfg, st), Path(cfg.p("logs", st.name + ".log"))) + say(f"END {st.name} rc={rc}") + if rc != 0: + say(f"STOP: {st.name} failed; re-running this command resumes (every stage is keyed and resumable)") + return rc + mk.parent.mkdir(parents=True, exist_ok=True) + mk.write_text(time.strftime("%F %T")) + return 0 + + +# =========================================================================== 5. estimate + + +MEASURED = { # docs/K5_LEVERS.md section 7: from the P2 baseline manifest (Goedel-8B, miniF2F-test, cap 40960) + "box_tok_per_h": 522.5 * 3600, # end-to-end tokens/s of the whole 4xA10G box, generation overlapped with Lean + "mean_attempt_tokens": {"minif2f": 5685.0, "proofnet": 9000.0}, # proofnet: ASSUMED 1.6x (no measurement yet) + "fail_rate": {"minif2f": 0.36, "proofnet": 0.75}, # 1 - single-sample pass rate; proofnet ASSUMED +} + + +def estimate(sets: dict[str, int], cfg: Cfg, m: dict = MEASURED, repair_cost: float = 1.15, repair_success: float = 0.12, + sub_share: float = 0.35) -> dict: + """Box-hour estimate per arm. `sets` = {"minif2f": N, "proofnet": N}. The repair multiplier covers the longer + (history-carrying) prompts; sub_share is the tokens a subgoal round spends relative to one attempt per failed parent.""" + out: dict = {"assumptions": {"repair_cost": repair_cost, "repair_success": repair_success, "sub_share": sub_share, **{"box_tok_per_h": m["box_tok_per_h"]}}} + tot: Counter = Counter() + for s, n in sets.items(): + a, f = m["mean_attempt_tokens"][s], m["fail_rate"][s] + restart = n * cfg.n_restart * a + f1 = f + f2 = f * (1 - repair_success) + correct = n * cfg.n_restart * a * repair_cost * (f1 + f2) + agent_root = n * cfg.n_agentic * a + agent = agent_root + fa = f * 0.85 # bank-conditioned roots assumed slightly better + for r in range(1, cfg.rounds): + mode = route_for_round(cfg.routes, r) + k = n * cfg.n_agentic * fa + agent += k * a * (sub_share if mode == "subgoal" else repair_cost) + fa *= (1 - repair_success) + tot["restart"] += restart + tot["correct"] += correct + tot["agentic"] += agent + out["tokens"] = dict(tot) + out["box_hours"] = {k: v / m["box_tok_per_h"] for k, v in tot.items()} + out["box_hours"]["total"] = sum(out["box_hours"].values()) + return out + + +# =========================================================================== 6. CLI + + +def _read(path) -> list[dict]: + return list(C.iter_jsonl(path)) if path and Path(path).exists() else [] + + +def _problems(files: Iterable[str]) -> dict[str, dict]: + out: dict[str, dict] = {} + for f in files: + for pid, rec in C.load_problems(f).items(): + if pid in out and out[pid]["formal_statement"] != rec["formal_statement"]: + raise C.ContractError(f"problem {pid!r} appears in two files with different statements") + out[pid] = rec + return out + + +def _write(path, rows) -> int: + return C.write_jsonl_atomic(path, rows) + + +def cmd_prep(a) -> int: + rd = Path(a.run_dir) + probs = _problems(a.problems) + _write(rd / "dev.jsonl", probs.values()) + bank_entries: list[BankEntry] = [] + report: dict = {"dev_problems": len(probs)} + if a.bank_problems: + train = _problems([a.bank_problems]) + cands = bank_candidates(train, _read(a.bank_gens), _read(a.bank_vers)) + eval_rows = list(probs.values()) + [r for f in a.eval_problems for r in _read(f)] + bank_entries, dropped = decontaminate_bank(cands, eval_rows) + report.update(bank_candidates=len(cands), bank_kept=len(bank_entries), bank_dropped=len(dropped), + eval_items=len(eval_rows), dropped_reasons=dict(Counter(d["why"].split(":")[0] for d in dropped))) + _write(rd / "bank.jsonl", (dataclasses.asdict(e) for e in bank_entries)) + _write(rd / "bank_dropped.jsonl", dropped) + rows = attach_bank(list(probs.values()), BankIndex(bank_entries), a.bank_k) + _write(rd / "dev_bank.jsonl", rows) + report["with_examples"] = sum(1 for r in rows if r["bank_examples"]) + (rd / "prep.json").write_text(json.dumps(report, indent=1)) + print(json.dumps(report)) + return 0 + + +def _pairs(args) -> tuple[list[dict], list[dict]]: + gens, vers = [], [] + for v, g in args: + gens += _read(g) + vers += _read(v) + return gens, vers + + +def cmd_route(a) -> int: + """Agentic round k. Parents = failed, feedback-bearing nodes of round k-1 (every `--pairs` file). Subgoal rounds + plan each parent through Lean; parents with nothing extractable, and every parent of a feedback round, go to the + Goedel correction path via filtered copies of their (ver, gen) records.""" + from align.rl.envs import subgoal_repair as SR + + rd = Path(a.run_dir, "agentic", f"round{a.round}") + rd.mkdir(parents=True, exist_ok=True) + probs = {r["problem_id"]: r for r in _read(Path(a.run_dir, "dev_bank.jsonl"))} + gens, vers = _pairs(a.pairs) + nodes = [(g, v) for g, v in _join(gens, vers) if int(g["round"]) == a.round - 1] + st = Counter(parents_total=len(nodes)) + fb, plans, sg_rows = [], [], [] + probe_pool = None + index = None + bank = _read(Path(a.run_dir, "bank.jsonl")) + if bank: + index = BankIndex([BankEntry(**b) for b in bank]) + for g, v in nodes: + info = parent_info(g, v) + if info is None: + st["no_feedback_or_solved"] += 1 + continue + route = "feedback" + if a.mode == "subgoal": + if probe_pool is None: + probe_pool = _open_pool(a.workers) + plan = _plan_with_pool(probe_pool, probs[g["problem_id"]]["formal_statement"], g, v, SR) + if plan is not None and plan.lemmas: + route = "subgoal" + rows = [] + for j, gap in enumerate(plan.gaps): + if gap.lemma is None: + continue + stmt = SR.lemma_problem_statement(plan, gap) + row = {"problem_id": sg_problem_id(g, j), "source": "k5_subgoal", "split": "dev", "formal_statement": stmt, + "informal_prefix": None, "statement_sha": C.statement_sha(stmt), "parent": [g["problem_id"], int(g["sample_idx"]), int(g["round"])], + "gap_index": j} + if index is not None: + row["bank_examples"] = bank_examples(index.retrieve(stmt, a.bank_k)) + rows.append(row) + sg_rows += rows + plans.append({"parent_key": [g["problem_id"], int(g["sample_idx"]), int(g["round"])], "plan": SR.plan_to_dict(plan), + "sg_ids": [r["problem_id"] for r in rows]}) + st[route] += 1 + if route == "feedback": + fb.append((g, v)) + _write(rd / "fb_parents.gen.jsonl", (g for g, _ in fb)) + _write(rd / "fb_parents.ver.jsonl", (v for _, v in fb)) + _write(rd / "sg_problems.jsonl", sg_rows) + _write(rd / "sg_plans.jsonl", plans) + (rd / "route.json").write_text(json.dumps(dict(st), indent=1)) + print(json.dumps(dict(st))) + if probe_pool is not None: + probe_pool.close() + return 0 + + +def _open_pool(workers: int): + from align.verify.lean import ReplPool, find_toolchain + from align.verify.lean.repl_pool import ReplLimits + + tc = find_toolchain() + return ReplPool.from_toolchain(tc, n_workers=workers, limits=ReplLimits(), preflight=True, + scratch_dir=str(Path(os.environ.get("TMPDIR", str(REPO / ".tmp"))) / "k5_route_pool")) + + +def _plan_with_pool(pool, statement: str, g: dict, v: dict, SR): + """SubgoalPlan for a failed node, lemma statements checked to elaborate; None when Lean cannot run it.""" + from align.verify.lean.repl_pool import ReplError + + def work(w, _): + try: + probe = SR.repl_probe(w, statement) + plan = SR.plan_subgoals(statement, g["lean_code"], v["repl_errors"], probe, parent_id=g["problem_id"], repl_code=v["repl_code"]) + for gap in plan.gaps: + if gap.lemma is not None and not SR.check_lemma_elaborates(w, plan, gap): + gap.lemma, gap.why_not = None, "lemma_does_not_elaborate" + return plan + except (ReplError, ValueError): + return None + + r = pool.map(work, [None])[0] + return None if isinstance(r, BaseException) else r + + +def cmd_assemble(a) -> int: + from align.rl.envs import subgoal_repair as SR + + rd = Path(a.run_dir, "agentic", f"round{a.round}") + plans = _read(rd / "sg_plans.jsonl") + sg_gens = _read(rd / "sg_gen.jsonl") + sg_vers = _read(rd / "sg_ver.jsonl") + ver_by = {C.gen_key(v): v for v in sg_vers} + by_pid: dict[str, list[tuple[dict, dict]]] = defaultdict(list) + for g in sg_gens: + v = ver_by.get(C.gen_key(g)) + if v is None: + raise ValueError(f"subgoal sample {C.gen_key(g)} is not verified: run the sg stage's verification to completion") + by_pid[g["problem_id"]].append((g, v)) + # parents' generation records, from every pair of the previous round + parents = {} + for f in sorted(Path(a.run_dir, "agentic").glob("gen_r*.jsonl")) + sorted(Path(a.run_dir, "agentic").glob("round*/asm.gen.jsonl")): + for g in _read(f): + parents[(g["problem_id"], int(g["sample_idx"]), int(g["round"]))] = g + pvers = {} + for f in sorted(Path(a.run_dir, "agentic").glob("ver_r*.jsonl")) + sorted(Path(a.run_dir, "agentic").glob("round*/asm.ver.jsonl")): + for v in _read(f): + pvers[(v["problem_id"], int(v["sample_idx"]), int(v["round"]))] = v + gens, vers, st = [], [], Counter() + for pl in plans: + pk = tuple(pl["parent_key"]) + pg, pv = parents[pk], pvers[pk] + plan = SR.plan_from_dict(pl["plan"]) + bodies, sub_tokens = {}, 0 + for sid in pl["sg_ids"]: + sub_tokens += sum(node_tokens(g) for g, _ in by_pid.get(sid, [])) + for j, gap in enumerate(plan.gaps): + if gap.lemma is None: + continue + sid = sg_problem_id(pg, j) + ok = [g for g, v in by_pid.get(sid, []) if v["accepted"] and g.get("lean_code")] + ok.sort(key=lambda g: len(g["lean_code"])) + for g in ok: + body = SR.lemma_body(g["lean_code"], gap.lemma.name) + if body is not None: + bodies[j] = body + break + gen, ver = make_asm_nodes(plan, pg, pv, bodies, sub_tokens, len(bodies), a.round, a.cap) + gens.append(gen) + if ver is not None: + vers.append(ver) + st["assembled"] += 1 + st["gaps"] += len(plan.gaps) + st["proven"] += len(bodies) + st["unchanged_verdict_copied"] += ver is not None + _write(rd / "asm.gen.jsonl", gens) + if vers: + _write(rd / "asm.ver.jsonl", vers) + print(json.dumps(dict(st))) + return 0 + + +def _arm_files(run_dir: str, rounds: int) -> dict[str, tuple[list[str], list[str]]]: + rd = Path(run_dir) + ag = [rd / "agentic/gen_r0.jsonl"] + av = [rd / "agentic/ver_r0.jsonl"] + for r in range(1, rounds): + ag += [rd / f"agentic/gen_r{r}.jsonl", rd / f"agentic/round{r}/asm.gen.jsonl"] + av += [rd / f"agentic/ver_r{r}.jsonl", rd / f"agentic/round{r}/asm.ver.jsonl"] + files = { + "restart": ([rd / "restart/gen_r0.jsonl"], [rd / "restart/ver_r0.jsonl"]), + "correct": ([rd / "restart/gen_r0.jsonl", rd / "correct/gen_r1.jsonl", rd / "correct/gen_r2.jsonl"], + [rd / "restart/ver_r0.jsonl", rd / "correct/ver_r1.jsonl", rd / "correct/ver_r2.jsonl"]), + "agentic": (ag, av), + } + return {k: ([str(x) for x in g], [str(x) for x in v]) for k, (g, v) in files.items()} + + +def load_arms(run_dir: str, rounds: int = 4, arms: Iterable[str] = ARMS) -> dict[str, dict[str, list[Unit]]]: + out = {} + for arm, (gf, vf) in _arm_files(run_dir, rounds).items(): + if arm not in arms: + continue + if arm == "correct" and not Path(gf[1]).exists(): + continue # no repair round generated yet: round-0 roots alone are not the correction arm + gens = [g for f in gf for g in _read(f)] + vers = [v for f in vf for v in _read(f)] + if not gens: + continue + out[arm] = units_restart(gens, vers) if arm == "restart" else units_chains(gens, vers, arm) + return out + + +def build_report(arms: dict[str, dict[str, list[Unit]]], budget_spec: str, n_perm: int = 256, seed: int = 0) -> dict: + from align.eval.paired import holm, paired_bootstrap_ci, sign_flip_test + + if "restart" not in arms: + raise ValueError("the restart arm defines the budget and the pairing; run it first") + pids = sorted(arms["restart"]) + budgets = budget_table(budget_spec, arms["restart"], pids) + rep = equalise(arms, budgets, n_perm, seed) + out: dict = {"budget": budget_spec, "n_problems": len(pids), "n_perm": n_perm, "seed": seed, "arms": {}, "curves": {}} + for arm in arms: + out["arms"][arm] = arm_summary(rep[arm][1.0], budgets, 1.0) + out["curves"][arm] = {str(f): arm_summary(rep[arm][f], budgets, f)["mean_p_solve"] for f in rep[arm]} + fam = [("correct", "restart"), ("agentic", "restart"), ("agentic", "correct")] + tests, ps = [], [] + for x, y in fam: + if x in arms and y in arms: + d = [rep[x][1.0][p].p_solve - rep[y][1.0][p].p_solve for p in pids] + t = sign_flip_test(d, seed=seed) + lo, hi = paired_bootstrap_ci(d, seed=seed) + tests.append({"contrast": f"{x} - {y}", "mean_diff": t["observed"], "p": t["p"], "ci95": [lo, hi], "n": t["n"], "n_nonzero": t["n_nonzero"]}) + ps.append(t["p"]) + if ps: + h = holm(ps) + for t, adj, rej in zip(tests, h["adjusted"], h["reject"]): + t["p_holm"], t["reject_at_0.05"] = adj, rej + out["tests"] = tests + out["family"] = "K5 pairwise (Holm over the contrasts present); the training-seed null of K0 does not apply: no training here" + out["per_problem"] = {arm: {p: rep[arm][1.0][p].p_solve for p in pids} for arm in arms} + return out + + +def cmd_report(a) -> int: + arms = load_arms(a.run_dir, a.rounds) + rep = build_report(arms, a.budget, a.n_perm, a.seed) + Path(a.run_dir, "k5_report.json").write_text(json.dumps(rep, indent=1)) + for arm, s in rep["arms"].items(): + print(f"{arm:9s} p_solve@B={s['mean_p_solve']:.4f} spent/B={s['spend_ratio']:.3f} underfilled={s['underfilled_problems']}") + for t in rep["tests"]: + print(f"{t['contrast']:22s} diff={t['mean_diff']:+.4f} CI[{t['ci95'][0]:+.4f},{t['ci95'][1]:+.4f}] p_holm={t.get('p_holm', float('nan')):.4f}") + return 0 + + +def cmd_status(a) -> int: + arms = load_arms(a.run_dir, a.rounds) + pids = sorted(arms["restart"]) + budgets = budget_table(a.budget, arms["restart"], pids) + for arm, by_p in arms.items(): + rep = {p: replay_budget(by_p.get(p, []), budgets[p], a.n_perm, a.seed, p) for p in pids} + need = units_needed(by_p, budgets, rep) + print(json.dumps({"arm": arm, "underfilled": len(need), "extra_units_total": sum(need.values()), "worst": sorted(need.items(), key=lambda kv: -kv[1])[:5]})) + return 0 + + +def cmd_ledger(a) -> int: + arms = load_arms(a.run_dir, a.rounds) + for arm, by_p in arms.items(): + rows = [dataclasses.asdict(u) for us in by_p.values() for u in us] + _write(Path(a.run_dir, "ledger", f"units_{arm}.jsonl"), rows) + print(arm, len(rows), "units over", len(by_p), "problems") + return 0 + + +def cmd_estimate(a) -> int: + cfg = Cfg(run_dir="-", problems=[], n_restart=a.n_restart, n_agentic=a.n_agentic) + sets = {"minif2f": a.minif2f, "proofnet": a.proofnet} + print(json.dumps(estimate(sets, cfg), indent=1)) + return 0 + + +def main(argv=None) -> int: + ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) + sub = ap.add_subparsers(dest="cmd", required=True) + + def common(p, run=True): + if run: + p.add_argument("--run-dir", required=True) + + def cfgargs(p): + d = Cfg(run_dir="", problems=[]) + common(p) + p.add_argument("--problems", nargs="+", required=True) + p.add_argument("--model", default=d.model) + p.add_argument("--seed", type=int, default=d.seed) + p.add_argument("--cap", type=int, default=d.cap) + p.add_argument("--traj-cap", type=int, default=d.traj_cap) + p.add_argument("--n-restart", type=int, default=d.n_restart) + p.add_argument("--n-agentic", type=int, default=d.n_agentic) + p.add_argument("--rounds", type=int, default=d.rounds) + p.add_argument("--routes", default=d.routes) + p.add_argument("--sub-n", type=int, default=d.sub_n) + p.add_argument("--sub-cap", type=int, default=d.sub_cap) + p.add_argument("--bank-k", type=int, default=d.bank_k) + p.add_argument("--budget", default=d.budget) + p.add_argument("--workers", type=int, default=d.workers) + p.add_argument("--bank-problems") + p.add_argument("--bank-gens") + p.add_argument("--bank-vers") + p.add_argument("--eval-problems", nargs="*", default=[]) + p.add_argument("--no-hostguard", dest="hostguard", action="store_false") + + p = sub.add_parser("plan", help="print the stages and exact command lines") + cfgargs(p) + p = sub.add_parser("run", help="execute the stages (resumable)") + cfgargs(p) + p.add_argument("--only", nargs="*", default=None) + + p = sub.add_parser("prep") + common(p) + p.add_argument("--problems", nargs="+", required=True) + p.add_argument("--bank-problems") + p.add_argument("--bank-gens") + p.add_argument("--bank-vers") + p.add_argument("--eval-problems", nargs="*", default=[]) + p.add_argument("--bank-k", type=int, default=3) + + p = sub.add_parser("route") + common(p) + p.add_argument("--round", type=int, required=True) + p.add_argument("--mode", choices=("subgoal", "feedback"), required=True) + p.add_argument("--pairs", nargs=2, action="append", metavar=("VER", "GEN"), required=True) + p.add_argument("--workers", type=int, default=2) + p.add_argument("--bank-k", type=int, default=3) + p.add_argument("--sub-n", type=int, default=4) + + p = sub.add_parser("assemble") + common(p) + p.add_argument("--round", type=int, required=True) + p.add_argument("--cap", type=int, default=40960) + + for name, fn in (("report", cmd_report), ("status", cmd_status), ("ledger", cmd_ledger)): + p = sub.add_parser(name) + common(p) + p.add_argument("--budget", default="restart_k:8") + p.add_argument("--rounds", type=int, default=4) + p.add_argument("--n-perm", type=int, default=256) + p.add_argument("--seed", type=int, default=0) + + p = sub.add_parser("estimate") + p.add_argument("--minif2f", type=int, default=226) + p.add_argument("--proofnet", type=int, default=185) + p.add_argument("--n-restart", type=int, default=32) + p.add_argument("--n-agentic", type=int, default=8) + + a = ap.parse_args(argv) + if a.cmd in ("plan", "run"): + cfg = Cfg(run_dir=a.run_dir, problems=a.problems, model=a.model, seed=a.seed, cap=a.cap, traj_cap=a.traj_cap, n_restart=a.n_restart, + n_agentic=a.n_agentic, rounds=a.rounds, routes=a.routes, sub_n=a.sub_n, sub_cap=a.sub_cap, bank_k=a.bank_k, budget=a.budget, + workers=a.workers, bank_problems=a.bank_problems, bank_gens=a.bank_gens, bank_vers=a.bank_vers, + eval_problems=a.eval_problems, hostguard=a.hostguard) + stages = build_stages(cfg) + if a.cmd == "plan": + for st in stages: + print(f"# {st.name} [{'GPU' if st.gpu else 'CPU'}]\n{' '.join(shlex.quote(x) for x in _hg(cfg, st))}\n") + return 0 + return run_stages(cfg, stages, Runner(), only=set(a.only) if a.only else None) + return {"prep": cmd_prep, "route": cmd_route, "assemble": cmd_assemble, "report": cmd_report, "status": cmd_status, + "ledger": cmd_ledger, "estimate": cmd_estimate}[a.cmd](a) + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/code/scripts/p2/kimina_server_start.sh b/code/scripts/p2/kimina_server_start.sh new file mode 100644 index 0000000000000000000000000000000000000000..e034c453bb178346610ee9009862656de52317db --- /dev/null +++ b/code/scripts/p2/kimina_server_start.sh @@ -0,0 +1,14 @@ +#!/bin/bash +# Launch kimina-lean-server pinned to OUR Lean 4.9.0-rc1 + Mathlib d1d1e4b7 (scrubbed env). +set -e +K=/apps/development/rlhf/toolchains/src/kimina/kimina-lean-server +L=/apps/development/rlhf/lean +exec env -i HOME=/apps/development/rlhf/toolchains/home TMPDIR=/apps/development/rlhf/lean/tmp \ + ELAN_HOME=$L/elan XDG_CACHE_HOME=$L/xdg-cache PATH=$L/elan/bin:/usr/bin:/bin LANG=C.UTF-8 MALLOC_ARENA_MAX=2 \ + PYTHONPATH=$K/client:$K \ + LEAN_SERVER_HOST=127.0.0.1 LEAN_SERVER_PORT=${PORT:-18000} LEAN_SERVER_ENVIRONMENT=prod \ + LEAN_SERVER_LEAN_VERSION=v4.9.0-rc1 \ + LEAN_SERVER_REPL_PATH=/apps/development/rlhf/toolchains/work/kimina-repl49/.lake/build/bin/repl \ + LEAN_SERVER_PROJECT_DIR=$L/workspace \ + LEAN_SERVER_MAX_REPLS=${MAX_REPLS:-4} LEAN_SERVER_MAX_REPL_MEM=${MEM:-16G} LEAN_SERVER_MAX_WAIT=3600 LEAN_SERVER_LOG_LEVEL=WARNING \ + /apps/development/rlhf/toolchains/kimina-venv/bin/python -m uvicorn server.main:app --host 127.0.0.1 --port ${PORT:-18000} --app-dir $K --log-level warning diff --git a/code/scripts/p2/launch_baseline.py b/code/scripts/p2/launch_baseline.py new file mode 100644 index 0000000000000000000000000000000000000000..053c7bc2bbfb2bd1ce46e8d26cfe469747d54655 --- /dev/null +++ b/code/scripts/p2/launch_baseline.py @@ -0,0 +1,383 @@ +#!/usr/bin/env python +"""P2: the baseline miniF2F pass@8 run (cap 16384, then the 40960 reference) as ONE supervised, resumable job. + + .venv/bin/python scripts/p2/launch_baseline.py --run-dir .tmp/p2//baseline \\ + --problems .tmp/p2/problems/minif2f.jsonl # add --print to show the commands only + +What this process IS: the heavy job. It takes the repo heavy-job flock ONCE (waiting if another job +holds it), keeps it for the whole sequence, and runs each phase as a child that *inherits* it +(`--inherit-lock `; `align.utils.guard.inherit_heavy_job_lock` verifies the claim). +Phases, in this order so the pre-registered primary result exists first: + + gen_c16384 -> ver_c16384 -> gen_c40960 -> ver_c40960 + +Generation and Lean verification are both heavy; they never overlap, and no other agent's job can slip +in between phases, because the lock is not released between them. + +Properties (each one is a failure this job would otherwise have): + * RESUMABLE at every level. Re-running the same command skips phases with a `.done` marker; a phase + killed half way resumes from its append-only output (both children skip finished keys). + * COMPLETENESS IS CHECKED, not assumed: a generation phase is done only when it holds exactly + problems x n records at that cap; a verification phase only when its key set equals the generation's. + A child that exits 0 with fewer records is retried, not accepted. + * BOUNDED RETRIES that need PROGRESS: a failed phase is retried (max `--max-retries`) only if its + output grew since the previous attempt; a crash loop with no progress stops with `stalled`. + * NO ORPHANS: every child runs in its own session; after it exits (cleanly or not) its process group + is signalled by its exact pgid (vLLM's EngineCore is known to outlive its parent and keep 19 GB of + GPU memory), and the next GPU phase waits for GPU memory to drain. Nothing is ever killed by a + name pattern. + * SIGTERM/SIGINT to this process stops the current child (SIGTERM, grace, SIGKILL of its group), + writes `stopped` to the status log and exits 143; the lock goes with the process. + * Host rules: preflight (strict) before every phase; scratch and outputs under /apps; vLLM TP=4, + `--max-num-seqs 128` (B's measured knee); <= 8 Lean workers. + +Status: `/supervisor.jsonl` (one event per line) and `/logs/.log`. +""" + +from __future__ import annotations + +import argparse +import json +import os +import signal +import subprocess +import sys +import time +from pathlib import Path + +REPO = Path(__file__).resolve().parents[2] +sys.path.insert(0, str(REPO)) + +PY = str(REPO / ".venv" / "bin" / "python") +PYR = str(REPO / ".venv-rollout" / "bin" / "python") +MODEL = ("/apps/development/rlhf/hf_cache/hub/models--Goedel-LM--Goedel-Prover-V2-8B/snapshots/" + "dfd02e6271a58375dfbf3ece0175277cf6b6a89a") +KEY = ("problem_id", "sample_idx", "round", "cap_tokens") + + +class Stalled(RuntimeError): + pass + + +def phase_plan(a, parent_pid: int) -> list[dict]: + """The ordered phases and their exact argv. Pure (tests pin it).""" + d = Path(a.run_dir) + plan = [] + for cap in a.caps: + gen, ver = d / f"gen_minif2f_c{cap}.jsonl", d / f"ver_minif2f_c{cap}.jsonl" + plan.append({ + "name": f"gen_c{cap}", "kind": "gen", "out": str(gen), "cap": cap, + "cmd": [PYR, str(REPO / "extras/vllm_rollout/lean_gen.py"), "--model", a.model, "--problems", a.problems, + "--out", str(gen), "--n", str(a.n), "--cap-tokens", str(cap), "--round", "0", "--tp", str(a.tp), + "--seed", "0", "--max-num-seqs", str(a.max_num_seqs), "--inherit-lock", str(parent_pid), + "--label", f"p2-baseline-gen-c{cap}"], + }) + plan.append({ + "name": f"ver_c{cap}", "kind": "ver", "out": str(ver), "gen": str(gen), "cap": cap, + "cmd": [PY, str(REPO / "scripts/p2/verify_generations.py"), "--generations", str(gen), "--problems", a.problems, + "--out", str(ver), "--workers", str(a.workers), "--inherit-lock", str(parent_pid)], + }) + return plan + + +def count_keys(path: str, cap: int | None = None) -> set[tuple]: + keys = set() + p = Path(path) + if not p.is_file(): + return keys + with open(p, "rb") as f: + for line in f: + if not line.endswith(b"\n"): + break # torn tail of a killed writer: the child repairs it; do not count it + r = json.loads(line) + if cap is None or r.get("cap_tokens") == cap: + keys.add(tuple(r[k] for k in KEY)) + return keys + + +def phase_complete(ph: dict, n_problems: int, n: int) -> tuple[bool, str]: + """Is the phase's OUTPUT complete? gen: problems x n records at this cap. ver: same key set as gen.""" + keys = count_keys(ph["out"], ph["cap"]) + if ph["kind"] == "gen": + want = n_problems * n + # prompts too long for the window are skipped by lean_gen and listed in .skipped.jsonl + skipped = len(list(open(ph["out"] + ".skipped.jsonl"))) if os.path.isfile(ph["out"] + ".skipped.jsonl") else 0 + return len(keys) + skipped >= want and len(keys) > 0, f"{len(keys)} records + {skipped} skipped of {want}" + gkeys = count_keys(ph["gen"], ph["cap"]) + return bool(gkeys) and keys == gkeys, f"{len(keys)} verdicts for {len(gkeys)} generations" + + +def n_problems_of(path: str) -> int: + return sum(1 for l in open(path) if l.strip()) + + +def gpu_memory_used_mib() -> list[int]: + out = subprocess.run(["nvidia-smi", "--query-gpu=memory.used", "--format=csv,noheader,nounits"], + capture_output=True, text=True, timeout=30).stdout.split() + return [int(x) for x in out] + + +def wait_gpu_drained(limit_mib: int = 1024, timeout_s: float = 180.0) -> None: + t0 = time.time() + while True: + used = gpu_memory_used_mib() + if max(used) <= limit_mib: + return + if time.time() - t0 > timeout_s: + raise RuntimeError(f"GPU memory did not drain after the previous phase: {used} MiB (an orphan holds it; " + "find it by pid with nvidia-smi and stop it by exact pid)") + time.sleep(5) + + +SHM_DIR = "/dev/shm" +SHM_NAME_RE = r"psm_[0-9a-f]{8}" + + +def _paths_in_use(shm_dir: str) -> set[str]: + """Every file under `shm_dir` that some live process has open (fd) or mapped (maps).""" + held: set[str] = set() + prefix = shm_dir.rstrip("/") + "/" + for entry in os.listdir("/proc"): + if not entry.isdigit(): + continue + try: + for fd in os.listdir(f"/proc/{entry}/fd"): + try: + t = os.readlink(f"/proc/{entry}/fd/{fd}") + except OSError: + continue + if t.startswith(prefix): + held.add(t.split(" (deleted)")[0]) + with open(f"/proc/{entry}/maps") as f: + for line in f: + i = line.find(prefix) + if i >= 0: + held.add(line[i:].split(" (deleted)")[0].strip()) + except (FileNotFoundError, ProcessLookupError, PermissionError): + continue + return held + + +def sweep_orphan_shm(since: float, shm_dir: str = SHM_DIR, only: "set[str] | None" = None) -> tuple[list[str], int]: + """Unlink the `psm_*` shared-memory segments a vLLM phase left behind. Returns (names, bytes). + + THE /dev/shm LEAK (R2 review, measured): every vLLM launch leaves its broadcast ring buffers in /dev/shm + (a clean exit: 2 segments, ~0.19 GB; a SIGKILLed one: ~1.4 GB), and nothing removes them -- 46 segments / + 6.3 GB were sitting there from P2 alone. tmpfs is RAM on a box with NO swap, and /dev/shm exhaustion is one + of the two 2026-08-12 incidents. Only segments that are provably this phase's litter are removed: named + exactly like Python's SharedMemory (`psm_` + 8 hex), owned by this uid, modified after the phase began, and + held (open or mapped) by NO process. Nothing is matched by pattern against processes. `only` restricts the + candidates to the given names (tests use it so that a mutated copy cannot touch other jobs' segments). + """ + import re + + removed, total = [], 0 + held = _paths_in_use(shm_dir) + for name in sorted(os.listdir(shm_dir)): + if only is not None and name not in only: + continue + if not re.fullmatch(SHM_NAME_RE, name): + continue + path = os.path.join(shm_dir, name) + try: + st = os.stat(path) + except FileNotFoundError: + continue + if st.st_uid != os.getuid() or st.st_mtime < since or path in held: + continue + try: + os.unlink(path) + except FileNotFoundError: + continue + removed.append(name) + total += st.st_size + return removed, total + + +class Runner: + def __init__(self, a): + self.a = a + self.run_dir = Path(a.run_dir) + self.child: subprocess.Popen | None = None + self.stop = False + + def log(self, **ev) -> None: + ev["t"] = time.strftime("%F %T") + with open(self.run_dir / "supervisor.jsonl", "a") as f: + f.write(json.dumps(ev) + "\n") + print(json.dumps(ev), flush=True) + + def _signal(self, signum, _frame) -> None: + self.stop = True + self.log(event="signal", signum=signum) + self.kill_group(self.child, grace=60.0) + + @staticmethod + def group_alive(pgid: int) -> bool: + """Does any NON-ZOMBIE process still belong to process group `pgid`? + + `os.killpg(pgid, 0)` is the wrong probe: a killed leader stays a zombie until its parent reaps it, + and the SIGTERM handler runs in the very thread that is blocked in `Popen.wait()` (which owns the + reaping), so the probe saw the dead leader as "alive" for the whole grace period -- every stop took + grace + 10 s (70 s) with the heavy-job lock held, whatever the child did.""" + for entry in os.listdir("/proc"): + if not entry.isdigit(): + continue + try: + with open(f"/proc/{entry}/stat") as f: + rest = f.read().rsplit(")", 1)[1].split() + except (FileNotFoundError, ProcessLookupError, IndexError): + continue + if int(rest[2]) == pgid and rest[0] != "Z": # rest = [state, ppid, pgrp, ...] + return True + return False + + @staticmethod + def kill_group(proc, grace: float) -> None: + """SIGTERM then SIGKILL the child's process group, by the exact pgid it was given (its own session).""" + if proc is None: + return + for sig, wait in ((signal.SIGTERM, grace), (signal.SIGKILL, 10.0)): + if not Runner.group_alive(proc.pid): + return + try: + os.killpg(proc.pid, sig) + except ProcessLookupError: + return + t0 = time.time() + while time.time() - t0 < wait: + if not Runner.group_alive(proc.pid): + return + time.sleep(0.2) + + def run_child(self, ph: dict, attempt: int) -> int: + log_path = self.run_dir / "logs" / f"{ph['name']}.log" + env = dict(os.environ) + env.update({"TMPDIR": str(self.run_dir / "scratch"), "HF_HOME": "/apps/development/rlhf/hf_cache", + "HF_HUB_DISABLE_XET": "1", "PYTHONUNBUFFERED": "1"}) + for k in [k for k in env if k.startswith(("RANK", "LOCAL_RANK", "WORLD_SIZE"))]: + del env[k] + started_at = time.time() - 1.0 + with open(log_path, "a") as lf: + lf.write(f"\n=== attempt {attempt} {time.strftime('%F %T')}: {' '.join(ph['cmd'])}\n") + lf.flush() + self.child = subprocess.Popen(ph["cmd"], stdout=lf, stderr=subprocess.STDOUT, env=env, cwd=str(REPO), + start_new_session=True) + if self.stop: + # A stop signal that landed between Popen() starting the child and the assignment above found + # `self.child` still None, so the handler had nothing to kill: without this the supervisor + # would sit in wait() for the whole phase (hours) while holding the heavy-job lock. + self.kill_group(self.child, grace=60.0) + rc = self.child.wait() + # the leader is gone; anything still in its group is an orphan of THIS child + self.kill_group(self.child, grace=15.0) + self.child = None + gone, nbytes = sweep_orphan_shm(started_at) + if gone: + self.log(event="shm_swept", phase=ph["name"], segments=len(gone), bytes=nbytes) + return rc + + def run_phase(self, ph: dict, n_problems: int) -> None: + marker = self.run_dir / f"{ph['name']}.done" + if marker.is_file(): + self.log(event="skip_done", phase=ph["name"]) + return + last = -1 + for attempt in range(self.a.max_retries + 1): + if self.stop: + return + from align.utils import guard + + guard.preflight_host_check(strict=True) + if ph["kind"] == "gen": + wait_gpu_drained() + t0 = time.time() + self.log(event="start", phase=ph["name"], attempt=attempt) + rc = self.run_child(ph, attempt) + ok, detail = phase_complete(ph, n_problems, self.a.n) + size = len(count_keys(ph["out"], ph["cap"])) + self.log(event="end", phase=ph["name"], attempt=attempt, rc=rc, complete=ok, detail=detail, + wall_s=round(time.time() - t0, 1)) + if rc == 0 and ok: # a phase that finished while a stop was pending is still finished + marker.write_text(json.dumps({"detail": detail, "t": time.strftime("%F %T")})) + return + if self.stop: + return + if size <= last: + raise Stalled(f"{ph['name']}: attempt {attempt} made no progress ({detail}); stopping instead of looping") + last = size + time.sleep(self.a.backoff) + raise Stalled(f"{ph['name']}: still incomplete after {self.a.max_retries + 1} attempts ({detail})") + + def summary(self) -> dict: + out = {} + for cap in self.a.caps: + vp = self.run_dir / f"ver_minif2f_c{cap}.jsonl" + if not vp.is_file(): + continue + solved, seen, acc = set(), set(), 0 + for l in open(vp): + r = json.loads(l) + seen.add(r["problem_id"]) + if r["accepted"] and r["sample_idx"] < self.a.n: + solved.add(r["problem_id"]) + acc += int(r["accepted"]) + out[f"solved@{self.a.n}_cap{cap}"] = f"{len(solved)}/{len(seen)}" + out[f"accepted_samples_cap{cap}"] = acc + return out + + def run(self) -> int: + from align.utils import guard + + (self.run_dir / "logs").mkdir(parents=True, exist_ok=True) + (self.run_dir / "scratch").mkdir(parents=True, exist_ok=True) + guard.assert_paths_off_root([str(self.run_dir), str(self.run_dir / "scratch")]) + signal.signal(signal.SIGTERM, self._signal) + signal.signal(signal.SIGINT, self._signal) + t0 = time.time() + guard.acquire_heavy_job_lock("p2-baseline-supervisor", blocking=True, lock_path=getattr(self.a, "lock_path", None)) # waits; held until this process exits + self.log(event="lock_acquired", waited_s=round(time.time() - t0, 1), pid=os.getpid()) + n_problems = n_problems_of(self.a.problems) + try: + for ph in phase_plan(self.a, os.getpid()): + self.run_phase(ph, n_problems) + if self.stop: + self.log(event="stopped") + return 143 + except Stalled as e: + self.log(event="stalled", error=str(e)) + return 3 + self.log(event="complete", **self.summary()) + return 0 + + +def main(argv=None) -> int: + ap = argparse.ArgumentParser(description=__doc__.split("\n")[0]) + ap.add_argument("--run-dir", required=True) + ap.add_argument("--problems", required=True, help="miniF2F Problem JSONL (all 244)") + ap.add_argument("--model", default=MODEL) + ap.add_argument("--caps", type=int, nargs="+", default=[16384, 40960]) + ap.add_argument("--n", type=int, default=8) + ap.add_argument("--tp", type=int, default=4) + ap.add_argument("--max-num-seqs", type=int, default=128) + ap.add_argument("--workers", type=int, default=8) + ap.add_argument("--max-retries", type=int, default=2) + ap.add_argument("--backoff", type=float, default=60.0) + ap.add_argument("--print", action="store_true", dest="print_only") + a = ap.parse_args(argv) + if not 1 <= a.workers <= 8: + raise SystemExit("--workers must be in [1, 8] (host rule)") + if a.n != 8 or a.caps[0] != 16384: + print("note: the registered primary endpoint is n=8 at cap 16384 (docs/P2.md section 1)", file=sys.stderr) + for p in (a.run_dir,): + if not os.path.abspath(p).startswith("/apps/"): + raise SystemExit(f"--run-dir {p} must be on /apps (never / or /tmp)") + if a.print_only: + for ph in phase_plan(a, os.getpid()): + print(" ".join(ph["cmd"])) + return 0 + return Runner(a).run() + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/code/scripts/p2/mutation_check.py b/code/scripts/p2/mutation_check.py new file mode 100644 index 0000000000000000000000000000000000000000..0ee2e880677253f4cd45678e85e3d680403cbaad --- /dev/null +++ b/code/scripts/p2/mutation_check.py @@ -0,0 +1,140 @@ +#!/usr/bin/env python +"""Hand-written mutation check for the P2 modules: each mutant removes ONE fix (a guard, a mask +offset, a statistic's correction) in a private COPY of the repo subset and re-runs the targeted +tests; a mutant must make them fail ("KILLED"). The live tree is never touched. + + .venv/bin/python scripts/p2/mutation_check.py [--keep-going] + +Writes `.tmp/p2/mutants_result.json`. A SURVIVED or BAD-MUTANT line fails the run: a surviving +mutant is a guard no test exercises, a bad mutant is a pattern that no longer matches the source. +""" +import json, os, shutil, subprocess, sys +from pathlib import Path +ROOT = str(Path(__file__).resolve().parents[2]) +MR = f"{ROOT}/.tmp/p2/mutroot" +PY = sys.executable + +def setup(): + shutil.rmtree(MR, ignore_errors=True) + os.makedirs(MR) + shutil.copytree(f"{ROOT}/align", f"{MR}/align", ignore=shutil.ignore_patterns("__pycache__")) + shutil.copytree(f"{ROOT}/scripts/p2", f"{MR}/scripts/p2", ignore=shutil.ignore_patterns("__pycache__")) + os.makedirs(f"{MR}/tests") + for f in os.listdir(f"{ROOT}/tests"): + if f.startswith("test_p2_") or f == "conftest.py": + shutil.copy(f"{ROOT}/tests/{f}", f"{MR}/tests/{f}") + os.makedirs(f"{MR}/extras/vllm_rollout") + shutil.copy(f"{ROOT}/extras/vllm_rollout/lean_prompts.py", f"{MR}/extras/vllm_rollout/lean_prompts.py") # p2_common loads it by path + shutil.copy(f"{ROOT}/pyproject.toml", MR) + os.makedirs(f"{MR}/docs") + shutil.copy(f"{ROOT}/docs/P2.md", f"{MR}/docs/P2.md") # the pre-registration test reads it + +T = { + "stats": ["tests/test_p2_proof_stats.py"], + "prob": ["tests/test_p2_problems.py"], + "ver": ["tests/test_p2_verify.py"], + "sft": ["tests/test_p2_sft_data.py"], + "rep": ["tests/test_p2_eval_report.py"], +} +PS = "align/eval/proof_stats.py"; CM = "scripts/p2/p2_common.py"; BP = "scripts/p2/build_problems.py" +VG = "scripts/p2/verify_generations.py"; SD = "scripts/p2/build_sft_data.py"; ER = "scripts/p2/eval_report.py" +M = [ + ("M01 plug-in pass@k estimator", PS, [("return float(1 - Fraction(math.comb(n - c, k), math.comb(n, k)))", "return 1 - (1 - c / n) ** k")], "stats"), + ("M02 allow k>n", PS, [(" if k > n:\n raise ValueError(\n f\"pass@{k} needs", " if False:\n raise ValueError(\n f\"pass@{k} needs")], "stats"), + ("M03 mcnemar tail off by one", PS, [("tail = sum(math.comb(n, i) for i in range(m + 1))", "tail = sum(math.comb(n, i) for i in range(m))")], "stats"), + ("M04 mcnemar one-sided", PS, [("Fraction(2 * tail, 2**n)", "Fraction(tail, 2**n)")], "stats"), + ("M05 mcnemar p not capped at 1", PS, [("float(min(Fraction(1), Fraction(2 * tail, 2**n)))", "float(Fraction(2 * tail, 2**n))")], "stats"), + ("M06 solved@k over all samples not the prefix", PS, [("return any(accepted_by_sample_idx[:k])", "return any(accepted_by_sample_idx)")], "stats"), + ("M07 mcnemar pairs unchecked", PS, [(" if set(solved_a) != set(solved_b):", " if False:")], "stats"), + ("M08 wrong z", PS, [("Z95 = 1.959963984540054", "Z95 = 1.5")], "stats"), + ("M09 unpaired bootstrap (independent resamples)", PS, [("means = _boot_means(vb - va, seed, n_boot)", "means = _boot_means(vb, seed, n_boot) - _boot_means(va, seed + 1, n_boot)")], "stats"), + ("M10 statement_sha includes the theorem name", CM, [(" _, _, sig = statement_key(statement)\n return sha256_text(\" \".join(sig))", " nm, _, sig = statement_key(statement)\n return sha256_text(nm + \" \".join(sig))")], "prob"), + ("M11 alpha_sha does not rename", CM, [("out.append(mp.setdefault(t, f\"v{len(mp)}\"))", "out.append(t)")], "prob"), + ("M12 name layer off", BP, [("if n in self.names or (self._name_re is not None and self._name_re.search(n)):", "if False:")], "prob"), + ("M13 name substring layer off", BP, [("if n in self.names or (self._name_re is not None and self._name_re.search(n)):", "if n in self.names:")], "prob"), + ("M14 informal 13-gram layer off", BP, [(" if not self.grams.isdisjoint(C.ngrams(toks, self.ngram)):\n return \"informal_13gram\"", " if False:\n return \"informal_13gram\"")], "prob"), + ("M14b informal 8-gram layer off", BP, [("if len(self.short_grams & C.ngrams(toks, self.short_ngram)) >= self.short_hits:", "if False:")], "prob"), + ("M14c a single 8-gram triggers", BP, [("SHORT_HITS = 3", "SHORT_HITS = 1")], "prob"), + ("M15 sampler keeps largest hashes", BP, [("elif item > heap[0]:", "elif item < heap[0]:")], "prob"), + ("M16 sampler skips decontamination", BP, [(" if why is not None:\n counts[f\"contaminated_{why}\"] += 1\n continue", " if False:\n continue")], "prob"), + ("M17 failures counted beyond the cutoff", BP, [("cutoff = candidates.index(selected[-1]) + 1", "cutoff = len(log_rows)")], "prob"), + ("M18 elaboration instrument not ceiling-tested", BP, [(" if res[0] is not want:", " if False:")], "prob"), + ("M19 normalize accepts several ':= by'", CM, [("if len(hits) != 1:", "if len(hits) < 1:")], "prob"), + ("M20 length-capped samples sent to Lean", VG, [(" if gen[\"finish_reason\"] == \"length\":\n return \"finish_reason=length\"", " if False:\n return \"finish_reason=length\"")], "ver"), + ("M21 stale verdict accepted", VG, [("if existing[key][\"code_sha256\"] != C.code_sha(gen[\"lean_code\"]):", "if False:")], "ver"), + ("M22 resume ignores existing rows", VG, [(" if key in existing:\n if existing", " if False:\n if existing")], "ver"), + ("M23 dead-verifier chunk written", VG, [("if len(part) >= 8 and len(held) / len(part) > 0.5:", "if False:")], "ver"), + ("M24 feedback computed on the full file instead of compile_view", VG, [(" view = C.compile_view(code)", " view = code")], "ver"), + ("M24b feedback drops endPos None handling", VG, [("return None if not p else {\"line\": int(p[\"line\"]), \"column\": int(p[\"column\"])}", "return {\"line\": int((p or {\"line\": 0})[\"line\"]), \"column\": 0}")], "ver"), + ("M25b torn single line kept", CM, [(" cut = data.rfind(b\"\\n\") + 1 # 0 when the file has a single unterminated line", " cut = max(data.rfind(b\"\\n\"), 0)")], "ver"), + ("M25c atomic write leaves tmp", CM, [(" if tmp.exists():\n tmp.unlink()\n return n", " pass\n return n")], "ver"), + ("M25d round>0 parent unchecked", CM, [(" if parent[\"problem_id\"] != pid or parent[\"round\"] != rec[\"round\"] - 1:", " if False:")], "ver"), + ("M25 torn tail kept", CM, [(" with open(p, \"r+b\") as f:\n f.truncate(cut)", " with open(p, \"r+b\") as f:\n f.truncate(len(data))")], "ver"), + ("M26 prompt_len off by one (+ readback disabled)", SD, [("prompt_len=len(rec[\"prompt_ids\"]),", "prompt_len=len(rec[\"prompt_ids\"]) + 1,"), ("n_checked = readback_check(out, idx, selected, held)", "n_checked = len(selected)")], "sft"), + ("M27 completion truncated by one token (+ readback disabled)", SD, [("ids = rec[\"prompt_ids\"] + rec[\"completion_ids\"]", "ids = rec[\"prompt_ids\"] + rec[\"completion_ids\"][:-1]"), ("n_checked = readback_check(out, idx, selected, held)", "n_checked = len(selected)")], "sft"), + ("M28 prompt_len off by one (readback ON)", SD, [("prompt_len=len(rec[\"prompt_ids\"]),", "prompt_len=len(rec[\"prompt_ids\"]) - 1,")], "sft"), + ("M29 easy problems kept", SD, [("if n0 >= EASY_MIN_SAMPLES and c0 == n0 and not a.keep_easy:", "if False:")], "sft"), + ("M30 n<8 counted as easy", SD, [("if n0 >= EASY_MIN_SAMPLES and c0 == n0", "if c0 == n0")], "sft"), + ("M31 other-cap samples mixed in", SD, [(" if rec[\"cap_tokens\"] != cap_tokens:", " if False:")], "sft"), + ("M32 correction of an accepted parent kept", SD, [(" if ver[pkey][\"accepted\"]:", " if False:")], "sft"), + ("M33 correction prompt not checked for the failed attempt", SD, [("if not tail or tail not in prompt or \"rror\" not in prompt:", "if False:")], "sft"), + ("M34 duplicate proofs not de-duplicated", SD, [("if room(kind, kind_limit) and h not in seen_hash:", "if room(kind, kind_limit):")], "sft"), + ("M35 EOS requirement removed", SD, [("if not a.allow_no_eos and m[\"last_token\"] not in eos:", "if False:")], "sft"), + ("M36 eval-statement leak check removed", SD, [(" if leaked:", " if False:")], "sft"), + ("M37 foreign problems allowed", SD, [(" if foreign:", " if False:")], "sft"), + ("M38 stale verification accepted by the SFT builder", SD, [("if rec[\"code_sha256\"] != m[\"code_sha\"]:", "if False:")], "sft"), + ("M39 per-problem cap ignored", SD, [("return len(chosen) < a.max_per_problem and", "return True and")], "sft"), + ("M48 statement-fidelity filter removed from the SFT selection", SD, [("if why is None and not C.displayed_statement_matches(", "if False and not C.displayed_statement_matches(")], "sft"), + ("M49 displayed theorem compared by presence only (any statement passes)", CM, [("return shown is not None and shown == statement_key(formal_statement)[2]", "return shown is not None")], "sft"), + ("M51 PRIMARY label without the registered problem count", ER, [(" if base_metrics[\"n_problems\"] != PRIMARY[\"n_problems\"]:", " if False:")], "rep"), + ("M52 PRIMARY label without the registered baseline revision", ER, [(" if revs != [PRIMARY[\"baseline_model_rev\"]]:", " if False:")], "rep"), + ("M53 PRIMARY label without the registered sampling settings", ER, [(" if (t, p) != (want[\"temperature\"], want[\"top_p\"]) or kk not in want[\"top_k\"]:", " if False:")], "rep"), + ("M54 runs with different sampling settings compared", ER, [(" if len(sb) != 1 or len(sc) != 1 or sb != sc:", " if False:")], "rep"), + ("M54b a run with mixed sampling settings compared", ER, [(" if len(sb) != 1 or len(sc) != 1 or sb != sc:", " if sb != sc:")], "rep"), + ("M55 differently prompted runs compared", ER, [(" bad = sorted({k[0] for k in common if base[k].prompt_sha != cand[k].prompt_sha})", " bad = []")], "rep"), + ("M56 design mismatch accepted without the override flag", ER, [(" if not allow_design_mismatch:", " if False:")], "rep"), + ("M57 decision rule ignores the direction (any significant difference is a success)", ER, [(" if sig and m[\"solved_b\"] > m[\"solved_a\"]:", " if sig:")], "rep"), + ("M58 decision rule: alpha 0.5", ER, [("PRIMARY_ALPHA = 0.05", "PRIMARY_ALPHA = 0.5")], "rep"), + ("M40 caps pooled in the report", ER, [("if rd == 0 and c == cap and pid in problem_ids:", "if rd == 0 and pid in problem_ids:")], "rep"), + ("M41 missing problems scored as failures", ER, [(" if missing:\n raise C.ContractError(f\"incomplete run", " if False:\n raise C.ContractError(f\"incomplete run")], "rep"), + ("M42 verifier mismatch allowed silently", ER, [(" if not allow_verifier_mismatch:", " if False:")], "rep"), + ("M43 primary endpoint not flagged", ER, [("\"endpoint\": \"PRIMARY\" if not blockers else \"secondary\"", "\"endpoint\": \"secondary\"")], "rep"), + ("M44 correction of accepted parent allowed", ER, [(" if samples[par].accepted:", " if False:")], "rep"), + ("M45 stale verification accepted by the report", ER, [("if v[\"code_sha256\"] != C.code_sha(rec[\"lean_code\"]):", "if False:")], "rep"), + ("M47 pre-registration hash never mismatches", ER, [("\"verified\" if rec.group(1) == h else \"MODIFIED_AFTER_REGISTRATION\"", "\"verified\"")], "rep"), + ("M46 McNemar on pass@k means not solved@k (uses any of all samples)", ER, [("sa = {p: S.solved_at_k(fb[p], k) for p in fb}", "sa = {p: any(fb[p]) for p in fb}")], "rep"), +] +def run(tests): + env = dict(os.environ, PYTHONPATH=MR) + r = subprocess.run([PY, "-m", "pytest", *tests, "-q", "-x", "-m", "not slow", "-p", "no:cacheprovider"], cwd=MR, env=env, capture_output=True, text=True, timeout=900) + return r.returncode, r.stdout.strip().splitlines()[-1] if r.stdout.strip() else r.stderr[-200:] +if __name__ == "__main__": + setup() + # baseline must be green + allt = sum(T.values(), []) + rc, last = run(allt) + print("BASELINE", rc, last, flush=True) + assert rc == 0 + out = [] + for name, path, reps, grp in M: + full = f"{MR}/{path}" + orig = open(full).read() + new = orig + for o, n in reps: + if o not in new: + print("[BAD-MUTANT]", name); new = None; break + new = new.replace(o, n, 1) + if new is None: + out.append((name, "bad", "")); continue + open(full, "w").write(new) + try: + rc, last = run(T[grp]) + finally: + open(full, "w").write(orig) + st = "KILLED" if rc != 0 else "SURVIVED" + print(f"[{st}] {name} :: {last}", flush=True) + out.append((name, st, last)) + json.dump(out, open(f"{ROOT}/.tmp/p2/mutants_result.json", "w"), indent=1) + bad = [n for n, s, _ in out if s != "KILLED"] + print(f"{len(out) - len(bad)}/{len(out)} killed; survivors/bad:", bad) + sys.exit(1 if bad else 0) diff --git a/code/scripts/p2/mutation_check_ei.py b/code/scripts/p2/mutation_check_ei.py new file mode 100644 index 0000000000000000000000000000000000000000..e511a95bf588d0f56f8ba80359cb0d962e459cf6 --- /dev/null +++ b/code/scripts/p2/mutation_check_ei.py @@ -0,0 +1,105 @@ +#!/usr/bin/env python +"""Hand-written mutation check for the expert-iteration driver and weight averaging. + +Each mutant removes ONE rule in a private COPY of the repo subset and re-runs the targeted tests; a mutant +must make them fail ("KILLED"). The live tree is never touched. A SURVIVED or BAD-MUTANT line fails the run. + + CUDA_VISIBLE_DEVICES= .venv/bin/python scripts/p2/mutation_check_ei.py +""" +import json, os, shutil, subprocess, sys +from pathlib import Path + +ROOT = str(Path(__file__).resolve().parents[2]) +MR = f"{ROOT}/.tmp/p2/mutroot_ei" +PY = sys.executable +EI = "scripts/p2/expert_iteration.py" +WA = "align/model/weight_average.py" +SD = "scripts/p2/build_sft_data.py" +TESTS = ["tests/test_expert_iteration.py", "tests/test_weight_average.py"] + +M = [ + ("M01 band lower bound inclusive", EI, [('elif m["p"] <= lo:', 'elif m["p"] < lo:')]), + ("M02 band upper bound exclusive", EI, [('elif m["p"] > hi:', 'elif m["p"] >= hi:')]), + ("M03 unsolved problems always resampled", EI, [(" if not include_zero:\n for pid in groups[\"unsolved\"]:", " if False:\n for pid in groups[\"unsolved\"]:")]), + ("M04 shortest rank ignored by the SFT builder", SD, [('if getattr(a, "rank", "order") == "shortest":', 'if False:')]), + ("M05 shortest picks the LONGEST", SD, [('first.sort(key=lambda k: (idx.meta[k]["n_completion"], k))', 'first.sort(key=lambda k: (-idx.meta[k]["n_completion"], k))')]), + ("M06 stale marker accepted (config change not detected)", EI, [(' if mk.get("chain") != s.chain:\n return "stale"', ' if False:\n return "stale"')]), + ("M07 non-zero exit still writes the marker", EI, [(" if rc != 0:\n raise DriverError", " if False:\n raise DriverError")]), + ("M08 partial outputs not cleaned before a re-run", EI, [(" for p in s.pre_clean:\n if Path(p).exists():", " for p in []:\n if Path(p).exists():")]), + ("M09 resume re-runs finished stages", EI, [(' if status == "done":\n say(f"[round {s.r}] {s.name}: done (skipped)")\n return "skipped"', ' if False:\n return "skipped"')]), + ("M10 export picks the OLDEST checkpoint", EI, [(" return cands[-1] if cands else None", " return cands[0] if cands else None")]), + ("M11 export merges onto the hard-coded snapshot", EI, [('{**env_py, "P2_EXPORT_BASE_SNAPSHOT": gen_model}', "dict(env_py)")]), + ("M12 pass-rate table not carried across rounds", EI, [(" table = dict(prev)\n for pid, m in new.items():", " table = {}\n for pid, m in new.items():")]), + ("M13 regression boundary off by one", EI, [("last[\"solved\"] <= prior_best - max_regress", "last[\"solved\"] < prior_best - max_regress")]), + ("M14 plateau needs only one flat round", EI, [(" if flat == patience:", " if flat >= 1:")]), + ("M15 stop rule compares across different n/cap", EI, [('comp = [h for h in history if (h["n_eval"], h["cap"]) == (last["n_eval"], last["cap"])]', "comp = list(history)")]), + ("M16 average: storage-dtype arithmetic", WA, [("fa[lo:hi].to(torch.float64)", "fa[lo:hi]"), ("fb[lo:hi].to(torch.float64)", "fb[lo:hi]")]), + ("M17 average: weights swapped", WA, [("((1.0 - w) * fa[lo:hi]", "((w) * fa[lo:hi]"), ("+ w * fb[lo:hi]", "+ (1.0 - w) * fb[lo:hi]")]), + ("M18 average: last element of each chunk dropped", WA, [("hi = min(lo + chunk_elems, fa.numel())", "hi = min(lo + chunk_elems, fa.numel()) - (1 if fa.numel() > chunk_elems and lo + chunk_elems < fa.numel() else 0)")]), + ("M19 average: shape mismatch tolerated", WA, [(" if ha[n][1] != hb[n][1] or ha[n][2] != hb[n][2]:", " if False:")]), + ("M20 average: partial output left behind on a crash", WA, [(" handles.clear()\n shutil.rmtree(part, ignore_errors=True)\n raise", " handles.clear()\n raise")]), +] + + +def setup(): + shutil.rmtree(MR, ignore_errors=True) + os.makedirs(MR) + shutil.copytree(f"{ROOT}/align", f"{MR}/align", ignore=shutil.ignore_patterns("__pycache__")) + shutil.copytree(f"{ROOT}/scripts/p2", f"{MR}/scripts/p2", ignore=shutil.ignore_patterns("__pycache__")) + shutil.copy(f"{ROOT}/scripts/qwen3_export_check.py", f"{MR}/scripts/qwen3_export_check.py") + os.makedirs(f"{MR}/tests") + for f in ("conftest.py", "test_p2_support.py", *[Path(t).name for t in TESTS]): + shutil.copy(f"{ROOT}/tests/{f}", f"{MR}/tests/{f}") + os.makedirs(f"{MR}/extras/vllm_rollout") + shutil.copy(f"{ROOT}/extras/vllm_rollout/lean_prompts.py", f"{MR}/extras/vllm_rollout/lean_prompts.py") + shutil.copy(f"{ROOT}/pyproject.toml", MR) + os.makedirs(f"{MR}/docs") + shutil.copy(f"{ROOT}/docs/P2.md", f"{MR}/docs/P2.md") + + +def run_tests(): + env = dict(os.environ, CUDA_VISIBLE_DEVICES="", PYTHONDONTWRITEBYTECODE="1") + r = subprocess.run([PY, "-m", "pytest", *TESTS, "-q", "-x", "-p", "no:cacheprovider"], cwd=MR, env=env, capture_output=True, text=True) + return r.returncode, r.stdout[-600:] + + +def main(): + setup() + rc, out = run_tests() + if rc != 0: + print("BASELINE FAILS in the copy:\n", out) + return 2 + results, bad = [], 0 + for name, rel, subs in M: + path = f"{MR}/{rel}" + orig = open(path).read() + mut = orig + ok = True + for old, new in subs: + if old not in mut: + ok = False + break + mut = mut.replace(old, new, 1) + if not ok: + print(f"BAD-MUTANT {name}: pattern not found in {rel}") + results.append({"mutant": name, "result": "BAD-MUTANT"}) + bad += 1 + continue + open(path, "w").write(mut) + try: + rc, out = run_tests() + finally: + open(path, "w").write(orig) + res = "KILLED" if rc != 0 else "SURVIVED" + bad += res == "SURVIVED" + failing = [l for l in out.splitlines() if l.startswith("FAILED")][:1] + print(f"{res:9s} {name} {failing[0][:110] if failing else ''}") + results.append({"mutant": name, "result": res}) + os.makedirs(f"{ROOT}/.tmp/p2", exist_ok=True) + json.dump(results, open(f"{ROOT}/.tmp/p2/mutants_result_ei.json", "w"), indent=1) + print(f"{sum(r['result'] == 'KILLED' for r in results)}/{len(M)} killed") + return 1 if bad else 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/code/scripts/p2/mutation_check_integration.py b/code/scripts/p2/mutation_check_integration.py new file mode 100644 index 0000000000000000000000000000000000000000..e6b88fdf29c19f45843d37ad42f80b58d1d64a9e --- /dev/null +++ b/code/scripts/p2/mutation_check_integration.py @@ -0,0 +1,80 @@ +#!/usr/bin/env python +"""Mutation check for the P2 integration seams: each mutant removes ONE guard from a private copy of the +file under test and tests/test_p2_integration.py must go red. + + .venv/bin/python scripts/p2/mutation_check_integration.py + +The mutated file is a copy placed in a scratch tree that mirrors the repo layout (scripts/p2/, align/utils/); +nothing in the working tree is modified, so a concurrent job importing the real files is never exposed to +a mutant. Exit status 1 if any mutant survives. +""" + +from __future__ import annotations + +import json +import os +import shutil +import subprocess +import sys +import tempfile +from pathlib import Path + +REPO = Path(__file__).resolve().parents[2] + +MUTANTS = [ + # (id, file, old, new, test selector) + ("S1 keys: unverified samples allowed", "scripts/p2/check_seams.py", "if missing or extra:", "if False:", "keys_seam"), + ("S2 keys: stale verdict allowed", "scripts/p2/check_seams.py", "if stale:", "if False:", "stale_verdict"), + ("S3 keys: accepted truncated allowed", "scripts/p2/check_seams.py", "if bad:\n raise SeamError(f\"{len(bad)} length", "if False:\n raise SeamError(f\"{len(bad)} length", "truncated"), + ("S4 sft: rejected sample may be trained on", "scripts/p2/check_seams.py", "if not (key in vers and vers[key][\"accepted\"]):", "if False:", "rejected_sample"), + ("S5 sft: completion not compared", "scripts/p2/check_seams.py", "if not np.array_equal(trained, np.asarray(g[\"completion_ids\"], dtype=trained.dtype)):", "if False:", "differs_by_one_token"), + ("S6 sft: prompt not compared", "scripts/p2/check_seams.py", "if p != len(g[\"prompt_ids\"]) or not np.array_equal(ids[:p], np.asarray(g[\"prompt_ids\"])):", "if False:", "different_prompt"), + ("S7 sft: empty shard set passes", "scripts/p2/check_seams.py", "if rows == 0:", "if False:", "vacuously"), + ("L1 launch: exit code trusted over completeness", "scripts/p2/launch_baseline.py", "if rc == 0 and ok:", "if rc == 0:", "exits_zero"), + ("L2 launch: retry without progress", "scripts/p2/launch_baseline.py", "if size <= last:", "if False:", "crash_loop"), + ("L3 launch: finished phases rerun", "scripts/p2/launch_baseline.py", "if marker.is_file():", "if False:", "skipped_on_rerun"), + ("L4 launch: orphans not reaped", "scripts/p2/launch_baseline.py", " self.kill_group(self.child, grace=15.0)\n self.child = None", " self.child = None", "reaped"), + ("L5 launch: torn tail counted", "scripts/p2/launch_baseline.py", "if not line.endswith(b\"\\n\"):\n break", "if False:\n break", "torn_last_line"), + ("L6 launch: children do not inherit the lock", "scripts/p2/launch_baseline.py", "\"--inherit-lock\", str(parent_pid),\n \"--label\"", "\"--label\"", "inherits_the_lock"), + ("L7 launch: ver complete on any non-empty file", "scripts/p2/launch_baseline.py", "return bool(gkeys) and keys == gkeys,", "return bool(gkeys) and bool(keys),", "key_set"), + # R2: `_die_with_parent` re-reads getppid() (the parent may die between the ownership check and the prctl), which makes the + # first check redundant for a claimant that is not the holder's child; the mutant must therefore remove BOTH occurrences. + ("G1 guard: parent pid unchecked", "align/utils/guard.py", + ["if os.getppid() != int(parent_pid):", " if os.getppid() != parent_pid:\n raise PreflightRefused(f\"inherit_heavy_job_lock: parent"], + ["if False:", " if False:\n raise PreflightRefused(f\"inherit_heavy_job_lock: parent"], "inherit"), + ("G2 guard: holder label unchecked", "align/utils/guard.py", "if f\"pid={int(parent_pid)}\" not in holder.split():", "if False:", "inherit"), + ("G3 guard: flock held not verified", "align/utils/guard.py", " raise PreflightRefused(\n f\"lock file names pid={parent_pid} but nobody", " return True\n raise PreflightRefused(\n f\"lock file names pid={parent_pid} but nobody", "inherit"), + ("E1 export: group not signalled after result", "scripts/qwen3_export_check.py", "os.killpg(proc.pid, sig)", "pass", "vllm_child"), +] + + +def main() -> int: + work = Path(tempfile.mkdtemp(dir=REPO / ".tmp", prefix="mutint_")) + results = [] + try: + for mid, rel, old, new, sel in MUTANTS: + tree = work / mid.split()[0] + shutil.copytree(REPO, tree, ignore=shutil.ignore_patterns(".tmp", ".venv", ".venv-rollout", ".cache", "__pycache__", ".git", "hf_cache"), dirs_exist_ok=True) + src = (tree / rel).read_text() + pairs = list(zip(old, new)) if isinstance(old, (list, tuple)) else [(old, new)] + if any(src.count(o) != 1 for o, _ in pairs): + results.append({"mutant": mid, "status": "BAD_MUTANT", "count": [src.count(o) for o, _ in pairs]}) + continue + for o, n in pairs: + src = src.replace(o, n) + (tree / rel).write_text(src) + env = dict(os.environ, PYTHONPATH=str(tree)) + r = subprocess.run([str(REPO / ".venv/bin/python"), "-m", "pytest", "tests/test_p2_integration.py", "-q", "-x", "-k", sel, + "-p", "no:cacheprovider"], cwd=tree, capture_output=True, text=True, env=env, timeout=600) + results.append({"mutant": mid, "status": "killed" if r.returncode != 0 else "SURVIVED", "tail": r.stdout.strip().splitlines()[-1:]}) + print(results[-1], flush=True) + shutil.rmtree(tree, ignore_errors=True) + finally: + shutil.rmtree(work, ignore_errors=True) + bad = [r for r in results if r["status"] != "killed"] + print(json.dumps({"mutants": len(results), "killed": len(results) - len(bad), "not_killed": bad}, indent=1)) + return 1 if bad else 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/code/scripts/p2/mutation_check_r2.py b/code/scripts/p2/mutation_check_r2.py new file mode 100644 index 0000000000000000000000000000000000000000..82fa3ebdc12331a8590d3d70f6f8740593bd5945 --- /dev/null +++ b/code/scripts/p2/mutation_check_r2.py @@ -0,0 +1,94 @@ +#!/usr/bin/env python +"""Mutation check for the reviewer-R2 fixes: each mutant reverts ONE fix in a private copy of the tree and +tests/test_p2_r2_review.py must go red. + + .venv/bin/python scripts/p2/mutation_check_r2.py + +Same method as scripts/p2/mutation_check_integration.py (scratch tree under .tmp; the working tree is never +modified). Exit status 1 if any mutant survives or any mutation does not apply exactly once. +""" + +from __future__ import annotations + +import json +import os +import shutil +import subprocess +import sys +import tempfile +from pathlib import Path + +REPO = Path(__file__).resolve().parents[2] + +MUTANTS = [ + # (id, file, old, new, test selector) + ("D1 guard: borrowed lock not tied to the holder", "align/utils/guard.py", + " _die_with_parent(int(parent_pid))\n", "", "borrowed"), + ("D2 guard: pdeathsig failure ignored", "align/utils/guard.py", + " if rc != 0:\n raise PreflightRefused(\n f\"inherit_heavy_job_lock: prctl", " if False:\n raise PreflightRefused(\n f\"inherit_heavy_job_lock: prctl", "pdeathsig_failure"), + ("D3 guard: parent death before prctl ignored", "align/utils/guard.py", + " if os.getppid() != parent_pid:\n raise PreflightRefused(f\"inherit_heavy_job_lock: parent", " if False:\n raise PreflightRefused(f\"inherit_heavy_job_lock: parent", "parent_that_died"), + ("S1 supervisor: stop signal before child recorded ignored", "scripts/p2/launch_baseline.py", + " if self.stop:\n # A stop signal that landed between", " if False:\n # A stop signal that landed between", "stop_signal"), + ("S2 supervisor: zombie leader counted as alive", "scripts/p2/launch_baseline.py", + "if int(rest[2]) == pgid and rest[0] != \"Z\":", "if int(rest[2]) == pgid:", "zombie"), + ("S3 supervisor: group liveness ignores non-leader members", "scripts/p2/launch_baseline.py", + "if int(rest[2]) == pgid and rest[0] != \"Z\":", "if int(entry) == pgid and rest[0] != \"Z\":", "live_member"), + ("R1 lean_gen: model_rev not compared on resume", "extras/vllm_rollout/lean_gen.py", + "if r.get(\"model_rev\") != model_rev:", "if False:", "another_models_output"), + ("R2 lean_gen: seed not compared on resume", "extras/vllm_rollout/lean_gen.py", + "elif r.get(\"sampling\", {}).get(\"seed\") != want:", "elif False:", "base_seed"), + ("R3 lean_gen: sampling params not compared on resume", "extras/vllm_rollout/lean_gen.py", + "elif r.get(\"sampling\", {}).get(\"temperature\") != temperature or r.get(\"sampling\", {}).get(\"top_p\") != top_p:", "elif False:", "sampling_parameters"), + ("R4 lean_gen: conflict count truncated", "extras/vllm_rollout/lean_gen.py", + " if n_bad > len(bad):", " if False:", "bounded"), + ("R5 lean_gen: prompt module change ignored", "extras/vllm_rollout/lean_gen.py", + "if s.get(\"lean_prompts_sha256\") != prompts_sha256})", "if False})", "prompt_module_edited"), + ("R6 lean_gen: resume check not wired into main", "extras/vllm_rollout/lean_gen.py", + " conflicts = resume_conflicts(a.out, rev, a.seed)\n", " conflicts = []\n", "main_refuses"), + ("P1 sft.py: repo root not put on sys.path", "scripts/sft.py", + "sys.path.insert(0, str(Path(__file__).resolve().parent.parent))\n", "", "sft_script"), + ("H1 shm sweep: segments a live process holds are removed", "scripts/p2/launch_baseline.py", + "or path in held:", "or False:", "sweep_removes"), + ("H2 shm sweep: no time bound (other jobs' old segments removed)", "scripts/p2/launch_baseline.py", + "or st.st_mtime < since", "or False", "sweep_removes"), + ("H3 shm sweep: any file name removed", "scripts/p2/launch_baseline.py", + "if not re.fullmatch(SHM_NAME_RE, name):", "if False:", "sweep_removes"), + ("H4 shm sweep: not run after the child exits", "scripts/p2/launch_baseline.py", + " gone, nbytes = sweep_orphan_shm(started_at)\n", " gone, nbytes = [], 0\n", "run_child_sweeps"), + ("T1 conftest: a held heavy-job lock is not seen by gpu tests", "tests/conftest.py", + " return None if f\"pid={os.getpid()}\" in label.split() else (label or \"unknown holder\")", " return None", "gpu_test_guard"), + ("T2 conftest: own lock blocks own tests", "tests/conftest.py", + "None if f\"pid={os.getpid()}\" in label.split() else (label or \"unknown holder\")", "(label or \"unknown holder\")", "gpu_test_guard"), +] + + +def main() -> int: + (REPO / ".tmp").mkdir(exist_ok=True) + work = Path(tempfile.mkdtemp(dir=REPO / ".tmp", prefix="mutr2_")) + results = [] + try: + for mid, rel, old, new, sel in MUTANTS: + tree = work / mid.split()[0] + shutil.copytree(REPO, tree, ignore=shutil.ignore_patterns(".tmp", ".venv", ".venv-rollout", ".cache", "__pycache__", ".git", "hf_cache"), dirs_exist_ok=True) + src = (tree / rel).read_text() + if src.count(old) != 1: + results.append({"mutant": mid, "status": "BAD_MUTANT", "count": src.count(old)}) + print(results[-1], flush=True) + continue + (tree / rel).write_text(src.replace(old, new)) + env = dict(os.environ, PYTHONPATH=str(tree)) + r = subprocess.run([str(REPO / ".venv/bin/python"), "-m", "pytest", "tests/test_p2_r2_review.py", "-q", "-x", "-k", sel, + "-p", "no:cacheprovider"], cwd=tree, capture_output=True, text=True, env=env, timeout=600) + results.append({"mutant": mid, "status": "killed" if r.returncode == 1 else ("SURVIVED" if r.returncode == 0 else f"BAD_SELECTOR_rc{r.returncode}"), "tail": r.stdout.strip().splitlines()[-1:]}) + print(results[-1], flush=True) + shutil.rmtree(tree, ignore_errors=True) + finally: + shutil.rmtree(work, ignore_errors=True) + bad = [r for r in results if r["status"] != "killed"] + print(json.dumps({"mutants": len(results), "killed": len(results) - len(bad), "not_killed": bad}, indent=1)) + return 1 if bad else 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/code/scripts/p2/p2_common.py b/code/scripts/p2/p2_common.py new file mode 100644 index 0000000000000000000000000000000000000000..2a956b7200fde0d062e7589915cdd18241b1e12d --- /dev/null +++ b/code/scripts/p2/p2_common.py @@ -0,0 +1,388 @@ +"""Shared helpers for the P2 data / verification / evaluation scripts (stdlib + numpy). + +Loaded BY PATH from the sibling scripts (`scripts/` is not a package and the dependency +firewall rejects an import it cannot place in stdlib / allowed / align), the same way +`scripts/lean_ceiling_test.py` loads its case tables. + +Contents: the three JSONL record schemas of the P2 contract with strict validators, an +append-only JSONL writer that survives a SIGKILL mid-line, and the statement / proof +normalisations used for decontamination and for "distinct proof" selection. +""" + +from __future__ import annotations + +import hashlib +import importlib.util +import json +import os +import re +import threading +from pathlib import Path +from typing import Any, Iterable, Iterator, Optional + +REPO = Path(__file__).resolve().parents[2] + +#: The header Goedel's DeepSeekCoTHandler.extrac_code prepends to every extracted proof +#: (reference-repos/Goedel-Prover-V2/src/utils.py). A pool statement with any other header +#: can never be matched by a generated proof (strict verifier: header token-equality), so it +#: is unwinnable and is dropped at build time instead of becoming a silent 0. +GOEDEL_HEADER = "import Mathlib\nimport Aesop\n\nset_option maxHeartbeats 0\n\nopen BigOperators Real Nat Topology Rat\n\n" + +PROBLEM_FIELDS = ("problem_id", "source", "split", "formal_statement", "informal_prefix", "statement_sha") +GENERATION_FIELDS = ( + "problem_id", "sample_idx", "round", "cap_tokens", "prompt_ids", "completion_ids", "prompt_text", + "completion_text", "finish_reason", "lean_code", "messages", "model_path", "model_rev", "sampling", + "parent", +) +KEY_FIELDS = ("problem_id", "sample_idx", "round", "cap_tokens") + + +class ContractError(ValueError): + """A record violates the P2 shared contract. Always loud: a lenient reader is how a + cap-mislabelled or stale-verdict row ends up in a table.""" + + +# --------------------------------------------------------------------------- hashing + + +def sha256_text(s: str) -> str: + return hashlib.sha256(s.encode("utf-8")).hexdigest() + + +def sha256_file(path: str | os.PathLike, chunk: int = 4 << 20) -> str: + h = hashlib.sha256() + with open(path, "rb") as f: + while True: + b = f.read(chunk) + if not b: + break + h.update(b) + return h.hexdigest() + + +# --------------------------------------------------------------------------- jsonl io + + +def iter_jsonl(path: str | os.PathLike) -> Iterator[dict]: + """Strict JSONL reader: a malformed line raises with its line number (no skipping).""" + with open(path, encoding="utf-8") as f: + for i, line in enumerate(f, 1): + if not line.strip(): + continue + try: + obj = json.loads(line) + except json.JSONDecodeError as e: + raise ContractError(f"{path}:{i}: malformed JSON ({e.msg})") from e + if not isinstance(obj, dict): + raise ContractError(f"{path}:{i}: expected a JSON object, got {type(obj).__name__}") + yield obj + + +def write_jsonl_atomic(path: str | os.PathLike, rows: Iterable[dict]) -> int: + """tmp + fsync + rename. A half-written problem file looks complete; never leave one.""" + p = Path(path) + p.parent.mkdir(parents=True, exist_ok=True) + tmp = p.with_name(p.name + f".tmp{os.getpid()}") + n = 0 + try: + with open(tmp, "w", encoding="utf-8") as f: + for r in rows: + f.write(json.dumps(r, ensure_ascii=False) + "\n") + n += 1 + f.flush() + os.fsync(f.fileno()) + os.replace(tmp, p) + finally: + if tmp.exists(): + tmp.unlink() + return n + + +def repair_torn_tail(path: str | os.PathLike) -> int: + """Make an append-only JSONL safe to resume: if the last line was cut off by a kill, drop + it. Returns the number of bytes removed. A MIDDLE line that does not parse is not a torn + write (appends only ever damage the tail), so that raises when the file is read.""" + p = Path(path) + if not p.exists() or p.stat().st_size == 0: + return 0 + with open(p, "rb") as f: + data = f.read() + if data.endswith(b"\n"): + return 0 + cut = data.rfind(b"\n") + 1 # 0 when the file has a single unterminated line + tail = data[cut:] + try: + json.loads(tail.decode("utf-8")) + with open(p, "ab") as f: # complete object that lost only its newline + f.write(b"\n") + return 0 + except (json.JSONDecodeError, UnicodeDecodeError): + with open(p, "r+b") as f: + f.truncate(cut) + f.flush() + os.fsync(f.fileno()) + return len(tail) + + +class AppendJsonl: + """Thread-safe append-only JSONL writer; every `append` is flushed and fsynced so a + SIGKILL loses at most the record being written (which `repair_torn_tail` discards).""" + + def __init__(self, path: str | os.PathLike) -> None: + self.path = Path(path) + self.path.parent.mkdir(parents=True, exist_ok=True) + self.torn_bytes_removed = repair_torn_tail(self.path) + self._f = open(self.path, "ab") + self._lock = threading.Lock() + + def append(self, rec: dict) -> None: + line = (json.dumps(rec, ensure_ascii=False) + "\n").encode("utf-8") + with self._lock: + self._f.write(line) + self._f.flush() + os.fsync(self._f.fileno()) + + def close(self) -> None: + self._f.close() + + def __enter__(self) -> "AppendJsonl": + return self + + def __exit__(self, *exc) -> None: + self.close() + + +# --------------------------------------------------------------------------- contract + + +def gen_key(rec: dict) -> tuple: + return (rec["problem_id"], int(rec["sample_idx"]), int(rec["round"]), int(rec["cap_tokens"])) + + +def validate_problem(rec: dict) -> dict: + missing = [k for k in PROBLEM_FIELDS if k not in rec] + if missing: + raise ContractError(f"problem {rec.get('problem_id')!r} lacks fields {missing}") + for k in ("problem_id", "source", "split", "formal_statement", "statement_sha"): + if not isinstance(rec[k], str) or not rec[k].strip(): + raise ContractError(f"problem field {k!r} must be a non-empty string, got {rec[k]!r}") + if rec["informal_prefix"] is not None and not isinstance(rec["informal_prefix"], str): + raise ContractError("informal_prefix must be a string or null") + return rec + + +def effective_max_tokens(rec: dict) -> int: + """What the sampler was actually allowed to emit. `cap_tokens` is the NOMINAL cap every + number is labelled with; `sampling.max_tokens` is smaller only when the context window, + not the cap, ended the sample (`context_clipped`): at cap 40960 with a 40960-token window + every sample loses its prompt length (~250-600 tokens).""" + mt = (rec.get("sampling") or {}).get("max_tokens") + return int(mt) if mt is not None else int(rec["cap_tokens"]) + + +def validate_generation(rec: dict) -> dict: + """Shape checks on one Generation record. Cross-record checks live in the callers.""" + missing = [k for k in GENERATION_FIELDS if k not in rec] + pid = rec.get("problem_id") + if missing: + raise ContractError(f"generation {pid!r} lacks fields {missing}") + for k in ("sample_idx", "round", "cap_tokens"): + if not isinstance(rec[k], int) or isinstance(rec[k], bool) or rec[k] < 0: + raise ContractError(f"generation {pid!r}: {k} must be a non-negative int, got {rec[k]!r}") + if rec["cap_tokens"] < 1: + raise ContractError(f"generation {pid!r}: cap_tokens must be >= 1") + if rec["finish_reason"] not in ("stop", "length"): + raise ContractError(f"generation {pid!r}: finish_reason {rec['finish_reason']!r} not in stop|length") + for k in ("prompt_ids", "completion_ids"): + v = rec[k] + if not isinstance(v, list) or not v or not all(isinstance(t, int) and not isinstance(t, bool) for t in v): + raise ContractError(f"generation {pid!r}: {k} must be a non-empty list of ints") + eff = effective_max_tokens(rec) + if eff > rec["cap_tokens"]: + raise ContractError( + f"generation {pid!r}: sampling.max_tokens={eff} exceeds the stated cap_tokens={rec['cap_tokens']} -- the " + "cap label understates what the sampler was allowed, and every number must state its true cap" + ) + if len(rec["completion_ids"]) > eff: + raise ContractError( + f"generation {pid!r}: {len(rec['completion_ids'])} completion tokens exceed the sampler's max_tokens={eff} " + f"(cap_tokens={rec['cap_tokens']}) -- the cap label is wrong, and every number must state its true cap" + ) + if rec["finish_reason"] == "length" and len(rec["completion_ids"]) != eff: + raise ContractError( + f"generation {pid!r}: finish_reason=length but {len(rec['completion_ids'])} tokens != max_tokens {eff} " + "(the run was cut by a different limit than the one it is labelled with)" + ) + if rec["lean_code"] is not None and not isinstance(rec["lean_code"], str): + raise ContractError(f"generation {pid!r}: lean_code must be a string or null") + parent = rec["parent"] + if rec["round"] == 0 and parent is not None: + raise ContractError(f"generation {pid!r}: round 0 cannot have a parent") + if rec["round"] > 0: + if not (isinstance(parent, dict) and {"problem_id", "sample_idx", "round"} <= set(parent)): + raise ContractError(f"generation {pid!r}: round {rec['round']} needs a parent {{problem_id,sample_idx,round}}") + if parent["problem_id"] != pid or parent["round"] != rec["round"] - 1: + raise ContractError(f"generation {pid!r}: parent {parent} is not the previous round of the same problem") + return rec + + +def load_problems(path: str | os.PathLike) -> dict[str, dict]: + out: dict[str, dict] = {} + for rec in iter_jsonl(path): + validate_problem(rec) + if rec["problem_id"] in out: + raise ContractError(f"{path}: duplicate problem_id {rec['problem_id']!r}") + out[rec["problem_id"]] = rec + if not out: + raise ContractError(f"{path}: no problems") + return out + + +def code_sha(lean_code: Optional[str]) -> Optional[str]: + return None if lean_code is None else sha256_text(lean_code) + + +# --------------------------------------------------------------------------- statements + + +_BY = re.compile(r":=\s*by\b") + + +def normalize_formal_statement(raw: str) -> str: + """Goedel's statement normalisation (`lean4_code.split(":= by")[0] + ":= by sorry"`), made + safe. Goedel's split silently TRUNCATES a statement that contains `:= by` earlier (for + example a `(h : x = by norm_num)` binder) and so changes the problem; here that raises. A + line break between `:=` and `by` (`:=\\n by sorry`) is accepted and canonicalised, which + Goedel's literal split cannot handle at all.""" + s = raw.strip("\n") + hits = list(_BY.finditer(s)) + if len(hits) != 1: + raise ContractError(f"statement must contain exactly one ':= by', found {len(hits)}") + m = hits[0] + tail = s[m.end() :] + if tail.strip() not in ("", "sorry"): + raise ContractError(f"text after ':= by' is not a bare sorry: {tail.strip()[:60]!r}") + return s[: m.start()] + ":= by sorry" + + +def _strict(): + from align.verify.lean import strict # lazy: keeps `import p2_common` cheap + + return strict + + +def statement_key(statement: str) -> tuple[str, str, tuple[str, ...]]: + """(theorem name, header text, signature tokens) of a formal statement, via the SAME parser + the verifier uses. Raises ContractError when the verifier could not use the statement.""" + p = _strict().parse_statement(statement) + if not p.ok: + raise ContractError(f"unsupported statement: {p.problem}") + return p.name, p.header_text, tuple(p.sig_toks) + + +def statement_sha(statement: str) -> str: + """sha256 of the whitespace- and comment-normalised signature (lexer tokens joined by one + space). The theorem NAME is deliberately excluded: a renamed copy is the same problem.""" + _, _, sig = statement_key(statement) + return sha256_text(" ".join(sig)) + + +_KEEP_LOWER = frozenset( + "fun forall exists if then else let have by sorry at with from in do match return where " + "sin cos tan exp log sqrt abs max min id fst snd succ pred gcd lcm choose fact floor ceil " + "card sum prod range filter map deriv logb arctan sinh cosh cexp nnabs dist norm " + "theorem lemma".split() +) + + +def alpha_sha(statement: str) -> str: + """Like `statement_sha` but invariant to consistent renaming of local (lowercase, undotted) + identifiers. Deliberately conservative: it also equates `sin x = 1` with `cos y = 1`; a + false positive here costs one pool statement, a false negative costs a contaminated + evaluation.""" + _, _, sig = statement_key(statement) + mp: dict[str, str] = {} + out = [] + for t in sig: + if re.fullmatch(r"[a-z_][A-Za-z0-9_'₀-₉]*", t) and t not in _KEEP_LOWER: + out.append(mp.setdefault(t, f"v{len(mp)}")) + else: + out.append(t) + return sha256_text(" ".join(out)) + + +_LEAN_PROMPTS = None + + +def _lean_prompts(): + """`extras/vllm_rollout/lean_prompts.py` (the byte-faithful port of Goedel's extraction), loaded + BY PATH: it is stdlib-only at import and is the single source of truth for what the model's + code block means, so the SFT selection must not grow a second, slightly different regex.""" + global _LEAN_PROMPTS + if _LEAN_PROMPTS is None: + spec = importlib.util.spec_from_file_location("p2_lean_prompts", REPO / "extras" / "vllm_rollout" / "lean_prompts.py") + mod = importlib.util.module_from_spec(spec) + spec.loader.exec_module(mod) + _LEAN_PROMPTS = mod + return _LEAN_PROMPTS + + +def displayed_signature(completion_text: str) -> Optional[tuple[str, ...]]: + """Signature tokens of the theorem the MODEL WROTE in the code block Goedel's extractor takes + (last ```lean4 fence, first `theorem ... := by`), i.e. BEFORE `problem_check` swaps the original + statement in. None when there is no such theorem or the verifier's parser cannot read it. + + Why this exists: `problem_check` replaces whatever statement the model wrote with the original, + so a sample whose displayed theorem is `v = 66` but whose tactics happen to prove the original + `v = 65` is ACCEPTED, and its completion then teaches the model to state the wrong theorem. + Goedel's evaluation protocol credits it (and so does ours, to stay comparable); a TRAINING row + must not.""" + lp = _lean_prompts() + extracted = lp.DeepSeekCoTHandler().extrac_code(completion_text) + if extracted == "None": + return None + proof = lp.remove_comments(extracted) + span = lp.return_theorem_to_replace(proof) + if span is None: + return None + shown = re.sub(r":=\s*by$", ":= by sorry", proof[span[0] : span[1]].strip()) + parsed = _strict().parse_statement(shown) + return tuple(parsed.sig_toks) if parsed.ok else None + + +def displayed_statement_matches(formal_statement: str, completion_text: str) -> bool: + """True iff the theorem the model displayed has the same signature tokens as `formal_statement` + (theorem NAME ignored: `problem_check` renames it anyway and a renamed copy is the same problem).""" + shown = displayed_signature(completion_text) + return shown is not None and shown == statement_key(formal_statement)[2] + + +def norm_name(name: str) -> str: + return re.sub(r"[^a-z0-9]", "", name.lower()) + + +def informal_tokens(text: str) -> list[str]: + return re.findall(r"[a-z0-9]+", text.lower()) + + +def ngrams(tokens: list[str], n: int) -> set[tuple[str, ...]]: + return {tuple(tokens[i : i + n]) for i in range(len(tokens) - n + 1)} if len(tokens) >= n else set() + + +def proof_hash(lean_code: str) -> str: + """Hash of a proof invariant to whitespace and comments (lexer tokens), used to prefer + DISTINCT proofs. Falls back to whitespace-collapsed text when the lexer gives up.""" + lx = _strict().lex(lean_code) + if lx.broken: + return sha256_text(" ".join(lean_code.split())) + return sha256_text(" ".join(t.text for t in lx.toks)) + + +def compile_view(full_code: str) -> str: + """Goedel's `compile.py:handle`: drop every line whose stripped text starts with import / + set_option / open. Goedel compiles THIS text in a REPL environment that already ran the + header, so the line numbers of its error dicts index this text, and `get_error_str` must be + given this text (not `full_code`). Identical to `extras/vllm_rollout/lean_prompts.compile_view` + (a test pins the two together).""" + lines = full_code.split("\n") + return "\n".join(l for l in lines if not (l.strip().startswith("import") or l.strip().startswith("set_option") or l.strip().startswith("open"))) diff --git a/code/scripts/p2/pipeline_gen_verify.py b/code/scripts/p2/pipeline_gen_verify.py new file mode 100644 index 0000000000000000000000000000000000000000..2c50d921e997819a8ee20ef53120cb6d5c27acb1 --- /dev/null +++ b/code/scripts/p2/pipeline_gen_verify.py @@ -0,0 +1,224 @@ +"""Generation and verification as ONE heavy job, overlapped. + +THE IDLE-GPU DEFECT. The P2 queue ran `lean_gen.py` (GPU) and then `verify_generations.py` +(CPU, Lean) back to back. Verification of a 1,952-sample miniF2F run took 1.43 h and the pool +round's would take ~12 h; the four A10Gs sat at 0% for all of it. Running the verifier as a +second job beside the generator is not an option either: /apps/CLAUDE.md rule 2 forbids two heavy +jobs at once, and the repo's flock enforces it. + +So this supervisor takes the heavy-job lock ONCE and runs both phases as children that inherit it +(`--inherit-lock`, the mechanism scripts/p2/launch_baseline.py already uses): the generator owns +the GPUs, and every `--every` seconds a verification pass checks the COMPLETE lines written so far. +A pass works on a snapshot that stops at the last newline, because the generator appends while we +read and the strict JSONL reader (p2_common.iter_jsonl) rightly refuses a half-written line. +Verification is keyed and resumable, so each pass only verifies what is new, and the final pass +after the generator exits covers the tail. One lock, one job, both resources busy. + +Exit codes: 0 both phases complete; the generator's or verifier's non-zero code otherwise +(143 when stopped by a signal); 4 when the generator exited 0 but its manifest does not say +`complete` (a partial sample set must never read as a finished stage -- U12-2); 5 when GPU memory +did not drain after the generator (an orphan holds it -- U12-3). Re-running the same command +resumes both phases. + +After EVERY generator exit (clean, failed or stopped) the generator's process group is reaped by its +exact pgid, orphan `psm_*` /dev/shm segments of this phase are swept, and GPU memory is waited on +until drained, using scripts/p2/launch_baseline.py's helpers (vLLM's EngineCore is documented to +outlive its parent: 19 GB of GPU memory, 0.19-1.4 GB of /dev/shm). The next GPU stage (sft.py, +export, eval generation) therefore starts on a clean host even though this supervisor releases the +lock on exit. +""" + +from __future__ import annotations + +import argparse +import json +import os +import signal +import subprocess +import sys +import time +from pathlib import Path + +REPO = Path(__file__).resolve().parents[2] +sys.path.insert(0, str(REPO)) + +PYR = str(REPO / ".venv-rollout" / "bin" / "python") +PY = str(REPO / ".venv" / "bin" / "python") + + +_LB = None + + +def launch_baseline(): + """scripts/p2/launch_baseline.py, loaded by path (the repo idiom): its kill_group / sweep_orphan_shm / + wait_gpu_drained are the reviewed implementation; a second copy would drift.""" + global _LB + if _LB is None: + import importlib.util + + spec = importlib.util.spec_from_file_location("_p2_launch_baseline", REPO / "scripts/p2/launch_baseline.py") + _LB = importlib.util.module_from_spec(spec) + spec.loader.exec_module(_LB) + return _LB + + +def generation_complete(gen_out: str) -> tuple[bool, str]: + """Did the LAST lean_gen session on `gen_out` finish every planned sample? (its manifest says so)""" + mp = gen_out + ".manifest.json" + if not os.path.exists(mp): + return False, "no manifest" + with open(mp, encoding="utf-8") as f: + sessions = json.load(f).get("sessions", []) + if not sessions: + return False, "manifest has no sessions" + st = sessions[-1].get("status") + return st == "complete", f"last lean_gen session status {st!r}" + + +def reap_after_gen(proc, started_at: float, events, lb=None, log=None) -> None: + """Kill survivors of the generator's process group, sweep its /dev/shm litter, wait for the GPUs to drain.""" + lb = lb or launch_baseline() + log = log or (lambda **kw: log_event(events, **kw)) + lb.Runner.kill_group(proc, 15.0) + gone, nbytes = lb.sweep_orphan_shm(started_at) + if gone: + log(event="shm_swept", segments=len(gone), bytes=nbytes) + lb.wait_gpu_drained() + log(event="gpu_drained") + + +def snapshot_complete_lines(src: Path, dst: Path) -> int: + """Copy `src` up to and including its last newline into `dst` (atomically). Returns the + number of complete lines. A trailing partial line -- the generator mid-append -- is left out.""" + if not src.exists(): + return 0 + data = src.read_bytes() + cut = data.rfind(b"\n") + 1 + tmp = dst.with_suffix(dst.suffix + ".tmp") + tmp.write_bytes(data[:cut]) + os.replace(tmp, dst) + return data[:cut].count(b"\n") + + +def log_event(path: Path, **kw) -> None: + kw["t"] = time.strftime("%Y-%m-%d %H:%M:%S") + with open(path, "a", encoding="utf-8") as f: + f.write(json.dumps(kw) + "\n") + + +def main(argv=None) -> int: + ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) + ap.add_argument("--run-dir", required=True) + ap.add_argument("--gen-args", required=True, help="arguments for lean_gen.py, as ONE quoted string (must include --out)") + ap.add_argument("--problems", required=True) + ap.add_argument("--verify-out", required=True) + ap.add_argument("--workers", type=int, default=8) + ap.add_argument("--every", type=float, default=300.0, help="seconds between verification passes") + ap.add_argument("--feedback", action="store_true") + ap.add_argument("--label", default="p2-gen-verify") + a = ap.parse_args(argv) + + from align.utils import guard + + run = Path(a.run_dir) + run.mkdir(parents=True, exist_ok=True) + events = run / "pipeline.jsonl" + gen_args = a.gen_args.split() + gen_out = Path(gen_args[gen_args.index("--out") + 1]) + snap = run / (gen_out.stem + ".snapshot.jsonl") + + guard.acquire_heavy_job_lock(a.label, blocking=True) + guard.preflight_host_check() # AFTER the wait: a lock wait can last hours, a stale preflight proves nothing + me = os.getpid() + log_event(events, event="lock_acquired", pid=me) + + stop = {"sig": None} + + def on_signal(sig, _frm): + stop["sig"] = sig + + signal.signal(signal.SIGTERM, on_signal) + signal.signal(signal.SIGINT, on_signal) + + gen_log = open(run / "gen.log", "ab") + started_at = time.time() - 1.0 + gen = subprocess.Popen( + [PYR, str(REPO / "extras/vllm_rollout/lean_gen.py"), *gen_args, "--inherit-lock", str(me)], + stdout=gen_log, stderr=subprocess.STDOUT, start_new_session=True, + ) + log_event(events, event="gen_start", pid=gen.pid, args=gen_args) + + def verify_pass(source: Path) -> int: + cmd = [PY, str(REPO / "scripts/p2/verify_generations.py"), "--generations", str(source), + "--problems", a.problems, "--out", a.verify_out, "--workers", str(a.workers), + "--inherit-lock", str(me)] + if a.feedback: + cmd.append("--feedback") + with open(run / "verify.log", "ab") as vl: + v = subprocess.Popen(cmd, stdout=vl, stderr=subprocess.STDOUT, start_new_session=True) + while v.poll() is None: + if stop["sig"] is not None: + os.killpg(v.pid, signal.SIGTERM) + v.wait() + break + time.sleep(2) + return v.returncode + + rc_v = 0 + last_lines = -1 + while gen.poll() is None and stop["sig"] is None: + n = snapshot_complete_lines(gen_out, snap) + if n > last_lines and n > 0: + log_event(events, event="verify_pass", lines=n) + rc_v = verify_pass(snap) + log_event(events, event="verify_pass_end", lines=n, rc=rc_v) + if rc_v != 0 and stop["sig"] is None: + # a failing verifier must not cost the GPU work: keep generating, stop the + # overlapped passes, and let the final pass (or a re-run) surface the error + log_event(events, event="verify_overlap_disabled", rc=rc_v) + last_lines = float("inf") + rc_v = 0 + continue + last_lines = n + t_end = time.monotonic() + a.every + while time.monotonic() < t_end and gen.poll() is None and stop["sig"] is None: + time.sleep(5) + + if stop["sig"] is not None: + if gen.poll() is None: + os.killpg(gen.pid, signal.SIGTERM) + try: + gen.wait(timeout=120) + except subprocess.TimeoutExpired: + os.killpg(gen.pid, signal.SIGKILL) + gen.wait() + try: + reap_after_gen(gen, started_at, events) + except RuntimeError as e: + log_event(events, event="gpu_not_drained", error=str(e)) + log_event(events, event="stopped", signal=stop["sig"], verify_rc=rc_v) + return 143 + + rc_g = gen.wait() + log_event(events, event="gen_end", rc=rc_g) + try: + reap_after_gen(gen, started_at, events) + except RuntimeError as e: + log_event(events, event="gpu_not_drained", error=str(e)) + return rc_g or 5 + if rc_g != 0: + return rc_g + complete, why = generation_complete(gen_out) + if not complete: + log_event(events, event="gen_incomplete", detail=why) + return 4 + # final pass over the complete file (the generator has exited, so every line is whole) + rc_v = verify_pass(gen_out) + log_event(events, event="final_verify_end", rc=rc_v) + if snap.exists(): + snap.unlink() + return 0 if stop["sig"] is None and rc_v == 0 else (143 if stop["sig"] is not None else rc_v) + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/code/scripts/p2/prereg_hash.py b/code/scripts/p2/prereg_hash.py new file mode 100644 index 0000000000000000000000000000000000000000..b26c76c68af420a87ed617ebb3983ac6939bf568 --- /dev/null +++ b/code/scripts/p2/prereg_hash.py @@ -0,0 +1,67 @@ +#!/usr/bin/env python +"""Print the hash that goes to the external time-stamp store for the SOTA pre-registration. + + .venv/bin/python scripts/p2/prereg_hash.py # manifest + overall sha256 + .venv/bin/python scripts/p2/prereg_hash.py --json # machine-readable + .venv/bin/python scripts/p2/prereg_hash.py --check HEX # exit 0 iff HEX is the current overall hash + +The overall hash covers the registration text (docs/PREREG_SOTA.md) AND the analysis code that +implements it (paired statistics, the report script, the shared contract helpers, this script), +byte for byte, so the time-stamp commits to both what was promised and the code that will compute it. +Any edit to any of them changes the hash; the report prints the current hash next to the registered one. + +Where the hash is stored is a user decision (docs/IMPLEMENTATION_LANES.md); this script only computes it. +""" + +from __future__ import annotations + +import argparse +import hashlib +import json +import sys +from pathlib import Path + +REPO = Path(__file__).resolve().parents[2] + +#: Order is part of the registration. +FILES = ( + "docs/PREREG_SOTA.md", + "align/eval/paired.py", + "align/eval/proof_stats.py", + "scripts/p2/eval_report.py", + "scripts/p2/p2_common.py", + "scripts/p2/prereg_hash.py", +) + + +def compute(repo: Path = REPO, files=FILES) -> dict: + entries = [] + for rel in files: + p = Path(repo) / rel + if not p.is_file(): + raise FileNotFoundError(f"registration input missing: {rel}") + entries.append({"path": rel, "sha256": hashlib.sha256(p.read_bytes()).hexdigest()}) + manifest = "".join(f"{e['sha256']} {e['path']}\n" for e in entries) + return {"files": entries, "manifest": manifest, "sha256": hashlib.sha256(manifest.encode()).hexdigest()} + + +def main(argv=None) -> int: + ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) + ap.add_argument("--json", action="store_true") + ap.add_argument("--check", metavar="HEX") + a = ap.parse_args(argv) + r = compute() + if a.check is not None: + ok = a.check.strip().lower() == r["sha256"] + print("MATCH" if ok else f"MISMATCH (current {r['sha256']})") + return 0 if ok else 1 + if a.json: + print(json.dumps({"files": r["files"], "sha256": r["sha256"]}, indent=1)) + else: + print(r["manifest"], end="") + print(f"\nPREREG_OVERALL_SHA256: {r['sha256']}") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/code/scripts/p2/rl_lean.py b/code/scripts/p2/rl_lean.py new file mode 100644 index 0000000000000000000000000000000000000000..4ac536b4a5d17352d45e7b4a7abf097306d70222 --- /dev/null +++ b/code/scripts/p2/rl_lean.py @@ -0,0 +1,107 @@ +#!/usr/bin/env python +"""Single-turn on-policy RL for Lean provers (lane R). Library: align/rl/lean_rl.py, align/rl/lean_rl_run.py. + + # the iteration loop (CPU-only parent; spawns generation, verification and training children in series) + .venv/bin/python scripts/p2/rl_lean.py run configs/p2_rl_lean.toml \\ + --set run_dir="RUN" --set problems="POOL.jsonl" [--nproc 4] [--until K] [--dry-run] + + # one training iteration (what `run` launches under torchrun; also the Phase 0b probe, docs/RL_LEAN.md) + torchrun --standalone --nproc_per_node=4 scripts/p2/rl_lean.py train-iter CFG --iteration K [--set ...] + # K = -1 writes checkpoint 0 (fresh adapter, fresh optimizer) + + # build the loss-mode contrast on the SAME cached rollouts (K1): a second run with loss.mode="ei" + ... run CFG --set run_dir="RUN_EI" --set replay_from="RUN" --set 'loss.mode="ei"' + +Resumable: re-running `run` continues from the last committed checkpoint; every stage commits atomically. +The parent never takes the heavy-job lock itself: each child does (lean_gen --wait-lock, verify_generations when +workers > 2, train-iter on rank 0), so exactly one heavy child exists at any time. + +IMPORT ORDER IS LOAD-BEARING (as in scripts/sft.py): the guard runs before anything imports torch. +""" + +from __future__ import annotations + +import argparse +import json +import logging +import os +import sys +from pathlib import Path + +REPO = Path(__file__).resolve().parents[2] +sys.path.insert(0, str(REPO)) + +from align.utils.guard import apply_resource_guards # noqa: E402 + + +def parse_args(argv=None): + ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) + sub = ap.add_subparsers(dest="cmd", required=True) + for name in ("run", "train-iter"): + p = sub.add_parser(name) + p.add_argument("config") + p.add_argument("--set", dest="overrides", action="append", default=[], metavar="KEY=TOML_VALUE") + if name == "run": + p.add_argument("--nproc", type=int, default=4, help="training ranks (torchrun --nproc_per_node)") + p.add_argument("--until", type=int, default=None, help="stop after this many iterations (resume continues)") + p.add_argument("--dry-run", action="store_true", help="print iteration 0's child command lines, touch nothing") + else: + p.add_argument("--iteration", type=int, required=True, help="-1 = write checkpoint 0") + return ap.parse_args(argv) + + +def cmd_run(a) -> int: + from align.rl import lean_rl as L + from align.rl import lean_rl_run as R + + cfg = L.load_config(a.config, a.overrides) + if not cfg.run_dir or not cfg.problems: + raise SystemExit('run_dir and problems must be set (--set \'run_dir="..."\' --set \'problems="..."\')') + gen, ver = R.LeanGenCliGenerator(cfg), R.VerifyCliVerifier(cfg) + trainer = R.TorchrunTrainer(cfg, os.path.abspath(a.config), a.nproc, a.overrides) + if a.dry_run: + d = R.iter_dir(cfg.run_dir, 0) + print(json.dumps({ + "fingerprint": cfg.fingerprint(), + "init": R.torchrun_command(trainer.cfg_path, -1, a.nproc, a.overrides), + "gen": gen.command(0, d, os.path.join(d, "problems.jsonl"), os.path.join(R.ckpt_path(cfg.run_dir, 0), "adapter"), + os.path.join(R.ckpt_path(cfg.run_dir, 0), "merged") if cfg.export_merged else None, + R.derive_seed(cfg.seed, 0)), + "verify": ver.command(d, os.path.join(d, "problems.jsonl")), + "train": R.torchrun_command(trainer.cfg_path, 0, a.nproc, a.overrides)}, indent=1)) + return 0 + ledger = R.run_loop(cfg, gen, ver, trainer, until=a.until) + print(json.dumps({"iterations_committed": len(ledger), "cumulative": ledger[-1]["cumulative"] if ledger else {}})) + return 0 + + +def cmd_train_iter(a) -> int: + from align.rl import lean_rl as L + + cfg = L.load_config(a.config, a.overrides) + apply_resource_guards(cpu_threads=8, heavy=True, label=f"rl-lean-train-{a.iteration}") + logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(name)s: %(message)s") + from align.utils.dist import init_distributed + + init_distributed(run_dir=os.path.join(cfg.run_dir, "nccl")) + from align.rl import lean_rl_run as R + + trainer = R.InProcessTrainer(cfg, lambda: R.build_real_policy(cfg)) + if a.iteration < 0: + trainer.init() + else: + R.clean_partial(cfg.run_dir) if R._rank() == 0 else None + trainer.train(a.iteration) + return 0 + + +def main(argv=None) -> int: + a = parse_args(argv) + if a.cmd == "run": + apply_resource_guards(cpu_threads=2, heavy=False, label="rl-lean-orchestrator") + return cmd_run(a) + return cmd_train_iter(a) + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/code/scripts/p2/run_round.sh b/code/scripts/p2/run_round.sh new file mode 100644 index 0000000000000000000000000000000000000000..9eee8fe1a5f64d52cf56b669e5740ca4160fe0b0 --- /dev/null +++ b/code/scripts/p2/run_round.sh @@ -0,0 +1,133 @@ +#!/usr/bin/env bash +# P2: one full expert-iteration round, as an explicit sequence of RESUMABLE steps. +# +# scripts/p2/run_round.sh [...] +# scripts/p2/run_round.sh all # in order; stops at the first failure +# scripts/p2/run_round.sh print # show every command, run nothing +# +# Steps (each is one process that takes and releases the repo heavy-job lock itself; NEVER run +# two at once, and never wrap the whole script in a lock: the children would deadlock on it): +# +# problems eval sets + decontaminated training pool + elaboration filter +# base_eval baseline: generate (cap 16384, n=8) + verify on miniF2F and MOBench +# pool_gen round-0 generation on the training pool +# pool_verify verify round 0 (with --feedback: correction prompts need the Lean errors) +# corr_gen correction round 1 from the failed round-0 samples +# corr_verify verify round 1 +# sft_data build the SFT shard set (exact sampled tokens, completion-only loss) +# train P2-B's trainer (command supplied via $P2_TRAIN_CMD; not defined here) +# export P2-B's HF export of the trained weights (command via $P2_EXPORT_CMD) +# post_eval the same evaluation as base_eval on the trained weights +# report eval_report.py: pass@k, CIs, paired McNemar, correction lift +# +# Required env: RUN (run name); MAX_SEQ_LEN for sft_data. Optional: CAP (16384), N_EVAL (8), N_POOL (8), WORKERS (8), +# TP (4), CAP_REF (40960, set to also run the reference-cap baseline). +# Host rules (/apps/CLAUDE.md) are enforced by the python entry points (preflight + flock + +# <= 8 Lean workers + scratch under /apps); this script only sequences them. +set -euo pipefail + +REPO="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +cd "$REPO" +: "${RUN:?set RUN=}" +CAP="${CAP:-16384}" +N_EVAL="${N_EVAL:-8}" +N_POOL="${N_POOL:-8}" +WORKERS="${WORKERS:-8}" +TP="${TP:-4}" +D="$REPO/.tmp/p2/$RUN" +PY="$REPO/.venv/bin/python" +PYR="$REPO/.venv-rollout/bin/python" +export TMPDIR="$D/scratch" HF_HOME="${HF_HOME:-/apps/development/rlhf/hf_cache}" HF_HUB_DISABLE_XET=1 +MODEL_BASE="${MODEL_BASE:-/apps/development/rlhf/hf_cache/hub/models--Goedel-LM--Goedel-Prover-V2-8B/snapshots/dfd02e6271a58375dfbf3ece0175277cf6b6a89a}" +MODEL_SFT="${MODEL_SFT:-$D/export}" +PRINT_ONLY=0 + +run() { # print the command; execute unless printing + echo "+ $*" >&2 + [ "$PRINT_ONLY" = 1 ] || "$@" +} + +gen() { # gen [extra...] + local model="$1" probs="$2" out="$3" n="$4" cap="$5"; shift 5 + run "$PYR" extras/vllm_rollout/lean_gen.py --model "$model" --problems "$probs" --out "$out" --n "$n" \ + --cap-tokens "$cap" --round 0 --tp "$TP" --seed 0 --wait-lock "$@" +} +verify() { # verify [extra...] + local g="$1" p="$2" o="$3"; shift 3 + run "$PY" scripts/p2/verify_generations.py --generations "$g" --problems "$p" --out "$o" --workers "$WORKERS" "$@" +} + +step_problems() { + mkdir -p "$D/problems" "$TMPDIR" + run "$PY" scripts/p2/build_problems.py --phase eval --out-dir "$D/problems" + run "$PYR" scripts/p2/build_problems.py --phase pool-sample --out-dir "$D/problems" --n 2000 --candidates 5000 + run "$PY" scripts/p2/build_problems.py --phase check --out-dir "$D/problems" --n 2000 --workers "$WORKERS" +} +export_rev() { # content id of an exported model (INTEGRATOR FIX: lean_gen refuses a non-snapshot dir without --model-rev) + [ "$PRINT_ONLY" = 1 ] && { echo ""; return; } + echo "sft-$(cd "$1" && cat $(ls model*.safetensors | sort) | sha256sum | cut -c1-16)" +} +eval_one() { # eval_one + local tag="$1" model="$2" cap="$3" rev=() + [ "$model" = "$MODEL_BASE" ] || rev=(--model-rev "$(export_rev "$model")") + for set in minif2f mobench; do + gen "$model" "$D/problems/$set.jsonl" "$D/$tag/gen_${set}_c${cap}.jsonl" "$N_EVAL" "$cap" "${rev[@]}" + verify "$D/$tag/gen_${set}_c${cap}.jsonl" "$D/problems/$set.jsonl" "$D/$tag/ver_${set}_c${cap}.jsonl" + done +} +step_base_eval() { + eval_one base "$MODEL_BASE" "$CAP" + [ -z "${CAP_REF:-}" ] || eval_one base "$MODEL_BASE" "$CAP_REF" +} +step_pool_gen() { gen "$MODEL_BASE" "$D/problems/train_pool.jsonl" "$D/pool/gen_r0.jsonl" "$N_POOL" "$CAP"; } +step_pool_verify() { verify "$D/pool/gen_r0.jsonl" "$D/problems/train_pool.jsonl" "$D/pool/ver_r0.jsonl" --feedback; } +step_corr_gen() { + run "$PYR" extras/vllm_rollout/lean_gen.py --model "$MODEL_BASE" --problems "$D/problems/train_pool.jsonl" \ + --out "$D/pool/gen_r1.jsonl" --n 1 --cap-tokens "$CAP" --round 1 --tp "$TP" --seed 0 --wait-lock \ + --corrections-from "$D/pool/ver_r0.jsonl" "$D/pool/gen_r0.jsonl" +} +step_corr_verify() { verify "$D/pool/gen_r1.jsonl" "$D/problems/train_pool.jsonl" "$D/pool/ver_r1.jsonl"; } +step_sft_data() { + # No default on purpose: the longest row the trainer can hold (prompt + completion; correction + # rows carry the failed attempt in the prompt) is a memory fact P2-B measures, not a constant. + [ "$PRINT_ONLY" = 1 ] || : "${MAX_SEQ_LEN:?set MAX_SEQ_LEN to the longest sequence the trainer fits (P2-B probe)}" + run "$PY" scripts/p2/build_sft_data.py \ + --generations "$D/pool/gen_r0.jsonl" "$D/pool/gen_r1.jsonl" \ + --verifications "$D/pool/ver_r0.jsonl" "$D/pool/ver_r1.jsonl" \ + --problems "$D/problems/train_pool.jsonl" \ + --eval-problems "$D/problems/minif2f.jsonl" "$D/problems/mobench.jsonl" \ + --cap-tokens "$CAP" --out-dir "$D/sft_data" --max-seq-len "${MAX_SEQ_LEN:-}" +} +step_train() { + [ "$PRINT_ONLY" = 1 ] || : "${P2_TRAIN_CMD:?P2-B supplies the training command (reads $D/sft_data/shards)}" + run bash -c "${P2_TRAIN_CMD:-}" +} +step_export() { + [ "$PRINT_ONLY" = 1 ] || : "${P2_EXPORT_CMD:?P2-B supplies the HF export command (writes $MODEL_SFT)}" + run bash -c "${P2_EXPORT_CMD:-}" +} +step_post_eval() { + eval_one sft "$MODEL_SFT" "$CAP" + [ -z "${CAP_REF:-}" ] || eval_one sft "$MODEL_SFT" "$CAP_REF" +} +step_report() { + local runs=() cap + for tag in base sft; do + local g="" v="" + for cap in "$CAP" ${CAP_REF:-}; do + for set in minif2f mobench; do g+="${g:+,}$D/$tag/gen_${set}_c${cap}.jsonl"; v+="${v:+,}$D/$tag/ver_${set}_c${cap}.jsonl"; done + done + runs+=(--run "$tag:$g:$v") + done + run "$PY" scripts/p2/eval_report.py --problems "$D/problems/minif2f.jsonl" "$D/problems/mobench.jsonl" \ + "${runs[@]}" --baseline base --candidate sft --out-dir "$D/report" +} + +[ "$#" -ge 1 ] || { sed -n '2,30p' "$0"; exit 2; } +if [ "$1" = print ]; then PRINT_ONLY=1; set -- all; fi +steps=("$@") +[ "${steps[0]}" != all ] || steps=(problems base_eval pool_gen pool_verify corr_gen corr_verify sft_data train export post_eval report) +for s in "${steps[@]}"; do + echo "=== step: $s ($(date '+%F %T'))" >&2 + "step_$s" +done diff --git a/code/scripts/p2/smoke_loop.sh b/code/scripts/p2/smoke_loop.sh new file mode 100644 index 0000000000000000000000000000000000000000..53e324d9da975387abc43379bfac9104f0868e3c --- /dev/null +++ b/code/scripts/p2/smoke_loop.sh @@ -0,0 +1,134 @@ +#!/usr/bin/env bash +# P2 integrator: ONE tiny end-to-end loop on the real model, serialized under the heavy lock. +# +# scripts/p2/smoke_loop.sh [...] steps in order: see STEPS below +# scripts/p2/smoke_loop.sh print show every command, run nothing +# +# Purpose: prove the PLUMBING (keys, tokens, files, the export) with numbers that mean nothing: +# 16 short training statements, 8 miniF2F problems, cap 4096. It is NOT the experiment. +# +# Host rules (/apps/CLAUDE.md): every heavy step is preceded by `preflight` (>=45 GB MemAvailable, +# load < 2x cores, disk queue < 64; strict, raises) and by `waitfree` (poll the repo heavy-job flock +# until nobody holds it). The python entry points then take the lock themselves for the whole +# step; this script never holds it (children would deadlock on it). Scratch is under +# .tmp/p2/p2i, TMPDIR too; nothing goes to / or /tmp. +# +# STEPS: problems gen_r0 ver_r0 gen_r1 ver_r1 sft_data seams train_a train_resume export gen_post gen_base ver_eval report cleanup +set -euo pipefail + +REPO="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +cd "$REPO" +RUN="${RUN:-p2i}" +D="$REPO/.tmp/p2/$RUN" +PY="$REPO/.venv/bin/python" +PYR="$REPO/.venv-rollout/bin/python" +CAP="${CAP:-4096}" +TP="${TP:-4}" +WORKERS="${WORKERS:-8}" +MAX_SEQ_LEN="${MAX_SEQ_LEN:-9216}" # correction rows carry the failed attempt: <= cap + prompt + errors +MODEL_BASE="${MODEL_BASE:-/apps/development/rlhf/hf_cache/hub/models--Goedel-LM--Goedel-Prover-V2-8B/snapshots/dfd02e6271a58375dfbf3ece0175277cf6b6a89a}" +LOCK="$REPO/.align_heavy_job.lock" +export TMPDIR="$D/scratch" HF_HOME="${HF_HOME:-/apps/development/rlhf/hf_cache}" HF_HUB_DISABLE_XET=1 +PRINT_ONLY=0 +mkdir -p "$TMPDIR" "$D/problems" "$D/logs" + +run() { echo "+ $*" >&2; [ "$PRINT_ONLY" = 1 ] || "$@"; } + +preflight() { # strict host preflight; prints the numbers it saw + [ "$PRINT_ONLY" = 1 ] && { echo "+ preflight" >&2; return; } + "$PY" -c "from align.utils.guard import preflight_host_check as p; import json; print('preflight', json.dumps(p(strict=True)))" >&2 +} +waitfree() { # block until the heavy-job lock is free (probe + release; the job takes it itself) + [ "$PRINT_ONLY" = 1 ] && { echo "+ waitfree" >&2; return; } + local t0=$SECONDS + while ! flock -n "$LOCK" true; do + echo "waiting for heavy lock held by: $(cat "$LOCK" 2>/dev/null)" >&2; sleep 60 + done + echo "lock free after $((SECONDS - t0)) s" >&2 +} +heavy() { waitfree; preflight; run "$@"; } +heavy_log() { # heavy_log cmd... : as `heavy`, output also appended to the log (pipefail keeps the exit code) + local log="$1"; shift; waitfree; preflight; echo "+ $*" >&2 + [ "$PRINT_ONLY" = 1 ] || "$@" 2>&1 | tee -a "$log" +} + +step_problems() { + # Real eval set + a 16-problem slice of the decontaminated 2,000-problem pool (the 16 SHORTEST + # statements: this is a plumbing test and wants some accepted proofs), 8 miniF2F problems. + [ "$PRINT_ONLY" = 1 ] && { echo "+ (slice problems -> $D/problems)" >&2; return; } + "$PY" - "$REPO/.tmp/p2/problems" "$D/problems" <<'PYEOF' +import json, sys +src, dst = sys.argv[1:3] +tr = [json.loads(l) for l in open(f"{src}/train_pool.jsonl")] +mf = [json.loads(l) for l in open(f"{src}/minif2f.jsonl")] +tr.sort(key=lambda r: (len(r["formal_statement"]), r["problem_id"])) +w = lambda p, rows: open(p, "w").write("".join(json.dumps(r, ensure_ascii=False) + "\n" for r in rows)) +w(f"{dst}/train16.jsonl", tr[:16]); w(f"{dst}/minif2f8.jsonl", mf[:8]); w(f"{dst}/minif2f.jsonl", mf) +w(f"{dst}/mobench.jsonl", [json.loads(l) for l in open(f"{src}/mobench.jsonl")]) +PYEOF +} +gen() { # gen [extra...] + local model="$1" probs="$2" out="$3" n="$4" cap="$5"; shift 5 + heavy "$PYR" extras/vllm_rollout/lean_gen.py --model "$model" --problems "$probs" --out "$out" --n "$n" \ + --cap-tokens "$cap" --tp "$TP" --seed 0 --wait-lock "$@" +} +verify() { # verify [extra...] + local g="$1" p="$2" o="$3"; shift 3 + heavy "$PY" scripts/p2/verify_generations.py --generations "$g" --problems "$p" --out "$o" --workers "$WORKERS" "$@" +} +step_gen_r0() { gen "$MODEL_BASE" "$D/problems/train16.jsonl" "$D/pool/gen_r0.jsonl" 4 "$CAP" --round 0; } +step_ver_r0() { verify "$D/pool/gen_r0.jsonl" "$D/problems/train16.jsonl" "$D/pool/ver_r0.jsonl" --feedback; } +step_gen_r1() { gen "$MODEL_BASE" "$D/problems/train16.jsonl" "$D/pool/gen_r1.jsonl" "${N_CORR:-1}" "$CAP" --round 1 \ + --corrections-from "$D/pool/ver_r0.jsonl" "$D/pool/gen_r0.jsonl"; } +step_ver_r1() { verify "$D/pool/gen_r1.jsonl" "$D/problems/train16.jsonl" "$D/pool/ver_r1.jsonl"; } +step_sft_data() { + run "$PY" scripts/p2/build_sft_data.py \ + --generations "$D/pool/gen_r0.jsonl" "$D/pool/gen_r1.jsonl" \ + --verifications "$D/pool/ver_r0.jsonl" "$D/pool/ver_r1.jsonl" \ + --problems "$D/problems/train16.jsonl" \ + --eval-problems "$D/problems/minif2f.jsonl" "$D/problems/mobench.jsonl" \ + --cap-tokens "$CAP" --out-dir "$D/sft_data" --max-seq-len "$MAX_SEQ_LEN" --held-frac 0 ${SFT_EXTRA:-} +} +step_seams() { + run "$PY" scripts/p2/check_seams.py keys --generations "$D/pool/gen_r0.jsonl" --verifications "$D/pool/ver_r0.jsonl" + [ ! -s "$D/pool/gen_r1.jsonl" ] || run "$PY" scripts/p2/check_seams.py keys --generations "$D/pool/gen_r1.jsonl" --verifications "$D/pool/ver_r1.jsonl" + run "$PY" scripts/p2/check_seams.py sft --generations "$D/pool/gen_r0.jsonl" "$D/pool/gen_r1.jsonl" \ + --verifications "$D/pool/ver_r0.jsonl" "$D/pool/ver_r1.jsonl" --shards "$D/sft_data/shards" +} + +# ---- training: 4 ranks, LoRA, configs/p2_sft.toml + overrides -------------------------------- +TRAIN_SEQ_LEN="${TRAIN_SEQ_LEN:-9216}"; TRAIN_EPOCHS="${TRAIN_EPOCHS:-1}"; SAVE_EVERY="${SAVE_EVERY:-20}" +train_cmd() { + echo "env PYTHONPATH=$REPO $PY -m torch.distributed.run --standalone --nproc_per_node=4 scripts/sft.py configs/p2_sft.toml" \ + "--data.shard_dir=$D/sft_data/shards --data.manifest_path=$D/sft_data/manifest.json" \ + "--paths.checkpoint_dir=$D/sft --train.seq_len=$TRAIN_SEQ_LEN --train.epochs=$TRAIN_EPOCHS" \ + "--train.save_every_micro_steps=$SAVE_EVERY" +} +step_train_a() { heavy_log "$D/logs/train.log" bash -c "$(train_cmd)"; } # SIGTERM'd by the operator, see docs/P2.md +step_train_resume() { heavy_log "$D/logs/train_resume.log" bash -c "$(train_cmd)"; } +step_export() { # merge adapter + HF export + vLLM reload + first-token logprob seam (keeps the export) + local ck="" + if [ "$PRINT_ONLY" != 1 ]; then ck="$(ls -d "$D"/sft/*/checkpoints/micro_???????? 2>/dev/null | sort -V | tail -1)"; : "${ck:?no checkpoint under $D/sft}"; fi + heavy "$PY" scripts/qwen3_export_check.py run --ckpt "$ck" --out-dir "$D/export" --work "$D/export_check" --r 64 --alpha 128 --keep +} +export_rev() { # content id of an exported model: lean_gen cannot infer a revision from a non-snapshot dir (it refuses) + [ "$PRINT_ONLY" = 1 ] && { echo ""; return; } + echo "sft-$(cd "$1" && cat $(ls model*.safetensors | sort) | sha256sum | cut -c1-16)" +} +step_gen_post() { gen "$D/export" "$D/problems/minif2f8.jsonl" "$D/sft/gen_minif2f8.jsonl" 2 "$CAP" --round 0 --model-rev "$(export_rev "$D/export")"; } +step_gen_base() { gen "$MODEL_BASE" "$D/problems/minif2f8.jsonl" "$D/base/gen_minif2f8.jsonl" 2 "$CAP" --round 0; } +step_ver_eval() { + verify "$D/base/gen_minif2f8.jsonl" "$D/problems/minif2f8.jsonl" "$D/base/ver_minif2f8.jsonl" + verify "$D/sft/gen_minif2f8.jsonl" "$D/problems/minif2f8.jsonl" "$D/sft/ver_minif2f8.jsonl" +} +step_report() { + run "$PY" scripts/p2/eval_report.py --problems "$D/problems/minif2f8.jsonl" \ + --run "base:$D/base/gen_minif2f8.jsonl:$D/base/ver_minif2f8.jsonl" \ + --run "sft:$D/sft/gen_minif2f8.jsonl:$D/sft/ver_minif2f8.jsonl" \ + --baseline base --candidate sft --out-dir "$D/report" --ks 1 2 +} +step_cleanup() { run rm -rf "$D/export" "$D/scratch"; } + +[ "$#" -ge 1 ] || { sed -n '2,10p' "$0"; exit 2; } +if [ "$1" = print ]; then PRINT_ONLY=1; set -- problems gen_r0 ver_r0 gen_r1 ver_r1 sft_data seams train_a train_resume export gen_post gen_base ver_eval report cleanup; fi +for s in "$@"; do echo "=== step: $s ($(date '+%F %T'))" >&2; "step_$s"; done diff --git a/code/scripts/p2/supply/decontaminate.py b/code/scripts/p2/supply/decontaminate.py new file mode 100644 index 0000000000000000000000000000000000000000..11a4a1b91037e11af4110d4179c89a5ed1967f3e --- /dev/null +++ b/code/scripts/p2/supply/decontaminate.py @@ -0,0 +1,164 @@ +#!/usr/bin/env python +"""Decontaminate ingested statement sets and the existing train pool against the evaluation sets +(miniF2F test+valid, MOBench, ProofNet# valid+test, optionally FormalMATH). Uses align.data.decontam. + + .venv/bin/python scripts/p2/supply/decontaminate.py --supply-dir .tmp/p2/supply + +Writes, per input set ``: `.clean.jsonl` (kept rows), `.flagged.jsonl` (id, reason, hits), +and `decontam_report.json` (hit counts per stage per source, independent and first-hit, plus each stage's +unique contribution). Nothing is deleted from the inputs. +""" + +from __future__ import annotations + +import argparse +import importlib.util +import json +import sys +from collections import Counter +from pathlib import Path + +REPO = Path(__file__).resolve().parents[3] +sys.path.insert(0, str(REPO)) + +from align.data import hub # noqa: E402 +from align.data.decontam import STAGES, ContaminationLedger, Decontaminator, EvalItem # noqa: E402 + +spec = importlib.util.spec_from_file_location("p2_common", REPO / "scripts" / "p2" / "p2_common.py") +C = sys.modules.get("p2_common") or importlib.util.module_from_spec(spec) +if "p2_common" not in sys.modules: + sys.modules["p2_common"] = C + spec.loader.exec_module(C) + +import os # noqa: E402 + +#: .tmp/ is gitignored and lives in the main checkout; worktrees share it (override with ALIGN_P2_TMP). +P2_TMP = Path(os.environ.get("ALIGN_P2_TMP", "/apps/development/rlhf/align/.tmp/p2")) +PROBLEMS = P2_TMP / "problems" +GOEDEL_MOBENCH = Path("/apps/development/rlhf/reference-repos/Goedel-Prover-V2/dataset/MOBench.jsonl") +PROOFNET = "PAug/ProofNetSharp" + + +def fetch_proofnet(out_dir: Path) -> list[EvalItem]: + """ProofNet# (Lean 4 port, valid 185 + test 186) through the datasets-server JSON API; cached as JSONL.""" + items = [] + with urllib_open(f"https://huggingface.co/api/datasets/{PROOFNET}") as r: + rev = json.loads(r.read())["sha"] + for split in ("valid", "test"): + cache = out_dir / f"proofnet_{split}.jsonl" + if not cache.exists(): + rows = list(hub.iter_rows(PROOFNET, "default", split)) + with open(cache, "w", encoding="utf-8") as f: + for i, row in enumerate(rows): + f.write(json.dumps({"row_index": i, "revision": rev, **row}, ensure_ascii=False) + "\n") + for row in C.iter_jsonl(cache): + stmt = (row.get("lean4_src_header") or "").rstrip() + "\n\n" + (row.get("lean4_formalization") or "") + items.append(EvalItem(f"proofnet-{split}:{row.get('id')}", f"proofnet_{split}", stmt, row.get("nl_statement"))) + return items + + +def urllib_open(url): + import urllib.request + return urllib.request.urlopen(url, timeout=60) + + +def eval_items(supply_dir: Path, formalmath_as_eval: bool) -> tuple[list[EvalItem], dict]: + items: list[EvalItem] = [] + for name, path in (("minif2f_test", PROBLEMS / "minif2f.jsonl"), ("minif2f_valid", PROBLEMS / "minif2f_valid.jsonl")): + for r in C.iter_jsonl(path): + items.append(EvalItem(f"{name}:{r['problem_id']}", name, r["formal_statement"], r.get("informal_prefix"))) + # all 360 MOBench problems (the verifier supports only 135 of them, contamination does not care) + for r in C.iter_jsonl(GOEDEL_MOBENCH): + items.append(EvalItem(f"mobench:{r['problem_id']}", "mobench", r.get("lean4_code") or r["formal_statement"], r.get("informal_prefix"))) + items += fetch_proofnet(supply_dir / "evalsets") + if formalmath_as_eval: + for r in C.iter_jsonl(supply_dir / "formalmath-all.jsonl"): + items.append(EvalItem(f"formalmath:{r['problem_id']}", "formalmath", r["formal_statement"], r.get("informal_prefix"))) + return items, dict(Counter(i.eval_set for i in items)) + + +def run_set(name: str, path: Path, dec: Decontaminator, out_dir: Path, elab: dict[str, str]) -> dict: + ledger = ContaminationLedger() + rows_meta: list[tuple[str, dict]] = [] + n = 0 + per_stage: Counter = Counter() + first: Counter = Counter() + by_eval: Counter = Counter() + unique: Counter = Counter() + flagged_f = open(out_dir / f"{name}.flagged.jsonl", "w", encoding="utf-8") + clean_f = open(out_dir / f"{name}.clean.jsonl", "w", encoding="utf-8") + inherited = 0 + # single pass: the ledger needs ancestry, and mutation children may precede their parent in a file, so + # rows are held only as (id, line) -- the statement text is dropped once checked + pending: list[tuple[str, str]] = [] + for r in C.iter_jsonl(path): + n += 1 + hits = dec.check(r["formal_statement"], r.get("informal_prefix"), elab.get(r["problem_id"])) + ledger.add(r["problem_id"], hits, r.get("ancestry")) + pending.append((r["problem_id"], json.dumps(r, ensure_ascii=False))) + if hits: + stages = {h.stage for h in hits} + for s in stages: + per_stage[s] += 1 + first[min(stages, key=STAGES.index)] += 1 + if len(stages) == 1: + unique[next(iter(stages))] += 1 + for es in {dec.items[h.eval_id].eval_set for h in hits}: + by_eval[es] += 1 + flagged = 0 + for pid, line in pending: + bad, why = ledger.resolve(pid) + if bad: + flagged += 1 + inherited += why.startswith("inherited") + h = ledger.direct.get(pid, []) + flagged_f.write(json.dumps({"problem_id": pid, "reason": why, "hits": [ + {"stage": x.stage, "eval_id": x.eval_id, "eval_set": dec.items[x.eval_id].eval_set, "score": round(x.score, 3)} + for x in h[:8]]}, ensure_ascii=False) + "\n") + else: + clean_f.write(line + "\n") + flagged_f.close(); clean_f.close() + return {"rows": n, "flagged": flagged, "clean": n - flagged, "inherited": inherited, + "flagged_by_stage_independent": {s: per_stage[s] for s in STAGES if per_stage[s]}, + "first_hit_stage": {s: first[s] for s in STAGES if first[s]}, + "only_this_stage_fired": {s: unique[s] for s in STAGES if unique[s]}, + "flagged_rows_by_eval_set": dict(by_eval)} + + +def main(argv=None) -> int: + ap = argparse.ArgumentParser(description=__doc__.split("\n")[0]) + ap.add_argument("--supply-dir", default=str(P2_TMP / "supply")) + ap.add_argument("--pool", default=str(PROBLEMS / "train_pool.jsonl")) + ap.add_argument("--formalmath-as-eval", action="store_true") + ap.add_argument("--elab-hashes", help="JSONL {problem_id, elab_sha} from elab_hash.py (eval + candidate sides)") + a = ap.parse_args(argv) + sd = Path(a.supply_dir) + (sd / "evalsets").mkdir(parents=True, exist_ok=True) + out = sd / "decontam" + out.mkdir(exist_ok=True) + items, eval_counts = eval_items(sd, a.formalmath_as_eval) + elab: dict[str, str] = {} + if a.elab_hashes: + for r in C.iter_jsonl(a.elab_hashes): + elab[r["problem_id"]] = r["elab_sha"] + for it in items: + if it.eval_id in elab: + it.elab_sha = elab[it.eval_id] + dec = Decontaminator(items) + report = {"eval_sets": eval_counts, "eval_index_skipped": dict(dec.skipped), "sets": {}} + inputs = {"train_pool": Path(a.pool)} + for p in sorted(sd.glob("*.jsonl")): + if p.name.endswith((".clean.jsonl", ".flagged.jsonl")): + continue + if a.formalmath_as_eval and p.name == "formalmath-all.jsonl": + continue + inputs[p.stem] = p + for name, path in inputs.items(): + report["sets"][name] = run_set(name, path, dec, out, elab) + print(name, json.dumps(report["sets"][name]), flush=True) + (out / "decontam_report.json").write_text(json.dumps(report, indent=1)) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/code/scripts/p2/supply/elab_hash.py b/code/scripts/p2/supply/elab_hash.py new file mode 100644 index 0000000000000000000000000000000000000000..697571df4492bda82707cce875e61bbc087627d1 --- /dev/null +++ b/code/scripts/p2/supply/elab_hash.py @@ -0,0 +1,237 @@ +#!/usr/bin/env python +"""Elaborated-type hash of formal statements (plan v3 Phase 0a item 5, first bullet). + +Why: token/alpha hashes (p2_common.statement_sha / alpha_sha) are blind to hypothesis order, notation +(`x ∈ Finset.range n` vs `x < n`-style rewrites that elaborate to the same Expr), implicit coercion spelling, and +`open` differences. The elaborated type of `theorem _align_ref := by sorry` in the pinned Lean env is +not. Expr uses de Bruijn indices for bound variables, so binder names never reach the hash. + +What is hashed (all decided in Lean, `LEAN_DUMP`, then canonicalised and hashed in pure Python): + * the statement type is split with `forallTelescope` into binders and a conclusion; + * instance-implicit binders are DROPPED, and so are instance-implicit ARGUMENTS of every application + (`getFunInfo` paramInfo), so `@HAdd.hAdd ℝ ℝ ℝ inst a b` is `HAdd.hAdd ℝ ℝ ℝ a b`; + * data binders (non-Prop) keep their order (later types may depend on earlier ones) and are named by that + ordinal `v0, v1, ...`; Prop binders (hypotheses) are serialised with the variables as `v_k`, and the + hypothesis list is SORTED, so reordering hypotheses does not change the hash; + * universe levels are dropped; binder names/infos inside terms are dropped; `mdata` is stripped; numerals + keep their type argument (`OfNat.ofNat ℝ 2` differs from `OfNat.ofNat ℕ 2`). +Known blind spots (by design, documented): permuting the order of data variables that are interchangeable is not +canonicalised; hypotheses that mention other hypotheses (proof-dependent types) are serialised with `?h`. + + # unit-testable pure parts: parse_dump, canonical_form, elab_sha, build_lean_prelude + # <=50 statements validation (2 Lean workers max): + .venv/bin/python scripts/p2/supply/elab_hash.py --in .tmp/p2/problems/minif2f.jsonl --out .tmp/p2/supply/elab_minif2f.jsonl --limit 50 --workers 2 + # FULL RUN (GPU-free window only; ~0.5-3 s/statement/worker after header warm-up; resumable by --out): + .venv/bin/python scripts/p2/supply/elab_hash.py --in .clean.jsonl --out .elab.jsonl --workers 2 +""" + +from __future__ import annotations + +import argparse +import hashlib +import importlib.util +import json +import os +import re +import sys +import time +from pathlib import Path +from typing import Any, Iterable, Optional + +REPO = Path(__file__).resolve().parents[3] +sys.path.insert(0, str(REPO)) + +MARK = "ALIGN_ELAB_HASH " +NS = "AlignElabHash" +REF = "_align_elab_ref" + +#: Trusted Lean prelude, elaborated once per worker BEFORE the header's `open`s (pristine namespace). +LEAN_DUMP = r''' +namespace AlignElabHash +open Lean Meta + +partial def ser (nm : FVarId → Option String) (e : Expr) : MetaM String := do + match e with + | .bvar i => return s!"#{i}" + | .fvar id => return (nm id).getD "?fv" + | .mvar _ => return "?m" + | .sort l => return s!"Sort({toString l})" + | .const n _ => return n.toString + | .lit (.natVal n) => return toString n + | .lit (.strVal s) => return s!"\"{s}\"" + | .mdata _ b => ser nm b + | .proj s i b => return s!"{s}.{i}({← ser nm b})" + | .lam _ t b _ => return s!"(fun {← ser nm t} => {← ser nm b})" + | .forallE _ t b _ => return s!"(all {← ser nm t} , {← ser nm b})" + | .letE _ t v b _ => return s!"(let {← ser nm t} := {← ser nm v} in {← ser nm b})" + | .app .. => + let f := e.getAppFn + let args := e.getAppArgs + let mut keep : Array Bool := args.map fun _ => true + try + let info ← getFunInfoNArgs f args.size + for i in [0:args.size] do + if i < info.paramInfo.size && info.paramInfo[i]!.isInstImplicit then + keep := keep.set! i false + catch _ => pure () + let mut out := #[← ser nm f] + for i in [0:args.size] do + if keep[i]! then out := out.push (← ser nm args[i]!) + return "(" ++ " ".intercalate out.toList ++ ")" + +def dump (n : Name) : Elab.Command.CommandElabM Unit := do + let some ci := (← getEnv).find? n | throwError "no such declaration {n}" + let js ← Elab.Command.liftTermElabM do + forallTelescope ci.type fun xs concl => do + let mut names : List (FVarId × String) := [] + let mut vars : Array String := #[] + let mut hyps : Array String := #[] + let mut k := 0 + for x in xs do + let d ← x.fvarId!.getDecl + if d.binderInfo.isInstImplicit then continue + let nmf := fun (id : FVarId) => (names.find? (fun p => p.1 == id)).map (·.2) + let ty ← ser nmf d.type + if (← isProp d.type) then + hyps := hyps.push ty + else + vars := vars.push ty + names := (x.fvarId!, s!"v{k}") :: names + k := k + 1 + let nmf := fun (id : FVarId) => (names.find? (fun p => p.1 == id)).map (·.2) + let c ← ser nmf concl + let hs := hyps.toList.map fun h => Json.str h + return Json.compress (Json.mkObj [("vars", Json.arr (vars.toList.map Json.str).toArray), ("hyps", Json.arr hs.toArray), ("concl", Json.str c)]) + logInfo m!"ALIGN_ELAB_HASH {js}" + +end AlignElabHash +''' + + +# --------------------------------------------------------------------------- pure parts + + +def build_lean_prelude() -> str: + return LEAN_DUMP + + +def build_ref_command(sig_text: str) -> str: + return f"theorem {REF} {sig_text} := by\n sorry" + + +DUMP_COMMAND = f"#eval {NS}.dump `{REF}" + + +def parse_dump(messages: Iterable[dict]) -> dict: + """Extract the JSON the Lean dump logged. Raises ValueError with the Lean error otherwise.""" + errs = [] + for m in messages: + if not isinstance(m, dict): + continue + data = str(m.get("data", "")) + if m.get("severity") == "error": + errs.append(data) + elif MARK in data: + return json.loads(data.split(MARK, 1)[1].strip()) + raise ValueError("no dump in output" + (": " + errs[0][:200] if errs else "")) + + +def canonical_form(dump: dict) -> str: + """Hypotheses sorted (multiset order-invariance); data binders and conclusion kept in order.""" + if not {"vars", "hyps", "concl"} <= set(dump): + raise ValueError(f"dump lacks fields: {sorted(dump)}") + return json.dumps({"vars": list(dump["vars"]), "hyps": sorted(dump["hyps"]), "concl": dump["concl"]}, + ensure_ascii=False, separators=(",", ":"), sort_keys=True) + + +def elab_sha(dump: dict) -> str: + return hashlib.sha256(canonical_form(dump).encode("utf-8")).hexdigest() + + +# --------------------------------------------------------------------------- Lean side + + +def _load_common(): + if "p2_common" in sys.modules: + return sys.modules["p2_common"] + spec = importlib.util.spec_from_file_location("p2_common", REPO / "scripts" / "p2" / "p2_common.py") + mod = importlib.util.module_from_spec(spec) + sys.modules["p2_common"] = mod + spec.loader.exec_module(mod) + return mod + + +def hash_statement(worker, statement: str, timeout_s: float = 120.0) -> dict: + """Elaborate one statement in the pinned env and return {elab_sha, dump} or {error}.""" + from align.verify.lean.repl_pool import ReplError, ReplProtocolError + from align.verify.lean.strict import parse_statement + + ref = parse_statement(statement) + if not ref.ok: + return {"error": f"unsupported_statement: {ref.problem}"} + try: + env = worker.header_env(ref.header_text, LEAN_DUMP) + except ReplProtocolError as e: + return {"error": "header_error: " + " ".join(str(e).split())[:200]} + except ReplError as e: + return {"error": e.code if hasattr(e, "code") else type(e).__name__} + try: + r1 = worker.command(build_ref_command(ref.sig_text), env, timeout_s) + errs = [m for m in r1.get("messages", []) if isinstance(m, dict) and m.get("severity") == "error"] + if errs or "env" not in r1: + return {"error": "elab_error: " + " ".join(str(errs[0].get("data", r1) if errs else r1).split())[:200]} + r2 = worker.command(DUMP_COMMAND, r1["env"], timeout_s) + dump = parse_dump(r2.get("messages", [])) + except ReplError as e: + return {"error": e.code if hasattr(e, "code") else type(e).__name__} + except ValueError as e: + return {"error": f"dump_error: {e}"} + return {"elab_sha": elab_sha(dump), "dump": dump} + + +def main(argv=None) -> int: + ap = argparse.ArgumentParser(description=__doc__.split("\n")[0]) + ap.add_argument("--in", dest="inp", required=True) + ap.add_argument("--out", required=True, help="JSONL {problem_id, elab_sha | error}; existing ids are skipped (resume)") + ap.add_argument("--limit", type=int, default=0) + ap.add_argument("--workers", type=int, default=2) + ap.add_argument("--timeout", type=float, default=120.0) + a = ap.parse_args(argv) + if not 1 <= a.workers <= 2: + raise SystemExit("--workers must be 1 or 2 (>2 needs the heavy-job lock; do the full run in a GPU-free window)") + C = _load_common() + from align.utils import guard + from align.verify.lean import ReplPool, find_toolchain + from align.verify.lean.repl_pool import ReplLimits + + guard.preflight_host_check(strict=True) + done = set() + if os.path.exists(a.out): + done = {r["problem_id"] for r in C.iter_jsonl(a.out)} + rows = [] + for r in C.iter_jsonl(a.inp): + if r["problem_id"] not in done: + rows.append(r) + if a.limit and len(rows) >= a.limit: + break + tc = find_toolchain() + pool = ReplPool.from_toolchain(tc, n_workers=a.workers, limits=ReplLimits(), preflight=True, + scratch_dir=str(Path(os.environ.get("TMPDIR", str(REPO / ".tmp"))) / "p2_elab_pool")) + t0 = time.time() + ok = 0 + with C.AppendJsonl(a.out) as out: + for i in range(0, len(rows), 16): + chunk = rows[i : i + 16] + res = pool.map(lambda w, r: hash_statement(w, r["formal_statement"], a.timeout), chunk) + for r, x in zip(chunk, res): + if isinstance(x, BaseException): + raise x + out.append({"problem_id": r["problem_id"], **({"elab_sha": x["elab_sha"]} if "elab_sha" in x else {"error": x["error"]})}) + ok += "elab_sha" in x + pool.close() + print(json.dumps({"statements": len(rows), "hashed": ok, "errors": len(rows) - ok, "seconds": round(time.time() - t0, 1)})) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/code/scripts/p2/supply/ingest.py b/code/scripts/p2/supply/ingest.py new file mode 100644 index 0000000000000000000000000000000000000000..df466541d8ab6cd6b2f132ddae73258fa330aa6f --- /dev/null +++ b/code/scripts/p2/supply/ingest.py @@ -0,0 +1,403 @@ +#!/usr/bin/env python +"""Stream candidate Lean statement sets into the P2 Problem JSONL schema (plan v3, Phase 0a item 4). + +Sources (docs/workbook/data/Datasets.json): FormalMATH-All, NuminaMath-LEAN-Sol, +Nemotron-Math-Proofs-v1, RL-Goedel-Pset (levels 3-5 / 4-5), Lean4-FormalConjectures. + +Properties + * Streaming and memory-bounded: JSONL files are read line by line over HTTP (Range-resumable, + `align.data.hub.iter_lines`), parquet-only datasets through the datasets-server JSON /rows API + (no pyarrow). Nothing is ever held beyond one row plus the per-source dedup set of statement shas. + * Every row is pinned to the dataset revision sha (HF API, resolved once at start) and carries + `provenance = {source_id, revision, row_index, license, ...}` plus an `ancestry` record + (`root` = own problem_id, `parents` = []), the field `align.data.decontam` propagates to mutation + children. + * Output rows are PROBLEM_FIELDS (+ `provenance`, `ancestry`): `formal_statement` is + `GOEDEL_HEADER + ... := by sorry` (the header is REWRITTEN when the source's differs, recorded in + `provenance.header_rewritten` / `original_header`; whether the rewrite still elaborates is the + elaboration check's job, `elab_hash.py` / `build_problems.py --phase check`). + * Nothing is dropped silently: every drop is counted by reason in `ingest_manifest.json` and the first + few examples per reason are kept in `.drops.sample.json`. + + HF_HOME=/apps/development/rlhf/hf_cache HF_HUB_DISABLE_XET=1 \ + .venv/bin/python scripts/p2/supply/ingest.py --out-dir .tmp/p2/supply --workers 2 +""" + +from __future__ import annotations + +import argparse +import importlib.util +import json +import logging +import os +import re +import sys +import time +import urllib.request +from collections import Counter +from concurrent.futures import ThreadPoolExecutor +from pathlib import Path +from typing import Any, Callable, Iterator + +REPO = Path(__file__).resolve().parents[3] +sys.path.insert(0, str(REPO)) +os.environ.setdefault("HF_HOME", "/apps/development/rlhf/hf_cache") +os.environ.setdefault("HF_HUB_DISABLE_XET", "1") + + +def _load_common(): + if "p2_common" in sys.modules: + return sys.modules["p2_common"] + spec = importlib.util.spec_from_file_location("p2_common", REPO / "scripts" / "p2" / "p2_common.py") + mod = importlib.util.module_from_spec(spec) + sys.modules["p2_common"] = mod + spec.loader.exec_module(mod) + return mod + + +C = _load_common() +from align.data import hub # noqa: E402 (after sys.path) + +log = logging.getLogger("p2.supply.ingest") +MAX_STATEMENT_CHARS = 4000 # same cap as build_problems.py +DEFAULT_OUT = Path(os.environ.get("ALIGN_P2_TMP", "/apps/development/rlhf/align/.tmp/p2")) / "supply" +_BY_SORRY_TAIL = re.compile(r":=\s*sorry\s*$") +_BY_TAIL = re.compile(r":=\s*by\s*$") + + +# --------------------------------------------------------------------------- source registry + + +class Source: + def __init__(self, key, repo_id, license_, iterate, split="train", problem_source=None, note=""): + self.key, self.repo_id, self.license, self.iterate = key, repo_id, license_, iterate + self.split, self.problem_source, self.note = split, problem_source or key, note + + +def _it_formalmath(src: Source, rev: str, args) -> Iterator[dict]: + """FormalMATH_all.json is one 13 MB JSON array (the repo has no JSONL); read once, yield rows.""" + req = urllib.request.Request(hub.resolve_url(src.repo_id, "FormalMATH_all.json", rev)) + with urllib.request.urlopen(req, timeout=120) as r: + data = json.loads(r.read()) + for i, row in enumerate(data): + yield {"row_index": i, "statement": row.get("autoformalization"), "header": None, + "informal": row.get("refined_statement"), "name": row.get("theorem_names"), + "extra": {"domain": row.get("domain"), "orig_source": row.get("source")}} + + +def _it_numina(src: Source, rev: str, args) -> Iterator[dict]: + for i, raw in enumerate(hub.iter_lines(hub.resolve_url(src.repo_id, "train.jsonl", rev))): + row = json.loads(raw) if raw.strip() else None + if row is None: + continue + yield {"row_index": i, "statement": row.get("formal_statement"), "header": None, + "informal": row.get("problem"), "name": row.get("uuid"), + "english": str(row.get("is_english")).lower() != "false", + "extra": {"uuid": row.get("uuid"), "orig_source": row.get("source"), + "statement_source": row.get("statement_source")}} + + +def _it_nemotron(src: Source, rev: str, args) -> Iterator[dict]: + """29.5 GB file (whole trajectories): never downloaded in full. `--nemotron-stripes` evenly spaced + Range windows of `--nemotron-rows/stripes` lines each, so the sample is not biased to the file head. + Row locator = byte offset of the line (stable for a pinned revision).""" + url = hub.resolve_url(src.repo_id, "data/lean.jsonl", rev) + req = urllib.request.Request(url, method="HEAD") + with hub._urlopen(req) as r: # follows the redirect + size = int(r.headers["Content-Length"]) + stripes = max(1, args.nemotron_stripes) + per = max(1, args.nemotron_rows // stripes) + for s in range(stripes): + start = (size // stripes) * s + pos = start + n = 0 + first = start > 0 # the first line after a mid-file Range is a fragment: skip it + for raw in hub.iter_lines(url, start_byte=start): + here = pos + pos += len(raw) + 1 + if first: + first = False + continue + if not raw.strip(): + continue + try: + row = json.loads(raw) + except json.JSONDecodeError: + continue # truncated last line of the file only + yield {"row_index": here, "statement": row.get("formal_statement"), "header": row.get("lean_header"), + "informal": row.get("problem"), "name": row.get("uuid"), + "extra": {"uuid": row.get("uuid"), "orig_source": row.get("source"), "byte_offset": here, + "stripe": s, "row_license": row.get("license")}} + n += 1 + if n >= per: + break + + +def _parquet_or_server_rows(repo_id: str, rev: str, max_rows: int | None) -> Iterator[dict]: + """Rows of the single `train` split. With pyarrow (run under .venv-rollout; the import lives in + align.data.ingest_parquet, the one whitelisted module) the pinned parquet shard(s) are fetched once to + HF_HOME and streamed in row batches. Without it: the datasets-server JSON /rows API, paced at 1 page/s + with a long backoff on HTTP 429 (the server rate-limits sequential paging).""" + from align.data import ingest_parquet as ip + + try: + ip._require_pyarrow() + have_pa = True + except ip.IngestUnavailable: + have_pa = False + if have_pa: + dest = Path(os.environ["HF_HOME"]) / "align_streams" / "supply" / repo_id.replace("/", "__") / rev + dest.mkdir(parents=True, exist_ok=True) + files = [e for e in hub.list_repo_files(repo_id, rev, "data") if e.get("type") == "file" and e["path"].endswith(".parquet")] + emitted = 0 + for e in sorted(files, key=lambda e: e["path"]): + target = dest / Path(e["path"]).name + want = (e.get("lfs") or {}).get("oid") + if not (target.exists() and target.stat().st_size == e["size"]): + tmp = target.with_name(target.name + f".tmp{os.getpid()}") + try: + with urllib.request.urlopen(hub.resolve_url(repo_id, e["path"], rev), timeout=300) as r, open(tmp, "wb") as f: + while chunk := r.read(4 << 20): + f.write(chunk) + if want and C.sha256_file(tmp) != want: + raise RuntimeError(f"{e['path']}: sha256 differs from the hub's LFS oid") + os.replace(tmp, target) + finally: + if tmp.exists(): + tmp.unlink() + for row in ip.iter_parquet_rows(target, prefer_ingested=False): + if max_rows and emitted >= max_rows: + return + emitted += 1 + yield row + return + offset, emitted = 0, 0 + while not max_rows or emitted < max_rows: + for attempt in range(8): + try: + page = hub.fetch_rows_page(repo_id, "default", "train", offset, 100, use_cache=False) + break + except hub.HubError as err: + if "429" not in str(err) or attempt == 7: + raise + time.sleep(60) + rows = page["rows"] + if not rows: + return + for r in rows: + if r.get("truncated_cells"): + raise hub.HubError(f"truncated cells at row {r.get('row_idx')}") + if max_rows and emitted >= max_rows: + return + emitted += 1 + yield r["row"] + offset += len(rows) + if offset >= page.get("num_rows_total", offset + 1): + return + time.sleep(1.0) + + +def _it_rows(src: Source, rev: str, args) -> Iterator[dict]: + for i, row in enumerate(_parquet_or_server_rows(src.repo_id, rev, args.max_rows)): + yield {"row_index": i, "statement": row.get("formal_statement"), "header": None, + "informal": row.get("informal_statement"), "name": row.get("problem_id"), + "extra": {"orig_problem_id": row.get("problem_id"), "orig_source": row.get("source")}} + + +def _it_conjectures(src: Source, rev: str, args) -> Iterator[dict]: + """Rows are declarations: statement = everything after the name (binders + type).""" + for i, row in enumerate(_parquet_or_server_rows(src.repo_id, rev, args.max_rows)): + kind = (row.get("type") or "").strip() + imports = row.get("imports") or "[]" + yield {"row_index": i, "statement": None, "header": None, "informal": row.get("docstring") or None, + "name": row.get("symbolic_name"), "decl_kind": kind, "decl_tail": row.get("statement"), + "imports": imports, + "extra": {"library": row.get("library"), "filename": row.get("filename"), + "source_url": row.get("source_url"), "upstream_commit": row.get("commit")}} + + +SOURCES: dict[str, Source] = {s.key: s for s in [ + Source("formalmath-all", "SphereLab/FormalMATH-All", "mit", _it_formalmath), + Source("numinamath-lean-sol", "iiis-lean/NuminaMath-LEAN-Sol", "apache-2.0", _it_numina), + Source("nemotron-math-proofs-v1", "nvidia/Nemotron-Math-Proofs-v1", "cc-by-sa-4.0", _it_nemotron, + note="sampled in Range stripes; the full file is 29.5 GB of trajectories"), + Source("rl-goedel-pset-l45", "HangHor/RL-Goedel-Pset-Level-4-5", "mit", _it_rows, + note="levels 4-5 proper (46,290 rows)"), + Source("rl-goedel-pset-l35", "HangHor/RL-Goedel-Pset-Level-3-5", "mit", _it_rows, + note="levels 3-5 superset of l45; not in the default set"), + Source("lean4-formalconjectures", "phanerozoic/Lean4-FormalConjectures", "apache-2.0", _it_conjectures), +]} +DEFAULT_SOURCES = ["formalmath-all", "numinamath-lean-sol", "nemotron-math-proofs-v1", "rl-goedel-pset-l45", + "lean4-formalconjectures"] + + +def repo_revision(repo_id: str) -> str: + with urllib.request.urlopen(f"https://huggingface.co/api/datasets/{repo_id}", timeout=60) as r: + return json.loads(r.read())["sha"] + + +# --------------------------------------------------------------------------- pure conversion + + +def assemble_statement(rec: dict) -> tuple[str, dict]: + """-> (candidate full source text, extra provenance). Raises ContractError('drop:').""" + extra: dict[str, Any] = {} + if rec.get("decl_kind") is not None: # FormalConjectures declaration + kind = rec["decl_kind"] + if kind not in ("theorem", "lemma"): + raise C.ContractError("drop:not_theorem_or_lemma") + imps = str(rec.get("imports", "")).lower() + if "formalconjectures" in imps or "formal_conjectures" in imps: + raise C.ContractError("drop:needs_nonmathlib_imports") + if not rec.get("name") or not rec.get("decl_tail"): + raise C.ContractError("drop:empty_statement") + raw = f"{kind} {rec['name']} {rec['decl_tail'].strip()} := by sorry" + return C.GOEDEL_HEADER + raw, {"constructed_from_declaration": True} + raw = rec.get("statement") + if not isinstance(raw, str) or not raw.strip(): + raise C.ContractError("drop:empty_statement") + raw = raw.strip("\n") + if rec.get("header"): + raw = rec["header"].rstrip() + "\n\n" + raw.lstrip() + if _BY_SORRY_TAIL.search(raw): # `:= sorry` term-mode placeholder + raw = _BY_SORRY_TAIL.sub(":= by sorry", raw) + extra["sorry_term_rewritten"] = True + elif _BY_TAIL.search(raw): # statement stored without the placeholder + raw = raw.rstrip() + " sorry" + extra["sorry_appended"] = True + elif ":= by" not in raw and re.search(r"\bsorry\b", raw) is None: + raw = raw.rstrip() + " := by sorry" # statement stored without any body + extra["body_appended"] = True + return raw, extra + + +def _open_names(header: str) -> set[str]: + out: set[str] = set() + for ln in header.splitlines(): + ln = ln.strip() + if ln.startswith("open "): + out.update(ln[5:].split()) + return out + + +def to_problem(rec: dict, source: Source, revision: str, pid: str) -> tuple[dict, dict]: + """Full conversion of one raw row -> (Problem record, side info). Raises ContractError('drop:').""" + raw, extra = assemble_statement(rec) + if len(raw) > MAX_STATEMENT_CHARS: + raise C.ContractError("drop:too_long") + try: + stmt = C.normalize_formal_statement(raw) + except C.ContractError: + raise C.ContractError("drop:not_single_by_sorry") + try: + name, header, _sig = C.statement_key(stmt) + except C.ContractError as e: + why = re.sub(r"`[^`]*`", "`X`", str(e).replace("unsupported statement: ", "")) + raise C.ContractError(f"drop:unsupported_shape: {why}"[:120]) + prov_header = {} + if header.rstrip() != C.GOEDEL_HEADER.rstrip(): + parsed = C._strict().parse_statement(stmt) + body = stmt[len(parsed.header_text):].lstrip("\n") + stmt = C.GOEDEL_HEADER + body + prov_header = {"header_rewritten": True, "original_header": header.strip()[:600]} + extra_open = sorted(_open_names(header) - _open_names(C.GOEDEL_HEADER)) + if extra_open: # the rewrite drops namespaces the statement may rely on: elaboration decides + prov_header["header_extra_opens"] = extra_open + try: # the rewritten statement must still be a supported shape + C.statement_key(stmt) + except C.ContractError: + raise C.ContractError("drop:unsupported_after_header_rewrite") + prob = { + "problem_id": pid, "source": source.problem_source, "split": "train", "formal_statement": stmt, + "informal_prefix": rec.get("informal") if isinstance(rec.get("informal"), str) and rec["informal"].strip() else None, + "statement_sha": C.statement_sha(stmt), + "provenance": {"source_id": source.repo_id, "revision": revision, "row_index": rec["row_index"], + "license": source.license, "theorem_name": name, **extra, **prov_header, **rec.get("extra", {})}, + "ancestry": {"root": pid, "parents": []}, + } + C.validate_problem(prob) + return prob, {"header_rewritten": bool(prov_header)} + + +def ingest_source(src: Source, revision: str, out_dir: Path, args) -> dict: + counts: Counter = Counter() + drop_examples: dict[str, list] = {} + seen: set[str] = set() + t0 = time.time() + out_path = out_dir / f"{src.key}.jsonl" + tmp = out_path.with_name(out_path.name + f".tmp{os.getpid()}") + try: + with open(tmp, "w", encoding="utf-8") as f: + for rec in src.iterate(src, revision, args): + counts["rows_seen"] += 1 + if rec.get("english") is False: + counts["drop:not_english"] += 1 + continue + pid = f"{src.key}-{rec['row_index']}" + try: + prob, side = to_problem(rec, src, revision, pid) + except C.ContractError as e: + why = str(e) + if not why.startswith("drop:"): + raise + counts[why] += 1 + ex = drop_examples.setdefault(why, []) + if len(ex) < 3: + ex.append({"row_index": rec["row_index"], "name": rec.get("name")}) + continue + if prob["statement_sha"] in seen: + counts["drop:duplicate_statement"] += 1 + continue + seen.add(prob["statement_sha"]) + f.write(json.dumps(prob, ensure_ascii=False) + "\n") + counts["kept"] += 1 + counts["header_rewritten"] += int(side["header_rewritten"]) + counts["header_extra_opens"] += int("header_extra_opens" in prob["provenance"]) + if counts["rows_seen"] % 20000 == 0: + log.info("%s: %d rows, %d kept", src.key, counts["rows_seen"], counts["kept"]) + f.flush() + os.fsync(f.fileno()) + os.replace(tmp, out_path) + finally: + if tmp.exists(): + tmp.unlink() + (out_dir / f"{src.key}.drops.sample.json").write_text(json.dumps(drop_examples, indent=1, ensure_ascii=False)) + return {"source_id": src.repo_id, "revision": revision, "license": src.license, "note": src.note, + "counts": dict(counts), "seconds": round(time.time() - t0, 1), "output": str(out_path), + "output_sha256": C.sha256_file(out_path)} + + +def main(argv=None) -> int: + ap = argparse.ArgumentParser(description=__doc__.split("\n")[0]) + ap.add_argument("--out-dir", default=str(DEFAULT_OUT)) + ap.add_argument("--sources", nargs="+", default=DEFAULT_SOURCES, choices=sorted(SOURCES)) + ap.add_argument("--workers", type=int, default=2, help="parallel sources; hard-capped at 2") + ap.add_argument("--max-rows", type=int, default=0, help="cap rows per datasets-server source (0 = all)") + ap.add_argument("--nemotron-rows", type=int, default=60000) + ap.add_argument("--nemotron-stripes", type=int, default=8) + a = ap.parse_args(argv) + logging.basicConfig(level=logging.INFO, format="%(asctime)s %(name)s %(message)s") + out_dir = Path(a.out_dir) + out_dir.mkdir(parents=True, exist_ok=True) + revs = {k: repo_revision(SOURCES[k].repo_id) for k in a.sources} + log.info("revisions: %s", revs) + workers = max(1, min(2, a.workers)) + results: dict[str, Any] = {} + with ThreadPoolExecutor(max_workers=workers) as ex: + futs = {k: ex.submit(ingest_source, SOURCES[k], revs[k], out_dir, a) for k in a.sources} + for k, fu in futs.items(): + try: + results[k] = fu.result() + log.info("%s: %s", k, results[k]["counts"]) + except Exception as e: # one failed source must not discard the others' results + log.error("%s FAILED: %r", k, e) + results[k] = {"source_id": SOURCES[k].repo_id, "revision": revs[k], "error": repr(e)} + man = out_dir / "ingest_manifest.json" + prev = json.loads(man.read_text()) if man.exists() else {} + prev.update(results) + man.write_text(json.dumps(prev, indent=1, ensure_ascii=False)) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/code/scripts/p2/supply/triage.py b/code/scripts/p2/supply/triage.py new file mode 100644 index 0000000000000000000000000000000000000000..7c10eb6f73d6326d53a30423cd9649b257a499f6 --- /dev/null +++ b/code/scripts/p2/supply/triage.py @@ -0,0 +1,375 @@ +#!/usr/bin/env python +"""In-band triage classifier (plan v3 Phase 0a item 4): predict, from statement text alone (CPU baseline) or from +8B hidden states (GPU hook, computed later), whether the base model's pass count on a statement is IN-BAND +(1 <= k <= n-1 accepted samples out of n), i.e. neither never-solved nor always-solved. + +numpy only: hashed char n-gram features + a handful of dense statement features -> L2-regularised logistic +regression fit by full-batch Adam. Evaluation: stratified k-fold CV AUC (rank-based, ties averaged) and a +held-out split chosen by a hash of problem_id (not by row order), with problem-level bootstrap intervals, and the +AUC of trivial baselines (statement length) so "the classifier is better than length" is a number, not a feeling. + +Cautions recorded with the result (they are properties of the data, not of the code): + * labels are noisy: k out of 8 samples, so a problem with true p=0.9 is labelled in-band only ~57% of the time; + * the pool is one source (Goedel-Pset) filtered by the SAME model that labels it, and near-templated statements + (lean_workbook / orca_math) can sit on both sides of a random split. CV AUC is therefore optimistic for + OTHER sources: the real test is the transfer to the profiled sample of FormalMATH / Numina / Nemotron. + + .venv/bin/python scripts/p2/supply/triage.py train --problems .tmp/p2/problems/train_pool.jsonl \ + --verdicts .tmp/p2/r1/pool/ver_r0.jsonl --n 8 --out .tmp/p2/supply/triage + .venv/bin/python scripts/p2/supply/triage.py score --model .tmp/p2/supply/triage/model.npz \ + --problems .jsonl --out .triage.jsonl +""" + +from __future__ import annotations + +import argparse +import hashlib +import importlib.util +import json +import re +import sys +import zlib +from collections import defaultdict +from pathlib import Path +from typing import Iterable, Optional, Sequence + +import numpy as np + +REPO = Path(__file__).resolve().parents[3] +sys.path.insert(0, str(REPO)) + +N_FEATURES = 1 << 14 +DENSE_NAMES = ("log_chars", "log_tokens", "n_hyp", "n_digits", "n_quant", "n_real", "n_nat_int", "has_informal", "log_informal") + + +def _load_common(): + if "p2_common" in sys.modules: + return sys.modules["p2_common"] + spec = importlib.util.spec_from_file_location("p2_common", REPO / "scripts" / "p2" / "p2_common.py") + mod = importlib.util.module_from_spec(spec) + sys.modules["p2_common"] = mod + spec.loader.exec_module(mod) + return mod + + +# --------------------------------------------------------------------------- labels + + +def read_pass_counts(verdicts_path: str | Path, n: int = 8) -> dict[str, tuple[int, int]]: + """problem_id -> (accepted, total) from a verdict JSONL, keeping ONLY problems with exactly `n` distinct + samples (a problem still being generated has a partial count that would mislabel it). Tolerates a torn last + line (the file may be appended to by a live chain). Duplicate (problem, sample_idx) keeps the last verdict.""" + seen: dict[str, dict[int, bool]] = defaultdict(dict) + with open(verdicts_path, encoding="utf-8") as f: + for line in f: + try: + r = json.loads(line) + except json.JSONDecodeError: + continue + seen[r["problem_id"]][int(r["sample_idx"])] = bool(r["accepted"]) + return {p: (sum(v.values()), len(v)) for p, v in seen.items() if len(v) == n} + + +def in_band(k: int, n: int) -> int: + return int(1 <= k <= n - 1) + + +# --------------------------------------------------------------------------- features + + +def statement_body(statement: str) -> str: + s = re.sub(r"/-.*?-/", " ", statement, flags=re.S) + s = re.sub(r"--[^\n]*", "", s) + s = re.sub(r"^\s*(import|open|set_option)\b[^\n]*$", "", s, flags=re.M) + s = re.sub(r"\btheorem\s+\S+", "theorem", s, count=1) + return " ".join(s.replace(":= by sorry", "").split()) + + +def _hash_ngrams(text: str, ns: Sequence[int], salt: str, vec: dict[int, float]) -> None: + for n in ns: + for i in range(len(text) - n + 1): + h = zlib.crc32(f"{salt}{text[i : i + n]}".encode()) + idx, sign = h % N_FEATURES, 1.0 if (h >> 31) & 1 else -1.0 + vec[idx] = vec.get(idx, 0.0) + sign + + +def featurize(statement: str, informal: Optional[str] = None) -> tuple[np.ndarray, np.ndarray, np.ndarray]: + """-> (indices, values, dense). Signed hashing of char 2..4-grams of the statement body plus word unigrams and + bigrams of the informal text; values are sublinear (sign*log1p|c|) and the sparse part is L2-normalised.""" + body = statement_body(statement) + vec: dict[int, float] = {} + _hash_ngrams(body, (2, 3, 4), "s", vec) + iw = re.findall(r"[a-z0-9]+", (informal or "").lower()) + for i, w in enumerate(iw): + for g in (w, w + " " + iw[i + 1] if i + 1 < len(iw) else None): + if g: + h = zlib.crc32(("w" + g).encode()) + idx = h % N_FEATURES + vec[idx] = vec.get(idx, 0.0) + (1.0 if (h >> 31) & 1 else -1.0) + idx = np.fromiter(vec.keys(), dtype=np.int64, count=len(vec)) + val = np.array([np.sign(c) * np.log1p(abs(c)) for c in vec.values()], dtype=np.float64) + nrm = np.linalg.norm(val) + if nrm > 0: + val /= nrm + toks = body.split() + dense = np.array([ + np.log1p(len(body)), np.log1p(len(toks)), len(re.findall(r"\(h[\w₀-₉']*\s*:", body)), + len(re.findall(r"\d", body)) / max(1, len(body)) * 10, len(re.findall(r"[∀∃]", body)), + body.count("ℝ") + body.count("Real"), body.count("ℕ") + body.count("ℤ"), + float(bool(informal)), np.log1p(len(iw)), + ], dtype=np.float64) + return idx, val, dense + + +def build_matrix(rows: Sequence[dict]) -> np.ndarray: + """Dense float32 [n, N_FEATURES + len(DENSE_NAMES)]. 850 rows is ~56 MB; for large sets use `batches`.""" + X = np.zeros((len(rows), N_FEATURES + len(DENSE_NAMES)), dtype=np.float32) + for r, row in enumerate(rows): + idx, val, dense = featurize(row["formal_statement"], row.get("informal_prefix")) + X[r, idx] = val + X[r, N_FEATURES:] = dense + return X + + +def batches(rows: Iterable[dict], size: int = 2048): + buf: list[dict] = [] + for r in rows: + buf.append(r) + if len(buf) >= size: + yield buf, build_matrix(buf) + buf = [] + if buf: + yield buf, build_matrix(buf) + + +# --------------------------------------------------------------------------- GPU hook + + +class HiddenStateFeatures: + """Hook for 8B hidden-state features, computed LATER on GPU (not here: no GPU in this lane). + + Contract: a `.npz` with `ids` (str array of problem_id) and `X` (float32 [n, d]) (or a directory holding + `ids.json` + `X.npy`, same content) -- e.g. the mean-pooled + last-layer (or a middle-layer) hidden state of the Goedel-Prover-V2-8B prompt tokens of each statement, one + forward pass, no sampling. `train --hidden-npz` aligns it to the labelled problems by id and fits the same + logistic regression on it (alone, or concatenated with the text features via --combine).""" + + def __init__(self, path: str | Path): + p = Path(path) + if p.is_dir(): + ids, X = json.loads((p / "ids.json").read_text()), np.load(p / "X.npy", allow_pickle=False) + else: + z = np.load(p, allow_pickle=False) + ids, X = z["ids"], z["X"] + if len(ids) != len(X): + raise ValueError(f"{len(ids)} ids but {len(X)} feature rows") + self.index = {str(i): j for j, i in enumerate(ids)} + self.X = X.astype(np.float32) + + def matrix(self, ids: Sequence[str]) -> np.ndarray: + miss = [i for i in ids if i not in self.index] + if miss: + raise KeyError(f"{len(miss)} problems have no hidden-state row, e.g. {miss[:3]}") + return self.X[[self.index[i] for i in ids]] + + @staticmethod + def compute_on_gpu(*_a, **_k): # pragma: no cover + raise NotImplementedError("run on GPU in a heavy-lock window: forward the prompt through the 8B, pool, np.savez(ids=..., X=...)") + + +# --------------------------------------------------------------------------- model + + +def auc(y: np.ndarray, s: np.ndarray) -> float: + """Rank AUC with average ranks for ties (== Mann-Whitney U / (n_pos n_neg)).""" + y = np.asarray(y).astype(bool) + npos, nneg = int(y.sum()), int((~y).sum()) + if npos == 0 or nneg == 0: + return float("nan") + order = np.argsort(s, kind="mergesort") + ss = np.asarray(s)[order] + ranks = np.empty(len(s)) + i = 0 + while i < len(ss): + j = i + while j + 1 < len(ss) and ss[j + 1] == ss[i]: + j += 1 + ranks[order[i : j + 1]] = (i + j) / 2 + 1 + i = j + 1 + return float((ranks[y].sum() - npos * (npos + 1) / 2) / (npos * nneg)) + + +class Logistic: + """L2 logistic regression, full-batch Adam. Features are standardised using training statistics (dense part + only; the sparse part is already unit-norm). Intercept unpenalised.""" + + def __init__(self, l2: float = 1e-2, iters: int = 400, lr: float = 0.05, seed: int = 0): + self.l2, self.iters, self.lr, self.seed = l2, iters, lr, seed + self.w: Optional[np.ndarray] = None + self.b = 0.0 + self.mu: Optional[np.ndarray] = None + self.sd: Optional[np.ndarray] = None + + def _prep(self, X: np.ndarray) -> np.ndarray: + return (X - self.mu) / self.sd + + def fit(self, X: np.ndarray, y: np.ndarray, sample_weight: Optional[np.ndarray] = None) -> "Logistic": + X = X.astype(np.float64) + self.mu = X.mean(0) + self.sd = X.std(0) + self.sd[self.sd < 1e-8] = 1.0 + # sparse hashed columns are not standardised: centring a sparse signed-hash column adds noise + self.mu[:N_FEATURES] = 0.0 + self.sd[:N_FEATURES] = 1.0 + if X.shape[1] <= N_FEATURES: # a non-text feature matrix (hidden states): standardise everything + self.mu, self.sd = X.mean(0), np.maximum(X.std(0), 1e-8) + Z = self._prep(X) + n, d = Z.shape + sw = np.ones(n) if sample_weight is None else sample_weight / sample_weight.mean() + w, b = np.zeros(d), float(np.log((y.mean() + 1e-3) / (1 - y.mean() + 1e-3))) + m, v, mb, vb = np.zeros(d), np.zeros(d), 0.0, 0.0 + for t in range(1, self.iters + 1): + p = 1 / (1 + np.exp(-(Z @ w + b))) + r = (p - y) * sw + g = Z.T @ r / n + self.l2 * w + gb = r.mean() + m, v = 0.9 * m + 0.1 * g, 0.999 * v + 0.001 * g * g + mb, vb = 0.9 * mb + 0.1 * gb, 0.999 * vb + 0.001 * gb * gb + mh, vh = m / (1 - 0.9**t), v / (1 - 0.999**t) + w -= self.lr * mh / (np.sqrt(vh) + 1e-8) + b -= self.lr * (mb / (1 - 0.9**t)) / (np.sqrt(vb / (1 - 0.999**t)) + 1e-8) + self.w, self.b = w, b + return self + + def decision(self, X: np.ndarray) -> np.ndarray: + return self._prep(X.astype(np.float64)) @ self.w + self.b + + def proba(self, X: np.ndarray) -> np.ndarray: + return 1 / (1 + np.exp(-self.decision(X))) + + +def stratified_folds(y: np.ndarray, k: int, seed: int) -> np.ndarray: + rs = np.random.RandomState(seed) + fold = np.zeros(len(y), dtype=int) + for c in (0, 1): + idx = np.where(y == c)[0] + rs.shuffle(idx) + fold[idx] = np.arange(len(idx)) % k + return fold + + +def cross_val_auc(X: np.ndarray, y: np.ndarray, k: int = 5, seed: int = 0, **kw) -> dict: + fold = stratified_folds(y, k, seed) + oof = np.zeros(len(y)) + per = [] + for f in range(k): + tr, te = fold != f, fold == f + m = Logistic(**kw).fit(X[tr], y[tr]) + oof[te] = m.decision(X[te]) + per.append(auc(y[te], oof[te])) + return {"fold_auc": [round(a, 4) for a in per], "mean": float(np.mean(per)), "std": float(np.std(per)), "oof_auc": auc(y, oof)} + + +def bootstrap_auc_ci(y: np.ndarray, s: np.ndarray, B: int = 1000, seed: int = 0) -> tuple[float, float]: + rs = np.random.RandomState(seed) + vals = [] + for _ in range(B): + i = rs.randint(0, len(y), len(y)) + a = auc(y[i], s[i]) + if not np.isnan(a): + vals.append(a) + return float(np.percentile(vals, 2.5)), float(np.percentile(vals, 97.5)) + + +def holdout_mask(ids: Sequence[str], frac: float = 0.25, salt: str = "triage-v1") -> np.ndarray: + """Deterministic by problem_id hash, independent of row order.""" + return np.array([int(hashlib.sha256((salt + i).encode()).hexdigest()[:8], 16) / 0xFFFFFFFF < frac for i in ids]) + + +# --------------------------------------------------------------------------- CLI + + +def cmd_train(a) -> int: + C = _load_common() + probs = {r["problem_id"]: r for r in C.iter_jsonl(a.problems)} + counts = read_pass_counts(a.verdicts, a.n) + ids = sorted(i for i in counts if i in probs) + rows = [probs[i] for i in ids] + k = np.array([counts[i][0] for i in ids]) + y = np.array([in_band(int(x), a.n) for x in k]) + print(f"labelled problems: {len(ids)} (of {len(counts)} with n={a.n} verdicts, {len(probs)} in pool); " + f"in-band {int(y.sum())} ({y.mean():.1%}); k histogram {np.bincount(k, minlength=a.n + 1).tolist()}") + Xt = build_matrix(rows) + hidden = HiddenStateFeatures(a.hidden_npz).matrix(ids) if a.hidden_npz else None + X = Xt if hidden is None else (np.hstack([Xt, hidden]) if a.combine else hidden) + held = holdout_mask(ids) + res: dict = {"n_labelled": len(ids), "in_band": int(y.sum()), "k_hist": np.bincount(k, minlength=a.n + 1).tolist(), + "features": "text_hash_char_ngram" + ("+hidden" if hidden is not None and a.combine else "_hidden" if hidden is not None else ""), + "n_train": int((~held).sum()), "n_heldout": int(held.sum()), "heldout_in_band": int(y[held].sum())} + best = None + for l2 in (1e-3, 1e-2, 1e-1, 1.0): # chosen by CV on the TRAIN part only; the held-out part is touched once + cv = cross_val_auc(X[~held], y[~held], k=5, seed=0, l2=l2) + res.setdefault("cv_by_l2", {})[str(l2)] = round(cv["oof_auc"], 4) + if best is None or cv["oof_auc"] > best[1]["oof_auc"]: + best = (l2, cv) + l2, cv = best + res["chosen_l2"], res["train_cv"] = l2, cv + m = Logistic(l2=l2).fit(X[~held], y[~held]) + s = m.decision(X[held]) + lo, hi = bootstrap_auc_ci(y[held], s) + res["heldout_auc"] = round(auc(y[held], s), 4) + res["heldout_auc_ci95"] = [round(lo, 4), round(hi, 4)] + # baselines: statement length, and "informal text length" -- a classifier that cannot beat these has learnt nothing + length = np.array([len(statement_body(r["formal_statement"])) for r in rows], dtype=float) + res["baseline_heldout_auc"] = {"statement_length": round(auc(y[held], length[held]), 4), + "neg_statement_length": round(auc(y[held], -length[held]), 4)} + # secondary targets: never solved vs not, always solved vs not (what the band filter actually removes) + for name, tgt in (("solved_at_least_once", (k >= 1).astype(int)), ("always_solved", (k == a.n).astype(int))): + mm = Logistic(l2=l2).fit(X[~held], tgt[~held]) + res.setdefault("secondary_heldout_auc", {})[name] = round(auc(tgt[held], mm.decision(X[held])), 4) + final = Logistic(l2=l2).fit(X, y) # refit on all labels for scoring + out = Path(a.out) + out.mkdir(parents=True, exist_ok=True) + np.savez(out / "model.npz", w=final.w, b=final.b, mu=final.mu, sd=final.sd, n_features=N_FEATURES) + (out / "triage_report.json").write_text(json.dumps(res, indent=1)) + print(json.dumps(res, indent=1)) + return 0 + + +def cmd_score(a) -> int: + C = _load_common() + z = np.load(a.model) + m = Logistic() + m.w, m.b, m.mu, m.sd = z["w"], float(z["b"]), z["mu"], z["sd"] + n = 0 + with open(a.out, "w", encoding="utf-8") as f: + for rows, X in batches(C.iter_jsonl(a.problems)): + for r, p in zip(rows, m.proba(X)): + f.write(json.dumps({"problem_id": r["problem_id"], "p_in_band": round(float(p), 5)}) + "\n") + n += len(rows) + print(f"scored {n} -> {a.out}") + return 0 + + +def main(argv=None) -> int: + ap = argparse.ArgumentParser(description=__doc__.split("\n")[0]) + sub = ap.add_subparsers(dest="cmd", required=True) + t = sub.add_parser("train") + t.add_argument("--problems", required=True) + t.add_argument("--verdicts", required=True) + t.add_argument("--n", type=int, default=8) + t.add_argument("--out", required=True) + t.add_argument("--hidden-npz", help="GPU features (ids, X); see HiddenStateFeatures") + t.add_argument("--combine", action="store_true", help="concatenate hidden-state and text features") + t.set_defaults(fn=cmd_train) + s = sub.add_parser("score") + s.add_argument("--model", required=True) + s.add_argument("--problems", required=True) + s.add_argument("--out", required=True) + s.set_defaults(fn=cmd_score) + a = ap.parse_args(argv) + return a.fn(a) + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/code/scripts/p2/verifier_hash.py b/code/scripts/p2/verifier_hash.py new file mode 100644 index 0000000000000000000000000000000000000000..b10330b07c57705a8e690c8570e7e6297ef5e50d --- /dev/null +++ b/code/scripts/p2/verifier_hash.py @@ -0,0 +1,21 @@ +#!/usr/bin/env python3 +"""Frozen-verifier hash (docs/LEAN_VERIFIER.md 7.2): sha256 over each align/verify/**/*.py in sorted path order, +hashing `path + NUL + bytes + NUL`. `--check HEX` exits 1 on mismatch.""" +import argparse, hashlib, sys +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[2] + + +def verifier_hash() -> str: + h = hashlib.sha256() + for p in sorted((ROOT / "align/verify").rglob("*.py")): + rel = p.relative_to(ROOT).as_posix() + h.update(rel.encode() + b"\0" + p.read_bytes() + b"\0") + return h.hexdigest() + + +if __name__ == "__main__": + ap = argparse.ArgumentParser(); ap.add_argument("--check") + a = ap.parse_args(); v = verifier_hash(); print(v) + sys.exit(1 if a.check and a.check != v else 0) diff --git a/code/scripts/p2/verify_generations.py b/code/scripts/p2/verify_generations.py new file mode 100644 index 0000000000000000000000000000000000000000..673783bb9918f4b0eaaedb23347c705f6fee40fc --- /dev/null +++ b/code/scripts/p2/verify_generations.py @@ -0,0 +1,371 @@ +#!/usr/bin/env python +"""Verify P2 Generation JSONL with the strict Lean verifier -> resumable Verification JSONL. + + .venv/bin/python scripts/p2/verify_generations.py \\ + --generations gens_r0.jsonl --problems minif2f.jsonl --out ver_r0.jsonl --workers 8 + +Output: one line per generation, keyed by (problem_id, sample_idx, round, cap_tokens): + + {problem_id, sample_idx, round, cap_tokens, accepted, reasons, verify_s, budget_s, + verifier: {pins, config, workers}, code_sha256, stage, diagnostics[, repl_errors, repl_code]} + +Properties (each one is a defect the first version of such a script has): + + * RESUMABLE. The file is append-only and fsynced per record; a rerun skips every key that is + already there. A line cut in half by SIGKILL is dropped on restart (`repair_torn_tail`); a + malformed line elsewhere raises (appends only ever damage the tail). + * NO STALE VERDICTS. Every record carries `code_sha256` of the code that was verified. A + resumed run whose generation file was regenerated (same keys, different samples) finds a + different hash and RAISES instead of attaching the old verdict to new text. + * NO LEAN FOR NOTHING. `finish_reason == "length"` (a truncated proof is not a proof) and + code-less samples are recorded as rejected with reasons ["no_code"] without calling Lean. + * A DEAD VERIFIER IS NOT A ZERO. Results whose reason is an infrastructure death (crash, + protocol, memory, output_too_large) are held back until their chunk is complete; if more + than half of a chunk of >= 8 died that way the run aborts WITHOUT writing them, so a + resume retries them. Otherwise they are written (a proof that kills its REPL scores 0). + * HOST RULES. >2 workers => exclusive heavy-job flock (waited for, not skipped) and the + host preflight; workers <= 8; scratch under /apps. + * `--feedback` additionally stores, for samples rejected with `compile_error`, `repl_errors` + (every Lean error: pos / endPos / data) and `repl_code` (the exact text the positions index: + Goedel's `compile_view(lean_code)`), which `extras/vllm_rollout/lean_gen.py` needs to build + correction prompts and which `StrictResult.diagnostics` (3 errors, 240 chars, no positions) + cannot supply. Rejections that are not compile errors (banned construct, statement + mismatch, timeout...) carry no Lean error list; the generator skips and counts them. +""" + +from __future__ import annotations + +import argparse +import importlib.util +import json +import logging +import os +import sys +import threading +import time +from dataclasses import asdict +from pathlib import Path +from typing import Callable, Optional + +REPO = Path(__file__).resolve().parents[2] +sys.path.insert(0, str(REPO)) + + +def _load_common(): + spec = importlib.util.spec_from_file_location("p2_common", Path(__file__).with_name("p2_common.py")) + mod = importlib.util.module_from_spec(spec) + sys.modules["p2_common"] = mod + spec.loader.exec_module(mod) + return mod + + +C = sys.modules.get("p2_common") or _load_common() +log = logging.getLogger("p2.verify") + +INFRA_REASONS = ("crash", "protocol", "memory", "output_too_large") +MAX_WORKERS = 8 + + +class StaleVerification(C.ContractError): + """An existing verification record belongs to different code than the generation now holds.""" + + +class VerifierDied(RuntimeError): + """More than half of a chunk died for infrastructure reasons; nothing from it was written.""" + + +class _NullWriter: + def append(self, rec) -> None: + pass + + def close(self) -> None: + pass + + +class Result: + """What the verify callback returns: the minimum the record needs.""" + + __slots__ = ("accepted", "reasons", "elapsed_s", "stage", "diagnostics", "feedback") + + def __init__(self, accepted, reasons, elapsed_s, stage="", diagnostics=None, feedback=None): + self.accepted = bool(accepted) + self.reasons = list(reasons) + self.elapsed_s = float(elapsed_s) + self.stage = stage + self.diagnostics = diagnostics or {} + self.feedback = feedback + + @property + def infra(self) -> bool: + return any(r in INFRA_REASONS for r in self.reasons) + + +# =========================================================================== core (verifier-agnostic) + + +def load_existing(path: Path) -> dict[tuple, dict]: + out: dict[tuple, dict] = {} + if not path.exists(): + return out + for rec in C.iter_jsonl(path): + for k in (*C.KEY_FIELDS, "accepted", "reasons", "verify_s", "budget_s", "verifier", "code_sha256"): + if k not in rec: + raise C.ContractError(f"{path}: verification record lacks {k!r}: {str(rec)[:200]}") + key = C.gen_key(rec) + if key in out: + raise C.ContractError(f"{path}: duplicate verification key {key} (the file is append-only)") + out[key] = rec + return out + + +def iter_generations(paths): + seen: set[tuple] = set() + for p in paths: + for rec in C.iter_jsonl(p): + C.validate_generation(rec) + key = C.gen_key(rec) + if key in seen: + raise C.ContractError(f"{p}: duplicate generation key {key} across the input files") + seen.add(key) + yield rec + + +def make_record(gen: dict, res: Result, budget_s: float, verifier_meta: dict) -> dict: + rec = { + "problem_id": gen["problem_id"], + "sample_idx": gen["sample_idx"], + "round": gen["round"], + "cap_tokens": gen["cap_tokens"], + "accepted": res.accepted, + "reasons": res.reasons, + "verify_s": round(res.elapsed_s, 4), + "budget_s": budget_s, + "verifier": verifier_meta, + "code_sha256": C.code_sha(gen["lean_code"]), + "stage": res.stage, + "diagnostics": res.diagnostics, + } + if res.feedback is not None: + rec.update(res.feedback) # repl_errors / repl_code (or repl_infra): top-level, as lean_gen.py reads them + return rec + + +def no_code_cause(gen: dict) -> Optional[str]: + if gen["finish_reason"] == "length": + return "finish_reason=length" + if gen["lean_code"] is None or not gen["lean_code"].strip(): + return "no_extracted_code" + return None + + +def verify_all( + gen_paths, + problems: dict[str, dict], + out_path: Path, + batch_fn: Callable[[list[tuple[str, str]], Callable[[int, Result], None]], None], + budget_s: float, + verifier_meta: dict, + chunk: int = 128, + limit: Optional[int] = None, + dry_run: bool = False, +) -> dict: + """Verify every generation not yet in `out_path`. `batch_fn(items, on_result)` must call + `on_result(index, Result)` once per item (from any thread). Returns run statistics.""" + out_path = Path(out_path) + torn = C.repair_torn_tail(out_path) + existing = load_existing(out_path) + stats = {"generations": 0, "already_done": 0, "no_code": 0, "verified": 0, "accepted": 0, "torn_bytes_removed": torn} + pending: list[dict] = [] + writer = _NullWriter() if dry_run else C.AppendJsonl(out_path) + try: + for gen in iter_generations(gen_paths): + stats["generations"] += 1 + if gen["problem_id"] not in problems: + raise C.ContractError(f"generation {C.gen_key(gen)}: problem_id not in the problems file(s)") + key = C.gen_key(gen) + if key in existing: + if existing[key]["code_sha256"] != C.code_sha(gen["lean_code"]): + raise StaleVerification( + f"{key}: the stored verdict was computed for different code (sha " + f"{existing[key]['code_sha256']} vs {C.code_sha(gen['lean_code'])}); the generation file was " + "regenerated under the same keys. Use a fresh --out, never reuse verdicts." + ) + stats["already_done"] += 1 + continue + cause = no_code_cause(gen) + if cause is not None: + rec = make_record(gen, Result(False, ["no_code"], 0.0, "skipped", {"cause": cause}), budget_s, verifier_meta) + writer.append(rec) + existing[key] = rec + stats["no_code"] += 1 + continue + pending.append(gen) + if limit is not None and len(pending) >= limit: + break + log.info("%d generations: %d already verified, %d no_code written, %d to verify", stats["generations"], + stats["already_done"], stats["no_code"], len(pending)) + stats["pending"] = len(pending) + t0 = time.monotonic() + for lo in range(0, 0 if dry_run else len(pending), chunk): + part = pending[lo : lo + chunk] + items = [(problems[g["problem_id"]]["formal_statement"], g["lean_code"]) for g in part] + held: list[tuple[int, Result]] = [] + lock = threading.Lock() + results: dict[int, Result] = {} + + def on_result(i: int, res: Result, part=part) -> None: + with lock: + results[i] = res + if res.infra: + held.append((i, res)) + return + writer.append(make_record(part[i], res, budget_s, verifier_meta)) + + batch_fn(items, on_result) + missing = [i for i in range(len(part)) if i not in results] + if missing: + raise RuntimeError(f"batch_fn returned without a result for items {missing[:5]}") + if len(part) >= 8 and len(held) / len(part) > 0.5: + raise VerifierDied( + f"{len(held)}/{len(part)} verifications of chunk {lo // chunk} died for infrastructure reasons " + f"({sorted({r for _, x in held for r in x.reasons})}); not writing them, so a resume retries. " + "Either the pool is dead or the policy is emitting REPL-killing proofs: look before rerunning." + ) + for i, res in held: + writer.append(make_record(part[i], res, budget_s, verifier_meta)) + for res in results.values(): + stats["verified"] += 1 + stats["accepted"] += int(res.accepted) + log.info("verified %d/%d (%.0fs elapsed, %d accepted so far)", min(lo + chunk, len(pending)), len(pending), + time.monotonic() - t0, stats["accepted"]) + finally: + writer.close() + return stats + + +# =========================================================================== real Lean backend + + +def collect_feedback(worker, statement: str, code: str, timeout_s: float = 120.0) -> Optional[dict]: + """Full Lean error list for `code`, in the form the correction-round prompt needs. + + Reproduces Goedel's compile step: `compile_view(code)` (import / set_option / open lines + dropped) is run in an environment that already holds the statement's header, and the + REPL's error dicts are kept as `repl_errors` with the exact text they index as `repl_code` + (`extras/vllm_rollout/lean_gen.py` joins on these two keys). `StrictResult.diagnostics` + cannot serve: it keeps 3 errors, 240 characters each, without positions. + + Returns None when the statement/code cannot be run at all (nothing to show a model).""" + from align.verify.lean.repl_pool import ReplError + from align.verify.lean.strict import lex, parse_statement + + ref = parse_statement(statement) + if not ref.ok or lex(code).broken: + return None + view = C.compile_view(code) + try: + env = worker.header_env(ref.header_text, "") + resp = worker.command(view, env, timeout_s) + except ReplError as e: + return {"repl_infra": e.code} + + def pos(p): + return None if not p else {"line": int(p["line"]), "column": int(p["column"])} + + errors = [ + {"pos": pos(m.get("pos")), "endPos": pos(m.get("endPos")), "data": m.get("data", "")} + for m in resp.get("messages", []) + if isinstance(m, dict) and m.get("severity") == "error" + ] + return {"repl_errors": errors, "repl_code": view} + + +def build_real(workers: int, budget_s: float, feedback: bool): + from align.verify.lean import ReplPool, StrictConfig, StrictVerifier, find_toolchain + from align.verify.lean.repl_pool import ReplLimits + + tc = find_toolchain() + cfg = StrictConfig(proof_timeout_s=budget_s) + pool = ReplPool.from_toolchain( + tc, n_workers=workers, limits=ReplLimits(), preflight=True, + scratch_dir=str(Path(os.environ.get("TMPDIR", str(REPO / ".tmp"))) / "p2_verify_pool"), + ) + verifier = StrictVerifier(pool, cfg) + cfgd = asdict(cfg) + cfgd["axiom_whitelist"] = sorted(cfgd["axiom_whitelist"]) + meta = {"pins": tc.pins, "config": cfgd, "workers": pool.n_workers} + + def batch_fn(items, on_result): + def one(w, ix_item): + ix, (stmt, code) = ix_item + sr = verifier._verify_on(w, (stmt, code)) + fb = None + if feedback and not sr.accepted and sr.reasons and sr.reasons[0] == "compile_error": + fb = collect_feedback(w, stmt, code) + on_result(ix, Result(sr.accepted, sr.reasons, sr.elapsed_s, sr.stage, _jsonable(sr.diagnostics), fb)) + return None + + out = pool.map(one, list(enumerate(items))) + for o in out: + if isinstance(o, BaseException): + raise o + + return batch_fn, meta, pool + + +def _jsonable(d: dict) -> dict: + return json.loads(json.dumps(d, default=str)) + + +# =========================================================================== CLI + + +def main(argv=None) -> int: + ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) + ap.add_argument("--generations", nargs="+", required=True) + ap.add_argument("--problems", nargs="+", required=True) + ap.add_argument("--out", required=True) + ap.add_argument("--workers", type=int, default=8) + ap.add_argument("--inherit-lock", type=int, default=None, metavar="PARENT_PID", + help="the heavy-job lock is already held by this (parent) pid; verify that and do not take it again") + ap.add_argument("--budget-s", type=float, default=180.0, help="per-proof wall budget (compile + audit); recorded in every row") + ap.add_argument("--feedback", action="store_true", help="store full Lean errors for compile_error rejections") + ap.add_argument("--chunk", type=int, default=128) + ap.add_argument("--limit", type=int, default=None, help="verify at most this many pending samples (smoke tests)") + ap.add_argument("--dry-run", action="store_true", help="count what would be verified; start no Lean") + a = ap.parse_args(argv) + logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(name)s: %(message)s") + if not 1 <= a.workers <= MAX_WORKERS: + raise SystemExit(f"--workers must be in [1, {MAX_WORKERS}]") + out = Path(a.out).resolve() + if not str(out).startswith("/apps/"): + raise SystemExit(f"--out {out} must be on /apps (never / or /tmp)") + + problems: dict[str, dict] = {} + for p in a.problems: + for pid, rec in C.load_problems(p).items(): + if pid in problems and problems[pid]["formal_statement"] != rec["formal_statement"]: + raise C.ContractError(f"problem {pid!r} appears in two problem files with different statements") + problems[pid] = rec + + if a.dry_run: + print(json.dumps(verify_all(a.generations, problems, out, None, a.budget_s, {}, a.chunk, a.limit, dry_run=True))) + return 0 + + from align.utils import guard + + if a.inherit_lock is not None: # a supervisor holds the lock for the whole job (scripts/p2/launch_baseline.py) + guard.inherit_heavy_job_lock(a.inherit_lock) + elif a.workers > 2: + log.info("waiting for the heavy-job lock (Lean pool with %d workers)", a.workers) + guard.acquire_heavy_job_lock("p2-verify-generations", blocking=True) + guard.preflight_host_check(strict=True) + batch_fn, meta, pool = build_real(a.workers, a.budget_s, a.feedback) + stats = verify_all(a.generations, problems, out, batch_fn, a.budget_s, meta, a.chunk, a.limit) + stats["pool"] = asdict(pool.stats) + print(json.dumps(stats)) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/code/scripts/teachers/check_snapshot.py b/code/scripts/teachers/check_snapshot.py new file mode 100644 index 0000000000000000000000000000000000000000..e51f01b490d6b1b8cbbeaecd5a9c3734264bc6c2 --- /dev/null +++ b/code/scripts/teachers/check_snapshot.py @@ -0,0 +1,131 @@ +#!/usr/bin/env python +"""Header-only completeness + config check of a HF safetensors snapshot (Llama / Qwen2 / Qwen3 dense). No weights loaded. + + python3 -I scripts/teachers/check_snapshot.py SNAPSHOT_DIR [--json] + +Checks: (1) config.json architecture is one this script knows and has no feature a vLLM-default loader would mishandle +silently (reports rope_scaling / sliding_window / tie_word_embeddings instead of refusing); (2) the index `weight_map` +names every tensor once and every shard it references exists; (3) each shard header parses and its declared extents equal +the file size (a truncated download fails here); (4) a tensor held by a different shard than the index names is a WARNING (--strict-index: a problem); (5) each +tensor shape equals the shape the config implies; (6) dtypes. Exit 0 = ok, 1 = problem. Memory: a few MB. +Does not replace scripts/p2/check_qwen3_snapshot.py, which additionally proves the key set maps into align's loader. +""" +from __future__ import annotations + +import argparse +import json +import os +import struct +import sys +from pathlib import Path + + +def read_header(path: Path) -> dict: + size = os.path.getsize(path) + with open(path, "rb") as f: + (n,) = struct.unpack(" 100 * 1024 * 1024 or 8 + n > size: + raise ValueError(f"{path.name}: implausible header length {n}") + h = json.loads(f.read(n)) + end = max((v["data_offsets"][1] for k, v in h.items() if k != "__metadata__"), default=0) + h["__ok__"] = 8 + n + end == size + return h + + +def expected_shapes(c: dict) -> dict[str, list[int]]: + L, H, I, V = c["num_hidden_layers"], c["hidden_size"], c["intermediate_size"], c["vocab_size"] + nh, nkv = c["num_attention_heads"], c.get("num_key_value_heads", c["num_attention_heads"]) + hd = c.get("head_dim") or H // nh + arch = c["architectures"][0] + s = {"model.embed_tokens.weight": [V, H], "model.norm.weight": [H]} + if not c.get("tie_word_embeddings", False): + s["lm_head.weight"] = [V, H] + for i in range(L): + p = f"model.layers.{i}." + s[p + "self_attn.q_proj.weight"] = [nh * hd, H] + s[p + "self_attn.k_proj.weight"] = [nkv * hd, H] + s[p + "self_attn.v_proj.weight"] = [nkv * hd, H] + s[p + "self_attn.o_proj.weight"] = [H, nh * hd] + s[p + "mlp.gate_proj.weight"] = [I, H] + s[p + "mlp.up_proj.weight"] = [I, H] + s[p + "mlp.down_proj.weight"] = [H, I] + s[p + "input_layernorm.weight"] = [H] + s[p + "post_attention_layernorm.weight"] = [H] + if arch == "Qwen3ForCausalLM": + s[p + "self_attn.q_norm.weight"] = [hd] + s[p + "self_attn.k_norm.weight"] = [hd] + if arch == "Qwen2ForCausalLM": # q/k/v biases + s[p + "self_attn.q_proj.bias"] = [nh * hd] + s[p + "self_attn.k_proj.bias"] = [nkv * hd] + s[p + "self_attn.v_proj.bias"] = [nkv * hd] + return s + + +def check(snap: Path, strict_index: bool = False) -> dict: + rep: dict = {"snapshot": str(snap), "problems": []} + P = rep["problems"] + c = json.loads((snap / "config.json").read_text()) + arch = (c.get("architectures") or ["?"])[0] + rep["config"] = {k: c.get(k) for k in ("architectures", "num_hidden_layers", "hidden_size", "intermediate_size", "num_attention_heads", + "num_key_value_heads", "head_dim", "vocab_size", "max_position_embeddings", "rope_theta", + "rope_scaling", "sliding_window", "tie_word_embeddings", "torch_dtype", "quantization_config")} + if arch not in ("LlamaForCausalLM", "Qwen2ForCausalLM", "Qwen3ForCausalLM"): + P.append(f"unknown architecture {arch}") + return rep + idx = snap / "model.safetensors.index.json" + if not idx.is_file(): + P.append("no model.safetensors.index.json") + return rep + wm = json.loads(idx.read_text())["weight_map"] + want = expected_shapes(c) + missing, extra = sorted(set(want) - set(wm)), sorted(set(wm) - set(want)) + rep["tensors"] = {"index": len(wm), "expected": len(want)} + if missing: + P.append(f"{len(missing)} tensors missing from index, e.g. {missing[:3]}") + if extra: + P.append(f"{len(extra)} unexpected tensors in index, e.g. {extra[:3]}") + dtypes: dict[str, int] = {} + total = 0 + seen: set[str] = set() + mismatched: list[str] = [] + for shard in sorted(set(wm.values())): + f = snap / shard + if not f.is_file(): + P.append(f"missing shard {shard}") + continue + total += f.stat().st_size + h = read_header(f) + if not h["__ok__"]: + P.append(f"shard {shard} truncated or padded (header extents != file size)") + for k, v in h.items(): + if k in ("__metadata__", "__ok__"): + continue + seen.add(k) + dtypes[v["dtype"]] = dtypes.get(v["dtype"], 0) + 1 + if wm.get(k) != shard: + mismatched.append(k) + if k in want and v["shape"] != want[k]: + P.append(f"{k}: shape {v['shape']} != expected {want[k]}") + if set(wm) - seen: + P.append(f"{len(set(wm) - seen)} indexed tensors absent from shard headers") + rep["dtypes"], rep["bytes"] = dtypes, total + if mismatched: + # every tensor exists exactly once, but the index points some at a different shard than the one holding them. A loader that + # opens every shard (vLLM, transformers) is unaffected; one that follows weight_map per tensor would fail. Upstream defect + # in Goedel-Prover-V2-32B (index byte-identical to the hub's). Warning, or a problem with --strict-index. + msg = f"{len(mismatched)} tensors are in a different shard than the index says, e.g. {mismatched[:2]}" + (P if strict_index else rep.setdefault("warnings", [])).append(msg) + rep["ok"] = not P + return rep + + +if __name__ == "__main__": + ap = argparse.ArgumentParser() + ap.add_argument("snapshot") + ap.add_argument("--json", action="store_true") + ap.add_argument("--strict-index", action="store_true", help="treat index-vs-shard assignment mismatches as problems") + a = ap.parse_args() + r = check(Path(a.snapshot), a.strict_index) + print(json.dumps(r, indent=1) if a.json else json.dumps({k: v for k, v in r.items() if k != "config"}, indent=1)) + print("config:", json.dumps(r["config"])) + sys.exit(0 if not r["problems"] else 1) diff --git a/code/scripts/teachers/download_teacher.py b/code/scripts/teachers/download_teacher.py new file mode 100644 index 0000000000000000000000000000000000000000..7d71936c1f3e59d36adae7225f75b3f15d109483 --- /dev/null +++ b/code/scripts/teachers/download_teacher.py @@ -0,0 +1,50 @@ +#!/usr/bin/env python +"""Disk-guarded snapshot download of an open Lean-prover teacher into HF_HOME (/apps only). + + source /apps/development/rlhf/env/apps_only.sh + PY=/apps/development/rlhf/mistral-prm-ppo/.venv/bin/python # any venv with huggingface_hub + align/.venv/bin/python scripts/hostguard.py run --cores 2 --mem-gb 8 --label dl --wait -- \ + $PY scripts/teachers/download_teacher.py REPO_ID [--revision SHA] [--min-free-gb 60] + +Refuses (exit 2) when free space on /apps minus the repo's weight bytes would fall below --min-free-gb. Sizes come from the +hub API (`blobs=true`), so the check happens BEFORE any byte is fetched. 2 workers, no xet. +""" +from __future__ import annotations + +import argparse +import os +import shutil +import sys + +os.environ.setdefault("HF_HUB_DISABLE_XET", "1") +if not os.environ.get("HF_HOME", "").startswith("/apps/"): + sys.exit("HF_HOME must be under /apps (source env/apps_only.sh)") + +from huggingface_hub import HfApi, snapshot_download # noqa: E402 + + +def main() -> int: + ap = argparse.ArgumentParser() + ap.add_argument("repo") + ap.add_argument("--revision", default=None) + ap.add_argument("--min-free-gb", type=float, default=60.0) + a = ap.parse_args() + info = HfApi().model_info(a.repo, revision=a.revision, files_metadata=True) + total = sum(s.size or 0 for s in info.siblings) + # a resumed download already holds part of the bytes (complete blobs and *.incomplete): count only what is still missing + blobs = os.path.join(os.environ["HF_HOME"], "hub", "models--" + a.repo.replace("/", "--"), "blobs") + have = sum(e.stat().st_size for e in os.scandir(blobs)) if os.path.isdir(blobs) else 0 + need = max(total - have, 0) + free = shutil.disk_usage("/apps").free + after = (free - need) / 1e9 + print(f"{a.repo}@{info.sha}: total {total / 1e9:.1f} GB, have {have / 1e9:.1f}, need {need / 1e9:.1f} GB, free {free / 1e9:.1f} GB, free after {after:.1f} GB (floor {a.min_free_gb})", flush=True) + if after < a.min_free_gb: + print("REFUSED: would cross the free-space floor", file=sys.stderr) + return 2 + p = snapshot_download(a.repo, revision=info.sha, max_workers=2) + print("snapshot:", p, flush=True) + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/code/tests/data/make_qwen2_unicode_tables.py b/code/tests/data/make_qwen2_unicode_tables.py new file mode 100644 index 0000000000000000000000000000000000000000..4bfc1ab8f33eb139b38c984a2913361e27d897ac --- /dev/null +++ b/code/tests/data/make_qwen2_unicode_tables.py @@ -0,0 +1,121 @@ +"""Regenerate align/tokenizer/_qwen2_unicode.py -- the Unicode tables the Qwen2 byte-level +tokenizer must use to be byte-identical with the HF/Rust tokenizer. + +RUN ONLY in the isolated rollout venv (it has transformers; the align venv must never): + + CUDA_VISIBLE_DEVICES= .venv-rollout/bin/python tests/data/make_qwen2_unicode_tables.py + +Why measured instead of taken from `unicodedata`: the Rust tokenizer's tables are NOT the +tables of any one Python. Measured against transformers 5.19.0 / tokenizers 0.23.3 +(Python 3.12.11, unicodedata 15.0.0): + + * regex classes (\\p{L}, \\p{N}) are NEWER than Python's: 5004 code points added in + Unicode 16 (Todhri, Garay, Tulu-Tigalari, Egyptian Hieroglyphs Ext-A, CJK Ext I, ...) + are letters/digits for the Rust side and unassigned (Cn) for Python 3.12, so "X's" + split differently ("X" + "'s" vs "X'" + "s"); + * the NFC normalizer is OLDER than Python's: 59 combining marks assigned in + Unicode 10-15 keep combining class 0 on the Rust side (never canonically reordered) + and U+11935 U+11930 does not compose to U+11938. + +This script probes the real HF tokenizer for every code point and freezes the answer. +""" +import os +import sys +import unicodedata as ud + +REPO = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) +SNAP = os.environ.get( + "GOEDEL_SNAPSHOT", + "/apps/development/rlhf/hf_cache/hub/models--Goedel-LM--Goedel-Prover-V2-8B/snapshots/" + "dfd02e6271a58375dfbf3ece0175277cf6b6a89a", +) +OUT = os.path.join(REPO, "align", "tokenizer", "_qwen2_unicode.py") + +import tokenizers # noqa: E402 +import transformers # noqa: E402 +from transformers import AutoTokenizer # noqa: E402 + +hf = AutoTokenizer.from_pretrained(SNAP) +pt = hf.backend_tokenizer.pre_tokenizer + +WHITE_SPACE = {c for a, b in ( + (0x09, 0x0D), (0x20, 0x20), (0x85, 0x85), (0xA0, 0xA0), (0x1680, 0x1680), + (0x2000, 0x200A), (0x2028, 0x2029), (0x202F, 0x202F), (0x205F, 0x205F), (0x3000, 0x3000), +) for c in range(a, b + 1)} + + +def spans(s): + return [o for _, o in pt.pre_tokenize_str(s)] + + +def ranges(cps): + out = [] + for c in sorted(cps): + if out and c == out[-1][1] + 1: + out[-1][1] = c + else: + out.append([c, c]) + return tuple((a, b) for a, b in out) + + +L, N = set(), set() +for cp in range(0x110000): + if 0xD800 <= cp <= 0xDFFF or cp in WHITE_SPACE: + continue + c = chr(cp) + if len(spans(c + c)) == 2: # \p{N}: one digit per piece + N.add(cp) + elif spans(c + "'s") == [(0, 1), (1, 3)]: # \p{L}+ then the 's contraction + L.add(cp) + +# ---- NFC: code points whose combining class the Rust side ignores / compositions it lacks +def reorders(cp): + c = chr(cp) + k = ud.combining(c) + s = ("́" + c) if k < 230 else (c + "̖") # 0301 is ccc 230, 0316 is ccc 220 + want = ud.normalize("NFC", s) + return hf.decode(hf(s, add_special_tokens=False)["input_ids"]) == want + + +ccc_ignored = {cp for cp in range(0x110000) + if not 0xD800 <= cp <= 0xDFFF and ud.combining(chr(cp)) and not reorders(cp)} +comp_second = set() +for cp in range(0x110000): + if 0xD800 <= cp <= 0xDFFF: + continue + c = chr(cp) + d = ud.decomposition(c) + if d and not d.startswith("<"): + nfd = ud.normalize("NFD", c) + if hf.decode(hf(nfd, add_special_tokens=False)["input_ids"]) != ud.normalize("NFC", nfd): + comp_second.add(ord(nfd[-1])) + +# sanity: where Python's table knows the code point, it must agree with the probe +py_l = {cp for cp in range(0x110000) if ud.category(chr(cp))[0] == "L"} +py_n = {cp for cp in range(0x110000) if ud.category(chr(cp))[0] == "N"} +print("L: rust", len(L), "python", len(py_l), "rust-only", len(L - py_l), "python-only", len(py_l - L)) +print("N: rust", len(N), "python", len(py_n), "rust-only", len(N - py_n), "python-only", len(py_n - N)) +print("ccc ignored:", len(ccc_ignored), "composition-second barriers:", sorted(map(hex, comp_second))) +assert not (py_l - L) and not (py_n - N), "Python knows letters/digits the Rust side lacks: investigate" + +with open(OUT, "w") as f: + f.write('"""GENERATED by tests/data/make_qwen2_unicode_tables.py -- do not edit.\n\n') + f.write(f"Probed from transformers {transformers.__version__} / tokenizers {tokenizers.__version__} " + f"against Python unicodedata {ud.unidata_version}.\nSee that script for why these are frozen.\n") + f.write('"""\n') + f.write(f"PYTHON_UNIDATA_VERSION = {ud.unidata_version!r} # the unicodedata these deltas were measured against\n\n") + f.write("#: \\p{L} / \\p{N} exactly as the Rust regex engine sees them (inclusive ranges).\n") + f.write("L_RANGES = (\n") + for a, b in ranges(L): + f.write(f" (0x{a:x}, 0x{b:x}),\n") + f.write(")\nN_RANGES = (\n") + for a, b in ranges(N): + f.write(f" (0x{a:x}, 0x{b:x}),\n") + f.write(")\n\n") + f.write("#: Code points the Rust NFC treats as combining-class 0 although unicodedata says otherwise,\n") + f.write("#: plus second members of compositions the Rust side lacks. NFC is applied around them.\n") + f.write("NFC_BARRIERS = (\n") + for a, b in ranges(ccc_ignored | comp_second): + f.write(f" (0x{a:x}, 0x{b:x}),\n") + f.write(")\n") +print("wrote", OUT) diff --git a/code/tests/data/make_qwen3_bytelevel_oracle.py b/code/tests/data/make_qwen3_bytelevel_oracle.py new file mode 100644 index 0000000000000000000000000000000000000000..322dc2ce64a2f41bc224f6ae7a0d19c9e297a89c --- /dev/null +++ b/code/tests/data/make_qwen3_bytelevel_oracle.py @@ -0,0 +1,199 @@ +"""Regenerate tests/data/qwen3_bytelevel_oracle.json.gz -- the frozen HF reference for +tests/test_tokenizer_bytelevel.py. + +RUN ONLY in the isolated rollout venv (it has transformers; the align venv must never): + + CUDA_VISIBLE_DEVICES= .venv-rollout/bin/python tests/data/make_qwen3_bytelevel_oracle.py + +transformers is imported HERE, as a separate process, to DUMP reference ids/decodes. +align itself never imports it (tests/test_dependency_firewall.py scans align/ and +scripts/ only; this file lives under tests/data and is data tooling, not runtime). + +Corpus: all 244 minif2f + 360 MOBench formal statements, the P2-B probe completions +(first 2000 chars each), Unicode stress strings, the exact chat-templated Goedel prompts +for the first 50 minif2f problems + correction-round prompts, chat-template cases, and +id sequences for decode. Ids are stored as zlib(uint32 LE) base64 to keep the file small. +""" +import array +import base64 +import gzip +import json +import os +import random +import sys +import zlib + +REPO = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) +SNAP = os.environ.get( + "GOEDEL_SNAPSHOT", + "/apps/development/rlhf/hf_cache/hub/models--Goedel-LM--Goedel-Prover-V2-8B/snapshots/" + "dfd02e6271a58375dfbf3ece0175277cf6b6a89a", +) +DATASETS = "/apps/development/rlhf/reference-repos/Goedel-Prover-V2/dataset" +PROBE = os.path.join(REPO, ".tmp/p2/P2-B/probe_16k_seqs256.jsonl") +OUT = os.path.join(REPO, "tests/data/qwen3_bytelevel_oracle.json.gz") + +import unicodedata # noqa: E402 + +from transformers import AutoTokenizer # noqa: E402 + +sys.path.insert(0, REPO) +from extras.vllm_rollout import lean_prompts # noqa: E402 (stdlib-only at import) + +hf = AutoTokenizer.from_pretrained(SNAP) + + +def pack(ids): + return base64.b64encode(zlib.compress(array.array("I", ids).tobytes(), 9)).decode() + + +def jl(path): + with open(path) as f: + return [json.loads(x) for x in f if x.strip()] + + +STRESS = [ + "", " ", "\n", "a", "Hello, world!", "hello world", " leading", "trailing ", "a\n\nb\n\n\nc", + "line1\r\nline2\rline3\n", " \n ", "\t\tx\t", "x \t\n y", "a\u00a0b\u2003c\u3000d\u2028e\u2029f\x0bg\x0ch\x85i", + "a\x1cb\x1dc\x1ed\x1fe", "zero\u200bwidth\u200djoiner\ufeffbom", + "2024", "x=1234567890", "3.14159", "١٢٣٤", "²³ ½ ⅷ Ⅷ ①", "1,000,000", "a1b22c333", "0x1F", + "don't can't I'm we're they've she'll he'd it's", "DON'T I'M WE'RE THEY'VE SHE'LL HE'D IT'S", "'s 't 're 've 'm 'll 'd", + "'ſ", "'ſt", "x'ſ", "'S'T'RE", "rock'n'roll", "'quoted'", "o'clock", "''s", "'re's", + "∀ x ∈ ℝ, ∃ y, x < y → ⟨x, y⟩", "theorem foo (f : ℝ → ℝ) (h₀ : ∀ x, f x = 5 * x + 4) : f 1 = 9 := by", + "ℕ ℤ ℚ ℝ ℂ ∑ ∏ ∫ √ ≤ ≥ ≠ ≈ ∈ ∉ ⊂ ⊆ ∪ ∩ ¬ ∧ ∨ ↔ ⇒ λ π", "x₁ x₂ x₃ a⁻¹ x²", + "你好,世界!这是一个测试。", "日本語のテキスト、ひらがな、カタカナ", "한국어 텍스트", "Привет, мир! Ёж", "مرحبا بالعالم", "שלום עולם", + "😀 😃 👨‍👩‍👧‍👦 🏳️‍🌈 👍🏽 🇮🇳", "\U0001f600\U0001f601", "emoji😀attached", + "e\u0301", "a\u0300\u0301\u0302", "\u1100\u1161\u11a8", "\u0041\u030a vs \u00c5", "\u212b \u2126 \ufb01", + "caf\u00e9 cafe\u0301", "\u0958 \u0915\u093c", "fi fl ㎏ ㍿", + "<|im_start|>user\nhi<|im_end|>\n<|im_start|>assistant\n", "\nreason\n\n\nanswer", "<|endoftext|>", + "<|im_end|>\u0301", "<|im_end|>e\u0301<|im_start|>", "e<|im_end|>\u0301x", "<|im_start|", "<|IM_START|>", ">", + "", "<|im_start|><|im_end|>", "{}", "<|fim_prefix|>a<|fim_suffix|>b<|fim_middle|>", + "<|vision_pad|><|image_pad|><|video_pad|>", "<|box_start|>x<|box_end|>", "<|file_sep|><|repo_name|>", + "\x00\x01\x02\x7f", "a\x00b", "\ue000 private use", "\ufffe \uffff", "\U0010ffff", "\ufffd", + "\U00001c89 new in Unicode 16", "\U00010d4a garay digit", "\U0001e5d0 ol onal", "\U0000a7ce \U0000a7cf", "\U00031350 CJK ext H", + "\u0378 unassigned", "\U000e0001 tag", "\u180e mongolian vowel sep", + "x" * 5000, " " * 5000, "-" * 3000, "\n" * 2000, "1" * 3000, "ab " * 2000, "=" * 100 + "\n" + "=" * 100, + "a" + " " * 40 + "b", "a" + "\n" * 40 + " b", "\t" * 50 + "x", + "!!!???...", "...", "a,b;c:d", "(a)[b]{c}", "a_b_c", "__init__", "foo.bar(baz)", "$x^2$", "\\frac{1}{3}Bh", + "/-- doc -/\ntheorem t : True := trivial", "```lean4\nexample : 1 + 1 = 2 := by norm_num\n```", " have h : x = 1 := by\n simp\n <;> linarith\n", + "https://example.com/a?b=c&d=e#f", "user@example.com", "C:\\Users\\x", "a\u2028b", "x\u00a0\u00a0y", " \u00a0 ", + "it'sx", "'tis", "I'dnt", "she'lled", "'rest", "'very", "'Sx", "'REx", "we'veto", "they'rent", "i'mnot", + "'The 'This 'Time 'Maybe 'Mean 'Does 'REAL 'Still 'Total", "He said 'The End' and 'THIS IS IT'", + "word\u0301s", "1\u0301", "!\u0301", " \u0301", "\u0301", "\u0301\u0301a", +] + + +def main(): + out = {"snapshot": os.path.basename(SNAP), "hf_transformers": __import__("transformers").__version__, + "hf_tokenizers": __import__("tokenizers").__version__, "texts": [], "id_seqs": [], "chat": []} + + texts = [] # (group, text) + for r in jl(os.path.join(DATASETS, "minif2f.jsonl")): + texts.append(("minif2f", r["formal_statement"])) + for r in jl(os.path.join(DATASETS, "MOBench.jsonl")): + texts.append(("mobench", r["formal_statement"])) + for r in jl(PROBE): + texts.append(("completion", r["completion_text"][:2000])) + for s in STRESS: + texts.append(("stress", s)) + + minif = jl(os.path.join(DATASETS, "minif2f.jsonl")) + for r in minif[:50]: + prompt, msgs = lean_prompts.initial_prompt(r["formal_statement"], hf) + texts.append(("chat_prompt", prompt)) + out["chat"].append({"messages": msgs, "kwargs": {"add_generation_prompt": True}, "expected": prompt}) + # correction rounds (assistant turn replayed, template rewrites it) + probe = jl(PROBE) + n_corr = 0 + for r, p in zip(minif[:20], probe[:20]): + try: + _, m0 = lean_prompts.initial_prompt(r["formal_statement"], hf) + prompt, msgs = lean_prompts.correction_prompt( + r["formal_statement"], m0, "\nplan\n\n\n" + p["completion_text"][:1500], + "import Mathlib\ntheorem x : True := by\n sorry", [], hf, 1) + except Exception as e: # noqa: BLE001 + print("correction prompt skipped:", repr(e)[:200]) + continue + texts.append(("chat_prompt", prompt)) + out["chat"].append({"messages": msgs, "kwargs": {"add_generation_prompt": True}, "expected": prompt}) + n_corr += 1 + print("correction prompts:", n_corr) + + for g, t in texts: + ids = hf.encode(t, add_special_tokens=False) + ids2 = hf(t)["input_ids"] + assert ids == ids2, ("add_special_tokens changes ids", g, t[:40]) + dec = hf.decode(ids) + dec_skip = hf.decode(ids, skip_special_tokens=True) + e = {"g": g, "t": t, "ids": pack(ids), "n": len(ids)} + nfc = None + # decode of the ids is the NFC text segment-wise; store explicit decode only when it is not t itself + if dec != t: + e["dec"] = dec + if dec_skip != dec: + e["dec_skip"] = dec_skip + out["texts"].append(e) + + # chat-template cases + U = lambda c: {"role": "user", "content": c} # noqa: E731 + A = lambda c, **k: {"role": "assistant", "content": c, **k} # noqa: E731 + S = lambda c: {"role": "system", "content": c} # noqa: E731 + cases = [ + [U("hi")], [S("be brief"), U("hi")], [U("hi"), A("hello")], [S("s"), U("q1"), A("a1"), U("q2")], + [U("q1"), A("\nr1\n\n\na1"), U("q2")], + [U("q1"), A("\nr1\n\n\na1"), U("q2"), A("\nr2\n\n\na2")], + [U("q1"), A("\nr1\n\n\na1")], + [U("q1"), A("a1 no think")], + [U("q1"), A("x"), A("\nr\n\n\ny")], + [U("q1"), A("\nr\n\n\ny"), A("\nr2\n\n\nz")], + [U("q1"), A("ans", reasoning_content="RC")], + [U("q1"), A("ans", reasoning_content="\n\nRC\n\n")], + [U("q1"), A("ans", reasoning_content="\n\nRC\n\n"), U("q2"), A("a2", reasoning_content="R2")], + [U("q1"), A("after")], [U("q1"), A("abc")], + [U("q1"), A("\n\n\nspaced\n\n\n\n\n\n\nbody\n\n")], + [U("q1"), A("\n\nleading newlines")], [U("q1"), A("")], [U(""), A("")], + [S("sys1"), S("sys2"), U("u")], [A("first is assistant"), U("u")], + [U("x"), A("a")], [U("u1"), U("u2")], + [S("only system")], [U("é e\u0301 😀 <|im_end|> literal")], + ] + for msgs in cases: + for kw in ({}, {"add_generation_prompt": True}, {"add_generation_prompt": True, "enable_thinking": False}, + {"add_generation_prompt": True, "enable_thinking": True}, {"enable_thinking": False}): + exp = hf.apply_chat_template(msgs, tokenize=False, **kw) + out["chat"].append({"messages": msgs, "kwargs": kw, "expected": exp}) + texts_ids = hf.encode(exp, add_special_tokens=False) + out["chat"][-1]["ids"] = pack(texts_ids) + + # id sequences for decode + rng = random.Random(1234) + V = len(hf) + seqs = [[i] for i in sorted(hf.get_added_vocab().values())] + seqs += [[rng.randrange(V) for _ in range(rng.randrange(1, 48))] for _ in range(150)] + for s in ("😀", "你好", "👨‍👩‍👧‍👦", "é", "\U0001f1ee\U0001f1f3"): + ids = hf.encode(s, add_special_tokens=False) + seqs += [ids, ids[:1], ids[1:], ids[:-1], ids[-1:], ids + ids[:1]] + seqs.append(ids[:1] + [151667] + ids[1:]) # added token splitting a character + seqs += [[151643, 9707, 151645], [9707, 151667, 15, 151668, 220, 151644, 872, 198], + [V - 1, V, V + 5, 151935, 151936, 200000, 9707], [], [9707] * 5] + for s in seqs: + rec = {"ids": s} + for skip in (False, True): + try: + rec["dec_skip" if skip else "dec"] = hf.decode(s, skip_special_tokens=skip) + except Exception as e: # noqa: BLE001 + rec["dec_skip" if skip else "dec"] = None + rec["err"] = type(e).__name__ + out["id_seqs"].append(rec) + + out["added_vocab"] = hf.get_added_vocab() + out["special_ids"] = sorted(hf.all_special_ids) + out["meta"] = {"vocab_size": hf.vocab_size, "len": len(hf), "eos": hf.eos_token_id, "pad": hf.pad_token_id, + "bos": hf.bos_token_id} + with gzip.open(OUT, "wt", encoding="utf-8", compresslevel=9) as f: + json.dump(out, f, ensure_ascii=False, separators=(",", ":")) + print("wrote", OUT, os.path.getsize(OUT), "bytes;", len(out["texts"]), "texts,", len(out["chat"]), "chat,", + len(out["id_seqs"]), "id seqs; tokens:", sum(e["n"] for e in out["texts"])) + + +if __name__ == "__main__": + main() diff --git a/code/tests/data/qwen3_bytelevel_oracle.json.gz b/code/tests/data/qwen3_bytelevel_oracle.json.gz new file mode 100644 index 0000000000000000000000000000000000000000..293f3fd24b9fd798ea3b97b07c085531f9733c3d --- /dev/null +++ b/code/tests/data/qwen3_bytelevel_oracle.json.gz @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:cf206ec5e8c5041ad56610f237ae49d7a5aed008d6f13ddcaad2645ae9b79144 +size 778388 diff --git a/code/tests/fixtures/k5_baseline_proofs.jsonl b/code/tests/fixtures/k5_baseline_proofs.jsonl new file mode 100644 index 0000000000000000000000000000000000000000..c9d796d426fad333d4f409006602c68ffd93466c --- /dev/null +++ b/code/tests/fixtures/k5_baseline_proofs.jsonl @@ -0,0 +1,20 @@ +{"problem_id": "induction_nfactltnexpnm1ngt3", "kind": "failed", "formal_statement": "import Mathlib\nimport Aesop\n\nset_option maxHeartbeats 0\n\nopen BigOperators Real Nat Topology Rat\n\n/-- Show that for any integer $n \\geq 3$, we have $n! < n^{n-1}$.-/\ntheorem induction_nfactltnexpnm1ngt3 (n : ℕ) (h₀ : 3 ≤ n) : n ! < n ^ (n - 1) := by sorry", "lean_code": "import Mathlib\nimport Aesop\n\nset_option maxHeartbeats 0\n\nopen BigOperators Real Nat Topology Rat\n\n\ntheorem induction_nfactltnexpnm1ngt3 (n : ℕ) (h₀ : 3 ≤ n) : n ! < n ^ (n - 1) := by \n have h₁ : ∀ (n : ℕ), 3 ≤ n → n ! < n ^ (n - 1) := by\n intro n hn\n have h₂ : ∀ (n : ℕ), 3 ≤ n → n ! < n ^ (n - 1) := by\n intro n hn\n induction' hn with n hn IH\n · \n norm_num [Nat.factorial]\n · \n cases n with\n | zero => contradiction \n | succ n =>\n cases n with\n | zero => contradiction \n | succ n =>\n cases n with\n | zero => contradiction \n | succ n =>\n simp_all [Nat.factorial, Nat.pow_succ, Nat.mul_assoc]\n <;>\n (try norm_num at *) <;>\n (try norm_num) <;>\n (try ring_nf at *) <;>\n (try ring_nf) <;>\n (try nlinarith) <;>\n (try\n {\n have h₃ : n + 3 > 0 := by linarith\n have h₄ : (n + 3 : ℕ) ^ (n + 2) > 0 := by positivity\n have h₅ : (n + 3 : ℕ) ^ (n + 2) > 0 := by positivity\n nlinarith [pow_pos (by linarith : (0 : ℕ) < n + 3) (n + 2)]\n }) <;>\n (try\n {\n have h₃ : (n + 3 : ℕ) ^ (n + 2) > 0 := by positivity\n have h₄ : (n + 3 : ℕ) ^ (n + 2) > 0 := by positivity\n nlinarith [pow_pos (by linarith : (0 : ℕ) < n + 3) (n + 2)]\n })\n exact h₂ n hn\n have h₂ : n ! < n ^ (n - 1) := by\n exact h₁ n h₀\n exact h₂", "repl_code": "\n\n\n\ntheorem induction_nfactltnexpnm1ngt3 (n : ℕ) (h₀ : 3 ≤ n) : n ! < n ^ (n - 1) := by \n have h₁ : ∀ (n : ℕ), 3 ≤ n → n ! < n ^ (n - 1) := by\n intro n hn\n have h₂ : ∀ (n : ℕ), 3 ≤ n → n ! < n ^ (n - 1) := by\n intro n hn\n induction' hn with n hn IH\n · \n norm_num [Nat.factorial]\n · \n cases n with\n | zero => contradiction \n | succ n =>\n cases n with\n | zero => contradiction \n | succ n =>\n cases n with\n | zero => contradiction \n | succ n =>\n simp_all [Nat.factorial, Nat.pow_succ, Nat.mul_assoc]\n <;>\n (try norm_num at *) <;>\n (try norm_num) <;>\n (try ring_nf at *) <;>\n (try ring_nf) <;>\n (try nlinarith) <;>\n (try\n {\n have h₃ : n + 3 > 0 := by linarith\n have h₄ : (n + 3 : ℕ) ^ (n + 2) > 0 := by positivity\n have h₅ : (n + 3 : ℕ) ^ (n + 2) > 0 := by positivity\n nlinarith [pow_pos (by linarith : (0 : ℕ) < n + 3) (n + 2)]\n }) <;>\n (try\n {\n have h₃ : (n + 3 : ℕ) ^ (n + 2) > 0 := by positivity\n have h₄ : (n + 3 : ℕ) ^ (n + 2) > 0 := by positivity\n nlinarith [pow_pos (by linarith : (0 : ℕ) < n + 3) (n + 2)]\n })\n exact h₂ n hn\n have h₂ : n ! < n ^ (n - 1) := by\n exact h₁ n h₀\n exact h₂", "repl_errors": [{"pos": {"line": 22, "column": 21}, "endPos": {"line": 42, "column": 18}, "data": "unsolved goals\ncase step.succ.succ.succ\nn✝² : ℕ\nh₀ : 3 ≤ n✝²\nn✝¹ : ℕ\nhn : 3 ≤ n✝¹\nn✝ n : ℕ\nIH :\n n * n ! * 11 + n ^ 2 * n ! * 6 + n ^ 3 * n ! + n ! * 6 < n * (3 + n) ^ n * 6 + n ^ 2 * (3 + n) ^ n + (3 + n) ^ n * 9\n⊢ n * n ! * 50 + n ^ 2 * n ! * 35 + n ^ 3 * n ! * 10 + n ^ 4 * n ! + n ! * 24 <\n n * (4 + n) ^ n * 48 + n ^ 2 * (4 + n) ^ n * 12 + n ^ 3 * (4 + n) ^ n + (4 + n) ^ n * 64"}]} +{"problem_id": "numbertheory_notequiv2i2jasqbsqdiv8", "kind": "failed", "formal_statement": "import Mathlib\nimport Aesop\n\nset_option maxHeartbeats 0\n\nopen BigOperators Real Nat Topology Rat\n\n/-- Let $a$ and $b$ be two integer numbers. Show that the following statement is false: $a$ and $b$ are both even iff $8 \\mid a^2 + b^2$.-/\ntheorem numbertheory_notequiv2i2jasqbsqdiv8 :\n ¬∀ a b : ℤ, (∃ i j, a = 2 * i ∧ b = 2 * j) ↔ ∃ k, a ^ 2 + b ^ 2 = 8 * k := by sorry", "lean_code": "import Mathlib\nimport Aesop\n\nset_option maxHeartbeats 0\n\nopen BigOperators Real Nat Topology Rat\n\n\ntheorem numbertheory_notequiv2i2jasqbsqdiv8 :\n ¬∀ a b : ℤ, (∃ i j, a = 2 * i ∧ b = 2 * j) ↔ ∃ k, a ^ 2 + b ^ 2 = 8 * k := by \n have h_main : ∃ (a b : ℤ), ¬((∃ i j, a = 2 * i ∧ b = 2 * j) ↔ ∃ k, a ^ 2 + b ^ 2 = 8 * k) := by\n use 2, 0\n intro h\n have h₁ : (∃ i j, (2 : ℤ) = 2 * i ∧ (0 : ℤ) = 2 * j) := by\n refine' ⟨1, 0, _⟩\n <;> norm_num\n have h₂ : ∃ k, (2 : ℤ) ^ 2 + (0 : ℤ) ^ 2 = 8 * k := by\n rw [h] at *\n exact by simpa [h₁] using h\n rcases h₂ with ⟨k, hk⟩\n have h₃ : (2 : ℤ) ^ 2 + (0 : ℤ) ^ 2 = 8 * k := by simpa using hk\n have h₄ : (8 : ℤ) * k = 4 := by linarith\n have h₅ : (k : ℤ) = 1 / 2 := by\n omega\n have h₆ : False := by\n have h₇ : (k : ℤ) * 8 = 4 := by linarith\n have h₈ : (k : ℤ) = 1 / 2 := by omega\n norm_num at h₈ ⊢\n <;>\n (try omega) <;>\n (try\n {\n ring_nf at h₈ ⊢\n <;> norm_cast at h₈ ⊢\n <;> omega\n })\n exact h₆\n \n have h_final : ¬∀ a b : ℤ, (∃ i j, a = 2 * i ∧ b = 2 * j) ↔ ∃ k, a ^ 2 + b ^ 2 = 8 * k := by\n intro h\n rcases h_main with ⟨a, b, h₁⟩\n have h₂ := h a b\n exact h₁ h₂\n \n exact h_final", "repl_code": "\n\n\n\ntheorem numbertheory_notequiv2i2jasqbsqdiv8 :\n ¬∀ a b : ℤ, (∃ i j, a = 2 * i ∧ b = 2 * j) ↔ ∃ k, a ^ 2 + b ^ 2 = 8 * k := by \n have h_main : ∃ (a b : ℤ), ¬((∃ i j, a = 2 * i ∧ b = 2 * j) ↔ ∃ k, a ^ 2 + b ^ 2 = 8 * k) := by\n use 2, 0\n intro h\n have h₁ : (∃ i j, (2 : ℤ) = 2 * i ∧ (0 : ℤ) = 2 * j) := by\n refine' ⟨1, 0, _⟩\n <;> norm_num\n have h₂ : ∃ k, (2 : ℤ) ^ 2 + (0 : ℤ) ^ 2 = 8 * k := by\n rw [h] at *\n exact by simpa [h₁] using h\n rcases h₂ with ⟨k, hk⟩\n have h₃ : (2 : ℤ) ^ 2 + (0 : ℤ) ^ 2 = 8 * k := by simpa using hk\n have h₄ : (8 : ℤ) * k = 4 := by linarith\n have h₅ : (k : ℤ) = 1 / 2 := by\n omega\n have h₆ : False := by\n have h₇ : (k : ℤ) * 8 = 4 := by linarith\n have h₈ : (k : ℤ) = 1 / 2 := by omega\n norm_num at h₈ ⊢\n <;>\n (try omega) <;>\n (try\n {\n ring_nf at h₈ ⊢\n <;> norm_cast at h₈ ⊢\n <;> omega\n })\n exact h₆\n \n have h_final : ¬∀ a b : ℤ, (∃ i j, a = 2 * i ∧ b = 2 * j) ↔ ∃ k, a ^ 2 + b ^ 2 = 8 * k := by\n intro h\n rcases h_main with ⟨a, b, h₁⟩\n have h₂ := h a b\n exact h₁ h₂\n \n exact h_final", "repl_errors": [{"pos": {"line": 15, "column": 15}, "endPos": {"line": 15, "column": 33}, "data": "type mismatch\n h✝\nhas type\n True : Prop\nbut is expected to have type\n ∃ k, 4 = 8 * k : Prop"}]} +{"problem_id": "imosl_2007_algebra_p6", "kind": "failed", "formal_statement": "import Mathlib\nimport Aesop\n\nset_option maxHeartbeats 0\n\nopen BigOperators Real Nat Topology Rat\n\n/-- For a series $\\{a_n\\}$, we have $\\sum_{n=0}^{99} a_{n+1}^2 = 1$. Show that $\\sum_{n=0}^{98} (a_{n+1}^2 a_{n+2}) + a_{100}^2 * a_1 < \\frac{12}{25}$.-/\ntheorem imosl_2007_algebra_p6 (a : ℕ → NNReal) (h₀ : (∑ x in Finset.range 100, a (x + 1) ^ 2) = 1) :\n (∑ x in Finset.range 99, a (x + 1) ^ 2 * a (x + 2)) + a 100 ^ 2 * a 1 < 12 / 25 := by sorry", "lean_code": "import Mathlib\nimport Aesop\n\nset_option maxHeartbeats 0\n\nopen BigOperators Real Nat Topology Rat\n\n\ntheorem imosl_2007_algebra_p6 (a : ℕ → NNReal) (h₀ : (∑ x in Finset.range 100, a (x + 1) ^ 2) = 1) :\n (∑ x in Finset.range 99, a (x + 1) ^ 2 * a (x + 2)) + a 100 ^ 2 * a 1 < 12 / 25 := by \n have h₁ : False := by\n \n have h₂ : a 100 ^ 2 * a 1 ≥ 0 := by positivity\n have h₃ : (∑ x in Finset.range 99, a (x + 1) ^ 2 * a (x + 2)) ≥ 0 := by positivity\n \n \n \n \n \n \n \n \n \n \n \n have h₄ : (∑ x in Finset.range 100, a (x + 1) ^ 2) = 1 := h₀\n have h₅ : a 100 ^ 2 ≤ 1 := by\n have h₅₁ : a 100 ^ 2 ≤ ∑ x in Finset.range 100, a (x + 1) ^ 2 := by\n have h₅₂ : a 100 ^ 2 ≤ ∑ x in Finset.range 100, a (x + 1) ^ 2 := by\n calc\n a 100 ^ 2 ≤ ∑ x in Finset.range 100, a (x + 1) ^ 2 := by\n \n \n \n \n \n \n exact Finset.single_le_sum (fun i _ => sq_nonneg (a (i + 1))) (Finset.mem_range.mpr (by norm_num : (99 : ℕ) < 100))\n _ = ∑ x in Finset.range 100, a (x + 1) ^ 2 := by rfl\n exact h₅₂\n calc\n a 100 ^ 2 ≤ ∑ x in Finset.range 100, a (x + 1) ^ 2 := h₅₁\n _ = 1 := h₄\n \n \n \n \n \n \n have h₆ : False := by\n \n \n \n \n \n \n norm_num [Finset.sum_range_succ, Finset.sum_range_zero, Nat.cast_zero, Nat.cast_add, Nat.cast_one] at h₄ h₅ ⊢\n <;>\n (try contradiction) <;>\n (try norm_num) <;>\n (try linarith) <;>\n (try ring_nf at h₄ h₅ ⊢) <;>\n (try simp_all [Finset.sum_range_succ, Finset.sum_range_zero, Nat.cast_zero, Nat.cast_add, Nat.cast_one]) <;>\n (try norm_num at h₄ h₅ ⊢) <;>\n (try linarith)\n exact h₆\n \n have h₂ : (∑ x in Finset.range 99, a (x + 1) ^ 2 * a (x + 2)) + a 100 ^ 2 * a 1 < 12 / 25 := by\n exfalso\n exact h₁\n \n exact h₂", "repl_code": "\n\n\n\ntheorem imosl_2007_algebra_p6 (a : ℕ → NNReal) (h₀ : (∑ x in Finset.range 100, a (x + 1) ^ 2) = 1) :\n (∑ x in Finset.range 99, a (x + 1) ^ 2 * a (x + 2)) + a 100 ^ 2 * a 1 < 12 / 25 := by \n have h₁ : False := by\n \n have h₂ : a 100 ^ 2 * a 1 ≥ 0 := by positivity\n have h₃ : (∑ x in Finset.range 99, a (x + 1) ^ 2 * a (x + 2)) ≥ 0 := by positivity\n \n \n \n \n \n \n \n \n \n \n \n have h₄ : (∑ x in Finset.range 100, a (x + 1) ^ 2) = 1 := h₀\n have h₅ : a 100 ^ 2 ≤ 1 := by\n have h₅₁ : a 100 ^ 2 ≤ ∑ x in Finset.range 100, a (x + 1) ^ 2 := by\n have h₅₂ : a 100 ^ 2 ≤ ∑ x in Finset.range 100, a (x + 1) ^ 2 := by\n calc\n a 100 ^ 2 ≤ ∑ x in Finset.range 100, a (x + 1) ^ 2 := by\n \n \n \n \n \n \n exact Finset.single_le_sum (fun i _ => sq_nonneg (a (i + 1))) (Finset.mem_range.mpr (by norm_num : (99 : ℕ) < 100))\n _ = ∑ x in Finset.range 100, a (x + 1) ^ 2 := by rfl\n exact h₅₂\n calc\n a 100 ^ 2 ≤ ∑ x in Finset.range 100, a (x + 1) ^ 2 := h₅₁\n _ = 1 := h₄\n \n \n \n \n \n \n have h₆ : False := by\n \n \n \n \n \n \n norm_num [Finset.sum_range_succ, Finset.sum_range_zero, Nat.cast_zero, Nat.cast_add, Nat.cast_one] at h₄ h₅ ⊢\n <;>\n (try contradiction) <;>\n (try norm_num) <;>\n (try linarith) <;>\n (try ring_nf at h₄ h₅ ⊢) <;>\n (try simp_all [Finset.sum_range_succ, Finset.sum_range_zero, Nat.cast_zero, Nat.cast_add, Nat.cast_one]) <;>\n (try norm_num at h₄ h₅ ⊢) <;>\n (try linarith)\n exact h₆\n \n have h₂ : (∑ x in Finset.range 99, a (x + 1) ^ 2 * a (x + 2)) + a 100 ^ 2 * a 1 < 12 / 25 := by\n exfalso\n exact h₁\n \n exact h₂", "repl_errors": [{"pos": {"line": 46, "column": 23}, "endPos": {"line": 61, "column": 20}, "data": "unsolved goals\na : ℕ → NNReal\nh₅ : a 100 ^ 2 ≤ 1\nh₄ :\n a 1 ^ 2 + a 2 ^ 2 + a 3 ^ 2 + a 4 ^ 2 + a 5 ^ 2 + a 6 ^ 2 + a 7 ^ 2 + a 8 ^ 2 + a 9 ^ 2 + a 10 ^ 2 + a 11 ^ 2 +\n a\n 12 ^\n 2 +\n a\n 13 ^\n 2 +\n a\n 14 ^\n 2 +\n a\n 15 ^\n 2 +\n a\n 16 ^\n 2 +\n a\n 17 ^\n 2 +\n a\n 18 ^\n 2 +\n a\n 19 ^\n 2 +\n a\n 20 ^\n 2 +\n a\n 21 ^\n 2 +\n a\n 22 ^\n 2 +\n a\n 23 ^\n 2 +\n a\n 24 ^\n 2 +\n a\n 25 ^\n 2 +\n a\n 26 ^\n 2 +\n a\n 27 ^\n 2 +\n a\n 28 ^\n 2 +\n a\n 29 ^\n 2 +\n a\n 30 ^\n 2 +\n a\n 31 ^\n 2 +\n a\n 32 ^\n 2 +\n a\n 33 ^\n 2 +\n a\n 34 ^\n 2 +\n a\n 35 ^\n 2 +\n a\n 36 ^\n 2 +\n a\n 37 ^\n 2 +\n a\n 38 ^\n 2 +\n a\n 39 ^\n 2 +\n a\n 40 ^\n 2 +\n a\n 41 ^\n 2 +\n a\n 42 ^\n 2 +\n a\n 43 ^\n 2 +\n a\n 44 ^\n 2 +\n a\n 45 ^\n 2 +\n a 46 ^\n 2 +\n a 47 ^\n 2 +\n a 48 ^ 2 +\n a 49 ^ 2 +\n a 50 ^ 2 +\n a 51 ^ 2 +\n a 52 ^ 2 +\n a 53 ^ 2 +\n a 54 ^ 2 +\n a 55 ^ 2 +\n a 56 ^ 2 +\n a 57 ^ 2 +\n a 58 ^ 2 +\n a 59 ^ 2 +\n a 60 ^ 2 +\n a 61 ^ 2 +\n a 62 ^ 2 +\n a 63 ^ 2 +\n a 64 ^ 2 +\n a 65 ^ 2 +\n a 66 ^ 2 +\n a 67 ^ 2 +\n a 68 ^ 2 +\n a 69 ^ 2 +\n a 70 ^ 2 +\n a 71 ^ 2 +\n a 72 ^ 2 +\n a 73 ^ 2 +\n a 74 ^ 2 +\n a 75 ^ 2 +\n a 76 ^ 2 +\n a 77 ^ 2 +\n a 78 ^ 2 +\n a 79 ^ 2 +\n a 80 ^ 2 +\n a 81 ^ 2 +\n a 82 ^ 2 +\n a 83 ^ 2 +\n a 84 ^ 2 +\n a 85 ^ 2 +\n a 86 ^ 2 +\n a 87 ^ 2 +\n a 88 ^ 2 +\n a 89 ^ 2 +\n a 90 ^ 2 +\n a 91 ^ 2 +\n a 92 ^ 2 +\n a 93 ^ 2 +\n a 94 ^ 2 +\n a 95 ^ 2 +\n a 96 ^ 2 +\n a 97 ^ 2 +\n a 98 ^ 2 +\n a 99 ^ 2 +\n a 100 ^ 2 =\n 1\n⊢ False"}]} +{"problem_id": "numbertheory_fxeq4powxp6powxp9powx_f2powmdvdf2pown", "kind": "failed", "formal_statement": "import Mathlib\nimport Aesop\n\nset_option maxHeartbeats 0\n\nopen BigOperators Real Nat Topology Rat\n\n/-- Let $f(x) = 4^x + 6^x + 9^x$. Show that if $m$ and $n$ are positive integers with $m \\leq n$, then $f(2^m)$ divides $f(2^n)$.-/\ntheorem numbertheory_fxeq4powxp6powxp9powx_f2powmdvdf2pown (m n : ℕ) (f : ℕ → ℕ)\n (h₀ : ∀ x, f x = 4 ^ x + 6 ^ x + 9 ^ x) (h₁ : 0 < m ∧ 0 < n) (h₂ : m ≤ n) :\n f (2 ^ m) ∣ f (2 ^ n) := by sorry", "lean_code": "import Mathlib\nimport Aesop\n\nset_option maxHeartbeats 0\n\nopen BigOperators Real Nat Topology Rat\n\n\ntheorem numbertheory_fxeq4powxp6powxp9powx_f2powmdvdf2pown (m n : ℕ) (f : ℕ → ℕ)\n (h₀ : ∀ x, f x = 4 ^ x + 6 ^ x + 9 ^ x) (h₁ : 0 < m ∧ 0 < n) (h₂ : m ≤ n) :\n f (2 ^ m) ∣ f (2 ^ n) := by \n have h_main : False := by\n have h₃ := h₁.1\n have h₄ := h₁.2\n have h₅ := h₂\n have h₆ : m ≤ n := h₂\n have h₇ : 0 < m := h₁.1\n have h₈ : 0 < n := h₁.2\n \n have h₉ := h₀ 2\n have h₁₀ := h₀ 3\n have h₁₁ := h₀ 4\n have h₁₂ := h₀ 8\n norm_num [pow_succ, pow_zero, pow_one, pow_two, pow_three] at h₉ h₁₀ h₁₁ h₁₂\n <;>\n (try omega) <;>\n (try\n {\n have h₁₃ := h₀ 1\n have h₁₄ := h₀ 0\n norm_num at h₁₃ h₁₄\n <;>\n omega\n }) <;>\n (try\n {\n have h₁₅ : m ≤ 3 := by\n by_contra h\n have h₁₆ : m ≥ 4 := by omega\n have h₁₇ : n ≥ m := by omega\n have h₁₈ : n ≥ 4 := by omega\n omega\n interval_cases m <;> interval_cases n <;> norm_num [h₀, pow_succ, pow_zero, pow_one, pow_two, pow_three] at h₉ h₁₀ h₁₁ h₁₂ ⊢ <;>\n (try omega) <;>\n (try {\n norm_num at h₉ h₁₀ h₁₁ h₁₂ ⊢\n <;>\n omega\n }) <;>\n (try {\n simp_all [Nat.dvd_iff_mod_eq_zero]\n <;>\n omega\n })\n }) <;>\n (try\n {\n have h₁₅ : m ≤ 4 := by\n by_contra h\n have h₁₆ : m ≥ 5 := by omega\n have h₁₇ : n ≥ m := by omega\n have h₁₈ : n ≥ 5 := by omega\n omega\n interval_cases m <;> interval_cases n <;> norm_num [h₀, pow_succ, pow_zero, pow_one, pow_two, pow_three] at h₉ h₁₀ h₁₁ h₁₂ ⊢ <;>\n (try omega) <;>\n (try {\n norm_num at h₉ h₁₀ h₁₁ h₁₂ ⊢\n <;>\n omega\n }) <;>\n (try {\n simp_all [Nat.dvd_iff_mod_eq_zero]\n <;>\n omega\n })\n }) <;>\n (try\n {\n omega\n })\n <;>\n (try\n {\n aesop\n })\n <;>\n (try\n {\n simp_all [h₀, pow_succ, pow_zero, pow_one, pow_two, pow_three]\n <;>\n ring_nf at *\n <;>\n norm_num at *\n <;>\n omega\n })\n <;>\n (try\n {\n omega\n })\n <;>\n (try\n {\n aesop\n })\n <;>\n (try\n {\n norm_num at *\n <;>\n omega\n })\n <;>\n (try\n {\n aesop\n })\n <;>\n (try\n {\n omega\n })\n <;>\n (try\n {\n aesop\n })\n <;>\n (try\n {\n norm_num at *\n <;>\n omega\n })\n <;>\n (try\n {\n aesop\n })\n \n have h_final : f (2 ^ m) ∣ f (2 ^ n) := by\n exfalso\n exact h_main\n \n exact h_final", "repl_code": "\n\n\n\ntheorem numbertheory_fxeq4powxp6powxp9powx_f2powmdvdf2pown (m n : ℕ) (f : ℕ → ℕ)\n (h₀ : ∀ x, f x = 4 ^ x + 6 ^ x + 9 ^ x) (h₁ : 0 < m ∧ 0 < n) (h₂ : m ≤ n) :\n f (2 ^ m) ∣ f (2 ^ n) := by \n have h_main : False := by\n have h₃ := h₁.1\n have h₄ := h₁.2\n have h₅ := h₂\n have h₆ : m ≤ n := h₂\n have h₇ : 0 < m := h₁.1\n have h₈ : 0 < n := h₁.2\n \n have h₉ := h₀ 2\n have h₁₀ := h₀ 3\n have h₁₁ := h₀ 4\n have h₁₂ := h₀ 8\n norm_num [pow_succ, pow_zero, pow_one, pow_two, pow_three] at h₉ h₁₀ h₁₁ h₁₂\n <;>\n (try omega) <;>\n (try\n {\n have h₁₃ := h₀ 1\n have h₁₄ := h₀ 0\n norm_num at h₁₃ h₁₄\n <;>\n omega\n }) <;>\n (try\n {\n have h₁₅ : m ≤ 3 := by\n by_contra h\n have h₁₆ : m ≥ 4 := by omega\n have h₁₇ : n ≥ m := by omega\n have h₁₈ : n ≥ 4 := by omega\n omega\n interval_cases m <;> interval_cases n <;> norm_num [h₀, pow_succ, pow_zero, pow_one, pow_two, pow_three] at h₉ h₁₀ h₁₁ h₁₂ ⊢ <;>\n (try omega) <;>\n (try {\n norm_num at h₉ h₁₀ h₁₁ h₁₂ ⊢\n <;>\n omega\n }) <;>\n (try {\n simp_all [Nat.dvd_iff_mod_eq_zero]\n <;>\n omega\n })\n }) <;>\n (try\n {\n have h₁₅ : m ≤ 4 := by\n by_contra h\n have h₁₆ : m ≥ 5 := by omega\n have h₁₇ : n ≥ m := by omega\n have h₁₈ : n ≥ 5 := by omega\n omega\n interval_cases m <;> interval_cases n <;> norm_num [h₀, pow_succ, pow_zero, pow_one, pow_two, pow_three] at h₉ h₁₀ h₁₁ h₁₂ ⊢ <;>\n (try omega) <;>\n (try {\n norm_num at h₉ h₁₀ h₁₁ h₁₂ ⊢\n <;>\n omega\n }) <;>\n (try {\n simp_all [Nat.dvd_iff_mod_eq_zero]\n <;>\n omega\n })\n }) <;>\n (try\n {\n omega\n })\n <;>\n (try\n {\n aesop\n })\n <;>\n (try\n {\n simp_all [h₀, pow_succ, pow_zero, pow_one, pow_two, pow_three]\n <;>\n ring_nf at *\n <;>\n norm_num at *\n <;>\n omega\n })\n <;>\n (try\n {\n omega\n })\n <;>\n (try\n {\n aesop\n })\n <;>\n (try\n {\n norm_num at *\n <;>\n omega\n })\n <;>\n (try\n {\n aesop\n })\n <;>\n (try\n {\n omega\n })\n <;>\n (try\n {\n aesop\n })\n <;>\n (try\n {\n norm_num at *\n <;>\n omega\n })\n <;>\n (try\n {\n aesop\n })\n \n have h_final : f (2 ^ m) ∣ f (2 ^ n) := by\n exfalso\n exact h_main\n \n exact h_final", "repl_errors": [{"pos": {"line": 8, "column": 25}, "endPos": {"line": 136, "column": 8}, "data": "unsolved goals\nm n : ℕ\nf : ℕ → ℕ\nh₀ : ∀ (x : ℕ), f x = 4 ^ x + 6 ^ x + 9 ^ x\nh₁ : 0 < m ∧ 0 < n\nh₂ : m ≤ n\nh₃ : 0 < m\nh₄ : 0 < n\nh₅ h₆ : m ≤ n\nh₇ : 0 < m\nh₈ : 0 < n\nh₉ : f 2 = 133\nh₁₀ : f 3 = 1009\nh₁₁ : f 4 = 8113\nh₁₂ : f 8 = 44791873\n⊢ False"}]} +{"problem_id": "aime_1987_p5", "kind": "failed", "formal_statement": "import Mathlib\nimport Aesop\n\nset_option maxHeartbeats 0\n\nopen BigOperators Real Nat Topology Rat\n\n/-- Find $3x^2 y^2$ if $x$ and $y$ are [[integer]]s such that $y^2 + 3x^2 y^2 = 30x^2 + 517$. Show that it is 588.-/\ntheorem aime_1987_p5 (x y : ℤ) (h₀ : y ^ 2 + 3 * (x ^ 2 * y ^ 2) = 30 * x ^ 2 + 517) :\n 3 * (x ^ 2 * y ^ 2) = 588 := by sorry", "lean_code": "import Mathlib\nimport Aesop\n\nset_option maxHeartbeats 0\n\nopen BigOperators Real Nat Topology Rat\n\n\ntheorem aime_1987_p5 (x y : ℤ) (h₀ : y ^ 2 + 3 * (x ^ 2 * y ^ 2) = 30 * x ^ 2 + 517) :\n 3 * (x ^ 2 * y ^ 2) = 588 := by \n have h_main : 3 * (x ^ 2 * y ^ 2) = 588 := by\n have h₁ : y ^ 2 + 3 * (x ^ 2 * y ^ 2) = 30 * x ^ 2 + 517 := h₀\n have h₂ : y ^ 2 = 30 * x ^ 2 - 71 := by\n \n have h₃ := h₁\n ring_nf at h₃ ⊢\n \n nlinarith [sq_nonneg (x ^ 2 - 4), sq_nonneg (x ^ 2 + 4), sq_nonneg (y - 7), sq_nonneg (y + 7),\n sq_nonneg (x - 2), sq_nonneg (x + 2), sq_nonneg (x ^ 2 - 2), sq_nonneg (x ^ 2 + 2)]\n have h₃ : x ≤ 14 := by\n nlinarith [sq_nonneg (x - 14), sq_nonneg (x + 14), sq_nonneg (y - 7), sq_nonneg (y + 7),\n sq_nonneg (x - 2), sq_nonneg (x + 2), sq_nonneg (x ^ 2 - 4), sq_nonneg (x ^ 2 + 4)]\n have h₄ : x ≥ -14 := by\n nlinarith [sq_nonneg (x - 14), sq_nonneg (x + 14), sq_nonneg (y - 7), sq_nonneg (y + 7),\n sq_nonneg (x - 2), sq_nonneg (x + 2), sq_nonneg (x ^ 2 - 4), sq_nonneg (x ^ 2 + 4)]\n \n interval_cases x <;> (try omega) <;> (try\n {\n have h₅ : y ≤ 14 := by\n nlinarith [sq_nonneg (y - 7), sq_nonneg (y + 7), sq_nonneg (y - 14), sq_nonneg (y + 14)]\n have h₆ : y ≥ -14 := by\n nlinarith [sq_nonneg (y - 7), sq_nonneg (y + 7), sq_nonneg (y - 14), sq_nonneg (y + 14)]\n interval_cases y <;> norm_num at h₂ ⊢ <;> (try omega) <;> (try nlinarith)\n }) <;> (try omega) <;> (try nlinarith) <;> (try\n {\n norm_num at h₂ ⊢\n <;>\n (try omega) <;>\n (try nlinarith) <;>\n (try\n {\n have h₅ : y ≤ 14 := by nlinarith\n have h₆ : y ≥ -14 := by nlinarith\n interval_cases y <;> norm_num at h₂ ⊢ <;> omega\n })\n })\n <;>\n (try omega) <;>\n (try nlinarith) <;>\n (try\n {\n have h₅ : y ≤ 14 := by nlinarith\n have h₆ : y ≥ -14 := by nlinarith\n interval_cases y <;> norm_num at h₂ ⊢ <;> omega\n })\n <;>\n (try omega) <;>\n (try nlinarith)\n <;>\n (try\n {\n have h₅ : y ≤ 14 := by nlinarith\n have h₆ : y ≥ -14 := by nlinarith\n interval_cases y <;> norm_num at h₂ ⊢ <;> omega\n })\n exact h_main", "repl_code": "\n\n\n\ntheorem aime_1987_p5 (x y : ℤ) (h₀ : y ^ 2 + 3 * (x ^ 2 * y ^ 2) = 30 * x ^ 2 + 517) :\n 3 * (x ^ 2 * y ^ 2) = 588 := by \n have h_main : 3 * (x ^ 2 * y ^ 2) = 588 := by\n have h₁ : y ^ 2 + 3 * (x ^ 2 * y ^ 2) = 30 * x ^ 2 + 517 := h₀\n have h₂ : y ^ 2 = 30 * x ^ 2 - 71 := by\n \n have h₃ := h₁\n ring_nf at h₃ ⊢\n \n nlinarith [sq_nonneg (x ^ 2 - 4), sq_nonneg (x ^ 2 + 4), sq_nonneg (y - 7), sq_nonneg (y + 7),\n sq_nonneg (x - 2), sq_nonneg (x + 2), sq_nonneg (x ^ 2 - 2), sq_nonneg (x ^ 2 + 2)]\n have h₃ : x ≤ 14 := by\n nlinarith [sq_nonneg (x - 14), sq_nonneg (x + 14), sq_nonneg (y - 7), sq_nonneg (y + 7),\n sq_nonneg (x - 2), sq_nonneg (x + 2), sq_nonneg (x ^ 2 - 4), sq_nonneg (x ^ 2 + 4)]\n have h₄ : x ≥ -14 := by\n nlinarith [sq_nonneg (x - 14), sq_nonneg (x + 14), sq_nonneg (y - 7), sq_nonneg (y + 7),\n sq_nonneg (x - 2), sq_nonneg (x + 2), sq_nonneg (x ^ 2 - 4), sq_nonneg (x ^ 2 + 4)]\n \n interval_cases x <;> (try omega) <;> (try\n {\n have h₅ : y ≤ 14 := by\n nlinarith [sq_nonneg (y - 7), sq_nonneg (y + 7), sq_nonneg (y - 14), sq_nonneg (y + 14)]\n have h₆ : y ≥ -14 := by\n nlinarith [sq_nonneg (y - 7), sq_nonneg (y + 7), sq_nonneg (y - 14), sq_nonneg (y + 14)]\n interval_cases y <;> norm_num at h₂ ⊢ <;> (try omega) <;> (try nlinarith)\n }) <;> (try omega) <;> (try nlinarith) <;> (try\n {\n norm_num at h₂ ⊢\n <;>\n (try omega) <;>\n (try nlinarith) <;>\n (try\n {\n have h₅ : y ≤ 14 := by nlinarith\n have h₆ : y ≥ -14 := by nlinarith\n interval_cases y <;> norm_num at h₂ ⊢ <;> omega\n })\n })\n <;>\n (try omega) <;>\n (try nlinarith) <;>\n (try\n {\n have h₅ : y ≤ 14 := by nlinarith\n have h₆ : y ≥ -14 := by nlinarith\n interval_cases y <;> norm_num at h₂ ⊢ <;> omega\n })\n <;>\n (try omega) <;>\n (try nlinarith)\n <;>\n (try\n {\n have h₅ : y ≤ 14 := by nlinarith\n have h₆ : y ≥ -14 := by nlinarith\n interval_cases y <;> norm_num at h₂ ⊢ <;> omega\n })\n exact h_main", "repl_errors": [{"pos": {"line": 14, "column": 6}, "endPos": {"line": 15, "column": 91}, "data": "linarith failed to find a contradiction\ncase h1.h\nx y : ℤ\nh₀ h₁ : y ^ 2 + 3 * (x ^ 2 * y ^ 2) = 30 * x ^ 2 + 517\nh₃ : y ^ 2 + y ^ 2 * x ^ 2 * 3 = 517 + x ^ 2 * 30\na✝ : y ^ 2 < -71 + x ^ 2 * 30\n⊢ False\nfailed"}]} +{"problem_id": "mathd_algebra_184", "kind": "failed", "formal_statement": "import Mathlib\nimport Aesop\n\nset_option maxHeartbeats 0\n\nopen BigOperators Real Nat Topology Rat\n\n/-- We have two geometric sequences of positive real numbers: $$6,a,b\\text{ and }\\frac{1}{b},a,54$$Solve for $a$. Show that it is 3\\sqrt{2}.-/\ntheorem mathd_algebra_184 (a b : NNReal) (h₀ : 0 < a ∧ 0 < b) (h₁ : a ^ 2 = 6 * b)\n (h₂ : a ^ 2 = 54 / b) : a = 3 * NNReal.sqrt 2 := by sorry", "lean_code": "import Mathlib\nimport Aesop\n\nset_option maxHeartbeats 0\n\nopen BigOperators Real Nat Topology Rat\n\n\ntheorem mathd_algebra_184 (a b : NNReal) (h₀ : 0 < a ∧ 0 < b) (h₁ : a ^ 2 = 6 * b)\n (h₂ : a ^ 2 = 54 / b) : a = 3 * NNReal.sqrt 2 := by \n have h_b_pos : 0 < b := by\n exact h₀.2\n \n have h_a_sq : a ^ 2 = 18 := by\n have h₃ : a ^ 2 = 6 * b := h₁\n have h₄ : a ^ 2 = 54 / b := h₂\n have h₅ : b > 0 := by exact_mod_cast h_b_pos\n have h₆ : (a ^ 2 : ℝ) = 6 * b := by exact_mod_cast h₃\n have h₇ : (a ^ 2 : ℝ) = 54 / b := by exact_mod_cast h₄\n have h₈ : (a ^ 2 : ℝ) = 18 := by\n have h₉ : (b : ℝ) > 0 := by exact_mod_cast h₅\n field_simp [h₉.ne'] at h₆ h₇ ⊢\n nlinarith [sq_nonneg ((a : ℝ) ^ 2 - 18)]\n exact_mod_cast h₈\n \n have h_a : a = 3 * NNReal.sqrt 2 := by\n have h₃ : a ^ 2 = 18 := h_a_sq\n have h₄ : 0 < a := by exact_mod_cast h₀.1\n have h₅ : a = 3 * NNReal.sqrt 2 := by\n \n \n have h₆ : (a : ℝ) ^ 2 = 18 := by exact_mod_cast h₃\n have h₇ : (a : ℝ) = 3 * Real.sqrt 2 := by\n \n have h₈ : 0 < (a : ℝ) := by exact_mod_cast h₄\n have h₉ : 0 < Real.sqrt 2 := Real.sqrt_pos.mpr (by norm_num)\n \n have h₁₀ : (a : ℝ) = 3 * Real.sqrt 2 := by\n \n nlinarith [Real.sq_sqrt (show 0 ≤ 2 by norm_num), Real.sqrt_nonneg 2,\n sq_nonneg ((a : ℝ) - 3 * Real.sqrt 2)]\n exact h₁₀\n \n have h₈ : (a : ℝ) = 3 * Real.sqrt 2 := h₇\n have h₉ : a = 3 * NNReal.sqrt 2 := by\n \n have h₁₀ : (a : ℝ) = 3 * Real.sqrt 2 := h₈\n have h₁₁ : a = 3 * NNReal.sqrt 2 := by\n \n apply Eq.symm\n apply Eq.symm\n norm_cast at h₁₀ ⊢\n <;>\n (try simp_all [mul_comm]) <;>\n (try ring_nf at * <;> nlinarith [Real.sqrt_nonneg 2, Real.sq_sqrt (show (0 : ℝ) ≤ 2 by norm_num)]) <;>\n (try field_simp at * <;> nlinarith [Real.sqrt_nonneg 2, Real.sq_sqrt (show (0 : ℝ) ≤ 2 by norm_num)])\n exact h₁₁\n exact h₉\n exact h₅\n \n exact h_a", "repl_code": "\n\n\n\ntheorem mathd_algebra_184 (a b : NNReal) (h₀ : 0 < a ∧ 0 < b) (h₁ : a ^ 2 = 6 * b)\n (h₂ : a ^ 2 = 54 / b) : a = 3 * NNReal.sqrt 2 := by \n have h_b_pos : 0 < b := by\n exact h₀.2\n \n have h_a_sq : a ^ 2 = 18 := by\n have h₃ : a ^ 2 = 6 * b := h₁\n have h₄ : a ^ 2 = 54 / b := h₂\n have h₅ : b > 0 := by exact_mod_cast h_b_pos\n have h₆ : (a ^ 2 : ℝ) = 6 * b := by exact_mod_cast h₃\n have h₇ : (a ^ 2 : ℝ) = 54 / b := by exact_mod_cast h₄\n have h₈ : (a ^ 2 : ℝ) = 18 := by\n have h₉ : (b : ℝ) > 0 := by exact_mod_cast h₅\n field_simp [h₉.ne'] at h₆ h₇ ⊢\n nlinarith [sq_nonneg ((a : ℝ) ^ 2 - 18)]\n exact_mod_cast h₈\n \n have h_a : a = 3 * NNReal.sqrt 2 := by\n have h₃ : a ^ 2 = 18 := h_a_sq\n have h₄ : 0 < a := by exact_mod_cast h₀.1\n have h₅ : a = 3 * NNReal.sqrt 2 := by\n \n \n have h₆ : (a : ℝ) ^ 2 = 18 := by exact_mod_cast h₃\n have h₇ : (a : ℝ) = 3 * Real.sqrt 2 := by\n \n have h₈ : 0 < (a : ℝ) := by exact_mod_cast h₄\n have h₉ : 0 < Real.sqrt 2 := Real.sqrt_pos.mpr (by norm_num)\n \n have h₁₀ : (a : ℝ) = 3 * Real.sqrt 2 := by\n \n nlinarith [Real.sq_sqrt (show 0 ≤ 2 by norm_num), Real.sqrt_nonneg 2,\n sq_nonneg ((a : ℝ) - 3 * Real.sqrt 2)]\n exact h₁₀\n \n have h₈ : (a : ℝ) = 3 * Real.sqrt 2 := h₇\n have h₉ : a = 3 * NNReal.sqrt 2 := by\n \n have h₁₀ : (a : ℝ) = 3 * Real.sqrt 2 := h₈\n have h₁₁ : a = 3 * NNReal.sqrt 2 := by\n \n apply Eq.symm\n apply Eq.symm\n norm_cast at h₁₀ ⊢\n <;>\n (try simp_all [mul_comm]) <;>\n (try ring_nf at * <;> nlinarith [Real.sqrt_nonneg 2, Real.sq_sqrt (show (0 : ℝ) ≤ 2 by norm_num)]) <;>\n (try field_simp at * <;> nlinarith [Real.sqrt_nonneg 2, Real.sq_sqrt (show (0 : ℝ) ≤ 2 by norm_num)])\n exact h₁₁\n exact h₉\n exact h₅\n \n exact h_a", "repl_errors": [{"pos": {"line": 44, "column": 44}, "endPos": {"line": 52, "column": 113}, "data": "unsolved goals\ncase h.h\na b : NNReal\nh₁ : 54 / b = b * 6\nh₂ : 18 = b * 6\nh_b_pos : 0 < b\nh₃ : a ^ 2 = b * 6\nh₄ : 0 < a\nh₆ : (3 * √2) ^ 2 = 18\nh₁₀ : ↑a = 3 * √2\n⊢ a = 3 * NNReal.sqrt 2"}]} +{"problem_id": "mathd_numbertheory_135", "kind": "failed", "formal_statement": "import Mathlib\nimport Aesop\n\nset_option maxHeartbeats 0\n\nopen BigOperators Real Nat Topology Rat\n\n/-- Let $n = 3^{17} + 3^{10}$. It is known that $11$ divides into $n+1$. If $n$ can be written in base $10$ as $ABCACCBAB$, where $A,B,C$ are distinct digits such that $A$ and $C$ are odd and $B$ is not divisible by $3$, find $100A + 10B + C$. Show that it is 129.-/\ntheorem mathd_numbertheory_135 (n A B C : ℕ) (h₀ : n = 3 ^ 17 + 3 ^ 10) (h₁ : 11 ∣ n + 1)\n (h₂ : [A, B, C].Pairwise (· ≠ ·)) (h₃ : {A, B, C} ⊂ Finset.Icc 0 9) (h₄ : Odd A ∧ Odd C)\n (h₅ : ¬3 ∣ B) (h₆ : Nat.digits 10 n = [B, A, B, C, C, A, C, B, A]) :\n 100 * A + 10 * B + C = 129 := by sorry", "lean_code": "import Mathlib\nimport Aesop\n\nset_option maxHeartbeats 0\n\nopen BigOperators Real Nat Topology Rat\n\n\ntheorem mathd_numbertheory_135 (n A B C : ℕ) (h₀ : n = 3 ^ 17 + 3 ^ 10) (h₁ : 11 ∣ n + 1)\n (h₂ : [A, B, C].Pairwise (· ≠ ·)) (h₃ : {A, B, C} ⊂ Finset.Icc 0 9) (h₄ : Odd A ∧ Odd C)\n (h₅ : ¬3 ∣ B) (h₆ : Nat.digits 10 n = [B, A, B, C, C, A, C, B, A]) :\n 100 * A + 10 * B + C = 129 := by \n have h_false : False := by\n have h₇ : n = 3 ^ 17 + 3 ^ 10 := h₀\n rw [h₇] at h₁\n norm_num [Nat.dvd_iff_mod_eq_zero, Nat.pow_mod, Nat.add_mod, Nat.mod_eq_of_lt] at h₁\n <;>\n (try omega) <;>\n (try\n {\n simp_all [List.Pairwise, List.map_cons, List.map_nil, List.map_singleton, List.cons_eq_append,\n List.append_assoc]\n <;>\n (try omega) <;>\n (try\n {\n rcases A with (_ | _ | _ | _ | _ | _ | _ | _ | _ | _ | _) <;>\n rcases B with (_ | _ | _ | _ | _ | _ | _ | _ | _ | _ | _) <;>\n rcases C with (_ | _ | _ | _ | _ | _ | _ | _ | _ | _ | _) <;>\n simp_all (config := {decide := true}) [Nat.odd_iff_not_even, Nat.even_iff, Nat.pow_succ] <;>\n norm_num at * <;>\n omega\n })\n })\n <;>\n (try\n {\n omega\n })\n <;>\n (try\n {\n simp_all [Finset.subset_iff, Finset.mem_Icc, Nat.dvd_iff_mod_eq_zero]\n <;> omega\n })\n \n have h_main : 100 * A + 10 * B + C = 129 := by\n exfalso\n exact h_false\n \n exact h_main", "repl_code": "\n\n\n\ntheorem mathd_numbertheory_135 (n A B C : ℕ) (h₀ : n = 3 ^ 17 + 3 ^ 10) (h₁ : 11 ∣ n + 1)\n (h₂ : [A, B, C].Pairwise (· ≠ ·)) (h₃ : {A, B, C} ⊂ Finset.Icc 0 9) (h₄ : Odd A ∧ Odd C)\n (h₅ : ¬3 ∣ B) (h₆ : Nat.digits 10 n = [B, A, B, C, C, A, C, B, A]) :\n 100 * A + 10 * B + C = 129 := by \n have h_false : False := by\n have h₇ : n = 3 ^ 17 + 3 ^ 10 := h₀\n rw [h₇] at h₁\n norm_num [Nat.dvd_iff_mod_eq_zero, Nat.pow_mod, Nat.add_mod, Nat.mod_eq_of_lt] at h₁\n <;>\n (try omega) <;>\n (try\n {\n simp_all [List.Pairwise, List.map_cons, List.map_nil, List.map_singleton, List.cons_eq_append,\n List.append_assoc]\n <;>\n (try omega) <;>\n (try\n {\n rcases A with (_ | _ | _ | _ | _ | _ | _ | _ | _ | _ | _) <;>\n rcases B with (_ | _ | _ | _ | _ | _ | _ | _ | _ | _ | _) <;>\n rcases C with (_ | _ | _ | _ | _ | _ | _ | _ | _ | _ | _) <;>\n simp_all (config := {decide := true}) [Nat.odd_iff_not_even, Nat.even_iff, Nat.pow_succ] <;>\n norm_num at * <;>\n omega\n })\n })\n <;>\n (try\n {\n omega\n })\n <;>\n (try\n {\n simp_all [Finset.subset_iff, Finset.mem_Icc, Nat.dvd_iff_mod_eq_zero]\n <;> omega\n })\n \n have h_main : 100 * A + 10 * B + C = 129 := by\n exfalso\n exact h_false\n \n exact h_main", "repl_errors": [{"pos": {"line": 9, "column": 26}, "endPos": {"line": 41, "column": 8}, "data": "unsolved goals\nn A B C : ℕ\nh₀ : n = 3 ^ 17 + 3 ^ 10\nh₂ : List.Pairwise (fun x x_1 => x ≠ x_1) [A, B, C]\nh₃ : {A, B, C} ⊂ Finset.Icc 0 9\nh₄ : Odd A ∧ Odd C\nh₅ : ¬3 ∣ B\nh₆ : digits 10 n = [B, A, B, C, C, A, C, B, A]\nh₇ : n = 3 ^ 17 + 3 ^ 10\nh₁ : True\n⊢ False"}]} +{"problem_id": "amc12b_2020_p6", "kind": "failed", "formal_statement": "import Mathlib\nimport Aesop\n\nset_option maxHeartbeats 0\n\nopen BigOperators Real Nat Topology Rat\n\n/-- For all integers $n \\geq 9,$ the value of\n$\\frac{(n+2)!-(n+1)!}{n!}$is always which of the following?\n\n$\\textbf{(A) } \\text{a multiple of 4} \\qquad \\textbf{(B) } \\text{a multiple of 10} \\qquad \\textbf{(C) } \\text{a prime number} \\qquad \\textbf{(D) } \\text{a perfect square} \\qquad \\textbf{(E) } \\text{a perfect cube}$ Show that it is \\textbf{(D) } \\text{a perfect square}.-/\ntheorem amc12b_2020_p6 (n : ℕ) (h₀ : 9 ≤ n) : ∃ x : ℕ, (x : ℝ) ^ 2 = ((n + 2)! - (n + 1)!) / n ! := by sorry", "lean_code": "import Mathlib\nimport Aesop\n\nset_option maxHeartbeats 0\n\nopen BigOperators Real Nat Topology Rat\n\n\ntheorem amc12b_2020_p6 (n : ℕ) (h₀ : 9 ≤ n) : ∃ x : ℕ, (x : ℝ) ^ 2 = ((n + 2)! - (n + 1)!) / n ! := by \n have h_main : ∃ x : ℕ, (x : ℝ) ^ 2 = ((n + 2)! - (n + 1)!) / n ! := by\n use n + 1\n have h₁ : ((n + 2)! - (n + 1)!) / n ! = (n + 1 : ℝ) ^ 2 := by\n have h₂ : (n + 2)! = (n + 2) * (n + 1)! := by\n simp [Nat.factorial_succ, Nat.mul_comm]\n <;> ring\n rw [h₂]\n have h₃ : (n + 2 : ℝ) * (n + 1)! - (n + 1)! = (n + 1 : ℝ) * (n + 1)! := by\n ring_nf\n <;> field_simp\n <;> ring_nf\n <;> nlinarith\n rw [h₃]\n have h₄ : (n + 1 : ℝ) * (n + 1)! / n ! = (n + 1 : ℝ) ^ 2 := by\n have h₅ : (n + 1)! = (n + 1) * n ! := by\n simp [Nat.factorial_succ, Nat.mul_comm]\n <;> ring\n rw [h₅]\n field_simp [Nat.factorial_ne_zero]\n <;> ring_nf\n <;> field_simp [Nat.factorial_ne_zero]\n <;> ring_nf\n <;> nlinarith\n simp_all [Nat.factorial_succ, Nat.mul_comm]\n <;> field_simp [Nat.factorial_ne_zero]\n <;> ring_nf\n <;> nlinarith\n simp_all [h₁]\n <;> norm_cast\n <;> ring_nf\n <;> nlinarith\n exact h_main", "repl_code": "\n\n\n\ntheorem amc12b_2020_p6 (n : ℕ) (h₀ : 9 ≤ n) : ∃ x : ℕ, (x : ℝ) ^ 2 = ((n + 2)! - (n + 1)!) / n ! := by \n have h_main : ∃ x : ℕ, (x : ℝ) ^ 2 = ((n + 2)! - (n + 1)!) / n ! := by\n use n + 1\n have h₁ : ((n + 2)! - (n + 1)!) / n ! = (n + 1 : ℝ) ^ 2 := by\n have h₂ : (n + 2)! = (n + 2) * (n + 1)! := by\n simp [Nat.factorial_succ, Nat.mul_comm]\n <;> ring\n rw [h₂]\n have h₃ : (n + 2 : ℝ) * (n + 1)! - (n + 1)! = (n + 1 : ℝ) * (n + 1)! := by\n ring_nf\n <;> field_simp\n <;> ring_nf\n <;> nlinarith\n rw [h₃]\n have h₄ : (n + 1 : ℝ) * (n + 1)! / n ! = (n + 1 : ℝ) ^ 2 := by\n have h₅ : (n + 1)! = (n + 1) * n ! := by\n simp [Nat.factorial_succ, Nat.mul_comm]\n <;> ring\n rw [h₅]\n field_simp [Nat.factorial_ne_zero]\n <;> ring_nf\n <;> field_simp [Nat.factorial_ne_zero]\n <;> ring_nf\n <;> nlinarith\n simp_all [Nat.factorial_succ, Nat.mul_comm]\n <;> field_simp [Nat.factorial_ne_zero]\n <;> ring_nf\n <;> nlinarith\n simp_all [h₁]\n <;> norm_cast\n <;> ring_nf\n <;> nlinarith\n exact h_main", "repl_errors": [{"pos": {"line": 18, "column": 10}, "endPos": {"line": 18, "column": 12}, "data": "tactic 'rewrite' failed, did not find instance of the pattern in the target expression\n (↑n + 2) * ↑(n + 1)! - ↑(n + 1)!\nn : ℕ\nh₀ : 9 ≤ n\nh₂ : (n + 2)! = (n + 2) * (n + 1)!\nh₃ : (↑n + 2) * ↑(n + 1)! - ↑(n + 1)! = (↑n + 1) * ↑(n + 1)!\n⊢ (↑((n + 2) * (n + 1)!) - ↑(n + 1)!) / ↑n ! = (↑n + 1) ^ 2"}]} +{"problem_id": "mathd_numbertheory_321", "kind": "failed", "formal_statement": "import Mathlib\nimport Aesop\n\nset_option maxHeartbeats 0\n\nopen BigOperators Real Nat Topology Rat\n\n/-- Notice that \\[35\\cdot40=1400.\\]Find some integer $n$ with $0\\leq n<1399$ such that $n$ is the multiplicative inverse to 160 modulo 1399. Show that it is 1058.-/\ntheorem mathd_numbertheory_321 (n : ZMod 1399) (h₁ : n = 160⁻¹) : n = 1058 := by sorry", "lean_code": "import Mathlib\nimport Aesop\n\nset_option maxHeartbeats 0\n\nopen BigOperators Real Nat Topology Rat\n\n\ntheorem mathd_numbertheory_321 (n : ZMod 1399) (h₁ : n = 160⁻¹) : n = 1058 := by \n have h₂ : (160 : ZMod 1399) * 1058 = 1 := by\n norm_num [ZMod.nat_cast_self]\n <;> rfl\n \n have h₃ : (160 : ZMod 1399)⁻¹ = 1058 := by\n have h₄ : (160 : ZMod 1399) * 1058 = 1 := h₂\n have h₅ : (160 : ZMod 1399) ≠ 0 := by\n norm_num [ZMod.nat_cast_self]\n <;> decide\n \n apply Eq.symm\n apply Eq.symm\n apply inv_eq_of_mul_eq_one_right\n <;> simp_all [h₄, h₅]\n <;> norm_num [ZMod.nat_cast_self]\n <;> rfl\n \n have h₄ : n = 1058 := by\n rw [h₁]\n rw [h₃]\n <;> rfl\n \n apply h₄", "repl_code": "\n\n\n\ntheorem mathd_numbertheory_321 (n : ZMod 1399) (h₁ : n = 160⁻¹) : n = 1058 := by \n have h₂ : (160 : ZMod 1399) * 1058 = 1 := by\n norm_num [ZMod.nat_cast_self]\n <;> rfl\n \n have h₃ : (160 : ZMod 1399)⁻¹ = 1058 := by\n have h₄ : (160 : ZMod 1399) * 1058 = 1 := h₂\n have h₅ : (160 : ZMod 1399) ≠ 0 := by\n norm_num [ZMod.nat_cast_self]\n <;> decide\n \n apply Eq.symm\n apply Eq.symm\n apply inv_eq_of_mul_eq_one_right\n <;> simp_all [h₄, h₅]\n <;> norm_num [ZMod.nat_cast_self]\n <;> rfl\n \n have h₄ : n = 1058 := by\n rw [h₁]\n rw [h₃]\n <;> rfl\n \n apply h₄", "repl_errors": [{"pos": {"line": 18, "column": 4}, "endPos": {"line": 18, "column": 36}, "data": "tactic 'apply' failed, failed to unify\n @Inv.inv ?G DivInvMonoid.toInv ?a = ?b\nwith\n @Inv.inv (ZMod 1399) (ZMod.instInv 1399) 160 = 1058\ncase h.h\nn : ZMod 1399\nh₁ : n = 160⁻¹\nh₂ h₄ : 160 * 1058 = 1\nh₅ : 160 ≠ 0\n⊢ 160⁻¹ = 1058"}]} +{"problem_id": "mathd_algebra_329", "kind": "failed", "formal_statement": "import Mathlib\nimport Aesop\n\nset_option maxHeartbeats 0\n\nopen BigOperators Real Nat Topology Rat\n\n/-- In a rectangular coordinate system, the line $3y = x$ intersects the line $2x + 5y = 11$ at point $A$. What is the sum of the coordinates of point $A$? Show that it is 4.-/\ntheorem mathd_algebra_329 (x y : ℝ) (h₀ : 3 * y = x) (h₁ : 2 * x + 5 * y = 11) : x + y = 4 := by sorry", "lean_code": "import Mathlib\nimport Aesop\n\nset_option maxHeartbeats 0\n\nopen BigOperators Real Nat Topology Rat\n\n\ntheorem mathd_algebra_329 (x y : ℝ) (h₀ : 3 * y = x) (h₁ : 2 * x + 5 * y = 11) : x + y = 4 := by \n have h_y : y = 1 := by\n have h₂ : 2 * x + 5 * y = 11 := h₁\n have h₃ : 3 * y = x := h₀\n \n have h₄ : 2 * (3 * y) + 5 * y = 11 := by\n rw [h₃] at h₂\n linarith\n \n have h₅ : 6 * y + 5 * y = 11 := by linarith\n have h₆ : 11 * y = 11 := by linarith\n have h₇ : y = 1 := by\n \n apply mul_left_cancel₀ (show (11 : ℝ) ≠ 0 by norm_num)\n nlinarith\n exact h₇\n \n have h_x : x = 3 := by\n have h₂ : x = 3 * y := by linarith\n rw [h₂, h_y]\n <;> ring\n <;> linarith\n \n have h_sum : x + y = 4 := by\n have h₃ : x = 3 := h_x\n have h₄ : y = 1 := h_y\n rw [h₃, h₄]\n <;> norm_num\n \n apply h_sum", "repl_code": "\n\n\n\ntheorem mathd_algebra_329 (x y : ℝ) (h₀ : 3 * y = x) (h₁ : 2 * x + 5 * y = 11) : x + y = 4 := by \n have h_y : y = 1 := by\n have h₂ : 2 * x + 5 * y = 11 := h₁\n have h₃ : 3 * y = x := h₀\n \n have h₄ : 2 * (3 * y) + 5 * y = 11 := by\n rw [h₃] at h₂\n linarith\n \n have h₅ : 6 * y + 5 * y = 11 := by linarith\n have h₆ : 11 * y = 11 := by linarith\n have h₇ : y = 1 := by\n \n apply mul_left_cancel₀ (show (11 : ℝ) ≠ 0 by norm_num)\n nlinarith\n exact h₇\n \n have h_x : x = 3 := by\n have h₂ : x = 3 * y := by linarith\n rw [h₂, h_y]\n <;> ring\n <;> linarith\n \n have h_sum : x + y = 4 := by\n have h₃ : x = 3 := h_x\n have h₄ : y = 1 := h_y\n rw [h₃, h₄]\n <;> norm_num\n \n apply h_sum", "repl_errors": [{"pos": {"line": 11, "column": 10}, "endPos": {"line": 11, "column": 12}, "data": "tactic 'rewrite' failed, did not find instance of the pattern in the target expression\n 3 * y\nx y : ℝ\nh₀ : 3 * y = x\nh₁ h₂ : 2 * x + 5 * y = 11\nh₃ : 3 * y = x\n⊢ 2 * (3 * y) + 5 * y = 11"}]} +{"problem_id": "amc12b_2021_p3", "kind": "failed", "formal_statement": "import Mathlib\nimport Aesop\n\nset_option maxHeartbeats 0\n\nopen BigOperators Real Nat Topology Rat\n\n/-- Suppose$2+\\frac{1}{1+\\frac{1}{2+\\frac{2}{3+x}}}=\\frac{144}{53}.$What is the value of $x?$\n\n$\\textbf{(A) }\\frac34 \\qquad \\textbf{(B) }\\frac78 \\qquad \\textbf{(C) }\\frac{14}{15} \\qquad \\textbf{(D) }\\frac{37}{38} \\qquad \\textbf{(E) }\\frac{52}{53}$ Show that it is \\text{A}.-/\ntheorem amc12b_2021_p3 (x : ℝ) (h₀ : 2 + 1 / (1 + 1 / (2 + 2 / (3 + x))) = 144 / 53) : x = 3 / 4 := by sorry", "lean_code": "import Mathlib\nimport Aesop\n\nset_option maxHeartbeats 0\n\nopen BigOperators Real Nat Topology Rat\n\n\ntheorem amc12b_2021_p3 (x : ℝ) (h₀ : 2 + 1 / (1 + 1 / (2 + 2 / (3 + x))) = 144 / 53) : x = 3 / 4 := by \n have h_main : x = 3 / 4 := by\n have h₁ : 3 + x ≠ 0 := by\n by_contra h\n have h₂ : x = -3 := by linarith\n rw [h₂] at h₀\n norm_num at h₀ ⊢\n <;>\n (try contradiction) <;>\n (try linarith) <;>\n (try\n {\n field_simp at h₀ ⊢ <;> ring_nf at h₀ ⊢ <;> nlinarith\n })\n have h₂ : 2 + 2 / (3 + x) ≠ 0 := by\n by_contra h\n have h₃ : 2 + 2 / (3 + x) = 0 := by linarith\n have h₄ : 2 / (3 + x) = -2 := by linarith\n have h₅ : 3 + x ≠ 0 := h₁\n field_simp at h₄\n nlinarith\n have h₃ : 1 + 1 / (2 + 2 / (3 + x)) ≠ 0 := by\n by_contra h\n have h₄ : 1 + 1 / (2 + 2 / (3 + x)) = 0 := by linarith\n have h₅ : 1 / (2 + 2 / (3 + x)) = -1 := by linarith\n have h₆ : 2 + 2 / (3 + x) ≠ 0 := h₂\n field_simp at h₅\n <;> simp_all [div_eq_mul_inv]\n <;> ring_nf at *\n <;> nlinarith\n have h₄ : 2 + 1 / (1 + 1 / (2 + 2 / (3 + x))) = 144 / 53 := h₀\n field_simp at h₄\n ring_nf at h₄\n have h₅ : x = 3 / 4 := by\n apply mul_left_cancel₀ (sub_ne_zero.mpr h₁)\n apply mul_left_cancel₀ (sub_ne_zero.mpr h₂)\n nlinarith [sq_pos_of_ne_zero h₁, sq_pos_of_ne_zero h₂, sq_nonneg (x - 3 / 4),\n sq_nonneg (x + 3 / 4), sq_nonneg (x + 1 / 2), sq_nonneg (x - 1 / 2)]\n exact h₅\n exact h_main", "repl_code": "\n\n\n\ntheorem amc12b_2021_p3 (x : ℝ) (h₀ : 2 + 1 / (1 + 1 / (2 + 2 / (3 + x))) = 144 / 53) : x = 3 / 4 := by \n have h_main : x = 3 / 4 := by\n have h₁ : 3 + x ≠ 0 := by\n by_contra h\n have h₂ : x = -3 := by linarith\n rw [h₂] at h₀\n norm_num at h₀ ⊢\n <;>\n (try contradiction) <;>\n (try linarith) <;>\n (try\n {\n field_simp at h₀ ⊢ <;> ring_nf at h₀ ⊢ <;> nlinarith\n })\n have h₂ : 2 + 2 / (3 + x) ≠ 0 := by\n by_contra h\n have h₃ : 2 + 2 / (3 + x) = 0 := by linarith\n have h₄ : 2 / (3 + x) = -2 := by linarith\n have h₅ : 3 + x ≠ 0 := h₁\n field_simp at h₄\n nlinarith\n have h₃ : 1 + 1 / (2 + 2 / (3 + x)) ≠ 0 := by\n by_contra h\n have h₄ : 1 + 1 / (2 + 2 / (3 + x)) = 0 := by linarith\n have h₅ : 1 / (2 + 2 / (3 + x)) = -1 := by linarith\n have h₆ : 2 + 2 / (3 + x) ≠ 0 := h₂\n field_simp at h₅\n <;> simp_all [div_eq_mul_inv]\n <;> ring_nf at *\n <;> nlinarith\n have h₄ : 2 + 1 / (1 + 1 / (2 + 2 / (3 + x))) = 144 / 53 := h₀\n field_simp at h₄\n ring_nf at h₄\n have h₅ : x = 3 / 4 := by\n apply mul_left_cancel₀ (sub_ne_zero.mpr h₁)\n apply mul_left_cancel₀ (sub_ne_zero.mpr h₂)\n nlinarith [sq_pos_of_ne_zero h₁, sq_pos_of_ne_zero h₂, sq_nonneg (x - 3 / 4),\n sq_nonneg (x + 3 / 4), sq_nonneg (x + 1 / 2), sq_nonneg (x - 1 / 2)]\n exact h₅\n exact h_main", "repl_errors": [{"pos": {"line": 25, "column": 6}, "endPos": {"line": 25, "column": 15}, "data": "linarith failed to find a contradiction\nx : ℝ\nh₀ : 2 + 1 / (1 + 1 / (2 + 2 / (3 + x))) = 144 / 53\nh₁ : 3 + x ≠ 0\nh h₃ : 2 + 2 / (3 + x) = 0\nh₅ : 3 + x ≠ 0\nh₄ : 2 = -(2 * (3 + x))\n⊢ False\nfailed"}, {"pos": {"line": 41, "column": 6}, "endPos": {"line": 42, "column": 76}, "data": "linarith failed to find a contradiction\ncase h1.h\nx : ℝ\nh₀ : 2 + 1 / (1 + 1 / (2 + 2 / (3 + x))) = 144 / 53\nh₁ : 3 + x ≠ 0\nh₂ : 2 + 2 / (3 + x) ≠ 0\nh₃ : 1 + 1 / (2 + 2 / (3 + x)) ≠ 0\nh₄ : 106 + (1 + x * (8 + x * 2)⁻¹ + (8 + x * 2)⁻¹ * 3)⁻¹ * 53 = 144\na✝ : (2 + 2 / (3 + x) - 0) * ((3 + x - 0) * x) < (2 + 2 / (3 + x) - 0) * ((3 + x - 0) * (3 / 4))\n⊢ False\nfailed"}]} +{"problem_id": "imo_1977_p6", "kind": "failed", "formal_statement": "import Mathlib\nimport Aesop\n\nset_option maxHeartbeats 0\n\nopen BigOperators Real Nat Topology Rat\n\n/-- Let $f(n)$ be a function $f: \\mathbb{N}^{+}\\to\\mathbb{N}^{+}$. Prove that if $ f(n+1) > f(f(n)) $ for each positive integer $n$, then $f(n)=n$.-/\ntheorem imo_1977_p6 (f : ℕ → ℕ) (h₀ : ∀ n, 0 < f n) (h₁ : ∀ n, 0 < n → f (f n) < f (n + 1)) :\n ∀ n, 0 < n → f n = n := by sorry", "lean_code": "import Mathlib\nimport Aesop\n\nset_option maxHeartbeats 0\n\nopen BigOperators Real Nat Topology Rat\n\n\ntheorem imo_1977_p6 (f : ℕ → ℕ) (h₀ : ∀ n, 0 < f n) (h₁ : ∀ n, 0 < n → f (f n) < f (n + 1)) :\n ∀ n, 0 < n → f n = n := by \n have h_main : ∀ n, 0 < n → f n = n := by\n intro n hn\n by_contra h\n \n have h₂ : f n > n := by\n \n have h₃ : f n ≠ n := h\n have h₄ : f n > n := by\n by_contra h₅\n \n have h₆ : f n < n := by\n omega\n \n have h₇ : 0 < f n := h₀ n\n have h₈ : f (f n) < f (n + 1) := h₁ n hn\n have h₉ : f (f n) < f (n + 1) := h₁ n hn\n have h₁₀ : f (f n) < f (n + 1) := h₁ n hn\n \n have h₁₁ : f n ≤ n := by omega\n have h₁₂ : f n < n := h₆\n have h₁₃ : f (f n) < f (n + 1) := h₁ n hn\n have h₁₄ : f (f n) < f (n + 1) := h₁ n hn\n have h₁₅ : f (f n) < f (n + 1) := h₁ n hn\n \n have h₁₆ : f n > 0 := h₀ n\n have h₁₇ : f (f n) > 0 := h₀ (f n)\n have h₁₈ : f (n + 1) > 0 := h₀ (n + 1)\n \n have h₁₉ : f (f n) < f (n + 1) := h₁ n hn\n have h₂₀ : f (f n) < f (n + 1) := h₁ n hn\n have h₂₁ : f (f n) < f (n + 1) := h₁ n hn\n \n omega\n exact h₄\n \n have h₃ : 0 < f n := h₀ n\n have h₄ : f (f n) < f (n + 1) := h₁ n hn\n have h₅ : f n > n := h₂\n have h₆ : f (f n) < f (n + 1) := h₁ n hn\n have h₇ : f n > 0 := h₀ n\n have h₈ : f (f n) > 0 := h₀ (f n)\n have h₉ : f (n + 1) > 0 := h₀ (n + 1)\n \n have h₁₀ : f (f n) < f (n + 1) := h₁ n hn\n have h₁₁ : f (f n) < f (n + 1) := h₁ n hn\n have h₁₂ : f (f n) < f (n + 1) := h₁ n hn\n \n have h₁₃ : f n > n := h₂\n have h₁₄ : f (f n) < f (n + 1) := h₁ n hn\n have h₁₅ : f (f n) < f (n + 1) := h₁ n hn\n have h₁₆ : f (f n) < f (n + 1) := h₁ n hn\n \n omega\n exact h_main", "repl_code": "\n\n\n\ntheorem imo_1977_p6 (f : ℕ → ℕ) (h₀ : ∀ n, 0 < f n) (h₁ : ∀ n, 0 < n → f (f n) < f (n + 1)) :\n ∀ n, 0 < n → f n = n := by \n have h_main : ∀ n, 0 < n → f n = n := by\n intro n hn\n by_contra h\n \n have h₂ : f n > n := by\n \n have h₃ : f n ≠ n := h\n have h₄ : f n > n := by\n by_contra h₅\n \n have h₆ : f n < n := by\n omega\n \n have h₇ : 0 < f n := h₀ n\n have h₈ : f (f n) < f (n + 1) := h₁ n hn\n have h₉ : f (f n) < f (n + 1) := h₁ n hn\n have h₁₀ : f (f n) < f (n + 1) := h₁ n hn\n \n have h₁₁ : f n ≤ n := by omega\n have h₁₂ : f n < n := h₆\n have h₁₃ : f (f n) < f (n + 1) := h₁ n hn\n have h₁₄ : f (f n) < f (n + 1) := h₁ n hn\n have h₁₅ : f (f n) < f (n + 1) := h₁ n hn\n \n have h₁₆ : f n > 0 := h₀ n\n have h₁₇ : f (f n) > 0 := h₀ (f n)\n have h₁₈ : f (n + 1) > 0 := h₀ (n + 1)\n \n have h₁₉ : f (f n) < f (n + 1) := h₁ n hn\n have h₂₀ : f (f n) < f (n + 1) := h₁ n hn\n have h₂₁ : f (f n) < f (n + 1) := h₁ n hn\n \n omega\n exact h₄\n \n have h₃ : 0 < f n := h₀ n\n have h₄ : f (f n) < f (n + 1) := h₁ n hn\n have h₅ : f n > n := h₂\n have h₆ : f (f n) < f (n + 1) := h₁ n hn\n have h₇ : f n > 0 := h₀ n\n have h₈ : f (f n) > 0 := h₀ (f n)\n have h₉ : f (n + 1) > 0 := h₀ (n + 1)\n \n have h₁₀ : f (f n) < f (n + 1) := h₁ n hn\n have h₁₁ : f (f n) < f (n + 1) := h₁ n hn\n have h₁₂ : f (f n) < f (n + 1) := h₁ n hn\n \n have h₁₃ : f n > n := h₂\n have h₁₄ : f (f n) < f (n + 1) := h₁ n hn\n have h₁₅ : f (f n) < f (n + 1) := h₁ n hn\n have h₁₆ : f (f n) < f (n + 1) := h₁ n hn\n \n omega\n exact h_main", "repl_errors": [{"pos": {"line": 39, "column": 8}, "endPos": {"line": 39, "column": 13}, "data": "omega could not prove the goal:\na possible counterexample may satisfy the constraints\n c ≥ 1\n a - b ≥ 1\n d ≥ 1\n b ≥ 1\n c - d ≥ 1\n a ≥ 1\nwhere\n a := ↑n\n b := ↑(f n)\n c := ↑(f (n + 1))\n d := ↑(f (f n))"}, {"pos": {"line": 59, "column": 4}, "endPos": {"line": 59, "column": 9}, "data": "omega could not prove the goal:\na possible counterexample may satisfy the constraints\n c ≥ 1\n a - b ≤ -1\n d ≥ 1\n b ≥ 1\n c - d ≥ 1\n a ≥ 1\nwhere\n a := ↑n\n b := ↑(f n)\n c := ↑(f (n + 1))\n d := ↑(f (f n))"}]} +{"problem_id": "induction_sumkexp3eqsumksq", "kind": "failed", "formal_statement": "import Mathlib\nimport Aesop\n\nset_option maxHeartbeats 0\n\nopen BigOperators Real Nat Topology Rat\n\n/-- Show that for all natural numbers $n$, we have $\\sum_{k=0}^{n-1}k^3 = \\left(\\sum_{k=0}^{n-1}k\\right)^2$.-/\ntheorem induction_sumkexp3eqsumksq (n : ℕ) :\n (∑ k in Finset.range n, k ^ 3) = (∑ k in Finset.range n, k) ^ 2 := by sorry", "lean_code": "import Mathlib\nimport Aesop\n\nset_option maxHeartbeats 0\n\nopen BigOperators Real Nat Topology Rat\n\n\ntheorem induction_sumkexp3eqsumksq (n : ℕ) :\n (∑ k in Finset.range n, k ^ 3) = (∑ k in Finset.range n, k) ^ 2 := by \n have h_main : ∀ (n : ℕ), (∑ k in Finset.range n, k ^ 3) = (∑ k in Finset.range n, k) ^ 2 := by\n intro n\n have h : ∀ n : ℕ, (∑ k in Finset.range n, k ^ 3) = (∑ k in Finset.range n, k) ^ 2 := by\n intro n\n induction n with\n | zero => simp\n | succ n ih =>\n rw [Finset.sum_range_succ, Finset.sum_range_succ, ih]\n simp [pow_two, Nat.mul_add, Nat.add_mul, Nat.mul_one, Nat.mul_zero, Nat.add_zero]\n <;> induction n with\n | zero => simp\n | succ n ih' =>\n simp_all [Finset.sum_range_succ, pow_two, Nat.mul_add, Nat.add_mul, Nat.mul_one, Nat.mul_zero, Nat.add_zero]\n <;> ring_nf at *\n <;> omega\n exact h n\n exact h_main n", "repl_code": "\n\n\n\ntheorem induction_sumkexp3eqsumksq (n : ℕ) :\n (∑ k in Finset.range n, k ^ 3) = (∑ k in Finset.range n, k) ^ 2 := by \n have h_main : ∀ (n : ℕ), (∑ k in Finset.range n, k ^ 3) = (∑ k in Finset.range n, k) ^ 2 := by\n intro n\n have h : ∀ n : ℕ, (∑ k in Finset.range n, k ^ 3) = (∑ k in Finset.range n, k) ^ 2 := by\n intro n\n induction n with\n | zero => simp\n | succ n ih =>\n rw [Finset.sum_range_succ, Finset.sum_range_succ, ih]\n simp [pow_two, Nat.mul_add, Nat.add_mul, Nat.mul_one, Nat.mul_zero, Nat.add_zero]\n <;> induction n with\n | zero => simp\n | succ n ih' =>\n simp_all [Finset.sum_range_succ, pow_two, Nat.mul_add, Nat.add_mul, Nat.mul_one, Nat.mul_zero, Nat.add_zero]\n <;> ring_nf at *\n <;> omega\n exact h n\n exact h_main n", "repl_errors": [{"pos": {"line": 21, "column": 14}, "endPos": {"line": 21, "column": 19}, "data": "omega could not prove the goal:\na possible counterexample may satisfy the constraints\n a - 2*b - c - d ≤ 0\n e ≥ 0\n d ≥ 0\n a - 2*b - d ≥ 1\n c ≥ 0\n f ≥ 0\n a ≥ 0\n a - 2*b - 2*e - f ≤ -1\nwhere\n a := ↑(n ^ 3)\n b := ↑(∑ k ∈ Finset.range n, k) * ↑n\n c := ↑((∑ k ∈ Finset.range n, k) ^ 2)\n d := ↑(n ^ 2)\n e := ↑(∑ k ∈ Finset.range n, k)\n f := ↑n"}, {"pos": {"line": 18, "column": 21}, "endPos": {"line": 21, "column": 19}, "data": "unsolved goals\ncase succ.succ\nn✝¹ n✝ n : ℕ\nih : ∑ x ∈ Finset.range n, x ^ 3 + n ^ 3 = (∑ k ∈ Finset.range n, k) * n * 2 + (∑ k ∈ Finset.range n, k) ^ 2 + n ^ 2\nih' :\n ∑ x ∈ Finset.range n, x ^ 3 = (∑ k ∈ Finset.range n, k) ^ 2 →\n (∑ k ∈ Finset.range n, k) ^ 2 + n ^ 3 = (∑ k ∈ Finset.range n, k) * n * 2 + (∑ k ∈ Finset.range n, k) ^ 2 + n ^ 2\n⊢ 1 + (∑ k ∈ Finset.range n, k) * n * 2 + (∑ k ∈ Finset.range n, k) ^ 2 + n * 3 + n ^ 2 * 4 + n ^ 3 =\n 1 + (∑ k ∈ Finset.range n, k) * 2 + (∑ k ∈ Finset.range n, k) * n * 4 + (∑ k ∈ Finset.range n, k) ^ 2 + n * 4 +\n n ^ 2 * 4"}]} +{"problem_id": "mathd_numbertheory_227", "kind": "failed", "formal_statement": "import Mathlib\nimport Aesop\n\nset_option maxHeartbeats 0\n\nopen BigOperators Real Nat Topology Rat\n\n/-- One morning each member of Angela's family drank an 8-ounce mixture of coffee with milk. The amounts of coffee and milk varied from cup to cup, but were never zero. Angela drank a quarter of the total amount of milk and a sixth of the total amount of coffee. How many people are in the family? Show that it is 5.-/\ntheorem mathd_numbertheory_227 (x y n : ℕ+) (h₀ : ↑x / (4 : ℝ) + y / 6 = (x + y) / n) : n = 5 := by sorry", "lean_code": "import Mathlib\nimport Aesop\n\nset_option maxHeartbeats 0\n\nopen BigOperators Real Nat Topology Rat\n\n\ntheorem mathd_numbertheory_227 (x y n : ℕ+) (h₀ : ↑x / (4 : ℝ) + y / 6 = (x + y) / n) : n = 5 := by \n have h₁ : (n : ℝ) > 0 := by\n exact mod_cast n.prop\n \n have h₂ : (3 : ℝ) * (n : ℝ) * x + 2 * (n : ℝ) * y = 12 * (x : ℝ) + 12 * (y : ℝ) := by\n have h₂₁ : (x : ℝ) / 4 + (y : ℝ) / 6 = ((x : ℝ) + (y : ℝ)) / n := by simpa using h₀\n have h₂₂ : (n : ℝ) ≠ 0 := by linarith\n field_simp at h₂₁ ⊢\n ring_nf at h₂₁ ⊢\n nlinarith [mul_pos (Nat.cast_pos.mpr x.prop) (Nat.cast_pos.mpr y.prop),\n mul_pos (Nat.cast_pos.mpr x.prop) h₁, mul_pos (Nat.cast_pos.mpr y.prop) h₁]\n \n have h₃ : (3 : ℝ) * (n : ℝ) * x + 2 * (n : ℝ) * y = 12 * (x : ℝ) + 12 * (y : ℝ) := by\n exact h₂\n \n have h₄ : (n : ℕ) ≤ 4 := by\n have h₄₁ : (3 : ℝ) * (n : ℝ) * x + 2 * (n : ℝ) * y = 12 * (x : ℝ) + 12 * (y : ℝ) := h₂\n have h₄₂ : (n : ℕ) ≤ 4 := by\n by_contra h\n have h₄₃ : (n : ℕ) ≥ 5 := by\n omega\n have h₄₄ : (n : ℝ) ≥ 5 := by\n exact_mod_cast h₄₃\n have h₄₅ : (x : ℝ) > 0 := by exact_mod_cast x.prop\n have h₄₆ : (y : ℝ) > 0 := by exact_mod_cast y.prop\n have h₄₇ : (3 : ℝ) * (n : ℝ) * x + 2 * (n : ℝ) * y > 12 * (x : ℝ) + 12 * (y : ℝ) := by\n nlinarith [mul_pos h₄₅ h₄₆, mul_pos (by positivity : (0 : ℝ) < 3) h₄₅,\n mul_pos (by positivity : (0 : ℝ) < 2) h₄₆]\n linarith\n exact h₄₂\n \n have h₅ : (n : ℕ) ≥ 5 := by\n have h₅₁ : (3 : ℝ) * (n : ℝ) * x + 2 * (n : ℝ) * y = 12 * (x : ℝ) + 12 * (y : ℝ) := h₂\n have h₅₂ : (n : ℕ) ≥ 5 := by\n by_contra h\n have h₅₃ : (n : ℕ) ≤ 4 := by omega\n have h₅₄ : (n : ℕ) ≤ 4 := h₅₃\n have h₅₅ : (n : ℝ) ≤ 4 := by exact_mod_cast h₅₄\n have h₅₆ : (x : ℝ) > 0 := by exact_mod_cast x.prop\n have h₅₇ : (y : ℝ) > 0 := by exact_mod_cast y.prop\n have h₅₈ : (3 : ℝ) * (n : ℝ) * x + 2 * (n : ℝ) * y < 12 * (x : ℝ) + 12 * (y : ℝ) := by\n have h₅₉ : (n : ℝ) ≤ 4 := h₅₅\n have h₅₁₀ : (n : ℝ) > 0 := h₁\n have h₅₁₁ : (x : ℝ) > 0 := h₅₆\n have h₅₁₂ : (y : ℝ) > 0 := h₅₇\n nlinarith [mul_pos h₅₁₁ h₅₁₂, mul_pos h₅₁₁ h₅₁₀, mul_pos h₅₁₂ h₅₁₀]\n linarith\n exact h₅₂\n \n have h₆ : n = 5 := by\n have h₆₁ : (n : ℕ) ≤ 4 := h₄\n have h₆₂ : (n : ℕ) ≥ 5 := h₅\n have h₆₃ : (n : ℕ) = 5 := by\n omega\n have h₆₄ : n = 5 := by\n exact_mod_cast h₆₃\n exact h₆₄\n \n apply h₆", "repl_code": "\n\n\n\ntheorem mathd_numbertheory_227 (x y n : ℕ+) (h₀ : ↑x / (4 : ℝ) + y / 6 = (x + y) / n) : n = 5 := by \n have h₁ : (n : ℝ) > 0 := by\n exact mod_cast n.prop\n \n have h₂ : (3 : ℝ) * (n : ℝ) * x + 2 * (n : ℝ) * y = 12 * (x : ℝ) + 12 * (y : ℝ) := by\n have h₂₁ : (x : ℝ) / 4 + (y : ℝ) / 6 = ((x : ℝ) + (y : ℝ)) / n := by simpa using h₀\n have h₂₂ : (n : ℝ) ≠ 0 := by linarith\n field_simp at h₂₁ ⊢\n ring_nf at h₂₁ ⊢\n nlinarith [mul_pos (Nat.cast_pos.mpr x.prop) (Nat.cast_pos.mpr y.prop),\n mul_pos (Nat.cast_pos.mpr x.prop) h₁, mul_pos (Nat.cast_pos.mpr y.prop) h₁]\n \n have h₃ : (3 : ℝ) * (n : ℝ) * x + 2 * (n : ℝ) * y = 12 * (x : ℝ) + 12 * (y : ℝ) := by\n exact h₂\n \n have h₄ : (n : ℕ) ≤ 4 := by\n have h₄₁ : (3 : ℝ) * (n : ℝ) * x + 2 * (n : ℝ) * y = 12 * (x : ℝ) + 12 * (y : ℝ) := h₂\n have h₄₂ : (n : ℕ) ≤ 4 := by\n by_contra h\n have h₄₃ : (n : ℕ) ≥ 5 := by\n omega\n have h₄₄ : (n : ℝ) ≥ 5 := by\n exact_mod_cast h₄₃\n have h₄₅ : (x : ℝ) > 0 := by exact_mod_cast x.prop\n have h₄₆ : (y : ℝ) > 0 := by exact_mod_cast y.prop\n have h₄₇ : (3 : ℝ) * (n : ℝ) * x + 2 * (n : ℝ) * y > 12 * (x : ℝ) + 12 * (y : ℝ) := by\n nlinarith [mul_pos h₄₅ h₄₆, mul_pos (by positivity : (0 : ℝ) < 3) h₄₅,\n mul_pos (by positivity : (0 : ℝ) < 2) h₄₆]\n linarith\n exact h₄₂\n \n have h₅ : (n : ℕ) ≥ 5 := by\n have h₅₁ : (3 : ℝ) * (n : ℝ) * x + 2 * (n : ℝ) * y = 12 * (x : ℝ) + 12 * (y : ℝ) := h₂\n have h₅₂ : (n : ℕ) ≥ 5 := by\n by_contra h\n have h₅₃ : (n : ℕ) ≤ 4 := by omega\n have h₅₄ : (n : ℕ) ≤ 4 := h₅₃\n have h₅₅ : (n : ℝ) ≤ 4 := by exact_mod_cast h₅₄\n have h₅₆ : (x : ℝ) > 0 := by exact_mod_cast x.prop\n have h₅₇ : (y : ℝ) > 0 := by exact_mod_cast y.prop\n have h₅₈ : (3 : ℝ) * (n : ℝ) * x + 2 * (n : ℝ) * y < 12 * (x : ℝ) + 12 * (y : ℝ) := by\n have h₅₉ : (n : ℝ) ≤ 4 := h₅₅\n have h₅₁₀ : (n : ℝ) > 0 := h₁\n have h₅₁₁ : (x : ℝ) > 0 := h₅₆\n have h₅₁₂ : (y : ℝ) > 0 := h₅₇\n nlinarith [mul_pos h₅₁₁ h₅₁₂, mul_pos h₅₁₁ h₅₁₀, mul_pos h₅₁₂ h₅₁₀]\n linarith\n exact h₅₂\n \n have h₆ : n = 5 := by\n have h₆₁ : (n : ℕ) ≤ 4 := h₄\n have h₆₂ : (n : ℕ) ≥ 5 := h₅\n have h₆₃ : (n : ℕ) = 5 := by\n omega\n have h₆₄ : n = 5 := by\n exact_mod_cast h₆₃\n exact h₆₄\n \n apply h₆", "repl_errors": [{"pos": {"line": 14, "column": 15}, "endPos": {"line": 14, "column": 74}, "data": "typeclass instance problem is stuck, it is often due to metavariables\n PosMulStrictMono ?m.21755"}, {"pos": {"line": 31, "column": 8}, "endPos": {"line": 32, "column": 52}, "data": "linarith failed to find a contradiction\ncase h\nx y n : ℕ+\nh₀ : ↑↑x / 4 + ↑↑y / 6 = (↑↑x + ↑↑y) / ↑↑n\nh₁ : ↑↑n > 0\nh₂ h₃ h₄₁ : 3 * ↑↑n * ↑↑x + 2 * ↑↑n * ↑↑y = 12 * ↑↑x + 12 * ↑↑y\nh : ¬↑n ≤ 4\nh₄₃ : ↑n ≥ 5\nh₄₄ : ↑↑n ≥ 5\nh₄₅ : ↑↑x > 0\nh₄₆ : ↑↑y > 0\na✝ : 12 * ↑↑x + 12 * ↑↑y ≥ 3 * ↑↑n * ↑↑x + 2 * ↑↑n * ↑↑y\n⊢ False\nfailed"}]} +{"problem_id": "mathd_algebra_33", "kind": "accepted", "formal_statement": "import Mathlib\nimport Aesop\n\nset_option maxHeartbeats 0\n\nopen BigOperators Real Nat Topology Rat\n\n/-- If $2x = 5y$ and $7y = 10z$, what is the value of $\\frac{z}{x}$ expressed as a common fraction? Show that it is \\frac{7}{25}.-/\ntheorem mathd_algebra_33 (x y z : ℝ) (h₀ : x ≠ 0) (h₁ : 2 * x = 5 * y) (h₂ : 7 * y = 10 * z) :\n z / x = 7 / 25 := by sorry", "lean_code": "import Mathlib\nimport Aesop\n\nset_option maxHeartbeats 0\n\nopen BigOperators Real Nat Topology Rat\n\n\ntheorem mathd_algebra_33 (x y z : ℝ) (h₀ : x ≠ 0) (h₁ : 2 * x = 5 * y) (h₂ : 7 * y = 10 * z) :\n z / x = 7 / 25 := by \n have h_y : y = (2 * x) / 5 := by\n have h₃ : y = (2 * x) / 5 := by\n \n apply Eq.symm\n field_simp\n linarith\n exact h₃\n \n have h_z : z = (7 * x) / 25 := by\n have h₃ : z = (7 * x) / 25 := by\n \n have h₄ : 7 * y = 10 * z := h₂\n rw [h_y] at h₄\n ring_nf at h₄ ⊢\n nlinarith\n exact h₃\n \n have h_main : z / x = 7 / 25 := by\n rw [h_z]\n have h₃ : x ≠ 0 := h₀\n field_simp [h₃]\n <;> ring_nf\n <;> field_simp [h₃] at *\n <;> nlinarith\n \n apply h_main", "repl_code": null, "repl_errors": null} +{"problem_id": "mathd_algebra_141", "kind": "accepted", "formal_statement": "import Mathlib\nimport Aesop\n\nset_option maxHeartbeats 0\n\nopen BigOperators Real Nat Topology Rat\n\n/-- A rectangular patio has an area of $180$ square feet and a perimeter of $54$ feet. What is the length of the diagonal (in feet) squared? Show that it is 369.-/\ntheorem mathd_algebra_141 (a b : ℝ) (h₁ : a * b = 180) (h₂ : 2 * (a + b) = 54) :\n a ^ 2 + b ^ 2 = 369 := by sorry", "lean_code": "import Mathlib\nimport Aesop\n\nset_option maxHeartbeats 0\n\nopen BigOperators Real Nat Topology Rat\n\n\ntheorem mathd_algebra_141 (a b : ℝ) (h₁ : a * b = 180) (h₂ : 2 * (a + b) = 54) :\n a ^ 2 + b ^ 2 = 369 := by \n have h_sum : a + b = 27 := by\n have h₃ : a + b = 27 := by\n \n have h₄ : 2 * (a + b) = 54 := h₂\n \n linarith\n exact h₃\n \n have h_main : a ^ 2 + b ^ 2 = 369 := by\n have h₃ : a ^ 2 + b ^ 2 = 369 := by\n have h₄ : (a + b) ^ 2 = 729 := by\n \n have h₅ : a + b = 27 := h_sum\n rw [h₅]\n norm_num\n \n have h₅ : a ^ 2 + b ^ 2 + 2 * (a * b) = 729 := by\n nlinarith\n \n nlinarith [sq_nonneg (a - b)]\n exact h₃\n exact h_main", "repl_code": null, "repl_errors": null} +{"problem_id": "mathd_numbertheory_1124", "kind": "accepted", "formal_statement": "import Mathlib\nimport Aesop\n\nset_option maxHeartbeats 0\n\nopen BigOperators Real Nat Topology Rat\n\n/-- The four-digit number $\\underline{374n}$ is divisible by 18. Find the units digit $n$. Show that it is 4.-/\ntheorem mathd_numbertheory_1124 (n : ℕ) (h₀ : n ≤ 9) (h₁ : 18 ∣ 374 * 10 + n) : n = 4 := by sorry", "lean_code": "import Mathlib\nimport Aesop\n\nset_option maxHeartbeats 0\n\nopen BigOperators Real Nat Topology Rat\n\n\ntheorem mathd_numbertheory_1124 (n : ℕ) (h₀ : n ≤ 9) (h₁ : 18 ∣ 374 * 10 + n) : n = 4 := by \n have h₂ : 14 + n = 18 := by\n have h₂₁ : 18 ∣ 374 * 10 + n := h₁\n have h₂₂ : 374 * 10 + n = 3740 + n := by norm_num\n rw [h₂₂] at h₂₁\n have h₂₃ : 18 ∣ 3740 + n := h₂₁\n have h₂₄ : 3740 % 18 = 14 := by norm_num\n have h₂₅ : (3740 + n) % 18 = 0 := by\n exact Nat.mod_eq_zero_of_dvd h₂₃\n have h₂₆ : (14 + n) % 18 = 0 := by\n omega\n have h₂₇ : 14 + n = 18 := by\n have h₂₈ : n ≤ 9 := h₀\n have h₂₉ : n ≤ 23 := by omega\n interval_cases n <;> norm_num at h₂₆ ⊢ <;> omega\n exact h₂₇\n \n have h₃ : n = 4 := by\n have h₃₁ : 14 + n = 18 := h₂\n have h₃₂ : n = 4 := by omega\n exact h₃₂\n \n exact h₃", "repl_code": null, "repl_errors": null} +{"problem_id": "amc12b_2020_p2", "kind": "accepted", "formal_statement": "import Mathlib\nimport Aesop\n\nset_option maxHeartbeats 0\n\nopen BigOperators Real Nat Topology Rat\n\n/-- What is the value of the following expression?\n\n$\\frac{100^2-7^2}{70^2-11^2} \\cdot \\frac{(70-11)(70+11)}{(100-7)(100+7)}$\n\n$\\textbf{(A) } 1 \\qquad \\textbf{(B) } \\frac{9951}{9950} \\qquad \\textbf{(C) } \\frac{4780}{4779} \\qquad \\textbf{(D) } \\frac{108}{107} \\qquad \\textbf{(E) } \\frac{81}{80} $ Show that it is \\textbf{(A) } 1.-/\ntheorem amc12b_2020_p2 :\n (100 ^ 2 - 7 ^ 2 : ℝ) / (70 ^ 2 - 11 ^ 2) * ((70 - 11) * (70 + 11) / ((100 - 7) * (100 + 7))) =\n 1 := by sorry", "lean_code": "import Mathlib\nimport Aesop\n\nset_option maxHeartbeats 0\n\nopen BigOperators Real Nat Topology Rat\n\n\ntheorem amc12b_2020_p2 :\n (100 ^ 2 - 7 ^ 2 : ℝ) / (70 ^ 2 - 11 ^ 2) * ((70 - 11) * (70 + 11) / ((100 - 7) * (100 + 7))) =\n 1 := by \n have h_main : (100 ^ 2 - 7 ^ 2 : ℝ) / (70 ^ 2 - 11 ^ 2) * ((70 - 11) * (70 + 11) / ((100 - 7) * (100 + 7))) = 1 := by\n have h₁ : (100 ^ 2 - 7 ^ 2 : ℝ) = 9951 := by\n norm_num\n have h₂ : (70 ^ 2 - 11 ^ 2 : ℝ) = 4779 := by\n norm_num\n have h₃ : ((70 - 11 : ℝ) * (70 + 11)) = 4779 := by\n norm_num\n have h₄ : ((100 - 7 : ℝ) * (100 + 7)) = 9951 := by\n norm_num\n rw [h₁, h₂, h₃, h₄]\n norm_num\n <;> field_simp\n <;> ring\n <;> norm_num\n <;> linarith\n exact h_main", "repl_code": null, "repl_errors": null} +{"problem_id": "mathd_algebra_209", "kind": "accepted", "formal_statement": "import Mathlib\nimport Aesop\n\nset_option maxHeartbeats 0\n\nopen BigOperators Real Nat Topology Rat\n\n/-- Suppose that $h(x)=f^{-1}(x)$. If $h(2)=10$, $h(10)=1$ and $h(1)=2$, what is $f(f(10))$? Show that it is 1.-/\ntheorem mathd_algebra_209 (σ : Equiv ℝ ℝ) (h₀ : σ.2 2 = 10) (h₁ : σ.2 10 = 1) (h₂ : σ.2 1 = 2) :\n σ.1 (σ.1 10) = 1 := by sorry", "lean_code": "import Mathlib\nimport Aesop\n\nset_option maxHeartbeats 0\n\nopen BigOperators Real Nat Topology Rat\n\n\ntheorem mathd_algebra_209 (σ : Equiv ℝ ℝ) (h₀ : σ.2 2 = 10) (h₁ : σ.2 10 = 1) (h₂ : σ.2 1 = 2) :\n σ.1 (σ.1 10) = 1 := by \n have h₃ : σ.1 10 = 2 := by\n have h₃₁ : σ.1 (σ.2 2) = 2 := by\n apply Equiv.apply_symm_apply\n rw [h₀] at h₃₁\n exact h₃₁\n \n have h₄ : σ.1 (σ.1 10) = σ.1 2 := by\n rw [h₃]\n <;> simp [h₃]\n <;>\n (try simp_all [Equiv.symm_apply_apply, Equiv.apply_symm_apply])\n <;>\n (try norm_num)\n <;>\n (try linarith)\n <;>\n (try ring_nf at *)\n <;>\n (try nlinarith)\n \n have h₅ : σ.1 2 = 1 := by\n have h₅₁ : σ.1 (σ.2 1) = 1 := by\n apply Equiv.apply_symm_apply\n rw [h₂] at h₅₁\n exact h₅₁\n \n have h₆ : σ.1 (σ.1 10) = 1 := by\n rw [h₄]\n rw [h₅]\n <;>\n (try simp_all [Equiv.symm_apply_apply, Equiv.apply_symm_apply])\n <;>\n (try norm_num)\n <;>\n (try linarith)\n <;>\n (try ring_nf at *)\n <;>\n (try nlinarith)\n \n apply h₆", "repl_code": null, "repl_errors": null} +{"problem_id": "mathd_algebra_419", "kind": "accepted", "formal_statement": "import Mathlib\nimport Aesop\n\nset_option maxHeartbeats 0\n\nopen BigOperators Real Nat Topology Rat\n\n/-- What is the value of $-a-b^2+3ab$ if $a=-1$ and $b=5$? Show that it is -39.-/\ntheorem mathd_algebra_419 (a b : ℝ) (h₀ : a = -1) (h₁ : b = 5) : -a - b ^ 2 + 3 * (a * b) = -39 := by sorry", "lean_code": "import Mathlib\nimport Aesop\n\nset_option maxHeartbeats 0\n\nopen BigOperators Real Nat Topology Rat\n\n\ntheorem mathd_algebra_419 (a b : ℝ) (h₀ : a = -1) (h₁ : b = 5) : -a - b ^ 2 + 3 * (a * b) = -39 := by \n have h₂ : -a = 1 := by\n rw [h₀]\n norm_num\n <;> simp_all\n <;> ring_nf\n <;> linarith\n \n have h₃ : -b ^ 2 = -25 := by\n rw [h₁]\n norm_num\n <;> simp_all\n <;> ring_nf\n <;> linarith\n \n have h₄ : 3 * (a * b) = -15 := by\n rw [h₀, h₁]\n norm_num\n <;> ring_nf\n <;> linarith\n \n have h₅ : -a - b ^ 2 + 3 * (a * b) = -39 := by\n have h₅₁ : -a - b ^ 2 + 3 * (a * b) = -a - b ^ 2 + 3 * (a * b) := by rfl\n rw [h₅₁]\n have h₅₂ : -a = 1 := h₂\n have h₅₃ : -b ^ 2 = -25 := h₃\n have h₅₄ : 3 * (a * b) = -15 := h₄\n \n nlinarith [sq_nonneg (a + 1), sq_nonneg (b - 5)]\n \n exact h₅", "repl_code": null, "repl_errors": null}