Skip to content

LoopProps: make loop_unfold a theorem, not an axiom (pure loopGo fixpoint)#81

Merged
junjihashimoto merged 2 commits into
mainfrom
feat/loop-pure-no-axiom
Jul 1, 2026
Merged

LoopProps: make loop_unfold a theorem, not an axiom (pure loopGo fixpoint)#81
junjihashimoto merged 2 commits into
mainfrom
feat/loop-pure-no-axiom

Conversation

@junjihashimoto

@junjihashimoto junjihashimoto commented Jul 1, 2026

Copy link
Copy Markdown
Contributor

Summary

Removes the custom loop_unfold axiom introduced in PR #65,
replacing it with a proved theorem. Signal.loop is now a pure
definition (⟨loopGo f⟩, the strong-recursion fixpoint) instead of an
opaque wrapper around the unsafe memoizing loopImpl. Execution
stays fast via @[implemented_by loopImpl]; only the logical value
changes 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 that opaque.

What changed

Sparkle/Core/Signal.lean

  • loopGo f t — fixpoint value at time t by strong recursion on t:
    apply f to a signal supplying the already-computed values for
    i < t and default at ≥ t. if i < t guard ⇒ well-founded
    (termination_by t). Total, no axiom, no unsafe.
  • loop f := ⟨loopGo f⟩ — a def, not opaque, still
    @[implemented_by loopImpl] for O(n) execution.
  • loopGo_eq — defining equation exposed for proofs.

Sparkle/Verification/LoopProps.lean

  • loop_unfold : axiomtheorem, proved from loopGo_eq +
    StrictlyCausal (a strictly causal body never observes the
    default placeholder at cycles ≥ t).
  • Docstring rewritten.

Trust base (the point of the PR)

#print axioms dividerSignal_result_gen (downstream RV32 divider
theorem from PR #65) no longer lists loop_unfold. Remaining
axioms are all Lean-standard:

propext, Classical.choice, Quot.sound,     -- Lean core
Lean.ofReduceBool,                          -- bv_decide/native_decide (pre-existing)
Lean.trustCompiler                          -- @[implemented_by] (standard)

The bespoke fixpoint axiom is gone. The only loop-specific trust is
now Lean.trustCompiler (that loopImpl computes the same value as
loopGo), 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):

  • direct Signal.loop counter: .val 2000 in ~0 ms (probe).
  • matched lake exe test: main 7m52s vs this branch 8m02s
    within noise, no regression.

Also fixed (latent main bugs surfaced by the first clean build)

lakefile.lean's sparkleModuleDeps monolithic-link list was stale;
a from-scratch .lake/build wipe exposed two missing/renamed .so:

  • sparkle_Sparkle_Backend_CppSim.so_CSim.so (CppSim was
    replaced by the C-only CSim backend; the old .so no longer exists).
  • added sparkle_Sparkle_Core_SignalLeavesDerive.so (the
    SignalLeaves deriving handler from the per-leaf output-port split
    was never registered).

Both only bite a clean build — incremental builds kept the stale .so
around, which is why CI hadn't caught them.

Verification

  • lake build clean.
  • lake exe test: 13/13 lspec pass.
  • Both Sparkle.Verification.Divider.{Correct,States33} build against
    the new pure loop with zero proof changes — PR Prove that Divider divides #65's divider
    proof is now the regression test confirming loopGo is the correct
    fixpoint.

…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 junjihashimoto merged commit 1f4c3f1 into main Jul 1, 2026
35 checks passed
@junjihashimoto junjihashimoto deleted the feat/loop-pure-no-axiom branch July 1, 2026 14:01
junjihashimoto added a commit that referenced this pull request Jul 1, 2026
LoopProps: make loop_unfold a theorem, not an axiom (pure loopGo fixpoint)
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.

1 participant