Split: engine = cubical-transport HoTT only
Some checks are pending
Lean Action CI / build (push) Waiting to run
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:
parent
c2e3ecb3e3
commit
31d19f655e
67 changed files with 121 additions and 11956 deletions
|
|
@ -1,5 +1,5 @@
|
|||
import Topolei.Cubical.Readback
|
||||
import Topolei.Cubical.FFI
|
||||
import CubicalTransport.Readback
|
||||
import CubicalTransport.FFI
|
||||
|
||||
/-!
|
||||
CubicalBench.lean — Phase D.2 performance benchmarks.
|
||||
|
|
|
|||
|
|
@ -1,10 +1,10 @@
|
|||
import Topolei.Cubical.FFITest
|
||||
import Topolei.Cubical.PropertyTest
|
||||
import CubicalTransport.FFITest
|
||||
import CubicalTransport.PropertyTest
|
||||
|
||||
def main : IO UInt32 := do
|
||||
let smokeFails ← TopoleiCubicalFFITest.runSmokeTests
|
||||
let smokeFails ← CubicalTransportFFITest.runSmokeTests
|
||||
IO.println ""
|
||||
let propFails ← TopoleiCubicalPropertyTest.runProperties
|
||||
let propFails ← CubicalTransportPropertyTest.runProperties
|
||||
let total := smokeFails + propFails
|
||||
IO.println ""
|
||||
if total > 0 then
|
||||
|
|
|
|||
22
CubicalTransport.lean
Normal file
22
CubicalTransport.lean
Normal 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
|
||||
|
|
@ -23,9 +23,9 @@
|
|||
preservation lemma on `eval`/`readback` (Stream B #2a).
|
||||
-/
|
||||
|
||||
import Topolei.Cubical.System
|
||||
import Topolei.Cubical.TransportLaws
|
||||
import Topolei.Cubical.ValueTyping
|
||||
import CubicalTransport.System
|
||||
import CubicalTransport.TransportLaws
|
||||
import CubicalTransport.ValueTyping
|
||||
|
||||
-- ── Subject reduction for composition ────────────────────────────────────────
|
||||
|
||||
|
|
@ -18,7 +18,7 @@
|
|||
value at i selects the correct endpoint type.
|
||||
-/
|
||||
|
||||
import Topolei.Cubical.Subst
|
||||
import CubicalTransport.Subst
|
||||
|
||||
-- ── DimLine ───────────────────────────────────────────────────────────────────
|
||||
|
||||
|
|
@ -29,7 +29,7 @@
|
|||
"Priority order" item 3).
|
||||
-/
|
||||
|
||||
import Topolei.Cubical.Typing
|
||||
import CubicalTransport.Typing
|
||||
|
||||
-- ── Equivalence data ─────────────────────────────────────────────────────────
|
||||
|
||||
|
|
@ -22,8 +22,8 @@
|
|||
metric. For now, `partial def` is the honest choice.
|
||||
-/
|
||||
|
||||
import Topolei.Cubical.Value
|
||||
import Topolei.Cubical.Transport
|
||||
import CubicalTransport.Value
|
||||
import CubicalTransport.Transport
|
||||
|
||||
-- ── Rust FFI declarations (Phase C.2) ──────────────────────────────────────
|
||||
-- `@[extern "topolei_cubical_*"] opaque *Rust ...` declares the Rust
|
||||
|
|
@ -16,7 +16,7 @@
|
|||
· transport / composition terms produce the expected neutrals.
|
||||
-/
|
||||
|
||||
import Topolei.Cubical.Eval
|
||||
import CubicalTransport.Eval
|
||||
|
||||
-- ── Free variable ───────────────────────────────────────────────────────────
|
||||
|
||||
|
|
@ -60,4 +60,4 @@
|
|||
- `KERNEL_BOUNDARY.md` — what this delivers vs. what requires kernel work.
|
||||
-/
|
||||
|
||||
import Topolei.Cubical.Readback
|
||||
import CubicalTransport.Readback
|
||||
|
|
@ -14,14 +14,14 @@
|
|||
them inside a compiled binary where Rust IS linked.
|
||||
|
||||
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.
|
||||
-/
|
||||
|
||||
import Topolei.Cubical.Readback
|
||||
import Topolei.Cubical.FFI
|
||||
import CubicalTransport.Readback
|
||||
import CubicalTransport.FFI
|
||||
|
||||
namespace TopoleiCubicalFFITest
|
||||
namespace CubicalTransportFFITest
|
||||
|
||||
-- ── Summarisers ────────────────────────────────────────────────────────────
|
||||
|
||||
|
|
@ -151,4 +151,4 @@ def runSmokeTests : IO UInt32 := do
|
|||
IO.println s!"── {tests.length - fails.toNat} / {tests.length} passed ──"
|
||||
return fails
|
||||
|
||||
end TopoleiCubicalFFITest
|
||||
end CubicalTransportFFITest
|
||||
|
|
@ -11,7 +11,7 @@
|
|||
The key invariant: (i=0) and (i=1) are mutually exclusive and jointly exhaustive.
|
||||
-/
|
||||
|
||||
import Topolei.Cubical.Interval
|
||||
import CubicalTransport.Interval
|
||||
|
||||
-- ── Face formulas ─────────────────────────────────────────────────────────────
|
||||
|
||||
|
|
@ -53,8 +53,8 @@
|
|||
CType-function level (`DimExpr → CType`) instead.
|
||||
-/
|
||||
|
||||
import Topolei.Cubical.Eval
|
||||
import Topolei.Cubical.Equiv
|
||||
import CubicalTransport.Eval
|
||||
import CubicalTransport.Equiv
|
||||
|
||||
-- ── Ergonomic glue-type construction from EquivData ─────────────────────────
|
||||
|
||||
|
|
@ -45,7 +45,7 @@
|
|||
*where* the obligation is discharged.
|
||||
-/
|
||||
|
||||
import Topolei.Cubical.Transport
|
||||
import CubicalTransport.Transport
|
||||
|
||||
-- ── DimLine.inv ──────────────────────────────────────────────────────────────
|
||||
-- Reversed line via DimExpr substitution. `.inv (.var i)` flips the
|
||||
|
|
@ -16,10 +16,10 @@
|
|||
`cubical-test` exe; see `CubicalTest.lean` for wiring.
|
||||
-/
|
||||
|
||||
import Topolei.Cubical.Readback
|
||||
import Topolei.Cubical.FFI
|
||||
import CubicalTransport.Readback
|
||||
import CubicalTransport.FFI
|
||||
|
||||
namespace TopoleiCubicalPropertyTest
|
||||
namespace CubicalTransportPropertyTest
|
||||
|
||||
-- ── 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 ──"
|
||||
return totalFails
|
||||
|
||||
end TopoleiCubicalPropertyTest
|
||||
end CubicalTransportPropertyTest
|
||||
|
|
@ -54,7 +54,7 @@
|
|||
env shadowing provides capture-avoidance.
|
||||
-/
|
||||
|
||||
import Topolei.Cubical.Eval
|
||||
import CubicalTransport.Eval
|
||||
|
||||
-- ── Inhabited instance for CTerm ────────────────────────────────────────────
|
||||
-- Needed for `partial def` elaboration: Lean's partial-fixpoint compilation
|
||||
|
|
@ -49,9 +49,9 @@
|
|||
cells-layer work; stated briefly below for completeness.
|
||||
-/
|
||||
|
||||
import Topolei.Cubical.TransportLaws
|
||||
import Topolei.Cubical.CompLaws
|
||||
import Topolei.Cubical.Glue
|
||||
import CubicalTransport.TransportLaws
|
||||
import CubicalTransport.CompLaws
|
||||
import CubicalTransport.Glue
|
||||
|
||||
namespace Soundness
|
||||
|
||||
|
|
@ -25,7 +25,7 @@
|
|||
it requires DimExpr.subst commutativity, which needs its own treatment.
|
||||
-/
|
||||
|
||||
import Topolei.Cubical.Syntax
|
||||
import CubicalTransport.Syntax
|
||||
|
||||
-- ── CTerm.substDimBool ────────────────────────────────────────────────────────
|
||||
|
||||
|
|
@ -22,7 +22,7 @@
|
|||
`CompLaws.lean`.
|
||||
-/
|
||||
|
||||
import Topolei.Cubical.Face
|
||||
import CubicalTransport.Face
|
||||
|
||||
-- ── Syntax ────────────────────────────────────────────────────────────────────
|
||||
|
||||
|
|
@ -19,7 +19,7 @@
|
|||
· 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
|
||||
-- from Typing. The HasType.comp rule uses raw components. This file provides
|
||||
-- the System.Valid → HasType.comp convenience bridge.)
|
||||
|
|
@ -33,8 +33,8 @@
|
|||
reversal.
|
||||
-/
|
||||
|
||||
import Topolei.Cubical.Value
|
||||
import Topolei.Cubical.DimLine -- for CType.dimAbsent and substDimExpr
|
||||
import CubicalTransport.Value
|
||||
import CubicalTransport.DimLine -- for CType.dimAbsent and substDimExpr
|
||||
|
||||
-- ── Rust FFI declaration (Phase C.2) ──────────────────────────────────────
|
||||
|
||||
|
|
@ -25,7 +25,7 @@
|
|||
`readback_transp_plam_general` in `Readback.lean` (Stream B #2c).
|
||||
-/
|
||||
|
||||
import Topolei.Cubical.ValueTyping
|
||||
import CubicalTransport.ValueTyping
|
||||
|
||||
-- ── Subject reduction for transport ──────────────────────────────────────────
|
||||
|
||||
|
|
@ -22,7 +22,7 @@
|
|||
Dependent Π is deferred until we have a term evaluator.
|
||||
-/
|
||||
|
||||
import Topolei.Cubical.DimLine
|
||||
import CubicalTransport.DimLine
|
||||
|
||||
-- ── Context ───────────────────────────────────────────────────────────────────
|
||||
|
||||
|
|
@ -21,7 +21,7 @@
|
|||
evaluator will grow a companion `evalType` returning a `VType`.
|
||||
-/
|
||||
|
||||
import Topolei.Cubical.Syntax
|
||||
import CubicalTransport.Syntax
|
||||
|
||||
mutual
|
||||
/-- Name-keyed environment: a cons-list of `(name, value)` bindings. The
|
||||
|
|
@ -60,8 +60,8 @@
|
|||
surface scales O(1) in type-formers, not O(n).
|
||||
-/
|
||||
|
||||
import Topolei.Cubical.Typing
|
||||
import Topolei.Cubical.Readback
|
||||
import CubicalTransport.Typing
|
||||
import CubicalTransport.Readback
|
||||
|
||||
-- ── Semantic typing (declarative stubs) ─────────────────────────────────────
|
||||
|
||||
132
HYPOTHESES.md
132
HYPOTHESES.md
|
|
@ -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?
|
||||
94
Main.lean
94
Main.lean
|
|
@ -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."
|
||||
473
NAGA_IR_PLAN.md
473
NAGA_IR_PLAN.md
|
|
@ -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 1–6 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 1–5** (hardcoded red → full
|
||||
probe parity). That's ~500–800 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 6–7 (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
199
PLAN.md
|
|
@ -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 (~30–80MB 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 1–4 + 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
|
||||
|
|
@ -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
|
||||
93
README.md
93
README.md
|
|
@ -1,51 +1,58 @@
|
|||
# topolei
|
||||
# cubical-transport-hott-lean4
|
||||
|
||||
A Lean 4 extension adding cubical-transport homotopy type theory to Lean 4
|
||||
via a Rust FFI module.
|
||||
A Lean 4 implementation of cubical-transport homotopy type theory
|
||||
(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
|
||||
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`** — H1–H4 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.
|
||||
## What's here
|
||||
|
||||
## 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
|
||||
*first* specified as Lean axioms; the one Rust FFI component later
|
||||
discharges those axioms via `@[extern]` + `@[implemented_by]`.
|
||||
## Reusing this engine
|
||||
|
||||
**The one Rust component** is the cubical evaluator backend (plus GPU
|
||||
runtime within the same crate). Its purpose is extending Lean 4 with
|
||||
computational cubical-transport HoTT.
|
||||
Add as a Lake dependency from another Lean 4 project:
|
||||
|
||||
**Everything else is Lean**, including the zigzag n-category engine
|
||||
(being ported in) and the numerical layer. The medium-term goal is to
|
||||
maximise what can be reasoned about inside Lean; Rust is used only
|
||||
where fundamentally required (effects) or as a post-spec optimisation
|
||||
target.
|
||||
```toml
|
||||
[[require]]
|
||||
name = "cubicalTransport"
|
||||
path = "../cubical-transport-hott-lean4" # or git = "..."
|
||||
```
|
||||
|
||||
The cubical core (Phase 1) is closed with zero Rust dependency. Many
|
||||
subsequent phases (Cells, Reactive, Color, Shader IR, Boundary models,
|
||||
Meta, Zigzag) are pure-Lean extensions of the axiom base.
|
||||
Then `import CubicalTransport.Syntax`, `import CubicalTransport.Eval`,
|
||||
etc. Link against `native/cubical/target/release/libtopolei_cubical.a`
|
||||
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.
|
||||
|
|
|
|||
152
REFERENCES.md
152
REFERENCES.md
|
|
@ -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
|
||||
|
|
@ -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 2–3 focused sessions.
|
||||
|
||||
---
|
||||
|
||||
## 7. Float semantics axioms — still carry a debt
|
||||
|
||||
The current pipeline relies on ~8 IEEE 754 axioms scattered across
|
||||
`GPU/Spec.lean` and `Render/ProjScene.lean`: `Float.log_one`,
|
||||
`Float.max_one_ge_eps`, `Float.sub_zero`, `Float.one_ne_zero_beq`,
|
||||
`Float.neg_one_mul_neg_one`, `Float.one_mul`, `Float.mul_assoc`.
|
||||
|
||||
**Status:** uncontroversial by construction, but each is an unverified
|
||||
assumption about the GPU's Float behaviour. Rust's `f32` is bit-exact
|
||||
IEEE; Vulkan's driver *mostly* is but allows some fast-math modes.
|
||||
|
||||
**The closure:** bundle these into one module (`Topolei/Float/IEEE.lean`)
|
||||
with a comment stating the driver-level expectations, and add one more:
|
||||
`Float.sin_range : ∀ t, -1 ≤ Float.sin t ≤ 1` (needed for `sineSweep`).
|
||||
|
||||
**Scope:** ~20 lines consolidation + 1 new axiom.
|
||||
|
||||
---
|
||||
|
||||
## 8. The `topolei_run2` two-panel path is unimplemented
|
||||
|
||||
`canvasRun2` (side-by-side two-shader panel) was handled by canvas.cpp's
|
||||
`topolei_run2_internal` via OpenGL `glScissor`. The wgpu port's
|
||||
`topolei_run2` currently falls back to rendering only the left panel.
|
||||
|
||||
**The fix:** one render pass with two sub-pipelines and a scissor rect
|
||||
per panel, matching the C++ logic. wgpu's
|
||||
`RenderPass::set_scissor_rect(x, y, w, h)` is the equivalent of
|
||||
`glScissor`.
|
||||
|
||||
**Scope:** ~120 lines Rust. No Lean changes — the FFI signature is
|
||||
already in place.
|
||||
|
||||
---
|
||||
|
||||
## 9. `canvas.cpp` + `CMakeLists.txt` should be retired
|
||||
|
||||
Once the formal bridge is closed and the wgpu path is proved
|
||||
equivalent, the OpenGL canvas can be deleted from the tree. Until then,
|
||||
they remain as:
|
||||
|
||||
- a working baseline if the wgpu path regresses;
|
||||
- reference for the `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:** 1–2 full sessions. Depends on the cubical-crate's wasm
|
||||
harness already working.
|
||||
|
||||
---
|
||||
|
||||
## 13. Summary table of work by item
|
||||
|
||||
| # | Component | Lines | Lean | Rust | Session count |
|
||||
|----|------------------------------------|------:|:----:|:----:|:-------------:|
|
||||
| 2 | `FrameUniforms.pathParam` | 60 | ✓ | | 0.5 |
|
||||
| 3 | `PathDriver` + `sineSweep` | 40 | ✓ | | 0.3 |
|
||||
| 4 | `render_faithful` + `readPixel` | 100 | ✓ | ✓ | 1 |
|
||||
| 5 | `cvalPathToEMLPath` | 200 | ✓ | | 1–2 |
|
||||
| 6 | Projection / Observation → GPU | 900 | ✓ | ✓ | 3–4 |
|
||||
| 7 | Float axiom consolidation | 20 | ✓ | | 0.2 |
|
||||
| 8 | `topolei_run2` scissor panes | 120 | | ✓ | 0.5 |
|
||||
| 9 | Retire canvas.cpp | — | | | 0.1 |
|
||||
| 10 | Merge render into canvas-rs | — | | ✓ | 0.5 |
|
||||
| 11 | Dynamic nix paths in lakefile | 80 | | | 0.3 |
|
||||
| 12 | wasm32 target | 150 | | ✓ | 1–2 |
|
||||
|
||||
**Critical path to a complete n-dim backend:** items 2 → 3 → 4 → 5 → 6.
|
||||
Items 7–12 are polish / portability / cleanup.
|
||||
|
||||
---
|
||||
|
||||
## 14. Design principle to keep
|
||||
|
||||
Every row in the table in §1 that currently reads AXIOM or NOT WRITTEN
|
||||
should resolve to **either a theorem (provable in Lean) or a `@[extern]`
|
||||
opaque with `@[implemented_by]` wiring** (a Rust function whose
|
||||
correctness is specified by remaining axioms about its behaviour). In
|
||||
all cases the Lean kernel reasons through *specs*, not Rust. Rust
|
||||
provides *speed and effect*; correctness stays in Lean.
|
||||
|
||||
Any time we're tempted to shortcut by making something computable via
|
||||
a Float constant or a hardcoded value (a "driver without spec", a
|
||||
"shader without semantic axiom", a "projection without composition
|
||||
theorem"), that's a bridge gap in the making. Adding the spec up front
|
||||
is cheaper than forensic reconstruction later.
|
||||
31
Topolei.lean
31
Topolei.lean
|
|
@ -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
|
||||
|
|
@ -1 +0,0 @@
|
|||
def hello := "world"
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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"
|
||||
}
|
||||
|
|
@ -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.
|
||||
|
|
@ -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]
|
||||
|
||||
-- ── EMLPath–GPU 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.
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
343
ZIGZAG_PORT.md
343
ZIGZAG_PORT.md
|
|
@ -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,500–3,000 Lean lines** to match the
|
||||
algorithmic core of the Rust implementation, with proofs adding
|
||||
perhaps another 1,000–2,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 5–6.
|
||||
|
||||
**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 1–4: ~2 weeks (data structures + basic algorithms).
|
||||
- Steps 5–7: ~3–4 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: 6–8 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.
|
||||
23
build.sh
23
build.sh
|
|
@ -2,30 +2,13 @@
|
|||
set -e
|
||||
cd "$(dirname "$0")"
|
||||
|
||||
GLFW_PC="/nix/store/hpdf5fwl5arkc8d625cxba604i8dwnvp-glfw-3.4/lib/pkgconfig"
|
||||
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 ──"
|
||||
echo "── building Rust cubical kernel ──"
|
||||
# Native staticlib for Lean linkage. Wasm build is a separate step
|
||||
# invoked by `cargo build --target wasm32-unknown-unknown` on demand.
|
||||
(cd native/cubical && cargo build --release)
|
||||
|
||||
echo "── building Rust render backend ──"
|
||||
# Scaffolding crate for future render-side FFI work.
|
||||
(cd native/render && cargo build --release)
|
||||
|
||||
echo "── building Lean ──"
|
||||
echo "── building Lean library + tests ──"
|
||||
lake build
|
||||
|
||||
echo "── done ──"
|
||||
echo "run: ./.lake/build/bin/topolei"
|
||||
echo "run: ./.lake/build/bin/cubical-test"
|
||||
|
|
|
|||
2391
cells-spec.md
2391
cells-spec.md
File diff suppressed because it is too large
Load diff
BIN
exp-log.pdf
BIN
exp-log.pdf
Binary file not shown.
|
|
@ -1,14 +1,14 @@
|
|||
name = "topolei"
|
||||
name = "cubicalTransport"
|
||||
version = "0.1.0"
|
||||
defaultTargets = ["topolei", "cubical-test"]
|
||||
defaultTargets = ["cubical-test"]
|
||||
|
||||
[[lean_lib]]
|
||||
name = "Topolei"
|
||||
name = "CubicalTransport"
|
||||
|
||||
[[lean_exe]]
|
||||
name = "cubical-test"
|
||||
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.
|
||||
moreLinkArgs = [
|
||||
"./native/cubical/target/release/libtopolei_cubical.a",
|
||||
|
|
@ -21,91 +21,3 @@ root = "CubicalBench"
|
|||
moreLinkArgs = [
|
||||
"./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",
|
||||
]
|
||||
|
|
|
|||
|
|
@ -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})
|
||||
2221
native/canvas-rs/Cargo.lock
generated
2221
native/canvas-rs/Cargo.lock
generated
File diff suppressed because it is too large
Load diff
|
|
@ -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"
|
||||
|
|
@ -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");
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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 4–7 (struct layout, `ResourceBinding`, `Binding` shape,
|
||||
//! NDC y-flip) become live in Stages 2–3.
|
||||
|
||||
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,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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.
|
||||
|
|
@ -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) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
32
native/render/Cargo.lock
generated
32
native/render/Cargo.lock
generated
|
|
@ -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",
|
||||
]
|
||||
|
|
@ -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"
|
||||
|
|
@ -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");
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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));
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue