Implements the cells-spec vision: a computation space that preserves auditability, correctness, interactivity. Phase 1 (Lean kernel + naga-IR Rust backend) is closed; foundation hypothesis stack (Selection H1+H2, Subobject H3, Trace H5, Obs.Ctx C2, Cubical.Trace) landed. Highlights: - Cubical-HoTT syntax + value/eval/readback in Lean - naga-IR pipeline (no GLSL string crosses FFI; 17/17 probes pass) - Honesty audit: every non-transport (sealed cells, vertex shader, Y-flip, presentation conventions) is documented as such - Polymorphic Trace α as free monoid; Cubical.Trace gives CTerm → Trace CTerm by structural fold (homomorphism = definition) - Selection as Huet zipper; Subobject as Boolean algebra over WCell - All theorems proven; the proof IS the implementation See STATUS.md for the resume guide. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
17 KiB
Render Bridge Gap — Formal Plan for N-Dimensional Projection Backend
Living document. Inventories the correctness gaps between the current Lean spec + wgpu render pipeline and a complete formal rendering backend for n-dimensional projection with cubical-transport semantics. Status as of 2026-04-24.
Short version
The rendering pipeline runs end-to-end — CVal/EMLPath → GLSL → naga
→ SPIR-V → wgpu → Vulkan — but the Lean-side formal model of the GPU
(Topolei/GPU/Spec.lean) is narrower than what the pipeline actually
does. Specifically, the formal model assumes a pixel-shader that depends
only on (PixelCoord, FrameUniforms = (time, resWidth, resHeight)); the
pipeline also supports a cubical path parameter and (eventually) an
n-dim scene encoded via Projection n k.
Closing the gap means extending the semantic model so that every stage
has a Lean-level counterpart whose correctness axiom constrains the Rust
implementation. Once the model is complete, any shader produced by
compileEML, any wgpu render call, any Rust rasterizer — all are
witnesses of the same Lean-level specification.
1. The current pipeline and where it's unbridged
Lean │ Bridge (axiom/theorem) │ Rust / GPU
─────────────────────────┼─────────────────────────────────────┼─────────────
CVal (cubical value) │ cvalPathToEMLPath ← NOT WRITTEN │ —
EMLPath { dim, body } │ body.toGLSL ← concrete def │ —
String (GLSL source) │ compileEML ← AXIOM │ naga-glsl
ShaderHandle │ compileEML_correct ← AXIOM │ wgpu pipeline
ShaderSemantic │ render_faithful ← AXIOM stubbed │ Vulkan exec
PixelColor │ — │ framebuffer
Five rows, three gaps (NOT WRITTEN, AXIOM, stub). The pipeline works computationally end-to-end; the gaps are where Lean's model stops describing what the GPU actually does.
2. FrameUniforms is too narrow
What the shader uses:
uniform float u_time;
uniform vec2 u_resolution;
// (generated locally, driven by u_time)
float t = 0.5 + 0.5 * sin(u_time * 0.7);
float px = (uv.x * 2.0 - 1.0) * xR;
float py = (uv.y * 2.0 - 1.0) * yR;
What the model has:
structure FrameUniforms where
time : Float
resWidth : Float
resHeight : Float
def shaderVar (name : String) (p : PixelCoord) (u : FrameUniforms) : Float :=
match name with
| "px" | "py" | "u_time" | "u_resWidth" | "u_resHeight" => …
| _ => 0.0
The gap: shaderVar "t" = 0.0 unconditionally. The existing theorem
render_eq_at1 requires shaderVar path.dimName = 1.0 — an
uninhabited hypothesis for parametric paths, so the theorem says
nothing about them.
The fix:
structure FrameUniforms where
time : Float
resWidth : Float
resHeight : Float
pathParam : Float := 0.0 -- NEW
def shaderVarWithDim
(dimName : String) (name : String)
(p : PixelCoord) (u : FrameUniforms) : Float :=
if name = dimName then u.pathParam
else shaderVar name p u
Then every EMLPath has a correctness theorem:
theorem compileEMLPath_correct_at
(path : EMLPath) (p : PixelCoord) (u : FrameUniforms)
(h : u.pathParam = τ) :
(compileEMLPath path).semantic p u =
(path.body.toColorWithEnv
(shaderVarWithDim path.dimName · p { u with pathParam := τ }))
This is the pointwise statement that the shader output at path parameter
τ equals the EML body evaluated at dimName ↦ τ.
Scope: ~60 lines in GPU/Spec.lean. No Rust changes required — this
is a specification pass. The existing Rust shader already behaves this
way; we're just saying so formally.
3. The sine-sweep driver is unspecified
What the shader does: t = 0.5 + 0.5 * sin(u_time * 0.7) — chosen on
the Lean side by EML.lean's shader emitter, not axiomatised.
Why it matters: the end-to-end correctness theorem we want is
"At any frame with
u.time = t₀, the GPU pixel equals the EMLPath body evaluated atdimName ↦ driver(t₀)."
Without an explicit driver : Float → Float, this theorem can't be
stated. The current model has path.at1 (driver ≡ 1) and path.at0
(driver ≡ 0) — two points of the sweep. The sweep itself is implicit.
The fix:
structure PathDriver where
fn : Float → Float -- time → path param
range01 : ∀ t, 0 ≤ fn t ∧ fn t ≤ 1 -- stays in [0,1]
def sineSweep (freq : Float) : PathDriver where
fn t := 0.5 + 0.5 * Float.sin (t * freq)
range01 := … -- IEEE Float axiom
theorem rendered_pixel_at_time
(path : EMLPath) (driver : PathDriver)
(p : PixelCoord) (u : FrameUniforms)
(h : u.pathParam = driver.fn u.time) :
(compileEMLPath path).semantic p u = …
sineSweep 0.7 becomes the specific driver the current Lean emitter
chooses. A renderer can swap drivers (linear sawtooth, ramp-and-hold,
externally controlled) without breaking the theorem — only the driver
instance changes.
Scope: ~40 lines in GPU/Spec.lean + one IEEE Float axiom
(Float.sin ∈ [-1, 1]).
4. render_faithful is True
The current axiom:
axiom render_faithful (ctx : GPUContext) (h : ShaderHandle) (u : FrameUniforms) :
True
This says nothing — it's a placeholder for the theorem "when the render
loop runs with handle h under uniforms u, the pixel at coord p on
screen equals h.semantic p u."
The gap: without a real body, there's no formal link between
ShaderHandle.semantic (what Lean says the shader computes) and actual
GPU pixel output (what ends up on screen).
The fix — two parts:
-
A pixel-readback FFI, added to
native/canvas-rs(or C++-side wrapper). Entry:topolei_read_pixel(ctx, x, y) -> [f32; 4]. Lean side:@[extern "topolei_read_pixel"] opaque readPixel (ctx : GPUContext) (x y : UInt32) : IO (Float × Float × Float × Float) -
Replace
Truewith a checkable theorem:axiom render_faithful (ctx : GPUContext) (h : ShaderHandle) (u : FrameUniforms) (p : PixelCoord) : let c := h.semantic p u -- Rust-discharged: after a render cycle, readPixel matches `c` True -- refine: ⟨c.r, c.g, c.b, c.a⟩ = readPixel (round p.x) (round p.y)Pixel-readback tests in the integration suite discharge it: compile a known shader, render, read pixel, assert equal-within-ε to the semantic value.
Scope: ~60 lines C++/Rust + ~40 Lean. Closes the spec loop: after this, a shader that evaluates wrong is caught by a test, not by human inspection.
5. cvalPathToEMLPath does not exist
The role: bridge CVal (arbitrary cubical value) to EMLPath
(renderable parametric scalar field). Without it, only hand-crafted
PlotConfigs go through the render pipeline — arbitrary cubical
transports can't.
The signature:
/-- Extract a renderable path from a cubical value. Succeeds when `v`
is a `vplam` closure whose body is expressible as an EMLExpr
(built from Float-valued variables, `exp`, `log`, and `1.0`). -/
def cvalPathToEMLPath : CVal → Option EMLPath
| .vplam env i body => Option.map (EMLPath.mk i.name) (ctermToEML env body)
| _ => none
where ctermToEML : CEnv → CTerm → Option EMLExpr is the restricted
translator: it supports var (free), app only for known builtins
(Float.exp, Float.log), plam/papp for dim handling, rejects
anything else. Returns none for out-of-scope terms.
Correctness theorem (wanted):
theorem cvalPathToEMLPath_at_endpoint
(v : CVal) (path : EMLPath)
(h : cvalPathToEMLPath v = some path)
(baseEnv : String → Float) :
-- value-level: what vPApp of `v` at endpoint-0 evaluates to
-- matches the EML at0 computation
… = path.at0 baseEnv
and similarly for at1.
Why this matters: after this bridge, the demo is any cubical path
Path A a₀ a₁ in the scalar-field universe → continuous GPU animation
of the 1-cell — no manual EML construction. Writing a transport in
Lean and running renderCVal renders it.
Scope: ~120 lines Lean + ~80 for the correctness theorems. No Rust. Entirely a Lean-internal translator.
6. N-dim scenes don't exist in the GPU spec
What we have in Lean:
ProjPoint n— homogeneous coords for ℝℙⁿ.Projection n k—apply,undefinedLocus,horizonImage.ObservationPrimitive n k— atomic or fractal-split.Camera = atom Projection ViewAugment(viaObservation.lean).
What the GPU spec has:
- Nothing about
ProjPoint,Projection, orObservationPrimitive. ShaderSemantic = PixelCoord → FrameUniforms → PixelColor— strictly 2-in, 1-out.
The gap (three layers):
6a. Projection compilation
We need compileProjection : Projection n k → ShaderFragmentHandle.
Running the projection on the GPU should fold the n-dim coords through
the chart's map (stereographic, gnomonic, perspective) into the output
coords, and report the horizonImage predicate as a uniform.
Representation at pixel level: the final screen is still PixelCoord,
but the intermediate ProjPoint values need GPU representation. Options:
(a) marshal n-dim coords as an Array Float uniform buffer,
(b) specialise shaders per-n at compile time,
(c) fix n ≤ 4 and use vec4 slots.
Option (c) matches graphics conventions and is probably the right first
pass: ℝℙ⁰, ℝℙ¹, ℝℙ², ℝℙ³, ℝℙ⁴ all fit in vec4.
6b. Observation compilation
compileObservation : ObservationPrimitive n k → ShaderHandle
| .atom proj aug => compileAtomic proj aug
| .compose children => compileBranch children
The tree structure of compose becomes a series of GPU branch tests:
at each pixel, evaluate each child's scene-region predicate and
dispatch. Atomic observations compile to one pass; composite ones to a
flat chain of predicate-gated passes.
Correctness: (compileObservation obs).semantic p u equals the
pointwise result of walking obs's tree on the scene point
projToScene u.pathParam p.
6c. Camera chain
A camera chain is View n := Projection n 2 built by
Projection.compose. On the GPU:
- A chain of 4D → 3D → 2D projections is a chain of shader fragments.
- Each fragment consumes the previous's output (a
vec4of homogeneous coords). - The last one writes
PixelColor.
Compilation: compileView : Projection n 2 → ViewAugment n → ShaderHandle.
Correctness: the chain's apply composition ↔ the shader pipeline's
composition.
Scope: Substantial. ~400 Lean lines + ~500 Rust for the marshalling and shader synthesis. Probably 2–3 focused sessions.
7. Float semantics axioms — still carry a debt
The current pipeline relies on ~8 IEEE 754 axioms scattered across
GPU/Spec.lean and Render/ProjScene.lean: Float.log_one,
Float.max_one_ge_eps, Float.sub_zero, Float.one_ne_zero_beq,
Float.neg_one_mul_neg_one, Float.one_mul, Float.mul_assoc.
Status: uncontroversial by construction, but each is an unverified
assumption about the GPU's Float behaviour. Rust's f32 is bit-exact
IEEE; Vulkan's driver mostly is but allows some fast-math modes.
The closure: bundle these into one module (Topolei/Float/IEEE.lean)
with a comment stating the driver-level expectations, and add one more:
Float.sin_range : ∀ t, -1 ≤ Float.sin t ≤ 1 (needed for sineSweep).
Scope: ~20 lines consolidation + 1 new axiom.
8. The topolei_run2 two-panel path is unimplemented
canvasRun2 (side-by-side two-shader panel) was handled by canvas.cpp's
topolei_run2_internal via OpenGL glScissor. The wgpu port's
topolei_run2 currently falls back to rendering only the left panel.
The fix: one render pass with two sub-pipelines and a scissor rect
per panel, matching the C++ logic. wgpu's
RenderPass::set_scissor_rect(x, y, w, h) is the equivalent of
glScissor.
Scope: ~120 lines Rust. No Lean changes — the FFI signature is already in place.
9. canvas.cpp + CMakeLists.txt should be retired
Once the formal bridge is closed and the wgpu path is proved equivalent, the OpenGL canvas can be deleted from the tree. Until then, they remain as:
- a working baseline if the wgpu path regresses;
- reference for the
glScissorlogic we need to port to #8.
Scope: git rm + update of native/include/ + any doc crossrefs,
once done.
10. Render-crate static-link collision
libtopolei_render.a and libtopolei_canvas.a both statically embed
the Rust runtime (rust_eh_personality, std::sys::args, etc.).
Linking both causes multi-definition errors.
Current state: render crate is unlinked (nothing in Lean calls it).
Options to restore render linkage:
- (A) Merge render into canvas-rs. One crate, one Rust runtime.
Simplest; preferred if the render crate's planned SDF rasterizer and
compileEMLRust impl can coexist with the canvas code. - (B) Convert one to
cdylib. Links dynamically at runtime; may complicate Lean-wasm composite target. - (C)
-Wl,--allow-multiple-definition. Works but brittle — the two runtimes may differ in subtle ways.
Option (A) is recommended. Fold native/render/ into native/canvas-rs/
as a render module; re-wire @[extern] names.
Scope: ~2 hours of file-moving and symbol renaming.
11. Nix-store paths in lakefile.toml are hardcoded
Current lakefile.toml contains paths like
/nix/store/wb6rhpznjfczwlwx23zmdrrw74bayxw4-glibc-2.42-47/lib/...
baked in for:
- glibc (
libc.so.6+ld-linux-x86-64.so.2, for__tls_get_addr) - vulkan-loader
- libX11, libxcb, libxkbcommon
- libXcursor, libXi, libXrandr, libXext, nix-ld
These break on any machine with different nix-store hashes.
The fix: generate them from pkg-config or nix-shell's
NIX_LDFLAGS at build time. A small build.sh preprocessing pass that
populates lakefile.toml from a template using pkg-config --libs.
Scope: ~80 lines bash + one lakefile.toml.in template. Doesn't
change Lean or Rust.
12. WebGPU / wasm32 target
The cubical crate already builds for wasm32-unknown-unknown
(see native/cubical/WASM.md). Canvas does not.
What it needs:
- Compile
topolei-canvaswith--target wasm32-unknown-unknown. winitwasm feature +wgpu-web.- Lean-wasm composite artifact (per cells-spec §4).
Blockers at this layer:
std::fs/ threading assumptions must be feature-gated.- winit's
any_thread(true)is X11-specific; for wasm, the event loop is browser-driven.
Scope: 1–2 full sessions. Depends on the cubical-crate's wasm harness already working.
13. Summary table of work by item
| # | Component | Lines | Lean | Rust | Session count |
|---|---|---|---|---|---|
| 2 | FrameUniforms.pathParam |
60 | ✓ | 0.5 | |
| 3 | PathDriver + sineSweep |
40 | ✓ | 0.3 | |
| 4 | render_faithful + readPixel |
100 | ✓ | ✓ | 1 |
| 5 | cvalPathToEMLPath |
200 | ✓ | 1–2 | |
| 6 | Projection / Observation → GPU | 900 | ✓ | ✓ | 3–4 |
| 7 | Float axiom consolidation | 20 | ✓ | 0.2 | |
| 8 | topolei_run2 scissor panes |
120 | ✓ | 0.5 | |
| 9 | Retire canvas.cpp | — | 0.1 | ||
| 10 | Merge render into canvas-rs | — | ✓ | 0.5 | |
| 11 | Dynamic nix paths in lakefile | 80 | 0.3 | ||
| 12 | wasm32 target | 150 | ✓ | 1–2 |
Critical path to a complete n-dim backend: items 2 → 3 → 4 → 5 → 6. Items 7–12 are polish / portability / cleanup.
14. Design principle to keep
Every row in the table in §1 that currently reads AXIOM or NOT WRITTEN
should resolve to either a theorem (provable in Lean) or a @[extern]
opaque with @[implemented_by] wiring (a Rust function whose
correctness is specified by remaining axioms about its behaviour). In
all cases the Lean kernel reasons through specs, not Rust. Rust
provides speed and effect; correctness stays in Lean.
Any time we're tempted to shortcut by making something computable via a Float constant or a hardcoded value (a "driver without spec", a "shader without semantic axiom", a "projection without composition theorem"), that's a bridge gap in the making. Adding the spec up front is cheaper than forensic reconstruction later.