cubical-transport-hott-lean4/RENDER_BRIDGE_GAP.md
Maximus Gorog c2e3ecb3e3
Some checks are pending
Lean Action CI / build (push) Waiting to run
Initial commit: topolei — cubical-transport HoTT in Lean 4 + Rust FFI
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>
2026-04-27 20:40:45 -06:00

17 KiB
Raw Blame History

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-endCVal/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 at dimName ↦ 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:

  1. 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)
    
  2. Replace True with 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 kapply, undefinedLocus, horizonImage.
  • ObservationPrimitive n k — atomic or fractal-split.
  • Camera = atom Projection ViewAugment (via Observation.lean).

What the GPU spec has:

  • Nothing about ProjPoint, Projection, or ObservationPrimitive.
  • 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 vec4 of 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 23 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 glScissor logic we need to port to #8.

Scope: git rm + update of native/include/ + any doc crossrefs, once done.


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 compileEML Rust 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-canvas with --target wasm32-unknown-unknown.
  • winit wasm 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: 12 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 12
6 Projection / Observation → GPU 900 34
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 12

Critical path to a complete n-dim backend: items 2 → 3 → 4 → 5 → 6. Items 712 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.