Skip to content

WIP: saw-core-lean — SAWCore→Lean 4 backend#3214

Draft
septract wants to merge 115 commits intoGaloisInc:masterfrom
septract:saw-core-lean
Draft

WIP: saw-core-lean — SAWCore→Lean 4 backend#3214
septract wants to merge 115 commits intoGaloisInc:masterfrom
septract:saw-core-lean

Conversation

@septract
Copy link
Copy Markdown

@septract septract commented May 2, 2026

Note: this branch and PR description are AI-generated (Claude Code, supervised). Treat all claims — including soundness claims — as needing human review.

Summary

Adds saw-core-lean, a SAW backend that translates SAWCore terms to Lean 4 source. Generated .lean files import a small handwritten support library (saw-core-lean/lean/CryptolToLean/) and elaborate under lake build.

The translator runs scNormalize on each input term to a fixed point, then walks the result. After normalisation the only constructs left are SAWCore primitives, inductives, and recursors, which are mapped 1:1 onto declarations in the handwritten Lean support library. User-defined definitions are inlined rather than emitted as separate Lean defs. This sidesteps the Lean cumulativity gap that blocked a direct mirror of the Rocq backend — rationale and alternatives in doc/2026-04-23_specialization-approach.md.

New SAW commands: write_lean_term, write_lean_cryptol_module, offline_lean.

Current coverage

Handles: monomorphic instances, Cryptol modules with {a}-polymorphism over Type 0, the saw-lean-example/ demo end-to-end.

Not yet handled: Prelude.fix, universe-polymorphic terms (parked alternative on saw-core-lean-p4-wip), native Lean.BitVec binding. See doc/2026-05-01_status-and-next-steps.md.

Soundness

No skip lists, no sorry in the translator, no close-but-not-equal mappings. Audits in doc/2026-04-24_audit-*.md, consolidated in doc/2026-04-24_soundness-boundaries.md.

septract and others added 30 commits May 1, 2026 18:46
New private sublibrary mirroring saw-core-rocq per the design in
saw-core-lean/doc/2026-04-22_lean-backend-design.md. Phase 0 covers
the Lean 4 surface-syntax AST (Language.Lean.AST) and its pretty
printer (Language.Lean.Pretty), smoke-tested against Lean 4.30.0-rc2.

Translator, interpreter wiring, and the Lean-side support library
follow in later phases.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds the minimal SAWCore Term -> Language.Lean.AST translator behind
the write_lean_term entry point:

- SAWCoreLean.Monad: trimmed mirror of SAWCoreRocq.Monad
  (TranslationError, TranslationConfiguration, runTranslationMonad).
  Drops vectorModule / monadicTranslation / postPreamble — Lean has
  native BitVec/Vector and no free-monad encoding is needed yet.
- SAWCoreLean.Term: structural walker handling Sort, Pi, Lambda, App,
  Variable, and Constant (via short-name lookup with constantRenaming
  applied). All other term forms raise NotSupported. No shared-subterm
  lifting, no SpecialTreatment table, no module-walk support — those
  arrive with CryptolModule / SAWModule in later phases.
- SAWCoreLean.Lean: translateTermAsDeclImports entry point plus a
  preamble listing the CryptolToLean support-library imports the
  generated output expects.

A new saw-core-lean-smoketest test-suite exercises the translator on
four synthetic terms without requiring a full saw binary; the Phase-0
flagship case \(x : Bool) -> x translates to
'def identity (x : Bool) : Bool := x' and elaborates cleanly under
Lean 4.30.0-rc2.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds the write_lean_term command to the SAWScript interpreter, backed
by writeLeanTerm in saw-central.

- SAWCentral.Prover.Exporter: export writeLeanTerm /
  leanTranslationConfiguration / writeLeanProp as Lean analogs of
  writeRocqTerm / rocqTranslationConfiguration / writeRocqProp.
- SAWScript.Interpreter: add do_write_lean_term and a prim entry for
  write_lean_term (no legacy alias — there's no prior name to
  deprecate).
- saw.cabal: add saw:saw-core-lean to saw-central and saw-script
  deps, mirroring where saw:saw-core-rocq sits.

Smoke-tested end-to-end: 'write_lean_term "idBool" [] [] "out.lean"
{{ \\(b : Bit) -> b }}' emits 'def idBool (b : Bool) : Bool := b',
which elaborates cleanly in Lean 4.30.0-rc2. The polymorphic
implRev{4,[8]} case also translates without error, using SAWCore
constant short names verbatim — proper Lean forms
(seq n a -> Vector a n, TCNum/NatPos/Bit0/One numerals) wait on the
Phase-1 SpecialTreatment table.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Introduces the Lean-side support library that generated output
imports. This is the first building block of Phase 1 per
saw-core-lean/doc/2026-04-22_lean-backend-design.md.

- lean/ is a Lake project pinned to leanprover/lean4:v4.29.1 (current
  latest published stable).
- Module layout uses one namespace per file under CryptolToLean, not a
  flat dump — preserves origin info and avoids cross-module name
  clashes as more Cryptol modules are translated in later phases.
- Phase-1 stub contents:
    * CryptolToLean.SAWCoreScaffolding.Bit      := Bool
    * CryptolToLean.SAWCoreVectors.Vec n a      := Vector a n
    * CryptolToLean.SAWCoreBitvectors.bitvector n := BitVec n
      plus U8 / U16 / U32 / U64 aliases.
  Bodies are deliberately thin; they fill in as the Haskell-side
  SpecialTreatment table grows.
- Uses std only (no mathlib); revisit if a specific primitive forces
  the call.
- SAWCoreLean.Lean preamble emits 'import CryptolToLean' plus 'open'
  lines for each sub-namespace, so generated code can reference
  primitives unqualified — mirrors how Rocq's 'Require Import' works.

End-to-end smoke: 'saw-lean-example/demo.saw' still translates
\(b : Bit) -> b to 'def idBool (b : Bool) : Bool := b', and the
emitted .lean now elaborates directly against the Lake project via
'lake env lean out/idBool.lean' with exit 0.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds the per-identifier treatment infrastructure that turns raw
SAWCore constant references into the right Lean target — qualified,
renamed, or macro-expanded.

- SAWCoreLean.SpecialTreatment: mirrors SAWCoreRocq.SpecialTreatment
  (DefSiteTreatment / UseSiteTreatment / IdentSpecialTreatment,
  translateModuleName, findSpecialTreatment, mapsTo / rename / skip /
  replace combinators, escapeIdent). Adds a mapsToCore combinator for
  primitives that resolve in Lean's own prelude (no module prefix).
  The Prelude / Cryptol tables start with a minimal seed — Bool, Nat,
  Int, String, True, False, Eq, plus Bit / Vec / bitvector pointing
  at the handwritten support modules.
- SAWCoreLean.Term: translateConstant and the App / asGlobalDef head
  case now dispatch through translateIdentWithArgs, which applies the
  UseSiteTreatment. UsePreserve qualifies with translateModuleName
  (so unseeded Prelude / Cryptol names land in
  CryptolToLean.SAWCorePrelude.* / CryptolToLean.CryptolPrimitivesForSAWCore.*,
  dangling until the Phase-2 generated preludes fill them in). No
  backwards-compat short-name fallback — matches the Rocq contract.
- SAWCoreLean.Monad: new UnderAppliedMacro TranslationError variant
  for when a UseMacro entry is supplied with fewer arguments than
  its arity promises.
- saw.cabal: saw-core-lean gains zenc dep (for Text.Encoding.Z used
  by escapeIdent) and exposes the new module.

End-to-end: demo.saw's idBool.lean still elaborates cleanly; the
implRev.lean output now uses fully qualified
CryptolToLean.CryptolPrimitivesForSAWCore.seq / .TCNum / .NatPos /
.Bit0 / .One names — correct per the contract, awaiting the Phase-2
generated preludes for the referenced modules to exist.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds UseMacro treatment entries for the SAWCore Nat / Pos
constructors (Zero, Succ, One, Bit0, Bit1, NatPos) and Cryptol's
TCNum Num-wrapper, plus Cryptol.seq -> Vec. Each macro collapses
when its input is already a Lean NatLit, falling back to a
qualified call otherwise.

Also maps Cryptol.seq to CryptolToLean.SAWCoreVectors.Vec with the
natural argument order (n first, then element type).

Fixes a related subtlety in the translator: 'applied' helper avoids
wrapping zero-arity results in 'Lean.App f []' so downstream macros
can pattern-match on bare 'NatLit' through nested applications.

Before: implRev4's type was
  CryptolToLean.SAWCoreVectors.Vec
    (CryptolToLean.SAWCorePrelude.Bit0
      (CryptolToLean.SAWCorePrelude.Bit0 1))
    (CryptolToLean.SAWCoreVectors.Vec
      (CryptolToLean.SAWCorePrelude.Bit0
        (CryptolToLean.SAWCorePrelude.Bit0
          (CryptolToLean.SAWCorePrelude.Bit0 1)))
      Bool)

After:
  CryptolToLean.SAWCoreVectors.Vec 4
    (CryptolToLean.SAWCoreVectors.Vec 8 Bool)

The only remaining dangling reference in implRev.lean is to
'implRev' itself — which needs constant-body translation, the final
big Phase-1 piece.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
When a SAWCore term references a constant with an ImportedName
(typically a Cryptol user-defined function lifted into SAWCore), the
translator now recursively translates the constant's body and emits
it as an auxiliary Lean def before the main output, mirroring the
Rocq translator's topLevelDeclarations handling.

- TranslationReader gains sawModuleMap so translateConstant can
  resolve SAWCore names via requireNameInMap.
- TranslationState grows globalDeclarations + topLevelDeclarations
  lists, with getNamesOfAllDeclarations as a dedup helper to avoid
  translating the same constant twice.
- withTopTranslationState runs a sub-translation in a fresh local
  scope (empty named environment + only reserved idents unavailable)
  — bodies are closed, so no outer bindings should leak in.
- ResolvedDef with a body emits as a Lean def; ResolvedDef without
  (axiom / primitive) emits as a Lean axiom.
- translateDefDoc / translateTermAsDeclImports / writeLeanTerm /
  the smoke-test harness all thread ModuleMap through.

End-to-end on saw-lean-example's implRev{4,[8]}:

Before (just the bare reference):
  def implRev4 : ... := implRev 4 (Vec 8 Bool)

After (peer body + use):
  def implRev (n : ...) (a : Type) (xs : Vec n a) : Vec n a := ...
  def implRev4 : ... := implRev 4 (Vec 8 Bool)

The emitted implRev body is a direct translation of the SAWCore
compilation of the Cryptol sequence comprehension, still referring
to many unmapped Cryptol primitives (ecAt, seqMap, tcSub, coerce,
seq_cong1, …). Those either land in the Phase-2 generated preludes
or get added to cryptolPreludeSpecialTreatmentMap as entries are
worked through.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds the offline_lean command to the SAWScript interpreter, mirroring
offline_rocq. Each invocation emits one .lean file per goal
containing both the translated Prop as 'def goal : Prop := ...' and
a 'theorem goal_holds : goal := by sorry' stub — so the user can
open the file in Lean and discharge the proof.

- SAWCentral.Builtins: offline_lean :: FilePath -> ProofScript () —
  thin wrapper over writeLeanProp, parallels offline_rocq.
- SAWCentral.Prover.Exporter: writeLeanProp now routes through
  translateGoalAsDeclImports (new) rather than reusing writeLeanTerm,
  so the theorem stub is appended.
- SAWCoreLean.Lean: translateGoalAsDeclImports — same as
  translateTermAsDeclImports but ends with the by-sorry theorem.
- SAWScript.Interpreter: do_offline_lean + prim 'offline_lean'.

End-to-end: saw-lean-example/demo.saw now produces
out/invol_prove0.lean and out/eq_spec_prove0.lean, each with
translated goal Prop plus the by-sorry theorem stub.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Covers the three new commands (write_lean_sawcore_prelude,
write_lean_cryptol_primitives_for_sawcore, write_lean_cryptol_module),
the two new translator modules (SAWCoreLean.SAWModule,
SAWCoreLean.CryptolModule), support-lib expansion sizing (Rocq 2728
LOC -> Lean ~510 LOC estimate thanks to native BitVec/Vector), and a
phased 2A-2E sub-plan sized at ~1 week of focused work.

Also catalogs the anticipated rough spots (recursion in
Prelude.sawcore, universe polymorphism gaps in the current AST, Int
vs. Cryptol Integer, dictionary-heavy Cryptol output) and open
decisions to make (where generated preludes live in git, namespace
scheme for user Cryptol modules, the skip list).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Addresses audit findings A2, A3, A4, A5, A6, A7, A8, B2, C1, C2.

Pretty.hs:
- App case now uses 'group (hang 2 (fillSep ...))' so long calls wrap
  at the layout width instead of producing 1200-char one-liners. Pi
  case similarly uses 'group (fillSep ...)'.
- Definition / Let build their headers by 'hsep' on a filtered list of
  parts rather than '<+>' chaining through potentially-empty docs —
  eliminates the double-space bug that showed up as 'def foo  :=' and
  'let x  :=' when binders or type annotations were absent.
- Anonymous implicit pi binder renders as '{_ : ty}' not '{ty}' —
  latter parses as a singleton-set literal in Lean.
- Drop the now-unused 'prettyMaybeTy' and 'prettyPiBinders' helpers.

SAWCoreLean.Lean:
- Drop the three 'open CryptolToLean.*' directives from the preamble.
  The translator always emits fully-qualified names, so the opens
  did nothing but reserve cosmetic space.
- translateGoalAsDeclImports had two extra hardlines between the goal
  def and the theorem stub; reduced to one blank line.

SAWCoreLean.Term:
- translateDefDoc was using 'intersperse hardline' on top of each
  prettyDecl's own trailing hardline, yielding two blank lines
  between aux decls. Now just 'vcat (map prettyDecl ds)'.

SAWCoreLean.SpecialTreatment:
- 'Prelude.Eq' now uses the new 'mapsToCoreExpl' combinator ('@eq')
  since Lean's 'Eq' takes its type parameter implicitly while SAWCore
  supplies it explicitly. Without this, every translated 'Eq T x y'
  would type-error once Phase 2 lands.
- Remove 'unwrapMacro'. The TCNum / NatPos entries it backed were
  collapsing type-level wrappers into bare Nat literals, breaking the
  Num vs Nat distinction ('seq : Num -> sort 0' can't accept a Nat).
  Both now translate as ordinary qualified references; the Phase-2
  generated prelude will provide them.

End-to-end impact on saw-lean-example:
- idBool.lean still elaborates cleanly against the Lake project.
- implRev.lean: 'TCNum 0 / TCNum (NatPos 4) / TCNum (NatPos 8)' now
  appear as faithful qualified references instead of hollow literals,
  and the previously ~1600-char body wraps across ~40 lines at
  reasonable indentation.
- Blank-line hygiene tightened throughout.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Closes the type-discipline gap the audit flagged as blocker A1: the
translator now inspects each SAWCore binder's sort flags via
asSortWithFlags + sortFlagsToList, and for binders whose type carries
the isort flag injects an auxiliary '[Inh_a : Inhabited a]' instance
hypothesis. Mirrors the Rocq translator's BindTrans /
translateImplicitHyp path at saw-core-rocq/src/SAWCoreRocq/Term.hs:494-512.

- Language.Lean.AST: add 'Instance' BinderImplicity variant so we can
  emit Lean's '[...]' typeclass binder syntax (needed so Lean's
  instance search fills the Inhabited hypothesis at call sites —
  a plain '{...}' implicit would require unification, which won't
  find instances).
- Language.Lean.Pretty: 'Instance' binders print as '[...]'; for
  anonymous instance pi binders use '[A] -> rest' (idiomatic Lean
  typeclass-arg form).
- Language.Lean.SAWCoreScaffolding: re-export 'Inhabited' under the
  CryptolToLean.SAWCoreScaffolding namespace so generated output's
  qualified references resolve via 'import CryptolToLean'.
- SAWCoreLean.Term: BindTrans sum-type (Ident + type + auxiliary
  instance hyps), bindTransToBinder / bindTransToPiBinder flatteners,
  translateImplicitHyp that builds the Inhabited-applied-to-a-pi
  hypothesis type, and a two-layer translateBinders (primed inner
  version returns BindTrans; outer version concat-maps to Lean
  binders for the non-Inhabited callers).

Impact: in saw-lean-example/out/implRev.lean, implRev's signature
now includes:
  (n : ...Num) (a : Type) [Inh_a : ...Inhabited a] (xs : ...Vec n a)

