Split: engine = cubical-transport HoTT only
Some checks are pending
Lean Action CI / build (push) Waiting to run

Restructure to engine-only contents.  Application code (Topolei.*
namespace, canvas-rs / render Rust crates, Main / ProbeTest, naga IR
pipeline, Selection / Subobject / Trace / Obs.Ctx hypothesis stack,
cells-spec / HYPOTHESES / STATUS / NAGA_IR_PLAN docs) moves to the
sibling repo max/topolei.

What moved:
- `Topolei/Cubical/*.lean` (22 files) → `CubicalTransport/*.lean`
  with namespace `Topolei.Cubical.*` renamed to `CubicalTransport.*`.
  Fully-qualified test types `TopoleiCubical{FFI,Property}Test` →
  `CubicalTransport{FFI,Property}Test` for consistency.
- New root file `CubicalTransport.lean` re-exporting all 22 modules.
- Lakefile: package `cubicalTransport`; lib `CubicalTransport`; only
  `cubical-test` and `cubical-bench` exes (no GPU link path).

The split criterion: anything an AI shortcut could break that would
cascade-corrupt downstream proofs lives here.  Anything that would
only break the application stays in the topolei interface repo.

cubical-test passes 62/62 (smoke + properties) on the renamed engine.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Maximus Gorog 2026-04-27 21:35:01 -06:00
parent c2e3ecb3e3
commit 31d19f655e
67 changed files with 121 additions and 11956 deletions

View file

@ -1,5 +1,5 @@
import Topolei.Cubical.Readback import CubicalTransport.Readback
import Topolei.Cubical.FFI import CubicalTransport.FFI
/-! /-!
CubicalBench.lean — Phase D.2 performance benchmarks. CubicalBench.lean — Phase D.2 performance benchmarks.

View file

@ -1,10 +1,10 @@
import Topolei.Cubical.FFITest import CubicalTransport.FFITest
import Topolei.Cubical.PropertyTest import CubicalTransport.PropertyTest
def main : IO UInt32 := do def main : IO UInt32 := do
let smokeFails ← TopoleiCubicalFFITest.runSmokeTests let smokeFails ← CubicalTransportFFITest.runSmokeTests
IO.println "" IO.println ""
let propFails ← TopoleiCubicalPropertyTest.runProperties let propFails ← CubicalTransportPropertyTest.runProperties
let total := smokeFails + propFails let total := smokeFails + propFails
IO.println "" IO.println ""
if total > 0 then if total > 0 then

22
CubicalTransport.lean Normal file
View file

@ -0,0 +1,22 @@
import CubicalTransport.Interval
import CubicalTransport.Face
import CubicalTransport.Syntax
import CubicalTransport.Subst
import CubicalTransport.DimLine
import CubicalTransport.Typing
import CubicalTransport.Equiv
import CubicalTransport.Glue
import CubicalTransport.Value
import CubicalTransport.Transport
import CubicalTransport.Line
import CubicalTransport.Eval
import CubicalTransport.EvalTest
import CubicalTransport.Readback
import CubicalTransport.FFI
import CubicalTransport.FFITest
import CubicalTransport.ValueTyping
import CubicalTransport.TransportLaws
import CubicalTransport.System
import CubicalTransport.CompLaws
import CubicalTransport.Soundness
import CubicalTransport.PropertyTest

View file

@ -23,9 +23,9 @@
preservation lemma on `eval`/`readback` (Stream B #2a). preservation lemma on `eval`/`readback` (Stream B #2a).
-/ -/
import Topolei.Cubical.System import CubicalTransport.System
import Topolei.Cubical.TransportLaws import CubicalTransport.TransportLaws
import Topolei.Cubical.ValueTyping import CubicalTransport.ValueTyping
-- ── Subject reduction for composition ──────────────────────────────────────── -- ── Subject reduction for composition ────────────────────────────────────────

View file

@ -18,7 +18,7 @@
value at i selects the correct endpoint type. value at i selects the correct endpoint type.
-/ -/
import Topolei.Cubical.Subst import CubicalTransport.Subst
-- ── DimLine ─────────────────────────────────────────────────────────────────── -- ── DimLine ───────────────────────────────────────────────────────────────────

View file

@ -29,7 +29,7 @@
"Priority order" item 3). "Priority order" item 3).
-/ -/
import Topolei.Cubical.Typing import CubicalTransport.Typing
-- ── Equivalence data ───────────────────────────────────────────────────────── -- ── Equivalence data ─────────────────────────────────────────────────────────

View file

@ -22,8 +22,8 @@
metric. For now, `partial def` is the honest choice. metric. For now, `partial def` is the honest choice.
-/ -/
import Topolei.Cubical.Value import CubicalTransport.Value
import Topolei.Cubical.Transport import CubicalTransport.Transport
-- ── Rust FFI declarations (Phase C.2) ────────────────────────────────────── -- ── Rust FFI declarations (Phase C.2) ──────────────────────────────────────
-- `@[extern "topolei_cubical_*"] opaque *Rust ...` declares the Rust -- `@[extern "topolei_cubical_*"] opaque *Rust ...` declares the Rust

View file

@ -16,7 +16,7 @@
· transport / composition terms produce the expected neutrals. · transport / composition terms produce the expected neutrals.
-/ -/
import Topolei.Cubical.Eval import CubicalTransport.Eval
-- ── Free variable ─────────────────────────────────────────────────────────── -- ── Free variable ───────────────────────────────────────────────────────────

View file

@ -60,4 +60,4 @@
- `KERNEL_BOUNDARY.md` — what this delivers vs. what requires kernel work. - `KERNEL_BOUNDARY.md` — what this delivers vs. what requires kernel work.
-/ -/
import Topolei.Cubical.Readback import CubicalTransport.Readback

View file

@ -14,14 +14,14 @@
them inside a compiled binary where Rust IS linked. them inside a compiled binary where Rust IS linked.
Invoke from a compiled executable. `Main.lean` can optionally Invoke from a compiled executable. `Main.lean` can optionally
route to `TopoleiCubicalFFITest.runSmokeTests` when passed route to `CubicalTransportFFITest.runSmokeTests` when passed
`--cubical-test`. Or a dedicated test exe target. `--cubical-test`. Or a dedicated test exe target.
-/ -/
import Topolei.Cubical.Readback import CubicalTransport.Readback
import Topolei.Cubical.FFI import CubicalTransport.FFI
namespace TopoleiCubicalFFITest namespace CubicalTransportFFITest
-- ── Summarisers ──────────────────────────────────────────────────────────── -- ── Summarisers ────────────────────────────────────────────────────────────
@ -151,4 +151,4 @@ def runSmokeTests : IO UInt32 := do
IO.println s!"── {tests.length - fails.toNat} / {tests.length} passed ──" IO.println s!"── {tests.length - fails.toNat} / {tests.length} passed ──"
return fails return fails
end TopoleiCubicalFFITest end CubicalTransportFFITest

View file

@ -11,7 +11,7 @@
The key invariant: (i=0) and (i=1) are mutually exclusive and jointly exhaustive. The key invariant: (i=0) and (i=1) are mutually exclusive and jointly exhaustive.
-/ -/
import Topolei.Cubical.Interval import CubicalTransport.Interval
-- ── Face formulas ───────────────────────────────────────────────────────────── -- ── Face formulas ─────────────────────────────────────────────────────────────

View file

@ -53,8 +53,8 @@
CType-function level (`DimExpr → CType`) instead. CType-function level (`DimExpr → CType`) instead.
-/ -/
import Topolei.Cubical.Eval import CubicalTransport.Eval
import Topolei.Cubical.Equiv import CubicalTransport.Equiv
-- ── Ergonomic glue-type construction from EquivData ───────────────────────── -- ── Ergonomic glue-type construction from EquivData ─────────────────────────

View file

@ -45,7 +45,7 @@
*where* the obligation is discharged. *where* the obligation is discharged.
-/ -/
import Topolei.Cubical.Transport import CubicalTransport.Transport
-- ── DimLine.inv ────────────────────────────────────────────────────────────── -- ── DimLine.inv ──────────────────────────────────────────────────────────────
-- Reversed line via DimExpr substitution. `.inv (.var i)` flips the -- Reversed line via DimExpr substitution. `.inv (.var i)` flips the

View file

@ -16,10 +16,10 @@
`cubical-test` exe; see `CubicalTest.lean` for wiring. `cubical-test` exe; see `CubicalTest.lean` for wiring.
-/ -/
import Topolei.Cubical.Readback import CubicalTransport.Readback
import Topolei.Cubical.FFI import CubicalTransport.FFI
namespace TopoleiCubicalPropertyTest namespace CubicalTransportPropertyTest
-- ── Summarisers (reuse from FFITest but private to this module) ──────────── -- ── Summarisers (reuse from FFITest but private to this module) ────────────
@ -246,4 +246,4 @@ def runProperties : IO UInt32 := do
IO.println s!"── {totalRun - totalFails.toNat} / {totalRun} properties passed ──" IO.println s!"── {totalRun - totalFails.toNat} / {totalRun} properties passed ──"
return totalFails return totalFails
end TopoleiCubicalPropertyTest end CubicalTransportPropertyTest

View file

@ -54,7 +54,7 @@
env shadowing provides capture-avoidance. env shadowing provides capture-avoidance.
-/ -/
import Topolei.Cubical.Eval import CubicalTransport.Eval
-- ── Inhabited instance for CTerm ──────────────────────────────────────────── -- ── Inhabited instance for CTerm ────────────────────────────────────────────
-- Needed for `partial def` elaboration: Lean's partial-fixpoint compilation -- Needed for `partial def` elaboration: Lean's partial-fixpoint compilation

View file

@ -49,9 +49,9 @@
cells-layer work; stated briefly below for completeness. cells-layer work; stated briefly below for completeness.
-/ -/
import Topolei.Cubical.TransportLaws import CubicalTransport.TransportLaws
import Topolei.Cubical.CompLaws import CubicalTransport.CompLaws
import Topolei.Cubical.Glue import CubicalTransport.Glue
namespace Soundness namespace Soundness

View file

@ -25,7 +25,7 @@
it requires DimExpr.subst commutativity, which needs its own treatment. it requires DimExpr.subst commutativity, which needs its own treatment.
-/ -/
import Topolei.Cubical.Syntax import CubicalTransport.Syntax
-- ── CTerm.substDimBool ──────────────────────────────────────────────────────── -- ── CTerm.substDimBool ────────────────────────────────────────────────────────

View file

@ -22,7 +22,7 @@
`CompLaws.lean`. `CompLaws.lean`.
-/ -/
import Topolei.Cubical.Face import CubicalTransport.Face
-- ── Syntax ──────────────────────────────────────────────────────────────────── -- ── Syntax ────────────────────────────────────────────────────────────────────

View file

@ -19,7 +19,7 @@
· System.Typed — packages the typing judgment on the body · System.Typed — packages the typing judgment on the body
-/ -/
import Topolei.Cubical.Typing import CubicalTransport.Typing
-- (Typing.lean is below System in the import chain; System cannot be imported -- (Typing.lean is below System in the import chain; System cannot be imported
-- from Typing. The HasType.comp rule uses raw components. This file provides -- from Typing. The HasType.comp rule uses raw components. This file provides
-- the System.Valid → HasType.comp convenience bridge.) -- the System.Valid → HasType.comp convenience bridge.)

View file

@ -33,8 +33,8 @@
reversal. reversal.
-/ -/
import Topolei.Cubical.Value import CubicalTransport.Value
import Topolei.Cubical.DimLine -- for CType.dimAbsent and substDimExpr import CubicalTransport.DimLine -- for CType.dimAbsent and substDimExpr
-- ── Rust FFI declaration (Phase C.2) ────────────────────────────────────── -- ── Rust FFI declaration (Phase C.2) ──────────────────────────────────────

View file

@ -25,7 +25,7 @@
`readback_transp_plam_general` in `Readback.lean` (Stream B #2c). `readback_transp_plam_general` in `Readback.lean` (Stream B #2c).
-/ -/
import Topolei.Cubical.ValueTyping import CubicalTransport.ValueTyping
-- ── Subject reduction for transport ────────────────────────────────────────── -- ── Subject reduction for transport ──────────────────────────────────────────

View file

@ -22,7 +22,7 @@
Dependent Π is deferred until we have a term evaluator. Dependent Π is deferred until we have a term evaluator.
-/ -/
import Topolei.Cubical.DimLine import CubicalTransport.DimLine
-- ── Context ─────────────────────────────────────────────────────────────────── -- ── Context ───────────────────────────────────────────────────────────────────

View file

@ -21,7 +21,7 @@
evaluator will grow a companion `evalType` returning a `VType`. evaluator will grow a companion `evalType` returning a `VType`.
-/ -/
import Topolei.Cubical.Syntax import CubicalTransport.Syntax
mutual mutual
/-- Name-keyed environment: a cons-list of `(name, value)` bindings. The /-- Name-keyed environment: a cons-list of `(name, value)` bindings. The

View file

@ -60,8 +60,8 @@
surface scales O(1) in type-formers, not O(n). surface scales O(1) in type-formers, not O(n).
-/ -/
import Topolei.Cubical.Typing import CubicalTransport.Typing
import Topolei.Cubical.Readback import CubicalTransport.Readback
-- ── Semantic typing (declarative stubs) ───────────────────────────────────── -- ── Semantic typing (declarative stubs) ─────────────────────────────────────

View file

@ -1,132 +0,0 @@
# Topolei — Hypothesis Log
Scientific tracking for the cells interface project.
Each hypothesis has: statement, prediction, falsification condition, test protocol, status.
---
## H1 — EML as Computational Cell Primitive
**Statement:** `eml(x,y) = exp(x) ln(y)` with constant `1`, embedded as a single
primitive 1-cell, is sufficient to generate all elementary function cells via transport
composition. No other arithmetic primitives are needed in the computational layer.
**Prediction:** sin, cos, +, ×, √, π can each be type-checked as EML tree cells in
Lean 4 with verified derivation proofs.
**Falsification:** Any elementary function requiring a primitive outside the EML grammar
`S → 1 | eml(S,S)`.
**Test protocol:** Formalize each derivation in `Topolei/EML/Derive.lean`. A failed
`#check` or `sorry`-free proof attempt falsifies.
**Status:** Supported by Odrzywolek (2026) constructive proof. Not yet formalized in Lean.
---
## H2 — Uniform Tree Structure Enables Intuitive Interface
**Statement:** Because all EML expressions share the grammar `S → 1 | eml(S,S)`,
the interface can present math manipulation as composing identical nodes — subjectively
more intuitive than a multi-button calculator or ad-hoc function syntax.
**Prediction:** After N interactive sessions, user reports EML-tree manipulation feels
natural for expressing the math they care about (calculus, ODEs, geometry).
**Falsification:** User finds EML trees opaque or mechanical for expressions of
practical interest, even after familiarity. Specific failure case: depth > 4 trees
feel unmanageable without additional abstraction.
**Test protocol:** Subjective sessions recorded in this file under H2-Sessions.
Scale: 1 (not intuitive, not powerful) to 5 (intuitive and powerful).
**Status:** Untested.
### H2-Sessions
<!-- append entries here after each test session -->
<!-- format: DATE | expression attempted | depth | score | notes -->
---
## H3 — Text and Graph Rendering Are Co-Projections of EML Cells
**Statement:** Text layout and graph rendering are both sections of the same cell
fibration — `RenderCell` parameterized by a projection map — not architecturally
distinct subsystems. EML provides the shared computational substrate for both.
**Prediction:** A single `RenderCell` type with two projection instances (text, graph)
compiles and renders correctly without duplicated primitives.
**Falsification:** Text rendering requires primitives (glyph rasterization, string
indexing, Unicode) with no EML analog, forcing a separate non-cell subsystem.
**Test protocol:** Implement `RenderCell` in Phase 4. If the text projection requires
falling outside the EML/cell framework, H3 is falsified. Partial falsification
(glyph lookup is external but layout is EML) is recorded as a refinement.
**Status:** Partially supported by cells-spec §1.5 (rendering context as a cell).
EML connection is new and untested.
---
## H4 — FM^fr as the Mathematical Foundation for Mathematical Notation
**Statement:** Mathematical notation should be modeled as factorization homology
∫_M A over a framed syntactic space M with an E_n-algebra A encoding local
mathematical operations. Under this model:
- A mathematical expression is an element of ∫_M A, where M is a framed manifold
(1-manifold for linear text; 2-manifold for 2D layout — fractions, matrices, integrals)
- A **rendering** (typeset, graph, interactive widget) is a choice of framing on M,
not a separate compilation pipeline
- A **transport between framings** is a provably structure-preserving language
transformation — syntax ↔ geometry ↔ interactive manipulation
- **Custom functionality** = changing A while holding M fixed (same syntactic space,
different algebra — e.g. symbolic vs. numeric vs. proof-term interpretation)
- **Custom language transformations** = changing the framing of M (same expression,
different geometric embedding — e.g. inline text vs. displayed equation vs. 3D surface)
LaTeX, under this model, is not a compiler but a particular choice of framing with
a fixed algebra. "Parsing" is not a pipeline but section selection. The ⊗-excision
property of FM^fr guarantees that local composition rules are globally consistent —
which is exactly what LaTeX currently enforces by convention and fragile macros.
**Prediction:** The `RenderCell` type from H3 is correctly typed as a section of the
FM^fr fibration ∫_{M^fr} A, where:
- M^fr is the framed syntactic manifold of the expression
- A is an E_n-algebra in the cells/EML framework
- Different renderings are provably related by framing transports
**Falsification:** Mathematical notation requires operations that cannot be expressed
as local E_n-algebra data over any framing of a finite-dimensional manifold — i.e.,
some notational structure is genuinely global and not ⊗-excisive.
**Connection to existing framework:**
- Refines H3: FM^fr is the *reason* text and graph are co-sections, not just an
architectural claim
- Fits cells-spec §1.6: the Grothendieck fibration whose sections are programs is
precisely ∫_{()} A as M varies over framed manifolds
- Lives in the ∞-operad / (∞,1)-category world that the cubical embedding targets
- EML is the computational algebra A; the framed syntactic space M is what the
cells-spec calls the "rendering context" (§1.5)
**Test protocol:** Formalize a minimal FM^fr structure in Lean 4:
1. Define a framed 1-manifold type (for linear text)
2. Define an E_1-algebra over EMLExpr
3. Show that ∫_M A recovers the standard left-to-right evaluation of a formula
4. Show that a framing change (1-manifold → 2-manifold embedding) recovers a
2D layout (e.g., fraction bar as a geometric separator)
**Status:** Proposed. Not yet formalized. Depends on Phase 1 (EML evaluator) and
the cubical core (transport, composition) being in place first.
---
## Open Questions
- OQ1: Does the EML framework extend to complex-valued rendering (i.e. shaders that
need arithmetic natively, not just as derived operations)?
- OQ2: Can EML tree depth be bounded for all functions needed in practical math
visualization, or do some require unbounded depth (infinite series)?
- OQ3: Is there a ternary EML variant (hinted at in the paper) that removes the
need for the distinguished constant `1`, and does that simplify the cell primitive?

View file

@ -1,94 +0,0 @@
import Topolei
/-! Entry point for the topolei live transport renderer.
Each subcommand opens a window showing **one fixed fiber** of a
cubical 1-cell. No host-side time-driven animation — fibers are
selected at render time and stay put. Re-run with a different
subcommand to see a different fiber.
Default subcommand (`mid`) shows `plotT` at `pathParam = 0.5`:
`plotT.body = var "t"`, so every pixel evaluates to `0.5` — a
solid 50% grey panel. `at0` shows it at `0.0` (solid black),
`at1` at `1.0` (solid white). `endpoints` shows `at0 | at1`
side-by-side: solid black on the left, solid white on the right
— two genuinely-distinct fibers of the same 1-cell.
`gradient` shows `plotPx`, a constant 1-cell whose body is the
spatial coordinate `px`. Every fiber is the same: a black→white
horizontal gradient. Useful as a sanity check that fibers
coincide on a constant 1-cell (`EMLPath.const_endpoints`).
`transp` (and the older `plotExp` / `plotLn`) are kept reachable
but their bodies leave `[0, 1]` and so their greyscale renders
saturate to white / clamp to black at the display. The probe
test verifies they still match the spec — they're just
visually less informative without a normalized colorspace.
-/
def main (args : List String) : IO Unit := do
match args with
| "at0" :: _ =>
canvasRunPath plotT.toEMLPath 0.0 700 700
"topolei — plotT at pathParam = 0 (solid black, the at0 fiber of var t)"
| "at1" :: _ =>
canvasRunPath plotT.toEMLPath 1.0 700 700
"topolei — plotT at pathParam = 1 (solid white, the at1 fiber of var t)"
| "mid" :: _ | [] =>
canvasRunPath plotT.toEMLPath 0.5 700 700
"topolei — plotT at pathParam = 0.5 (solid 50% grey, the midpoint fiber)"
| "qtr" :: _ =>
canvasRunPath plotT.toEMLPath 0.25 700 700
"topolei — plotT at pathParam = 0.25 (solid 25% grey)"
| "tqr" :: _ =>
canvasRunPath plotT.toEMLPath 0.75 700 700
"topolei — plotT at pathParam = 0.75 (solid 75% grey)"
| "endpoints" :: _ =>
-- Side-by-side boundary fibers of the SAME 1-cell `plotT`.
-- Left panel: pathParam = 0 → solid black.
-- Right panel: pathParam = 1 → solid white.
-- Each panel has its own uniform buffer with its own
-- pathParam — the two fibers are genuinely distinct, and
-- no visualization adapter is aliasing them.
canvasRunPath2
plotT.toEMLPath 0.0
plotT.toEMLPath 1.0
1200 600
"topolei — plotT at0 | at1 (boundary fibers: solid black | solid white)"
| "gradient" :: _ =>
-- Constant 1-cell — body is `var "px"` so the image is fixed
-- regardless of pathParam. Two panels at different pathParams
-- should be pixel-identical (`EMLPath.const_endpoints`
-- realised on the GPU).
canvasRunPath2
plotPx.toEMLPath 0.0
plotPx.toEMLPath 1.0
1200 600
"topolei — plotPx (constant 1-cell): two panels at different pathParam coincide"
| "transp" :: _ =>
-- The original `plotTransp` 1-cell (body = exp(px) - t). Its
-- image leaves [0, 1] for these inputs, so most pixels saturate
-- to white at the display — but the spec and the probes still
-- agree on the underlying float values.
canvasRunPath2
plotTransp.toEMLPath 0.0
plotTransp.toEMLPath 1.0
1200 600
"topolei — plotTransp at0 | at1 (image leaves [0,1]; expect saturation)"
| _ =>
IO.println "topolei — usage:"
IO.println " topolei show plotT at pathParam = 0.5 (default; solid 50% grey)"
IO.println " topolei mid same as default"
IO.println " topolei at0 plotT at pathParam = 0 (solid black)"
IO.println " topolei at1 plotT at pathParam = 1 (solid white)"
IO.println " topolei qtr plotT at pathParam = 0.25"
IO.println " topolei tqr plotT at pathParam = 0.75"
IO.println " topolei endpoints plotT at0 | at1 (black | white side-by-side)"
IO.println " topolei gradient plotPx (constant 1-cell): same gradient on both panels"
IO.println " topolei transp plotTransp at0 | at1 (saturates; spec is honest)"
IO.println ""
IO.println "Each window renders ONE fiber of the cubical 1-cell, statically."
IO.println "There is no host-side time-to-parameter animation — that would"
IO.println "not be a transport. The `Float → RGB` step is identity"
IO.println "(greyscale), not a hue cycle, so fibers that genuinely differ"
IO.println "show distinct pixel output."

View file

@ -1,473 +0,0 @@
# NAGA_IR_PLAN.md — direct naga IR construction from `EMLPath`
*A pick-up-in-one-session plan for eliminating the last text-format
intermediary in the render pipeline: the GLSL string that currently
sits between `Rust EMLExpr` and `naga::Module`. Future author: read
§1§4 before writing any code; §5 is the staging; §6§8 are the
traps.*
---
## 0. TL;DR
**Today** (after P6): `Lean EMLPath → FFI → Rust EMLPath → Rust GLSL
string → naga-glsl parser → naga::Module → SPIR-V → GPU`.
**Target**: `Lean EMLPath → FFI → Rust EMLPath → naga::Module (direct) →
SPIR-V → GPU`. No GLSL text format. No parser.
Why: the text stage is the last unverified intermediate in the pipeline.
naga-glsl is a third-party GLSL parser; its semantic interpretation of
our shader strings is trust-based. Eliminating it shortens the trust
boundary from "naga's GLSL frontend plus Lean+Rust emitters must all
agree" to "Lean and Rust emitters must produce equivalent naga modules".
It is not an axiom-killer on its own — `compileEMLPath_correct`
remains an axiom — but it removes a 10⁴-line dependency from the trust
surface and brings a Lean-side naga-IR model (future work) within
reach.
---
## 1. Prerequisite reading
Read these **before** writing any code. Skip any item at your peril:
each pitfall in §6 is a trap you'll step into without the background.
### 1.1 Upstream (naga + wgpu)
Match the version pinned in `native/canvas-rs/Cargo.toml` (currently
`wgpu = "22.1"``naga 22.1.0`). Paths given are on this machine's
cargo registry; repoint as needed.
| File | What to read it for |
|------|---------------------|
| `$CARGO_HOME/registry/src/*/naga-22.1.0/src/lib.rs` | `Module`, `Function`, `EntryPoint`, `Expression`, `Statement`, `Literal`, `MathFunction`, `Type`, `TypeInner`. Top-level IR vocabulary. |
| `$CARGO_HOME/registry/src/*/naga-22.1.0/src/arena.rs` | `Arena<T>`, `UniqueArena<T>`, `Handle<T>`. Why every expression lives behind a handle, why types must dedupe. |
| `$CARGO_HOME/registry/src/*/naga-22.1.0/src/valid/mod.rs` | `Validator`, `ValidationFlags`, `Capabilities`. Validation is a **hard precondition** for SPIR-V emission — unvalidated modules panic the writer. |
| `$CARGO_HOME/registry/src/*/naga-22.1.0/src/back/spv/mod.rs` | `write_vec(&Module, &ModuleInfo, &Options, Option<&PipelineOptions>) -> Result<Vec<u32>, Error>`. One call; needs `ModuleInfo` from the validator. |
| `$CARGO_HOME/registry/src/*/naga-22.1.0/src/front/glsl/*` | Reference implementation of a frontend building a `Module`. Good place to copy patterns from. |
| `$CARGO_HOME/registry/src/*/wgpu-22.1.0/src/lib.rs` (search `ShaderSource`) | Three variants: `SpirV(Cow<[u32]>)`, `Glsl{...}`, `Naga(Cow<'static, Module>)`. The `Naga` variant takes a module directly — we can pass our constructed `Module` without round-tripping through SPIR-V, though SPIR-V is the default and more widely exercised path. |
### 1.2 In-repo context
| Doc / source | Read for |
|--------------|----------|
| `RENDER_BRIDGE_GAP.md` §4, §6 | The original framing of the rendering-stack axioms. §6 describes the multi-layer Projection/Observation pipeline this plan is a prerequisite for. |
| `Topolei/GPU/Spec.lean` | `ShaderSemantic`, `shaderVar`, `shaderVarWithDim`, `EMLPath.toColor`, the axioms (`compileEMLPath`, `compileEMLPath_correct`, `render_faithful`). The semantic the naga module must realise. |
| `Topolei/EML.lean` + `Topolei/EML/Path.lean` | `EMLExpr`, `PlotConfig`, `EMLPath`, `EMLPath.toFragShaderProbe`. The Lean-side shader emitter — the naga builder must produce a module whose SPIR-V behaves identically on the seven P7 probes. |
| `native/canvas-rs/src/eml.rs` | Rust-side `EMLExpr` / `EMLPath` + `emlexpr_from_lean` walker + `to_frag_shader_probe` string emitter. The input side of the naga builder (walker is unchanged); the output side is what's being replaced. |
| `native/canvas-rs/src/lib.rs::offscreen_render_pixel` | Where the shader-module handoff happens. The swap-in point. |
| `Topolei/Render/Probe.lean` | `probeEmitterDiff` (string diff Lean vs Rust GLSL) + pixel probes. The test surface that must stay at 10/10 after the swap. |
### 1.3 Background concepts
- **Arena-based IR**. Every `Expression` and `Type` is stored by
handle in an arena owned by the enclosing `Function` / `Module`.
Construct nodes strictly bottom-up: a handle is only valid once all
its subexpressions exist in the same arena.
- **Emit statements**. Computing an expression isn't the same as
making its value available to later statements. Non-`const`
expressions must be wrapped in `Statement::Emit(range)` entries in
the function body, naming the ranges of expression handles that are
now "live" at this point in control flow. Forgetting this is a
silent-mis-emit trap (emits a module that validates but produces
wrong code).
- **`UniqueArena` vs `Arena`**. Types go in `UniqueArena` so `f32`
is allocated once and reused. Expressions go in `Arena` (each
`Literal(1.0)` is a distinct node). Mixing these up is a
validation-time error.
- **Bindings**. Entry-point args/results need `Binding`s
(`Location` for varyings, `BuiltIn` for special inputs like
`Position`). Uniform globals need `ResourceBinding { group, binding }`
matching the wgpu bind-group layout.
---
## 2. Current state — what's wired today
`native/canvas-rs/src/lib.rs::offscreen_render_pixel` builds a
`wgpu::ShaderModule` via `ShaderSource::Glsl { shader: fragment_glsl,
stage: Fragment, defines: ... }`. Behind the scenes wgpu calls
naga-glsl, parses the string, produces a `naga::Module`, then writes
SPIR-V via `naga::back::spv::write_vec`.
The `fragment_glsl` string comes from `eml::EMLPath::to_frag_shader_probe`
(or `PlotConfig::toFragShader` for the decorated live-render path).
### Emission structure (reference)
The probe shader emitted today is a small fixed scaffold plus one EML
expression. Reproduced here in the order a naga builder will need to
materialise the pieces:
```glsl
#version 450
layout(location=0) in vec2 uv;
layout(location=0) out vec4 fragColor;
layout(set=0, binding=0) uniform Uniforms {
float u_time; // offset 0
float u_pathParam; // offset 4
vec2 u_resolution; // offset 8
};
void main() {
float px = uv.x;
float py = uv.y;
float {dim} = u_pathParam;
float v = {body}; // the EML expression
float r = 0.5 + 0.5 * cos(6.2832 * v);
float g = 0.5 + 0.5 * cos(6.2832 * v + 2.094);
float b = 0.5 + 0.5 * cos(6.2832 * v + 4.189);
fragColor = vec4(r, g, b, 1.0);
}
```
Every line above has a direct naga IR counterpart; §5 is the
translation.
---
## 3. Target state — what the naga IR builder looks like
A new module `native/canvas-rs/src/emit_naga.rs` exposes:
```rust
pub fn build_probe_module(path: &crate::eml::EMLPath) -> naga::Module;
```
Internal organisation:
```rust
// Top-level orchestrator. Produces a fully-validated Module.
pub fn build_probe_module(path: &EMLPath) -> Module { ... }
// The fragment entry-point's body + its expression/statement arenas.
fn build_main_fn(module: &mut Module, types: &ProbeTypes,
globals: &ProbeGlobals, path: &EMLPath) -> Function { ... }
// The EML-specific piece: EMLExpr → Handle<Expression> under an
// extending function arena. This is the structural heart.
fn emit_emlexpr(
expr: &EMLExpr,
module: &Module,
function: &mut Function,
env: &EmitEnv,
) -> Handle<Expression> { ... }
// The scaffolding types referenced by multiple stages.
struct ProbeTypes {
f32: Handle<Type>,
vec2_f32: Handle<Type>,
vec4_f32: Handle<Type>,
uniforms: Handle<Type>, // struct { f32, f32, vec2<f32> }
}
struct ProbeGlobals {
uniforms_buf: Handle<GlobalVariable>, // @group(0) @binding(0)
uv_in: Handle<GlobalVariable>, // @location(0) input
frag_out: Handle<GlobalVariable>, // @location(0) output
}
// Compile-time env mapping EMLExpr variable names to the naga
// expression handle that evaluates to that variable's value at the
// current point. `path.dimName` maps to the `u_pathParam` field of
// the uniform; "px" / "py" map to uv.x / uv.y; everything else is
// a "bound to 0.0" fallback (matching shaderVar's fallback).
struct EmitEnv {
px: Handle<Expression>,
py: Handle<Expression>,
dim_name: String,
dim_value: Handle<Expression>,
}
```
Outside the builder:
```rust
// In offscreen_render_pixel (and the live renderer later):
let module = emit_naga::build_probe_module(&path);
let shader_module = device.create_shader_module(
wgpu::ShaderModuleDescriptor {
label: Some("probe-fragment"),
source: wgpu::ShaderSource::Naga(Cow::Owned(module)),
}
);
```
---
## 4. The EMLExpr translation table
Every variant maps to one or two naga expression nodes. The table is
small because EMLExpr is small — this is why the "maximally correct"
pathway is tractable.
| EMLExpr | naga Expression |
|---------|------------------------------------------------------|
| `One` | `Expression::Literal(Literal::F32(1.0))` |
| `Var "px"` | `env.px` (reuse handle) |
| `Var "py"` | `env.py` (reuse handle) |
| `Var name` where `name == path.dimName` | `env.dim_value` (reuse handle) |
| `Var name` (other) | `Expression::Literal(Literal::F32(0.0))` — matches `shaderVar`'s fallback |
| `Eml(l, r)` | `exp(l) - log(r)`: two `Math` calls + one `Binary::Subtract` |
And the main-body scaffolding above `v = eml(...)`:
| GLSL line | naga expressions |
|-----------|------------------|
| `float px = uv.x` | `AccessIndex { base: uv_in, index: 0 }` |
| `float py = uv.y` | `AccessIndex { base: uv_in, index: 1 }` |
| `float dim = u_pathParam` | `AccessIndex { base: Expression::Load(uniforms_buf), index: 1 }` |
| `0.5 * cos(6.2832 * v + phase)` | Literal + Math(Cos) + Binary(Mul/Add) chains |
| `fragColor = vec4(r, g, b, 1.0)` | `Expression::Compose { ty: vec4_f32, components: [r, g, b, Literal(1.0)] }`; then `Statement::Store { pointer: frag_out, value: composed }` |
Every intermediate expression must be reachable via `Statement::Emit`
before the `Store` that uses them.
---
## 5. Staging — seven commits, each independently verifiable
Each stage adds strictly more and ships green regression. Do not skip
stages — they're ordered so validation catches each class of mistake
in isolation.
**Status:** Stages 16 done as of 2026-04-25. All 17 probes pass on
the Vulkan-Intel-Iris-Xe target. Stage 7 (cutover / drop the GLSL
feature for the probe path) is the only remaining follow-up.
### Stage 1 — hardcoded red pixel — ✅ done
Goal: prove the SPIR-V pipeline works end-to-end with zero EML content.
- `build_probe_module` ignores `path`; returns a module whose
fragment main writes `vec4(1.0, 0.0, 0.0, 1.0)`.
- Wire it into `offscreen_render_pixel` behind a `#[cfg(feature =
"naga_ir")]` or a boolean.
- Run `probe-test`. Expected: the seven pixel probes **fail**
(pixels are red, not the EML color), but the adapter line prints
and no panic occurs. The failure mode confirms wiring; a panic
would mean module validation failed.
- Commit.
### Stage 2 — uniforms bound — ✅ done
Goal: confirm the bind-group layout matches.
- Build the `Uniforms` struct type in the module.
- Add `uniforms_buf` global with `@group(0) @binding(0)`.
- Output `vec4(u_pathParam, u_pathParam, u_pathParam, 1.0)`.
- Confirm probe pixels equal `(u_pathParam, u_pathParam,
u_pathParam)` — for `pathParam = 0.5` every channel is ≈ 0.5.
- Commit.
### Stage 3 — varyings wired — ✅ done
Goal: reach `px = uv.x`, `py = uv.y`.
- Add `uv_in` global with `@location(0)` input binding.
- Output `vec4(uv.x, uv.y, 0.0, 1.0)`.
- Confirm pixel `(x, y)` in framebuffer has `r = (x+0.5)/w`, `g = 1 -
(y+0.5)/h` (NDC y-flip).
- Commit.
### Stage 4 — minimal EML emission — ✅ done
Goal: walk the `EMLExpr` tree.
- Implement `emit_emlexpr` covering `One`, `Var`, `Eml`.
- Output `v = eml_body(path)`; write `vec4(v, v, v, 1.0)`.
- For `plotExp` (body `exp(px) - log(1.0)`), pixel `(64, 64)` should
give `v ≈ exp(0.504) - log(1.0) ≈ 1.655`. Stored as `1.655`
clamped by the format. Since Rgba32Float doesn't clamp, the value
is literal.
- Commit.
### Stage 5 — full probe-shader equivalent — ✅ done
Goal: match `EMLPath.toColor` pixel-for-pixel.
- Add the cos-cycle: `r = 0.5 + 0.5 * cos(6.2832 * v)`, g with phase
`2.094`, b with phase `4.189`.
- All seven pixel probes pass.
- Commit.
### Stage 6 — emitter-drift test extension — ✅ done
Goal: the naga-IR and GLSL-string emitters must agree on pixel
output.
- Keep both `build_probe_module` (naga) and `to_frag_shader_probe`
(GLSL) usable.
- Add a third probe row to `ProbeTest`: render the same path via both
paths, assert the pixel outputs agree.
- Runs 3 emitter-drift + 7 naga-pixel + 7 glsl-pixel = 17 probes.
- Commit.
### Stage 7 — cutover — pending
Goal: make naga-IR the default, drop naga-glsl from the probe.
- `offscreen_render_pixel` now uses `ShaderSource::Naga`
unconditionally.
- Keep `to_frag_shader_probe` only for the `rustEmitProbeShader` FFI
(used by the emitter-drift test on the Lean side — Lean's string
emitter still has to agree with something human-readable).
- Remove `features = ["glsl"]` from `wgpu` in `Cargo.toml` if no
remaining code path needs it. (Check: `PlotConfig::toFragShader`
is still a GLSL string used by the interactive render loop —
keeping `glsl` for that path is reasonable until a separate pass
converts the decorated plot shader to naga IR too.)
- Commit.
---
## 6. Known pitfalls
These are the traps the naga-glsl frontend handles silently and a
hand-built module must get right.
1. **Expression-before-use**. If you add
`Expression::Binary { left, right }` to the function arena before
`left` is in the arena, validation fails with a cryptic
`InvalidExpression`. Always append subexpressions first.
2. **`Statement::Emit`**. Non-constant expressions must be wrapped
in an `Emit` statement or they're not computed at runtime. Track
the last-emitted handle as you build; emit a fresh range on each
"new live handle". See `naga/src/front/glsl/builtins.rs` for the
pattern.
3. **`UniqueArena` for types**. `module.types.insert(ty, Span::UNDEFINED)`
deduplicates — calling `insert(f32, _)` twice returns the same
handle. Do not call `Arena::append` on the types arena; that API
isn't exposed on `UniqueArena`.
4. **Struct layout must match Rust `Uniforms`**. naga's struct-layout
validator rejects mismatched offsets/strides. Enforce
`Layout::Std140` (Vulkan UBO convention) and specify each member's
`offset` / `span` explicitly. Cross-check against
`native/canvas-rs/src/lib.rs::Uniforms``time: f32 @ 0`,
`path_param: f32 @ 4`, `resolution: [f32; 2] @ 8`.
5. **`ResourceBinding { group: 0, binding: 0 }`** on the uniform
global. Mismatch with wgpu's `BindGroupLayout` is a runtime error
in `create_render_pipeline`.
6. **Entry-point arguments and result**. Fragment entry-point
takes `uv: vec2<f32>` at `Location(0)` and returns
`vec4<f32>` at `Location(0)`. Naga's `EntryPoint`
encodes these on the `Function::arguments` / `result` directly,
not as globals — differs from the GLSL surface where they're top-
level `in`/`out`. Two idioms coexist in naga modules; the
argument-binding idiom is cleaner. Study
`naga/src/front/glsl/functions.rs` for the translation.
7. **NDC y-flip**. The GLSL emitter produces a shader that reads
`uv.y` matching the vertex-shader's `pos * 0.5 + 0.5` convention.
wgpu+Vulkan flip Y in the rasterizer. A naga-IR fragment shader
inherits the same conventions — don't add a compensating flip.
8. **Validation capabilities**. `ValidationFlags::all()` is usually
what you want. Some naga features (e.g., subgroup ops) require
enabling specific `Capabilities`; our probe shader uses none of
these. Keep `Capabilities::empty()` initially.
9. **Debug names**. `module.types[h].name = Some(...)` and
`Function::named_expressions` are optional but make SPIR-V debuggers
(RenderDoc) vastly more usable. Worth the 10 lines.
10. **`naga::Module::Default`**. `Module` doesn't implement `Default`;
construct explicitly with every arena as `::default()` and
`entry_points: Vec::new()`. Typo'd field initialisers are
silent.
---
## 7. Verification strategy
Each stage is verifiable independently, but the composite check is:
### 7.1 Static
- `naga::valid::Validator::new(ValidationFlags::all(),
Capabilities::empty()).validate(&module)?`
must succeed. Run on every module before SPIR-V emission.
Validation failures are informative (point at the handle + issue).
### 7.2 Dynamic
- **All 10 probes pass** (3 emitter-drift + 7 pixel). This is the
authoritative end-to-end check. If pixels diverge from the GLSL
path by more than tolerance, the naga emitter has a bug.
- **Byte-level SPIR-V diff** (optional). Dump the SPIR-V bytes from
the naga-IR path and the naga-glsl path; compare. Byte-equality is
unlikely but semantic equivalence is checked by the pixel probes.
### 7.3 Stage-by-stage check
Run `probe-test` after each stage. At Stage 5 onward all 10 pass;
earlier stages will have pixel-probe failures by design but still
exercise adapter selection + module validation + render pass.
---
## 8. Open questions (decide before Stage 1)
1. **Keep the GLSL path for `PlotConfig::toFragShader` (decorated
plot shader)?** That shader has grid/axes/curve overlay code not
representable in `EMLExpr`. Options:
- (a) Keep GLSL for decorated plots; naga-IR only for probe shader.
Simpler. Keeps `features = ["glsl"]`.
- (b) Extend `EMLExpr` with a "decoration" AST for grid/axes/curve.
Principled, larger scope.
- (c) Let the decorated plot be a fixed naga-IR template with an
`EMLExpr`-hole for the body. Middle ground.
*Recommendation: (a) for the first pass; revisit (c) once the
probe is direct.*
2. **Pipeline caching.** Each probe call recreates the wgpu device
(seconds per call). Separate concern from naga IR, but if we're
touching the same pipeline setup code, worth a paragraph.
3. **wgpu `ShaderSource::Naga` vs round-trip via `ShaderSource::SpirV`.**
`Naga` hands the module directly to wgpu's internal SPIR-V writer;
`SpirV` accepts pre-written bytes. Functionally equivalent for
our use; `Naga` is one less step but `SpirV` gives byte-level
inspectability for debugging. *Recommendation: use `Naga` for
the production path and expose a `--dump-spirv` debug switch for
the bytes.*
4. **Error-path UX.** naga validation errors are structured
(`ValidationError`); surface them in `offscreen_render_pixel`'s
return as `Err(format!(...))` rather than via `panic!`. A
mis-built probe module should produce a sentinel, not a crash.
---
## 9. Formal status after this pass
This push does **not** eliminate `compileEMLPath_correct` as an
axiom. The axiom's content is still "the shader handle produced by
Lean's `compileEMLPath` has semantic equal to `EMLPath.toColor`".
What it eliminates is the unverified text stage:
| Pipeline layer | Before | After |
|----------------|--------|-------|
| Lean → Rust AST | structured (P6) | structured (unchanged) |
| Rust AST → shader input | Rust emits GLSL text | Rust builds naga IR |
| shader input → naga Module | naga-glsl parses ~10⁴ LOC | same arena we built |
| naga Module → SPIR-V | naga::back::spv (unchanged) | unchanged |
| SPIR-V → GPU | wgpu + driver (unchanged) | unchanged |
The "same arena we built" line is the correctness gain: the module
the SPIR-V writer sees is the exact structure we constructed from
`EMLExpr`, with no parse step in between. A future Lean model of
naga IR could then close the remaining gap to a theorem — but that
is a separate project.
---
## 10. Session budget
Realistic one-session target: **Stages 15** (hardcoded red → full
probe parity). That's ~500800 Rust lines, most of it in the new
`emit_naga.rs`, with careful arena bookkeeping. Expect two hours of
naga-docs reading before writing one stage.
Stages 67 (drift test + cutover) are ~100 lines each and can go in
a follow-up.
---
*End of NAGA_IR_PLAN.md. Update §8 if the decisions change. Update
§5 as stages land — tick them here rather than spreading status across
session summaries.*

199
PLAN.md
View file

@ -1,199 +0,0 @@
# Topolei — Architecture Plan
## Core Premise
Topolei is a **Lean 4 extension** that adds cubical-transport homotopy type
theory to Lean 4 via a Rust FFI module. On top of that foundation, it
builds a unified rendering interface where text and graphs are co-projections
of the same computational primitive: the EML cell.
Process discipline: every layer that will ultimately cross the FFI boundary
is *first* formalized in Lean as axioms and data structures. The Rust
component then discharges those axioms at runtime via `@[extern]` /
`@[implemented_by]`. The cubical core (Phase 1) exemplifies this — its
axiom set (eval-level equations for `eval`/`vApp`/`vPApp`/`vTransp`/…,
six glueIn/unglue face axioms, Glue-transport axioms) is complete in Lean
with zero Rust written yet.
**Step-level axioms have been collapsed.** The Phase 1 Week 7
step↔eval bridge (`Cubical/Readback.lean`, `STATUS.md` § Week 7)
provides `CTerm.readback := readback ∘ eval .nil` and derives NbE
analogues of each step axiom. Stream B #2d (2026-04-23) completed
the cleanup by physically deleting the now-redundant axioms. Current
status:
- ✅ Removed from source (NbE theorems in `Readback.lean`):
T1 `transp_id`, T2 `transp_const_id`, C1 `comp_full`, C2 `comp_empty`,
`step_papp_plam`.
- ✅ T4 NbE coverage complete for path-typed lines via
`readback_transp_plam_general` (Stream B #2c, 2026-04-23) — combines
the full-face, constant-line, and varying-path cases. Non-path
varying lines are vacuous in well-typed code.
- ✅ T5 promoted to eval level via `eval_transp_face_congr` (Stream B
#2b, 2026-04-23); NbE form `readback_transp_face_congr`. Step-level
T5 axiom removed.
- ⚠️ Residual axioms (genuinely need extra machinery): T3 and C4
(subject reduction, need typing-preservation lemmas). Step-level T4
retained as syntactic fallback.
Rust's obligation set is the eval-level equations plus readback
equations (now including `readback_vPathTransp_plam` / `_other`) plus
the residual step axioms — not the full step axiom list.
EML operator (Odrzywolek 2026, arXiv:2603.21852):
eml(x, y) = exp(x) ln(y)
Grammar: S → 1 | eml(S, S)
This is the continuous analogue of NAND — a Sheffer operator for all elementary
functions. Every sin, cos, +, ×, √, π is a particular binary tree of eml nodes.
See `exp-log.pdf` for the full constructive proof.
---
## Rendering Stack
```
Lean 4
EMLTree S → 1 | eml(S,S)
↓ verified compile
ShaderIR typed binary IR; proofs attached here
↓ emit (primary)
SPIR-V bytes binary, formal semantics, direct to Vulkan driver
↓ (FFI: C ABI)
Rust (wgpu / Vulkan)
GPU context
draw(uniformBuffer)
Native window (X11/Wayland) — or WebGPU (browser) via the same wgpu
```
### Browser / WASM interoperability
When browser deployment is needed, add a WGSL emitter from the same ShaderIR node.
The proof layer and the IR are unchanged; only the emit step differs.
```
ShaderIR
├─ emit/SPIRV.lean → SPIR-V bytes (native Vulkan — primary dev target)
└─ emit/WGSL.lean → WGSL text (WebGPU / browser — secondary target)
```
WGSL text is compiled to native GPU instructions by the browser (Metal/DX12/Vulkan
under the hood), so runtime performance is identical. The format difference is
parse-time only and immaterial once shaders are uploaded.
**Decision:** Develop against SPIR-V + Vulkan. Add WGSL emitter before any browser
demo. Both share the same ShaderIR, so the WGSL path costs one emitter file, not
an architectural change.
### Why not WASM-compile Lean itself
Lean 4's WASM backend exists (via its C output + emscripten) but produces
large bundles (~3080MB with elaborator + stdlib). For the primary
deployment target — "browser-runnable shader demo" — keep Lean as a
native ahead-of-time compiler that emits shader strings/bytes + discharged
proof terms at build time; ship only the Rust FFI runtime as the `.wasm`
module. A secondary "prove-in-browser" artifact can be built later if
interactive theorem exploration is wanted.
---
## Window Interface
Three surfaces exposed to the window — nothing more:
1. `uploadShader(spirv: ByteArray) → ShaderHandle`
Compiled EML tree arrives as SPIR-V bytes.
2. `setUniform(handle: ShaderHandle, name: String, value: Float) → Unit`
Moving a slider = transport along a path in parameter space.
3. `onInput(event: InputEvent) → CellDeformation`
Click/drag lifts screen coordinates back to cell space (fiber selection).
The window is dumb. All homotopy structure is resolved in Lean before bytes cross
the FFI. Rust manages GPU context lifecycle and — on the other FFI surface —
the cubical evaluator kernel linked to Lean's `eval`/`readback` axioms
(with `step` derived — see Phase 4 below).
---
## Phase Roadmap
### Phase 1 — EML Core (Lean 4)
- `Topolei/EML/Tree.lean` — inductive `EMLTree` (S → 1 | eml S S)
- `Topolei/EML/Eval.lean` — evaluator `EMLTree → `
- `Topolei/EML/Derive.lean` — prove sin, cos, +, ×, π as EML trees (verified)
- `Topolei/EML/Compile.lean` — EMLTree → ShaderIR
### Phase 2 — ShaderIR + Emitters
- `Topolei/Shader/IR.lean` — typed intermediate representation
- `Topolei/Shader/SPIRV.lean` — ShaderIR → SPIR-V bytes (primary)
- `Topolei/Shader/WGSL.lean` — ShaderIR → WGSL text (browser compat)
### Phase 3 — Zigzag Engine Lean Port
Port the n-category combinatorial engine from Rust reference into Lean 4.
See `ZIGZAG_PORT.md` for the step-by-step plan. Delivers `Topolei/Zigzag/`:
`Monotone`, `Core`, `Diagram`, `Signature`, `Degeneracy`, `Pullback`,
`Normalise`, `Typecheck`, `Tests`. Plus `Cell/Zigzag.lean` bridging to the
cubical core. **Pure Lean — no Rust dependency for this phase.** The Rust
implementation at `zigzag-engine/` is reference material only; see its
README. Delivers dimension-general normalisation (past the homotopy.io
4D cap), provable essential-identity preservation, and the combinatorial
backend for higher cells.
### Phase 4 — Rust FFI: Cubical Evaluator Backend (the *one* Rust component)
Rust implementations of `eval`, `vApp`, `vPApp`, `vTransp`, `vHCompValue`,
`vCompAtTerm`, `vCompNAtTerm`, `readback`, `readbackNeu`, and the
face-disjoint reductions for transp/comp/glue. Linked via `@[extern]` +
`@[implemented_by]` to the axioms already stated in `Cubical/Eval.lean`,
`Cubical/Readback.lean`, `TransportLaws.lean` (residual), `CompLaws.lean`
(residual), `Soundness.lean`, and `Glue.lean`. Turns Lean's
reasoning-only cubical core into a kernel-speed reducer.
**Step is largely derived, not implemented.** The Week 7 step↔eval
bridge (Sessions 14 + Session 5 cleanup landed 2026-04-23) gives
`CTerm.readback := readback ∘ eval .nil` and NbE-level analogues of T1,
T2, C1, C2, `step_papp_plam` (+ partial T4) as Lean theorems — the
former axiom statements have been physically removed from the source.
Four step-level axioms remain on Rust's plate: T3, T5, C4 (each blocked
on separate machinery, see STATUS.md Week 7 table), and the general T4
case.
**This is the only Rust component in topolei.** It exists solely to
extend Lean 4 with computational cubical-transport HoTT — everything
else is Lean.
### Phase 5 — GPU Runtime (still Rust, but within Phase 4's crate)
wgpu (Vulkan/Metal/DX12/WebGPU) context, shader upload, framebuffer,
uniform buffers. Three-surface window FFI (upload / setUniform /
onInput) plus the render loop. Lives in the same Rust crate as Phase 4
for convenience (shared `lean-sys` interop) but is a distinct FFI
surface (effects vs. reductions).
### Phase 3.5 — FM^fr Notation Layer (H4)
- Model mathematical notation as ∫_M A (factorization homology, framed)
- M : framed syntactic manifold (1-manifold = linear text, 2-manifold = 2D layout)
- A : E_n-algebra over EMLExpr — local composition rules
- Framing transports = language transformations (syntax ↔ geometry ↔ interactive)
- LaTeX becomes one framing choice; other framings give graph/interactive/proof renderings
- Depends on: EML evaluator (Phase 1) + cubical transport (cells-spec Phase 1)
### Phase 4 — Cells-EML Bridge
- Connect EMLTree nodes into cells-spec CType/CTerm framework
- EML node = 1-cell; tree composition = path concatenation
### Phase 5 — Subjective Testing Loop
- Minimal interactive window: one EML tree → one rendered cell
- User manipulates parameters; intuition scores recorded in HYPOTHESES.md
- Iterate on interface based on H2 and H3 test results
---
## Constraints (from cells-spec)
1. Zero external HoTT dependencies — own everything from interval algebra up
2. Lean 4 kernel compatibility — cubical calculus deeply embedded as data
3. Self-maintainable — single developer buildable, no external package ecosystem
4. Practical GPU target — proof layer and performance layer separated by narrow FFI

View file

@ -1,10 +0,0 @@
import Topolei.RenderProbe
def main : IO UInt32 := do
let fails ← TopoleiProbe.runProbes
if fails > 0 then
IO.println s!"FAIL: {fails} probe(s) diverged"
return 1
else
IO.println "PASS: GPU output matches Lean ShaderSemantic on all probes"
return 0

View file

@ -1,51 +1,58 @@
# topolei # cubical-transport-hott-lean4
A Lean 4 extension adding cubical-transport homotopy type theory to Lean 4 A Lean 4 implementation of cubical-transport homotopy type theory
via a Rust FFI module. (CCHM-flavor), with a fast Rust kernel exposed through C ABI.
## Documents The Lean side defines the syntax, semantics, and soundness theorems.
The Rust side discharges the per-step β-rules of the evaluator.
Lean axioms are routed through `@[implemented_by]` to Rust functions
that return Lean objects in the same shape Lean would have produced;
the soundness layer (`CubicalTransport/Soundness.lean`) certifies the
backend at the boundary, so the kernel speed of the Rust code
preserves the Lean-level proofs.
- **`STATUS.md`** — current formal status, Phase 1 closure, open ## What's here
obligations, three-stream priority order.
- **`PLAN.md`** — architecture plan: rendering stack, window FFI
surfaces, phase roadmap.
- **`cells-spec.md`** — full system specification: cubical core, cells,
shader pipeline, runtime, boundary, self-hosting.
- **`TRANSPORT_PLAN.md`** — step-by-step cubical evaluator formalization
plan (Phase 1 history).
- **`ZIGZAG_PORT.md`** — step-by-step Lean port plan for the n-category
combinatorial engine. Parallel to TRANSPORT_PLAN but for Phase 2+
higher-cell backend.
- **`NAGA_IR_PLAN.md`** — staged plan for direct `naga::Module`
construction from `EMLPath` (eliminating the last text-format stage
in the render pipeline). Seven-stage roadmap with reading list and
known pitfalls; pick up in a fresh session.
- **`NUMERICAL.md`** — principles for numerical implementations:
separation of mathematical content from execution context,
contracts, registry, construction faults to avoid.
- **`HYPOTHESES.md`** — H1H4 hypotheses about the approach.
- **`REFERENCES.md`** — papers and code references (CCHM, cubicaltt,
Agda Cubical, EML).
- **`zigzag-engine/`** — reference Rust implementation of the
n-category engine (~11K LOC) + papers. Port-from material, NOT a
dependency. See its own README.
## Core framing - `CubicalTransport/` — 22 Lean modules for syntax, substitution,
dimensional structure, faces, typing, evaluation (eval / value /
readback), transport, Glue, composition, and the soundness theorems.
- `native/cubical/` — Rust kernel (`#![no_std]`, dual-target native
staticlib + cdylib, wasm32 cdylib).
- `CubicalTest.lean`, `CubicalBench.lean` — engine smoke + property
tests (62/62 passing) and microbenchmarks.
**Topolei is a Lean 4 extension.** Everything external to Lean is ## Reusing this engine
*first* specified as Lean axioms; the one Rust FFI component later
discharges those axioms via `@[extern]` + `@[implemented_by]`.
**The one Rust component** is the cubical evaluator backend (plus GPU Add as a Lake dependency from another Lean 4 project:
runtime within the same crate). Its purpose is extending Lean 4 with
computational cubical-transport HoTT.
**Everything else is Lean**, including the zigzag n-category engine ```toml
(being ported in) and the numerical layer. The medium-term goal is to [[require]]
maximise what can be reasoned about inside Lean; Rust is used only name = "cubicalTransport"
where fundamentally required (effects) or as a post-spec optimisation path = "../cubical-transport-hott-lean4" # or git = "..."
target. ```
The cubical core (Phase 1) is closed with zero Rust dependency. Many Then `import CubicalTransport.Syntax`, `import CubicalTransport.Eval`,
subsequent phases (Cells, Reactive, Color, Shader IR, Boundary models, etc. Link against `native/cubical/target/release/libtopolei_cubical.a`
Meta, Zigzag) are pure-Lean extensions of the axiom base. in your own `moreLinkArgs` so the FFI symbols resolve.
## Build
```bash
(cd native/cubical && cargo build --release)
lake build
./.lake/build/bin/cubical-test # 62/62 tests pass
```
## Reference
- `FFI_DESIGN.md` — C ABI contract.
- `FFI_COMPLETENESS.md` — per-function axiom audit.
- `KERNEL_BOUNDARY.md` — what this delivers in unmodified Lean 4 vs.
what would need upstream Lean kernel work.
- `NUMERICAL.md` — numerical implementation principles.
- `TRANSPORT_PLAN.md` — formalization plan (history of Phase 1).
## Used by
- [`max/topolei`](../topolei) — interactive cells-spec workspace
front-end built on this engine.

View file

@ -1,152 +0,0 @@
# Topolei — References
Papers and code referenced for implementation. Organized by subsystem.
---
## Cubical Type Theory — Foundational Papers
**CCHM — the primary reference for our cubical core.**
Cohen, Coquand, Huber, Mörtberg (2016)
"Cubical Type Theory: a constructive interpretation of the univalence axiom"
arXiv:1611.02108
https://arxiv.org/abs/1611.02108
> De Morgan interval algebra, face formulas, hcomp, transport, Glue types, univalence.
> This is the spec for Cells/Cubical/*.lean.
**De Morgan Implementation Tutorial — how to actually build it.**
Mörtberg (2022)
"A tutorial on implementing De Morgan cubical type theory"
arXiv:2210.08232
https://arxiv.org/abs/2210.08232
> Type-checking algorithms, cofibration handling, evaluator structure.
> Closest thing to a recipe for our Eval.lean and Transport.lean.
**ABCFHL — Cartesian cubical variant (alternative to de Morgan).**
Angiuli, Brunerie, Coquand, Favonia, Harper, Licata
"Syntax and Models of Cartesian Cubical Type Theory"
https://www.cs.cmu.edu/~rwh/papers/uniform/uniform.pdf
> Read if de Morgan interval causes problems with Lean's kernel; Cartesian
> variant has different composition rules that may embed more cleanly.
**Univalence in cubical sets.**
Bezem, Coquand, Huber (2017)
arXiv:1710.10941
https://arxiv.org/abs/1710.10941
**Axioms for cubical type theory in a topos.**
Orton, Pitts (2017)
arXiv:1712.04864
https://arxiv.org/abs/1712.04864
**Unifying cubical and multimodal type theory.**
Aagaard, Kristensen, Gratzer, Birkedal (2022)
arXiv:2203.13000
https://arxiv.org/abs/2203.13000
---
## Cubical Type Theory — Reference Code
**cubicaltt — original Haskell implementation by Mörtberg et al.**
https://github.com/mortberg/cubicaltt
> Reference for hcomp algorithm, face formula solver, evaluator structure.
> Read the source, do not depend on it.
**Agda Cubical Library — target architecture for our Lean embedding.**
https://github.com/agda/cubical
> Key files to read:
> Cubical/Core/Primitives.agda — interval, face, transport primitives
> Cubical/Foundations/Transport.agda — transport lemmas
> Cubical/Core/Glue.agda — Glue type and univalence
> We are reimplementing this structure in Lean 4 as a deep embedding.
> Do not import; use as architectural reference only.
**Ground Zero — Lean 4 synthetic HoTT library.**
https://github.com/rzrn/ground_zero
> Shows how to avoid Lean's native equality and build HoTT synthetically in Lean 4.
> Read for: eliminator construction patterns, HIT techniques via quotients.
> cells-spec constraint: do not take it as a dependency.
---
## EML — Exp-Minus-Log Binary Primitive
**The EML paper. Primary computational reference for this project.**
Odrzywolek, Andrzej (2026)
"All elementary functions from a single operator"
arXiv:2603.21852
https://arxiv.org/abs/2603.21852
Local copy: exp-log.pdf
> eml(x,y) = exp(x) ln(y) with constant 1 generates all elementary functions.
> Grammar: S → 1 | eml(S,S). Constructive proof for sin, cos, +, ×, π, etc.
> Foundation for H1. See HYPOTHESES.md.
---
## Factorization Homology — FM^fr Notation Layer
**The primer — best entry point.**
Ayala, Francis (2019)
"A factorization homology primer"
arXiv:1903.10961
https://arxiv.org/abs/1903.10961
> E_n-algebras, framed manifolds, ⊗-excision. Read this first for H4.
**Original paper.**
Ayala, Francis (2012)
"Factorization homology of topological manifolds"
arXiv:1206.5522
https://arxiv.org/abs/1206.5522
**Higher categories.**
Ayala, Francis (2015)
"Factorization homology I: higher categories"
arXiv:1504.04007
https://arxiv.org/abs/1504.04007
**Stratified spaces (for mixed text/graph layouts).**
Ayala, Francis, Tanaka (2014)
"Factorization homology of stratified spaces"
arXiv:1409.0848
https://arxiv.org/abs/1409.0848
> Relevant when notation mixes 1D (text) and 2D (diagram) regions.
**Traces in dimension 1 (for linear/sequential syntax).**
arXiv:2105.01143
https://arxiv.org/abs/2105.01143
> Circle-invariant traces; relevant for cyclic/recursive notation structures.
---
## Verified Compiler / Shader IR
**Lean4Lean — verified Lean typechecker in Lean. Pattern reference.**
Carneiro (2024)
"Lean4Lean: Verifying a Typechecker for Lean, in Lean"
arXiv:2403.14064
https://arxiv.org/abs/2403.14064
> Reference for: how to structure a verified evaluator/compiler in Lean 4.
> Our EML → ShaderIR compiler should follow similar patterns.
**SPIR-V specification.**
Khronos Group
https://www.khronos.org/spirv/
> Binary format target for native GPU path. Formal grammar maps onto our ShaderIR.
**MLIR SPIR-V dialect — IR structure reference.**
https://mlir.llvm.org/docs/Dialects/SPIR-V/
> Reference for what a typed shader IR looks like before binary encoding.
> Informs the design of Topolei/Shader/IR.lean.
---
## Recommended Reading Order
1. CCHM (1611.02108) — understand the cubical core we are embedding
2. De Morgan tutorial (2210.08232) — implementation recipe
3. cubicaltt source — see the evaluator and hcomp in action
4. Agda Cubical library — see the Lean-side target architecture
5. EML paper (2603.21852) — already read; revisit Sect. 3 for derivation tables
6. Ayala-Francis primer (1903.10961) — FM^fr foundation for H4
7. Lean4Lean (2403.14064) — verified compiler patterns before writing ShaderIR

View file

@ -1,458 +0,0 @@
# 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:**
```glsl
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:**
```lean
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:**
```lean
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:
```lean
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:**
```lean
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:**
```lean
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:
```lean
@[extern "topolei_read_pixel"]
opaque readPixel (ctx : GPUContext) (x y : UInt32) : IO (Float × Float × Float × Float)
```
2. **Replace `True` with a checkable theorem:**
```lean
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
`PlotConfig`s go through the render pipeline — arbitrary cubical
transports can't.
**The signature:**
```lean
/-- 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):**
```lean
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` (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
```lean
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.
---
## 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
`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.

1163
STATUS.md

File diff suppressed because it is too large Load diff

View file

@ -1,31 +0,0 @@
import Topolei.Basic
import Topolei.Canvas
import Topolei.EML
import Topolei.EML.Path
import Topolei.Cubical.Interval
import Topolei.Cubical.Face
import Topolei.Cubical.Syntax
import Topolei.Cubical.Subst
import Topolei.Cubical.DimLine
import Topolei.Cubical.Typing
import Topolei.Cubical.Equiv
import Topolei.Cubical.Glue
import Topolei.Cubical.Value
import Topolei.Cubical.Transport
import Topolei.Cubical.Line
import Topolei.Cubical.Eval
import Topolei.Cubical.EvalTest
import Topolei.Cubical.Readback
import Topolei.Cubical.FFI
import Topolei.Cubical.FFITest
import Topolei.Cubical.ValueTyping
import Topolei.Cubical.TransportLaws
import Topolei.Cubical.System
import Topolei.Cubical.CompLaws
import Topolei.Cubical.Soundness
import Topolei.GPU.Spec
import Topolei.Selection
import Topolei.Subobject
import Topolei.Trace
import Topolei.Cubical.Trace
import Topolei.Obs.Ctx

View file

@ -1 +0,0 @@
def hello := "world"

View file

@ -1,46 +0,0 @@
import Topolei.EML.Path
/-!
Topolei.Canvas
==============
Lean bindings to the live wgpu canvas. Both entry points take an
`EMLPath` (a structured cubical-1-cell) and a fixed `pathParam : Float`,
not a shader-source string and not a time-driven driver. The
fragment shader is built directly as a `naga::Module` on the Rust
side from the Lean inductive walk; the rendering is **static** —
every frame is the 1-cell's fiber at exactly `pathParam`.
## Why no animation curve?
An earlier iteration animated `pathParam` host-side via a sine
sweep of `u_time`. That sweep is not a cubical transport — it's a
free-standing time-to-parameter function chosen for visual effect.
Driving rendering by something that is not itself a transport
violates the cells-spec discipline that "every continuous function
in the visible pipeline is a transport". The animated form belongs
to a 2-cell (a homotopy of 1-cells parameterised by a second
interval); we don't have 2-cell infrastructure yet, so we render
fixed fibers and leave time-driven motion for the 2-cell pass.
See `NAGA_IR_PLAN.md` for the IR-builder plan; `compileEMLPath_correct`
in `Topolei.GPU.Spec` is the contract the Rust IR builder satisfies.
-/
/-- Live render of one fiber of an `EMLPath`. `pathParam` chooses
the fiber; the rendering is static (the same fiber persists for
the lifetime of the window). -/
@[extern "topolei_run_path"]
opaque canvasRunPath
(path : @& EMLPath) (pathParam : Float)
(width height : UInt32) (title : @& String) : IO Unit
/-- Two-panel side-by-side variant: `pathL` rendered at fiber `ppL`
on the left, `pathR` at fiber `ppR` on the right. Each panel has
its own uniform buffer, so the two fibers are independent — pass
distinct `ppL`/`ppR` to display two different fibers (e.g. `at0`
vs `at1` of the same 1-cell). -/
@[extern "topolei_run_path2"]
opaque canvasRunPath2
(pathL : @& EMLPath) (ppL : Float)
(pathR : @& EMLPath) (ppR : Float)
(width height : UInt32) (title : @& String) : IO Unit

View file

@ -1,207 +0,0 @@
/-
Topolei.Cubical.Trace
=====================
The trace map at the cubical-syntax level.
## What this file does
Given a cubical term `t : CTerm`, `traceOf t : Trace CTerm` returns
the list of *all sub-terms encountered* in walking `t` (including
`t` itself). This is the **provenance fold**: every constructor
visited, every variable referenced, every face-conditional clause
walked.
Every rendered output produced from `t` traces back to `traceOf t`.
No instrumentation of the renderer is required — the trace is a
property of the *cubical structure*, computable in pure Lean before
the term ever leaves the host for the GPU. The Rust side renders
whatever single concrete shader Lean hands it; the trace was already
extracted upstream.
## Why this is the Euler-elegant move
Composition of cubical terms (via `comp`, `compN`, `glueIn`,
`unglue`) automatically composes their traces — this is forced by
`traceOf`'s structural recursion + `Trace`'s monoid structure. The
homomorphism theorems below say: for any constructor `C` with
sub-terms `s₁..sₙ`,
traceOf (C s₁ … sₙ) = single (C s₁ … sₙ) traceOf s₁ traceOf sₙ
Every one is `rfl`. The homomorphism IS the definition. No
external machinery, no enumerated cases, just structural recursion
realised as a fold + Trace's free-monoid algebra.
## What this gets us, semantically
Per-pixel traces (the user's "different pixels carrying projections
of different fibers") become straightforward once we add a face-
pruning version `traceOfAt : DimAssignment → CTerm → Trace CTerm`
that, before recursing into `compN`'s clauses, evaluates each face
formula at the given assignment and skips clauses whose face is
inactive. That's a sibling function we can land later.
Coherence between fibers (the differential / sheaf / bundle
question) is then a *predicate over `Trace CTerm`*: "the traces at
adjacent pixels share a long prefix," "the traces vary smoothly
along the rendered path," etc. All landed via simple `Prop`s, no
new types.
## Why no namespace wrap on `traceOf`
`CTerm` lives at the root namespace (Syntax.lean has no
`namespace` declaration), so `CTerm.traceOf` must too — that's
what makes the dot notation `t.traceOf` resolve for any `t : CTerm`.
Theorems are in a namespace below; the function is at root.
-/
import Topolei.Cubical.Syntax
import Topolei.Trace
open Topolei.Trace
-- ── traceOf : structural fold over CTerm ──────────────────────────────────
-- The trace of a cubical term: itself, plus the union of traces of
-- its immediate sub-terms (recursively).
--
-- Mutual with `traceOf.clauses` to handle `compN`'s list of face-
-- conditional sub-terms — same pattern as `CTerm.substDim` /
-- `CTerm.substDim.clauses` in `Syntax.lean`.
mutual
/-- The trace of a cubical term: itself, plus the union of traces of
its immediate sub-terms (recursively). -/
def CTerm.traceOf : CTerm → Trace CTerm
| t@(.var _) => Trace.single t
| t@(.lam _ body) =>
(Trace.single t).union body.traceOf
| t@(.app f a) =>
(Trace.single t).union (f.traceOf.union a.traceOf)
| t@(.plam _ body) =>
(Trace.single t).union body.traceOf
| t@(.papp body _) =>
(Trace.single t).union body.traceOf
| t@(.transp _ _ _ body) =>
(Trace.single t).union body.traceOf
| t@(.comp _ _ _ u v) =>
(Trace.single t).union (u.traceOf.union v.traceOf)
| t@(.compN _ _ clauses v) =>
(Trace.single t).union ((CTerm.traceOf.clauses clauses).union v.traceOf)
| t@(.glueIn _ a b) =>
(Trace.single t).union (a.traceOf.union b.traceOf)
| t@(.unglue _ f g) =>
(Trace.single t).union (f.traceOf.union g.traceOf)
| t@(.pair a b) =>
(Trace.single t).union (a.traceOf.union b.traceOf)
| t@(.fst a) =>
(Trace.single t).union a.traceOf
| t@(.snd a) =>
(Trace.single t).union a.traceOf
/-- Walk a `compN`'s face-conditional clauses, unioning each
sub-term's trace. The face formulas themselves contribute
*no* trace items — they're metadata about *when* the
sub-term participates, not source items. A future
`traceOfAt` will use the formulas to prune; the
unrestricted `traceOf` records all clauses unconditionally. -/
def CTerm.traceOf.clauses : List (FaceFormula × CTerm) → Trace CTerm
| [] => Trace.empty
| (_, c) :: rest => c.traceOf.union (CTerm.traceOf.clauses rest)
end
-- ── Theorems live in a sub-namespace ──────────────────────────────────────
namespace Topolei.Cubical.Trace
-- ── Homomorphism theorems (the construction-language equations) ──────────
--
-- For each cubical constructor C with sub-terms s₁..sₙ:
-- traceOf (C s₁ … sₙ) = single (C s₁ … sₙ) traceOf s₁ traceOf sₙ
--
-- Every one of these is `rfl` by the definition above. This is the
-- "Euler-elegant" core: the homomorphism IS the definition; we don't
-- need separate proofs. The theorems exist as named references for
-- downstream code, and as a stable contract that future refactors
-- of `traceOf` must preserve.
@[simp] theorem traceOf_var (x : String) :
(CTerm.var x).traceOf = Trace.single (CTerm.var x) := rfl
@[simp] theorem traceOf_lam (x : String) (body : CTerm) :
(CTerm.lam x body).traceOf =
(Trace.single (CTerm.lam x body)).union body.traceOf := rfl
@[simp] theorem traceOf_app (f a : CTerm) :
(CTerm.app f a).traceOf =
(Trace.single (CTerm.app f a)).union (f.traceOf.union a.traceOf) := rfl
@[simp] theorem traceOf_plam (i : DimVar) (body : CTerm) :
(CTerm.plam i body).traceOf =
(Trace.single (CTerm.plam i body)).union body.traceOf := rfl
@[simp] theorem traceOf_papp (body : CTerm) (r : DimExpr) :
(CTerm.papp body r).traceOf =
(Trace.single (CTerm.papp body r)).union body.traceOf := rfl
@[simp] theorem traceOf_transp (i : DimVar) (A : CType)
(φ : FaceFormula) (body : CTerm) :
(CTerm.transp i A φ body).traceOf =
(Trace.single (CTerm.transp i A φ body)).union body.traceOf := rfl
@[simp] theorem traceOf_comp (i : DimVar) (A : CType) (φ : FaceFormula)
(u v : CTerm) :
(CTerm.comp i A φ u v).traceOf =
(Trace.single (CTerm.comp i A φ u v)).union
(u.traceOf.union v.traceOf) := rfl
@[simp] theorem traceOf_glueIn (φ : FaceFormula) (a b : CTerm) :
(CTerm.glueIn φ a b).traceOf =
(Trace.single (CTerm.glueIn φ a b)).union (a.traceOf.union b.traceOf) :=
rfl
@[simp] theorem traceOf_unglue (φ : FaceFormula) (f g : CTerm) :
(CTerm.unglue φ f g).traceOf =
(Trace.single (CTerm.unglue φ f g)).union (f.traceOf.union g.traceOf) :=
rfl
@[simp] theorem traceOf_pair (a b : CTerm) :
(CTerm.pair a b).traceOf =
(Trace.single (CTerm.pair a b)).union (a.traceOf.union b.traceOf) := rfl
@[simp] theorem traceOf_fst (a : CTerm) :
(CTerm.fst a).traceOf =
(Trace.single (CTerm.fst a)).union a.traceOf := rfl
@[simp] theorem traceOf_snd (a : CTerm) :
(CTerm.snd a).traceOf =
(Trace.single (CTerm.snd a)).union a.traceOf := rfl
-- ── Length / non-emptiness ────────────────────────────────────────────────
--
-- A trivial but useful corollary: every term's trace is non-empty
-- (it always contains at least the term itself). The user's
-- introspection guarantee depends on this — "every rendered element
-- has *some* provenance" is a typed property, not a runtime hope.
theorem traceOf_nonempty (t : CTerm) : t.traceOf.items ≠ [] := by
cases t <;> simp [CTerm.traceOf, Trace.single, Trace.union]
end Topolei.Cubical.Trace
-- ── Operational sanity ────────────────────────────────────────────────────
/-- A demo: the trace of `λx. x` (an identity term) contains the lam
AND the var. -/
def demoIdentity : CTerm := .lam "x" (.var "x")
#eval demoIdentity.traceOf.items.length -- expected: 2 (lam + var)
/-- A demo: the trace of `(a, b)` contains pair, var "a", var "b" → 3. -/
def demoPair : CTerm := .pair (.var "a") (.var "b")
#eval demoPair.traceOf.items.length -- expected: 3
/-- A demo: the trace of an application contains app + f-trace + a-trace. -/
def demoApp : CTerm := .app (.var "f") (.var "a")
#eval demoApp.traceOf.items.length -- expected: 3

View file

@ -1,90 +0,0 @@
-- EML tree: S → 1 | eml(S, S)
-- eml(x, y) = exp(x) ln(y)
--
-- Single binary primitive that generates all elementary functions.
-- (Odrzywolek 2026, arXiv:2603.21852)
-- ── Core inductive ────────────────────────────────────────────────────────────
inductive EMLExpr where
| one : EMLExpr
| var (name : String) : EMLExpr -- free variable resolved in the rendering env
| eml (l r : EMLExpr) : EMLExpr
-- ── Derived forms ─────────────────────────────────────────────────────────────
-- exp(x) = eml(x, 1) since exp(x) ln(1) = exp(x) 0 = exp(x)
def EMLExpr.expOf (x : EMLExpr) : EMLExpr := .eml x .one
-- ln(x) = eml(1, eml(eml(1, x), 1))
def EMLExpr.lnOf (x : EMLExpr) : EMLExpr :=
.eml .one (.eml (.eml .one x) .one)
-- ── Plot configuration ────────────────────────────────────────────────────────
/-- A path config: an EML expression plus its distinguished path-dimension
variable. `dimName` is the name that ranges over `{0, 1}` when the
config is interpreted as an `EMLPath` (see `EML/Path.lean`'s
`PlotConfig.toEMLPath`). When `dimName` does not occur in `expr`,
the path is *constant* (its value is the same at both endpoints);
when it does, the path is genuinely parametric. -/
structure PlotConfig where
expr : EMLExpr
dimName : String := "t"
-- ── Named demo expressions (probe test fixtures) ─────────────────────────────
-- exp(x): depth-1 EML tree, one node.
def plotExp : PlotConfig := {
expr := EMLExpr.expOf (.var "px")
}
-- ln(x): depth-3 EML tree, three nodes.
--
-- Historical note: an earlier `plotLn` used the variable name
-- `"max(px, 0.001)"` to clamp negative inputs on the GPU side. That
-- made the GPU shader evaluate `max(px, 0.001)` as a GLSL expression
-- but left Lean's `shaderVar` hitting the fallback `0.0` — the two
-- sides disagreed on the semantic of the variable. The probe test
-- surfaced the divergence; the fix is to use a real variable `px`
-- and accept that `ln` of negative `px` produces `NaN` on both sides
-- (the two sides agree, which is what `render_faithful` cares about).
def plotLn : PlotConfig := {
expr := EMLExpr.lnOf (.var "px")
}
-- ── Continuous-homotopy demo: a genuinely parametric path ────────────────────
-- `exp(px) t` translates the exponential curve down by `t`. At `t=0`
-- the curve is `y = exp(x)`; at `t=1` it's `y = exp(x) 1`; in between it
-- smoothly slides. EML-expressible because `exp(px) - log(exp(t))` reduces
-- (via `log ∘ exp = id`) to `exp(px) - t`; body is
-- `eml(var "px", eml(var "t", one))`.
def plotTransp : PlotConfig := {
expr := .eml (.var "px") (.eml (.var "t") .one)
dimName := "t"
}
-- ── Clean fibers in [0, 1] for greyscale demos ──────────────────────────────
-- These bodies are picked specifically so their image lies in `[0, 1]`,
-- matching the framebuffer's natural display range. Each is a 1-cell;
-- their shape under different `pathParam` values shows the transport
-- in action with no clamping artifacts.
/-- The 1-cell whose body IS the dim variable: at `pathParam = c` every
pixel evaluates to `c`. Different fibers display as solid-color
panels with brightness equal to the fiber's parameter.
`at0 = solid black`, `at1 = solid white`, `mid = solid 50% grey`. -/
def plotT : PlotConfig := {
expr := .var "t"
dimName := "t"
}
/-- The 1-cell whose body is `px`: image is the horizontal coordinate
itself, a black-to-white left-to-right gradient. Constant
1-cell (no `t` dependence) — every fiber is the same gradient.
Useful as a sanity check: fibers should NOT differ. -/
def plotPx : PlotConfig := {
expr := .var "px"
dimName := "t"
}

View file

@ -1,243 +0,0 @@
/-
Topolei.EML.Path
================
Connection between EML expressions and the cubical interval.
An EML expression with a free "dimension variable" (a named string)
that is evaluated at 0.0 or 1.0 is a path in the Float domain.
This is the bridge between:
· the cubical interval I (Bool endpoints: false = 0, true = 1)
· EML evaluation (Float-valued)
Key definitions:
· EMLExpr.evalWithEnv — generalised evaluator with custom var resolver
· EMLExpr.varAbsent — syntactic check that a variable does not appear
· EMLPath — an EML expression with a distinguished dim var
· EMLPath.atBool/at0/at1 — evaluation at Bool endpoints
· EMLPath.const_endpoints — absent dim var → at0 = at1
The GPU-layer connection (linking baseEnv to shaderVar) lives in
GPU/Spec.lean rather than here, to avoid a circular import.
-/
import Topolei.EML
import Topolei.Cubical.DimLine
-- ── Bool → Float endpoint map ─────────────────────────────────────────────────
/-- Map Bool to its Float interval endpoint: false ↦ 0.0, true ↦ 1.0 -/
def boolToFloat : Bool → Float
| false => 0.0
| true => 1.0
@[simp] theorem boolToFloat_false : boolToFloat false = 0.0 := rfl
@[simp] theorem boolToFloat_true : boolToFloat true = 1.0 := rfl
-- ── Generalised EML evaluator ─────────────────────────────────────────────────
/-- Evaluate an EML expression using a custom variable resolver.
This generalises EMLExpr.evalAt (which uses shaderVar) so we can
override the dimension variable without touching shader-layer types. -/
def EMLExpr.evalWithEnv (env : String → Float) : EMLExpr → Float
| .one => 1.0
| .var name => env name
| .eml l r =>
let lv := l.evalWithEnv env
let rv := r.evalWithEnv env
Float.exp lv - Float.log (max rv 1e-9)
-- ── Variable absence predicate ────────────────────────────────────────────────
/-- Syntactic check: named variable does not appear in the expression. -/
def EMLExpr.varAbsent (name : String) : EMLExpr → Bool
| .one => true
| .var n => n != name
| .eml l r => l.varAbsent name && r.varAbsent name
/-- Two environments that agree on all names except `name` give the same
evaluation on expressions that don't mention `name`. -/
theorem EMLExpr.evalWithEnv_congr
(e : EMLExpr) (name : String)
(habs : e.varAbsent name = true)
(env1 env2 : String → Float)
(henv : ∀ n, n ≠ name → env1 n = env2 n) :
e.evalWithEnv env1 = e.evalWithEnv env2 := by
induction e with
| one => rfl
| var n =>
simp only [varAbsent, bne_iff_ne] at habs
simp [evalWithEnv, henv n habs]
| eml l r ihl ihr =>
simp only [varAbsent, Bool.and_eq_true] at habs
simp [evalWithEnv, ihl habs.1, ihr habs.2]
-- ── EMLPath ───────────────────────────────────────────────────────────────────
/-- An EML path: an expression parametric in a named dimension variable.
The `dimName` variable ranges over {0.0, 1.0} ≅ Bool. -/
structure EMLPath where
dimName : String -- dimension variable (e.g. "t")
body : EMLExpr -- parametric expression
/-- Evaluate an EMLPath at a Bool endpoint, given a base variable resolver for
all variables other than dimName. -/
def EMLPath.atBool (path : EMLPath) (b : Bool) (baseEnv : String → Float) : Float :=
path.body.evalWithEnv (fun name =>
if name = path.dimName then boolToFloat b else baseEnv name)
def EMLPath.at0 (path : EMLPath) (baseEnv : String → Float) : Float :=
path.atBool false baseEnv
def EMLPath.at1 (path : EMLPath) (baseEnv : String → Float) : Float :=
path.atBool true baseEnv
-- ── Endpoint reduction lemmas ─────────────────────────────────────────────────
@[simp] theorem EMLPath.at0_def (path : EMLPath) (baseEnv : String → Float) :
path.at0 baseEnv =
path.body.evalWithEnv (fun name =>
if name = path.dimName then 0.0 else baseEnv name) := by
simp [at0, atBool, boolToFloat]
@[simp] theorem EMLPath.at1_def (path : EMLPath) (baseEnv : String → Float) :
path.at1 baseEnv =
path.body.evalWithEnv (fun name =>
if name = path.dimName then 1.0 else baseEnv name) := by
simp [at1, atBool, boolToFloat]
-- ── Constant path ─────────────────────────────────────────────────────────────
/-- When the body does not mention dimName, at0 = at1 for any baseEnv. -/
theorem EMLPath.const_endpoints (path : EMLPath)
(habs : path.body.varAbsent path.dimName = true)
(baseEnv : String → Float) :
path.at0 baseEnv = path.at1 baseEnv := by
simp only [at0_def, at1_def]
apply EMLExpr.evalWithEnv_congr _ _ habs
intro n hn
simp [if_neg hn]
-- ── Example: exp path ────────────────────────────────────────────────────────
/-- The exponential path: f(t) = exp(t) for t ∈ {0, 1}. -/
def expPath : EMLPath :=
{ dimName := "t"
body := EMLExpr.expOf (.var "t") }
/-- At t = 0, expPath evaluates to exp(0) - log(max 1.0 1e-9). -/
theorem expPath_at0 (baseEnv : String → Float) :
expPath.at0 baseEnv =
Float.exp 0.0 - Float.log (max 1.0 1e-9) := by
simp [expPath, EMLExpr.expOf, EMLExpr.evalWithEnv]
/-- At t = 1, expPath evaluates to exp(1) - log(max 1.0 1e-9). -/
theorem expPath_at1 (baseEnv : String → Float) :
expPath.at1 baseEnv =
Float.exp 1.0 - Float.log (max 1.0 1e-9) := by
simp [expPath, EMLExpr.expOf, EMLExpr.evalWithEnv]
-- ── Connection to DimLine ─────────────────────────────────────────────────────
/-
Structural parallel:
DimLine (cubical, CType-valued) ↔ EMLPath (rendering, Float-valued)
───────────────────────────────────────────────────────────────────────
DimLine.binder : DimVar ↔ EMLPath.dimName : String
DimLine.at0 : CType ↔ EMLPath.at0 : Float
DimLine.at1 : CType ↔ EMLPath.at1 : Float
transp_const_id (T2) ↔ EMLPath.const_endpoints
Transport along a constant DimLine is the identity (T2).
EML analogue: evaluation of a constant EMLPath (dimName absent) gives
the same Float value at both endpoints.
The rendering correctness claim:
"If the GPU evaluates shader at the 1-end of a DimLine,
the Float result equals EMLPath.at1 baseEnv."
This is the bridge axiom in GPU/Spec.lean (to be added there).
-/
theorem EMLPath_const_mirrors_T2
(path : EMLPath)
(habs : path.body.varAbsent path.dimName = true)
(baseEnv : String → Float) :
path.at0 baseEnv = path.at1 baseEnv :=
EMLPath.const_endpoints path habs baseEnv
-- ── PlotConfig → EMLPath ──────────────────────────────────────────────────────
/-
A `PlotConfig` carries an `EMLExpr` and a distinguished `dimName` which is
the path dimension. Viewing a plot as an `EMLPath` is a direct projection:
drop the display metadata, keep the expression and the dimension.
When the plot's expression does not mention `dimName`, the resulting path
is constant (T2 analogue); when it does, the plot is genuinely time-varying
and `at0`, `at1` differ.
-/
/-- Project a `PlotConfig` to its `EMLPath` view. -/
def PlotConfig.toEMLPath (cfg : PlotConfig) : EMLPath :=
{ dimName := cfg.dimName
body := cfg.expr }
@[simp] theorem PlotConfig.toEMLPath_dimName (cfg : PlotConfig) :
cfg.toEMLPath.dimName = cfg.dimName := rfl
@[simp] theorem PlotConfig.toEMLPath_body (cfg : PlotConfig) :
cfg.toEMLPath.body = cfg.expr := rfl
/-- A plot is a *constant path* exactly when its expression does not mention
the plot's declared dimension variable. -/
theorem PlotConfig.const_path_of_varAbsent
(cfg : PlotConfig)
(habs : cfg.expr.varAbsent cfg.dimName = true)
(baseEnv : String → Float) :
cfg.toEMLPath.at0 baseEnv = cfg.toEMLPath.at1 baseEnv :=
EMLPath.const_endpoints cfg.toEMLPath habs baseEnv
-- ── Demo expressions as paths ─────────────────────────────────────────────────
/-
`plotExp` and `plotLn` (in `EML.lean`) use `px` as their free variable,
not the dimension variable `t`. They are therefore *constant paths* — the
same value at both endpoints — under the default `dimName := "t"`.
-/
theorem plotExp_body_varAbsent : plotExp.expr.varAbsent "t" = true := by decide
theorem plotExp_is_const_path (baseEnv : String → Float) :
plotExp.toEMLPath.at0 baseEnv = plotExp.toEMLPath.at1 baseEnv :=
plotExp.const_path_of_varAbsent plotExp_body_varAbsent baseEnv
theorem plotLn_body_varAbsent : plotLn.expr.varAbsent "t" = true := by decide
theorem plotLn_is_const_path (baseEnv : String → Float) :
plotLn.toEMLPath.at0 baseEnv = plotLn.toEMLPath.at1 baseEnv :=
plotLn.const_path_of_varAbsent plotLn_body_varAbsent baseEnv
-- ── Parametric example (a genuinely non-constant path) ───────────────────────
/-- A parametric plot that actually uses the dimension variable `t`:
`eml(t, 1) = exp(t)` — a path from `exp(0)` at `t=0` to `exp(1)` at `t=1`. -/
def plotExpT : PlotConfig :=
{ expr := EMLExpr.expOf (.var "t")
dimName := "t" }
theorem plotExpT_at0 (baseEnv : String → Float) :
plotExpT.toEMLPath.at0 baseEnv =
Float.exp 0.0 - Float.log (max 1.0 1e-9) := by
simp [plotExpT, PlotConfig.toEMLPath, EMLPath.at0, EMLPath.atBool,
EMLExpr.expOf, EMLExpr.evalWithEnv, boolToFloat]
theorem plotExpT_at1 (baseEnv : String → Float) :
plotExpT.toEMLPath.at1 baseEnv =
Float.exp 1.0 - Float.log (max 1.0 1e-9) := by
simp [plotExpT, PlotConfig.toEMLPath, EMLPath.at1, EMLPath.atBool,
EMLExpr.expOf, EMLExpr.evalWithEnv, boolToFloat]
-- The shader whose semantic IS `EMLPath.toColor` is now built directly
-- as a `naga::Module` (no GLSL string intermediary) on the Rust side
-- by `native/canvas-rs/src/emit_naga.rs::build_probe_module`. See
-- `NAGA_IR_PLAN.md` for the construction; `Topolei.GPU.Spec`'s
-- `compileEMLPath_correct` axiom states the contract that builder
-- must satisfy.

View file

@ -1,448 +0,0 @@
/-
Topolei.GPU.Spec
================
Axiomatic specification of the GPU layer.
Everything declared `axiom` here is a CLAIM about what the Rust/GPU
implementation must satisfy. The Lean math is proved correct *assuming*
these axioms. Before writing Rust, we verify the math is consistent with
the specs. When the Rust ships, we verify it against them.
Proof obligations:
- Lean side: proved below (no axioms needed)
- Rust/GPU side: declared as axioms (discharged by `native/canvas-rs/`)
## Honesty audit — non-transports the rendering pipeline depends on
Every continuous function reaching the GPU should ideally be a
cubical transport. In practice the pipeline relies on a finite
set of non-transports. This block enumerates them. They split
into three classes:
### A. Sealed cells (cells-spec §1.7)
GPU intrinsics whose interiors are sealed at the IEEE/spec level.
We consume their boundaries (the IEEE 754 contract for `exp`,
`log`, `cos`, `+`, `-`, `*`) and trust the hardware to satisfy
them. Discharge obligation = the IEEE 754 spec on the chip.
· `Float.exp`, `Float.log` (for `eml(l, r) = exp(l) - log(r)`)
· IEEE 754 `+`, `-`, `*` on `Float`
· Rasterizer interpolation of `uv` (perspective barycentric
across the fullscreen triangle)
· WGSL vertex shader's coordinate algebra
(`pos = vec2(select(-1, 3, …)…); uv = pos * 0.5 + 0.5`)
### B. Visualization adapter (frozen by `compileEMLPath_correct`)
The cosine cycle in `EMLExpr.toColor` (`r,g,b = 0.5 +
0.5·cos(2π·v + φ)`). NOT a cubical transport — see the docstring
on `EMLExpr.toColor` below. Removing it requires re-axiomatizing
the spec or modeling color spaces as `CType` cells.
### C. Presentation conventions
Window-side choices the renderer makes that aren't part of any
semantic claim:
· Window dimensions (caller's choice)
· Aspect ratio (uv²ⁿ → window's W×H rectangle; bodies that use
`py` will be visibly stretched if W ≠ H)
· Y-flip in `pixelUV` (`uv.y = 1 - (y+0.5)/h`) to match Vulkan's
rasterizer Y-flip
· Floating-point tolerance `5e-3` in the probe (CPU vs GPU
IEEE 754 disagreement)
· The two-panel viewport split
## What is NOT in this list (by design)
Things that *are* transports in our calculus:
· `EMLExpr.eml(l, r) = exp(l) - log(r)` — generates elementary
functions per the EML primitive (Odrzywolek 2026)
· The `EMLPath` body's value as `pathParam` varies — this is
genuinely the transport we're rendering
-/
import Topolei.EML
import Topolei.EML.Path
-- ── Opaque GPU types ──────────────────────────────────────────────────────────
-- C++ objects; Lean sees them only through their axioms.
axiom ShaderHandle : Type
axiom GPUContext : Type
-- ── Semantic domain (pure Lean) ───────────────────────────────────────────────
structure PixelCoord where
x : Float
y : Float
structure FrameUniforms where
time : Float
resWidth : Float
resHeight : Float
/-- The path parameter — a fixed value chosen per render. The shader
reads this uniform and binds its cubical-path dim variable to it;
see `shaderVarWithDim` below. Earlier the canvas-rs runtime
animated this from `u.time` via a sine sweep; that was removed
because a host-side `Float → Float` is not a transport. Default
`0.0` keeps existing construction sites (record literals and
`sorry`/`default`-derived values) well-typed. -/
pathParam : Float := 0.0
structure PixelColor where
r : Float
g : Float
b : Float
a : Float
/-- The semantic type of a shader: pure function from pixel + frame state to color. -/
def ShaderSemantic := PixelCoord → FrameUniforms → PixelColor
-- ── EML reference evaluator (pure Lean) ──────────────────────────────────────
-- Defines what the GPU *should* compute. The axioms below say it does.
/-- Look up the value of a free variable name in shader context. -/
def shaderVar (name : String) (p : PixelCoord) (u : FrameUniforms) : Float :=
match name with
| "px" => p.x
| "py" => p.y
| "u_time" => u.time
| "u_resWidth" => u.resWidth
| "u_resHeight" => u.resHeight
| _ => 0.0
/-- Dim-aware variable resolver. Routes the path's distinguished
`dimName` to `u.pathParam`; every other name passes through to
`shaderVar`. This is the semantic-side analogue of the GPU shader
referencing the dim variable via `u_pathParam` rather than a
GLSL-local computation.
Named so the rewrite `shaderVarWithDim d d p u = u.pathParam` is
`rfl` on the dim-hit case; the miss case delegates cleanly. -/
def shaderVarWithDim (dimName : String) (name : String)
(p : PixelCoord) (u : FrameUniforms) : Float :=
if name = dimName then u.pathParam
else shaderVar name p u
@[simp] theorem shaderVarWithDim_dim
(dimName : String) (p : PixelCoord) (u : FrameUniforms) :
shaderVarWithDim dimName dimName p u = u.pathParam := by
simp [shaderVarWithDim]
@[simp] theorem shaderVarWithDim_other
(dimName name : String) (p : PixelCoord) (u : FrameUniforms)
(h : name ≠ dimName) :
shaderVarWithDim dimName name p u = shaderVar name p u := by
simp [shaderVarWithDim, h]
/-- Evaluate an EML expression to a Float at a given pixel and frame state.
This is the reference semantics — the ground truth for what the shader computes. -/
def EMLExpr.evalAt (p : PixelCoord) (u : FrameUniforms) : EMLExpr → Float
| .one => 1.0
| .var name => shaderVar name p u
| .eml l r =>
let lv := l.evalAt p u
let rv := r.evalAt p u
-- exp(l) ln(r), clamping rv > 0 for real-valued rendering
Float.exp lv - Float.log (max rv 1e-9)
/-- Direct greyscale projection: write the EML value into all three
color channels.
This is the minimal-honest `Float → Color` mapping — it's the
identity injection of the Float-valued transport's image into
the framebuffer's three channels. No `cos`, no normalization,
no period-wrapping. Negative `v` displays as black after the
sealed-cell sRGB clamp at the display; `v > 1` displays as
white.
A previous version applied a cosine cycle
(`r = 0.5 + 0.5·cos(2π·v + φ)` etc.) here to make any value
visible, but the cycle has period 1 in `v` and so *aliased*
fibers that differ by 1 (e.g. `plotTransp.at0` vs
`plotTransp.at1` differ by exactly 1) into pixel-identical
output. That made it visually impossible to distinguish two
genuinely-distinct transports. The cycle is removed.
Greyscale is still not a "transport" in the cubical sense —
the framebuffer-clamp on overflow / underflow is a sealed-cell
behavior at the display, not a derived cell. Building a real
`Float ↔ Color` transport (cells-spec §8: color spaces as
`CType`, conversions as cells via `ua` of an equivalence) is
on the roadmap. -/
def EMLExpr.toColor (p : PixelCoord) (u : FrameUniforms) (expr : EMLExpr) : PixelColor :=
let v := expr.evalAt p u
{ r := v, g := v, b := v, a := 1.0 }
-- ── GPU axioms ────────────────────────────────────────────────────────────────
/-- Axiom G1: Every ShaderHandle carries a semantic — the function it computes. -/
axiom ShaderHandle.semantic : ShaderHandle → ShaderSemantic
/-- Axiom G2: An abstract EML-to-shader compiler. The C++ side is obliged to
implement something that matches this spec. -/
axiom compileEML : EMLExpr → ShaderHandle
/-- Axiom G3: `compileEML` is correct — the compiled shader's semantic
function agrees with the Lean reference evaluator `toColor`.
Scoped to the handle produced by `compileEML expr`; this avoids the
earlier unsound formulation `∀ expr h, h.semantic = expr.toColor`, which
forced `expr₁.toColor = expr₂.toColor` for any pair of exprs via a
shared `h`. -/
axiom compileEML_correct (expr : EMLExpr) :
(compileEML expr).semantic = expr.toColor
/-- Axiom G4: The render loop is faithful. For a compiled shader `h`
running under uniforms `u`, the pixel written at screen coord `p`
equals `h.semantic p u` (within IEEE 754 float tolerance — bit-
exact only on driver+arch combinations that expose it).
The axiom body is `True` because Lean cannot evaluate an IO action
in a pure proof. The **empirical discharge** happens in
`Topolei.Render.Probe` via `renderProbePixel` — a Rust FFI that
renders a shader offscreen into an `Rgba32Float` texture and reads
one pixel back. See `ProbeTest.lean` + the `probe-test` lake exe. -/
axiom render_faithful (ctx : GPUContext) (h : ShaderHandle) (u : FrameUniforms) :
True -- empirical discharge via Topolei.Render.Probe + probe-test exe
-- ── Bridge theorems (proved in Lean, assuming the axioms) ─────────────────────
/-- The compiled-shader semantic agrees with the Lean reference evaluator at
every pixel and frame, for the handle `compileEML` produced. -/
theorem compileEML_semantic_eq_toColor
(expr : EMLExpr) (p : PixelCoord) (u : FrameUniforms) :
(compileEML expr).semantic p u = expr.toColor p u := by
rw [compileEML_correct]
/-- Legacy hypothesis-carrying form: given a per-handle correctness witness,
`semantic = toColor` pointwise. Useful for handles produced by code
paths other than `compileEML` (e.g. externally provided shaders). -/
theorem compiled_semantic_eq_eval
(expr : EMLExpr) (h : ShaderHandle)
(hc : h.semantic = expr.toColor)
(p : PixelCoord) (u : FrameUniforms) :
h.semantic p u = expr.toColor p u := by
rw [hc]
-- ── Evaluator correctness (pure Lean proofs) ──────────────────────────────────
-- ── IEEE 754 Float-arithmetic obligations ────────────────────────────────────
-- Lean's `Float` is axiomatic; it has no DecidableEq matching propositional
-- equality, so `native_decide` cannot discharge even simple numeric identities.
-- The three axioms below are required for `evalAt_expOf` and are uncontroversial
-- IEEE 754 facts. They become verification obligations on the C++ runtime.
/-- `log 1 = 0` in IEEE 754 double precision. -/
axiom Float.log_one : Float.log 1.0 = 0.0
/-- `max 1.0 1e-9 = 1.0` (1.0 > 1e-9 in IEEE 754). -/
axiom Float.max_one_ge_eps : max (1.0 : Float) 1e-9 = 1.0
/-- IEEE 754 subtraction by zero is identity (for non-NaN operands;
the `evalAt` call site always feeds a finite `Float.exp` result,
which is non-NaN except at `Float.exp +∞`). -/
axiom Float.sub_zero (a : Float) : a - 0.0 = a
-- The earlier `PathDriver` / `sineSweep` / `canonicalDriver` /
-- `Float.sin_range` / `sineSweep_range01_of` block was removed
-- because no part of it was a transport in the cells-spec sense:
-- it described a host-side `Float → Float` function used to
-- animate `u.pathParam` from `u.time`, which is not derived from
-- any cubical path or type family. The `canvas-rs` runtime no
-- longer animates `pathParam`; each window renders one fiber of
-- the 1-cell at a fixed `pathParam` chosen by the caller.
--
-- Animated rendering — a continuous deformation of fibers over
-- time — is properly a 2-cell (a homotopy of 1-cells parameterised
-- by a second interval). When 2-cell infrastructure lands, the
-- spec for it goes here, derived from the cubical calculus, not as
-- a free-standing `Float → Float`.
/-- exp(x) = eml(x, 1): evaluates to Float.exp p.x.
Proof chain:
evalAt p u (eml (var "px") one)
= Float.exp p.x Float.log (max 1.0 1e-9) [unfold evalAt, shaderVar]
= Float.exp p.x Float.log 1.0 [Float.max_one_ge_eps]
= Float.exp p.x 0.0 [Float.log_one]
= Float.exp p.x [Float.sub_zero]. -/
theorem evalAt_expOf (p : PixelCoord) (u : FrameUniforms) :
EMLExpr.evalAt p u (EMLExpr.expOf (.var "px")) = Float.exp p.x := by
simp only [EMLExpr.expOf, EMLExpr.evalAt, shaderVar,
Float.max_one_ge_eps, Float.log_one, Float.sub_zero]
/-- The depth-1 EML tree is the exponential — H1 first concrete instance. -/
theorem h1_exp_instance (p : PixelCoord) (u : FrameUniforms)
(h : ShaderHandle)
(hc : h.semantic = fun p u => EMLExpr.toColor p u (EMLExpr.expOf (.var "px"))) :
h.semantic p u = EMLExpr.toColor p u (EMLExpr.expOf (.var "px")) := by
rw [hc]
-- ── EMLPathGPU bridge ────────────────────────────────────────────────────────
/-
An EMLPath with dimName = "t" represents a shader that varies along a
dimension variable t ∈ {0, 1}. The GPU evaluates it at the 1-end (t = 1.0).
The baseEnv here is (shaderVar · p u): the standard pixel+frame variable map.
This is the link between:
· EMLPath.at1 (cubical/EML: what the 1-end value should be)
· EMLExpr.evalAt (GPU/Spec: what the reference evaluator computes)
The condition "dimName variable in baseEnv" is handled by the override in
atBool; the rest of the variables use shaderVar as usual.
-/
/-- evalAt agrees with evalWithEnv when using shaderVar as the resolver. -/
theorem EMLExpr.evalAt_eq_evalWithEnv (p : PixelCoord) (u : FrameUniforms)
(e : EMLExpr) :
e.evalAt p u = e.evalWithEnv (fun name => shaderVar name p u) := by
induction e with
| one => rfl
| var name => simp [evalAt, evalWithEnv, shaderVar]
| eml l r ihl ihr =>
simp only [evalAt, evalWithEnv, ihl, ihr]
/-- An EMLPath evaluated at the 1-end (b = true) agrees with the standard
evaluator when the dimension variable is overridden to 1.0 in the env. -/
theorem EMLPath.at1_eq_evalAt_override
(path : EMLPath) (p : PixelCoord) (u : FrameUniforms) :
path.at1 (fun name => shaderVar name p u) =
path.body.evalWithEnv (fun name =>
if name = path.dimName then 1.0
else shaderVar name p u) :=
rfl
/-- If the dimension variable is not `px` and not in shaderVar's domain,
and the body of the path does not use the dim variable,
then EMLPath.at1 = EMLExpr.evalAt for the body. -/
theorem EMLPath.at1_of_absent_eq_evalAt
(path : EMLPath) (p : PixelCoord) (u : FrameUniforms)
(habs : path.body.varAbsent path.dimName = true) :
path.at1 (fun name => shaderVar name p u) =
path.body.evalAt p u := by
rw [EMLExpr.evalAt_eq_evalWithEnv]
apply EMLExpr.evalWithEnv_congr _ _ habs
intro n hn
simp [if_neg hn]
-- ── Rendering bridge: dim uniform overridden to 1.0 ⇒ shader = EMLPath.at1 ──
/-- Scalar bridge: when `shaderVar` already resolves the path's dim variable
to 1.0, the GPU evaluator on the path body equals `EMLPath.at1` computed
against the standard `shaderVar`-based env. Proof: rewrite `evalAt` via
`evalAt_eq_evalWithEnv`, then use `evalWithEnv_congr` on the two envs
which agree everywhere (the override matches the baseline at dimName). -/
theorem EMLPath.evalAt_body_eq_at1
(path : EMLPath) (p : PixelCoord) (u : FrameUniforms)
(hu : shaderVar path.dimName p u = 1.0) :
path.body.evalAt p u =
path.at1 (fun name => shaderVar name p u) := by
rw [EMLExpr.evalAt_eq_evalWithEnv, EMLPath.at1_eq_evalAt_override]
-- Both sides are evalWithEnv against envs that agree pointwise:
-- env1 n = shaderVar n p u
-- env2 n = if n = dimName then 1.0 else shaderVar n p u
-- At n = dimName: env1 = 1.0 (by hu), env2 = 1.0. Else: equal by construction.
congr 1
funext n
by_cases h : n = path.dimName
· subst h; simp [hu]
· simp [if_neg h]
/-- Color bridge: under the same dim-uniform assumption, `toColor` of the
path body equals `toColor` of the `EMLPath.at1` value. This is the
formal "rendering at t=1 equals EMLPath.at1" statement promised in
`EML/Path.lean`. -/
theorem EMLPath.toColor_body_eq_at1_toColor
(path : EMLPath) (p : PixelCoord) (u : FrameUniforms)
(hu : shaderVar path.dimName p u = 1.0) :
EMLExpr.toColor p u path.body =
let v := path.at1 (fun name => shaderVar name p u)
({ r := v, g := v, b := v, a := 1.0 } : PixelColor) := by
simp only [EMLExpr.toColor, EMLPath.evalAt_body_eq_at1 path p u hu]
/-- End-to-end rendering bridge: the compiled shader for `path.body`,
under a frame whose `shaderVar` resolves the path's dim variable to 1.0,
produces the `toColor` of `EMLPath.at1`.
This closes the cubical-to-pixel loop:
DimLine.at1 ↔ EMLPath.at1 ↔ compileEML(body) @ {dim uniform = 1.0}.
**Note (P3+P5 pass).** The hypothesis `shaderVar path.dimName p u = 1.0`
is *uninhabited* for path dims that aren't in `shaderVar`'s match
(e.g. "t") — `shaderVar` returns `0.0` as fallback, never `1.0`.
The theorem therefore says nothing about parametric shaders that run
in the `canvas-rs` runtime. The inhabited successor is
`render_eq_at_pathParam` below, built on `compileEMLPath` +
`shaderVarWithDim`. -/
theorem render_eq_at1
(path : EMLPath) (p : PixelCoord) (u : FrameUniforms)
(hu : shaderVar path.dimName p u = 1.0) :
(compileEML path.body).semantic p u =
let v := path.at1 (fun name => shaderVar name p u)
({ r := v, g := v, b := v, a := 1.0 } : PixelColor) := by
rw [compileEML_correct]
exact EMLPath.toColor_body_eq_at1_toColor path p u hu
-- ── Dim-aware shader semantic: inhabited rendering theorems (P3+P5) ─────────
/-
The semantic of a compiled *path* shader. Uses `shaderVarWithDim` so
the path's distinguished `dimName` is resolved via `u.pathParam` —
exactly how the `canvas-rs` runtime binds the uniform after its host-
side `PathDriver` computes the parameter from `u.time`.
-/
/-- Rendering color of an `EMLPath` at a pixel + frame. Uses
`shaderVarWithDim` so the path's dim name resolves to `u.pathParam`.
Greyscale projection: the EML body's Float value goes into all
three channels. See `EMLExpr.toColor` for the rationale (the
cosine cycle was removed because it aliased fibers differing by
integer multiples of 1 into pixel-identical output). -/
def EMLPath.toColor (path : EMLPath) (p : PixelCoord) (u : FrameUniforms) : PixelColor :=
let v := path.body.evalWithEnv (fun n => shaderVarWithDim path.dimName n p u)
{ r := v, g := v, b := v, a := 1.0 }
/-- **Axiom G2**: compile an `EMLPath` to a shader handle. The path's
`dimName` determines which scalar uniform the shader references for
the path parameter; the caller binds that uniform to a fixed
`pathParam` value (no host-side animation curve — see the audit
block at the top of this file). -/
axiom compileEMLPath : EMLPath → ShaderHandle
/-- **Axiom G3**: `compileEMLPath` is correct — the compiled shader's
semantic agrees with `EMLPath.toColor`. Like `compileEML_correct`
but dim-aware: the shader's semantic function uses `u.pathParam` in
place of `0.0` at `path.dimName`. -/
axiom compileEMLPath_correct (path : EMLPath) :
(compileEMLPath path).semantic = EMLPath.toColor path
/-- The compiled-path semantic equals `EMLPath.toColor path` pointwise —
the inhabited successor of `compileEML_semantic_eq_toColor` for
parametric shaders. -/
theorem compileEMLPath_semantic_eq_toColor
(path : EMLPath) (p : PixelCoord) (u : FrameUniforms) :
(compileEMLPath path).semantic p u = EMLPath.toColor path p u := by
rw [compileEMLPath_correct]
/-- **Inhabited rendering theorem.** The compiled path shader at
`(p, u)` equals the path body evaluated with the dim name resolved
to `u.pathParam` via `shaderVarWithDim`. No uninhabited hypotheses.
For a fixed-point instance: when `u.pathParam = 1.0`, the RHS
agrees with the earlier `render_eq_at1` form (modulo
`evalWithEnv_congr` between `shaderVarWithDim` and the ad-hoc env
used by `EMLPath.at1`). For a sweep: `u.pathParam` varies
continuously and the RHS traces the path's image. -/
theorem render_eq_at_pathParam
(path : EMLPath) (p : PixelCoord) (u : FrameUniforms) :
(compileEMLPath path).semantic p u = EMLPath.toColor path p u :=
compileEMLPath_semantic_eq_toColor path p u
-- The earlier `shaderVarWithDim_eq_driverEnv`, `EMLPath.toColor_of_driver`,
-- and `render_eq_at_driver` theorems were removed alongside the
-- `PathDriver` scaffolding above: they all said "if the host computes
-- pathParam by some `Float → Float` function, the rendering equals
-- the path body evaluated at that function's output". That's a
-- trivial restatement of `compileEMLPath_correct` once the function
-- is fixed — and the fixed function was a sine sweep, which is not a
-- transport. Use `render_eq_at_pathParam` directly (see above) for
-- the abstract form, and `EMLPath.toColor_body_eq_at1_toColor` for
-- the `pathParam = 1` reduction.

View file

@ -1,198 +0,0 @@
/-
Topolei.Obs.Ctx
===============
C2 of the categories-from-the-interface stack:
**the observation category for peripherals.**
Observations are typed peripheral configurations — they describe
*what surface the rendering lands on*, not the rendering itself.
Window dimensions, pixel format, eventually GPU adapter / mouse
position / clock tick. None of these are transports in the
cubical sense (cells-spec §1.7 calls them "sealed cells whose
interior cannot be deformed from above"); but they do organise
themselves into a category, and that category is what every
transport in the cell calculus eventually has to live over.
## What this file proves
We define `Ctx` as a record (the typed peripheral) and `resize` /
`reformat` as the basic ops. We prove the *laws* that say these
ops compose like morphisms in a category:
- **Identity**: `c.resize c.width c.height = c`,
`c.reformat c.pixelFmt = c`
- **Idempotence on overwrite**:
`(c.resize w₁ h₁).resize w₂ h₂ = c.resize w₂ h₂`
(the second resize wins; the first is forgotten)
- **Commutativity** of independent ops:
`(c.resize w h).reformat fmt = (c.reformat fmt).resize w h`
These laws *make `Ctx` a category* — objects are values of `Ctx`,
morphisms are equivalence classes of resize/reformat sequences,
and the laws above quotient the free monoid down to the right
thing. We don't materialise the category as a Lean structure
here because the laws-as-`@[simp]`-rewrites are enough for the
reasoning we'll need; if H4 (horizontal lifts of transports
along observation arrows) needs the explicit category, we add it
then.
## What this file does NOT do
- It does not define **the fibration** `p : Cells → Obs` that
the cells-spec promises. That's C5, derived from C2 + C3.
Once `Ctx` is in place, we lift `compileEMLPath` to take a
`Ctx` explicitly instead of having `width`/`height`/format
floating as bare arguments.
- It does not define **arrows as a typed inductive** (the
`Arrow : Ctx → Ctx → Type` form I sketched earlier). That
would force us to either prove laws *up to a quotient* or
use a higher-inductive type — overhead that buys nothing
new at this layer. Instead, we use the *equational
presentation*: ops + laws. When H4 needs to talk about
"an arrow `f : c → c'`" abstractly, we'll add the typed
inductive then.
- It does not yet bridge to `Topolei.Selection.Selection`. A
`WiredSelection` would pair `(c : Ctx) (s : Selection)` with
a compatibility predicate; deferred until we know which
compatibility actually matters for rendering.
## Reference
Cells-spec §1.5 ("Rendering Context as a Cell"), §15.2
("Presheaf of Potential Cells").
-/
namespace Topolei.Obs
-- ── Pixel format ──────────────────────────────────────────────────────────
--
-- The "type" of a framebuffer. Sealed-cell — its IEEE / sRGB /
-- linear-RGB semantics are determined by the GPU's hardware spec,
-- not by our calculus. We only carry it as a typed tag so
-- rendering pipelines can refuse to bind into a context whose
-- format they don't support.
inductive PixelFormat where
/-- 32-bit float per channel; no display clamp. Used by the
`render_faithful` probe so CPU/GPU agreement isn't
dominated by quantisation. -/
| rgbaF32 : PixelFormat
/-- 8-bit per channel sRGB. Display surface; values clamp
to [0, 1] and gamma-encode. Standard for live windows. -/
| rgbaSrgb : PixelFormat
deriving Repr, DecidableEq, Inhabited
-- ── Observation context ───────────────────────────────────────────────────
/-- An observation context: the typed peripheral configuration
a render lands on. Objects of the observation category. -/
structure Ctx where
width : Nat
height : Nat
pixelFmt : PixelFormat
deriving Repr, DecidableEq, Inhabited
namespace Ctx
-- ── Operations (morphisms in the implicit category) ──────────────────────
/-- Resize the observation surface. Width × height update. -/
def resize (c : Ctx) (w h : Nat) : Ctx :=
{ c with width := w, height := h }
/-- Change the pixel format. -/
def reformat (c : Ctx) (fmt : PixelFormat) : Ctx :=
{ c with pixelFmt := fmt }
-- ── Identity laws: ops with the current value are no-ops ─────────────────
/-- Resizing a context to its current dimensions is the identity. -/
@[simp] theorem resize_self (c : Ctx) :
c.resize c.width c.height = c := by
rcases c with ⟨_, _, _⟩
rfl
/-- Reformatting a context to its current format is the identity. -/
@[simp] theorem reformat_self (c : Ctx) :
c.reformat c.pixelFmt = c := by
rcases c with ⟨_, _, _⟩
rfl
-- ── Idempotence on overwrite: the last value wins ────────────────────────
/-- Composed resizes collapse: only the outer dimensions matter. -/
@[simp] theorem resize_resize (c : Ctx) (w₁ h₁ w₂ h₂ : Nat) :
(c.resize w₁ h₁).resize w₂ h₂ = c.resize w₂ h₂ := by
rcases c with ⟨_, _, _⟩
rfl
/-- Composed reformats collapse: only the outer format matters. -/
@[simp] theorem reformat_reformat (c : Ctx) (f₁ f₂ : PixelFormat) :
(c.reformat f₁).reformat f₂ = c.reformat f₂ := by
rcases c with ⟨_, _, _⟩
rfl
-- ── Commutativity of independent ops ─────────────────────────────────────
/-- Resize and reformat commute — they touch independent fields, so
order doesn't matter. -/
@[simp] theorem resize_reformat (c : Ctx) (w h : Nat) (fmt : PixelFormat) :
(c.resize w h).reformat fmt = (c.reformat fmt).resize w h := by
rcases c with ⟨_, _, _⟩
rfl
/-- Symmetric form: reformat-then-resize = resize-then-reformat. -/
theorem reformat_resize (c : Ctx) (fmt : PixelFormat) (w h : Nat) :
(c.reformat fmt).resize w h = (c.resize w h).reformat fmt :=
(Ctx.resize_reformat c w h fmt).symm
-- ── Read-back of the field updates ───────────────────────────────────────
-- These are field-update lemmas that downstream proofs will lean on.
@[simp] theorem resize_width (c : Ctx) (w h : Nat) :
(c.resize w h).width = w := by
rcases c with ⟨_, _, _⟩
rfl
@[simp] theorem resize_height (c : Ctx) (w h : Nat) :
(c.resize w h).height = h := by
rcases c with ⟨_, _, _⟩
rfl
@[simp] theorem resize_pixelFmt (c : Ctx) (w h : Nat) :
(c.resize w h).pixelFmt = c.pixelFmt := by
rcases c with ⟨_, _, _⟩
rfl
@[simp] theorem reformat_width (c : Ctx) (fmt : PixelFormat) :
(c.reformat fmt).width = c.width := by
rcases c with ⟨_, _, _⟩
rfl
@[simp] theorem reformat_height (c : Ctx) (fmt : PixelFormat) :
(c.reformat fmt).height = c.height := by
rcases c with ⟨_, _, _⟩
rfl
@[simp] theorem reformat_pixelFmt (c : Ctx) (fmt : PixelFormat) :
(c.reformat fmt).pixelFmt = fmt := by
rcases c with ⟨_, _, _⟩
rfl
end Ctx
-- ── Concrete demo (operational sanity check via `#eval`) ──────────────────
/-- Default rendering context: 800×600, sRGB. -/
def defaultCtx : Ctx :=
{ width := 800, height := 600, pixelFmt := PixelFormat.rgbaSrgb }
#eval defaultCtx.width -- expected: 800
#eval defaultCtx.resize 1024 768 |>.width -- expected: 1024
#eval (defaultCtx.resize 1024 768).reformat PixelFormat.rgbaF32 |>.pixelFmt
-- expected: PixelFormat.rgbaF32
#eval ((defaultCtx.resize 100 100).resize 200 200).width -- expected: 200
end Topolei.Obs

View file

@ -1,158 +0,0 @@
/-
Topolei.Render.Probe
====================
Empirical check for `render_faithful` — the axiom in `Topolei.GPU.Spec`
that claims the GPU's runtime output equals the compiled shader's
`ShaderSemantic` at every pixel and uniform setting.
The Rust FFI `topolei_canvas_render_probe_path_pixel` accepts a Lean
`EMLPath` inductive structurally, builds the fragment shader as a
`naga::Module` directly via `emit_naga::build_probe_module` (no GLSL
text intermediary), submits the module to wgpu's SPIR-V backend, and
reads back a single pixel from an `Rgba32Float` offscreen target.
This module binds that FFI and exercises it on the demo paths
`plotExp` / `plotLn` / `plotTransp`.
## Tolerance
IEEE 754 rounding differs slightly between the CPU (Lean-side
`ShaderSemantic` evaluation via `Float.exp`, `Float.log`, `Float.cos`)
and the GPU (wgpu → Vulkan → driver → shader unit). The comparison
therefore uses an absolute tolerance rather than bit-exact equality.
`5e-3` is generous; a bit-exact probe on known IEEE 754 drivers
(e.g. SwiftShader) might tighten it to 1e-6 or better.
## Running
The probe requires a Vulkan/Metal/DX12 adapter (or a software
rasterizer like SwiftShader) on the host. When no adapter is
available the FFI returns a sentinel `RGBA { r := -1, g := -1, ... }`
and the test skips that comparison rather than asserting failure.
Run with: `lake build probe-test && ./.lake/build/bin/probe-test`.
-/
import Topolei.EML
import Topolei.EML.Path
import Topolei.GPU.Spec
namespace TopoleiProbe
/-- Rendered-pixel RGBA as four Lean `Float` values. Layout matches
the `topolei_canvas_shim_io_ok_rgba` helper in `canvas-rs/shim.c`:
tag 0, 4 boxed-Float fields. -/
structure RGBA where
r : Float
g : Float
b : Float
a : Float
deriving Repr, Inhabited
/-- **Structured probe.** `renderProbePath path width height time
pathParam x y` — passes the `EMLPath` inductive *structurally* to
the Rust side, which walks the object tree
(`eml::emlpath_from_lean`), builds a `naga::Module` directly via
`emit_naga::build_probe_module`, and runs the offscreen probe.
No shader-source string crosses the FFI boundary. See
`NAGA_IR_PLAN.md` for the construction; `compileEMLPath_correct`
in `Topolei.GPU.Spec` is the contract the IR builder must satisfy. -/
@[extern "topolei_canvas_render_probe_path_pixel"]
opaque renderProbePath
(path : @& EMLPath)
(width height : UInt32)
(time pathParam : Float)
(x y : UInt32) : IO RGBA
/-- True when `renderProbePath` returned its GPU-unavailable sentinel.
A channel of `-1.0` is outside the valid RGBA range `[0, 1]`, so
tests can use this as a reliable skip signal. -/
def RGBA.isSentinel (c : RGBA) : Bool :=
c.r < 0.0 && c.g < 0.0 && c.b < 0.0 && c.a < 0.0
/-- Bridge to `GPU/Spec.lean`: the CPU-side `PixelColor` that the GPU
is expected to match. One-to-one translation. -/
def RGBA.ofPixelColor (c : PixelColor) : RGBA :=
{ r := c.r, g := c.g, b := c.b, a := c.a }
/-- Absolute-tolerance comparison for `Float`. Both arguments must
differ by no more than `tol`. -/
def floatClose (a b tol : Float) : Bool :=
let d := a - b
let ad := if d < 0.0 then -d else d
ad <= tol
/-- Compare two RGBA values channel-wise under `tol`. -/
def rgbaClose (expected actual : RGBA) (tol : Float) : Bool :=
floatClose expected.r actual.r tol &&
floatClose expected.g actual.g tol &&
floatClose expected.b actual.b tol &&
floatClose expected.a actual.a tol
/-- Convert framebuffer pixel `(x, y)` at resolution `(w, h)` into the
`uv ∈ [0, 1]²` coordinates the probe shader sees at that pixel's
fragment center. Framebuffer y is top-down (wgpu origin) while
the vertex shader's uv tracks NDC, so we flip: `uv.y = 1 -
(y+0.5)/h`. `uv.x = (x+0.5)/w`. The `+0.5` lands us on the pixel
center where the rasterizer samples. -/
def pixelUV (x y width height : UInt32) : PixelCoord :=
let w := width.toNat.toFloat
let h := height.toNat.toFloat
let px := (x.toNat.toFloat + 0.5) / w
let py := 1.0 - (y.toNat.toFloat + 0.5) / h
{ x := px, y := py }
/-- Run a single probe: pass the `EMLPath` directly to the Rust
offscreen probe; compare the GPU-returned pixel `(x, y)` against
Lean's `EMLPath.toColor path (pixelUV ...) u`. -/
def runProbe
(path : EMLPath) (label : String)
(width height : UInt32)
(time pathParam : Float)
(x y : UInt32)
(tol : Float := 5e-3) : IO Bool := do
let actual ← renderProbePath path width height time pathParam x y
if actual.isSentinel then
IO.println s!" ⏭ SKIP [{label}] at ({x}, {y}) — no GPU adapter"
return true
let p : PixelCoord := pixelUV x y width height
let u : FrameUniforms :=
{ time, resWidth := width.toNat.toFloat, resHeight := height.toNat.toFloat,
pathParam }
let expectedColor := EMLPath.toColor path p u
let expected := RGBA.ofPixelColor expectedColor
if rgbaClose expected actual tol then
IO.println s!" ✅ [{label}] pixel ({x}, {y}): GPU matches Lean (r={expected.r} g={expected.g} b={expected.b})"
return true
else
IO.println s!" ❌ [{label}] pixel ({x}, {y}) divergent"
IO.println s!" expected: r={expected.r} g={expected.g} b={expected.b} a={expected.a}"
IO.println s!" actual: r={actual.r} g={actual.g} b={actual.b} a={actual.a}"
return false
/-- Full probe battery. Returns number of failures.
Each entry asserts `EMLPath.toColor path p u ≈ GPU_pixel(p, u)`
within tolerance — i.e. the inhabited form of `render_faithful`
on the naga-IR pipeline. -/
def runProbes : IO UInt32 := do
IO.println "── Topolei render_faithful probes ──"
IO.println " Naga-IR pixel probes (EMLPath → naga::Module → SPIR-V → GPU):"
-- Constant path (dim variable absent from body): `pathParam` is
-- irrelevant and the two endpoints coincide.
let n1 ← runProbe plotExp.toEMLPath "plotExp" 128 128 0.0 0.0 64 64
let n2 ← runProbe plotExp.toEMLPath "plotExp" 128 128 0.0 1.0 64 64
let n3 ← runProbe plotLn.toEMLPath "plotLn" 128 128 0.0 0.5 32 64
-- Parametric path: `pathParam` changes the color — sample across
-- values to cover the sweep.
let n4 ← runProbe plotTransp.toEMLPath "plotTransp" 128 128 0.0 0.0 64 64
let n5 ← runProbe plotTransp.toEMLPath "plotTransp" 128 128 0.0 0.5 64 64
let n6 ← runProbe plotTransp.toEMLPath "plotTransp" 128 128 0.0 1.0 64 64
let n7 ← runProbe plotTransp.toEMLPath "plotTransp" 128 128 0.0 0.5 32 96
let mut fails : UInt32 := 0
for ok in [n1, n2, n3, n4, n5, n6, n7] do
if !ok then fails := fails + 1
IO.println s!"── {7 - fails.toNat} / 7 probes passed ──"
return fails
end TopoleiProbe

View file

@ -1,291 +0,0 @@
/-
Topolei.Selection
=================
Foundational selection abstraction — hypothesis stack H1 + H2.
## What's a hypothesis here
The user's design intent:
"Selection has to live in cell space as a cell abstraction.
We want a stack of options... navigate a selection path.
Observation returns results we use the observation on again.
The space [we navigate] must match the space we are in."
My natural-transformation reading: a Selection is a *focus inside
a cell-tree with history*. The tree structure is the cell space;
the focus is "where we currently are"; the history is "how we got
here". Mathematically: a **zipper** (Huet 1997).
## The two hypotheses this file commits to
**H1 — Selection is a focused sub-cell with breadcrumb history.**
A `Selection` carries a `focus : WCell` and a `crumbs : List Crumb`
trail. `read` returns the focus. Round-trip: `descend i` then
`ascend` is the identity (when descend succeeded), so the trail
faithfully encodes the path back to the root.
**H2 — Path application is associative.** Building selections by
composing `Move`s is associative: `applyPath s (p₁ ++ p₂) =
(applyPath s p₁) >>= (·.applyPath p₂)`. Identity move-list is
the unit. Together these say `(Selection, applyPath, [], ++)` is
a partial monoid action by the move-monoid.
## What this is NOT yet
- H3 (Boolean algebra ∩, , ¬ over `σ`-predicates) — selections as
sub-objects beyond focus;
- H4 (horizontal lifts) — selections that follow the connection on
the fibration;
- H5 (trace map) — inverse projection from rendered elements back
to source morphisms.
Each of those is a separate file once H1+H2 are stable.
## Why no Rust
This is structural reasoning about a Lean inductive. The
zipper's correctness is decided by Lean's kernel from the
definitions; nothing here needs to execute on a GPU. When
selections eventually drive the renderer, the existing
`compileEMLPath` pipeline consumes the *focus* of a selection
(a single cell) and runs unchanged.
-/
namespace Topolei.Selection
-- ── Workspace cell ─────────────────────────────────────────────────────────
--
-- A minimal labeled tree. Stand-in for the broader cell calculus —
-- when the cubical Cell type stabilises, concrete cells (EMLPath,
-- CTerm, …) project into this structure for selection purposes via
-- a `toWCell` function we'll add per-cell-type. The selection
-- algebra here doesn't care about the cell's interior, only its
-- tree shape.
inductive WCell where
| mk : String → List WCell → WCell
deriving Inhabited
namespace WCell
def data : WCell → String
| .mk d _ => d
def children : WCell → List WCell
| .mk _ c => c
@[simp] theorem mk_data (d : String) (cs : List WCell) :
(WCell.mk d cs).data = d := rfl
@[simp] theorem mk_children (d : String) (cs : List WCell) :
(WCell.mk d cs).children = cs := rfl
@[simp] theorem eta : ∀ c : WCell, WCell.mk c.data c.children = c
| .mk _ _ => rfl
end WCell
-- ── Crumb (one step of breadcrumb trail) ──────────────────────────────────
--
-- When we descend from a parent into its i-th child, we leave a
-- breadcrumb that records: the parent's data + the index we took +
-- the parent's full children list. Reconstructing the parent from
-- the (possibly modified) child = `set`-replacing the i-th slot
-- with the focus.
--
-- Storing the whole `parentChildren` list is more memory than
-- splitting into (left, right) but makes reconstruction equationally
-- clean and the round-trip proof a one-liner via `List.set_get?_eq`.
structure Crumb where
parentData : String
index : Nat
parentChildren : List WCell
deriving Inhabited
namespace Crumb
/-- Reconstruct the parent cell from a focused child + this breadcrumb. -/
def reconstruct (cr : Crumb) (child : WCell) : WCell :=
WCell.mk cr.parentData (cr.parentChildren.set cr.index child)
end Crumb
-- ── Selection ─────────────────────────────────────────────────────────────
/-- A Selection: a focused cell + a breadcrumb trail back to the root.
Trail's head is the immediate parent (most recent crumb); trail's
last element is the root's parent (none — i.e., focus is root —
when the trail is empty).
Invariant we will *never* state in the type but is true by
construction: `crumbs.head?.parentChildren[crumbs.head?.index]?` is
the position the focus was descended into. We don't carry this
invariant in the type because it makes manipulation awkward; the
`descend_ascend` theorem below proves it implicitly. -/
structure Selection where
focus : WCell
crumbs : List Crumb
deriving Inhabited
namespace Selection
/-- The trivial selection at the root of `c`: focus = c, empty trail. -/
def atRoot (c : WCell) : Selection := { focus := c, crumbs := [] }
/-- Read the currently-focused cell. -/
def read (s : Selection) : WCell := s.focus
-- ── H1.1: round-trip on `atRoot` ──────────────────────────────────────────
/-- Reading the at-root selection of `c` returns `c`. This is the
most basic round-trip: the trivial selection of a cell faithfully
represents the cell. -/
@[simp] theorem atRoot_read (c : WCell) : (atRoot c).read = c := rfl
-- ── Navigation: descend / ascend ──────────────────────────────────────────
/-- Descend into the i-th child of the focus. Returns `none` if `i`
is out of range — the user can then handle the failure
however the calling layer prefers. -/
def descend (s : Selection) (i : Nat) : Option Selection :=
match s.focus.children[i]? with
| none => none
| some child =>
some { focus := child
crumbs := { parentData := s.focus.data
index := i
parentChildren := s.focus.children } :: s.crumbs }
/-- Ascend back to the parent. Returns `none` if the focus IS the
root (empty crumbs). -/
def ascend (s : Selection) : Option Selection :=
match s.crumbs with
| [] => none
| cr :: rest => some { focus := cr.reconstruct s.focus, crumbs := rest }
-- ── H1.2: descend-then-ascend = identity ──────────────────────────────────
/-- The key list-set lemma we need for `descend_ascend`: if `l[i]? =
some x`, then `l.set i x = l`. Replacing an element at a position
with the same element it already had is a no-op. Proved by
induction; standalone because the exact name in the Lean stdlib
has churned across versions. -/
private theorem List.set_self_of_getElem? {α : Type _}
: ∀ {l : List α} {i : Nat} {x : α}, l[i]? = some x → l.set i x = l
| [], _, _, h => by simp at h
| _ :: _, 0, _, h => by simp at h; subst h; rfl
| _ :: tl, i+1, _, h => by
simp [List.set]
exact List.set_self_of_getElem? (l := tl) (by simpa using h)
/-- **H1.2 — descend-then-ascend round-trip.** If descending into
child `i` succeeded, ascending from the result returns the
original selection.
The proof: `descend` produces a selection whose focus is the
i-th child and whose top crumb stores the parent's children
list. `ascend` reconstructs the parent by `set`-replacing
position `i` with the focus. Since the focus IS the i-th
child (it's what we descended into), `set i child` is a no-op
on `parentChildren`, giving back the original parent. -/
theorem descend_ascend (s : Selection) (i : Nat) (s' : Selection)
(h : s.descend i = some s') : s'.ascend = some s := by
-- Unpack `descend` to extract the child + the structure of s'.
unfold descend at h
match hChild : s.focus.children[i]? with
| none =>
rw [hChild] at h
contradiction
| some child =>
rw [hChild] at h
-- Now h : some {focus := child, crumbs := newCrumb :: s.crumbs} = some s'
injection h with h'
subst h'
-- Goal: ascend (the_descended_selection) = some s
simp only [ascend, Crumb.reconstruct]
-- The goal reduces to:
-- { focus := WCell.mk s.focus.data (s.focus.children.set i child),
-- crumbs := s.crumbs } = s
-- which follows from `set i child = s.focus.children` (since
-- `child = s.focus.children[i]`) plus WCell.eta on s.focus.
rw [List.set_self_of_getElem? hChild]
simp [WCell.eta]
-- ── Composition: Move + Path + applyPath ──────────────────────────────────
/-- A single navigation step. -/
inductive Move where
| descend : Nat → Move
| ascend : Move
deriving Repr, Inhabited
/-- Apply a single move. `descend i` may fail if `i` is out of
range; `ascend` may fail if focus is root. -/
def applyMove (s : Selection) : Move → Option Selection
| .descend i => s.descend i
| .ascend => s.ascend
/-- Apply a sequence of moves left-to-right. Threads `Option`
through the fold — any failed move aborts the whole path. -/
def applyPath : Selection → List Move → Option Selection
| s, [] => some s
| s, m :: ms => (applyMove s m).bind (·.applyPath ms)
-- ── H2.1: identity ────────────────────────────────────────────────────────
/-- The empty path is the identity. -/
@[simp] theorem applyPath_nil (s : Selection) : applyPath s [] = some s := rfl
-- ── H2.2: associativity ───────────────────────────────────────────────────
/-- **H2 — applying a concatenated path = applying the parts in
order.** This is the partial-monoid associativity for the
selection action. The proof is induction on the first list,
pushing the bind through. -/
theorem applyPath_append (s : Selection) (p₁ p₂ : List Move) :
applyPath s (p₁ ++ p₂) = (applyPath s p₁).bind (·.applyPath p₂) := by
induction p₁ generalizing s with
| nil => simp [applyPath]
| cons m ms ih =>
simp only [List.cons_append, applyPath]
cases applyMove s m with
| none => simp
| some s' => simp [ih]
-- ── Concrete demo (operational sanity check via `#eval`) ──────────────────
/-- A small example tree:
root [ inner [ leaf-A, leaf-B ], leaf-C ]
Used by `#eval`s below. We use `#eval`-style introspection
rather than `decide`-backed example proofs because the
Decidable instance for `Selection` doesn't reduce in the
elaborator (it's defined on a recursive `WCell.mk` and the
elaborator gets stuck on `sorry`-mocking unwound recursion).
The abstract theorems above are what actually verify the
abstraction; these `#eval`s just let a human eyeball that
operations behave as expected. -/
def demoTree : WCell :=
WCell.mk "root"
[ WCell.mk "inner"
[ WCell.mk "leaf-A" [], WCell.mk "leaf-B" [] ]
, WCell.mk "leaf-C" []
]
/-- Descend twice into the "inner" child then "leaf-A". The focused
cell's data should be `"leaf-A"`. -/
def demoFocus : Option String :=
((applyPath (atRoot demoTree) [.descend 0, .descend 0]).map (·.read.data))
#eval demoFocus -- expected: some "leaf-A"
/-- Round-trip check: descend into the inner cell, ascend, focus's
data should be `"root"` (the original root). -/
def demoRoundTrip : Option String :=
(((atRoot demoTree).descend 0).bind (·.ascend)).map (·.read.data)
#eval demoRoundTrip -- expected: some "root"
end Selection
end Topolei.Selection

View file

@ -1,258 +0,0 @@
/-
Topolei.Subobject
=================
H3 of the selection-foundation stack.
## What this file is
A `Subobject` of `WCell` is a *characteristic function*:
`σ : WCell → Bool`, identifying which cells are "in" the
subobject. Subobjects form a **Boolean algebra** under ∩, , ¬
with units , ⊥ — every law follows from `Bool`'s algebra by
pointwise reasoning, so the file is mostly `funext + simp + Bool`.
## Why this layer
H1+H2 gave us *focused* selections — one cell, with a path
back to the root. H3 gives us *scoped* selections — a
*family* of candidate cells, with a focus picked from inside.
The Boolean algebra then lets us:
- intersect two scopes (∩) — "things in both selections";
- union two scopes () — "things in either";
- complement (¬) — "things outside this selection".
These operations are what peripheral observations need in
order to *combine* or *refine* the cells they're looking at,
without descending to ad-hoc selection tools per peripheral
type (the VFX problem the user named: "their transports are
forgetful, so they have many selector tools"). Here, ONE
abstraction (Subobject + Boolean algebra) covers all of it.
## What this file does NOT contain
- The *focused-subobject* combination (Selection × Subobject).
That goes in `Topolei.Selection` once H1+H2's focus type
is extended with a scope field.
- The *action* of cell-endomorphisms on Subobjects. That's
`Subobject.preimage` below — actually, it IS in this file.
The thing NOT here is the action on `Selection` (which
requires the focused-subobject layer to be in place).
- **Heyting / intuitionistic refinement** to `WCell → Type` for
proof-relevant subobjects (where membership tracks *why* a
cell is in scope). That's a future H7 — when H5 (the trace
map) needs provenance, we lift `σ`'s codomain from `Bool` to
`Type`. For now, classical Boolean is enough.
## Reference
- Cells-spec §15.4 ("Lawvere-Tierney topology") — Subobjects
are exactly the level-0 instance of the LT-topology hierarchy
the cells-spec uses for accessibility / security.
- Cells-spec §1.5 ("Rendering Context as a Cell") — peripheral
observations as fiber selectors.
-/
namespace Topolei.Subobject
-- We don't import `Topolei.Selection` to keep this file independent;
-- both `Subobject` and `Selection` are foundational, neither
-- depends on the other. The bridge between them lives in a
-- third file.
private inductive WCell where
| mk : String → List WCell → WCell
namespace WCell
def data : WCell → String | .mk d _ => d
def children : WCell → List WCell | .mk _ c => c
end WCell
-- ── The Subobject type ────────────────────────────────────────────────────
/-- A subobject of `WCell`: a Boolean-valued characteristic function
saying which cells are "in" the subobject. Equivalence between
subobjects is pointwise (function-extensional). -/
structure Subobject where
σ : WCell → Bool
deriving Inhabited
namespace Subobject
/-- Two subobjects are equal iff their characteristic functions
are pointwise equal. Function extensionality is `funext`. -/
@[ext] theorem ext {a b : Subobject} (h : ∀ c, a.σ c = b.σ c) : a = b := by
cases a; cases b
congr 1
funext c
exact h c
-- ── Constants: (everywhere) and ⊥ (nowhere) ────────────────────────────
/-- The total subobject — every cell is in it. -/
def top : Subobject := { σ := fun _ => true }
/-- The empty subobject — no cell is in it. -/
def bot : Subobject := { σ := fun _ => false }
-- ── Pointwise operations ─────────────────────────────────────────────────
/-- Intersection (AND of characteristic functions). -/
def inter (a b : Subobject) : Subobject := { σ := fun c => a.σ c && b.σ c }
/-- Union (OR of characteristic functions). -/
def union (a b : Subobject) : Subobject := { σ := fun c => a.σ c || b.σ c }
/-- Complement (NOT of characteristic function). -/
def compl (a : Subobject) : Subobject := { σ := fun c => !(a.σ c) }
-- ── Boolean algebra laws (every one follows from `Bool` algebra) ──────────
-- The pattern: `ext c; simp [Subobject.inter, Subobject.union, Subobject.compl,
-- Subobject.top, Subobject.bot, Bool.<lemma>]`.
-- ── Commutativity ─────────────────────────────────────────────────────────
@[simp] theorem inter_comm (a b : Subobject) : a.inter b = b.inter a := by
ext c; simp [inter, Bool.and_comm]
@[simp] theorem union_comm (a b : Subobject) : a.union b = b.union a := by
ext c; simp [union, Bool.or_comm]
-- ── Associativity ─────────────────────────────────────────────────────────
theorem inter_assoc (a b c : Subobject) :
(a.inter b).inter c = a.inter (b.inter c) := by
ext x; simp [inter, Bool.and_assoc]
theorem union_assoc (a b c : Subobject) :
(a.union b).union c = a.union (b.union c) := by
ext x; simp [union, Bool.or_assoc]
-- ── Idempotence ───────────────────────────────────────────────────────────
@[simp] theorem inter_self (a : Subobject) : a.inter a = a := by
ext c; simp [inter]
@[simp] theorem union_self (a : Subobject) : a.union a = a := by
ext c; simp [union]
-- ── Identity laws (top is unit of ∩, bot is unit of ) ───────────────────
@[simp] theorem inter_top (a : Subobject) : a.inter top = a := by
ext c; simp [inter, top]
@[simp] theorem top_inter (a : Subobject) : top.inter a = a := by
ext c; simp [inter, top]
@[simp] theorem union_bot (a : Subobject) : a.union bot = a := by
ext c; simp [union, bot]
@[simp] theorem bot_union (a : Subobject) : bot.union a = a := by
ext c; simp [union, bot]
-- ── Annihilation (top is absorber of , bot of ∩) ────────────────────────
@[simp] theorem inter_bot (a : Subobject) : a.inter bot = bot := by
ext c; simp [inter, bot]
@[simp] theorem bot_inter (a : Subobject) : bot.inter a = bot := by
ext c; simp [inter, bot]
@[simp] theorem union_top (a : Subobject) : a.union top = top := by
ext c; simp [union, top]
@[simp] theorem top_union (a : Subobject) : top.union a = top := by
ext c; simp [union, top]
-- ── Distributivity ───────────────────────────────────────────────────────
theorem inter_distrib_union (a b c : Subobject) :
a.inter (b.union c) = (a.inter b).union (a.inter c) := by
ext x; simp [inter, union, Bool.and_or_distrib_left]
theorem union_distrib_inter (a b c : Subobject) :
a.union (b.inter c) = (a.union b).inter (a.union c) := by
ext x; simp [union, inter, Bool.or_and_distrib_left]
-- ── Complement laws ──────────────────────────────────────────────────────
@[simp] theorem compl_compl (a : Subobject) : a.compl.compl = a := by
ext c; simp [compl]
@[simp] theorem inter_compl_self (a : Subobject) : a.inter a.compl = bot := by
ext c; simp [inter, compl, bot]
@[simp] theorem union_compl_self (a : Subobject) : a.union a.compl = top := by
ext c; simp [union, compl, top]
@[simp] theorem compl_top : (top : Subobject).compl = bot := by
ext c; simp [compl, top, bot]
@[simp] theorem compl_bot : (bot : Subobject).compl = top := by
ext c; simp [compl, top, bot]
-- ── De Morgan ────────────────────────────────────────────────────────────
theorem compl_inter (a b : Subobject) :
(a.inter b).compl = a.compl.union b.compl := by
ext c; simp [inter, union, compl, Bool.not_and]
theorem compl_union (a b : Subobject) :
(a.union b).compl = a.compl.inter b.compl := by
ext c; simp [union, inter, compl, Bool.not_or]
-- ── Absorption ───────────────────────────────────────────────────────────
@[simp] theorem inter_union_self (a b : Subobject) :
a.inter (a.union b) = a := by
ext c; cases h : a.σ c <;> simp [inter, union, h]
@[simp] theorem union_inter_self (a b : Subobject) :
a.union (a.inter b) = a := by
ext c; cases h : a.σ c <;> simp [inter, union, h]
-- ── Bridge to construction: scope-preserving endomorphisms ───────────────
--
-- A peripheral observation produces a `Subobject` (the cells the
-- user is "looking at"). A constructor — i.e. an endomorphism of
-- WCell — should respect the scope: cells inside the selection
-- map to cells inside the selection. This is the type-level
-- guarantee that "applying a tool to a selection produces a new
-- valid selection".
--
-- Note: this is a *property* of an endomorphism, not a structure
-- on Subobjects. The action on Selections — `applyEndo` — uses
-- this property as a precondition; it lives in
-- `Topolei.Selection.Scoped` (next module to land).
/-- `f` preserves the subobject `a`: every cell in `a` maps to a
cell in `a`. This is the scope-preservation precondition
for actions on focused-subobject selections. -/
def Preserves (a : Subobject) (f : WCell → WCell) : Prop :=
∀ c, a.σ c = true → a.σ (f c) = true
/-- The identity always preserves any subobject. -/
theorem Preserves.id (a : Subobject) : Preserves a (fun c => c) := fun _ h => h
/-- Composition of preserving endomorphisms is preserving. -/
theorem Preserves.comp {a : Subobject} {f g : WCell → WCell}
(hf : Preserves a f) (hg : Preserves a g) :
Preserves a (fun c => f (g c)) := fun c hc => hf (g c) (hg c hc)
end Subobject
-- ── Operational sanity check ──────────────────────────────────────────────
/-- A demo subobject: cells whose data starts with `"leaf"`. -/
def demoLeaves : Subobject := { σ := fun c => c.data.startsWith "leaf" }
#eval demoLeaves.σ (WCell.mk "leaf-A" []) -- expected: true
#eval demoLeaves.σ (WCell.mk "root" []) -- expected: false
#eval (demoLeaves.inter demoLeaves).σ (WCell.mk "leaf-A" []) -- expected: true
#eval (demoLeaves.compl).σ (WCell.mk "root" []) -- expected: true
#eval (Subobject.top : Subobject).σ (WCell.mk "anything" []) -- expected: true
#eval (Subobject.bot : Subobject).σ (WCell.mk "anything" []) -- expected: false
end Topolei.Subobject

View file

@ -1,188 +0,0 @@
/-
Topolei.Trace
=============
H5 of the foundation stack: the **trace map** (inverse projection),
in its polymorphic form.
## Polymorphic from day 1
A `Trace α` is a list of contributing items of type `α`. The
parameter `α` is what the trace is *about*:
- `Trace WCell` — workspace-tree-level traces (rendered → workspace cells)
- `Trace CTerm` — cubical-syntax-level traces (rendered → CTerm sub-terms)
- `Trace SourceLoc` — file/line provenance traces
- `Trace HandleId` — GPU resource provenance
All four are the same algebraic structure (a free monoid on `α`) with
the same theorems. Polymorphism captures the "consolidate
abstractions" principle: ONE Trace type, instantiated wherever
needed.
## Why this layer
H1+H2 (Selection) gave us *focus + history* — pointing at one cell
with a path back to the root. H3 (Subobject) gave us the *algebra
of scopes*. C2 (Obs.Ctx) gave us the typed peripheral category.
H5 closes the loop: every rendered element carries a typed pointer
back to the source items that contributed to producing it. This is
the **inverse projection**: given an output (a pixel, region, curve
on the screen), recover the items whose values projected to it.
## Geometry, sheaves, bundles, differential structure: ALL derived
Differential geometry is NOT a separate framework bolted onto cells.
It is a *property of trace coherence* — particular shapes of how
`Trace`s relate across the rendered space:
- Trace varies smoothly across adjacent rendered elements →
differential structure (Jacobian / connection / parallel transport).
- Trace shares open sets in coherent overlaps → sheaf structure.
- Multiple sources project to one rendered point → fiber bundle's
preimage at that point.
The same `Trace α` carries all three; the geometry is in the
*predicates* / *projections* over traces, not in the type itself.
## What this file does NOT contain
- **The cubical-trace function** `CTerm → Trace CTerm`. That's a
sibling file `Topolei.Cubical.Trace`, which uses *this* `Trace`.
- **DecidableEq-dependent operations** (diff, intersect). Add when
needed; they require `DecidableEq α`.
## Reference
Cells-spec §1.5 ("Rendering Context as a Cell"), §17.1
("Vulnerabilities as Topological Failures": side channels are
unwanted traces — same abstraction, security layer).
-/
namespace Topolei.Trace
-- ── The Trace structure ───────────────────────────────────────────────────
/-- A trace: the typed list of items that contributed to producing a
rendered element. Polymorphic in the item type — instantiate
with `WCell` for workspace-tree traces, `CTerm` for cubical-
syntax traces, etc. Plural because at singularities, multiple
sources project to one rendered point — and even at non-singular
points the trace can include intermediate items (the sub-things
visited en route to the final output).
The list order records *contribution order*: the first item is
the deepest contributor, the last is the most recently seen. This
order matters when stacking traces during rendering. -/
structure Trace (α : Type) where
items : List α
deriving Repr, Inhabited
namespace Trace
/-- The empty trace — no items contributed. Identity element for
`union`, witness that some rendered element has no source-item
provenance (e.g., a clear-color background pixel). -/
def empty {α : Type} : Trace α := { items := [] }
/-- The single-item trace — exactly one source-item contributed.
Used when lifting a primitive into a traced render. -/
def single {α : Type} (a : α) : Trace α := { items := [a] }
/-- Combine two traces by concatenating their item lists. Order is
preserved: the second trace's items append after the first's.
This is *not* deduplicating — if an item appears in both traces
we keep both occurrences (the list is multi-set-like).
Deduplication is a separate operation that requires `DecidableEq`. -/
def union {α : Type} (t₁ t₂ : Trace α) : Trace α :=
{ items := t₁.items ++ t₂.items }
-- ── Monoid laws (empty is unit, union is associative) ────────────────────
@[simp] theorem empty_union {α : Type} (t : Trace α) :
Trace.empty.union t = t := by
cases t; simp [union, empty]
@[simp] theorem union_empty {α : Type} (t : Trace α) :
t.union Trace.empty = t := by
cases t; simp [union, empty]
theorem union_assoc {α : Type} (t₁ t₂ t₃ : Trace α) :
(t₁.union t₂).union t₃ = t₁.union (t₂.union t₃) := by
cases t₁; cases t₂; cases t₃
simp [union, List.append_assoc]
end Trace
-- ── TracedRender: rendered element + its trace ────────────────────────────
/-- A `TracedRender R α` is a rendered element of type `R` paired
with its `Trace α`. Every rendered output carries its provenance.
`R` is the rendered-element type (e.g. `PixelColor`, a region
label, a frame). `α` is the trace-item type (e.g. `WCell`,
`CTerm`, …). Polymorphism lets the same abstraction work for
every rendering granularity and every provenance shape. -/
structure TracedRender (R α : Type) where
render : R
trace : Trace α
deriving Repr, Inhabited
namespace TracedRender
/-- Lift a rendered value with a single-item trace. -/
def lift {R α : Type} (r : R) (a : α) : TracedRender R α :=
{ render := r, trace := Trace.single a }
/-- Lift a rendered value with no trace — for outputs without source-
item provenance (clear color, vertex-stage outputs, sealed-cell
rasterizer products). -/
def liftEmpty {R α : Type} (r : R) : TracedRender R α :=
{ render := r, trace := Trace.empty }
/-- Combine two traced renders by combining their traces (left-biased
on the rendered value). -/
def merge {R α : Type} (a b : TracedRender R α) : TracedRender R α :=
{ render := a.render, trace := a.trace.union b.trace }
@[simp] theorem merge_liftEmpty {R α : Type} (a : TracedRender R α) (r' : R) :
a.merge (liftEmpty r') = a := by
cases a; simp [merge, liftEmpty, Trace.union_empty]
@[simp] theorem liftEmpty_merge {R α : Type} (r : R) (b : TracedRender R α) :
(liftEmpty r).merge b = { render := r, trace := b.trace } := by
simp [merge, liftEmpty, Trace.empty_union]
end TracedRender
-- ── Operational sanity (polymorphic on `String` items as the simplest demo) ─
/-- Demo trace over `String` items. -/
def demoTrace : Trace String :=
(Trace.single "alpha").union (Trace.single "beta")
#eval demoTrace.items.length -- expected: 2
#eval demoTrace.items -- expected: ["alpha", "beta"]
/-- Empty-union round-trip. -/
example : (Trace.empty : Trace String).union demoTrace = demoTrace :=
Trace.empty_union _
/-- Union associativity exercised on three traces. -/
example :
let t₁ := (Trace.single "a" : Trace String)
let t₂ := Trace.single "b"
let t₃ := Trace.empty
(t₁.union t₂).union t₃ = t₁.union (t₂.union t₃) :=
Trace.union_assoc _ _ _
/-- `TracedRender` lift carries the trace it's given. -/
example : (TracedRender.lift (5 : Nat) "src").trace = Trace.single "src" := rfl
/-- Merging into a no-trace render leaves the trace alone. -/
example :
let a : TracedRender Nat String := TracedRender.lift 5 "src"
a.merge (TracedRender.liftEmpty 7) = a :=
TracedRender.merge_liftEmpty _ _
end Topolei.Trace

View file

@ -1,343 +0,0 @@
# Zigzag Engine — Lean Port Plan
*Parallel to `TRANSPORT_PLAN.md` (which guided Phase 1's cubical
formalisation). This document plans the step-by-step port of the
zigzag engine from its Rust reference implementation into Lean 4,
as the combinatorial n-category backend for topolei's cell layer.*
---
## Decision (2026-04-22)
**The zigzag engine will be reimplemented in Lean 4.** The existing
Rust implementation at `zigzag-engine/zigzag-engine/` is **reference
material only** — a structural template for the port, not a
dependency. This matches topolei's Lean-as-host discipline and
maximises the project's medium-term goal of Lean-native reasoning
(see below).
**The only Rust component in topolei is the cubical evaluator FFI
backend** — the module that discharges the Phase 1 axioms (`step`,
`eval`, `vApp`, `vPApp`, `vTransp`, etc.) via `@[extern]` +
`@[implemented_by]`. That one Rust crate exists to extend Lean 4 with
computational cubical-transport HoTT; it is topolei's reason for any
FFI whatsoever. Everything else lives in Lean.
## Why Lean, not the Rust backend (Option A over Option B)
Axiomatic Rust backends give `axiom normalise_idempotent : ...`
statements we can *use* but cannot *prove*. Porting to Lean makes
each such statement a **theorem** the kernel checks. The project's
medium-term goal is to maximise what can be reasoned about in Lean;
that forecloses FFI-backed hiding of mathematical content.
The Rust implementation was itself AI-assisted and is not
hand-polished artefact we are throwing away; it is a
test-oracle-quality scaffold that the Lean port can match against.
## Reference materials
- `zigzag-engine/papers/zigzag-normalisation-2205.08952.pdf`
Heidemann-Reutter-Vicary, LICS 2022. The algorithm (Construction 17)
and correctness (Proposition 19).
- `zigzag-engine/papers/layout-algorithm-2305.06938.pdf`
Tataru-Vicary, 2024. Explosion / k-points / layout.
- `zigzag-engine/papers/homotopy-io-2402.13179.pdf`
Corbyn et al., FSCD 2024. The parent proof assistant.
- `zigzag-engine/zigzag-engine/src/*.rs` — reference Rust
implementation (11,003 lines across 13 modules).
- `zigzag-engine/zigzag-engine-spec/zigzag-engine-spec.md` — original
spec for the reference implementation.
## Port destination
All Lean modules land under `Topolei/Zigzag/`:
| Lean module | Rust reference | Approx. size |
|-------------|----------------|--------------|
| `Zigzag/Monotone.lean` | `src/monotone.rs` (325 LOC) | ~150 LOC + proofs |
| `Zigzag/Core.lean` | `src/zigzag.rs` (291 LOC) | ~150 LOC |
| `Zigzag/Diagram.lean` | `src/diagram.rs` (1484 LOC) | ~600 LOC |
| `Zigzag/Signature.lean` | `src/signature.rs` (200 LOC) | ~100 LOC |
| `Zigzag/Degeneracy.lean` | `src/degeneracy.rs` (1284 LOC) | ~500 LOC + proofs |
| `Zigzag/Normalise.lean` | `src/normalise.rs` (849 LOC) | ~400 LOC + proofs |
| `Zigzag/Typecheck.lean` | `src/typecheck.rs` (597 LOC) | ~250 LOC |
| `Zigzag/Explosion.lean` | `src/explosion.rs` (1414 LOC) | ~500 LOC |
| `Zigzag/Tests.lean` | `tests/` + `examples/` | ~200 LOC `#eval` regressions |
**Intentionally not ported:**
- `src/import.rs` (1491 LOC) — homotopy.io interop, not needed.
- `src/discover.rs` (1981 LOC) — search over diagrams; decide later.
- `src/python.rs` (716 LOC) — Python bindings, not needed.
- `src/layout.rs` (320 LOC) — geometric layout; deferred to Phase 4
Interaction, may be a Lean module or may defer to a Rust
`@[implemented_by]` optimisation later.
Core port size: roughly **2,5003,000 Lean lines** to match the
algorithmic core of the Rust implementation, with proofs adding
perhaps another 1,0002,000 depending on how far the correctness
theorems are pursued (Step 9 below).
---
## Steps
### Step 1 — `Zigzag/Monotone.lean` (foundation)
**Content**:
- `MonotoneMap (n m : Nat)` structure with `entries : List (Fin m)`
and `is_monotone` proof.
- Composition, identity, face maps `dᵢ`.
- Wraith's R equivalence `Δ₊ → Δ₌ᵒᵖ` as a pure function.
- Preimage computation.
**Proofs**:
- `MonotoneMap.compose_assoc`
- `MonotoneMap.wraith_r_involution` (R² = id on the nose)
- `MonotoneMap.face_map_image` — face maps omit exactly one element.
**Deliverable test**: `#eval` the `inspect_half_braid` example's
monotone substructure; compare to the Rust engine's output on the
same input.
---
### Step 2 — `Zigzag/Core.lean` (zigzags themselves)
**Content**:
- `Zigzag (T : Type) : Type` — `{ regular : Vec T, singular : Vec T,
forward : Vec Morphism, backward : Vec Morphism }`.
- `ZigzagMap` — singular map `fˢ : n → m` in `Δ₊` with regular/singular
slices and the commutativity conditions as `Prop`-valued fields.
- Composition of zigzag maps.
**Proofs**:
- `ZigzagMap.compose_respects_commutativity` — composition preserves
the commutativity predicates.
- `Zigzag.identity_is_length_zero` — the identity zigzag is trivially
a zero-length zigzag.
---
### Step 3 — `Zigzag/Diagram.lean` (the main data structure)
**Content**:
- Mutual inductive `Diagram` / `DiagramN` / `Cospan` / `Rewrite` /
`Cone`. Same shape as Rust's `pub enum Diagram { Diagram0(Generator)
| DiagramN(DiagramN) }`.
- Smart constructors: `Diagram.identity`, `Diagram.attach`,
`Diagram.compose`.
- Dimension predicate `Diagram.dimension : Diagram → Nat`.
- Source/target extractors.
- Regular-slice / singular-slice computation (mirrors
`DiagramN.regular_slice` in the Rust).
**Proofs**:
- `Diagram.dimension_of_attach` — attaching a generator of dimension `k`
produces a diagram of dimension `k`.
- `Diagram.source_source` / `Diagram.target_target` boundary
consistency.
- Globularity predicate + decidability.
---
### Step 4 — `Zigzag/Signature.lean`
**Content**:
- `Generator` structure: id, dimension, invertibility.
- `GeneratorData` with source / target diagrams.
- `Signature` as a list/hashmap of `GeneratorData`.
- Well-formedness: every `GeneratorData`'s source / target dimension
= `generator.dimension - 1`.
**Proofs**:
- `Signature.well_formed` is decidable.
---
### Step 5 — `Zigzag/Degeneracy.lean`
**Content**:
- Predicates: `IsSimpleDegeneracy`, `IsParallelDegeneracy`,
`IsDegeneracy` (closure under composition of the first two).
- Constructors: `Degeneracy.insert_identity_cospan` (the basic simple
degeneracy).
- Factorisation: every degeneracy factors as simple ∘ parallel
(Lemma 7 from the paper).
**Proofs**:
- `Degeneracy.isomorphisms_are_degeneracies` (Lemma 6).
- `Degeneracy.factorisation_unique_up_to_iso` (Lemma 7).
- `Degeneracy.is_monomorphism` (Lemma 8).
- `Degeneracy.left_cancellation` (Lemma 10).
- `Degeneracy.finite_subobjects` (Lemma 14).
**This step is where the bulk of the Phase 1-style proof work sits.**
Some of these may start as `axiom` and promote to `theorem` as the
infrastructure firms up — same pattern as how T1/T2/C1/C2 worked in
`Cubical/TransportLaws.lean`.
---
### Step 6 — `Zigzag/Pullback.lean` (Proposition 13)
**Content**:
- Pullback construction for degeneracy maps.
- `pullback_is_degeneracy` statement.
**Proofs**:
- `Degeneracy.pullback_exists` — the construction terminates.
- `Degeneracy.pullback_legs_are_degeneracies` (Proposition 13).
**Note**: Proposition 13 is the most algorithmically dense piece. **OK
to start as an axiom**. Pattern to follow: state the axiom, write the
construction as a `partial def` with test-case regression, upgrade to
a total def + theorem when the proof is clearer. Exactly how
`step`/`eval` were handled in Phase 1.
---
### Step 7 — `Zigzag/Normalise.lean` (Construction 17)
**Content**:
- `NormalisationResult` structure: `normal_form`, `degeneracy`,
`factorisations`.
- `Sink` structure for relative normalisation.
- `normalise : Diagram → NormalisationResult` (absolute case).
- `normalise_sink : Sink → NormalisationResult` (relative case).
- Termination: structural recursion on `Diagram.dimension`.
**Proofs**:
- `normalise_idempotent` — the headline result (easy, structural).
- `normalise_preserves_globularity` (Proposition 23).
- `normalise_correctness` (Proposition 19) — relative to the axiom
set from Steps 56.
**Test**: port Rust unit tests from `tests/integration_tests.rs` to
Lean `#eval` regressions (Eckmann-Hilton dim 3, syllepsis dim 5,
Figure 6 dim 4 essential-identity).
---
### Step 8 — `Zigzag/Typecheck.lean`
**Content**:
- `SingularContent` extraction.
- Piece decomposition.
- `type_check : Diagram → Signature → Except TypeError Unit`.
**Proofs**:
- `type_check_sound` — if `type_check D Σ` returns `ok`, then all
pieces' normalisations are in `Σ`.
---
### Step 9 — `Zigzag/Tests.lean` (regression battery)
Port the Rust test cases:
- `tests/integration_tests.rs` — normalisation regressions.
- `tests/nontrivial_constructors.rs` — diagram construction.
- `examples/inspect_half_braid.rs` — the Eckmann-Hilton braiding.
- `examples/render_braiding.rs` — braiding as a 3-diagram.
- `examples/scaffold_analysis.rs` / `trace_scaffold.rs` / `trace_merge.rs` — reduction traces.
Each becomes a Lean `#eval` or `example` proving the expected output.
These are the correctness gradient that catches porting errors early.
---
### Step 10 — `Cell/Zigzag.lean` (bridge to cubical core)
**Content**:
- Translator: `CType → Option Diagram` for the dimensions where both
make sense (0-cells, 1-cells, 2-cells-via-Path).
- Translator: `Diagram → Option CType` for the inverse.
- Identity / compose / whisker operations at the `Cell` layer that
dispatch to the right backend: cubical for low dimensions (where
univalence matters), zigzag for higher dimensions (where
combinatorial composition dominates).
**This is where the two formalisms meet.** Cubical Phase 1 gives us
equivalence and transport; Zigzag gives us higher-composition and
normalisation; `Cell/` combines them.
---
## Explosion and layout (post-core)
Steps 11+ (not critical for the n-category reasoning goal):
- `Zigzag/Explosion.lean` — k-points, poset structure. Lean-native
port of `src/explosion.rs` (1414 Rust LOC).
- `Zigzag/Layout.lean` — constraint system. May remain pure Lean or
may defer the QP solver to a Rust `@[implemented_by]` optimisation.
**Decided later** once performance requirements are known.
---
## Axiom discipline (from Phase 1 experience)
The port follows the same axiom-first pattern established in Phase 1:
1. **First pass**: data structures pure; algorithm as `def` (maybe
`partial def`); key correctness statements as `axiom`.
2. **Second pass**: tighten `partial def` into `def` with structural
termination; promote axioms to theorems where the proof is
mechanical.
3. **Third pass**: prove the hard theorems (Proposition 13, correctness
of Construction 17 relative to the degeneracy axioms).
At every stage, axioms are **formal specs for what the algorithm must
satisfy**, not blanket assumptions. The Rust reference implementation
tests each axiom via example; the Lean port must match those tests.
---
## Relationship to topolei's other phases
- **Phase 1 (Cubical Core)** — complete. Not touched by this port.
- **Phase 2 (Cells)** — the zigzag Lean port *is* a prerequisite for
cells-spec §6.3 "Higher Cells". `Cell/Basic.lean` can begin using
cubical-only semantics for 0/1/2-cells; higher cells use the zigzag
backend from Step 10 above.
- **Rust FFI (cubical evaluator)** — independent work stream. The
zigzag port does not depend on it. When the Rust FFI lands, it
backs the cubical axioms; the zigzag Lean code becomes a consumer
of the now-computational cubical layer.
- **Numerical layer** (`NUMERICAL.md`) — independent. Schemes can use
zigzag diagrams as structural source / target types once the port
is complete.
---
## Sizing
- Steps 14: ~2 weeks (data structures + basic algorithms).
- Steps 57: ~34 weeks (degeneracy + normalisation + proofs; this
is the heart of the port).
- Step 8: ~3 days.
- Step 9: ~1 week (regression battery).
- Step 10: ~1 week (bridge).
- **Total: 68 weeks** for the core port with correctness theorems.
Comparable to Phase 1 in size; same single-developer feasibility.
---
## Success criteria
The port is **complete** when:
1. All regression tests from `zigzag-engine/tests/` pass as Lean
`#eval`s or `example`s.
2. `normalise_idempotent` is a theorem (not an axiom).
3. The Eckmann-Hilton (dim 3), syllepsis (dim 5), and Figure 6 (dim 4)
examples type-check and normalise to their documented results.
4. `Cell/Zigzag.lean` (Step 10) compiles and bridges to the cubical
core without circular dependencies.
5. `STATUS.md` can claim "Phase 2 Higher-Cell backend: closed in Lean"
with zero Rust dependency (beyond the cubical-evaluator FFI, which
is a separate work stream).
At that point, topolei has a Lean-native combinatorial n-category
engine, provably correct where proven, with the Rust zigzag engine
demoted from reference to archive.

View file

@ -2,30 +2,13 @@
set -e set -e
cd "$(dirname "$0")" cd "$(dirname "$0")"
GLFW_PC="/nix/store/hpdf5fwl5arkc8d625cxba604i8dwnvp-glfw-3.4/lib/pkgconfig" echo "── building Rust cubical kernel ──"
GL_PC="/nix/store/q9fb1ps2fxa8p4n13mbsijz9w0svhsd4-libglvnd-1.7.0-dev/lib/pkgconfig"
GLEW_PC="/nix/store/nw97c9lkxpzmpq99sgda8aa8xp9q9q4f-glew-2.2.0-dev/lib/pkgconfig"
GLU_PC="/nix/store/vrfv132mqnh44001g96iczc31n1rpgc8-glu-9.0.3-dev/lib/pkgconfig"
export PKG_CONFIG_PATH="$GLFW_PC:$GL_PC:$GLEW_PC:$GLU_PC:$PKG_CONFIG_PATH"
echo "── building Rust canvas (wgpu + winit) ──"
# Replaces the old C++ canvas.cpp — targets Vulkan/Metal/DX12/WebGPU
# via wgpu, with cross-platform window via winit, shader translation
# via naga-glsl. The C++ canvas.cpp + CMakeLists.txt are retained as
# reference but no longer linked.
(cd native/canvas-rs && cargo build --release)
echo "── building Rust cubical backend ──"
# Native staticlib for Lean linkage. Wasm build is a separate step # Native staticlib for Lean linkage. Wasm build is a separate step
# invoked by `cargo build --target wasm32-unknown-unknown` on demand. # invoked by `cargo build --target wasm32-unknown-unknown` on demand.
(cd native/cubical && cargo build --release) (cd native/cubical && cargo build --release)
echo "── building Rust render backend ──" echo "── building Lean library + tests ──"
# Scaffolding crate for future render-side FFI work.
(cd native/render && cargo build --release)
echo "── building Lean ──"
lake build lake build
echo "── done ──" echo "── done ──"
echo "run: ./.lake/build/bin/topolei" echo "run: ./.lake/build/bin/cubical-test"

File diff suppressed because it is too large Load diff

Binary file not shown.

View file

@ -1,14 +1,14 @@
name = "topolei" name = "cubicalTransport"
version = "0.1.0" version = "0.1.0"
defaultTargets = ["topolei", "cubical-test"] defaultTargets = ["cubical-test"]
[[lean_lib]] [[lean_lib]]
name = "Topolei" name = "CubicalTransport"
[[lean_exe]] [[lean_exe]]
name = "cubical-test" name = "cubical-test"
root = "CubicalTest" root = "CubicalTest"
# Runs Phase C.3 smoke tests + Phase D.1 property tests on the # Phase C.3 smoke tests + Phase D.1 property tests on the
# Rust-backed cubical evaluator. No GPU dependencies. # Rust-backed cubical evaluator. No GPU dependencies.
moreLinkArgs = [ moreLinkArgs = [
"./native/cubical/target/release/libtopolei_cubical.a", "./native/cubical/target/release/libtopolei_cubical.a",
@ -21,91 +21,3 @@ root = "CubicalBench"
moreLinkArgs = [ moreLinkArgs = [
"./native/cubical/target/release/libtopolei_cubical.a", "./native/cubical/target/release/libtopolei_cubical.a",
] ]
[[lean_exe]]
name = "topolei"
root = "Main"
moreLinkArgs = [
# Rust canvas (wgpu + winit + naga-glsl) — replaces the OpenGL/GLFW C++
# canvas.cpp. Provides topolei_run / topolei_run2 declared in
# Topolei/Canvas.lean. Targets Vulkan/Metal/DX12/WebGPU under wgpu.
"./native/canvas-rs/target/release/libtopolei_canvas.a",
# Rust cubical-HoTT backend (topolei-cubical) built by build.sh via cargo.
# Provides topolei_cubical_* symbols declared in Topolei/Cubical/FFI.lean.
"./native/cubical/target/release/libtopolei_cubical.a",
# NOTE: the render crate (libtopolei_render.a) is not linked here — it
# and canvas-rs each embed their own copy of the Rust runtime, which
# collides at static-link time (rust_eh_personality et al.). The render
# crate will be merged into canvas-rs, or one of the two made a cdylib,
# when render actually has load-bearing FFI.
# System libs needed by wgpu (Vulkan driver loader + X11 for winit) and
# glibc (TLS symbols via __tls_get_addr required by wgpu's dlopen path).
# Match Lean's toolchain glibc path so __tls_get_addr resolves from the
# same DSO ld-linux-x86-64.so.2 the final executable will load.
# Explicitly include libc.so.6 + ld-linux-x86-64.so.2 — wgpu's statically-
# linked Rust code references __tls_get_addr, resolved only by the
# dynamic linker DSO. Paths point at Lean's toolchain glibc.
# Avoid -L/run/current-system/sw/lib because its libc.a leaks
# __open_nocancel/_setjmp into the static link. Point -L at each lib's
# nix-store dir individually.
"-Wl,--no-as-needed",
"/nix/store/wb6rhpznjfczwlwx23zmdrrw74bayxw4-glibc-2.42-47/lib/libc.so.6",
"/nix/store/wb6rhpznjfczwlwx23zmdrrw74bayxw4-glibc-2.42-47/lib/ld-linux-x86-64.so.2",
"-L/nix/store/7h3p5dm7p8wrbdm7ssb3mybvcjm5f79p-vulkan-loader-1.4.328.0/lib",
"-L/nix/store/2yvh4kwhfd65dcd3r6y6bgdwclfndvzr-libX11-1.8.12/lib",
"-L/nix/store/xwd1s74zk3bwilv4p02284ckyy319vhz-libxcb-1.17.0/lib",
"-L/nix/store/k9ab9lfy15l7br6iagxiwdgdi9kkby88-libxkbcommon-1.8.1/lib",
"-lvulkan",
"-lX11",
"-lxcb",
"-lxkbcommon",
"-ldl",
"-lpthread",
"-lm",
"-lgcc_s",
# RPATH: bake library search paths into the binary so winit's runtime
# dlopen() calls find libXcursor.so.1, libXi.so.6, libXrandr.so.2,
# libXext.so, etc. without the user setting LD_LIBRARY_PATH.
"-Wl,-rpath,/nix/store/7h3p5dm7p8wrbdm7ssb3mybvcjm5f79p-vulkan-loader-1.4.328.0/lib",
"-Wl,-rpath,/nix/store/c0z5kfib8j6xcmbkdknwkkqy38nwph4c-libXcursor-1.2.3/lib",
"-Wl,-rpath,/nix/store/frfb398wg8imfw5r0ac18gy389by0vap-libXi-1.8.2/lib",
"-Wl,-rpath,/nix/store/fxw37cf0j4zp5xagyq0j144536qwc9q4-libXrandr-1.5.4/lib",
"-Wl,-rpath,/nix/store/yzd9jj5q0ad2dzpmxhfs0ssp4ddq2j2r-ld-library-path/share/nix-ld/lib",
"-Wl,-rpath,/nix/store/2v2nlnxm34grn5iq1s1n4di9vsn3k4si-libXext-1.3.6/lib",
]
[[lean_exe]]
name = "probe-test"
root = "ProbeTest"
# Empirical check for `render_faithful` (Topolei/GPU/Spec.lean).
# Uses the `topolei_canvas_render_probe_pixel` FFI to render a handful
# of known shaders offscreen and compare GPU pixel output against the
# Lean-side ShaderSemantic. Requires a GPU adapter or software
# rasterizer at runtime; the probe gracefully returns a sentinel and
# the test SKIPs when none is available. Link args mirror the
# interactive `topolei` exe since canvas-rs pulls the same wgpu /
# Vulkan / X11 stack (winit is compiled in but not used at probe time).
moreLinkArgs = [
"./native/canvas-rs/target/release/libtopolei_canvas.a",
"-Wl,--no-as-needed",
"/nix/store/wb6rhpznjfczwlwx23zmdrrw74bayxw4-glibc-2.42-47/lib/libc.so.6",
"/nix/store/wb6rhpznjfczwlwx23zmdrrw74bayxw4-glibc-2.42-47/lib/ld-linux-x86-64.so.2",
"-L/nix/store/7h3p5dm7p8wrbdm7ssb3mybvcjm5f79p-vulkan-loader-1.4.328.0/lib",
"-L/nix/store/2yvh4kwhfd65dcd3r6y6bgdwclfndvzr-libX11-1.8.12/lib",
"-L/nix/store/xwd1s74zk3bwilv4p02284ckyy319vhz-libxcb-1.17.0/lib",
"-L/nix/store/k9ab9lfy15l7br6iagxiwdgdi9kkby88-libxkbcommon-1.8.1/lib",
"-lvulkan",
"-lX11",
"-lxcb",
"-lxkbcommon",
"-ldl",
"-lpthread",
"-lm",
"-lgcc_s",
"-Wl,-rpath,/nix/store/7h3p5dm7p8wrbdm7ssb3mybvcjm5f79p-vulkan-loader-1.4.328.0/lib",
"-Wl,-rpath,/nix/store/c0z5kfib8j6xcmbkdknwkkqy38nwph4c-libXcursor-1.2.3/lib",
"-Wl,-rpath,/nix/store/frfb398wg8imfw5r0ac18gy389by0vap-libXi-1.8.2/lib",
"-Wl,-rpath,/nix/store/fxw37cf0j4zp5xagyq0j144536qwc9q4-libXrandr-1.5.4/lib",
"-Wl,-rpath,/nix/store/yzd9jj5q0ad2dzpmxhfs0ssp4ddq2j2r-ld-library-path/share/nix-ld/lib",
"-Wl,-rpath,/nix/store/2v2nlnxm34grn5iq1s1n4di9vsn3k4si-libXext-1.3.6/lib",
]

View file

@ -1,27 +0,0 @@
cmake_minimum_required(VERSION 3.20)
project(topolei_native CXX)
set(CMAKE_CXX_STANDARD 17)
find_package(PkgConfig REQUIRED)
pkg_check_modules(GLFW REQUIRED glfw3)
pkg_check_modules(GL REQUIRED gl)
pkg_check_modules(GLEW REQUIRED glew)
add_library(topolei_native STATIC src/canvas.cpp)
target_include_directories(topolei_native PUBLIC
${CMAKE_CURRENT_SOURCE_DIR}/include
${GLFW_INCLUDE_DIRS}
${GL_INCLUDE_DIRS}
${GLEW_INCLUDE_DIRS}
"$ENV{HOME}/.elan/toolchains/leanprover--lean4---v4.30.0-rc2/include"
)
target_link_libraries(topolei_native PUBLIC
${GLFW_LIBRARIES}
${GL_LIBRARIES}
${GLEW_LIBRARIES}
)
target_compile_options(topolei_native PRIVATE ${GLFW_CFLAGS_OTHER})

File diff suppressed because it is too large Load diff

View file

@ -1,39 +0,0 @@
[package]
name = "topolei-canvas"
version = "0.1.0"
edition = "2021"
rust-version = "1.76"
description = "wgpu + winit canvas for topolei. Builds fragment shaders as `naga::Module` directly from Lean `EMLPath` inductives — no GLSL string intermediary on the path-render side."
license = "MIT"
publish = false
[lib]
name = "topolei_canvas"
crate-type = ["staticlib"]
[dependencies]
# `naga-ir` enables `wgpu::ShaderSource::Naga(...)` for the fragment
# stage. Default features cover WGSL (used for the small
# fullscreen-triangle vertex shader) + Vulkan / Metal / DX12 backends.
wgpu = { version = "22.1", features = ["naga-ir"] }
# X11-only winit — disables Wayland backend to sidestep dlopen(libwayland)
# TLS symbols that collide with Lean's static linker on NixOS.
winit = { version = "0.30", default-features = false, features = ["x11", "rwh_06"] }
pollster = "0.3"
bytemuck = { version = "1.15", features = ["derive"] }
log = "0.4"
env_logger = "0.11"
raw-window-handle = "0.6"
[build-dependencies]
cc = "1.0"
[profile.release]
opt-level = 3
lto = true
codegen-units = 1
panic = "abort"
[profile.dev]
opt-level = 0
panic = "abort"

View file

@ -1,26 +0,0 @@
//! Build script: compile shim.c exposing Lean's inline runtime helpers.
fn main() {
let target = std::env::var("TARGET").unwrap_or_default();
if target.starts_with("wasm32") {
return;
}
let lean_include = std::env::var("LEAN_INCLUDE").unwrap_or_else(|_| {
let prefix = std::process::Command::new("lean")
.arg("--print-prefix")
.output()
.expect("failed to run `lean --print-prefix`; set LEAN_INCLUDE instead");
let prefix = String::from_utf8(prefix.stdout).unwrap();
format!("{}/include", prefix.trim())
});
cc::Build::new()
.file("shim.c")
.include(&lean_include)
.flag("-Wno-unused-parameter")
.compile("topolei_canvas_shim");
println!("cargo:rerun-if-changed=shim.c");
println!("cargo:rerun-if-env-changed=LEAN_INCLUDE");
}

View file

@ -1,64 +0,0 @@
/* shim.c — expose Lean's static inline runtime helpers used by the
* canvas crate's FFI (lean_string_cstr, lean_io_result_mk_ok, etc.).
*
* Mirror of `native/cubical/shim.c` + `native/render/shim.c`.
* Names prefixed `topolei_canvas_shim_` to avoid collisions at link.
*/
#include <lean/lean.h>
#include <stdint.h>
#include <string.h>
const char* topolei_canvas_shim_string_cstr(b_lean_obj_arg s) {
return lean_string_cstr(s);
}
lean_obj_res topolei_canvas_shim_io_ok_unit(void) {
return lean_io_result_mk_ok(lean_box(0));
}
/* Build a Lean `RGBA` structure (tag 0) and wrap in `IO.Result.ok`.
*
* Representation: Lean compiles a structure whose fields are all
* `Float` (f64) using **inline unboxed scalar storage** the four
* doubles are packed directly into the ctor's scalar area as 4 × 8 =
* 32 bytes. `lean_alloc_ctor(tag, num_objs=0, scalar_bytes=32)` +
* writes via `lean_ctor_scalar_cptr` match that layout.
*
* An earlier version of this shim used `lean_alloc_ctor(0, 4, 0)`
* with `lean_box_float` per field that produces a ctor with 4
* boxed Float objects, which does not match the compiled layout.
* Lean-side reads then saw uninitialised scalar memory (all zeros)
* regardless of what the GPU rendered. If the Lean `RGBA` structure
* ever gains a non-Float field, this shim must be revisited.
*/
lean_obj_res topolei_canvas_shim_io_ok_rgba(double r, double g, double b, double a) {
lean_object* ctor = lean_alloc_ctor(0, 0, 32);
uint8_t* scalars = lean_ctor_scalar_cptr(ctor);
memcpy(scalars + 0, &r, 8);
memcpy(scalars + 8, &g, 8);
memcpy(scalars + 16, &b, 8);
memcpy(scalars + 24, &a, 8);
return lean_io_result_mk_ok(ctor);
}
/* Inductive-walk helpers — expose Lean's static-inline object accessors
* so Rust can traverse inductive data (EMLExpr, EMLPath, etc.) without
* a separate `lean-sys` dependency. Mirror of the same helpers in
* `native/cubical/shim.c`, scoped under the `topolei_canvas_shim_`
* prefix to avoid link collisions when a single binary pulls in both
* crates.
*/
uint32_t topolei_canvas_shim_obj_tag(b_lean_obj_arg o) {
return (uint32_t)lean_obj_tag(o);
}
b_lean_obj_res topolei_canvas_shim_ctor_get(b_lean_obj_arg o, uint32_t idx) {
return lean_ctor_get(o, idx);
}
/* Wrap a C NUL-terminated string in an `IO String` result object. */
lean_obj_res topolei_canvas_shim_mk_string_io(const char* s) {
lean_object* str = lean_mk_string(s);
return lean_io_result_mk_ok(str);
}

View file

@ -1,435 +0,0 @@
//! # emit_naga — direct naga IR construction from `EMLPath`
//!
//! Replaces the GLSL-text intermediary between Rust `EMLPath` and
//! `naga::Module`. The naga-glsl frontend (~10⁴ LOC) drops out of the
//! trust surface; what the SPIR-V backend writes is exactly the module
//! we built here.
//!
//! See `NAGA_IR_PLAN.md` (top-level) for staging. This file lands in
//! seven commits; consult §5 of the plan for which behaviour each stage
//! locks in.
//!
//! ## Greyscale projection (post-cos-strip)
//!
//! Fragment writes `vec4(v, v, v, 1.0)` where `v = body`. Direct
//! identity mapping into the framebuffer's three channels.
//!
//! An earlier version applied a cosine cycle
//! (`r = 0.5 + 0.5*cos(2π·v + φ)`) here to make any value visible.
//! That cycle has period 1 in `v`, so two fibers of a 1-cell that
//! differ by 1 (e.g. `plotTransp.at0` vs `plotTransp.at1`) rendered
//! to pixel-identical output — visually destroying the transport's
//! content. The cycle is removed; the spec was updated in tandem
//! (`Topolei.GPU.Spec::EMLPath.toColor`).
//!
//! ## Module shape
//!
//! - `entry_points[0]`: fragment, `name = "main"`.
//! - `arguments`: `uv: vec2<f32> @location(0)` — kept on the entry
//! point even when unused, because Stages 3+ need it and reusing the
//! same shape across stages keeps the bind-group layout stable.
//! - `result`: `vec4<f32> @location(0)`.
//! - `body`: a single `Return` of a Compose expression.
//!
//! ## Pitfalls handled (NAGA_IR_PLAN.md §6)
//!
//! 1. **Expression-before-use** — composition uses handles already in
//! the arena. Order of `append` calls matters; we read each handle
//! only after the call that produced it.
//! 2. **`Statement::Emit`** — non-literal expressions (here just the
//! `Compose`) sit inside an `Emit` range covering them.
//! 3. **`UniqueArena` for types** — types go in `module.types` via
//! `insert(_, Span::UNDEFINED)`; identical types dedupe.
//! 8. **Validation capabilities** — `Capabilities::empty()` is enough
//! for the probe shader.
//!
//! Pitfalls 47 (struct layout, `ResourceBinding`, `Binding` shape,
//! NDC y-flip) become live in Stages 23.
use wgpu::naga::{
self, AddressSpace, BinaryOperator, Binding, EntryPoint, Expression, Function,
FunctionArgument, FunctionResult, GlobalVariable, Handle, Literal, MathFunction, Module,
ResourceBinding, Scalar, ScalarKind, ShaderStage, Span, Statement, StructMember, Type,
TypeInner, VectorSize,
};
use crate::eml::{EMLExpr, EMLPath};
// ── Type interning helpers ─────────────────────────────────────────────────
//
// `module.types` is a `UniqueArena`: identical `TypeInner` values
// produce identical handles. Helpers below keep the call site short.
#[inline]
fn ty_f32(module: &mut Module) -> Handle<Type> {
module.types.insert(
Type {
name: None,
inner: TypeInner::Scalar(Scalar {
kind: ScalarKind::Float,
width: 4,
}),
},
Span::UNDEFINED,
)
}
#[inline]
fn ty_vec2_f32(module: &mut Module) -> Handle<Type> {
module.types.insert(
Type {
name: None,
inner: TypeInner::Vector {
size: VectorSize::Bi,
scalar: Scalar {
kind: ScalarKind::Float,
width: 4,
},
},
},
Span::UNDEFINED,
)
}
#[inline]
fn ty_vec4_f32(module: &mut Module) -> Handle<Type> {
module.types.insert(
Type {
name: None,
inner: TypeInner::Vector {
size: VectorSize::Quad,
scalar: Scalar {
kind: ScalarKind::Float,
width: 4,
},
},
},
Span::UNDEFINED,
)
}
/// Pre-built type handles, deduped through `module.types`.
///
/// `uniforms` matches `Uniforms` in `lib.rs` and `FrameUniforms` in
/// `Topolei.GPU.Spec`: `{ time: f32 @ 0, path_param: f32 @ 4,
/// resolution: vec2<f32> @ 8 }` with span 16. Validation rejects
/// mismatched offsets / spans, so this is single-source-of-truth on
/// the IR side.
struct ProbeTypes {
#[allow(dead_code)] // Stage 4+
f32: Handle<Type>,
vec2_f32: Handle<Type>,
vec4_f32: Handle<Type>,
uniforms: Handle<Type>,
}
impl ProbeTypes {
fn build(module: &mut Module) -> Self {
let f32_h = ty_f32(module);
let vec2_f32_h = ty_vec2_f32(module);
let vec4_f32_h = ty_vec4_f32(module);
let uniforms_h = module.types.insert(
Type {
name: Some("Uniforms".to_string()),
inner: TypeInner::Struct {
members: vec![
StructMember {
name: Some("time".to_string()),
ty: f32_h,
binding: None,
offset: 0,
},
StructMember {
name: Some("path_param".to_string()),
ty: f32_h,
binding: None,
offset: 4,
},
StructMember {
name: Some("resolution".to_string()),
ty: vec2_f32_h,
binding: None,
offset: 8,
},
],
span: 16,
},
},
Span::UNDEFINED,
);
Self {
f32: f32_h,
vec2_f32: vec2_f32_h,
vec4_f32: vec4_f32_h,
uniforms: uniforms_h,
}
}
}
/// Globals used by the probe shader. Each global is a `naga::Module`
/// global variable; the entry-point body refers to them via
/// `Expression::GlobalVariable` (which produces a *pointer* to the
/// value, except for `AddressSpace::Handle` which produces the value
/// directly).
struct ProbeGlobals {
uniforms_buf: Handle<GlobalVariable>,
}
impl ProbeGlobals {
fn build(module: &mut Module, types: &ProbeTypes) -> Self {
let uniforms_buf = module.global_variables.append(
GlobalVariable {
name: Some("uniforms".to_string()),
space: AddressSpace::Uniform,
binding: Some(ResourceBinding {
group: UNIFORM_GROUP,
binding: UNIFORM_BINDING,
}),
ty: types.uniforms,
init: None,
},
Span::UNDEFINED,
);
Self { uniforms_buf }
}
}
const FRAGMENT_LOCATION: u32 = 0;
const UNIFORM_GROUP: u32 = 0;
const UNIFORM_BINDING: u32 = 0;
/// `Uniforms.path_param` field index. Must match the Rust
/// `Uniforms` struct layout (`time, path_param, resolution`).
#[allow(dead_code)] // Stage 4+
const UNIFORMS_FIELD_PATH_PARAM: u32 = 1;
// ── build_probe_module — top-level orchestrator ────────────────────────────
/// Build a fully-validated fragment-shader `naga::Module` whose pixel
/// output (eventually) equals `EMLPath.toColor path p u`.
///
/// At Stage 1 the path is ignored and every pixel is `(1, 0, 0, 1)`.
/// Pixel probes therefore fail by design; the module is still expected
/// to validate and round-trip through wgpu's SPIR-V writer.
pub fn build_probe_module(path: &EMLPath) -> Module {
let mut module = Module::default();
let types = ProbeTypes::build(&mut module);
let globals = ProbeGlobals::build(&mut module, &types);
let entry = build_main_fn(path, &types, &globals);
module.entry_points.push(entry);
module
}
// ── build_main_fn — fragment entry point ───────────────────────────────────
fn build_main_fn(path: &EMLPath, types: &ProbeTypes, globals: &ProbeGlobals) -> EntryPoint {
let mut function = Function::default();
function.name = Some("main".to_string());
// Argument: uv: vec2<f32> @location(0). Naga's fragment-stage
// pattern keeps varyings on the function arguments rather than on
// top-level globals; this is the idiom the GLSL frontend produces
// and what the SPIR-V backend expects.
function.arguments.push(FunctionArgument {
name: Some("uv".to_string()),
ty: types.vec2_f32,
binding: Some(Binding::Location {
location: FRAGMENT_LOCATION,
second_blend_source: false,
// Match the WGSL vertex shader's default interpolation
// (`perspective`) + sampling (`center`). Mismatching the
// sampling causes wgpu to reject the pipeline at
// `create_render_pipeline` time with "Input sampling
// doesn't match provided Some(Center)".
interpolation: Some(naga::Interpolation::Perspective),
sampling: Some(naga::Sampling::Center),
}),
});
// Result: vec4<f32> @location(0).
function.result = Some(FunctionResult {
ty: types.vec4_f32,
binding: Some(Binding::Location {
location: FRAGMENT_LOCATION,
second_blend_source: false,
interpolation: None,
sampling: None,
}),
});
// Body — Stage 4: walk the EMLExpr tree, build `v = body`,
// emit `vec4(v, v, v, 1.0)`.
//
// Expression order rules (NAGA_IR_PLAN.md §6 #1, #2):
// - Every handle must be appended before any expression that
// references it.
// - `Literal`, `Constant`, `ZeroValue`, `FunctionArgument`,
// `GlobalVariable`, `LocalVariable` are *implicitly* evaluated
// and must NOT sit inside an `Emit` range. Naga rejects them
// with "Expression [n] can't be introduced — it's already in
// scope".
// - All other expressions need to be covered by an `Emit` whose
// range is `expressions.range_from(len_before_first_emittable)`.
//
// The strategy here: append every implicit (FunctionArgument,
// GlobalVariable, all Literals) up front, then snapshot the arena
// length, then run the rest of the body (AccessIndex, Load,
// Math, Binary, Compose) inside one continuous Emit range. The
// `emit_emlexpr` walker only ever appends emittable expressions —
// it reuses the pre-allocated `one` / `zero` literals when the
// tree calls for them.
let uniforms_ptr = function.expressions.append(
Expression::GlobalVariable(globals.uniforms_buf),
Span::UNDEFINED,
);
let uv_arg = function
.expressions
.append(Expression::FunctionArgument(0), Span::UNDEFINED);
let zero = function
.expressions
.append(Expression::Literal(Literal::F32(0.0)), Span::UNDEFINED);
let one = function
.expressions
.append(Expression::Literal(Literal::F32(1.0)), Span::UNDEFINED);
let pre_chain = function.expressions.len();
let px = function.expressions.append(
Expression::AccessIndex {
base: uv_arg,
index: 0,
},
Span::UNDEFINED,
);
let py = function.expressions.append(
Expression::AccessIndex {
base: uv_arg,
index: 1,
},
Span::UNDEFINED,
);
let path_param_ptr = function.expressions.append(
Expression::AccessIndex {
base: uniforms_ptr,
index: UNIFORMS_FIELD_PATH_PARAM,
},
Span::UNDEFINED,
);
let dim_value = function.expressions.append(
Expression::Load {
pointer: path_param_ptr,
},
Span::UNDEFINED,
);
let env = EmitEnv {
px,
py,
dim_name: &path.dim_name,
dim_value,
one,
zero,
};
let v = emit_emlexpr(&path.body, &mut function, &env);
// Greyscale projection: write `v` to all three channels. No cos
// cycle, no normalization — see the file-level comment.
let composed = function.expressions.append(
Expression::Compose {
ty: types.vec4_f32,
components: vec![v, v, v, one],
},
Span::UNDEFINED,
);
function.body.push(
Statement::Emit(function.expressions.range_from(pre_chain)),
Span::UNDEFINED,
);
function.body.push(
Statement::Return {
value: Some(composed),
},
Span::UNDEFINED,
);
EntryPoint {
name: "main".to_string(),
stage: ShaderStage::Fragment,
early_depth_test: None,
workgroup_size: [0, 0, 0],
function,
}
}
// ── EMLExpr → naga Expression handle ───────────────────────────────────────
/// Bindings for the leaves of an `EMLExpr`. All four handles must be
/// already allocated in `function.expressions` *and* either implicit
/// (the literal pair) or already covered by the open `Emit` range
/// (the uv-derived px/py and the loaded dim value).
struct EmitEnv<'a> {
px: Handle<Expression>,
py: Handle<Expression>,
dim_name: &'a str,
dim_value: Handle<Expression>,
one: Handle<Expression>,
zero: Handle<Expression>,
}
/// Walk an `EMLExpr` and return the `Handle<Expression>` for its
/// value. Only appends emittable expressions (`Math`, `Binary`); the
/// caller wraps the chain in a single `Statement::Emit`.
///
/// Mirrors `Topolei.EML.EMLExpr.toGLSL` semantically (and `eml.rs`
/// `to_glsl` literally) — `Var` falls back to `0.0` when the name is
/// neither `px`/`py` nor the path's dim variable (matches
/// `shaderVar`'s fallback in `GPU/Spec.lean`).
fn emit_emlexpr(expr: &EMLExpr, function: &mut Function, env: &EmitEnv) -> Handle<Expression> {
match expr {
EMLExpr::One => env.one,
EMLExpr::Var(name) => {
if name == "px" {
env.px
} else if name == "py" {
env.py
} else if name == env.dim_name {
env.dim_value
} else {
env.zero
}
}
EMLExpr::Eml(l, r) => {
let lh = emit_emlexpr(l, function, env);
let rh = emit_emlexpr(r, function, env);
let exp_l = function.expressions.append(
Expression::Math {
fun: MathFunction::Exp,
arg: lh,
arg1: None,
arg2: None,
arg3: None,
},
Span::UNDEFINED,
);
let log_r = function.expressions.append(
Expression::Math {
fun: MathFunction::Log,
arg: rh,
arg1: None,
arg2: None,
arg3: None,
},
Span::UNDEFINED,
);
function.expressions.append(
Expression::Binary {
op: BinaryOperator::Subtract,
left: exp_l,
right: log_r,
},
Span::UNDEFINED,
)
}
}
}

View file

@ -1,112 +0,0 @@
//! # eml
//!
//! Rust mirror of `Topolei.EML.EMLExpr` and `Topolei.EML.Path.EMLPath`.
//! Used by the probe and (future) live-render pipelines to accept a
//! **structured** path from Lean over FFI — not a pre-emitted GLSL
//! string — and then emit GPU shader source on this side.
//!
//! Having the AST on both sides means:
//!
//! - The shader emitter lives in Rust where it runs, not in Lean
//! where it would need to produce a string that Rust then parses.
//! - Lean's `EMLPath.toFragShaderProbe` becomes a **reference
//! emitter**: the two sides are expected to produce the same
//! shader for the same path, and any divergence is a spec-vs-impl
//! bug that can be caught by a string diff.
//! - Future work (direct naga IR construction, SPIR-V emission) has
//! a typed starting point instead of a string.
//!
//! The Lean object layout this module consumes:
//!
//! ```text
//! inductive EMLExpr where
//! | one : EMLExpr -- tag 0, 0 fields
//! | var (name : String) : EMLExpr -- tag 1, 1 field
//! | eml (l r : EMLExpr) : EMLExpr -- tag 2, 2 fields
//!
//! structure EMLPath where
//! dimName : String
//! body : EMLExpr -- tag 0, 2 fields
//! ```
use std::ffi::{c_char, c_void, CStr};
// ── Shim bindings (from `native/canvas-rs/shim.c`) ──────────────────────────
extern "C" {
fn topolei_canvas_shim_obj_tag(o: *const c_void) -> u32;
fn topolei_canvas_shim_ctor_get(o: *const c_void, idx: u32) -> *const c_void;
fn topolei_canvas_shim_string_cstr(s: *const c_void) -> *const c_char;
}
// ── Rust EMLExpr / EMLPath ─────────────────────────────────────────────────
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum EMLExpr {
One,
Var(String),
Eml(Box<EMLExpr>, Box<EMLExpr>),
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct EMLPath {
pub dim_name: String,
pub body: EMLExpr,
}
// ── Lean object walkers ────────────────────────────────────────────────────
/// Walk a Lean `EMLExpr` object and build the Rust mirror. Caller
/// must keep `obj` alive for the duration — we only read fields
/// (`ctor_tag` / `ctor_get` / `string_cstr`), never retain anything.
///
/// # Safety
///
/// `obj` must be a valid `lean_object*` pointing at an `EMLExpr`. On
/// an unknown tag this returns `EMLExpr::One` rather than panic — the
/// caller can detect divergence from Lean state by string-diffing the
/// emitted shader, which is a stronger check than a panic path.
pub unsafe fn emlexpr_from_lean(obj: *const c_void) -> EMLExpr {
let tag = topolei_canvas_shim_obj_tag(obj);
match tag {
0 => EMLExpr::One,
1 => {
let s = topolei_canvas_shim_ctor_get(obj, 0);
EMLExpr::Var(read_lean_string(s))
}
2 => {
let l = topolei_canvas_shim_ctor_get(obj, 0);
let r = topolei_canvas_shim_ctor_get(obj, 1);
EMLExpr::Eml(Box::new(emlexpr_from_lean(l)), Box::new(emlexpr_from_lean(r)))
}
_ => EMLExpr::One,
}
}
/// Walk a Lean `EMLPath` object (single-ctor structure with two
/// fields). Uses [`emlexpr_from_lean`] on the body.
///
/// # Safety
///
/// See [`emlexpr_from_lean`].
pub unsafe fn emlpath_from_lean(obj: *const c_void) -> EMLPath {
let dim_name_obj = topolei_canvas_shim_ctor_get(obj, 0);
let body_obj = topolei_canvas_shim_ctor_get(obj, 1);
EMLPath {
dim_name: read_lean_string(dim_name_obj),
body: emlexpr_from_lean(body_obj),
}
}
unsafe fn read_lean_string(s: *const c_void) -> String {
let ptr = topolei_canvas_shim_string_cstr(s);
if ptr.is_null() {
String::new()
} else {
CStr::from_ptr(ptr).to_string_lossy().into_owned()
}
}
// GLSL emission removed — the path-based probe builds a `naga::Module`
// directly via `crate::emit_naga::build_probe_module`. See
// NAGA_IR_PLAN.md.

View file

@ -1,830 +0,0 @@
//! # topolei-canvas
//!
//! wgpu + winit canvas for the topolei transport renderer. Both the
//! live render loop and the headless `render_faithful` probe build the
//! fragment shader as a `naga::Module` directly from a Lean `EMLPath`
//! inductive — no GLSL string intermediary.
//!
//! ## FFI
//!
//! - `topolei_run_path(path, width, height, title, world) -> IO Unit`
//! Open a window, animate one path with the canonical driver
//! (sine-sweep of `u_time` mapped onto `u_pathParam`). Returns
//! when the user closes the window.
//!
//! - `topolei_run_path2(pathL, pathR, width, height, title, world) -> IO Unit`
//! Side-by-side two-panel variant. Both panels share the uniform
//! buffer (same `u_time`, `u_pathParam`).
//!
//! - `topolei_canvas_render_probe_path_pixel(path, w, h, time,
//! pathParam, x, y, world) -> IO RGBA`
//! Headless one-pixel probe used by `Topolei.Render.Probe` to
//! verify `compileEMLPath_correct`.
//!
//! Each panel/probe builds its `naga::Module` via
//! `emit_naga::build_probe_module(&path)` and hands it to wgpu via
//! `ShaderSource::Naga(Cow::Owned(module))`. The vertex stage is a
//! tiny WGSL fullscreen-triangle shader; no GLSL frontend is involved.
use std::borrow::Cow;
use std::ffi::{c_char, CStr, c_void};
use std::sync::Arc;
use bytemuck::{Pod, Zeroable};
use wgpu::util::DeviceExt;
use winit::application::ApplicationHandler;
use winit::dpi::LogicalSize;
use winit::event::WindowEvent;
use winit::event_loop::{ActiveEventLoop, ControlFlow, EventLoop};
use winit::platform::x11::EventLoopBuilderExtX11;
use winit::window::{Window, WindowAttributes, WindowId};
mod eml;
mod emit_naga;
// ── Uniform layout ──────────────────────────────────────────────────────────
#[repr(C)]
#[derive(Copy, Clone, Debug, Pod, Zeroable)]
struct Uniforms {
// std140 layout: scalars are 4-byte aligned; vec2 is 8-byte aligned.
//
// Layout contract — must match the UBO declared by the naga IR
// emitter (`emit_naga::ProbeTypes::uniforms`) and the semantic
// spec in `Topolei.GPU.Spec.FrameUniforms`:
//
// offset 0: float u_time
// offset 4: float u_pathParam ← host-driven cubical path parameter
// offset 8: vec2 u_resolution (16-byte boundary is implicit)
//
// `path_param` is fixed at construction. We do NOT animate it
// host-side: a host-chosen time→pathParam function is not itself
// a transport in the cells-spec sense (it lives outside the
// cubical calculus). Each frame renders the EMLPath at exactly
// one fiber chosen by the caller. An animated rendering would
// be a 2-cell (a homotopy of 1-cells parameterised by a second
// interval) — that requires 2-cell infrastructure we don't have
// yet.
time: f32,
path_param: f32,
resolution: [f32; 2],
}
// ── Vertex shader (WGSL fullscreen triangle) ───────────────────────────────
//
// One large triangle covering NDC `[-1, 1]²`; gives the fragment stage
// `uv ∈ [0, 1]²` across the panel. WGSL is wgpu's first-class shader
// language and is in the default feature set; nothing depends on
// naga-glsl here.
const VERTEX_WGSL: &str = r#"
struct VsOut {
@builtin(position) pos: vec4<f32>,
@location(0) uv: vec2<f32>,
};
@vertex
fn main(@builtin(vertex_index) idx: u32) -> VsOut {
let x = select(-1.0, 3.0, idx == 1u);
let y = select(-1.0, 3.0, idx == 2u);
var out: VsOut;
out.pos = vec4<f32>(x, y, 0.0, 1.0);
out.uv = vec2<f32>(x, y) * 0.5 + 0.5;
return out;
}
"#;
// ── Pipeline construction ──────────────────────────────────────────────────
//
// One fragment shader + one render pipeline per panel, sharing the
// vertex module + uniform bind group with every other panel. The
// fragment module is always a `naga::Module` we build ourselves from
// an `EMLPath` — no GLSL.
/// Build one naga-IR fragment-shader render pipeline against the
/// shared vertex module + pipeline layout.
fn make_panel_pipeline_from_path(
device: &wgpu::Device,
format: wgpu::TextureFormat,
vert_module: &wgpu::ShaderModule,
pipeline_layout: &wgpu::PipelineLayout,
path: &eml::EMLPath,
label: &str,
) -> wgpu::RenderPipeline {
let module = emit_naga::build_probe_module(path);
let frag_module = device.create_shader_module(wgpu::ShaderModuleDescriptor {
label: Some(label),
source: wgpu::ShaderSource::Naga(Cow::Owned(module)),
});
device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
label: Some(label),
layout: Some(pipeline_layout),
vertex: wgpu::VertexState {
module: vert_module,
entry_point: "main",
buffers: &[],
compilation_options: Default::default(),
},
fragment: Some(wgpu::FragmentState {
module: &frag_module,
entry_point: "main",
targets: &[Some(wgpu::ColorTargetState {
format,
// `blend: None` is universally valid; `Some(REPLACE)`
// fails validation on non-blendable formats like
// `Rgba32Float` used by the offscreen probe.
blend: None,
write_mask: wgpu::ColorWrites::ALL,
})],
compilation_options: Default::default(),
}),
primitive: wgpu::PrimitiveState {
topology: wgpu::PrimitiveTopology::TriangleList,
..Default::default()
},
depth_stencil: None,
multisample: wgpu::MultisampleState::default(),
multiview: None,
cache: None,
})
}
// ── Live-render state ──────────────────────────────────────────────────────
//
// N-panel engine. Per frame: begin one render pass (clear once), then
// for each panel set its viewport and draw its pipeline. Viewports
// partition the window width evenly left-to-right; each panel's
// fragment shader sees `uv ∈ [0, 1]²` across its own pane rather than
// across the whole window.
//
// 1 panel → one full-width viewport (topolei_run_path).
// 2 panels → side-by-side halves (topolei_run_path2).
struct GpuState {
surface: wgpu::Surface<'static>,
device: wgpu::Device,
queue: wgpu::Queue,
config: wgpu::SurfaceConfiguration,
panels: Vec<PanelState>,
size: winit::dpi::PhysicalSize<u32>,
}
/// One panel's render state: pipeline + per-panel uniform buffer +
/// bind group. Each panel binds its own `pathParam` because the
/// fiber a panel shows is a property of *that* panel, not the
/// window. Sharing one uniform across panels would conflate two
/// independent fibers.
struct PanelState {
pipeline: wgpu::RenderPipeline,
uniform_buf: wgpu::Buffer,
uniform_bg: wgpu::BindGroup,
path_param: f32,
}
impl GpuState {
/// Initialise the GPU state for `paths.len()` panels. Requires
/// at least one path; panics otherwise (an empty window has no
/// useful meaning). `path_params[i]` selects the fiber of the
/// `i`th 1-cell to render — fixed for the lifetime of the window.
/// `path_params.len()` must equal `paths.len()`.
fn new(
window: Arc<Window>,
paths: &[eml::EMLPath],
path_params: &[f32],
) -> Result<Self, String> {
assert_eq!(
paths.len(),
path_params.len(),
"GpuState::new: per-panel path_params length must match paths length"
);
assert!(!paths.is_empty(), "GpuState::new needs at least one path");
let size = window.inner_size();
let instance = wgpu::Instance::new(wgpu::InstanceDescriptor {
backends: wgpu::Backends::VULKAN | wgpu::Backends::METAL | wgpu::Backends::DX12,
..Default::default()
});
let surface = instance
.create_surface(window.clone())
.map_err(|e| format!("create_surface: {e:?}"))?;
let adapter = pollster::block_on(instance.request_adapter(
&wgpu::RequestAdapterOptions {
power_preference: wgpu::PowerPreference::LowPower,
compatible_surface: Some(&surface),
force_fallback_adapter: false,
},
))
.ok_or("no GPU adapter found")?;
let (device, queue) = pollster::block_on(adapter.request_device(
&wgpu::DeviceDescriptor {
label: Some("topolei-canvas device"),
required_features: wgpu::Features::empty(),
required_limits: wgpu::Limits::downlevel_defaults(),
memory_hints: wgpu::MemoryHints::Performance,
},
None,
))
.map_err(|e| format!("request_device: {e:?}"))?;
let surface_caps = surface.get_capabilities(&adapter);
let format = surface_caps
.formats
.iter()
.find(|f| f.is_srgb())
.copied()
.unwrap_or(surface_caps.formats[0]);
let config = wgpu::SurfaceConfiguration {
usage: wgpu::TextureUsages::RENDER_ATTACHMENT,
format,
width: size.width.max(1),
height: size.height.max(1),
present_mode: wgpu::PresentMode::Fifo,
desired_maximum_frame_latency: 2,
alpha_mode: surface_caps.alpha_modes[0],
view_formats: vec![],
};
surface.configure(&device, &config);
// Shared vertex module (fullscreen triangle, WGSL).
let vert_module = device.create_shader_module(wgpu::ShaderModuleDescriptor {
label: Some("vertex"),
source: wgpu::ShaderSource::Wgsl(VERTEX_WGSL.into()),
});
// Bind-group layout (shared — every panel binds the same
// shape, just to a different buffer).
let uniform_bgl = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
label: Some("uniform-bgl"),
entries: &[wgpu::BindGroupLayoutEntry {
binding: 0,
visibility: wgpu::ShaderStages::FRAGMENT,
ty: wgpu::BindingType::Buffer {
ty: wgpu::BufferBindingType::Uniform,
has_dynamic_offset: false,
min_binding_size: None,
},
count: None,
}],
});
let pipeline_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
label: Some("pipeline-layout"),
bind_group_layouts: &[&uniform_bgl],
push_constant_ranges: &[],
});
// One panel per path — each gets its own uniform buffer with
// its own pathParam, its own bind group, and its own pipeline.
// Sharing one uniform across panels would conflate fibers
// from different 1-cells, so we don't.
let panels: Vec<PanelState> = paths
.iter()
.zip(path_params.iter())
.enumerate()
.map(|(i, (path, &pp))| {
let uniform_buf = device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
label: Some(&format!("uniforms-{i}")),
contents: bytemuck::bytes_of(&Uniforms {
time: 0.0,
path_param: pp,
resolution: [size.width as f32, size.height as f32],
}),
usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
});
let uniform_bg = device.create_bind_group(&wgpu::BindGroupDescriptor {
label: Some(&format!("uniform-bg-{i}")),
layout: &uniform_bgl,
entries: &[wgpu::BindGroupEntry {
binding: 0,
resource: uniform_buf.as_entire_binding(),
}],
});
let pipeline = make_panel_pipeline_from_path(
&device,
config.format,
&vert_module,
&pipeline_layout,
path,
&format!("panel-{i}"),
);
PanelState { pipeline, uniform_buf, uniform_bg, path_param: pp }
})
.collect();
Ok(Self {
surface,
device,
queue,
config,
panels,
size,
})
}
fn resize(&mut self, new_size: winit::dpi::PhysicalSize<u32>) {
if new_size.width > 0 && new_size.height > 0 {
self.size = new_size;
self.config.width = new_size.width;
self.config.height = new_size.height;
self.surface.configure(&self.device, &self.config);
// Update only the resolution field on each panel; pathParam
// stays fixed because it parameterises the 1-cell, not the
// window.
for panel in &self.panels {
self.queue.write_buffer(
&panel.uniform_buf,
0,
bytemuck::bytes_of(&Uniforms {
time: 0.0,
path_param: panel.path_param,
resolution: [self.size.width as f32, self.size.height as f32],
}),
);
}
}
}
fn render(&mut self) -> Result<(), wgpu::SurfaceError> {
let frame = self.surface.get_current_texture()?;
let view = frame
.texture
.create_view(&wgpu::TextureViewDescriptor::default());
let mut encoder = self
.device
.create_command_encoder(&wgpu::CommandEncoderDescriptor { label: None });
// Compute per-panel viewports. Width is sliced evenly; the
// final panel extends to the window edge to cover any rounding.
let n = self.panels.len() as u32;
let total_w = self.config.width;
let full_h = self.config.height as f32;
let slice_w = total_w / n.max(1);
{
let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
label: Some("main"),
color_attachments: &[Some(wgpu::RenderPassColorAttachment {
view: &view,
resolve_target: None,
ops: wgpu::Operations {
load: wgpu::LoadOp::Clear(wgpu::Color {
r: 0.0,
g: 0.0,
b: 0.0,
a: 1.0,
}),
store: wgpu::StoreOp::Store,
},
})],
depth_stencil_attachment: None,
timestamp_writes: None,
occlusion_query_set: None,
});
for (i, panel) in self.panels.iter().enumerate() {
let x = (i as u32) * slice_w;
let w = if (i as u32) + 1 == n {
total_w - x
} else {
slice_w
};
pass.set_viewport(x as f32, 0.0, w as f32, full_h, 0.0, 1.0);
pass.set_pipeline(&panel.pipeline);
pass.set_bind_group(0, &panel.uniform_bg, &[]);
pass.draw(0..3, 0..1);
}
}
self.queue.submit(Some(encoder.finish()));
frame.present();
Ok(())
}
}
// ── winit App ──────────────────────────────────────────────────────────────
struct App {
window: Option<Arc<Window>>,
state: Option<GpuState>,
paths: Vec<eml::EMLPath>,
path_params: Vec<f32>,
title: String,
initial_size: LogicalSize<u32>,
init_error: Option<String>,
}
impl App {
fn new(
paths: Vec<eml::EMLPath>,
path_params: Vec<f32>,
title: String,
w: u32,
h: u32,
) -> Self {
Self {
window: None,
state: None,
paths,
path_params,
title,
initial_size: LogicalSize::new(w, h),
init_error: None,
}
}
}
impl ApplicationHandler for App {
fn resumed(&mut self, event_loop: &ActiveEventLoop) {
if self.window.is_some() {
return;
}
let attrs = WindowAttributes::default()
.with_title(&self.title)
.with_inner_size(self.initial_size);
let window = match event_loop.create_window(attrs) {
Ok(w) => Arc::new(w),
Err(e) => {
self.init_error = Some(format!("create_window: {e:?}"));
event_loop.exit();
return;
}
};
match GpuState::new(window.clone(), &self.paths, &self.path_params) {
Ok(s) => {
self.window = Some(window);
self.state = Some(s);
}
Err(e) => {
self.init_error = Some(e);
event_loop.exit();
}
}
}
fn window_event(
&mut self,
event_loop: &ActiveEventLoop,
_window_id: WindowId,
event: WindowEvent,
) {
let Some(state) = self.state.as_mut() else { return; };
match event {
WindowEvent::CloseRequested => event_loop.exit(),
WindowEvent::Resized(sz) => state.resize(sz),
WindowEvent::RedrawRequested => {
match state.render() {
Ok(_) => {}
Err(wgpu::SurfaceError::Lost | wgpu::SurfaceError::Outdated) => {
state.resize(state.size)
}
Err(wgpu::SurfaceError::OutOfMemory) => event_loop.exit(),
Err(e) => eprintln!("render error: {e:?}"),
}
if let Some(w) = self.window.as_ref() {
w.request_redraw();
}
}
_ => {}
}
}
}
fn run_path_loop(
paths: Vec<eml::EMLPath>,
path_params: Vec<f32>,
title: String,
w: u32,
h: u32,
) -> i32 {
if paths.is_empty() {
eprintln!("topolei-canvas: run_path_loop called with zero paths");
return -4;
}
if paths.len() != path_params.len() {
eprintln!(
"topolei-canvas: paths.len() = {} but path_params.len() = {}",
paths.len(),
path_params.len()
);
return -5;
}
let _ = env_logger::builder().is_test(false).try_init();
// `any_thread(true)` lets us create the event loop from whichever
// thread Lean called into — Lean's runtime doesn't guarantee we're
// on the "main thread" as winit normally wants on Linux. The X11
// backend tolerates this.
let event_loop = match EventLoop::builder().with_any_thread(true).build() {
Ok(el) => el,
Err(e) => {
eprintln!("topolei-canvas: EventLoop::new failed: {e:?}");
return -1;
}
};
event_loop.set_control_flow(ControlFlow::Poll);
let mut app = App::new(paths, path_params, title, w, h);
if let Err(e) = event_loop.run_app(&mut app) {
eprintln!("topolei-canvas: run_app failed: {e:?}");
return -2;
}
if let Some(err) = app.init_error.as_ref() {
eprintln!("topolei-canvas: initialization failed: {err}");
return -3;
}
0
}
// ── Offscreen probe: render-faithful empirical check ───────────────────────
//
// Headless one-pixel render, used by `Topolei.Render.Probe`. Same
// fragment-naga construction as the live render; output read back from
// an `Rgba32Float` offscreen texture. Each call creates fresh GPU
// resources; this is deliberately slow and deliberately independent
// of the live loop.
fn offscreen_render_pixel_path(
path: &eml::EMLPath,
width: u32,
height: u32,
time: f32,
path_param: f32,
x: u32,
y: u32,
) -> Result<[f32; 4], String> {
if x >= width || y >= height {
return Err(format!(
"pixel ({}, {}) out of bounds for ({}, {})",
x, y, width, height
));
}
let instance = wgpu::Instance::new(wgpu::InstanceDescriptor {
backends: wgpu::Backends::VULKAN | wgpu::Backends::METAL | wgpu::Backends::DX12,
..Default::default()
});
let adapter = pollster::block_on(instance.request_adapter(
&wgpu::RequestAdapterOptions {
power_preference: wgpu::PowerPreference::default(),
compatible_surface: None,
force_fallback_adapter: false,
},
))
.ok_or_else(|| "no GPU adapter available for probe".to_string())?;
let adapter_info = adapter.get_info();
eprintln!(
"topolei-probe: adapter = {} ({:?}, {:?})",
adapter_info.name, adapter_info.backend, adapter_info.device_type
);
let (device, queue) = pollster::block_on(adapter.request_device(
&wgpu::DeviceDescriptor {
label: Some("topolei-probe"),
required_features: wgpu::Features::empty(),
required_limits: wgpu::Limits::downlevel_defaults(),
memory_hints: wgpu::MemoryHints::Performance,
},
None,
))
.map_err(|e| format!("request_device: {:?}", e))?;
let format = wgpu::TextureFormat::Rgba32Float;
let texture = device.create_texture(&wgpu::TextureDescriptor {
label: Some("probe-target"),
size: wgpu::Extent3d { width, height, depth_or_array_layers: 1 },
mip_level_count: 1,
sample_count: 1,
dimension: wgpu::TextureDimension::D2,
format,
usage: wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::COPY_SRC,
view_formats: &[],
});
let view = texture.create_view(&wgpu::TextureViewDescriptor::default());
let vert_module = device.create_shader_module(wgpu::ShaderModuleDescriptor {
label: Some("probe-vertex"),
source: wgpu::ShaderSource::Wgsl(VERTEX_WGSL.into()),
});
let uniform_buf = device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
label: Some("probe-uniforms"),
contents: bytemuck::bytes_of(&Uniforms {
time,
path_param,
resolution: [width as f32, height as f32],
}),
usage: wgpu::BufferUsages::UNIFORM,
});
let uniform_bgl = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
label: Some("probe-uniform-bgl"),
entries: &[wgpu::BindGroupLayoutEntry {
binding: 0,
visibility: wgpu::ShaderStages::FRAGMENT,
ty: wgpu::BindingType::Buffer {
ty: wgpu::BufferBindingType::Uniform,
has_dynamic_offset: false,
min_binding_size: None,
},
count: None,
}],
});
let uniform_bg = device.create_bind_group(&wgpu::BindGroupDescriptor {
label: Some("probe-uniform-bg"),
layout: &uniform_bgl,
entries: &[wgpu::BindGroupEntry {
binding: 0,
resource: uniform_buf.as_entire_binding(),
}],
});
let pipeline_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
label: Some("probe-pipeline-layout"),
bind_group_layouts: &[&uniform_bgl],
push_constant_ranges: &[],
});
let pipeline = make_panel_pipeline_from_path(
&device, format, &vert_module, &pipeline_layout, path, "probe-pipeline",
);
let mut encoder = device.create_command_encoder(&wgpu::CommandEncoderDescriptor {
label: Some("probe-encoder"),
});
{
let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
label: Some("probe-pass"),
color_attachments: &[Some(wgpu::RenderPassColorAttachment {
view: &view,
resolve_target: None,
ops: wgpu::Operations {
load: wgpu::LoadOp::Clear(wgpu::Color {
r: 0.0, g: 0.0, b: 0.0, a: 1.0,
}),
store: wgpu::StoreOp::Store,
},
})],
depth_stencil_attachment: None,
timestamp_writes: None,
occlusion_query_set: None,
});
pass.set_pipeline(&pipeline);
pass.set_bind_group(0, &uniform_bg, &[]);
pass.draw(0..3, 0..1);
}
// Copy texture → staging buffer. wgpu requires bytes_per_row to
// be a multiple of 256.
let bytes_per_pixel: u32 = 16; // Rgba32Float
let align: u32 = wgpu::COPY_BYTES_PER_ROW_ALIGNMENT;
let bytes_per_row_unaligned = width * bytes_per_pixel;
let bytes_per_row = ((bytes_per_row_unaligned + align - 1) / align) * align;
let buffer_size = (bytes_per_row as u64) * (height as u64);
let staging = device.create_buffer(&wgpu::BufferDescriptor {
label: Some("probe-staging"),
size: buffer_size,
usage: wgpu::BufferUsages::COPY_DST | wgpu::BufferUsages::MAP_READ,
mapped_at_creation: false,
});
encoder.copy_texture_to_buffer(
wgpu::ImageCopyTexture {
texture: &texture,
mip_level: 0,
origin: wgpu::Origin3d::ZERO,
aspect: wgpu::TextureAspect::All,
},
wgpu::ImageCopyBuffer {
buffer: &staging,
layout: wgpu::ImageDataLayout {
offset: 0,
bytes_per_row: Some(bytes_per_row),
rows_per_image: Some(height),
},
},
wgpu::Extent3d { width, height, depth_or_array_layers: 1 },
);
queue.submit(Some(encoder.finish()));
let slice = staging.slice(..);
let (tx, rx) = std::sync::mpsc::channel();
slice.map_async(wgpu::MapMode::Read, move |r| {
let _ = tx.send(r);
});
device.poll(wgpu::Maintain::Wait);
rx.recv()
.map_err(|e| format!("map_async recv: {:?}", e))?
.map_err(|e| format!("map_async: {:?}", e))?;
let data = slice.get_mapped_range();
let row_offset = (y as usize) * (bytes_per_row as usize);
let pixel_offset = row_offset + (x as usize) * (bytes_per_pixel as usize);
let bytes = &data[pixel_offset..pixel_offset + 16];
let r = f32::from_le_bytes([bytes[0], bytes[1], bytes[2], bytes[3]]);
let g = f32::from_le_bytes([bytes[4], bytes[5], bytes[6], bytes[7]]);
let b = f32::from_le_bytes([bytes[8], bytes[9], bytes[10], bytes[11]]);
let a = f32::from_le_bytes([bytes[12], bytes[13], bytes[14], bytes[15]]);
drop(data);
staging.unmap();
Ok([r, g, b, a])
}
// ── FFI glue ───────────────────────────────────────────────────────────────
extern "C" {
fn topolei_canvas_shim_string_cstr(s: *const c_void) -> *const c_char;
fn topolei_canvas_shim_io_ok_unit() -> *mut c_void;
fn topolei_canvas_shim_io_ok_rgba(r: f64, g: f64, b: f64, a: f64) -> *mut c_void;
}
fn lean_string_to_string(s: *const c_void) -> String {
unsafe {
let ptr = topolei_canvas_shim_string_cstr(s);
if ptr.is_null() {
String::new()
} else {
CStr::from_ptr(ptr).to_string_lossy().into_owned()
}
}
}
/// `topolei_run_path(path, pathParam, width, height, title, world) -> IO Unit`.
///
/// Single-panel render of one fiber of the cubical 1-cell `path`.
/// `pathParam` selects which fiber; the rendering is static — every
/// frame is the same fiber, the GPU just keeps it on screen until
/// the user closes the window. No host-side animation curve is
/// applied; that would not be a transport.
#[no_mangle]
pub extern "C" fn topolei_run_path(
path_obj: *const c_void,
path_param: f64,
width: u32,
height: u32,
title: *const c_void,
_world: *const c_void,
) -> *mut c_void {
let path = unsafe { eml::emlpath_from_lean(path_obj) };
let title_s = lean_string_to_string(title);
let _ = run_path_loop(vec![path], vec![path_param as f32], title_s, width, height);
unsafe { topolei_canvas_shim_io_ok_unit() }
}
/// `topolei_run_path2(pathL, ppL, pathR, ppR, width, height, title, world) -> IO Unit`.
///
/// Two-panel side-by-side variant. `pathL` is rendered at fiber
/// `ppL` on the left; `pathR` at fiber `ppR` on the right. Each
/// panel binds its own uniform buffer with its own pathParam — the
/// two panels do not share fibers. Use this when the visual demand
/// is "show two specific fibers side-by-side" (e.g. `at0` vs `at1`
/// of the same 1-cell to display its boundary).
#[no_mangle]
pub extern "C" fn topolei_run_path2(
path_l_obj: *const c_void,
pp_l: f64,
path_r_obj: *const c_void,
pp_r: f64,
width: u32,
height: u32,
title: *const c_void,
_world: *const c_void,
) -> *mut c_void {
let path_l = unsafe { eml::emlpath_from_lean(path_l_obj) };
let path_r = unsafe { eml::emlpath_from_lean(path_r_obj) };
let title_s = lean_string_to_string(title);
let _ = run_path_loop(
vec![path_l, path_r],
vec![pp_l as f32, pp_r as f32],
title_s,
width,
height,
);
unsafe { topolei_canvas_shim_io_ok_unit() }
}
/// `topolei_canvas_render_probe_path_pixel(path, w, h, time, pathParam, x, y, world) -> IO RGBA`.
///
/// Headless one-pixel readback for `render_faithful`. Same
/// fragment-naga construction as the live render. On GPU / adapter
/// failure, returns the sentinel `RGBA { -1, -1, -1, -1 }`.
#[no_mangle]
pub extern "C" fn topolei_canvas_render_probe_path_pixel(
path_obj: *const c_void,
width: u32,
height: u32,
time: f64,
path_param: f64,
x: u32,
y: u32,
_world: *const c_void,
) -> *mut c_void {
let path = unsafe { eml::emlpath_from_lean(path_obj) };
match offscreen_render_pixel_path(
&path, width, height, time as f32, path_param as f32, x, y,
) {
Ok([r, g, b, a]) => unsafe {
topolei_canvas_shim_io_ok_rgba(r as f64, g as f64, b as f64, a as f64)
},
Err(e) => {
eprintln!("topolei-canvas: render_probe_path_pixel: {}", e);
unsafe { topolei_canvas_shim_io_ok_rgba(-1.0, -1.0, -1.0, -1.0) }
}
}
}

View file

@ -1,20 +0,0 @@
#pragma once
#include <lean/lean.h>
#include <stdint.h>
#ifdef __cplusplus
extern "C" {
#endif
// Single shader fullscreen.
lean_obj_res topolei_run(lean_obj_arg shader, uint32_t width, uint32_t height,
lean_obj_arg title, lean_obj_arg world);
// Two shaders side by side in one window.
lean_obj_res topolei_run2(lean_obj_arg shaderL, lean_obj_arg shaderR,
uint32_t width, uint32_t height,
lean_obj_arg title, lean_obj_arg world);
#ifdef __cplusplus
}
#endif

View file

@ -1,32 +0,0 @@
# This file is automatically @generated by Cargo.
# It is not intended for manual editing.
version = 3
[[package]]
name = "cc"
version = "1.2.61"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d16d90359e986641506914ba71350897565610e87ce0ad9e6f28569db3dd5c6d"
dependencies = [
"find-msvc-tools",
"shlex",
]
[[package]]
name = "find-msvc-tools"
version = "0.1.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582"
[[package]]
name = "shlex"
version = "1.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64"
[[package]]
name = "topolei-render"
version = "0.1.0"
dependencies = [
"cc",
]

View file

@ -1,27 +0,0 @@
[package]
name = "topolei-render"
version = "0.1.0"
edition = "2021"
rust-version = "1.76"
description = "CPU SDF rasterizer for topolei's graph renderer. Native-only (std), sits beside the no_std cubical crate."
license = "MIT"
publish = false
[lib]
name = "topolei_render"
crate-type = ["staticlib"]
[dependencies]
[build-dependencies]
cc = "1.0"
[profile.release]
opt-level = 3
lto = true
codegen-units = 1
panic = "abort"
[profile.dev]
opt-level = 0
panic = "abort"

View file

@ -1,29 +0,0 @@
//! Build script — compiles the C shim that exposes Lean's inline
//! runtime helpers as real extern symbols (see shim.c).
//!
//! Mirrors `native/cubical/build.rs`. Native targets only.
fn main() {
let target = std::env::var("TARGET").unwrap_or_default();
if target.starts_with("wasm32") {
return;
}
let lean_include = std::env::var("LEAN_INCLUDE").unwrap_or_else(|_| {
let prefix = std::process::Command::new("lean")
.arg("--print-prefix")
.output()
.expect("failed to run `lean --print-prefix`; set LEAN_INCLUDE instead");
let prefix = String::from_utf8(prefix.stdout).unwrap();
format!("{}/include", prefix.trim())
});
cc::Build::new()
.file("shim.c")
.include(&lean_include)
.flag("-Wno-unused-parameter")
.compile("topolei_render_shim");
println!("cargo:rerun-if-changed=shim.c");
println!("cargo:rerun-if-env-changed=LEAN_INCLUDE");
}

View file

@ -1,72 +0,0 @@
/* shim.c — expose Lean's static inline runtime helpers as real extern
* symbols for the render crate's future Rust FFI to link against.
*
* Mirror of `native/cubical/shim.c`, kept separate so the render crate
* can link its own shim without dragging the cubical crate's object
* files into its build.
*
* Lean 4's `lean_obj_tag` / `lean_ctor_get` / `lean_ctor_set` /
* `lean_alloc_ctor` / `lean_inc` / `lean_dec` / `lean_string_cstr` /
* `lean_mk_string` are all `static inline` in `<lean/lean.h>`. A Rust
* staticlib that calls them via `extern "C"` produces unresolved
* references at link time. The wrappers below have real ELF symbols
* with the `topolei_render_shim_*` prefix. Zero overhead the
* compiler should inline the calls.
*
* Compiled by `build.rs` via the `cc` crate. Native targets only;
* wasm builds don't link against Lean's runtime.
*/
#include <lean/lean.h>
#include <stdint.h>
uint32_t topolei_render_shim_obj_tag(b_lean_obj_arg o) {
return lean_obj_tag(o);
}
lean_obj_res topolei_render_shim_ctor_get(b_lean_obj_arg o, unsigned i) {
return lean_ctor_get(o, i);
}
void topolei_render_shim_ctor_set(lean_object* o, unsigned i, lean_obj_arg v) {
lean_ctor_set(o, i, v);
}
lean_obj_res topolei_render_shim_alloc_ctor(unsigned tag, unsigned num_objs, unsigned scalar_sz) {
return lean_alloc_ctor(tag, num_objs, scalar_sz);
}
void topolei_render_shim_inc(b_lean_obj_arg o) {
lean_inc(o);
}
void topolei_render_shim_dec(b_lean_obj_arg o) {
lean_dec(o);
}
const char* topolei_render_shim_string_cstr(b_lean_obj_arg s) {
return lean_string_cstr(s);
}
lean_obj_res topolei_render_shim_mk_string(const char* s) {
return lean_mk_string(s);
}
/* ByteArray (lean_sarray) helpers — used by future bytes-based FFI.
* These aren't in the cubical shim because cubical operates on ctor
* objects, not byte buffers; for render we'll pass serialised
* primitive lists + shader bytes through ByteArrays. */
size_t topolei_render_shim_sarray_size(b_lean_obj_arg a) {
return lean_sarray_size(a);
}
const uint8_t* topolei_render_shim_sarray_cptr(b_lean_obj_arg a) {
return lean_sarray_cptr(a);
}
lean_obj_res topolei_render_shim_alloc_sarray1(size_t size, size_t capacity) {
/* Element size 1 (bytes); result has size==capacity elements ready
* to be filled by the caller. */
return lean_alloc_sarray(1, size, capacity);
}

