LoopProps: make loop_unfold a theorem, not an axiom (pure loopGo fixpoint)#81
Merged
Conversation
…oint) `Signal.loop` was `opaque` (a wrapper around the `unsafe` memoizing `loopImpl`), so `(Signal.loop f).val t` could not be unfolded by the kernel. The fixpoint equation `loop f = f (loop f)` — the load- bearing fact for every feedback-circuit proof (e.g. PR #65's RV32 divider) — was therefore a *trusted axiom* (`loop_unfold`). This replaces the opaque wrapper with a *pure* definition and turns `loop_unfold` into a proved theorem, removing the custom axiom from the trust base. ## What changed `Sparkle/Core/Signal.lean`: - `loopGo f t` — the fixpoint value at time `t`, by strong recursion: apply `f` to a signal that recursively supplies the already-computed values for every earlier cycle `i < t` and `default` at `≥ t`. The `if i < t` guard makes it well-founded (`termination_by t`). Total, axiom-free. - `loop f := ⟨loopGo f⟩` — now a `def`, not `opaque`. Still carries `@[implemented_by loopImpl]`, so *execution* uses the O(n) memoizing implementation; only the *logical* value is the pure fixpoint. - `loopGo_eq` — the defining equation, exposed for proofs. `Sparkle/Verification/LoopProps.lean`: - `loop_unfold` goes from `axiom` to `theorem`, proved by rewriting through `loopGo_eq` and discharging the `default` placeholder with `StrictlyCausal` (a strictly causal body never observes cycles `≥ t`, which is exactly why the placeholder is invisible). - Module docstring updated to describe the pure definition. ## Trust base `#print axioms dividerSignal_result_gen` (a downstream RV32 divider theorem) no longer lists `loop_unfold`. Remaining axioms are all Lean-standard: `propext`, `Classical.choice`, `Quot.sound`, `Lean.ofReduceBool` (from `bv_decide`/`native_decide`, pre-existing), and `Lean.trustCompiler` (from `@[implemented_by]` — the standard trust that `loopImpl` computes the same value as `loopGo`). The bespoke fixpoint axiom is gone. ## Performance `@[implemented_by loopImpl]` keeps sim fast: a direct `Signal.loop`-counter samples `.val 2000` in ~0 ms (memoized O(n), confirmed by probe). Matched `lake exe test` runs: main 7m52s vs this branch 8m02s — within noise, no regression. ## Also fixed (surfaced by the first truly-clean build) `lakefile.lean`'s `sparkleModuleDeps` list was stale: - `sparkle_Sparkle_Backend_CppSim.so` → `_CSim.so` (CppSim was removed / renamed to CSim in the C-only-backend work; the `.so` no longer exists, so a clean build's monolithic link failed with "-l:...CppSim.so not found"). - Added the missing `sparkle_Sparkle_Core_SignalLeavesDerive.so` (the `SignalLeaves` deriving handler, added with the per-leaf output-port split, was never registered → clean-build load error "undefined symbol: initialize_..._SignalLeavesDerive"). Both were latent bugs on `main` that only bite a from-scratch `.lake/build` wipe; incremental builds kept the old `.so` around. ## Verification - `lake build` clean. - `lake exe test`: 13/13 lspec pass. - Both `Sparkle.Verification.Divider.{Correct,States33}` build against the new pure `loop` with no proof changes needed — PR #65's divider proof is now the regression test that `loop = loopGo` is the right fixpoint.
…m-smoke) PR #65 (`c59820c`) added `moreLinkArgs` to the `Sparkle` lean_lib to force the `sparkle_barrier` / `sparkle_jit` extern archives whole-into the precompiled `libsparkle_Sparkle.so` — necessary so the `.so` self-resolves `sparkle_cache_get` / `sparkle_eval_at` (the `@[extern]` LICM barriers `Signal.loop`'s memoization uses; without it the interpreter/LSP fails to dlopen the `.so`). But it used a *relative* path `./.lake/build/c_src`. `moreLinkArgs` propagates to every DOWNSTREAM consumer of the `sparkle` package, and there the relative path resolves against the *downstream's* build dir — which has no `c_src` — so the link fails: ld.lld: error: unable to find library -l:libsparkle_barrier.a ld.lld: error: unable to find library -l:libsparkle_jit.a This is why `downstream-smoke` (ubuntu / macos / wsl) went red on `main` starting at the PR #65 merge (`9c0e776`); it was green on the prior commit (`df8560e`). Fix: compute an ABSOLUTE path from the lakefile's own location (`__dir__`), captured as `sparkleCSrcDir`. Whether Sparkle is the root package (`lake build` here) or a git dependency (`<downstream>/.lake/packages/sparkle/…`), the path points at Sparkle's *own* populated `c_src`, so an inheriting downstream link resolves the archives correctly. Also simplifies the Linux arm to pass the absolute `.a` paths directly inside `--whole-archive` (no `-L dir` + `-l:name.so` file search needed). The `Sparkle`-lib force_load remains; only the path form changes. The IP-lib `sparkleDynlibLinkArgs` (per-module `.so` deps for the interpreter/LSP force-load path) is unaffected — it's already per-platform guarded and doesn't reach downstream MyBlinky. Verification: - `nm -D libsparkle_Sparkle.so` shows `sparkle_cache_get` / `sparkle_eval_at` as `T` (defined) — the `.so` is still self-contained. - `lake build` clean; `lake exe test` 13/13. - The emitted arg is now e.g. `-Wl,--whole-archive /abs/.lake/build/c_src/libsparkle_barrier.a …`.
junjihashimoto
added a commit
that referenced
this pull request
Jul 1, 2026
LoopProps: make loop_unfold a theorem, not an axiom (pure loopGo fixpoint)
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Removes the custom
loop_unfoldaxiom introduced in PR #65,replacing it with a proved theorem.
Signal.loopis now a puredefinition (
⟨loopGo f⟩, the strong-recursion fixpoint) instead of anopaquewrapper around theunsafememoizingloopImpl. Executionstays fast via
@[implemented_by loopImpl]; only the logical valuechanges from "unknowable opaque" to "the pure fixpoint the kernel can
unfold".
Prompted by the review question on PR #65: "loop is a Lean
expression, so why does it need an axiom?" — the answer was "because
it was
opaque", and this PR removes thatopaque.What changed
Sparkle/Core/Signal.leanloopGo f t— fixpoint value at timetby strong recursion ont:apply
fto a signal supplying the already-computed values fori < tanddefaultat≥ t.if i < tguard ⇒ well-founded(
termination_by t). Total, no axiom, nounsafe.loop f := ⟨loopGo f⟩— adef, notopaque, still@[implemented_by loopImpl]for O(n) execution.loopGo_eq— defining equation exposed for proofs.Sparkle/Verification/LoopProps.leanloop_unfold:axiom→theorem, proved fromloopGo_eq+StrictlyCausal(a strictly causal body never observes thedefaultplaceholder at cycles≥ t).Trust base (the point of the PR)
#print axioms dividerSignal_result_gen(downstream RV32 dividertheorem from PR #65) no longer lists
loop_unfold. Remainingaxioms are all Lean-standard:
The bespoke fixpoint axiom is gone. The only loop-specific trust is
now
Lean.trustCompiler(thatloopImplcomputes the same value asloopGo), which is the same compiler-trust every@[implemented_by]in the codebase already relies on — a much weaker and more standard
assumption than a hand-written fixpoint axiom.
Performance
@[implemented_by loopImpl]keeps simulation at O(n):Signal.loopcounter:.val 2000in ~0 ms (probe).lake exe test: main 7m52s vs this branch 8m02s —within noise, no regression.
Also fixed (latent
mainbugs surfaced by the first clean build)lakefile.lean'ssparkleModuleDepsmonolithic-link list was stale;a from-scratch
.lake/buildwipe exposed two missing/renamed.so:sparkle_Sparkle_Backend_CppSim.so→_CSim.so(CppSim wasreplaced by the C-only CSim backend; the old
.sono longer exists).sparkle_Sparkle_Core_SignalLeavesDerive.so(theSignalLeavesderiving handler from the per-leaf output-port splitwas never registered).
Both only bite a clean build — incremental builds kept the stale
.soaround, which is why CI hadn't caught them.
Verification
lake buildclean.lake exe test: 13/13 lspec pass.Sparkle.Verification.Divider.{Correct,States33}build againstthe new pure
loopwith zero proof changes — PR Prove that Divider divides #65's dividerproof is now the regression test confirming
loopGois the correctfixpoint.