idBool.lean is unchanged (Bool binders don't carry isort).

Also expands the smoke test (audit finding B6). Old SmokeTest.hs was
4 trivial cases printed to stdout; replaced with a tasty-hunit
harness of 17 assertion-based tests across three groups:
- Language.Lean.Pretty (12 tests: anonymous implicit pi, instance
  pi, double-space regression guards on def / let, If / List /
  StringLit / Tactic / IntLit / Ascription / ExplVar / Namespace)
- SAWCoreLean.Term (4 tests: idBool, Inhabited injection,
  unqualified short-name behaviour, UnderAppliedMacro error path)
- SAWCoreLean.Lean.translateGoalAsDeclImports (1 test: verify the
  by-sorry theorem stub is present with the right shape)

saw.cabal: add tasty + tasty-hunit as test-suite deps.

Design-doc §4 table: update the Let row to reflect that we emit
'let x := e; b' (audit finding E4) — the leading ';' form was a
deliberate Phase-0 choice but had drifted out of the doc.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Ports SAWCoreRocq.SAWModule to SAWCoreLean.SAWModule, adds the
translateSAWModule entry point in SAWCoreLean.Lean, and extends the
term-translation Reader with a 'currentModule :: Maybe ModuleName'
field so references within a module being emitted go unqualified
(Lean's namespace scope supplies the prefix).

- Language.Lean.AST: no changes.
- SAWCoreLean.Term:
  * TranslationReader: add _currentModule.
  * runTermTranslationMonad gains a 'Maybe ModuleName' argument and
    separates the old single ident list into explicit 'globals' vs.
    'localEnv' params — matches the Rocq signature so the
    SAWModule walker can hand in both.
  * translateIdentWithArgs: UsePreserve now emits unqualified when
    the target module matches 'currentModule'; everything else stays
    fully qualified.
  * Add translateIdentToIdent (needed by translateCtor to learn a
    constructor's Lean target name).
  * Expose globalDeclarations / topLevelDeclarations lenses and
    mkDefinition / translateParams / translatePiBinders for the
    module walker.
  * Single-term entry points thread 'Nothing' for currentModule —
    no module context, so every Prelude/Cryptol reference qualifies.
- SAWCoreLean.SAWModule: new module (~210 lines, 1:1 with Rocq
  modulo Lean AST names and the 'Lean' / 'Rocq' InjectCodeDecl
  namespace tag). Handles TypeDecl (via translateDataType ->
  InductiveDecl, with constructor-name qualification stripping),
  DefDecl (via translateDef -> Definition/Axiom per DefQualifier),
  and InjectCodeDecl (only 'Lean'-tagged code is injected).
- SAWCoreLean.Lean: translateSAWModule wraps moduleDecls in a
  'namespace X ... end X' block using the translated module name.
  Also adds moduleDeclName helper for skip-list lookups (to be
  used by the Phase 2D Cryptol-module translator).
- saw.cabal: expose SAWCoreLean.SAWModule in the sublibrary.

Smoke test (all 17 pretty-printer, translator, and goal-emission
assertions) still green. Existing write_lean_term behaviour
unchanged — single-term mode passes 'Nothing' for currentModule,
so the no-module case behaves identically to before.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Wires write_lean_sawcore_prelude and write_lean_cryptol_primitives_for_sawcore
in saw-central's Exporter.hs (mirrors writeRocqSAWCorePrelude /
writeRocqCryptolPrimitivesForSAWCore), plus their interpreter prim
entries. Both run to completion against the real SAWCore sources:
- Prelude.sawcore (2628 lines) -> SAWCorePrelude.lean (4785 lines)
- Cryptol.sawcore (2180 lines) -> CryptolPrimitivesForSAWCore.lean (16643 lines)

Translator expansions needed to unblock the walk:

- FTermF now handles Recursor, ArrayValue, StringLit (not just Sort).
  Recursor emits Foo.rec (Lean convention) instead of Foo_rect; the
  ArrayValue bitvector-literal specialization from Rocq is deferred
  (needs parameterized-utils machinery we can add later if output
  noise calls for it).
- defaultIdentTarget helper centralizes 'this SAWCore ident's Lean
  reference.' Two cases it handles that previously weren't:
  * Constructors of inductive types live inside the type's Lean
    namespace (MyData.ctor, not ctor alone). The translator now
    consults resolveNameInMap to detect ResolvedCtor and prefixes
    accordingly.
  * Same for the 'explicit application' marker: SAWCore applies
    datatype parameters to a constructor explicitly, but Lean's
    auto-generated Foo.ctor takes them implicitly — so ctor uses
    always emit with a leading @, forcing all args explicit.
- translateSAWModule uses Text.intercalate '.' moduleNamePieces for
  its namespace header, not moduleNameText (which separates with ':').

SpecialTreatment: remove the Phase-1 Zero / Succ / One / Bit0 / Bit1
numeric-literal macros. They served to pretty-print small Nat
literals in standalone implRev.lean but they fire on *every*
reference, including the Prelude's own definitions of Succ etc.,
which then fail with UnderAppliedMacro when the definition introduces
Succ as a top-level constant without applying it. Phase 2 delivers
these definitions via the generated SAWCorePrelude.lean, so the
cosmetic workaround is no longer needed.

Smoke test: drop the under-applied-macro test (target was Bit0,
which no longer carries a macro treatment). 16/16 now green. The
drop is noted in-place so a future UseMacro entry can reinstate an
equivalent assertion.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Concrete issues surfaced by running write_lean_sawcore_prelude +
write_lean_cryptol_primitives_for_sawcore on the real SAWCore
sources and attempting 'lake env lean' on the output:

1. Lean rejects '.rec' in ordinary defs — needs 'noncomputable def'.
2. Lean core uses 'Eq.refl'/'Eq.symm'; SAWCore's 'Eq.Refl'/'Eq.Sym'
   need SpecialTreatment entries.
3. '_'/_''/_''' anonymous-binder suffixes are ugly; emit real
   anonymous pi binders where possible.
4. SAWCore 'Unit' constructor collides with Lean's 'Unit' type.
5. Some constructor applications still fail; needs investigation
   after (1) clears the cascade.
6. 'Sort' AST loses the universe level — extend to 'Sort (Maybe Nat)'.

Each maps to a Phase-2C work item. Phase 2A + 2B landed cleanly; the
walker runs end-to-end, producing 4.8K + 16.6K lines of Lean. Fixing
these six classes is what turns that into something lake-buildable.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Closes 2C items 1-4 and 6 per the findings doc. Five of six audit
deltas; only the constructor-application investigation (2C-5) is
deferred to after regeneration.

2C-1 'noncomputable def' (audit finding 1):
- Language.Lean.AST: Definition gains a 'Noncomputable | Computable'
  flag.
- Language.Lean.Pretty: emits 'noncomputable def' when set.
- SAWCoreLean.Term.mkDefinition{With}: Computable by default;
  mkDefinitionWith lets callers override.
- SAWCoreLean.SAWModule.translateDef: NoQualifier defs emit as
  Noncomputable — conservative over-approximation since the generator
  has no body-walk to decide otherwise. Safe: the generated prelude
  is for type-checking only, not execution.
- Smoketest call sites take the new flag.

2C-2 Eq.refl (audit finding 2):
- SpecialTreatment adds 'Refl' -> '@Eq.refl' (mapsToCoreExpl) so
  SAWCore's @refl a x@ lines up with Lean's universe-polymorphic
  @Eq.refl : {α} {a : α} -> a = a@.

2C-3 anonymous binder cleanup (audit finding 3):
- Remove '_' from reservedIdents. Lean accepts '_' as a binder name
  in lambdas, let bindings, and pi binders ('fun _ => e', '(_ : A) ->
  B'), so the previous '_' / '_'' / '_'''  renaming chain was
  unnecessary ugliness.

2C-4 UnitType (audit finding 4):
- SpecialTreatment maps Prelude.UnitType -> Lean core Unit and its
  'Unit' constructor -> Lean's 'Unit.unit'. Avoids the name collision
  between SAWCore's Unit constructor and Lean core's Unit type.

2C-6 universe levels (audit finding 6):
- Language.Lean.AST: Sort is now 'Prop | TypeLvl Integer' with a
  'pattern Type = TypeLvl 0' bridge so existing call sites are
  unchanged.
- Language.Lean.Pretty: emits 'Type', 'Type n' for level > 0.
- SAWCoreLean.Term.translateSort: keeps the Rocq-style collapse
  (sort n -> Type) because a faithful 'Type n' emission trips the
  many @sort 1@ vs 'Prop' impedance mismatches between SAWCore's Eq
  family and Lean's core Eq. The AST support lands for future
  targeted use; the default stays Rocq-compatible.

Scaffolding: Inhabited is now declared as a universe-polymorphic
class in CryptolToLean.SAWCoreScaffolding so the [Inh_a : Inhabited a]
injection on polymorphic defs elaborates even when SAWCore's
'isort' sits at a sort level above 0. An instance bridge from
_root_.Inhabited preserves the default Lean instances.

Impact on the first-run SAWCorePrelude.lean elaboration:
- Before 2C: hundreds of cascading errors, almost all rooted in the
  'non-noncomputable invoking .rec' issue.
- After 2C (items 1-4, 6): ~100 errors, all concentrated in the
  proof-heavy core of the Prelude (eq_cong, sym, trans, uip, coerce
  variants) where SAWCore's 'sort 1' elaboration meets Lean's
  Prop-valued Eq in ways that can't be reconciled without a case-by-
  case SpecialTreatment 'DefReplace' realisation.

Pragmatic follow-up: the remaining errors all belong to the
'proof-only' slice of the Prelude that Cryptol programs never
actually exercise. A constantSkips list can silence them at
generation time; that's Phase 2C-5 work.

16/16 smoketest assertions still green.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Eliminates two cases where the translator quietly produced Lean
output with different semantics from the SAWCore source.

1. 'bitvector n' in SAWCore is 'Vec n Bool' — a vector of booleans.
   The scaffolding previously aliased it to Lean's native 'BitVec n',
   which is semantically distinct (packed word vs. bit-list;
   indexing, equality, bitwise ops don't match). Fix: scaffolding
   now defines 'bitvector n := Vec n Bool', honest to SAWCore. Lean's
   BitVec ergonomics are given up — correct trade-off: a proof about
   'Vec n Bool' that silently runs on 'BitVec n' proves the wrong
   thing.

2. Cryptol 'seq' has two cases: 'seq (TCNum n) a = Vec n a' and
   'seq TCInf a = Stream a'. A prior SpecialTreatment entry mapped
   'seq' -> 'Vec' unconditionally, collapsing the infinite-sequence
   case and quietly changing meaning. Entry removed; 'seq' now falls
   through to the generated Cryptol prelude, which case-splits via
   Num#rec1 as the source does.

Also adds doc/2026-04-22_soundness.md: the discipline document. Top
rule: soundness w.r.t. SAW semantics is the absolute top-level goal.
Catalogs legitimate mapping patterns (precise equivalence,
handwritten realization, explicit failure) vs. the one broken
pattern (skip + no realization, which silently creates dangling
references). Documents the known approximations (Eq's sort 1 vs
Prop, Inhabited as a separate universe-polymorphic class,
bitvector/BitVec now fixed). Lays out an audit checklist for
adding new SpecialTreatment entries.

Context: a prior pass of Phase 2C had added ~25 'skip' entries to
silence the proof-heavy Prelude's elaboration errors (eq_cong, sym,
trans, coerce__def family, etc.). On review, that was reverted —
skip without a handwritten realization is a soundness hole that
Lean's elaborator will only sometimes catch. The ~100 Lean errors
from the auto-translated proof slice stay as loud failures; real
Cryptol programs don't exercise those defs.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Full SAWCore -> Lean parity on demo.saw. After this commit,
saw-lean-example/demo.saw produces five output files — idBool.lean,
implRev.lean, Rev.lean, invol_prove0.lean, eq_spec_prove0.lean —
analogous to saw-rocq-example's five .v files.

- SAWCoreLean.CryptolModule (new, 64 lines): port of
  SAWCoreRocq.CryptolModule. Walks a CryptolModule's term map,
  translates each (name, term, type) triple as a Lean Definition,
  and registers the name in globalDeclarations so subsequent entries
  that reference it don't re-emit the body inline.
- SAWCoreLean.Lean gains translateCryptolModule: wraps the
  translated defs in a 'namespace <name> ... end <name>' block.
  Also imports Language.Lean.Pretty to render the namespace decl.
- SAWCentral.Prover.Exporter.writeLeanCryptolModule: the TopLevel
  action that loads a .cry file, resolves the Cryptol module
  structure, and routes through translateCryptolModule. Mirrors
  writeRocqCryptolModule 1:1.
- SAWScript.Interpreter: do_write_lean_cryptol_module + 'prim
  write_lean_cryptol_module' entry.
- saw.cabal: saw-core-lean gains 'cryptol' dep and
  'cryptol-saw-core' sublib dep (needed for CryptolEnv / CryptolModule
  / TypedTerm). Exposes SAWCoreLean.CryptolModule.

Output validation on rev.cry: Rev.lean emits two decls
(specRev, implRev) inside 'namespace rev'. Elaborating it against
the Lake support lib shows 18 unknown-identifier errors, all for
generated-prelude names (CryptolPrimitivesForSAWCore.seq / .Num /
.ecReverse / .ecEq etc., SAWCorePrelude.coerce). These are the
expected Phase-2C gap — once the generated preludes land compilably,
Rev.lean elaborates.

Also adds '.tmp/' to .gitignore (used by audit-agent scratchpads).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Captures the sub-agent audit's findings on why the auto-translated
SAWCorePrelude.lean fails to elaborate. Key corrections to my
earlier diagnosis in 2026-04-22_soundness.md:

- The 'Eq/Prop universe gap' I conjectured isn't real — Lean 4's
  Eq.rec is fully universe-polymorphic and accepts Prop motives.
- The real cause of ~60 errors is a translator bug: translateSort
  collapses every SAWCore sort level to Type 0, so emitted wrappers
  are monomorphic. Fix is to propagate universe levels (Phase 2C
  item 6, extended).
- Two latent soundness bugs surfaced:
  * Bool.rec arg order swapped (SAW True-first, Lean false-first)
    — silent runtime wrongness on any Bool eliminator.
  * Nat = Nat mapping: SAW binary-positive vs Lean unary. Loudly
    fails today, but any cosmetic 'fix' would make it quietly wrong.
- Plus UnitType: Lean's Unit is abbrev PUnit.{1}, no Unit.rec exists.

Prioritized as P1-P5. Soundness-critical P1+P2 first, then P3 (Unit
cleanup), then the bigger P4 (universe polymorphism), then P5 (doc
correction).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Three audit findings, all soundness-critical or adjacent.

P1 (critical): remove the ('Nat', mapsToCore 'Nat') mapping. SAW's
Nat is 'Zero | NatPos Pos' (binary-positive); Lean's Nat is
'zero | succ Nat' (unary). They are structurally different types.
The mapping was a landmine — today it fails loudly because the
constructor names don't match, but any cosmetic 'fix' that renamed
them (Zero→zero, NatPos→succ) would silently change the semantics
of every Nat#rec elimination. Leave SAW's Nat as a native translated
inductive.

P2 (critical): SAW declares 'Bool { True; False }' (True first);
Lean 'Bool | false | true' (false first). Auto-translated iteDep /
ite use 'Bool.rec' with case args in SAW order, but Lean's Bool.rec
is in the opposite order — silently swaps the True and False
branches at every reduction.

Fix: new CryptolToLean/SAWCorePreludeExtra.lean with handwritten
iteDep, iteDep_True, iteDep_False, ite, ite_eq_iteDep that permute
the case arguments. Reduction rules close by rfl against Lean's
Bool.rec (verified in the file). SpecialTreatment adds mapsTo
entries pointing the SAWCore names at these realisations. The
generated output now shows 'Prelude::iteDep@core was skipped' at
def sites and 'CryptolToLean.SAWCorePreludeExtra.iteDep' at use
sites, preserving SAW semantics faithfully.

Audited the generated preludes for direct @Bool.rec or Bool.rec
calls — none exist; all Bool elimination goes through iteDep/ite.

P3: Lean's 'Unit' is 'abbrev PUnit.{1}' — no Unit.rec. My Phase-2C
mapping UnitType → Unit + Unit → Unit.unit made the translated
UnitType__rec emit '@Unit.rec', which doesn't resolve. Revert the
UnitType mapping (let SAW's UnitType translate as a native inductive
with an auto-generated .rec) and rename the 'Unit' constructor to
'TTUnit' via a regular rename treatment so it doesn't clash with
Lean core's Unit type at use sites.

Smoketest still green. Regenerated SAWCorePrelude.lean error count:
still at maxErrors cap (~100+) because the dominant group-1
universe-polymorphism errors stay until P4 lands — but the classes
of errors targeted by P1/P2/P3 (Nat name resolution, Unit.rec
missing) are gone.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Earlier draft proposed handwriting Prelude realisations for the
subset of names the rev.cry demo touches, parking full-Prelude
elaboration as a later phase. User flagged that: rev.cry is a
driving use case, not a scope limit. The translator must handle
arbitrary SAWCore input soundly, including the proof-heavy
Prelude regions.

This doc restates the actual problem:
- SAWCore is cumulative (sort i <= sort j for i <= j).
- Lean 4 is not.
- Translating soundly requires explicit universe management in
  the emitted Lean.

Catalogs what I've already tried (collapse to Type; per-def
fresh universe vars) and what I don't yet know (exact root
causes of the ~100 errors, Lean community practice, whether SAW
scTypeOf can give us level info at emission time).

Proposes: spawn a research agent to investigate, then design a
real approach that handles arbitrary SAWCore. No scope-dodge.
Branch saw-core-lean-p4-wip preserves the first-attempt
infrastructure (AST + universe lists + translateSort recording)
for reuse.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Captures the sub-agent's findings on how mathport, mathlib, and the
Lean 4 ecosystem handle universe polymorphism in non-cumulative
Lean 4. Key corrections to my P4 first-attempt framing:

1. Lean 4 non-cumulativity is kernel-baked (confirmed in installed
   toolchain source). No pragma restores it.
2. No prior structural Coq->Lean4 translator exists. SAWCore->Lean4
   is new ground; no template to copy.
3. Mathport's pattern is the closest precedent: always emit
   'mkConst name [explicit-level-list]' at call sites. My P4
   attempt emitted bare '@foo' and hoped for inference — that's
   the known-brittle path.