View file

@ -1,41 +0,0 @@
//! # topolei-render
//!
//! Scaffolding crate for future Rust-side rendering work. Kept separate
//! from `native/cubical/` because the `no_std` + wasm-targeted cubical
//! crate shouldn't grow `std::fs` or heap allocation pressure unrelated
//! to cubical evaluation.
//!
//! ## Current state
//!
//! Contains one entry point — `topolei_render_version` — used as a
//! link-liveness check. The `shim.c` (in `shim.c` alongside this file)
//! mirrors `native/cubical/shim.c` so future FFI work can read Lean
//! objects properly instead of relying on hardcoded values.
//!
//! ## Planned entries (not yet implemented)
//!
//! - `topolei_render_sdf(prim, point) -> f32` — Rust-speed
//! implementation of `RenderPrim.sdf`. Wires via `@[extern]` +
//! `@[implemented_by]` on the Lean side. Equational laws
//! (`sdf_union_eq_min` etc.) are already `rfl` theorems in Lean;
//! the Rust impl must satisfy them by construction.
//!
//! - `topolei_render_compile_eml(expr_bytes) -> shader_bytes` —
//! Rust-side `compileEML` via `naga` (WGSL / SPIR-V / GLSL triple
//! target). Discharges `compileEML` + `compileEML_correct` in
//! `Topolei/GPU/Spec.lean`.
//!
//! ## FFI surface (today)
//!
//! - `topolei_render_version() -> u32`
//! Returns the crate's ABI version. Calling this from Lean and
//! getting `1` back confirms the staticlib is correctly linked.
// ── ABI version check ───────────────────────────────────────────────────────
/// Returns the render crate's ABI version. Bumped whenever the FFI
/// signatures below change incompatibly.
#[no_mangle]
pub extern "C" fn topolei_render_version() -> u32 {
1
}

View file

@ -1,210 +0,0 @@
#include "topolei/canvas.h"
// Lean C API — must come before any GL header.
#include <lean/lean.h>
// GLEW must be included before any GL header.
#include <GL/glew.h>
#define GLFW_INCLUDE_NONE
#include <GLFW/glfw3.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
// ── shader helpers ────────────────────────────────────────────────────────────
static const char* VERT_SRC = R"glsl(
#version 330 core
out vec2 uv;
void main() {
vec2 pos = vec2((gl_VertexID == 1) ? 3.0 : -1.0,
(gl_VertexID == 2) ? 3.0 : -1.0);
uv = pos * 0.5 + 0.5;
gl_Position = vec4(pos, 0.0, 1.0);
}
)glsl";
// Vertex shader that clips to the left or right half of the window.
// u_panel: 0 = left half, 1 = right half
static const char* VERT_PANEL_SRC = R"glsl(
#version 330 core
out vec2 uv;
uniform int u_panel; // 0 = left, 1 = right
void main() {
vec2 pos = vec2((gl_VertexID == 1) ? 3.0 : -1.0,
(gl_VertexID == 2) ? 3.0 : -1.0);
uv = pos * 0.5 + 0.5;
// Remap x into the panel's half: left=[1,0], right=[0,1]
pos.x = pos.x * 0.5 + (u_panel == 0 ? -0.5 : 0.5);
gl_Position = vec4(pos, 0.0, 1.0);
}
)glsl";
static GLuint compile_shader(GLenum type, const char* src) {
GLuint s = glCreateShader(type);
glShaderSource(s, 1, &src, NULL);
glCompileShader(s);
GLint ok; glGetShaderiv(s, GL_COMPILE_STATUS, &ok);
if (!ok) {
char log[2048]; glGetShaderInfoLog(s, sizeof(log), NULL, log);
fprintf(stderr, "shader compile error:\n%s\n", log);
return 0;
}
return s;
}
static GLuint link_program(const char* vert_src, const char* frag_src) {
GLuint vert = compile_shader(GL_VERTEX_SHADER, vert_src);
GLuint frag = compile_shader(GL_FRAGMENT_SHADER, frag_src);
if (!vert || !frag) return 0;
GLuint prog = glCreateProgram();
glAttachShader(prog, vert);
glAttachShader(prog, frag);
glLinkProgram(prog);
glDeleteShader(vert);
glDeleteShader(frag);
GLint ok; glGetProgramiv(prog, GL_LINK_STATUS, &ok);
if (!ok) {
char log[2048]; glGetProgramInfoLog(prog, sizeof(log), NULL, log);
fprintf(stderr, "program link error:\n%s\n", log);
return 0;
}
return prog;
}
static GLFWwindow* make_window(int width, int height, const char* title) {
if (!glfwInit()) { fprintf(stderr, "glfwInit failed\n"); return nullptr; }
glfwWindowHint(GLFW_CONTEXT_VERSION_MAJOR, 3);
glfwWindowHint(GLFW_CONTEXT_VERSION_MINOR, 3);
glfwWindowHint(GLFW_OPENGL_PROFILE, GLFW_OPENGL_CORE_PROFILE);
GLFWwindow* win = glfwCreateWindow(width, height, title, NULL, NULL);
if (!win) { fprintf(stderr, "glfwCreateWindow failed\n"); glfwTerminate(); return nullptr; }
glfwMakeContextCurrent(win);
glfwSwapInterval(1);
glewExperimental = GL_TRUE;
if (glewInit() != GLEW_OK) {
fprintf(stderr, "glewInit failed\n");
glfwDestroyWindow(win); glfwTerminate(); return nullptr;
}
return win;
}
// ── single fullscreen shader ──────────────────────────────────────────────────
static int topolei_run_internal(const char* shader_src, int width, int height, const char* title) {
GLFWwindow* win = make_window(width, height, title);
if (!win) return 1;
GLuint prog = link_program(VERT_SRC, shader_src);
if (!prog) { glfwDestroyWindow(win); glfwTerminate(); return 1; }
GLuint vao; glGenVertexArrays(1, &vao); glBindVertexArray(vao);
GLint time_loc = glGetUniformLocation(prog, "u_time");
GLint res_loc = glGetUniformLocation(prog, "u_resolution");
while (!glfwWindowShouldClose(win)) {
int w, h; glfwGetFramebufferSize(win, &w, &h);
glViewport(0, 0, w, h);
glUseProgram(prog);
if (time_loc >= 0) glUniform1f(time_loc, (float)glfwGetTime());
if (res_loc >= 0) glUniform2f(res_loc, (float)w, (float)h);
glDrawArrays(GL_TRIANGLES, 0, 3);
glfwSwapBuffers(win);
glfwPollEvents();
}
glDeleteVertexArrays(1, &vao);
glDeleteProgram(prog);
glfwDestroyWindow(win);
glfwTerminate();
return 0;
}
// ── two panels side by side ───────────────────────────────────────────────────
static int topolei_run2_internal(
const char* srcL, const char* srcR,
int width, int height, const char* title)
{
GLFWwindow* win = make_window(width, height, title);
if (!win) return 1;
GLuint progL = link_program(VERT_PANEL_SRC, srcL);
GLuint progR = link_program(VERT_PANEL_SRC, srcR);
if (!progL || !progR) { glfwDestroyWindow(win); glfwTerminate(); return 1; }
GLuint vao; glGenVertexArrays(1, &vao); glBindVertexArray(vao);
auto bind_uniforms = [](GLuint prog, int panel, float t, float w, float h) {
glUseProgram(prog);
GLint pl = glGetUniformLocation(prog, "u_panel");
GLint tl = glGetUniformLocation(prog, "u_time");
GLint rl = glGetUniformLocation(prog, "u_resolution");
if (pl >= 0) glUniform1i(pl, panel);
if (tl >= 0) glUniform1f(tl, t);
// Each panel sees only its half-width
if (rl >= 0) glUniform2f(rl, w * 0.5f, h);
};
while (!glfwWindowShouldClose(win)) {
int w, h; glfwGetFramebufferSize(win, &w, &h);
float t = (float)glfwGetTime();
glViewport(0, 0, w, h);
glClear(GL_COLOR_BUFFER_BIT);
// Left panel
glEnable(GL_SCISSOR_TEST);
glScissor(0, 0, w/2, h);
bind_uniforms(progL, 0, t, (float)w, (float)h);
glDrawArrays(GL_TRIANGLES, 0, 3);
// Right panel
glScissor(w/2, 0, w - w/2, h);
bind_uniforms(progR, 1, t, (float)w, (float)h);
glDrawArrays(GL_TRIANGLES, 0, 3);
glDisable(GL_SCISSOR_TEST);
// Divider line
glScissor(w/2 - 1, 0, 2, h);
glEnable(GL_SCISSOR_TEST);
glClearColor(0.5f, 0.5f, 0.6f, 1.0f);
glClear(GL_COLOR_BUFFER_BIT);
glClearColor(0.0f, 0.0f, 0.0f, 1.0f);
glDisable(GL_SCISSOR_TEST);
glfwSwapBuffers(win);
glfwPollEvents();
}
glDeleteVertexArrays(1, &vao);
glDeleteProgram(progL);
glDeleteProgram(progR);
glfwDestroyWindow(win);
glfwTerminate();
return 0;
}
// ── Lean FFI ──────────────────────────────────────────────────────────────────
extern "C" lean_obj_res topolei_run(
lean_obj_arg shader, uint32_t width, uint32_t height,
lean_obj_arg title, lean_obj_arg /* world */)
{
topolei_run_internal(lean_string_cstr(shader), (int)width, (int)height, lean_string_cstr(title));
return lean_io_result_mk_ok(lean_box(0));
}
extern "C" lean_obj_res topolei_run2(
lean_obj_arg shaderL, lean_obj_arg shaderR,
uint32_t width, uint32_t height,
lean_obj_arg title, lean_obj_arg /* world */)
{
topolei_run2_internal(
lean_string_cstr(shaderL), lean_string_cstr(shaderR),
(int)width, (int)height, lean_string_cstr(title));
return lean_io_result_mk_ok(lean_box(0));
}