4. Lean issue GaloisInc#2297 documents the 'max u v =?= max u ?v'
   unification weakness that likely explains our ~100 errors. The
   'abbrev TypeMax' folklore workaround is an option.
5. PLift/ULift/PULift (Init.Prelude) are legitimate lifting
   operators; mathlib's Small/UnivLE are typeclass-based escape
   hatches.

Actionable: reject the bare-@foo pattern, thread explicit levels
via Pattern A (mathport's default), consider PULift wrapping
for SAWCore sort-polymorphic types that genuinely need to cross
universes.

Complements 2026-04-22_universe-problem.md. The internal-errors
agent is still running and will provide the other half.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Companion doc to 2026-04-22_universe-external-research.md. The
internal-errors sub-agent ran the P4-WIP-generated prelude through
Lean and categorized every error; the finding is that all 101
errors have one shared root cause (with minor variations), not a
fundamental impedance.

Root cause: translateSort in my P4-WIP names universe variables
by sort level ('u1' for every sort 1 occurrence), sharing one
Lean universe variable across all sort 1 binders in a def. But
Eq__rec's '(t : sort 1)' binder and its '(p : ... -> sort 1)'
motive-codomain need *independent* Lean universe variables —
when a caller's motive returns Prop (Sort 0) and the t arg is a
concrete type (Sort k > 0), they can't be the same variable.

Fix: each call to translateSort at a binder position allocates a
fresh universe variable. Sort 0 stays as concrete 'Type'. The
rule 'per binder-occurrence = fresh universe variable' is
conceptually simple and tracks how Rocq does it via implicit
polymorphism (just with explicit naming since Lean doesn't
auto-generalize).

Agent validated this with seven Lean probe files covering
Eq__rec, eq_cong, coerce__def, and the ite/iteDep support lib.
All elaborate cleanly under the proposed fix.

Secondary issue (Group C): our handwritten
SAWCorePreludeExtra.lean's iteDep/ite family is monomorphic at
'Type'. Callers pass 'Sort u'-valued motives; mismatch. Fix:
make the support defs universe-polymorphic via '.{u}'.

Effort estimate: 1-2 days, with the AST infrastructure from P4-WIP
reusable.

Combines with the external-research finding that Lean 4
non-cumulativity is kernel-baked and the mathport pattern
(explicit levels at every mkConst call site) is the correct
template. Our fix implements that template at the translateSort
level.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Incomplete attempt at making emitted Lean defs universe-polymorphic
so SAWCore 'sort 1' motives pass Lean's universe check. The approach
taken:

- Extend Language.Lean.AST.Sort with:
  * SortVar String      — 'Sort u', universe-polymorphic
  * SortMax1Var String  — 'Sort (max 1 u)', for inductive results
    whose parameters use 'Sort u'.
- Thread universe-variable lists onto Definition, Axiom, and
  Inductive in the AST.
- SAWCoreLean.Term: translateSort is now monadic, recording
  encountered 'sort k' levels in TranslationState.universeVars.
  sort 0 -> Type; sort k (k >= 1) -> Sort u{k}.
- SAWCoreLean.SAWModule: harvests the recorded universeVars per
  def / inductive and emits them onto the Lean declaration's
  universe-binder list.

What works:
- The AST/pretty-printer infrastructure is clean.
- Emitted preludes show 'def foo.{u1} (a : Sort u1) ...' and
  'axiom fix.{u1} : ...' form.
- The 16 smoketest assertions still pass.
- Individual def declarations with clean universe signatures
  (id, fix, sawLet, PairType, PairType1) now elaborate.

What doesn't yet work:
- Use-site universe instantiation. When a translated body calls
  'Eq__rec t x motive ... y pf', Lean has to infer the universe of
  the motive — but the caller's local universe variables and the
  callee's universe variables aren't connected. Result: ~100
  elaboration errors remain (identical count to pre-P4 because
  different errors now, not because nothing improved).
- Inductive constructor universe constraints: 'inductive
  PairType1.{u1} (a : Sort u1) ... : Sort (max 1 u1)' emits
  structurally, but Lean then complains about constructor payload
  universes vs. the inductive's result universe.

Parking reason:
The approach I took (fresh universe variable per sort level per
def) is a reasonable first approximation but doesn't solve the
cross-def use-site instantiation problem. A proper fix needs:
  (a) A universe-resolution pass at the module level so all defs
      agree on which u-variable corresponds to which SAWCore sort,
      OR
  (b) Emit '.{_}' wildcards at use sites and let Lean infer, OR
  (c) Fundamentally different mechanism (explicit universe
      arguments at every call site, Rocq-style cumulativity).

Before another attempt, a design pass is needed to pick one
approach and understand how Rocq sidesteps this entirely via
Coq's implicit universe polymorphism.

Branch: saw-core-lean-p4-wip. Base branch 'saw-core-lean' reverts
to the pre-P4 state (e4bd622) for now.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…ives

Substantial progress on universe-polymorphic emission. The first
~2000 lines of the auto-translated SAWCorePrelude.lean (through
the pair/sum/list/Bool-theorem regions) now elaborate cleanly.
Errors now start at line 2001+ in the Nat-arithmetic and Vec
regions — separate investigation needed for those.

Core translator changes:

- translateSort: allocate a fresh universe variable per call for
  each 'sort k ≥ 1' occurrence, not deduplicated by sort level.
  Rationale: SAWCore's 'Eq__rec' has two independent 'sort 1'
  binder occurrences (type param t, motive codomain) that need
  separate Lean universe variables. Sharing one breaks when a
  caller supplies 'Prop'-valued motive and 'Type'-valued type.

- mkDefinitionWith: post-emission universe-variable GC. The
  type and body are translated separately and may each allocate
  universe variables that get shadowed when Lambda binders hoist
  into the 'def' signature. Walks the emitted Decl to collect
  actually-referenced universe names and filters the declared
  universe list to match, avoiding Lean's 'unused universe
  parameter' error.

- mkDefinitionWith: when the body has fewer lambdas than the type
  has pi binders (e.g. fstPairType1: 2 lambdas vs 3 pi binders),
  strip the body lambdas' type annotations. Lean infers them from
  the signature; keeping our annotations would reference separate
  universe vars that Lean reports as unused.

AST / Pretty changes:

- Sort gains SortMax1Vars [String]: 'Sort (max 1 (max u v w))' for
  inductives with multiple universe parameters. Single-var
  degenerates to 'Sort (max 1 u)'; empty list degenerates to 'Type'.
  Needed so 'PairType1 (a : Sort u) (b : Sort v) : ...' accepts
  constructor payload at either u or v.
- SAWModule's inductive result sort: use SortMax1Vars over all
  collected universe vars, not just the first.
- prettyUnivs emits comma-separated '.{u, v}' instead of
  space-separated — Lean 4 rejects the space-separated form.

translateIdentWithArgs:

- 'UseRename' path now ctor-qualifies when emitting a renamed
  constructor. SAWCore's UnitType has constructor Unit, which we
  rename to TTUnit to avoid clash with Lean core's Unit type.
  Without this fix, use sites emit bare 'TTUnit' but Lean scopes
  constructors inside their inductive's namespace, so the actual
  Lean name is 'UnitType.TTUnit' — now correctly emitted.
- Skip ctor-qualification when the rename target already contains
  '.', so 'Eq -> Eq.refl' doesn't get mangled into 'Eq.Eq.refl'.

SAWCorePreludeExtra:

- iteDep / iteDep_True / iteDep_False / ite / ite_eq_iteDep all
  universe-polymorphic via '.{u}'. Required so SAWCore callers
  passing motives at arbitrary sorts elaborate. rfl reduction
  proofs stay valid because Lean's Bool.rec is universe-poly.

Outstanding: ~100 errors remain in the second half of
SAWCorePrelude.lean (Nat-arithmetic, Vec, IsLeNat regions).
Needs separate investigation — likely further type-class or
constructor-parameter issues. 16/16 smoke tests pass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
After P4 v2 (commit 795e0dd), 2000 of 4025 lines of auto-translated
SAWCorePrelude.lean elaborate cleanly. Remaining ~100 errors all
share one root cause: SAWCore's Prop <= Type cumulativity doesn't
hold in Lean 4.

Where SAWCore writes 'Either (IsLtNat m n) (IsLeNat n m)' with
'IsLtNat' and 'IsLeNat' at Prop, Lean expects 'Type' and rejects.
The faithful-to-semantics fix is to emit 'PLift' wrappers at every
application site where a Prop-typed argument meets a Type-expecting
parameter.

This is a separate investigation — ~1 additional day — that needs
a type-directed pass comparing expected parameter types against
translated argument types. Documented as P4 follow-up rather than
rolled into 795e0dd so the substantial universe-handling progress
can land cleanly.

The soundness discipline forbids the alternative of collapsing
SAWCore Prop to Lean Type — that would silently erase proof
irrelevance. PLift insertion is the honest path.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Sub-agent report. Reframes the remaining P4 v2 errors and identifies
the sound fix with concrete probe validation. Headline corrections
to my preliminary plan:

1. Of 99 errors reported, only 17 are actually the Prop/Type gap.
   The other 82 are two separate bugs:
   - 68 are a Nat vs _root_.Nat name-resolution issue in
     SAWCoreVectors.Vec (expects Lean's native Nat, but SAWCorePrelude
     defines its own).
   - 13 are the Lean reserved keyword 'at' — Prelude.at emits unquoted.
   These are tracked as P6b and P6c; must not be conflated with P6.

2. Winning fix is NOT PLift insertion (the sketch I drafted in
   p4-v2-status.md). It's Approach C: universe-polymorphize the
   sort-0 inductives (Either.{u,v}, Maybe.{u}, PairType.{u,v}),
   mirroring what P4 v2 already does for sort-1. Lean then infers
   .{0,0} or .{1,1} at use sites automatically. Zero PLift
   wrapping, no support-lib additions, no Coe tricks.

3. Key refinement: defs with multiple sort-0 binders linked by Eq
   (like coerce__def) need a SINGLE shared universe across those
   binders — not per-binder fresh. This is the one change from
   naive P4 v2 logic.

4. Rejected alternatives (B: Coe instances; A: manual PLift with
   downstream .down) probed and shown to have blocker limitations
   on the translator's explicit-@-application form.

5. Rocq ground truth: Rocq emits Prop/Type literally with no
   universe variables, relying on Coq's native cumulativity.
   Lean doesn't have that, hence the need for polymorphization.

Effort estimate: 3-4 hours. Translator changes localized to a new
translateBinderType function + a Sort0Mode state flag. No
support-lib or pretty-printer changes.

Key probe files in .tmp/p6_probes/ (gitignored): C_final2.lean and
C_proveEqNat_full.lean validate the winning approach. A_*, B_* probes
validate the rejections.

Preserves the discipline: no SAWCore Prop -> Lean Type silent
collapse (would erase proof irrelevance); no skip lists; loud
failure where soundness can't be preserved.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Suggested by a Galois conversation: instead of emitting a
universe-polymorphic SAWCorePrelude.lean once and importing it
everywhere, specialize each user program's Prelude references at
the concrete type arguments and emit self-contained output per
program.

Rationale: Cryptol programs are monomorphic at the value level.
When a user writes implRev{4,[8]}, every 'sort 0' position in the
transitively-referenced Prelude defs resolves to a concrete type,
eliminating the universe-polymorphism impedance that Lean 4's
non-cumulativity creates.

This note is a de-risking exercise, not a commitment. Structured
as:
- Why the current polymorphic approach has stalled
- Why specialization plausibly avoids the impedance
- Where it could still fail (polymorphic user terms, output size,
  residual polymorphism, recursion-through-specialization)
- Staged investigation plan before committing: enumerate
  implRev4's dependencies; hand-specialize it; sketch translator
  changes; only then implement
- What's preserved vs. demoted from existing work (P4 v2 universe
  machinery stays but becomes fallback; generated prelude becomes
  reference rather than deliverable)

Recommends Stage 1 (dependency enumeration, ~2h read-only) and
Stage 2 (hand-specialize implRev4, ~half day) before any
implementation. Stage 2's result is the single biggest de-risk.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Implements the translator changes sketched in
doc/2026-04-23_stage3-translator-sketch.md:

- writeLeanTerm / writeLeanProp / writeLeanCryptolModule run
  scNormalize on the input before translation, with a fixed opaque
  list covering Nat/Pos eliminator-heavy defs so the output stays
  on the SpecialTreatment-mapped surface instead of unfolding into
  binary-positive scaffolding.

- New handwritten CryptolToLean/SAWCorePrimitives.lean declares
  axioms and inductives for the SAWCore primitives the Stage 1
  dependency scan identified: Either, Num, Stream, Integer,
  subNat/addNat, intSub/intNeg/intLe/natToInt/intToNat, gen,
  atWithDefault, foldr, coerce, unsafeAssert, error. Universe-
  polymorphic where the signature demands (unsafeAssert, error).

- SpecialTreatment wires each primitive and Nat/Pos constructor to
  the right Lean-side target. Nat constructors use literal-collapse
  (Zero -> 0, NatPos -> id, Bit0/Bit1 -> arithmetic helpers); the
  non-literal fallback is a plain reference rather than a loud
  error so polymorphic module translations still elaborate.

- polymorphismResidual on the residual type refuses binders at
  sort k > 0 (decision D3). Plain Type-0 polymorphism is fine
  (Cryptol's {a} binders translate as monomorphic Type params).

- translateSort reverts to its pre-P4 collapse (Prop for propSort,
  Type 0 otherwise); the Stage 3 universe work stays on the WIP
  branches.

- translateConstant's body-translation path for ImportedName is
  removed — under specialization every surviving Constant resolves
  via SpecialTreatment or falls through as a bare reference.

- translateDefDoc emits 'noncomputable def' unconditionally (every
  SAW primitive we can emit is an axiom Lean's code generator
  refuses to compile).

- CryptolModule.translateCryptolModule now actually returns the
  user-facing Decl list (the old code threw it away and only kept
  the auxiliary state).

- write_lean_sawcore_prelude and
  write_lean_cryptol_primitives_for_sawcore are deleted along with
  SAWCoreLean.SAWModule and translateSAWModule — dead code under
  the new architecture.

All five saw-lean-example outputs (idBool, implRev, Rev,
invol_prove0, eq_spec_prove0) elaborate under 'lake env lean' with
only 'unused variable' warnings and the expected 'by sorry' stubs.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…ranslation

Option A from the deep-dive: make scNormalizeForLean iterate
scNormalize to a fixed point. A single pass only unfolds constants
whose unfolding triggers a beta/recursor reduction at the call
site; constant-applied-to-variables forms (e.g. 'ecReverse n a xs')
halt after one step, leaving dangling references in polymorphic
Cryptol module output. Iterating unfolds one more layer each pass
— SAWCore non-'fix' defs form a DAG so convergence is guaranteed —
with a 100-iter safety bound.

Supporting changes the polymorphic case surfaced:

- SAWCorePrimitives.lean: add Stream (inductive, not axiom — has
  MkStream constructor), EmptyType (one-ctor Empty), RecordType
  (one-ctor RecordValue), foldl, and the missing int arithmetic
  (intAdd/intMul/intDiv/intMod/intEq). Make addNat/subNat reducible
  wrappers around Nat.add/Nat.sub so Lean can unify vector sizes
  like 'addNat 1 (subNat 10 0)' with literal '11'.

- SpecialTreatment: mapsTo entries for Stream/MkStream/EmptyType/
  Empty/RecordType/RecordValue and the int arithmetic set. Use
  mapsToExpl for constructors that take inductive type params
  explicitly in SAWCore.

- Drop the 'isort' -> '[Inh_a : Inhabited a]' binder auto-injection.
  SAWCore's isort flag is an advisory about reachability; turning it
  into a positional Instance binder clashed with SAWCore's
  motive-positional recursor calls. No soundness loss (the flag
  wasn't used to justify any Lean-side reasoning). Wrapper becomes a
  pair of (Ident, Type) instead of carrying auxiliary instance args.

- Recursor emission stays as '@Foo.rec' (explicit) — matches SAW's
  motive-first positional arg list. The rationale that previously
  argued against @ was the Inh_a auto-insertion path, which is gone.

- CryptolModule.translateTypedTermMap emits 'noncomputable def' for
  Cryptol-module defs (same reasoning as translateDefDoc: any user
  term can transitively depend on the coerce/unsafeAssert/error
  axioms Lean's code generator refuses).

All five saw-lean-example outputs (idBool 7 lines, implRev 298,
Rev 4109, invol_prove0 1081, eq_spec_prove0 555) elaborate under
'lake env lean' with only unused-variable warnings and intended
'sorry' stubs. Smoketest suite still passes 16/16.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The two audit docs in doc/2026-04-24_audit-*.md flagged three
concrete soundness improvements:

1. Tighten 'error' from universe-polymorphic to non-Prop. The old
   'error.{u} : (α : Sort u) → String → α' let a Lean user importing
   the module write 'exact error False ""' to produce 'False'.
   SAW's primitive is 'isort 1', forbidding Prop. We now use
   'Sort (u+1)' — admits every non-Prop sort, blocks the attack.

2. Fail loudly on surviving Nat#rec / Pos#rec. Previously these
   would emit '@Nat.rec' against Lean's auto-generated recursor
   whose case order doesn't match SAW's; a user constructing such
   a term saw an obscure Lean elaboration error instead of a
   translator-level refusal. Term.hs now throws a new
   'UnsoundRecursor' TranslationError when it sees a recursor over
   Prelude.Nat or Prelude.Pos. (Probed by hand-constructing such
   a term via 'parse_core' — it fails at saw-time with a clear
   diagnostic.)

3. Extend 'leanOpaqueBuiltins' to cover every Prelude def whose
   RHS uses Nat#rec or Pos#rec (per the audit list): leNat,
   expNat, widthNat, divNat, modNat, pred, natCase, if0Nat,
   Nat_cases, Nat__rec, Nat_cases2, doubleNat, BitM, posSub,
   posEq, posLe, posLt, posExp, dblZ, AccessibleNat_all. Without
   these, scNormalize would unfold them, surface their internal
   recursors, and trigger the new loud-failure guard
   unnecessarily.

Drive-by cleanup:

- Remove the 'CryptolToLean.SAWCoreScaffolding.Inhabited' class.
  It was the target of the now-removed [Inh_a : Inhabited a]
  auto-injection. Dormant, dead.

All 5 saw-lean-example outputs still elaborate (probe/Rev.lean
4109 lines, others unchanged in shape). 16/16 smoketests still
pass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
septract and others added 23 commits May 3, 2026 15:54
Update README and revised plan to reflect Slice B's closure
(Phase 5d) — the blocker was a wrong zip axiom signature in our
Lean support library, corrected in commit 9b27385. Popcount
now translates and elaborates end-to-end; pinned in the
integration suite.
SAW Prelude declares Float and Double as opaque types with
mantissa-exponent constructors. Faithful Lean axioms matching
Prelude.sawcore lines 2153-2165.

Note: SAW's mkDouble at Prelude.sawcore:2163 is declared with
return type Float (not Double). Possibly a SAW upstream typo,
but the Lean axiom mirrors it exactly per the soundness-paramount
rule (no silent corrections). Documented inline in
SAWCorePrimitives.lean. If SAW fixes the upstream declaration our
axiom should be updated to match.

Coverage: Cryptol's surface 'Float e p' doesn't lower through
these primitives — the cryptol-saw-core layer treats Cryptol Float
operators as 'Unimplemented' and emits 'error UnitType "..."'.
So our binding is exercised by parse_core / LLVM-extract paths,
not by Cryptol Float surface code. The L-14 startup audit is the
gate; smoketest pins that Float / Double / mkFloat / mkDouble are
all covered (not in the unmapped-and-missing list).

44 smoketests + 22 integration tests pass.
Move the support library closer to Rocq's by adding the lemma
shapes Rocq provides but we didn't yet.

SAWCoreBitvectors_proofs additions (~80 LOC, ~20 new entries):
- bvsmax / bvsmin / bvumax / bvumin constants — defined via gen
- isBvsle / isBvslt / isBvule / isBvult Prop wrappers — defined
  as Bool-equation reflections
- isBvX_def equivalences — Iff.rfl since the Prop wrappers unfold
  to the Bool eq by definition
- Cross-comparison lemmas (axioms): isBvslt_to_isBvsle,
  isBvult_to_isBvule, isBvule_to_isBvult_or_eq,
  isBvslt_to_bvEq_false, isBvult_to_bvEq_false
- Edge-case lemmas (axioms): isBvslt_antirefl, isBvsle_antisymm,
  not_isBvslt_bvsmin, not_isBvslt_bvsmax, isBvule_zero_n,
  isBvule_n_zero, isBvult_n_zero
- Round-trip lemmas (axioms): bvNat_bvToNat_id, bvToNat_bvNat,
  bvToNat_bounds

SAWCorePrelude_proofs additions:
- mulNat_eq_natMul / minNat_eq_natMin / maxNat_eq_natMax /
  expNat_eq_natPow / pred_eq_natPred / doubleNat_eq — by rfl
  since these are reducible defs over Lean stdlib

Each axiom matches Rocq's same-named lemma. Rocq proves via
'holds_for_bits_up_to_3' (exhaustive small-bit case analysis); we
transport the conclusion. Same trust character as the existing
bv axioms — SAW vouches for the semantics, we mirror.

44 smoketests + 22 integration tests pass.
Continued bringing the support library closer to Rocq parity.

SAWCoreBitvectors_proofs (~110 LOC added, ~25 entries):
- Successor/predecessor bv lemmas: isBvslt_to_isBvsle_suc,
  isBvult_to_isBvule_suc, isBvslt_pred_l, isBvsle_pred_l,
  isBvsle_suc_r, isBvslt_suc_r
- Sign/unsigned bridge lemmas: isBvult_to_isBvslt_pos,
  isBvule_to_isBvsle_pos, bvule_to_bvslt_zero, bvule_to_zero_bvsle
- Equality via subtraction: bvEq_bvSub_l, bvEq_bvSub_r
- Boolean truth-table theorems: bool_beq_eq, bool_beq_neq,
  and_bool_eq_true/false, or_bool_eq_true/false,
  not_bool_eq_true/false (provable in Lean by cases <;> simp)

SAWCorePreludeExtra:
- iterNat / iter from CryptolPrimitivesForSAWCoreExtra.v —
  Cryptol's iterate-n-times pattern, paired with Num dispatch

SAWCorePrelude_proofs:
- min_nn / min_nSn / min_Snn — small min identities provable from
  Lean's Nat.min_self, Nat.min_eq_left/right, Nat.le_succ

44 smoketests + 22 integration tests pass.
User correction: 'Some of these lemmas seem like trivial properties
of the Lean types. Surely that's not what you mean? you want to
prove things about the local prelude definitions.'

Two corrections:

1. Removed boolean truth-table theorems (bool_beq_eq,
   and_bool_eq_true, or_bool_eq_true, not_bool_eq_true, etc.).
   These were stated about Lean's standalone &&/||/\! operators —
   but SAW's Prelude.and / or / xor / not emit as
   ite Bool a b Bool.false-style chains in our translated output
   (per leanOpaqueBuiltins + L-16 wrapper routing). Lean's
   standalone && doesn't surface in user-facing SAW-translated
   goals, so theorems about it are Lean-stdlib trivia not
   actually useful for discharging SAW proofs. The
   iteDep_True / iteDep_False / ite_True / ite_False simp lemmas
   in SAWCorePreludeExtra already cover the SAW-emitted Bool-elim
   shape. Replaced with an inline note explaining the omission.

2. Restated min_nn / min_nSn / min_Snn in terms of SAW's minNat
   (the name the translator emits) rather than Lean's Nat.min.
   The proof reduces by rfl since minNat is a reducible alias,
   but the theorem's name and signature now match Rocq's intent
   and where users searching for 'minNat'-related rewrites would
   look.

Same test suite still passes (44 + 22).
Audit per user request: prove meaningful properties of the SAW
backend, not things that are trivial Lean-stdlib facts.

Removed (SAWCoreBitvectors_proofs.lean):
- not_not, and_comm, and_assoc, or_comm, or_assoc, and_or_distrib
  — Lean-stdlib Bool truth tables proven by 'cases <;> rfl'.
  These are properties of Lean's standalone Bool operators, but
  SAW's Prelude bool ops emit as 'ite Bool a b Bool.false' chains
  in our translated output (per leanOpaqueBuiltins + L-16 wrapper
  routing). Standalone Lean Bool ops do not surface in user-facing
  SAW-translated goals; the walkthrough proof confirms this by
  closing test_offline_lean.t2 (distributivity) via cases+rfl
  through iteDep reducibility, never using any of the deleted
  theorems. If a downstream user ever needs a raw Lean Bool fact,
  the stdlib has them under Bool.*.

Reframed (SAWCorePrelude_proofs.lean):
- The Nat-arithmetic alias section header now says 'convenience
  plumbing' rather than 'Rocq parity' or 'theorems about SAW
  behavior'. They are @[simp] entry points that rewrite SAW-named
  ops to their Lean stdlib equivalents — useful for proof
  ergonomics but not novel theorems. Mirrors Rocq's identical
  plumbing (addNat_add, mulNat_mul, etc.).

Surviving theorems are substantive: Vector round-trips, fold
reductions, Bool-Nat decision bridges, and the full bv axiom
set (bvAdd / bvSub / bvNeg / bvXor / bvEq / isBvX / bvNat /
bvToNat / bvule families). Those are the load-bearing 'Rocq
parity' content.

44 smoketests + 22 integration tests pass.
Real-world exercise from
exercises/functional-correctness/point/Point.cry — pinning end-to-end
that we cover records + Cryptol Ring class dictionary (via 'zero') +
property emission. ~150 lines of emitted Lean. 23 integration tests.
Mirrors otherTests/saw-core-rocq/test_cryptol_module_sha512.saw,
which pins SAW's clean refusal of SHA-512's full functor (the
parametric Merkle-Damgard chain over polymorphic message length L
lowers to Prelude.fix over Cryptol::Num#rec1, which both backends
refuse).

For Lean: the L-5 lockdown's RejectedPrimitive fires; L-1's
polymorphismResidual would also flag the L\@free Num residual.
Pinned log.good captures the diagnostic shape so a regression
that drops the rejection (or changes its wording in a way that
breaks user-facing error messages) is caught.

Lifting this is gated on a Phase 5 extension for the polymorphic
Num#rec1 dispatch shape; documented inline in the .expect-fail.

24 integration tests now (was 23). Closes the last
otherTests/saw-core-rocq parallel.
Convert ~80 opaque Lean axioms to definitions/theorems by routing
through Lean's native BitVec, Int, Rat, and Vector libraries.

Primitives (SAWCorePrimitives.lean):
- Add vecToBitVec/bitVecToVec converters between SAW's MSB-first
  Vec n Bool and Lean's BitVec n.
- Replace ~25 bv-op axioms (bvAdd, bvSub, bvMul, bvNeg, bvUDiv,
  bvURem, bvSDiv, bvSRem, bvShl, bvShr, bvSShr, bvNot, bvAnd, bvOr,
  bvXor, bvEq, bv*lt/le/gt/ge, bvUExt, bvSExt, bvNat, bvToNat,
  bvToInt, intToBv, sbvToInt) with noncomputable defs via BitVec.
- Define bvPopcount/bvCountLeadingZeros/bvCountTrailingZeros via
  state-pair folds; bvLg2 via Nat.log2.
- Convert Integer ops to defs over Lean's Int. Use Int.fdiv/Int.fmod
  to match SAW's concrete-simulator Haskell-div/mod (floor) semantics.
- Convert IntMod n to Int + Int.fmod, Rational to Lean's Rat,
  Float/Double to Int x Int (faithful since SAW has no operations).
- Convert zip from axiom to Vector.ofFn with Nat.min bound proofs.
- Convert coerce from axiom to a def via Lean's cast (real
  type-equality transport, sound).
- Add Inhabited instances for Stream, UnitType, EmptyType, PairType,
  RecordType (data inductives with Inhabited-arg constructors).
- Fix bvsmin/bvsmax: previous defs assumed LSB-first but vecToBitVec
  is MSB-first, making boundary axioms vacuously violable. Now route
  through BitVec.intMin/intMax.

Add 2 SAW<->BitVec round-trip coherence axioms
(vecToBitVec_bitVecToVec, bitVecToVec_vecToBitVec); decidable per
width via 'by decide'.

Proof library (SAWCoreBitvectors_proofs.lean):
- Convert all ~30 axioms to theorems proven from BitVec lemmas plus
  the 2 coherence axioms.
- Includes signed comparison + successor/predecessor + sign-bridge
  lemmas (isBvslt_to_isBvsle_suc, isBvslt_pred_l, isBvsle_pred_l,
  isBvsle_suc_r, isBvslt_suc_r, isBvult_to_isBvslt_pos,
  isBvule_to_isBvsle_pos, bvule_to_bvslt_zero, bvule_to_zero_bvsle).
- Add toInt_eq_toNat_of_nonneg / _sub_of_neg helpers and
  vecToBitVec_bvAdd / _bvSub / _intToBv routing helpers.
- Add Std.Tactic.BVDecide import (bv_decide proves these for any
  fixed width; auditors can spot-check).

error.{u} investigation: tested tightening to
'def error (α : Sort (u+1)) [Inhabited α] (_ : String) : α := default'
matching SAW's isort 1, but Cryptol typeclass elaboration emits
'error <T> "invalid instance"' at potentially-uninhabited types
(e.g., Eq over Stream a) inside dead dictionary branches. SAW's
isort is advisory in practice. Reverted; documented in residual-trust
catalog.

Final axiom count in SAWCorePrimitives.lean: 4 (down from ~80).
- 2 Vec<->BitVec coherence (decidable per width)
- unsafeAssert (SAW-faithful intentional unsoundness, L-2 pinned)
- error.{u} (SAW-faithful intentional unsoundness, L-original pinned)

Verification: lake build passes, 44/44 smoketests, 24/24 integration
tests in otherTests/saw-core-lean (all .diff empty, no
.elaboration.fail), L-2/L-8/L-original soundness tests pass,
discharge proofs at intTests/test_lean_offline_proof_t1/3/4,
test_lean_recursion_stream_corec_proof, test_lean_walkthrough_proof
all close.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Phase 9 (axiom -> def conversions) substantially changed the
proof-side experience: bv arithmetic now reduces, the BV theorem
library is complete, and bv_decide is wired (via Std.Tactic.BVDecide
import). The user-facing docs reflected the pre-Phase-9 state.

- getting-started.md: refresh 'What works today, what doesn't'
  to reflect Phase 9. Document that bv arithmetic is now defined
  (concrete-input goals close by 'decide'), the theorem library
  in SAWCoreBitvectorsProofs covers symbolic identities, and
  Integer/Rat ops are reducible aliases over Lean natives.

- doc/proof-cookbook.md (new): worked-examples companion with
  8 patterns matching common Cryptol-emitted goal shapes, each
  with a tested discharge tactic. Tables of theorem names for
  bv arithmetic and signed predicates.

- intTests/test_lean_proof_cookbook/ (new): pins the cookbook
  examples. If a future support-library change breaks any
  documented pattern, this test fails loud.

Phase 7's first slice. Remaining: tactics for SAW-translated
goals, more end-to-end discharge examples for symbolic-width bv
properties, possibly a saw_simp meta-tactic.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Three macros packaged as CryptolToLean.Tactics, auto-imported via
the root CryptolToLean module:

- saw_bv: simp with every bv-op unfolded plus round-trip rewrites.
  Closes concrete-input goals, reduces symbolic goals to pure
  BitVec form. Replacement pattern for 'by decide' when decide
  stalls but simp can make progress.

- saw_unfold: unfold the SAW-named bv primitives without closing.
  For inspection-before-manual-work.

- saw_to_bitvec: saw_unfold + round-trip rewrites. Lifts a SAW-
  typed goal to a pure BitVec n goal so bv_decide / BitVec lemmas
  apply.

Extended test_lean_proof_cookbook with saw_bv examples. Updated
proof-cookbook.md with a 'convenience tactics' section + the
saw_bv alternate in Pattern 1.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Stress-test plan (doc/2026-05-03_stress-test-plan.md) baseline
example. Cryptol property \(x y : [8]) -> x + y == y + x;
offline_lean emits the goal, intTests discharge closes it in 3
lines via bvAdd_comm + bvEq_refl.

Pinned:
- otherTests/saw-core-lean/test_offline_lean_stress.saw (driver)
- otherTests/saw-core-lean/test_offline_lean_stress.log.good
- otherTests/saw-core-lean/test_offline_lean_stress.E1_prove0.lean.good
- intTests/test_lean_E1_bvAdd_comm_proof/ (discharge)

Baseline is clean: standard import + open, three tactic lines.
Ready for E2.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Cryptol property \(b : Bit) (x y : [8]) ->
  (if b then x else y) == (if b then x else y).

Both sides syntactically identical in the emitted goal, so
bvEq_refl closes it without touching the iteDep wrapper. Two
tactic lines.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Cryptol property \(p1 p2 : Point) -> point_add p1 p2 == point_add p2 p1
where Point = { x : [32], y : [32] } and point_add adds fieldwise.

Exercises RecordType.rec reduction on two-field records. The
emitted goal has ~80 lines of RecordType.rec projections; after
`obtain \<x, y\> := p` these reduce to .1 / .2 accesses.

Discharge: 5 tactic lines total (intro + 2 obtains + 2 rws +
simp [bvEq_refl]). Clean — no support-library changes needed.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Cryptol property \(xs : [4][32]) -> map (\x -> x + 0) xs == xs.

Discharge (8 lines):
- simp only [bvAdd_id_r] — remove the +0
- rw [gen_atWithDefault] — gen-of-at-id-index round-trip
- construct hgen for pointwise bvEq_refl (simp can't do this
  automatically because bvEq_refl's RHS has a metavariable)
- simp only [hgen]; decide — fold of all-trues at size 4

BUG FOUND: all intTests/test_lean_*_proof/test.sh scripts
accepted proofs that elaborated with `sorry`. The grep was for
'error' only, not for 'declaration uses sorry' warnings. This
silently masked incomplete proofs.

Fix: added an additional grep for 'declaration uses `sorry`'
in test.sh; propagated to all 9 lean-proof test dirs.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Previously every intTests/test_lean_*/test.sh directory carried
its own copy of a 52-line / 75-line bash harness with subtle
per-dir variations. Duplication invited drift; in particular the
sorry-detection fix (E4 commit) had to be propagated across 10
identical files by hand. Soundness-affecting infrastructure must
be centralized, not scattered.

Two shared helpers in intTests/support/:

  lean-proof-test.sh  — for test_lean_*_proof/ directories. Runs
                        proof.lean through 'lake env lean' and
                        fails if elaboration errors OR if the
                        result uses 'sorry'.

  lean-shape-test.sh  — for test_lean_soundness_*_{shape,prop}/
                        directories. Runs every *.shouldfail.lean
                        (must fail elaboration) and every
                        *.shouldpass.lean (must succeed without
                        sorry) probe.

Probe file naming is the full contract for shape tests — no env
variables, no test.sh variance. Renamed:
  attack.lean            -> attack.shouldfail.lean
  positive.lean          -> positive.shouldpass.lean    (coerce)
  non_prop.lean          -> non_prop.shouldpass.lean    (error, unsafe_assert)

Each affected test.sh is now a one-liner:
  exec ${TEST_SHELL:-bash} ../support/lean-{proof,shape}-test.sh "$@"

Adding a new proof or shape test is now: create the directory,
drop in proof.lean (or *.should{fail,pass}.lean probes), copy
the one-liner test.sh. Zero plumbing per directory.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Previously each intTests/test_lean_*_proof/proof.lean copy-pasted
the SAW-emitted goal verbatim from its .lean.good file and added
a tactic proof. This is unsafe: if SAW's emission drifts, the
pasted goal and the real emission silently diverge. The discharge
proves a copy, not the actual output — soundness gap.

New pattern: emitted file is untouched ground truth, discharge
imports it.

  source.txt      — one line: relative path (from saw-script/)
                    to the SAW-emitted .lean file.
  proof.lean      — does `import Emitted` and proves
                    `theorem goal_closed : goal := by …`.
                    The definition of `goal` comes from the
                    imported emitted file.

lean-proof-test.sh:
  1. Read source.txt.
  2. Copy the emitted file to Emitted.lean in the probe dir.
  3. Compile Emitted.lean -> Emitted.olean.
  4. Elaborate proof.lean with LEAN_PATH pointing at the probe
     dir so `import Emitted` resolves.
  5. Fail if the emission doesn't compile OR the tactic proof
     doesn't close OR proof.lean's own declarations use sorry.

source.txt is optional: tests that are self-contained (like
test_lean_proof_cookbook, which exercises library patterns
without an emitted goal) omit it, and the Emitted step is
skipped.

Emission drift now surfaces loudly: a change in SAW's output
that the hand-written tactics don't handle fails elaboration,
rather than silently running a stale copied goal.

Converted all 9 existing import-style discharges:
- E1, E2, E3, E4 (stress tests)
- offline_proof_t1, t3, t4
- walkthrough_proof
- recursion_stream_corec_proof

Left the cookbook untouched (no emitted goal; source.txt absent).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Setting up E5 (littleendian round-trip discharge) surfaced a real
soundness bug: the translator emits
'CryptolToLean.SAWCorePrelude.divNat' (and modNat) into .lean
files, but those identifiers don't exist in our library. The
emitted .lean fails to compile with 'unknown identifier divNat'.

Root cause:
  - divNat, modNat, divModNat are defined in SAW Prelude.sawcore
    via Nat#rec. They're marked in 'leanOpaqueBuiltins' to keep
    their bodies opaque during normalization (sound — we don't
    want to expose Nat#rec).
  - But they had NO SpecialTreatment entry routing them to a
    Lean-side function. So the translator fell back to emitting
    'CryptolToLean.SAWCorePrelude.<name>', which Lean can't
    resolve.
  - L-14 (existing check) only audits 'primitive' decls (no
    body). These defs have Nat#rec bodies, so L-14 missed them.

Fixes:
  1. Add divNat/modNat/divModNat to SpecialTreatment map,
     routing to sawCorePrimitivesModule.
  2. Add reducible Lean defs in SAWCorePrimitives.lean backed
     by Nat.div / Nat.mod (matching SAW's div-by-zero = 0
     convention that it inherits from the Prelude impl).

New audit (catches the bug class going forward):
  'auditOpaqueBuiltinsCoveredBySpecialTreatment' in Exporter.hs
  flags every leanOpaqueBuiltins entry that names a Prelude def
  but lacks a SpecialTreatment mapping. The new smoketest
  'every leanOpaqueBuiltins entry has SpecialTreatment
  (L-14 companion)' runs this at CI time.

The audit immediately found 19 more entries lacking mappings.
All 19 are in 'leanOpaqueBuiltinsIntentionallyUnmapped'
(new list in Exporter.hs) with reasons: Pos/Z arithmetic that
Cryptol doesn't expose, Nat#rec wrappers already rejected by
L-3. Any new addition to leanOpaqueBuiltins now forces either a
real mapping OR a conscious entry in the exception list.

E5 setup: otherTests driver updated with the Cryptol property;
discharge dir exists with source.txt pointing at the emitted
file. The proof itself is still a sorry placeholder — the
import-based test.sh now correctly fails on sorry, so this is a
visible WIP rather than a false pass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Cryptol property \(xs : [4][8]) -> reverse (reverse xs) == xs.

Initial attempt parked because the support library was missing
a structural lemma about the gen-of-(at-of-gen-of-at) shape that
emerges from double reverse. Added:

  gen_atWithDefault_double_reverse :
    gen n α (fun i => at (gen n α (fun j => at xs (subNat (subNat n 1) j)))
                       (subNat (subNat n 1) i)) = xs

(Stated in subNat form so simp only can match the translator's
emitted shape — subNat is a reducible alias but simp only doesn't
unfold reducibles by default.)

With that lemma, the discharge is a one-liner: simp only with
the new lemma collapses the inner double-reverse to xs; then
each fold element is bvEq xs[i] xs[i] = true via a
hand-quantified bvEq_refl; decide closes the all-trues fold of
size 4.

E5 originally targeted the full Salsa20 littleendian round-trip
(reverse-split-join-reverse). That remains parked as a future
tier-4 exercise since closing it needs a structural lemma for
split/join interaction we haven't added.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The point of E7 is that SMT bit-blasting at 256 bits is painful
(cubic-ish in width), while Lean closes the goal in one line via
`bvAdd_assoc`. The discharge is structurally identical to the
width-16 t3 proof — `bvAdd_assoc` is generic in the width, so
the same `rw ; bvEq_refl` recipe scales.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Width-4 discharge by case-decomposing bits : Vec 4 Bool into four
booleans and finishing by `decide`. Both the foldl-spec form and
the genFix-lowered self-referential comprehension reduce to a
concrete 3-bit popcount per case (16 cases), so decide closes them.

This is a stopgap. As the user pointed out, the principled
discharge is a polymorphic-over-width inductive lemma in the
support library — matching the E1/E5/E7 pattern where each
per-width discharge is a one-line instantiation. The blocker is
a `genFix_snoc` characterization for the Cryptol comprehension
shape, which hasn't been built yet. Filed as a Phase 7 follow-up
task.

The proof file's docstring documents this gap explicitly so the
test holds the slot until the inductive form lands.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
CRITICAL SOUNDNESS FIX. The previous error.{u} signature
`(α : Sort (u+1)) → String → α` was unsound:

  example : False :=
    Empty.elim (error Empty "boom")  -- elaborated cleanly\!

`Empty : Type 0 = Sort 1` fits `Sort (u+1)` at u := 0, so
`error Empty "" : Empty` typechecked, and Empty.elim then gave
False. The Sort restriction excluding Prop was insufficient — it
blocked the False-attack but missed the Empty-attack.

Confirmed by direct check (lean_run_code):
  'fake_false_via_empty' depends on axioms: [error]

Fix: tighten to `(α : Type u) → [Inhabited α] → String → α`.
`Inhabited Empty` does not exist, so the call no longer
synthesizes. Type u still excludes Prop. All legitimate
translator-emitted shapes carry generic Inhabited instances
(Vec / PairType / RecordType / Either / Stream / Stream-endo /
Bool / Nat) — confirmed by re-emitting all stress-test goals
(E1-E7 .good files identical) and re-running every Lean-side
intTest (16/16 pass).

New regression: attack_empty.shouldfail.lean pins the Empty hole
closed. soundness-boundaries.md updated to reflect the L-17
restriction.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…sion

writeLeanProp / translateGoalAsDeclImports now emits two extra
opens at the top of every `*_prove0.lean` file:
  open CryptolToLean.SAWCoreBitvectorsProofs
  open CryptolToLean.SAWCorePreludeProofs

This makes the emitted goal file self-contained: a user who
discharges in-place by replacing the `sorry` in `goal_holds`
can reference `bvAdd_comm`, `gen_atWithDefault_double_reverse`,
etc. without restating opens.

Limitation (documented in the preamble's docstring): Lean opens
are file-local. A separate discharge file using `import Emitted`
still needs its own `open` statements. So the originally-claimed
boilerplate reduction in proof.lean files is NOT delivered by
this change. Reducing that boilerplate would require an
`export`-aggregator module or `#export ... from ...`
(no Lean equivalent). Left for a follow-up.

writeLeanTerm / translateTermAsDeclImports keeps the False flag
(no proof opens) — generated module files don't need them.

Refresh of all 11 pinned .good files in otherTests/saw-core-lean/
verified line-by-line: each diff is exactly +2 `open` lines and
zero changes elsewhere.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@sauclovian-g
Copy link
Copy Markdown
Contributor

It seems to have dumped a lot of lean-only stuff in intTests, where it really doesn't belong. Assuming those have any value at all (not immediately clear), they probably ought to be in otherTests/saw-core-lean. FWIW

septract and others added 6 commits May 4, 2026 21:42
Soundness probes that silently passed when their preconditions were
missing — that's how the L-17 Empty hole survived for so long, and
how the (Pi) drift in test_lean_soundness_polymorphic{,_nested} sat
for weeks without anyone noticing. Phase A patches the four
silent-skip patterns:

1. lean-shape-test.sh: `lake` missing → was `exit 0`, now `exit 1`
   with a clear error message + remediation. Soundness-shape probes
   have NO .good fallback; if the harness can't elaborate them, the
   test must fail, not silently succeed.

2. lean-proof-test.sh: same. Proof discharges only validate via Lean
   elaboration; lake missing must surface as a real failure.

3. Both harnesses: `lake build` failure → was `exit 0` (treated as
   environment skip), now `exit 1`. A failing support-library build
   is a real defect, not an environment issue.

4. lean-elaborate.sh (used for the OPTIONAL elaboration step in
   driver tests): `lake` missing still exits 77 (correctly skipped
   — driver tests have authoritative .good diff coverage), but
   `lake build` failure now exits 1 (loud; library bugs aren't
   environment skips).

5. test-lean.sh: run-tests now also clears `*.lean.elaboration.fail`
   from previous runs (was missing from the cleanup glob; could
   leak stale failures into check-diffs).

CI workflow comment updated to reflect that the elan install step
is no longer optional — Linux/macOS integration-tests now require
lake. Windows is already `continue-on-error: true` (issue GaloisInc#2648),
so this patch makes Windows test_lean_* failures visible-but-
non-gating rather than invisible. Filed as task GaloisInc#134 follow-up:
either install elan on Windows or filter test_lean_* off Windows
deliberately.

Verified: all 19 Lean-side intTests still pass with lake available;
both modified harnesses fail loudly when run with lake stripped from
PATH (env -i PATH=/usr/bin:/bin bash test.sh → exit 1 with the
remediation message).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
intTests/test_lean_soundness_polymorphic{,_nested}/poly{,_nested}.log.good
have been stale since commit 92f0020 (L-discipline-5: Lambda-side
polymorphismResidual check). That commit added the (Pi) / (Lambda)
disambiguator to the diagnostic — the translator's behavior is
correct, only the pinned reference output was forgotten.

Diff per file is exactly one line:
  -Problematic binders: ["t : sort 1"]
  +Problematic binders: ["t : sort 1 (Pi)"]

Sweep verified: every other .good file in otherTests/saw-core-lean/
(217 total: 25 .log + 192 .lean) and intTests/test_lean_*/ (3 other
.log) is byte-identical to fresh emission. No other drift exists.

How this kind of drift gets caught next time:
- Phase A (committed earlier this session): silent-skip patterns
  removed from lean-shape-test.sh / lean-proof-test.sh. Lake or
  build failures now surface as test failures, not silent passes.
- These two tests already failed loudly via test-lean.sh's
  check-diffs (non-empty .diff → exit 1). The reason they sat
  unnoticed wasn't silent-skip in the harness; it was that the
  cabal integration-tests target on Linux/macOS would have caught
  them — implication is that someone has been ignoring CI failures
  on this branch, or this branch hasn't been pushed to CI recently.
  Filed as task GaloisInc#134 follow-up.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…lean/

Per SAW maintainer feedback ("lean-only stuff in intTests doesn't
belong; should be in otherTests/saw-core-lean") and the user's
explicit constraints:

  1. Everything Lean-backend specific lives under
     otherTests/saw-core-lean/. Nothing in intTests/.
  2. NO per-example shell scripts. ONE orchestrator + 3 harnesses.
  3. Clear category-based subdir structure.
  4. NO hand-rolled Lean that mimics generator output.
  5. ALL FAILURES NOISY. No silent skips, no dropped errors.

New layout:

  otherTests/saw-core-lean/
  ├── test.sh                 # ONE orchestrator (verb dispatch)
  ├── Makefile                # local-dev wrapper
  ├── support/                # 3 harness scripts + helper
  │   ├── lean-driver-test.sh
  │   ├── lean-proof-test.sh
  │   ├── lean-shape-test.sh
  │   └── lean-elaborate.sh
  ├── drivers/<25 tests>/     # SAW emission + diff + elaborate
  ├── proofs/<13 tests>/      # discharge generator-emitted goals
  ├── shape/<3 tests>/        # negative attack probes only
  └── saw-boundary/<5 tests>/ # SAW-rejection / boundary diagnostics

Subdirs are PURE DATA. No test.sh, no Makefile, no README,
no lean-elaborate flags.

Migration:
  - 21 directories moved from intTests/test_lean_*/ → categorized
    subdirs of otherTests/saw-core-lean/. Renamed (test_lean_E1_
    bvAdd_comm_proof → proofs/E1_bvAdd_comm, etc.) for clarity.
  - 25 flat top-level driver tests moved into drivers/<short-name>/
    so each is self-contained.
  - 4 harness scripts moved from intTests/support/ → support/.
  - .cry files moved alongside their tests.
  - Deleted 113 per-subdir scaffolding files (46 test.sh,
    21 Makefile, 18 README, 25 lean-elaborate flags,
    3 *.shouldpass.lean).
  - Deleted intTests/.elan/ artifacts (leftover when Test.hs
    set HOME=intTests; should never have been committed).

Harness changes ("all failures noisy"):
  - lean-elaborate.sh: removed exit 77 path on missing lake;
    now exit 1 with remediation message. Build failures also
    exit 1.
  - lean-driver-test.sh: dropped `lean-elaborate` flag check —
    Lean elaboration is now mandatory for all driver tests with
    *.lean output.
  - lean-shape-test.sh: only handles *.shouldfail.lean now
    (positive coverage moved to drivers/). Verb dispatch added.
  - lean-proof-test.sh: same loud-fail policy. Verb dispatch
    added.

Orchestrator (otherTests/saw-core-lean/test.sh):
  - Iterates each category, runs every test, accumulates
    failures, prints summary, exits non-zero on any failure.
    No fail-fast (so all problems visible per run), but no
    silent passes either.

L-17 reverted (separate task GaloisInc#137):
  - The `error.{u}` Inhabited-α tightening turned out to break
    real translator emission (free type variables at error
    positions). Reverted to Sort (u+1). The Empty soundness
    residual is documented and tracked via task GaloisInc#137 (proper
    fix requires translator changes to emit Inhabited evidence
    at every type binder).
  - attack_empty.shouldfail.lean removed (would no longer fail
    elaboration).
  - soundness-boundaries.md updated with the residual trust
    statement.

Verified: `bash test.sh test` from otherTests/saw-core-lean/
prints ALL TESTS PASSED. Every category exercised, every test
produces a clear pass/fail signal.

Final intTests/ diff vs master:
  - 2 files modified: test1646/test15.log.good and
    test_search/search03.log.good. Both refresh the SAW :list
    and search enumerations to include the new Lean-backend
    SAW commands (offline_lean, write_lean_cryptol_module,
    write_lean_term, dump_lean_residual_primitives). These are
    SAW command-set fixtures, not Lean-specific tests.
  - Everything else: zero.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…mbo probe

L-17 incident lesson: example-driven attack tests ("error False")
miss structurally-different routes to the same goal (Empty.elim ∘
error Empty). Building a cross-cutting audit so every known route
to False is either pinned by a probe or documented as a residual.

Deliverables:

1. doc/2026-05-04_attack-surface.md — single inventory listing
   every known attack route, classified as:
     - Blocked (pinned by *.shouldfail.lean): 6 routes
     - Residual (currently passes by design): 6+ routes
       split into L-17 family (closes via GaloisInc#137) and unsafeAssert
       family (cannot close without changing SAW semantics)
     - Not applicable (symbol not user-reachable): 2 routes

2. shape/coerce_unsafeassert_combo/attack.shouldfail.lean — new
   probe for the most direct combinational attack:
     unsafeAssert _ : Eq Type Bool Empty
     |> coerce Bool Empty _ true : Empty
     |> Empty.elim : False
   Currently blocked by the universe-level mismatch between
   unsafeAssert's Eq.{1} output and coerce's expected Eq.{2}. If
   either signature's universe pins shift, this probe trips —
   making the combinational attack a regression alarm rather than
   silent unsoundness.

3. soundness-boundaries.md updated to point at the new attack-
   surface inventory.

Verified: bash test.sh test from otherTests/saw-core-lean/ passes,
including the new probe.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Phase 9 (2026-05-03) had attempted to close the L-17 "Empty
attack" residual by tightening `error.{u}` to require
`[Inhabited α]`. That broke real Cryptol module emission:
SAW emits `error <T> "invalid instance"` placeholders in dead
typeclass-dictionary branches even when `T` may be uninhabited
(e.g., `Eq` over `Stream a` for a free type variable `a`),
and Lean cannot synthesize `Inhabited a` for an abstract type.
Phase 9 reverted to the unrestricted `Sort (u+1)` axiom,
documenting the user-side Empty attack as residual trust.

Two-tier design (this commit) sidesteps the conflict by splitting
the surface:

  * `error_unrestricted.{u} : (α : Sort (u+1)) → String → α` —
    the unsafe SAW-faithful axiom. SpecialTreatment routes SAW's
    `Prelude.error` here. Translator emission target only.
    Long, scary name signals "don't use this in user code."

  * `error.{u} (α : Type u) [Inhabited α] (msg : String) : α` —
    user-facing constrained def. Unqualified `error` resolves
    here in user discharges. Empty attack
    `Empty.elim (error Empty "boom")` fails at Inhabited
    synthesis. Same blocker for PEmpty, Fin 0, Inhabited Empty,
    etc.

Translator emission is unaffected: `SpecialTreatment.hs` routes
`error` to `error_unrestricted`, which has no Inhabited
constraint, so free type variables in dead-branch typeclass
elaborations still emit cleanly.

Residual: a user who explicitly writes `error_unrestricted
Empty "..."` can still extract `Empty`. This is an opt-out
(same semantic class as deliberate `unsafeAssert` misuse), not
silent unsoundness, and is documented.

Changes:
  - SAWCorePrimitives.lean: split error into two-tier definitions.
  - SpecialTreatment.hs: route SAW's error → error_unrestricted.
  - shape/error_prop/attack_empty.shouldfail.lean: re-add the
    L-17 attack probe; user-facing error blocks it.
  - proofs/E4_map_id, E5_littleendian: update proof.lean to
    reference `error_unrestricted` (matching emission shape).
  - 22 driver .good files refreshed: emission `error` →
    `error_unrestricted`.
  - soundness-boundaries.md, residual-trust.md, attack-surface.md
    updated: L-17 family is now "blocked" status; only
    `error_unrestricted` explicit-opt-out remains as residual.

Verified: all tests pass (drivers, proofs, shape, saw-boundary).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
First end-to-end test of the canonical SAW workflow with Lean as
the prover for one obligation. Adapted from
exercises/functional-correctness/point/ — point.c (LLVM impl) is
verified against Point.cry; the equivalence obligation is dispatched
to offline_lean instead of SMT, and proofs/llvm_point_eq/proof.lean
discharges it via a 6-line tactic.

The canonical workflow exercised:

  exercises/functional-correctness/point/point.c (LLVM impl)
       │
       │  llvm_verify against Point.cry
       ▼
  drivers/llvm_point_verify/test_llvm_point_verify.saw
       │  (offline_lean) emits the equivalence obligation
       ▼
  drivers/llvm_point_verify/*.point_eq_return_value_matching0.lean
       │  imports Emitted, discharges via tactics
       ▼
  proofs/llvm_point_eq/proof.lean

Three real translator/SAW bugs surfaced and were fixed:

1. `writeLeanProp` did not abstract free symbolic variables.
   llvm_verify-emitted goals carry SAWCore Variables introduced
   by llvm_fresh_var (e.g., "p1.x", "p2.y"). The Lean translator
   hit LocalVarOutOfBounds because nothing Pi-bound them. Fix:
   use SC.getAllVars + scPiList in writeLeanProp to abstract them
   into outer Pi binders before translation. No-op for closed
   prove_print goals (zero free vars).

2. `proveWithPropExporter` put spaces in filenames. crucible-LLVM
   goalType strings ("return value matching") leaked into output
   filenames as `*.point_eq_return value matching0.lean`. Fix:
   sanitize spaces and shell-awkward chars to '_' in the
   constructed path. Affects all offline_* exporters.

3. `escapeIdent` was not applied to local SAWCore var names.
   SAW's "p1.x" (record-field-projection naming from
   llvm_fresh_var) emitted as a Lean binder `(p1.x : ...)` —
   invalid because Lean uses '.' as namespace separator. Fix:
   route translateLocalIdent through escapeIdent so dot-containing
   names get Z-encoded ("p1.x" → "Op_p1zix").

Discharge proof (6 tactic lines):
  intro x1 y1 x2 y2
  by_cases hx : bvEq 32 x1 x2 = true
  all_goals (by_cases hy : bvEq 32 y1 y2 = true)
  all_goals
    (simp_all [CryptolToLean.SAWCorePreludeExtra.ite, bvEq_sym]
     <;> rfl)

Soundness audit via #print axioms:
  goal_closed depends on axioms: [propext]

Only Lean's standard propositional extensionality. NOT in the
dependency list: sorryAx, Classical.choice, error,
error_unrestricted, unsafeAssert, the Vec↔BitVec coherence
axioms. The C↔Cryptol equivalence is genuinely proved, grounded
in Lean stdlib + provably-correct SAWCoreBitvectors_proofs
theorems.

Files:
- drivers/llvm_point_verify/{test_llvm_point_verify.saw,point.bc,
  *.log.good,*.lean.good}: new driver. point.bc rebuilds via
  clang from exercises/.../point.c (referenced, not duplicated).
- proofs/llvm_point_eq/{source.txt,proof.lean}: new discharge.
- saw-central/src/SAWCentral/{Builtins,Prover/Exporter}.hs: SAW
  fixes (filename sanitization, free-var abstraction).
- saw-core-lean/src/SAWCoreLean/Term.hs: translator fix
  (escapeIdent on local var names).

Verified: bash test.sh test → ALL TESTS PASSED.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants