Ixon: .ixe bundle format and diff tool - #474

Merged
johnchandlerburnham merged 8 commits into
mainfrom
jcb/ixe-improvements
Jul 14, 2026
Merged

Ixon: .ixe bundle format and diff tool#474
johnchandlerburnham merged 8 commits into
mainfrom
jcb/ixe-improvements

Conversation

@johnchandlerburnham

@johnchandlerburnhamjohnchandlerburnham commented Jul 5, 2026

Copy link
Copy Markdown
Member

Two related changes

  1. the .ixebundle format (a single env file that pins one Lean value as a self-contained, verifiable unit — the groundwork for Ixon as a client/server interchange format), plus its first producer CLI, ix pack
  2. ix diff — a structured, content-address-aware diff of two .ixe environments, with ripple root-causing (separating intrinsic edits from the content-address ripple) and a memory-lean --meta, validated at mathlib scale.

Part 1 — .ixe bundle format

Evolves the format in place (pre-alpha, Tag4(0xE,0) kept, no compat shims). A constant's address already pins its entire dependency DAG (refs tables are content addresses, recursively); this adds the transport half: a distinguished root, an explicit trust boundary for thin bundles, and closure helpers to produce/validate them.

[Tag4(0xE,0)] [consts merkle root : 32]
[main : u8 flag + 32? ] -- NEW: bundle root; readers enforce main ∈ consts
[assumptions : Tag0 count + 32*] -- NEW: strictly ascending; thin-bundle cut points
§1 blobs -- NEW: blake3(bytes) == addr verified per entry, every reader
§2 consts -- unchanged (already verified)
§3 anon_hints -- moved before metadata; ALWAYS written (derived from Named Def
-- metadata when the map is empty)
§4 names / §5 named / §6 comms -- metadata last
  • Metadata-last reorder: get_anon/get_anon_mmap read §1–§3 and stop — no more parse-and-discard of names/named/comms; the full readers reject trailing bytes.
  • §3 is the canonical hint channel: read-time hint harvesting is gone. merkle_root_canonical(assumptions) reproduces Claim.assumptions roots, so claim interop is derivable, not stored.
  • Integrity: blobs were previously unverified in every reader — a swapped blob silently changed a Nat/String literal under an otherwise-valid file. Lean getEnv also gains the per-const hash check it was missing.
  • Closure helpers: Env::bfs_closure (3-edge traversal: refs, projection → Muts block, block → member/ctor projection addresses), Env::prune_to_closure(main, assumed) (minimal closed bundle incl. display metadata), Env::validate_closed() (receiver check). kernel::anon_work::closure_addrs now delegates to bfs_closure.
  • Byte-identical mirrors (bonus fix): the Lean and Rust writers were never byte-identical (anonymous-name table entry 0; latent putIdxgetD 0 collision). Now locked by strict serEnv == rsSerEnv tests — byte-exact at 505,759,024 bytes on the whole-stdlib env.

ix pack — producing bundles

lake exe ix pack <env.ixe> <name> [--out <path>] [--assume <name|hex64,...>] [--assume-file <f>] [--verbose]

The first production consumer of prune_to_closure: resolves <name> (displayed form) against the env's named table, prunes to the self-contained closure — main set, reached cut points recorded in assumptions, display metadata carried to fixpoint — re-validates with validate_closed, and writes the bundle (default <name>.ixe). --assume entries (names or 64-hex constant addresses) declare thin-bundle trust boundaries. Euclid's theorem out of the 3.2 GB mathlib env:

❯ lake exe ix pack compilemathlib-8850ed93.ixe Nat.exists_infinite_primes --verbose
[rs_pack_env] main 091d37b69086bf163ecfb6c12233e1e741655b8ab09f76979f0bbbdc0c3176b5 (Nat.exists_infinite_primes)
[rs_pack_env] kept 3138/648914 consts, 10181/746073 named, 9504/329107 blobs, 0 assumption(s)
[rs_pack_env] wrote Nat.exists_infinite_primes.ixe (13006199 bytes)

3,138 constants (0.5% of mathlib), 13 MB, closed by construction — main's 32 bytes alone pin the value. (ix shard extract remains the non-bundle sibling: a general sub-env for the kernel-check pipeline, no main root.) Pack v1 reads with the full reader so metadata survives the prune; the 3.2 GB parse dominates its runtime — a leaner selective-metadata pack is future work on the §5 streaming machinery from Part 2.

Part 2 — ix diff

lake exe ix diff <old.ixe> <new.ixe> [--anon | --meta] [--verbose]

Content-addressing frames the semantics: a name "changed" ⇔ its constant address changed, and value-equality coincides with address-equality — so the interesting work is saying what changed, honestly.

[diff] old.ixe: 647127 consts, 743832 named, 328281 blobs, 0 comms
[diff] new.ixe: 648914 consts, 746073 named, 329107 blobs, 0 comms
named: 3763 added, 1522 removed, 143528 changed (4516 roots, 139012 rippled)
+ AddEquiv.withBotCongr 33ccd4f845d0…
~ AbsoluteValue.exists_partition_int defn a382796a804c… → f1cee63a8923… [type, value]
~ RecursiveIn iprj→defn 13b38a4e0f38… → 3fe097b1e646… [kind]
(139012 rippled rows hidden — address changes fully explained by dependency re-addressing; --verbose lists)
consts: 140144 only in old, 141931 only in new (mutual blocks/projections)
hints changed: 1

Exit codes follow GNU diff: 0 = no difference (in the selected mode), 1 = differences, 2 = error. Byte-equal files short-circuit to identical without parsing (mmap memcmp; 1.5 s on a 3 GB input).

Modes

  • --anon (default): compares only anonymous structure — name→addr changes with per-field classification, consts/blobs set differences, comms, main/assumptions, and reducibility hints (they live in anon §3 and drive kernel unfolding). Names are join/display keys only.
  • --meta: additionally compares Named metadata per component (meta.info with kind transitions, meta.sharing/refs/univs, original.added/removed/addr/meta) — a same-addr metadata-only change is its own report category.

Field-level classification (Rust engine, ixon::diff)

  • exprs_equal is an iterative lockstep walk that resolves Share/Sort/Ref/ Str/Nat/Prj indices through each side's own constant tables: identical expr bytes over different refs tables compare different; permuted tables with equal resolution compare equal. Pointer-pair memoized (shared subterms don't re-walk), Share-cycle-guarded, no recursion (kernel terms nest thousands deep).
  • Per-kind field labels (type, value, lvls, safety, rules[i].rhs, ctors[j].type, …). Kind changes report kind; an address change with no detected semantic difference reports encoding (representation churn) — never an empty list.
  • Projection block descent: inductives/ctors/recursors live only inside Muts blocks behind projections, so "block changed" alone would gut the tool. Same-kind projections with a changed block descend one level: changed member → block.* labels; untouched member → block-siblings (the block hash moved because a sibling changed — the common, most informative case).

Ripple root-causing (root vs rippled)

One edited constant re-addresses its whole reverse-dependency cone, so a content-addressed diff drowns the human edits in transitive re-addressings. Every changed row now carries a verdict: after the join, a second pass re-classifies each changed pair under a quotient where an (old, new) address pair compares equal when some changed name maps old→new. Rows whose residual labels are all encoding/block-siblings are rippled — fully explained by their dependencies' re-addressing; the rest are roots (intrinsic edits). A single-level map is complete because expression comparison only ever consults immediate-dependency addresses — composition across the DAG happens through the per-row verdicts, never M∘M.

  • fields keeps the strict classification; rippled : Bool is the orthogonal verdict. The default listing shows roots (plus, under --meta, rippled rows carrying metadata edits); --verbose lists everything.
  • The map is set-valued (name splits tolerated) and built from every changed row, kind changes included — so a iprj→defn refactor's dependents still ripple.
  • Block descent runs under the quotient too: an intrinsically edited ctor's projection is the root (block.ctor.type), its untouched siblings ripple, and blocks themselves (unnamed) never enter the map — descent is mandatory or block-internal roots would vanish.
  • Documented accepted edges, all fail-safe over-reports: induced re-elaboration (a dependency's universe/arity signature change alters dependents' terms beyond addresses) verdicts root; an intrinsically edited block member with no named projection has no root row of its own; the block fallback on assumption-cut bundles verdicts root.
  • Alias rows share one cached verdict per (old, new) pair; constants re-parse rather than stash (the deliberate LazyConstant no-cache policy keeps memory flat). The pass costs ~one extra classify sweep (+16 s at mathlib scale).

Memory: --meta without the OOM, and an mmap fast path

The first --meta design read both envs with the full reader — at mathlib scale that materializes every ConstantMeta twice and OOM'd a 124 GiB machine. Both modes now load via the lazy index, and meta mode compares metadata by streaming both files' §5 named sections in a lockstep merge-join (§5 is written in ascending name-hash order — exactly Name's Ord — in every file): parse one entry per side against its own §4 reverse index, compare, drop. Resident metadata is O(1) instead of everything-at-once.

Raw §5 byte windows are not comparable across files — metadata name references are file-relative §4 indices, so identical metadata serializes to different bytes over different name tables. The sweep therefore compares parsed, Address-valued ConstantMeta (index-independent), pinned by a §4 index-shift soundness regression test and full-reader parity tests.

The CLI also stopped reading files into Lean ByteArrays entirely: rs_diff_env_files mmaps both inputs (constant windows stay zero-copy mmap slices backed by the OS page cache) and rs_ixe_files_equal does the byte-equal fast path. Anon mode dropped from 54 GiB heap-resident to 8 GiB peak with a byte-identical report.

Progress reporting

Large inputs (≥ 100 MB) stream progress to stderr (stdout stays clean for piping): per-env parse timings, then live per-phase events — meta sweep (meta mode), named join, ripple pass:

❯ lake exe ix diff compilemathlib-4_29.ixe compilemathlib-8850ed93.ixe
[rs_diff_envs] parsing first env (3211 MB, lazy reader)...
[rs_diff_envs] first env parsed in 12.9s (647127 consts, 743832 named, 328281 blobs)
[rs_diff_envs] parsing second env (3222 MB, lazy reader)...
[rs_diff_envs] second env parsed in 12.1s (648914 consts, 746073 named, 329107 blobs)
[rs_diff_envs] named join: 100000/743832 (19300 changed so far)
...
[rs_diff_envs] named join: 743832/743832 (143528 changed so far)
[rs_diff_envs] ripple pass: 10000/143528 (317 roots so far)
...
[rs_diff_envs] ripple pass: 143528/143528 (4516 roots so far)
[rs_diff_env_files] diff computed in 44.1s

Mathlib-scale validation

Diffed the v4.29.0 tag env against 8850ed93 (the last mathlib master commit on the v4.29.0 toolchain — 5 days of master; the Benchmarks/Compile pin is bumped accordingly): two 3.2 GB envs, 744k named join. **Anon: 65 s wall, 8 GiB peak. --meta: 67 s, 8 GiB

[diff] compilemathlib-4_29.ixe: 647127 consts, 743832 named, 328281 blobs, 0 comms
[diff] compilemathlib-8850ed93.ixe: 648914 consts, 746073 named, 329107 blobs, 0 comms
named: 3763 added, 1522 removed, 143528 changed (4516 roots, 139012 rippled)
(synthetic mutual-block names: 627 added, 614 removed — --verbose lists)
+ AddAction.IsPretransitive.of_orbit 2ee60e47dad5…
+ AddEquiv.coe_withBotCongr 2c55b9612a26…
+ AddEquiv.withBotCongr 33ccd4f845d0…
...
- AddCommGroup.ofIsAddCommutative 5ec793909daf…
- AddCommMonoid.ofIsAddCommutative c4954ee67492…
...
~ AbsoluteValue.exists_partition_int defn a382796a804c… → f1cee63a8923… [type, value]
~ AddAction.isClosedMap_quotient defn dbba20e21c96… → 4deaf8b7fdc7… [value]
...
~ CategoryTheory.Classifier iprj→defn c632275bad82… → 28096c100344… [kind]
~ CategoryTheory.HasClassifier iprj→defn 2ba3ede041e1… → 46a6379b87bc… [kind]
~ LinearPMap.mk cprj 1df2b7a39e8a… → ae57a82f6164… [block.ctor.lvls, block.ctor.params, block.ctor.type]
~ OmegaCompletePartialOrder.Chain defn→iprj b6326c126d8f… → 04e157a9b4cd… [kind]
~ RecursiveIn iprj→defn 13b38a4e0f38… → 3fe097b1e646… [kind]
~ RecursiveIn.oracle cprj→defn 2796cba80450… → 3d3d403cff32… [kind]
...
(139012 rippled rows hidden — address changes fully explained by dependency re-addressing; --verbose lists)
consts: 140144 only in compilemathlib-4_29.ixe, 141931 only in compilemathlib-8850ed93.ixe (mutual blocks/projections; --verbose lists)
blobs: 678 only in compilemathlib-4_29.ixe, 1504 only in compilemathlib-8850ed93.ixe
hints changed: 1
38bfaaa99e15… abbrev → regular(2)
[diff] compilemathlib-4_29.ixe ≠ compilemathlib-8850ed93.ixe
  • 3,763 added / 1,522 removed / 143,528 changed, of which 4,516 roots and 139,012 rippled — 96.9% of the changed set is the content-address ripple, explained away by the quotient. Rippled label distribution confirms the shape: 120,941 [type, value] + 15,834 [value] pure re-addressings.
  • The roots are the human-edit surface: all 5 kind changes (CategoryTheory.Classifier/HasClassifieriprj→defn, OmegaCompletePartialOrder.Chaindefn→iprj, RecursiveIn/.oracle destructured), all 245 universe-signature changes ([lvls, type, value]), and 8 genuine constructor edits (e.g. LinearPMap.mk gaining block.ctor.lvls/params/type). The 613 block.ctor.type rows the strict classifier had surfaced turn out to be 8 real edits + 605 induced re-addressings — exactly the noise/signal split the verdict exists for.
  • Verdicts are conservative and checkable: zero rippled rows carry a scalar/coordinate label (machine-checked over all 139k), kind/lvls/cidx changes always root, and the report is deterministic across runs.
  • --meta additionally finds 7,998 names with differing metadata: 867 metadata-only rows (same address — binder/arena edits like …._proof_1 [meta.info]) and 7,131 changed rows with meta labels.
  • Zero [encoding] entries — compilation is deterministic end-to-end.
  • 1 hint change (abbrev → regular(2)) on an otherwise-unchanged constant, correctly absent from the named rows (hints live in §3 and don't re-address constants).

Breaking change

All existing .ixe files are stale — regenerate with ix compile (readers emit a "pre-bundle-format .ixe; recompile it" hint). FFI RawEnv ctor arity is 5→8, RawEnvLazy 3→5; NamedDiff gains a rippled : Bool scalar field.

Verification

  • Rust: full workspace tests green, clippy --all-features clean. ixon at 247 tests; the diff engine alone carries 34 (refs-shift guard, share-vs-inline, univ-table shift, memo-clearing regression, block descent, lazy/full parity, per-phase progress totals, 14 ripple-verdict tests incl. two-hop chains / name splits / renamed deps / induced re-elaboration, §5-sweep parity vs the full reader, the §4 index-shift soundness regression, mmap-side parity).
  • Lean: full lake test green; FFI tests incl. self-diff-empty properties over generated envs in both modes (pins marshaling slot order), a ripple-verdict marshaling test (pins the num_8 slot), file-based-vs-bytes-based diff parity, and six ix pack fixtures (closure bundle, removals-only diff subset, name/hex --assume cuts, error paths).
  • Cross-language: whole-stdlib env byte-exact between writers (505,759,024 bytes); ix check-rs 60,601/60,601 (meta) and 51,681/51,681 (anon); bench-typecheck executes and proves over the new format.

Follow-ups

  • prune_to_closure rescans env.named per fixpoint round — worth a reverse index if bulk pruning against mathlib-scale envs becomes common; the same §5 streaming machinery would also let ix pack skip the full-reader parse.
  • Bundle transport niceties (store sync protocol) remain future work.

Base automatically changed from jcb/compiler to mainJuly 7, 2026 23:12
@johnchandlerburnhamjohnchandlerburnham changed the title Ixon: .ixe bundle format — main/assumptions header, metadata-last sections, blob integrityIxon: .ixe bundle format and diff toolJul 10, 2026
@johnchandlerburnham
johnchandlerburnhamforce-pushed the jcb/ixe-improvements branch 3 times, most recently from 683ed55 to 359aeabCompareJuly 13, 2026 15:55
samuelburnham
samuelburnham previously approved these changes Jul 13, 2026
Comment threadBenchmarks/Compile/lakefile.toml Outdated
@johnchandlerburnham

Copy link
Copy Markdown
MemberAuthor

!benchmark compile

@johnchandlerburnham
johnchandlerburnham enabled auto-merge (squash) July 13, 2026 17:05
@argument-ci-bot

Copy link
Copy Markdown
Contributor

!benchmark — main vs 83867c8

backends: compile · envs: InitStd · set: primary · shard: 0

compile · InitStd — main from: bencher @ c8b5e47

envcompile-time (main)compile-time (PR)Δ%throughput (main)throughput (PR)Δ%peak-ram (main)peak-ram (PR)Δ%env-size (main)env-size (PR)Δ%constants (main)constants (PR)Δ%
InitStd3.936 s4.486 s+14.0% (1.14× slower) ⚠️26.80K23.52K-12.3% (1.14× slower) ⚠️3.64 GiB3.59 GiB-1.3%306.43 MiB309.16 MiB+0.9%105,492105,492+0.0%

1 env · 1 with regressions · 0 with improvements (|Δ| > 3.0% on any metric).

Workflow logs

@samuelburnham

Copy link
Copy Markdown
Member

!benchmark compile BENCH_ENVS=InitStd,Lean,FLT,Mathlib

@argument-ci-bot

Copy link
Copy Markdown
Contributor

!benchmark — main vs 83867c8

backends: compile · envs: InitStd,Lean,FLT,Mathlib · set: primary · shard: 0

compile · FLT — main from: bencher @ c8b5e47

envcompile-time (main)compile-time (PR)Δ%throughput (main)throughput (PR)Δ%peak-ram (main)peak-ram (PR)Δ%env-size (main)env-size (PR)Δ%constants (main)constants (PR)Δ%
FLT42.726 s47.188 s+10.4% (1.10× slower) ⚠️11.95K10.82K-9.5% (1.10× slower) ⚠️12.33 GiB12.50 GiB+1.4%1.70 GiB1.72 GiB+0.8%510,687510,687+0.0%

1 env · 1 with regressions · 0 with improvements (|Δ| > 3.0% on any metric).

compile · InitStd — main from: bencher @ c8b5e47

envcompile-time (main)compile-time (PR)Δ%throughput (main)throughput (PR)Δ%peak-ram (main)peak-ram (PR)Δ%env-size (main)env-size (PR)Δ%constants (main)constants (PR)Δ%
InitStd3.936 s4.486 s+14.0% (1.14× slower) ⚠️26.80K23.52K-12.3% (1.14× slower) ⚠️3.64 GiB3.59 GiB-1.3%306.43 MiB309.16 MiB+0.9%105,492105,492+0.0%

1 env · 1 with regressions · 0 with improvements (|Δ| > 3.0% on any metric).

compile · Lean — main from: bencher @ c8b5e47

envcompile-time (main)compile-time (PR)Δ%throughput (main)throughput (PR)Δ%peak-ram (main)peak-ram (PR)Δ%env-size (main)env-size (PR)Δ%constants (main)constants (PR)Δ%
Lean6.929 s7.290 s+5.2% (1.05× slower) ⚠️27.28K25.93K-5.0% (1.05× slower) ⚠️4.97 GiB4.96 GiB-0.1%458.03 MiB462.76 MiB+1.0%188,999188,999+0.0%

1 env · 1 with regressions · 0 with improvements (|Δ| > 3.0% on any metric).

compile · Mathlib — main from: bencher @ c8b5e47

envcompile-time (main)compile-time (PR)Δ%throughput (main)throughput (PR)Δ%peak-ram (main)peak-ram (PR)Δ%env-size (main)env-size (PR)Δ%constants (main)constants (PR)Δ%
Mathlib48.420 s49.268 s+1.8%15.21K14.95K-1.7%18.34 GiB18.40 GiB+0.3%2.97 GiB2.99 GiB+0.6%736,618736,618+0.0%

1 env · 0 with regressions · 0 with improvements (|Δ| > 3.0% on any metric).

Workflow logs

@samuelburnham

Copy link
Copy Markdown
Member

!benchmark compile BENCH_ENVS=InitStd,Lean,FLT,Mathlib

@argument-ci-bot

Copy link
Copy Markdown
Contributor

!benchmark — main vs 46a7673

backends: compile · envs: InitStd,Lean,FLT,Mathlib · set: primary · shard: 0

compile · FLT — main from: bencher @ 3a0817f

envcompile-time (main)compile-time (PR)Δ%throughput (main)throughput (PR)Δ%peak-ram (main)peak-ram (PR)Δ%env-size (main)env-size (PR)Δ%constants (main)constants (PR)Δ%
FLT44.314 s45.559 s+2.8%11.52K11.21K-2.7%12.57 GiB12.43 GiB-1.1%1.70 GiB1.72 GiB+0.7%510,687510,687+0.0%

1 env · 0 with regressions · 0 with improvements (|Δ| > 3.0% on any metric).

compile · InitStd — main from: bencher @ 3a0817f

envcompile-time (main)compile-time (PR)Δ%throughput (main)throughput (PR)Δ%peak-ram (main)peak-ram (PR)Δ%env-size (main)env-size (PR)Δ%constants (main)constants (PR)Δ%
InitStd3.916 s4.033 s+3.0%26.94K26.16K-2.9%3.68 GiB3.62 GiB-1.6%306.43 MiB309.05 MiB+0.9%105,492105,492+0.0%

1 env · 0 with regressions · 0 with improvements (|Δ| > 3.0% on any metric).

compile · Lean — main from: bencher @ 3a0817f

envcompile-time (main)compile-time (PR)Δ%throughput (main)throughput (PR)Δ%peak-ram (main)peak-ram (PR)Δ%env-size (main)env-size (PR)Δ%constants (main)constants (PR)Δ%
Lean6.558 s6.927 s+5.6% (1.06× slower) ⚠️28.82K27.28K-5.3% (1.06× slower) ⚠️4.91 GiB4.85 GiB-1.3%458.03 MiB462.55 MiB+1.0%188,999188,999+0.0%

1 env · 1 with regressions · 0 with improvements (|Δ| > 3.0% on any metric).

compile · Mathlib — main from: bencher @ 3a0817f

envcompile-time (main)compile-time (PR)Δ%throughput (main)throughput (PR)Δ%peak-ram (main)peak-ram (PR)Δ%env-size (main)env-size (PR)Δ%constants (main)constants (PR)Δ%
Mathlib45.269 s51.564 s+13.9% (1.14× slower) ⚠️16.27K14.29K-12.2% (1.14× slower) ⚠️18.74 GiB18.49 GiB-1.3%2.97 GiB2.99 GiB+0.6%736,618736,618+0.0%

1 env · 1 with regressions · 0 with improvements (|Δ| > 3.0% on any metric).

Workflow logs

johnchandlerburnhamand others added 8 commits July 13, 2026 17:43
…tions, blob integrity
Evolves .ixe in place (Tag4(0xE,0) kept, pre-alpha) so a single env file
can pin one Lean value as a self-contained bundle:
- Header gains main : Option Address (bundle root; writers and all
readers enforce main ∈ consts) and a strictly-ascending assumptions
list (thin-bundle trust boundary; merkle_root_canonical over the
leaves reproduces Claim.assumptions roots).
- Sections reordered hot-first: blobs, consts, anon_hints, names,
named, comms. get_anon/get_anon_mmap stop after §3 (no metadata
parse-and-discard); parse_lazy_index stops after §5; the full
readers reject trailing bytes.
- anon_hints promoted from optional trailer to an always-written §3,
derived from Named Def metadata when the map is empty; read-time
hint harvesting removed (EnvHandle::from_bytes post-pass dropped).
- Blob entries are hash-verified in every reader on both sides (a
swapped blob silently changed a Nat/String literal before); Lean
getEnv also gains the const hash check it was missing.
- New Env::bfs_closure follows all three edge kinds (Constant.refs,
Prj→block, Muts→member/ctor projections — a refs-only walk returns
just the root for projections); prune_to_closure builds bundles
(value closure + named-metadata pruning: name parent chains and
string blobs, DataValue payload blobs, meta_refs edges, aux_gen
originals, per-const hints); validate_closed is the receiver check.
kernel::anon_work::closure_addrs now delegates to bfs_closure.
- Lean topologicalSortNames emits the anonymous name at index 0,
matching Rust: serEnv and rsSerEnv are now byte-identical (verified
byte-exact on a 505 MB whole-stdlib env) and the putIdx getD-0
fallback now resolves to anon instead of colliding with the first
real name.
- FFI mirrors: RawEnv carries main/assumptions/anonHints (ctor arity
5→8), RawEnvLazy carries main/assumptions (3→5); the manual RawEnv
builders in ffi compile.rs populate the new slots via
set_raw_env_bundle_fields.
- Generators/fixtures content-address blobs and exercise the bundle
fields; tampering tests compute offsets programmatically instead of
hardcoding the header layout; docs/Ixon.md spec updated.
lake exe ix diff <old.ixe> <new.ixe> [--anon|--meta] [--verbose] compares
two serialized environments and exits 0/1/2 (GNU diff convention).
Engine (crates/ixon/src/diff.rs, host-gated like prune_to_closure):
- diff_envs(a, b, meta): named join on Name (added/removed/changed/
meta-only), consts/blobs set diffs, comms join, main/assumptions,
hints joined on shared consts. Deterministic ordering.
- exprs_equal: iterative lockstep walk resolving Share/Sort/Ref/Str/
Nat/Prj indices through each side's own Constant tables (same bytes
+ different tables => different; permuted tables with equal
resolution => equal), pointer-pair memoized, cycle-guarded.
- Per-kind field classification (type/value/lvls/safety/rules[i].rhs/
ctors[j].*, ...); kind changes label "kind"; representation-only
address churn labels "encoding". Projections descend one level into
their Muts block: changed member => block.* labels, untouched member
=> "block-siblings".
- Default anon mode compares only anonymous structure (hints included:
they live in anon §3); meta mode also compares ConstantMeta/original
per component.
FFI: rs_diff_envs (ByteArray -> ByteArray -> Bool -> Except String
EnvDiff) parses both inputs with the full reader (Named.original
participates) and marshals EnvStats/NamedDiff/EnvDiff (lean.rs layouts
num_64:4 / num_obj:7 / num_obj:17); names cross pre-rendered and
pre-sorted, addresses raw.
Tests: 15 engine unit tests (refs-shift guard, share-vs-inline,
univ-table shift, memo-clearing regression, block descent, ...) and 12
Lean FFI tests incl. self-diff-empty properties over genRawEnv in both
modes.
diff_envs_with adds a JoinProgress callback (fires every 100k
named-join entries and once at completion); diff_envs delegates with a
no-op. rs_diff_envs reports phases to stderr ([rs_diff_envs] ..., the
rs_compile_env idiom): per-env parse timings with reader label and
live join progress — emitted only when an input is >= 100 MB, so unit
tests, property tests, and small bundles stay silent.
Anon mode (the default) now loads both envs via parse_lazy_index +
Env::from_lazy_index — constant byte-windows, name->addr, verbatim §3
hints, §6 comms — never materializing ConstantMeta. Two full-reader
mathlib envs OOM a 124 GiB machine; the lazy path diffs two 3.2 GB
mathlib envs in ~62 s at 54 GiB peak with identical output (locked by
a lazy/full parity test). --meta keeps the full reader, since
Named.meta/original are the point of that mode.
parse_lazy_index now carries §3 hints and §6 comms verbatim on
LazyIndex (both tiny) and, since it consumes every section, enforces
EOF exactly like Env::get.
Mathlib-scale validation (v4.29.0 tag vs 8850ed93, 5 days of master):
3763 added / 1522 removed / 143528 changed names over 744k joined,
exit 1, deterministic — zero [encoding] entries; 5 genuine kind
changes and 1 hint change surface above the content-address ripple.
`lake exe ix pack <env.ixe> <name>` prunes a serialized env to the
self-contained bundle pinning one named constant and writes it as a
standalone .ixe — the first production consumer of
Env::prune_to_closure (sets main, records reached cut-points in
assumptions, carries display metadata to fixpoint) followed by the
receiver-side Env::validate_closed.
- New FFI rs_pack_env (crates/ffi/src/lean_ixon/pack.rs), mirroring
the rs_env_extract read→resolve→write shape: full reader, displayed
name→address resolution over env.named, --assume entries resolving
as names first then 64-hex addresses.
- New Ix/Cli/PackCmd.lean: --assume/--assume-file (ConstsFile.gather),
--out (default <name>.ixe), --verbose; exit 0/1.
- Tests/FFI/Ixon.lean: six directed IO tests over a temp-file fixture
(closure bundle, removals-only diff subset, name and hex assume
cuts, unknown-root and main-assumed errors). The fixture stores
name-string blobs via addNameComponentsWithBlobs — the compiler
convention prune_to_closure recreates via carry_name.
- docs/Ixon.md: pack workflow in the bundle section.
E2E: Nat.add out of the 324 MB stdlib env → 13 KB closed bundle
(17 consts, 43 blobs) in ~4 s; --assume Nat drops the cut constant
and records 1 assumption.
One edited constant re-addresses its whole reverse-dependency cone, so
a content-addressed diff drowns the human edits in transitive
re-addressings. Every changed row now carries a verdict: after the
named join, pass 2 re-classifies each changed pair under a quotient
where an (old, new) address pair compares equal when some changed name
maps old→new. Rows whose residual labels are all "encoding" /
"block-siblings" are rippled (fully explained by dependency
re-addressing); the rest are roots. A single-level map is complete
because expression comparison only consults immediate-dependency
addresses — composition happens through per-row verdicts.
Engine (ixon::diff): set-valued AddrMap built in the join loop (kind
and encoding rows included; tolerates name splits); ExprCmp/classify
parameterized by Option<&AddrMap> at the three address sites
(Ref/Str+Nat/Prj-expr); classify_prj block short-circuit stays strict
== and always descends (blocks are unnamed — a mapped shortcut would
hide block-internal roots); pass-2 verdict cache keyed on the (old,
new) pair so alias rows share one computation; constants re-parse
rather than stash (LazyConstant no-cache policy keeps peak RSS flat).
JoinProgress gains DiffPhase {NamedJoin, RippleClassify} with a 10k
ripple stride. NamedChange.rippled crosses the FFI as a num_8 scalar
(fresh scalar region; obj slots untouched).
CLI: the counts line reports "(N roots, M rippled)"; the default
listing shows roots only (plus, under --meta, rippled rows carrying
metadata edits — namedMetaOnly only covers same-addr rows); --verbose
lists rippled rows with a " (rippled)" suffix.
Documented semantic edges: induced re-elaboration (a dependency
universe/arity change alters dependents beyond addresses) verdicts
root; an intrinsically edited block member with no named projection
has no root row; the "block" fallback on assumption-cut bundles
verdicts root — all fail-safe over-reports.
Tests: 14 new engine tests (two-hop chain, block-ctor edit descent,
name splits, kind-row mapping, univ-arity root, renamed dep, literal
root, encoding ripple, alias cache, Prj-expr site, cidx root, …);
join-progress test now asserts per-phase; Lean FFI marshaling tests
pin the num_8 slot.
Mathlib validation (v4.29.0 ↔ 8850ed93, 744k joined names): 143,528
changed → 4,516 roots / 139,012 rippled (96.9% explained). All 5 kind
changes, all 245 lvls signature changes, and 8 genuine ctor-type edits
surface as roots; 605 induced block.ctor.type rows correctly ripple;
zero rippled rows carry scalar labels; deterministic across runs.
Ripple pass +16.5s (compute 30.8s vs 14.3s), peak RSS unchanged at
54 GiB.
…diff
--meta at mathlib scale previously OOM'd a 124 GiB machine (the full
reader materializes every ConstantMeta on both sides). It now runs in
8 GiB peak / 67 s on the 744k-name mathlib pair.
Streaming §5 sweep: both modes load via the lazy index; meta mode
additionally merge-joins the two files' §5 named sections with a pair
of streaming cursors (NamedMetaCursor), parsing each side's entry
against its own §4 reverse index, comparing, and dropping — resident
metadata is O(1) instead of everything-at-once. Raw §5 byte windows
are NOT comparable across files (metadata name references are
file-relative §4 indices; identical metadata serializes to different
bytes over different name tables), so the sweep compares parsed,
Address-valued ConstantMeta — pinned by a §4 index-shift soundness
regression test and full-reader parity tests. §5 arrives in ascending
name-hash order in both files (exactly Name's Ord), which makes the
lockstep merge-join sound. LazyIndex now records the §5 offset and
retains the §4 reverse index (~32 B/name); parse_lazy_index is
otherwise unchanged.
Engine API: diff_env_bytes (bytes-level, both modes lazy) and
diff_envs_lazy over LazySide {env, index, data}; diff_envs/
diff_envs_with keep the in-env metadata path for materialized envs.
DiffPhase gains MetaSweep (progress: "meta sweep: N/M (K differing)").
Mmap path FFI: rs_diff_env_files mmaps both files
(Env::from_lazy_index_mmap keeps constant windows as zero-copy mmap
slices via store_const_lazy_mmap) and rs_ixe_files_equal does the
byte-equal fast path (metadata length check, then mmap memcmp). The
CLI no longer reads files into Lean ByteArrays at all. The
ByteArray-based rs_diff_envs stays for property tests, now also on
the lazy path in both modes.
Mathlib validation (v4.29.0 ↔ 8850ed93): --meta completes at 8 GiB
peak / 67 s wall (sweep finds 7,998 differing names → 867
metadata-only rows + 7,131 changed rows with meta labels; structural
counts identical to anon: 143,528 changed, 4,516 roots). Anon through
the path FFI: 8 GiB peak (was 54 GiB heap-resident), report
byte-identical to the ByteArray-path baseline. Identical-file fast
path on a 3 GB input: 1.5 s.
docs/Ixon.md gains a "Diffing environments" section (semantics,
root/rippled, memory model).
Pack no longer uses the full reader. The source env is memory-mapped
and lazily loaded (constant windows stay zero-copy mmap slices), and
display metadata is carried by re-streaming §5 with a NamedMetaCursor
once per prune fixpoint round, materializing Named entries only for
carried constants — resident metadata is O(survivors) instead of
O(env). Packing Euclid out of the 3.2 GB mathlib env: 22 s / 5.3 GiB
peak (was ~60 s at full-reader tens-of-GiB), output byte-identical to
the full-reader bundle.
Engine: prune_to_closure refactored into prune_init +
prune_value_pass + carry_named_entry (the named-pass body,
parameterized over name/blob resolvers) — prune_to_closure_streaming
shares it verbatim, so the in-memory and streaming paths cannot
drift; a byte-identity test (with an aux-original fixture forcing a
second fixpoint round, a §4-resolved binder name, and a meta_refs
blob) locks it. parse_lazy_index_with_names returns the §4
Address→Name lookup the walk already builds (the plain variant keeps
dropping it).
--anon packs only anonymous structure via prune_to_closure_anon:
value closure + §3 hints, empty §4/§5 — the minimal artifact a
receiver needs to typecheck/evaluate the pinned value
(validate_closed checks the value pin only). Anon Euclid: 2.5 MB vs
13 MB (3,138 consts, 89 value blobs vs 9,504 with name strings),
13.6 s.
FFI rs_pack_env: mmap + lazy load, gains the anon flag (arity 5→6);
mmap_file shared with the diff module. CLI: ix pack --anon. Tests:
streaming/full byte parity (+ assumed-cut variant), anon value-only
contents + roundtrip, Lean FFI anon-bundle fixture; 249 ixon tests,
workspace + ffi/ixon suites green.
Remove the hints field from ConstantMeta::Def (Rust, Lean, the FFI
constructor slots, and the .ixe named-section encoding). Reducibility
hints now live only in the env-level anon_hints map, keyed by constant
address.
- The compiler is the sole producer: compile_definition records each
definition's hints per name (CompileState::def_hints), and
finalize_hints resolves them through the registered Named entries
into env.anon_hints via Env::register_hint once addresses are final.
The Lean compiler mirror (BlockState.defHints + Ixon.mergeHints)
does the same. Alias collisions merge order-independently (min by
(tag, height)), so parallel workers and both mirrors agree
byte-for-byte.
- Writers serialize the map directly; the derive-from-Named fallback
is deleted from Env::put / put_file / serialized_size_breakdown and
both Lean writers. This removes the serial full-decode of every
demoted Named entry that regressed ix compile by 5-14%: the
derivation loop called Named::meta() per entry, which re-decodes
demoted metadata (the IX_COMPILE_DEMOTE default) on every call.
- Kernel ingress reads hints from anon_hints in both anon and meta
modes; the hints_override parameter threading is gone. Decompile
(both languages) and the IxVM claim harness look hints up by
address. The lazy check path transports the hints section on
RawEnvLazy instead of fabricating per-name Def metas
(RawNamedLite.toConstMeta is removed).
- anon_hints becomes an IxonMap like the other env maps; Env now
derives Clone (the riscv64 IxonMap wrapper gained Clone, replacing
the manual per-map Clone impl).
Format change (pre-alpha, no compat shims): Def metadata loses its
hints byte in the named sections, so every hint is stored once instead
of two or three times (hints section + named meta + aux originals) —
the whole-stdlib env shrinks by ~232 KB. InitStd compile-time returns
to its pre-hints-section cost (median 7.3s -> 5.7s locally vs the
derivation code), with byte-exact Lean/Rust writer parity at
505,674,085 bytes on the whole-stdlib env.
Behavior note: a hint-only change now reports as a pure hintsChanged
row in ix diff; it previously also surfaced as a Def metadata diff.
@samuelburnham

Copy link
Copy Markdown
Member

!benchmark compile BENCH_ENVS=InitStd,Mathlib

@argument-ci-bot

Copy link
Copy Markdown
Contributor

!benchmark — main vs 250277d

backends: compile · envs: InitStd,Mathlib · set: primary · shard: 0

compile · InitStd — main from: bencher @ 3a0817f

envcompile-time (main)compile-time (PR)Δ%throughput (main)throughput (PR)Δ%peak-ram (main)peak-ram (PR)Δ%env-size (main)env-size (PR)Δ%constants (main)constants (PR)Δ%
InitStd3.916 s3.694 s-5.7% (1.06× faster) 🟢26.94K28.56K+6.0% (1.06× faster) 🟢3.68 GiB3.64 GiB-0.9%306.43 MiB309.05 MiB+0.9%105,492105,492+0.0%

1 env · 0 with regressions · 1 with improvements (|Δ| > 3.0% on any metric).

compile · Mathlib — main from: bencher @ 3a0817f

envcompile-time (main)compile-time (PR)Δ%throughput (main)throughput (PR)Δ%peak-ram (main)peak-ram (PR)Δ%env-size (main)env-size (PR)Δ%constants (main)constants (PR)Δ%
Mathlib45.269 s46.926 s+3.7% ⚠️16.27K15.70K-3.5% ⚠️18.74 GiB18.59 GiB-0.8%2.97 GiB2.99 GiB+0.6%736,618736,618+0.0%

1 env · 1 with regressions · 0 with improvements (|Δ| > 3.0% on any metric).

Workflow logs

@johnchandlerburnham
johnchandlerburnham merged commit 33c9cbb into mainJul 14, 2026
17 of 18 checks passed
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants

@johnchandlerburnham@samuelburnham@arthurpaulino
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Add copy buttons to all
 blocks\n(function() {\n function addCopyButtons() {\n document.querySelectorAll('pre code').forEach(function(codeBlock) {\n if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;\n codeBlock.parentElement.setAttribute('data-copy-added', 'true');\n \n var btn = document.createElement('button');\n btn.textContent = 'Copy';\n btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';\n btn.onmouseover = function() { this.style.opacity = '1'; };\n btn.onmouseout = function() { this.style.opacity = '0.7'; };\n btn.onclick = function() {\n navigator.clipboard.writeText(codeBlock.textContent).then(function() {\n btn.textContent = 'Copied!';\n setTimeout(function() { btn.textContent = 'Copy'; }, 1500);\n });\n };\n codeBlock.parentElement.style.position = 'relative';\n codeBlock.parentElement.appendChild(btn);\n });\n }\n \n addCopyButtons();\n \n // Re-run on dynamic content\n var observer = new MutationObserver(addCopyButtons);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Add Copy Buttons to Code Blocks");
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
Skip to content

Ixon: .ixe bundle format and diff tool - #474

Merged
johnchandlerburnham merged 8 commits into
mainfrom
jcb/ixe-improvements
Jul 14, 2026
Merged

Ixon: .ixe bundle format and diff tool#474
johnchandlerburnham merged 8 commits into
mainfrom
jcb/ixe-improvements

Conversation

@johnchandlerburnham

@johnchandlerburnhamjohnchandlerburnham commented Jul 5, 2026

Copy link
Copy Markdown
Member

Two related changes

  1. the .ixebundle format (a single env file that pins one Lean value as a self-contained, verifiable unit — the groundwork for Ixon as a client/server interchange format), plus its first producer CLI, ix pack
  2. ix diff — a structured, content-address-aware diff of two .ixe environments, with ripple root-causing (separating intrinsic edits from the content-address ripple) and a memory-lean --meta, validated at mathlib scale.

Part 1 — .ixe bundle format

Evolves the format in place (pre-alpha, Tag4(0xE,0) kept, no compat shims). A constant's address already pins its entire dependency DAG (refs tables are content addresses, recursively); this adds the transport half: a distinguished root, an explicit trust boundary for thin bundles, and closure helpers to produce/validate them.

[Tag4(0xE,0)] [consts merkle root : 32]
[main : u8 flag + 32? ] -- NEW: bundle root; readers enforce main ∈ consts
[assumptions : Tag0 count + 32*] -- NEW: strictly ascending; thin-bundle cut points
§1 blobs -- NEW: blake3(bytes) == addr verified per entry, every reader
§2 consts -- unchanged (already verified)
§3 anon_hints -- moved before metadata; ALWAYS written (derived from Named Def
-- metadata when the map is empty)
§4 names / §5 named / §6 comms -- metadata last
  • Metadata-last reorder: get_anon/get_anon_mmap read §1–§3 and stop — no more parse-and-discard of names/named/comms; the full readers reject trailing bytes.
  • §3 is the canonical hint channel: read-time hint harvesting is gone. merkle_root_canonical(assumptions) reproduces Claim.assumptions roots, so claim interop is derivable, not stored.
  • Integrity: blobs were previously unverified in every reader — a swapped blob silently changed a Nat/String literal under an otherwise-valid file. Lean getEnv also gains the per-const hash check it was missing.
  • Closure helpers: Env::bfs_closure (3-edge traversal: refs, projection → Muts block, block → member/ctor projection addresses), Env::prune_to_closure(main, assumed) (minimal closed bundle incl. display metadata), Env::validate_closed() (receiver check). kernel::anon_work::closure_addrs now delegates to bfs_closure.
  • Byte-identical mirrors (bonus fix): the Lean and Rust writers were never byte-identical (anonymous-name table entry 0; latent putIdxgetD 0 collision). Now locked by strict serEnv == rsSerEnv tests — byte-exact at 505,759,024 bytes on the whole-stdlib env.

ix pack — producing bundles

lake exe ix pack <env.ixe> <name> [--out <path>] [--assume <name|hex64,...>] [--assume-file <f>] [--verbose]

The first production consumer of prune_to_closure: resolves <name> (displayed form) against the env's named table, prunes to the self-contained closure — main set, reached cut points recorded in assumptions, display metadata carried to fixpoint — re-validates with validate_closed, and writes the bundle (default <name>.ixe). --assume entries (names or 64-hex constant addresses) declare thin-bundle trust boundaries. Euclid's theorem out of the 3.2 GB mathlib env:

❯ lake exe ix pack compilemathlib-8850ed93.ixe Nat.exists_infinite_primes --verbose
[rs_pack_env] main 091d37b69086bf163ecfb6c12233e1e741655b8ab09f76979f0bbbdc0c3176b5 (Nat.exists_infinite_primes)
[rs_pack_env] kept 3138/648914 consts, 10181/746073 named, 9504/329107 blobs, 0 assumption(s)
[rs_pack_env] wrote Nat.exists_infinite_primes.ixe (13006199 bytes)

3,138 constants (0.5% of mathlib), 13 MB, closed by construction — main's 32 bytes alone pin the value. (ix shard extract remains the non-bundle sibling: a general sub-env for the kernel-check pipeline, no main root.) Pack v1 reads with the full reader so metadata survives the prune; the 3.2 GB parse dominates its runtime — a leaner selective-metadata pack is future work on the §5 streaming machinery from Part 2.

Part 2 — ix diff

lake exe ix diff <old.ixe> <new.ixe> [--anon | --meta] [--verbose]

Content-addressing frames the semantics: a name "changed" ⇔ its constant address changed, and value-equality coincides with address-equality — so the interesting work is saying what changed, honestly.

[diff] old.ixe: 647127 consts, 743832 named, 328281 blobs, 0 comms
[diff] new.ixe: 648914 consts, 746073 named, 329107 blobs, 0 comms
named: 3763 added, 1522 removed, 143528 changed (4516 roots, 139012 rippled)
+ AddEquiv.withBotCongr 33ccd4f845d0…
~ AbsoluteValue.exists_partition_int defn a382796a804c… → f1cee63a8923… [type, value]
~ RecursiveIn iprj→defn 13b38a4e0f38… → 3fe097b1e646… [kind]
(139012 rippled rows hidden — address changes fully explained by dependency re-addressing; --verbose lists)
consts: 140144 only in old, 141931 only in new (mutual blocks/projections)
hints changed: 1

Exit codes follow GNU diff: 0 = no difference (in the selected mode), 1 = differences, 2 = error. Byte-equal files short-circuit to identical without parsing (mmap memcmp; 1.5 s on a 3 GB input).

Modes

  • --anon (default): compares only anonymous structure — name→addr changes with per-field classification, consts/blobs set differences, comms, main/assumptions, and reducibility hints (they live in anon §3 and drive kernel unfolding). Names are join/display keys only.
  • --meta: additionally compares Named metadata per component (meta.info with kind transitions, meta.sharing/refs/univs, original.added/removed/addr/meta) — a same-addr metadata-only change is its own report category.

Field-level classification (Rust engine, ixon::diff)

  • exprs_equal is an iterative lockstep walk that resolves Share/Sort/Ref/ Str/Nat/Prj indices through each side's own constant tables: identical expr bytes over different refs tables compare different; permuted tables with equal resolution compare equal. Pointer-pair memoized (shared subterms don't re-walk), Share-cycle-guarded, no recursion (kernel terms nest thousands deep).
  • Per-kind field labels (type, value, lvls, safety, rules[i].rhs, ctors[j].type, …). Kind changes report kind; an address change with no detected semantic difference reports encoding (representation churn) — never an empty list.
  • Projection block descent: inductives/ctors/recursors live only inside Muts blocks behind projections, so "block changed" alone would gut the tool. Same-kind projections with a changed block descend one level: changed member → block.* labels; untouched member → block-siblings (the block hash moved because a sibling changed — the common, most informative case).

Ripple root-causing (root vs rippled)

One edited constant re-addresses its whole reverse-dependency cone, so a content-addressed diff drowns the human edits in transitive re-addressings. Every changed row now carries a verdict: after the join, a second pass re-classifies each changed pair under a quotient where an (old, new) address pair compares equal when some changed name maps old→new. Rows whose residual labels are all encoding/block-siblings are rippled — fully explained by their dependencies' re-addressing; the rest are roots (intrinsic edits). A single-level map is complete because expression comparison only ever consults immediate-dependency addresses — composition across the DAG happens through the per-row verdicts, never M∘M.

  • fields keeps the strict classification; rippled : Bool is the orthogonal verdict. The default listing shows roots (plus, under --meta, rippled rows carrying metadata edits); --verbose lists everything.
  • The map is set-valued (name splits tolerated) and built from every changed row, kind changes included — so a iprj→defn refactor's dependents still ripple.
  • Block descent runs under the quotient too: an intrinsically edited ctor's projection is the root (block.ctor.type), its untouched siblings ripple, and blocks themselves (unnamed) never enter the map — descent is mandatory or block-internal roots would vanish.
  • Documented accepted edges, all fail-safe over-reports: induced re-elaboration (a dependency's universe/arity signature change alters dependents' terms beyond addresses) verdicts root; an intrinsically edited block member with no named projection has no root row of its own; the block fallback on assumption-cut bundles verdicts root.
  • Alias rows share one cached verdict per (old, new) pair; constants re-parse rather than stash (the deliberate LazyConstant no-cache policy keeps memory flat). The pass costs ~one extra classify sweep (+16 s at mathlib scale).

Memory: --meta without the OOM, and an mmap fast path

The first --meta design read both envs with the full reader — at mathlib scale that materializes every ConstantMeta twice and OOM'd a 124 GiB machine. Both modes now load via the lazy index, and meta mode compares metadata by streaming both files' §5 named sections in a lockstep merge-join (§5 is written in ascending name-hash order — exactly Name's Ord — in every file): parse one entry per side against its own §4 reverse index, compare, drop. Resident metadata is O(1) instead of everything-at-once.

Raw §5 byte windows are not comparable across files — metadata name references are file-relative §4 indices, so identical metadata serializes to different bytes over different name tables. The sweep therefore compares parsed, Address-valued ConstantMeta (index-independent), pinned by a §4 index-shift soundness regression test and full-reader parity tests.

The CLI also stopped reading files into Lean ByteArrays entirely: rs_diff_env_files mmaps both inputs (constant windows stay zero-copy mmap slices backed by the OS page cache) and rs_ixe_files_equal does the byte-equal fast path. Anon mode dropped from 54 GiB heap-resident to 8 GiB peak with a byte-identical report.

Progress reporting

Large inputs (≥ 100 MB) stream progress to stderr (stdout stays clean for piping): per-env parse timings, then live per-phase events — meta sweep (meta mode), named join, ripple pass:

❯ lake exe ix diff compilemathlib-4_29.ixe compilemathlib-8850ed93.ixe
[rs_diff_envs] parsing first env (3211 MB, lazy reader)...
[rs_diff_envs] first env parsed in 12.9s (647127 consts, 743832 named, 328281 blobs)
[rs_diff_envs] parsing second env (3222 MB, lazy reader)...
[rs_diff_envs] second env parsed in 12.1s (648914 consts, 746073 named, 329107 blobs)
[rs_diff_envs] named join: 100000/743832 (19300 changed so far)
...
[rs_diff_envs] named join: 743832/743832 (143528 changed so far)
[rs_diff_envs] ripple pass: 10000/143528 (317 roots so far)
...
[rs_diff_envs] ripple pass: 143528/143528 (4516 roots so far)
[rs_diff_env_files] diff computed in 44.1s

Mathlib-scale validation

Diffed the v4.29.0 tag env against 8850ed93 (the last mathlib master commit on the v4.29.0 toolchain — 5 days of master; the Benchmarks/Compile pin is bumped accordingly): two 3.2 GB envs, 744k named join. **Anon: 65 s wall, 8 GiB peak. --meta: 67 s, 8 GiB

[diff] compilemathlib-4_29.ixe: 647127 consts, 743832 named, 328281 blobs, 0 comms
[diff] compilemathlib-8850ed93.ixe: 648914 consts, 746073 named, 329107 blobs, 0 comms
named: 3763 added, 1522 removed, 143528 changed (4516 roots, 139012 rippled)
(synthetic mutual-block names: 627 added, 614 removed — --verbose lists)
+ AddAction.IsPretransitive.of_orbit 2ee60e47dad5…
+ AddEquiv.coe_withBotCongr 2c55b9612a26…
+ AddEquiv.withBotCongr 33ccd4f845d0…
...
- AddCommGroup.ofIsAddCommutative 5ec793909daf…
- AddCommMonoid.ofIsAddCommutative c4954ee67492…
...
~ AbsoluteValue.exists_partition_int defn a382796a804c… → f1cee63a8923… [type, value]
~ AddAction.isClosedMap_quotient defn dbba20e21c96… → 4deaf8b7fdc7… [value]
...
~ CategoryTheory.Classifier iprj→defn c632275bad82… → 28096c100344… [kind]
~ CategoryTheory.HasClassifier iprj→defn 2ba3ede041e1… → 46a6379b87bc… [kind]
~ LinearPMap.mk cprj 1df2b7a39e8a… → ae57a82f6164… [block.ctor.lvls, block.ctor.params, block.ctor.type]
~ OmegaCompletePartialOrder.Chain defn→iprj b6326c126d8f… → 04e157a9b4cd… [kind]
~ RecursiveIn iprj→defn 13b38a4e0f38… → 3fe097b1e646… [kind]
~ RecursiveIn.oracle cprj→defn 2796cba80450… → 3d3d403cff32… [kind]
...
(139012 rippled rows hidden — address changes fully explained by dependency re-addressing; --verbose lists)
consts: 140144 only in compilemathlib-4_29.ixe, 141931 only in compilemathlib-8850ed93.ixe (mutual blocks/projections; --verbose lists)
blobs: 678 only in compilemathlib-4_29.ixe, 1504 only in compilemathlib-8850ed93.ixe
hints changed: 1
38bfaaa99e15… abbrev → regular(2)
[diff] compilemathlib-4_29.ixe ≠ compilemathlib-8850ed93.ixe
  • 3,763 added / 1,522 removed / 143,528 changed, of which 4,516 roots and 139,012 rippled — 96.9% of the changed set is the content-address ripple, explained away by the quotient. Rippled label distribution confirms the shape: 120,941 [type, value] + 15,834 [value] pure re-addressings.
  • The roots are the human-edit surface: all 5 kind changes (CategoryTheory.Classifier/HasClassifieriprj→defn, OmegaCompletePartialOrder.Chaindefn→iprj, RecursiveIn/.oracle destructured), all 245 universe-signature changes ([lvls, type, value]), and 8 genuine constructor edits (e.g. LinearPMap.mk gaining block.ctor.lvls/params/type). The 613 block.ctor.type rows the strict classifier had surfaced turn out to be 8 real edits + 605 induced re-addressings — exactly the noise/signal split the verdict exists for.
  • Verdicts are conservative and checkable: zero rippled rows carry a scalar/coordinate label (machine-checked over all 139k), kind/lvls/cidx changes always root, and the report is deterministic across runs.
  • --meta additionally finds 7,998 names with differing metadata: 867 metadata-only rows (same address — binder/arena edits like …._proof_1 [meta.info]) and 7,131 changed rows with meta labels.
  • Zero [encoding] entries — compilation is deterministic end-to-end.
  • 1 hint change (abbrev → regular(2)) on an otherwise-unchanged constant, correctly absent from the named rows (hints live in §3 and don't re-address constants).

Breaking change

All existing .ixe files are stale — regenerate with ix compile (readers emit a "pre-bundle-format .ixe; recompile it" hint). FFI RawEnv ctor arity is 5→8, RawEnvLazy 3→5; NamedDiff gains a rippled : Bool scalar field.

Verification

  • Rust: full workspace tests green, clippy --all-features clean. ixon at 247 tests; the diff engine alone carries 34 (refs-shift guard, share-vs-inline, univ-table shift, memo-clearing regression, block descent, lazy/full parity, per-phase progress totals, 14 ripple-verdict tests incl. two-hop chains / name splits / renamed deps / induced re-elaboration, §5-sweep parity vs the full reader, the §4 index-shift soundness regression, mmap-side parity).
  • Lean: full lake test green; FFI tests incl. self-diff-empty properties over generated envs in both modes (pins marshaling slot order), a ripple-verdict marshaling test (pins the num_8 slot), file-based-vs-bytes-based diff parity, and six ix pack fixtures (closure bundle, removals-only diff subset, name/hex --assume cuts, error paths).
  • Cross-language: whole-stdlib env byte-exact between writers (505,759,024 bytes); ix check-rs 60,601/60,601 (meta) and 51,681/51,681 (anon); bench-typecheck executes and proves over the new format.

Follow-ups

  • prune_to_closure rescans env.named per fixpoint round — worth a reverse index if bulk pruning against mathlib-scale envs becomes common; the same §5 streaming machinery would also let ix pack skip the full-reader parse.
  • Bundle transport niceties (store sync protocol) remain future work.

Base automatically changed from jcb/compiler to mainJuly 7, 2026 23:12
@johnchandlerburnhamjohnchandlerburnham changed the title Ixon: .ixe bundle format — main/assumptions header, metadata-last sections, blob integrityIxon: .ixe bundle format and diff toolJul 10, 2026
@johnchandlerburnham
johnchandlerburnhamforce-pushed the jcb/ixe-improvements branch 3 times, most recently from 683ed55 to 359aeabCompareJuly 13, 2026 15:55
samuelburnham
samuelburnham previously approved these changes Jul 13, 2026
Comment threadBenchmarks/Compile/lakefile.toml Outdated
@johnchandlerburnham

Copy link
Copy Markdown
MemberAuthor

!benchmark compile

@johnchandlerburnham
johnchandlerburnham enabled auto-merge (squash) July 13, 2026 17:05
@argument-ci-bot

Copy link
Copy Markdown
Contributor

!benchmark — main vs 83867c8

backends: compile · envs: InitStd · set: primary · shard: 0

compile · InitStd — main from: bencher @ c8b5e47

envcompile-time (main)compile-time (PR)Δ%throughput (main)throughput (PR)Δ%peak-ram (main)peak-ram (PR)Δ%env-size (main)env-size (PR)Δ%constants (main)constants (PR)Δ%
InitStd3.936 s4.486 s+14.0% (1.14× slower) ⚠️26.80K23.52K-12.3% (1.14× slower) ⚠️3.64 GiB3.59 GiB-1.3%306.43 MiB309.16 MiB+0.9%105,492105,492+0.0%

1 env · 1 with regressions · 0 with improvements (|Δ| > 3.0% on any metric).

Workflow logs

@samuelburnham

Copy link
Copy Markdown
Member

!benchmark compile BENCH_ENVS=InitStd,Lean,FLT,Mathlib

@argument-ci-bot

Copy link
Copy Markdown
Contributor

!benchmark — main vs 83867c8

backends: compile · envs: InitStd,Lean,FLT,Mathlib · set: primary · shard: 0

compile · FLT — main from: bencher @ c8b5e47

envcompile-time (main)compile-time (PR)Δ%throughput (main)throughput (PR)Δ%peak-ram (main)peak-ram (PR)Δ%env-size (main)env-size (PR)Δ%constants (main)constants (PR)Δ%
FLT42.726 s47.188 s+10.4% (1.10× slower) ⚠️11.95K10.82K-9.5% (1.10× slower) ⚠️12.33 GiB12.50 GiB+1.4%1.70 GiB1.72 GiB+0.8%510,687510,687+0.0%

1 env · 1 with regressions · 0 with improvements (|Δ| > 3.0% on any metric).

compile · InitStd — main from: bencher @ c8b5e47

envcompile-time (main)compile-time (PR)Δ%throughput (main)throughput (PR)Δ%peak-ram (main)peak-ram (PR)Δ%env-size (main)env-size (PR)Δ%constants (main)constants (PR)Δ%
InitStd3.936 s4.486 s+14.0% (1.14× slower) ⚠️26.80K23.52K-12.3% (1.14× slower) ⚠️3.64 GiB3.59 GiB-1.3%306.43 MiB309.16 MiB+0.9%105,492105,492+0.0%

1 env · 1 with regressions · 0 with improvements (|Δ| > 3.0% on any metric).

compile · Lean — main from: bencher @ c8b5e47

envcompile-time (main)compile-time (PR)Δ%throughput (main)throughput (PR)Δ%peak-ram (main)peak-ram (PR)Δ%env-size (main)env-size (PR)Δ%constants (main)constants (PR)Δ%
Lean6.929 s7.290 s+5.2% (1.05× slower) ⚠️27.28K25.93K-5.0% (1.05× slower) ⚠️4.97 GiB4.96 GiB-0.1%458.03 MiB462.76 MiB+1.0%188,999188,999+0.0%

1 env · 1 with regressions · 0 with improvements (|Δ| > 3.0% on any metric).

compile · Mathlib — main from: bencher @ c8b5e47

envcompile-time (main)compile-time (PR)Δ%throughput (main)throughput (PR)Δ%peak-ram (main)peak-ram (PR)Δ%env-size (main)env-size (PR)Δ%constants (main)constants (PR)Δ%
Mathlib48.420 s49.268 s+1.8%15.21K14.95K-1.7%18.34 GiB18.40 GiB+0.3%2.97 GiB2.99 GiB+0.6%736,618736,618+0.0%

1 env · 0 with regressions · 0 with improvements (|Δ| > 3.0% on any metric).

Workflow logs

@samuelburnham

Copy link
Copy Markdown
Member

!benchmark compile BENCH_ENVS=InitStd,Lean,FLT,Mathlib

@argument-ci-bot

Copy link
Copy Markdown
Contributor

!benchmark — main vs 46a7673

backends: compile · envs: InitStd,Lean,FLT,Mathlib · set: primary · shard: 0

compile · FLT — main from: bencher @ 3a0817f

envcompile-time (main)compile-time (PR)Δ%throughput (main)throughput (PR)Δ%peak-ram (main)peak-ram (PR)Δ%env-size (main)env-size (PR)Δ%constants (main)constants (PR)Δ%
FLT44.314 s45.559 s+2.8%11.52K11.21K-2.7%12.57 GiB12.43 GiB-1.1%1.70 GiB1.72 GiB+0.7%510,687510,687+0.0%

1 env · 0 with regressions · 0 with improvements (|Δ| > 3.0% on any metric).

compile · InitStd — main from: bencher @ 3a0817f

envcompile-time (main)compile-time (PR)Δ%throughput (main)throughput (PR)Δ%peak-ram (main)peak-ram (PR)Δ%env-size (main)env-size (PR)Δ%constants (main)constants (PR)Δ%
InitStd3.916 s4.033 s+3.0%26.94K26.16K-2.9%3.68 GiB3.62 GiB-1.6%306.43 MiB309.05 MiB+0.9%105,492105,492+0.0%

1 env · 0 with regressions · 0 with improvements (|Δ| > 3.0% on any metric).

compile · Lean — main from: bencher @ 3a0817f

envcompile-time (main)compile-time (PR)Δ%throughput (main)throughput (PR)Δ%peak-ram (main)peak-ram (PR)Δ%env-size (main)env-size (PR)Δ%constants (main)constants (PR)Δ%
Lean6.558 s6.927 s+5.6% (1.06× slower) ⚠️28.82K27.28K-5.3% (1.06× slower) ⚠️4.91 GiB4.85 GiB-1.3%458.03 MiB462.55 MiB+1.0%188,999188,999+0.0%

1 env · 1 with regressions · 0 with improvements (|Δ| > 3.0% on any metric).

compile · Mathlib — main from: bencher @ 3a0817f

envcompile-time (main)compile-time (PR)Δ%throughput (main)throughput (PR)Δ%peak-ram (main)peak-ram (PR)Δ%env-size (main)env-size (PR)Δ%constants (main)constants (PR)Δ%
Mathlib45.269 s51.564 s+13.9% (1.14× slower) ⚠️16.27K14.29K-12.2% (1.14× slower) ⚠️18.74 GiB18.49 GiB-1.3%2.97 GiB2.99 GiB+0.6%736,618736,618+0.0%

1 env · 1 with regressions · 0 with improvements (|Δ| > 3.0% on any metric).

Workflow logs

johnchandlerburnhamand others added 8 commits July 13, 2026 17:43
…tions, blob integrity
Evolves .ixe in place (Tag4(0xE,0) kept, pre-alpha) so a single env file
can pin one Lean value as a self-contained bundle:
- Header gains main : Option Address (bundle root; writers and all
readers enforce main ∈ consts) and a strictly-ascending assumptions
list (thin-bundle trust boundary; merkle_root_canonical over the
leaves reproduces Claim.assumptions roots).
- Sections reordered hot-first: blobs, consts, anon_hints, names,
named, comms. get_anon/get_anon_mmap stop after §3 (no metadata
parse-and-discard); parse_lazy_index stops after §5; the full
readers reject trailing bytes.
- anon_hints promoted from optional trailer to an always-written §3,
derived from Named Def metadata when the map is empty; read-time
hint harvesting removed (EnvHandle::from_bytes post-pass dropped).
- Blob entries are hash-verified in every reader on both sides (a
swapped blob silently changed a Nat/String literal before); Lean
getEnv also gains the const hash check it was missing.
- New Env::bfs_closure follows all three edge kinds (Constant.refs,
Prj→block, Muts→member/ctor projections — a refs-only walk returns
just the root for projections); prune_to_closure builds bundles
(value closure + named-metadata pruning: name parent chains and
string blobs, DataValue payload blobs, meta_refs edges, aux_gen
originals, per-const hints); validate_closed is the receiver check.
kernel::anon_work::closure_addrs now delegates to bfs_closure.
- Lean topologicalSortNames emits the anonymous name at index 0,
matching Rust: serEnv and rsSerEnv are now byte-identical (verified
byte-exact on a 505 MB whole-stdlib env) and the putIdx getD-0
fallback now resolves to anon instead of colliding with the first
real name.
- FFI mirrors: RawEnv carries main/assumptions/anonHints (ctor arity
5→8), RawEnvLazy carries main/assumptions (3→5); the manual RawEnv
builders in ffi compile.rs populate the new slots via
set_raw_env_bundle_fields.
- Generators/fixtures content-address blobs and exercise the bundle
fields; tampering tests compute offsets programmatically instead of
hardcoding the header layout; docs/Ixon.md spec updated.
lake exe ix diff <old.ixe> <new.ixe> [--anon|--meta] [--verbose] compares
two serialized environments and exits 0/1/2 (GNU diff convention).
Engine (crates/ixon/src/diff.rs, host-gated like prune_to_closure):
- diff_envs(a, b, meta): named join on Name (added/removed/changed/
meta-only), consts/blobs set diffs, comms join, main/assumptions,
hints joined on shared consts. Deterministic ordering.
- exprs_equal: iterative lockstep walk resolving Share/Sort/Ref/Str/
Nat/Prj indices through each side's own Constant tables (same bytes
+ different tables => different; permuted tables with equal
resolution => equal), pointer-pair memoized, cycle-guarded.
- Per-kind field classification (type/value/lvls/safety/rules[i].rhs/
ctors[j].*, ...); kind changes label "kind"; representation-only
address churn labels "encoding". Projections descend one level into
their Muts block: changed member => block.* labels, untouched member
=> "block-siblings".
- Default anon mode compares only anonymous structure (hints included:
they live in anon §3); meta mode also compares ConstantMeta/original
per component.
FFI: rs_diff_envs (ByteArray -> ByteArray -> Bool -> Except String
EnvDiff) parses both inputs with the full reader (Named.original
participates) and marshals EnvStats/NamedDiff/EnvDiff (lean.rs layouts
num_64:4 / num_obj:7 / num_obj:17); names cross pre-rendered and
pre-sorted, addresses raw.
Tests: 15 engine unit tests (refs-shift guard, share-vs-inline,
univ-table shift, memo-clearing regression, block descent, ...) and 12
Lean FFI tests incl. self-diff-empty properties over genRawEnv in both
modes.
diff_envs_with adds a JoinProgress callback (fires every 100k
named-join entries and once at completion); diff_envs delegates with a
no-op. rs_diff_envs reports phases to stderr ([rs_diff_envs] ..., the
rs_compile_env idiom): per-env parse timings with reader label and
live join progress — emitted only when an input is >= 100 MB, so unit
tests, property tests, and small bundles stay silent.
Anon mode (the default) now loads both envs via parse_lazy_index +
Env::from_lazy_index — constant byte-windows, name->addr, verbatim §3
hints, §6 comms — never materializing ConstantMeta. Two full-reader
mathlib envs OOM a 124 GiB machine; the lazy path diffs two 3.2 GB
mathlib envs in ~62 s at 54 GiB peak with identical output (locked by
a lazy/full parity test). --meta keeps the full reader, since
Named.meta/original are the point of that mode.
parse_lazy_index now carries §3 hints and §6 comms verbatim on
LazyIndex (both tiny) and, since it consumes every section, enforces
EOF exactly like Env::get.
Mathlib-scale validation (v4.29.0 tag vs 8850ed93, 5 days of master):
3763 added / 1522 removed / 143528 changed names over 744k joined,
exit 1, deterministic — zero [encoding] entries; 5 genuine kind
changes and 1 hint change surface above the content-address ripple.
`lake exe ix pack <env.ixe> <name>` prunes a serialized env to the
self-contained bundle pinning one named constant and writes it as a
standalone .ixe — the first production consumer of
Env::prune_to_closure (sets main, records reached cut-points in
assumptions, carries display metadata to fixpoint) followed by the
receiver-side Env::validate_closed.
- New FFI rs_pack_env (crates/ffi/src/lean_ixon/pack.rs), mirroring
the rs_env_extract read→resolve→write shape: full reader, displayed
name→address resolution over env.named, --assume entries resolving
as names first then 64-hex addresses.
- New Ix/Cli/PackCmd.lean: --assume/--assume-file (ConstsFile.gather),
--out (default <name>.ixe), --verbose; exit 0/1.
- Tests/FFI/Ixon.lean: six directed IO tests over a temp-file fixture
(closure bundle, removals-only diff subset, name and hex assume
cuts, unknown-root and main-assumed errors). The fixture stores
name-string blobs via addNameComponentsWithBlobs — the compiler
convention prune_to_closure recreates via carry_name.
- docs/Ixon.md: pack workflow in the bundle section.
E2E: Nat.add out of the 324 MB stdlib env → 13 KB closed bundle
(17 consts, 43 blobs) in ~4 s; --assume Nat drops the cut constant
and records 1 assumption.
One edited constant re-addresses its whole reverse-dependency cone, so
a content-addressed diff drowns the human edits in transitive
re-addressings. Every changed row now carries a verdict: after the
named join, pass 2 re-classifies each changed pair under a quotient
where an (old, new) address pair compares equal when some changed name
maps old→new. Rows whose residual labels are all "encoding" /
"block-siblings" are rippled (fully explained by dependency
re-addressing); the rest are roots. A single-level map is complete
because expression comparison only consults immediate-dependency
addresses — composition happens through per-row verdicts.
Engine (ixon::diff): set-valued AddrMap built in the join loop (kind
and encoding rows included; tolerates name splits); ExprCmp/classify
parameterized by Option<&AddrMap> at the three address sites
(Ref/Str+Nat/Prj-expr); classify_prj block short-circuit stays strict
== and always descends (blocks are unnamed — a mapped shortcut would
hide block-internal roots); pass-2 verdict cache keyed on the (old,
new) pair so alias rows share one computation; constants re-parse
rather than stash (LazyConstant no-cache policy keeps peak RSS flat).
JoinProgress gains DiffPhase {NamedJoin, RippleClassify} with a 10k
ripple stride. NamedChange.rippled crosses the FFI as a num_8 scalar
(fresh scalar region; obj slots untouched).
CLI: the counts line reports "(N roots, M rippled)"; the default
listing shows roots only (plus, under --meta, rippled rows carrying
metadata edits — namedMetaOnly only covers same-addr rows); --verbose
lists rippled rows with a " (rippled)" suffix.
Documented semantic edges: induced re-elaboration (a dependency
universe/arity change alters dependents beyond addresses) verdicts
root; an intrinsically edited block member with no named projection
has no root row; the "block" fallback on assumption-cut bundles
verdicts root — all fail-safe over-reports.
Tests: 14 new engine tests (two-hop chain, block-ctor edit descent,
name splits, kind-row mapping, univ-arity root, renamed dep, literal
root, encoding ripple, alias cache, Prj-expr site, cidx root, …);
join-progress test now asserts per-phase; Lean FFI marshaling tests
pin the num_8 slot.
Mathlib validation (v4.29.0 ↔ 8850ed93, 744k joined names): 143,528
changed → 4,516 roots / 139,012 rippled (96.9% explained). All 5 kind
changes, all 245 lvls signature changes, and 8 genuine ctor-type edits
surface as roots; 605 induced block.ctor.type rows correctly ripple;
zero rippled rows carry scalar labels; deterministic across runs.
Ripple pass +16.5s (compute 30.8s vs 14.3s), peak RSS unchanged at
54 GiB.
…diff
--meta at mathlib scale previously OOM'd a 124 GiB machine (the full
reader materializes every ConstantMeta on both sides). It now runs in
8 GiB peak / 67 s on the 744k-name mathlib pair.
Streaming §5 sweep: both modes load via the lazy index; meta mode
additionally merge-joins the two files' §5 named sections with a pair
of streaming cursors (NamedMetaCursor), parsing each side's entry
against its own §4 reverse index, comparing, and dropping — resident
metadata is O(1) instead of everything-at-once. Raw §5 byte windows
are NOT comparable across files (metadata name references are
file-relative §4 indices; identical metadata serializes to different
bytes over different name tables), so the sweep compares parsed,
Address-valued ConstantMeta — pinned by a §4 index-shift soundness
regression test and full-reader parity tests. §5 arrives in ascending
name-hash order in both files (exactly Name's Ord), which makes the
lockstep merge-join sound. LazyIndex now records the §5 offset and
retains the §4 reverse index (~32 B/name); parse_lazy_index is
otherwise unchanged.
Engine API: diff_env_bytes (bytes-level, both modes lazy) and
diff_envs_lazy over LazySide {env, index, data}; diff_envs/
diff_envs_with keep the in-env metadata path for materialized envs.
DiffPhase gains MetaSweep (progress: "meta sweep: N/M (K differing)").
Mmap path FFI: rs_diff_env_files mmaps both files
(Env::from_lazy_index_mmap keeps constant windows as zero-copy mmap
slices via store_const_lazy_mmap) and rs_ixe_files_equal does the
byte-equal fast path (metadata length check, then mmap memcmp). The
CLI no longer reads files into Lean ByteArrays at all. The
ByteArray-based rs_diff_envs stays for property tests, now also on
the lazy path in both modes.
Mathlib validation (v4.29.0 ↔ 8850ed93): --meta completes at 8 GiB
peak / 67 s wall (sweep finds 7,998 differing names → 867
metadata-only rows + 7,131 changed rows with meta labels; structural
counts identical to anon: 143,528 changed, 4,516 roots). Anon through
the path FFI: 8 GiB peak (was 54 GiB heap-resident), report
byte-identical to the ByteArray-path baseline. Identical-file fast
path on a 3 GB input: 1.5 s.
docs/Ixon.md gains a "Diffing environments" section (semantics,
root/rippled, memory model).
Pack no longer uses the full reader. The source env is memory-mapped
and lazily loaded (constant windows stay zero-copy mmap slices), and
display metadata is carried by re-streaming §5 with a NamedMetaCursor
once per prune fixpoint round, materializing Named entries only for
carried constants — resident metadata is O(survivors) instead of
O(env). Packing Euclid out of the 3.2 GB mathlib env: 22 s / 5.3 GiB
peak (was ~60 s at full-reader tens-of-GiB), output byte-identical to
the full-reader bundle.
Engine: prune_to_closure refactored into prune_init +
prune_value_pass + carry_named_entry (the named-pass body,
parameterized over name/blob resolvers) — prune_to_closure_streaming
shares it verbatim, so the in-memory and streaming paths cannot
drift; a byte-identity test (with an aux-original fixture forcing a
second fixpoint round, a §4-resolved binder name, and a meta_refs
blob) locks it. parse_lazy_index_with_names returns the §4
Address→Name lookup the walk already builds (the plain variant keeps
dropping it).
--anon packs only anonymous structure via prune_to_closure_anon:
value closure + §3 hints, empty §4/§5 — the minimal artifact a
receiver needs to typecheck/evaluate the pinned value
(validate_closed checks the value pin only). Anon Euclid: 2.5 MB vs
13 MB (3,138 consts, 89 value blobs vs 9,504 with name strings),
13.6 s.
FFI rs_pack_env: mmap + lazy load, gains the anon flag (arity 5→6);
mmap_file shared with the diff module. CLI: ix pack --anon. Tests:
streaming/full byte parity (+ assumed-cut variant), anon value-only
contents + roundtrip, Lean FFI anon-bundle fixture; 249 ixon tests,
workspace + ffi/ixon suites green.
Remove the hints field from ConstantMeta::Def (Rust, Lean, the FFI
constructor slots, and the .ixe named-section encoding). Reducibility
hints now live only in the env-level anon_hints map, keyed by constant
address.
- The compiler is the sole producer: compile_definition records each
definition's hints per name (CompileState::def_hints), and
finalize_hints resolves them through the registered Named entries
into env.anon_hints via Env::register_hint once addresses are final.
The Lean compiler mirror (BlockState.defHints + Ixon.mergeHints)
does the same. Alias collisions merge order-independently (min by
(tag, height)), so parallel workers and both mirrors agree
byte-for-byte.
- Writers serialize the map directly; the derive-from-Named fallback
is deleted from Env::put / put_file / serialized_size_breakdown and
both Lean writers. This removes the serial full-decode of every
demoted Named entry that regressed ix compile by 5-14%: the
derivation loop called Named::meta() per entry, which re-decodes
demoted metadata (the IX_COMPILE_DEMOTE default) on every call.
- Kernel ingress reads hints from anon_hints in both anon and meta
modes; the hints_override parameter threading is gone. Decompile
(both languages) and the IxVM claim harness look hints up by
address. The lazy check path transports the hints section on
RawEnvLazy instead of fabricating per-name Def metas
(RawNamedLite.toConstMeta is removed).
- anon_hints becomes an IxonMap like the other env maps; Env now
derives Clone (the riscv64 IxonMap wrapper gained Clone, replacing
the manual per-map Clone impl).
Format change (pre-alpha, no compat shims): Def metadata loses its
hints byte in the named sections, so every hint is stored once instead
of two or three times (hints section + named meta + aux originals) —
the whole-stdlib env shrinks by ~232 KB. InitStd compile-time returns
to its pre-hints-section cost (median 7.3s -> 5.7s locally vs the
derivation code), with byte-exact Lean/Rust writer parity at
505,674,085 bytes on the whole-stdlib env.
Behavior note: a hint-only change now reports as a pure hintsChanged
row in ix diff; it previously also surfaced as a Def metadata diff.
@samuelburnham

Copy link
Copy Markdown
Member

!benchmark compile BENCH_ENVS=InitStd,Mathlib

@argument-ci-bot

Copy link
Copy Markdown
Contributor

!benchmark — main vs 250277d

backends: compile · envs: InitStd,Mathlib · set: primary · shard: 0

compile · InitStd — main from: bencher @ 3a0817f

envcompile-time (main)compile-time (PR)Δ%throughput (main)throughput (PR)Δ%peak-ram (main)peak-ram (PR)Δ%env-size (main)env-size (PR)Δ%constants (main)constants (PR)Δ%
InitStd3.916 s3.694 s-5.7% (1.06× faster) 🟢26.94K28.56K+6.0% (1.06× faster) 🟢3.68 GiB3.64 GiB-0.9%306.43 MiB309.05 MiB+0.9%105,492105,492+0.0%

1 env · 0 with regressions · 1 with improvements (|Δ| > 3.0% on any metric).

compile · Mathlib — main from: bencher @ 3a0817f

envcompile-time (main)compile-time (PR)Δ%throughput (main)throughput (PR)Δ%peak-ram (main)peak-ram (PR)Δ%env-size (main)env-size (PR)Δ%constants (main)constants (PR)Δ%
Mathlib45.269 s46.926 s+3.7% ⚠️16.27K15.70K-3.5% ⚠️18.74 GiB18.59 GiB-0.8%2.97 GiB2.99 GiB+0.6%736,618736,618+0.0%

1 env · 1 with regressions · 0 with improvements (|Δ| > 3.0% on any metric).

Workflow logs

@johnchandlerburnham
johnchandlerburnham merged commit 33c9cbb into mainJul 14, 2026
17 of 18 checks passed
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants

@johnchandlerburnham@samuelburnham@arthurpaulino
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Force GitHub README to respect dark mode\n(function() {\n var style = document.createElement('style');\n style.textContent = '\n .markdown-body {\n color-scheme: dark light;\n }\n .markdown-body pre { background: #161b22 !important; }\n .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; }\n .markdown-body table th, .markdown-body table td { border-color: #30363d !important; }\n .markdown-body img { background: #0d1117; }\n .markdown-body blockquote { border-left-color: #8b949e; }\n .markdown-body hr { border-color: #30363d; }\n ';\n document.head.appendChild(style);\n})();", "GitHub Dark Mode README Fix"); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

Ixon: .ixe bundle format and diff tool - #474

Merged
johnchandlerburnham merged 8 commits into
mainfrom
jcb/ixe-improvements
Jul 14, 2026
Merged

Ixon: .ixe bundle format and diff tool#474
johnchandlerburnham merged 8 commits into
mainfrom
jcb/ixe-improvements

Conversation

@johnchandlerburnham

@johnchandlerburnhamjohnchandlerburnham commented Jul 5, 2026

Copy link
Copy Markdown
Member

Two related changes

  1. the .ixebundle format (a single env file that pins one Lean value as a self-contained, verifiable unit — the groundwork for Ixon as a client/server interchange format), plus its first producer CLI, ix pack
  2. ix diff — a structured, content-address-aware diff of two .ixe environments, with ripple root-causing (separating intrinsic edits from the content-address ripple) and a memory-lean --meta, validated at mathlib scale.

Part 1 — .ixe bundle format

Evolves the format in place (pre-alpha, Tag4(0xE,0) kept, no compat shims). A constant's address already pins its entire dependency DAG (refs tables are content addresses, recursively); this adds the transport half: a distinguished root, an explicit trust boundary for thin bundles, and closure helpers to produce/validate them.

[Tag4(0xE,0)] [consts merkle root : 32]
[main : u8 flag + 32? ] -- NEW: bundle root; readers enforce main ∈ consts
[assumptions : Tag0 count + 32*] -- NEW: strictly ascending; thin-bundle cut points
§1 blobs -- NEW: blake3(bytes) == addr verified per entry, every reader
§2 consts -- unchanged (already verified)
§3 anon_hints -- moved before metadata; ALWAYS written (derived from Named Def
-- metadata when the map is empty)
§4 names / §5 named / §6 comms -- metadata last
  • Metadata-last reorder: get_anon/get_anon_mmap read §1–§3 and stop — no more parse-and-discard of names/named/comms; the full readers reject trailing bytes.
  • §3 is the canonical hint channel: read-time hint harvesting is gone. merkle_root_canonical(assumptions) reproduces Claim.assumptions roots, so claim interop is derivable, not stored.
  • Integrity: blobs were previously unverified in every reader — a swapped blob silently changed a Nat/String literal under an otherwise-valid file. Lean getEnv also gains the per-const hash check it was missing.
  • Closure helpers: Env::bfs_closure (3-edge traversal: refs, projection → Muts block, block → member/ctor projection addresses), Env::prune_to_closure(main, assumed) (minimal closed bundle incl. display metadata), Env::validate_closed() (receiver check). kernel::anon_work::closure_addrs now delegates to bfs_closure.
  • Byte-identical mirrors (bonus fix): the Lean and Rust writers were never byte-identical (anonymous-name table entry 0; latent putIdxgetD 0 collision). Now locked by strict serEnv == rsSerEnv tests — byte-exact at 505,759,024 bytes on the whole-stdlib env.

ix pack — producing bundles

lake exe ix pack <env.ixe> <name> [--out <path>] [--assume <name|hex64,...>] [--assume-file <f>] [--verbose]

The first production consumer of prune_to_closure: resolves <name> (displayed form) against the env's named table, prunes to the self-contained closure — main set, reached cut points recorded in assumptions, display metadata carried to fixpoint — re-validates with validate_closed, and writes the bundle (default <name>.ixe). --assume entries (names or 64-hex constant addresses) declare thin-bundle trust boundaries. Euclid's theorem out of the 3.2 GB mathlib env:

❯ lake exe ix pack compilemathlib-8850ed93.ixe Nat.exists_infinite_primes --verbose
[rs_pack_env] main 091d37b69086bf163ecfb6c12233e1e741655b8ab09f76979f0bbbdc0c3176b5 (Nat.exists_infinite_primes)
[rs_pack_env] kept 3138/648914 consts, 10181/746073 named, 9504/329107 blobs, 0 assumption(s)
[rs_pack_env] wrote Nat.exists_infinite_primes.ixe (13006199 bytes)

3,138 constants (0.5% of mathlib), 13 MB, closed by construction — main's 32 bytes alone pin the value. (ix shard extract remains the non-bundle sibling: a general sub-env for the kernel-check pipeline, no main root.) Pack v1 reads with the full reader so metadata survives the prune; the 3.2 GB parse dominates its runtime — a leaner selective-metadata pack is future work on the §5 streaming machinery from Part 2.

Part 2 — ix diff

lake exe ix diff <old.ixe> <new.ixe> [--anon | --meta] [--verbose]

Content-addressing frames the semantics: a name "changed" ⇔ its constant address changed, and value-equality coincides with address-equality — so the interesting work is saying what changed, honestly.

[diff] old.ixe: 647127 consts, 743832 named, 328281 blobs, 0 comms
[diff] new.ixe: 648914 consts, 746073 named, 329107 blobs, 0 comms
named: 3763 added, 1522 removed, 143528 changed (4516 roots, 139012 rippled)
+ AddEquiv.withBotCongr 33ccd4f845d0…
~ AbsoluteValue.exists_partition_int defn a382796a804c… → f1cee63a8923… [type, value]
~ RecursiveIn iprj→defn 13b38a4e0f38… → 3fe097b1e646… [kind]
(139012 rippled rows hidden — address changes fully explained by dependency re-addressing; --verbose lists)
consts: 140144 only in old, 141931 only in new (mutual blocks/projections)
hints changed: 1

Exit codes follow GNU diff: 0 = no difference (in the selected mode), 1 = differences, 2 = error. Byte-equal files short-circuit to identical without parsing (mmap memcmp; 1.5 s on a 3 GB input).

Modes

  • --anon (default): compares only anonymous structure — name→addr changes with per-field classification, consts/blobs set differences, comms, main/assumptions, and reducibility hints (they live in anon §3 and drive kernel unfolding). Names are join/display keys only.
  • --meta: additionally compares Named metadata per component (meta.info with kind transitions, meta.sharing/refs/univs, original.added/removed/addr/meta) — a same-addr metadata-only change is its own report category.

Field-level classification (Rust engine, ixon::diff)

  • exprs_equal is an iterative lockstep walk that resolves Share/Sort/Ref/ Str/Nat/Prj indices through each side's own constant tables: identical expr bytes over different refs tables compare different; permuted tables with equal resolution compare equal. Pointer-pair memoized (shared subterms don't re-walk), Share-cycle-guarded, no recursion (kernel terms nest thousands deep).
  • Per-kind field labels (type, value, lvls, safety, rules[i].rhs, ctors[j].type, …). Kind changes report kind; an address change with no detected semantic difference reports encoding (representation churn) — never an empty list.
  • Projection block descent: inductives/ctors/recursors live only inside Muts blocks behind projections, so "block changed" alone would gut the tool. Same-kind projections with a changed block descend one level: changed member → block.* labels; untouched member → block-siblings (the block hash moved because a sibling changed — the common, most informative case).

Ripple root-causing (root vs rippled)

One edited constant re-addresses its whole reverse-dependency cone, so a content-addressed diff drowns the human edits in transitive re-addressings. Every changed row now carries a verdict: after the join, a second pass re-classifies each changed pair under a quotient where an (old, new) address pair compares equal when some changed name maps old→new. Rows whose residual labels are all encoding/block-siblings are rippled — fully explained by their dependencies' re-addressing; the rest are roots (intrinsic edits). A single-level map is complete because expression comparison only ever consults immediate-dependency addresses — composition across the DAG happens through the per-row verdicts, never M∘M.

  • fields keeps the strict classification; rippled : Bool is the orthogonal verdict. The default listing shows roots (plus, under --meta, rippled rows carrying metadata edits); --verbose lists everything.
  • The map is set-valued (name splits tolerated) and built from every changed row, kind changes included — so a iprj→defn refactor's dependents still ripple.
  • Block descent runs under the quotient too: an intrinsically edited ctor's projection is the root (block.ctor.type), its untouched siblings ripple, and blocks themselves (unnamed) never enter the map — descent is mandatory or block-internal roots would vanish.
  • Documented accepted edges, all fail-safe over-reports: induced re-elaboration (a dependency's universe/arity signature change alters dependents' terms beyond addresses) verdicts root; an intrinsically edited block member with no named projection has no root row of its own; the block fallback on assumption-cut bundles verdicts root.
  • Alias rows share one cached verdict per (old, new) pair; constants re-parse rather than stash (the deliberate LazyConstant no-cache policy keeps memory flat). The pass costs ~one extra classify sweep (+16 s at mathlib scale).

Memory: --meta without the OOM, and an mmap fast path

The first --meta design read both envs with the full reader — at mathlib scale that materializes every ConstantMeta twice and OOM'd a 124 GiB machine. Both modes now load via the lazy index, and meta mode compares metadata by streaming both files' §5 named sections in a lockstep merge-join (§5 is written in ascending name-hash order — exactly Name's Ord — in every file): parse one entry per side against its own §4 reverse index, compare, drop. Resident metadata is O(1) instead of everything-at-once.

Raw §5 byte windows are not comparable across files — metadata name references are file-relative §4 indices, so identical metadata serializes to different bytes over different name tables. The sweep therefore compares parsed, Address-valued ConstantMeta (index-independent), pinned by a §4 index-shift soundness regression test and full-reader parity tests.

The CLI also stopped reading files into Lean ByteArrays entirely: rs_diff_env_files mmaps both inputs (constant windows stay zero-copy mmap slices backed by the OS page cache) and rs_ixe_files_equal does the byte-equal fast path. Anon mode dropped from 54 GiB heap-resident to 8 GiB peak with a byte-identical report.

Progress reporting

Large inputs (≥ 100 MB) stream progress to stderr (stdout stays clean for piping): per-env parse timings, then live per-phase events — meta sweep (meta mode), named join, ripple pass:

❯ lake exe ix diff compilemathlib-4_29.ixe compilemathlib-8850ed93.ixe
[rs_diff_envs] parsing first env (3211 MB, lazy reader)...
[rs_diff_envs] first env parsed in 12.9s (647127 consts, 743832 named, 328281 blobs)
[rs_diff_envs] parsing second env (3222 MB, lazy reader)...
[rs_diff_envs] second env parsed in 12.1s (648914 consts, 746073 named, 329107 blobs)
[rs_diff_envs] named join: 100000/743832 (19300 changed so far)
...
[rs_diff_envs] named join: 743832/743832 (143528 changed so far)
[rs_diff_envs] ripple pass: 10000/143528 (317 roots so far)
...
[rs_diff_envs] ripple pass: 143528/143528 (4516 roots so far)
[rs_diff_env_files] diff computed in 44.1s

Mathlib-scale validation

Diffed the v4.29.0 tag env against 8850ed93 (the last mathlib master commit on the v4.29.0 toolchain — 5 days of master; the Benchmarks/Compile pin is bumped accordingly): two 3.2 GB envs, 744k named join. **Anon: 65 s wall, 8 GiB peak. --meta: 67 s, 8 GiB

[diff] compilemathlib-4_29.ixe: 647127 consts, 743832 named, 328281 blobs, 0 comms
[diff] compilemathlib-8850ed93.ixe: 648914 consts, 746073 named, 329107 blobs, 0 comms
named: 3763 added, 1522 removed, 143528 changed (4516 roots, 139012 rippled)
(synthetic mutual-block names: 627 added, 614 removed — --verbose lists)
+ AddAction.IsPretransitive.of_orbit 2ee60e47dad5…
+ AddEquiv.coe_withBotCongr 2c55b9612a26…
+ AddEquiv.withBotCongr 33ccd4f845d0…
...
- AddCommGroup.ofIsAddCommutative 5ec793909daf…
- AddCommMonoid.ofIsAddCommutative c4954ee67492…
...
~ AbsoluteValue.exists_partition_int defn a382796a804c… → f1cee63a8923… [type, value]
~ AddAction.isClosedMap_quotient defn dbba20e21c96… → 4deaf8b7fdc7… [value]
...
~ CategoryTheory.Classifier iprj→defn c632275bad82… → 28096c100344… [kind]
~ CategoryTheory.HasClassifier iprj→defn 2ba3ede041e1… → 46a6379b87bc… [kind]
~ LinearPMap.mk cprj 1df2b7a39e8a… → ae57a82f6164… [block.ctor.lvls, block.ctor.params, block.ctor.type]
~ OmegaCompletePartialOrder.Chain defn→iprj b6326c126d8f… → 04e157a9b4cd… [kind]
~ RecursiveIn iprj→defn 13b38a4e0f38… → 3fe097b1e646… [kind]
~ RecursiveIn.oracle cprj→defn 2796cba80450… → 3d3d403cff32… [kind]
...
(139012 rippled rows hidden — address changes fully explained by dependency re-addressing; --verbose lists)
consts: 140144 only in compilemathlib-4_29.ixe, 141931 only in compilemathlib-8850ed93.ixe (mutual blocks/projections; --verbose lists)
blobs: 678 only in compilemathlib-4_29.ixe, 1504 only in compilemathlib-8850ed93.ixe
hints changed: 1
38bfaaa99e15… abbrev → regular(2)
[diff] compilemathlib-4_29.ixe ≠ compilemathlib-8850ed93.ixe
  • 3,763 added / 1,522 removed / 143,528 changed, of which 4,516 roots and 139,012 rippled — 96.9% of the changed set is the content-address ripple, explained away by the quotient. Rippled label distribution confirms the shape: 120,941 [type, value] + 15,834 [value] pure re-addressings.
  • The roots are the human-edit surface: all 5 kind changes (CategoryTheory.Classifier/HasClassifieriprj→defn, OmegaCompletePartialOrder.Chaindefn→iprj, RecursiveIn/.oracle destructured), all 245 universe-signature changes ([lvls, type, value]), and 8 genuine constructor edits (e.g. LinearPMap.mk gaining block.ctor.lvls/params/type). The 613 block.ctor.type rows the strict classifier had surfaced turn out to be 8 real edits + 605 induced re-addressings — exactly the noise/signal split the verdict exists for.
  • Verdicts are conservative and checkable: zero rippled rows carry a scalar/coordinate label (machine-checked over all 139k), kind/lvls/cidx changes always root, and the report is deterministic across runs.
  • --meta additionally finds 7,998 names with differing metadata: 867 metadata-only rows (same address — binder/arena edits like …._proof_1 [meta.info]) and 7,131 changed rows with meta labels.
  • Zero [encoding] entries — compilation is deterministic end-to-end.
  • 1 hint change (abbrev → regular(2)) on an otherwise-unchanged constant, correctly absent from the named rows (hints live in §3 and don't re-address constants).

Breaking change

All existing .ixe files are stale — regenerate with ix compile (readers emit a "pre-bundle-format .ixe; recompile it" hint). FFI RawEnv ctor arity is 5→8, RawEnvLazy 3→5; NamedDiff gains a rippled : Bool scalar field.

Verification

  • Rust: full workspace tests green, clippy --all-features clean. ixon at 247 tests; the diff engine alone carries 34 (refs-shift guard, share-vs-inline, univ-table shift, memo-clearing regression, block descent, lazy/full parity, per-phase progress totals, 14 ripple-verdict tests incl. two-hop chains / name splits / renamed deps / induced re-elaboration, §5-sweep parity vs the full reader, the §4 index-shift soundness regression, mmap-side parity).
  • Lean: full lake test green; FFI tests incl. self-diff-empty properties over generated envs in both modes (pins marshaling slot order), a ripple-verdict marshaling test (pins the num_8 slot), file-based-vs-bytes-based diff parity, and six ix pack fixtures (closure bundle, removals-only diff subset, name/hex --assume cuts, error paths).
  • Cross-language: whole-stdlib env byte-exact between writers (505,759,024 bytes); ix check-rs 60,601/60,601 (meta) and 51,681/51,681 (anon); bench-typecheck executes and proves over the new format.

Follow-ups

  • prune_to_closure rescans env.named per fixpoint round — worth a reverse index if bulk pruning against mathlib-scale envs becomes common; the same §5 streaming machinery would also let ix pack skip the full-reader parse.
  • Bundle transport niceties (store sync protocol) remain future work.

Base automatically changed from jcb/compiler to mainJuly 7, 2026 23:12
@johnchandlerburnhamjohnchandlerburnham changed the title Ixon: .ixe bundle format — main/assumptions header, metadata-last sections, blob integrityIxon: .ixe bundle format and diff toolJul 10, 2026
@johnchandlerburnham
johnchandlerburnhamforce-pushed the jcb/ixe-improvements branch 3 times, most recently from 683ed55 to 359aeabCompareJuly 13, 2026 15:55
samuelburnham
samuelburnham previously approved these changes Jul 13, 2026
Comment threadBenchmarks/Compile/lakefile.toml Outdated
@johnchandlerburnham

Copy link
Copy Markdown
MemberAuthor

!benchmark compile

@johnchandlerburnham
johnchandlerburnham enabled auto-merge (squash) July 13, 2026 17:05
@argument-ci-bot

Copy link
Copy Markdown
Contributor

!benchmark — main vs 83867c8

backends: compile · envs: InitStd · set: primary · shard: 0

compile · InitStd — main from: bencher @ c8b5e47

envcompile-time (main)compile-time (PR)Δ%throughput (main)throughput (PR)Δ%peak-ram (main)peak-ram (PR)Δ%env-size (main)env-size (PR)Δ%constants (main)constants (PR)Δ%
InitStd3.936 s4.486 s+14.0% (1.14× slower) ⚠️26.80K23.52K-12.3% (1.14× slower) ⚠️3.64 GiB3.59 GiB-1.3%306.43 MiB309.16 MiB+0.9%105,492105,492+0.0%

1 env · 1 with regressions · 0 with improvements (|Δ| > 3.0% on any metric).

Workflow logs

@samuelburnham

Copy link
Copy Markdown
Member

!benchmark compile BENCH_ENVS=InitStd,Lean,FLT,Mathlib

@argument-ci-bot

Copy link
Copy Markdown
Contributor

!benchmark — main vs 83867c8

backends: compile · envs: InitStd,Lean,FLT,Mathlib · set: primary · shard: 0

compile · FLT — main from: bencher @ c8b5e47

envcompile-time (main)compile-time (PR)Δ%throughput (main)throughput (PR)Δ%peak-ram (main)peak-ram (PR)Δ%env-size (main)env-size (PR)Δ%constants (main)constants (PR)Δ%
FLT42.726 s47.188 s+10.4% (1.10× slower) ⚠️11.95K10.82K-9.5% (1.10× slower) ⚠️12.33 GiB12.50 GiB+1.4%1.70 GiB1.72 GiB+0.8%510,687510,687+0.0%

1 env · 1 with regressions · 0 with improvements (|Δ| > 3.0% on any metric).

compile · InitStd — main from: bencher @ c8b5e47

envcompile-time (main)compile-time (PR)Δ%throughput (main)throughput (PR)Δ%peak-ram (main)peak-ram (PR)Δ%env-size (main)env-size (PR)Δ%constants (main)constants (PR)Δ%
InitStd3.936 s4.486 s+14.0% (1.14× slower) ⚠️26.80K23.52K-12.3% (1.14× slower) ⚠️3.64 GiB3.59 GiB-1.3%306.43 MiB309.16 MiB+0.9%105,492105,492+0.0%

1 env · 1 with regressions · 0 with improvements (|Δ| > 3.0% on any metric).

compile · Lean — main from: bencher @ c8b5e47

envcompile-time (main)compile-time (PR)Δ%throughput (main)throughput (PR)Δ%peak-ram (main)peak-ram (PR)Δ%env-size (main)env-size (PR)Δ%constants (main)constants (PR)Δ%
Lean6.929 s7.290 s+5.2% (1.05× slower) ⚠️27.28K25.93K-5.0% (1.05× slower) ⚠️4.97 GiB4.96 GiB-0.1%458.03 MiB462.76 MiB+1.0%188,999188,999+0.0%

1 env · 1 with regressions · 0 with improvements (|Δ| > 3.0% on any metric).

compile · Mathlib — main from: bencher @ c8b5e47

envcompile-time (main)compile-time (PR)Δ%throughput (main)throughput (PR)Δ%peak-ram (main)peak-ram (PR)Δ%env-size (main)env-size (PR)Δ%constants (main)constants (PR)Δ%
Mathlib48.420 s49.268 s+1.8%15.21K14.95K-1.7%18.34 GiB18.40 GiB+0.3%2.97 GiB2.99 GiB+0.6%736,618736,618+0.0%

1 env · 0 with regressions · 0 with improvements (|Δ| > 3.0% on any metric).

Workflow logs

@samuelburnham

Copy link
Copy Markdown
Member

!benchmark compile BENCH_ENVS=InitStd,Lean,FLT,Mathlib

@argument-ci-bot

Copy link
Copy Markdown
Contributor

!benchmark — main vs 46a7673

backends: compile · envs: InitStd,Lean,FLT,Mathlib · set: primary · shard: 0

compile · FLT — main from: bencher @ 3a0817f

envcompile-time (main)compile-time (PR)Δ%throughput (main)throughput (PR)Δ%peak-ram (main)peak-ram (PR)Δ%env-size (main)env-size (PR)Δ%constants (main)constants (PR)Δ%
FLT44.314 s45.559 s+2.8%11.52K11.21K-2.7%12.57 GiB12.43 GiB-1.1%1.70 GiB1.72 GiB+0.7%510,687510,687+0.0%

1 env · 0 with regressions · 0 with improvements (|Δ| > 3.0% on any metric).

compile · InitStd — main from: bencher @ 3a0817f

envcompile-time (main)compile-time (PR)Δ%throughput (main)throughput (PR)Δ%peak-ram (main)peak-ram (PR)Δ%env-size (main)env-size (PR)Δ%constants (main)constants (PR)Δ%
InitStd3.916 s4.033 s+3.0%26.94K26.16K-2.9%3.68 GiB3.62 GiB-1.6%306.43 MiB309.05 MiB+0.9%105,492105,492+0.0%

1 env · 0 with regressions · 0 with improvements (|Δ| > 3.0% on any metric).

compile · Lean — main from: bencher @ 3a0817f

envcompile-time (main)compile-time (PR)Δ%throughput (main)throughput (PR)Δ%peak-ram (main)peak-ram (PR)Δ%env-size (main)env-size (PR)Δ%constants (main)constants (PR)Δ%
Lean6.558 s6.927 s+5.6% (1.06× slower) ⚠️28.82K27.28K-5.3% (1.06× slower) ⚠️4.91 GiB4.85 GiB-1.3%458.03 MiB462.55 MiB+1.0%188,999188,999+0.0%

1 env · 1 with regressions · 0 with improvements (|Δ| > 3.0% on any metric).

compile · Mathlib — main from: bencher @ 3a0817f

envcompile-time (main)compile-time (PR)Δ%throughput (main)throughput (PR)Δ%peak-ram (main)peak-ram (PR)Δ%env-size (main)env-size (PR)Δ%constants (main)constants (PR)Δ%
Mathlib45.269 s51.564 s+13.9% (1.14× slower) ⚠️16.27K14.29K-12.2% (1.14× slower) ⚠️18.74 GiB18.49 GiB-1.3%2.97 GiB2.99 GiB+0.6%736,618736,618+0.0%

1 env · 1 with regressions · 0 with improvements (|Δ| > 3.0% on any metric).

Workflow logs

johnchandlerburnhamand others added 8 commits July 13, 2026 17:43
…tions, blob integrity
Evolves .ixe in place (Tag4(0xE,0) kept, pre-alpha) so a single env file
can pin one Lean value as a self-contained bundle:
- Header gains main : Option Address (bundle root; writers and all
readers enforce main ∈ consts) and a strictly-ascending assumptions
list (thin-bundle trust boundary; merkle_root_canonical over the
leaves reproduces Claim.assumptions roots).
- Sections reordered hot-first: blobs, consts, anon_hints, names,
named, comms. get_anon/get_anon_mmap stop after §3 (no metadata
parse-and-discard); parse_lazy_index stops after §5; the full
readers reject trailing bytes.
- anon_hints promoted from optional trailer to an always-written §3,
derived from Named Def metadata when the map is empty; read-time
hint harvesting removed (EnvHandle::from_bytes post-pass dropped).
- Blob entries are hash-verified in every reader on both sides (a
swapped blob silently changed a Nat/String literal before); Lean
getEnv also gains the const hash check it was missing.
- New Env::bfs_closure follows all three edge kinds (Constant.refs,
Prj→block, Muts→member/ctor projections — a refs-only walk returns
just the root for projections); prune_to_closure builds bundles
(value closure + named-metadata pruning: name parent chains and
string blobs, DataValue payload blobs, meta_refs edges, aux_gen
originals, per-const hints); validate_closed is the receiver check.
kernel::anon_work::closure_addrs now delegates to bfs_closure.
- Lean topologicalSortNames emits the anonymous name at index 0,
matching Rust: serEnv and rsSerEnv are now byte-identical (verified
byte-exact on a 505 MB whole-stdlib env) and the putIdx getD-0
fallback now resolves to anon instead of colliding with the first
real name.
- FFI mirrors: RawEnv carries main/assumptions/anonHints (ctor arity
5→8), RawEnvLazy carries main/assumptions (3→5); the manual RawEnv
builders in ffi compile.rs populate the new slots via
set_raw_env_bundle_fields.
- Generators/fixtures content-address blobs and exercise the bundle
fields; tampering tests compute offsets programmatically instead of
hardcoding the header layout; docs/Ixon.md spec updated.
lake exe ix diff <old.ixe> <new.ixe> [--anon|--meta] [--verbose] compares
two serialized environments and exits 0/1/2 (GNU diff convention).
Engine (crates/ixon/src/diff.rs, host-gated like prune_to_closure):
- diff_envs(a, b, meta): named join on Name (added/removed/changed/
meta-only), consts/blobs set diffs, comms join, main/assumptions,
hints joined on shared consts. Deterministic ordering.
- exprs_equal: iterative lockstep walk resolving Share/Sort/Ref/Str/
Nat/Prj indices through each side's own Constant tables (same bytes
+ different tables => different; permuted tables with equal
resolution => equal), pointer-pair memoized, cycle-guarded.
- Per-kind field classification (type/value/lvls/safety/rules[i].rhs/
ctors[j].*, ...); kind changes label "kind"; representation-only
address churn labels "encoding". Projections descend one level into
their Muts block: changed member => block.* labels, untouched member
=> "block-siblings".
- Default anon mode compares only anonymous structure (hints included:
they live in anon §3); meta mode also compares ConstantMeta/original
per component.
FFI: rs_diff_envs (ByteArray -> ByteArray -> Bool -> Except String
EnvDiff) parses both inputs with the full reader (Named.original
participates) and marshals EnvStats/NamedDiff/EnvDiff (lean.rs layouts
num_64:4 / num_obj:7 / num_obj:17); names cross pre-rendered and
pre-sorted, addresses raw.
Tests: 15 engine unit tests (refs-shift guard, share-vs-inline,
univ-table shift, memo-clearing regression, block descent, ...) and 12
Lean FFI tests incl. self-diff-empty properties over genRawEnv in both
modes.
diff_envs_with adds a JoinProgress callback (fires every 100k
named-join entries and once at completion); diff_envs delegates with a
no-op. rs_diff_envs reports phases to stderr ([rs_diff_envs] ..., the
rs_compile_env idiom): per-env parse timings with reader label and
live join progress — emitted only when an input is >= 100 MB, so unit
tests, property tests, and small bundles stay silent.
Anon mode (the default) now loads both envs via parse_lazy_index +
Env::from_lazy_index — constant byte-windows, name->addr, verbatim §3
hints, §6 comms — never materializing ConstantMeta. Two full-reader
mathlib envs OOM a 124 GiB machine; the lazy path diffs two 3.2 GB
mathlib envs in ~62 s at 54 GiB peak with identical output (locked by
a lazy/full parity test). --meta keeps the full reader, since
Named.meta/original are the point of that mode.
parse_lazy_index now carries §3 hints and §6 comms verbatim on
LazyIndex (both tiny) and, since it consumes every section, enforces
EOF exactly like Env::get.
Mathlib-scale validation (v4.29.0 tag vs 8850ed93, 5 days of master):
3763 added / 1522 removed / 143528 changed names over 744k joined,
exit 1, deterministic — zero [encoding] entries; 5 genuine kind
changes and 1 hint change surface above the content-address ripple.
`lake exe ix pack <env.ixe> <name>` prunes a serialized env to the
self-contained bundle pinning one named constant and writes it as a
standalone .ixe — the first production consumer of
Env::prune_to_closure (sets main, records reached cut-points in
assumptions, carries display metadata to fixpoint) followed by the
receiver-side Env::validate_closed.
- New FFI rs_pack_env (crates/ffi/src/lean_ixon/pack.rs), mirroring
the rs_env_extract read→resolve→write shape: full reader, displayed
name→address resolution over env.named, --assume entries resolving
as names first then 64-hex addresses.
- New Ix/Cli/PackCmd.lean: --assume/--assume-file (ConstsFile.gather),
--out (default <name>.ixe), --verbose; exit 0/1.
- Tests/FFI/Ixon.lean: six directed IO tests over a temp-file fixture
(closure bundle, removals-only diff subset, name and hex assume
cuts, unknown-root and main-assumed errors). The fixture stores
name-string blobs via addNameComponentsWithBlobs — the compiler
convention prune_to_closure recreates via carry_name.
- docs/Ixon.md: pack workflow in the bundle section.
E2E: Nat.add out of the 324 MB stdlib env → 13 KB closed bundle
(17 consts, 43 blobs) in ~4 s; --assume Nat drops the cut constant
and records 1 assumption.
One edited constant re-addresses its whole reverse-dependency cone, so
a content-addressed diff drowns the human edits in transitive
re-addressings. Every changed row now carries a verdict: after the
named join, pass 2 re-classifies each changed pair under a quotient
where an (old, new) address pair compares equal when some changed name
maps old→new. Rows whose residual labels are all "encoding" /
"block-siblings" are rippled (fully explained by dependency
re-addressing); the rest are roots. A single-level map is complete
because expression comparison only consults immediate-dependency
addresses — composition happens through per-row verdicts.
Engine (ixon::diff): set-valued AddrMap built in the join loop (kind
and encoding rows included; tolerates name splits); ExprCmp/classify
parameterized by Option<&AddrMap> at the three address sites
(Ref/Str+Nat/Prj-expr); classify_prj block short-circuit stays strict
== and always descends (blocks are unnamed — a mapped shortcut would
hide block-internal roots); pass-2 verdict cache keyed on the (old,
new) pair so alias rows share one computation; constants re-parse
rather than stash (LazyConstant no-cache policy keeps peak RSS flat).
JoinProgress gains DiffPhase {NamedJoin, RippleClassify} with a 10k
ripple stride. NamedChange.rippled crosses the FFI as a num_8 scalar
(fresh scalar region; obj slots untouched).
CLI: the counts line reports "(N roots, M rippled)"; the default
listing shows roots only (plus, under --meta, rippled rows carrying
metadata edits — namedMetaOnly only covers same-addr rows); --verbose
lists rippled rows with a " (rippled)" suffix.
Documented semantic edges: induced re-elaboration (a dependency
universe/arity change alters dependents beyond addresses) verdicts
root; an intrinsically edited block member with no named projection
has no root row; the "block" fallback on assumption-cut bundles
verdicts root — all fail-safe over-reports.
Tests: 14 new engine tests (two-hop chain, block-ctor edit descent,
name splits, kind-row mapping, univ-arity root, renamed dep, literal
root, encoding ripple, alias cache, Prj-expr site, cidx root, …);
join-progress test now asserts per-phase; Lean FFI marshaling tests
pin the num_8 slot.
Mathlib validation (v4.29.0 ↔ 8850ed93, 744k joined names): 143,528
changed → 4,516 roots / 139,012 rippled (96.9% explained). All 5 kind
changes, all 245 lvls signature changes, and 8 genuine ctor-type edits
surface as roots; 605 induced block.ctor.type rows correctly ripple;
zero rippled rows carry scalar labels; deterministic across runs.
Ripple pass +16.5s (compute 30.8s vs 14.3s), peak RSS unchanged at
54 GiB.
…diff
--meta at mathlib scale previously OOM'd a 124 GiB machine (the full
reader materializes every ConstantMeta on both sides). It now runs in
8 GiB peak / 67 s on the 744k-name mathlib pair.
Streaming §5 sweep: both modes load via the lazy index; meta mode
additionally merge-joins the two files' §5 named sections with a pair
of streaming cursors (NamedMetaCursor), parsing each side's entry
against its own §4 reverse index, comparing, and dropping — resident
metadata is O(1) instead of everything-at-once. Raw §5 byte windows
are NOT comparable across files (metadata name references are
file-relative §4 indices; identical metadata serializes to different
bytes over different name tables), so the sweep compares parsed,
Address-valued ConstantMeta — pinned by a §4 index-shift soundness
regression test and full-reader parity tests. §5 arrives in ascending
name-hash order in both files (exactly Name's Ord), which makes the
lockstep merge-join sound. LazyIndex now records the §5 offset and
retains the §4 reverse index (~32 B/name); parse_lazy_index is
otherwise unchanged.
Engine API: diff_env_bytes (bytes-level, both modes lazy) and
diff_envs_lazy over LazySide {env, index, data}; diff_envs/
diff_envs_with keep the in-env metadata path for materialized envs.
DiffPhase gains MetaSweep (progress: "meta sweep: N/M (K differing)").
Mmap path FFI: rs_diff_env_files mmaps both files
(Env::from_lazy_index_mmap keeps constant windows as zero-copy mmap
slices via store_const_lazy_mmap) and rs_ixe_files_equal does the
byte-equal fast path (metadata length check, then mmap memcmp). The
CLI no longer reads files into Lean ByteArrays at all. The
ByteArray-based rs_diff_envs stays for property tests, now also on
the lazy path in both modes.
Mathlib validation (v4.29.0 ↔ 8850ed93): --meta completes at 8 GiB
peak / 67 s wall (sweep finds 7,998 differing names → 867
metadata-only rows + 7,131 changed rows with meta labels; structural
counts identical to anon: 143,528 changed, 4,516 roots). Anon through
the path FFI: 8 GiB peak (was 54 GiB heap-resident), report
byte-identical to the ByteArray-path baseline. Identical-file fast
path on a 3 GB input: 1.5 s.
docs/Ixon.md gains a "Diffing environments" section (semantics,
root/rippled, memory model).
Pack no longer uses the full reader. The source env is memory-mapped
and lazily loaded (constant windows stay zero-copy mmap slices), and
display metadata is carried by re-streaming §5 with a NamedMetaCursor
once per prune fixpoint round, materializing Named entries only for
carried constants — resident metadata is O(survivors) instead of
O(env). Packing Euclid out of the 3.2 GB mathlib env: 22 s / 5.3 GiB
peak (was ~60 s at full-reader tens-of-GiB), output byte-identical to
the full-reader bundle.
Engine: prune_to_closure refactored into prune_init +
prune_value_pass + carry_named_entry (the named-pass body,
parameterized over name/blob resolvers) — prune_to_closure_streaming
shares it verbatim, so the in-memory and streaming paths cannot
drift; a byte-identity test (with an aux-original fixture forcing a
second fixpoint round, a §4-resolved binder name, and a meta_refs
blob) locks it. parse_lazy_index_with_names returns the §4
Address→Name lookup the walk already builds (the plain variant keeps
dropping it).
--anon packs only anonymous structure via prune_to_closure_anon:
value closure + §3 hints, empty §4/§5 — the minimal artifact a
receiver needs to typecheck/evaluate the pinned value
(validate_closed checks the value pin only). Anon Euclid: 2.5 MB vs
13 MB (3,138 consts, 89 value blobs vs 9,504 with name strings),
13.6 s.
FFI rs_pack_env: mmap + lazy load, gains the anon flag (arity 5→6);
mmap_file shared with the diff module. CLI: ix pack --anon. Tests:
streaming/full byte parity (+ assumed-cut variant), anon value-only
contents + roundtrip, Lean FFI anon-bundle fixture; 249 ixon tests,
workspace + ffi/ixon suites green.
Remove the hints field from ConstantMeta::Def (Rust, Lean, the FFI
constructor slots, and the .ixe named-section encoding). Reducibility
hints now live only in the env-level anon_hints map, keyed by constant
address.
- The compiler is the sole producer: compile_definition records each
definition's hints per name (CompileState::def_hints), and
finalize_hints resolves them through the registered Named entries
into env.anon_hints via Env::register_hint once addresses are final.
The Lean compiler mirror (BlockState.defHints + Ixon.mergeHints)
does the same. Alias collisions merge order-independently (min by
(tag, height)), so parallel workers and both mirrors agree
byte-for-byte.
- Writers serialize the map directly; the derive-from-Named fallback
is deleted from Env::put / put_file / serialized_size_breakdown and
both Lean writers. This removes the serial full-decode of every
demoted Named entry that regressed ix compile by 5-14%: the
derivation loop called Named::meta() per entry, which re-decodes
demoted metadata (the IX_COMPILE_DEMOTE default) on every call.
- Kernel ingress reads hints from anon_hints in both anon and meta
modes; the hints_override parameter threading is gone. Decompile
(both languages) and the IxVM claim harness look hints up by
address. The lazy check path transports the hints section on
RawEnvLazy instead of fabricating per-name Def metas
(RawNamedLite.toConstMeta is removed).
- anon_hints becomes an IxonMap like the other env maps; Env now
derives Clone (the riscv64 IxonMap wrapper gained Clone, replacing
the manual per-map Clone impl).
Format change (pre-alpha, no compat shims): Def metadata loses its
hints byte in the named sections, so every hint is stored once instead
of two or three times (hints section + named meta + aux originals) —
the whole-stdlib env shrinks by ~232 KB. InitStd compile-time returns
to its pre-hints-section cost (median 7.3s -> 5.7s locally vs the
derivation code), with byte-exact Lean/Rust writer parity at
505,674,085 bytes on the whole-stdlib env.
Behavior note: a hint-only change now reports as a pure hintsChanged
row in ix diff; it previously also surfaced as a Def metadata diff.
@samuelburnham

Copy link
Copy Markdown
Member

!benchmark compile BENCH_ENVS=InitStd,Mathlib

@argument-ci-bot

Copy link
Copy Markdown
Contributor

!benchmark — main vs 250277d

backends: compile · envs: InitStd,Mathlib · set: primary · shard: 0

compile · InitStd — main from: bencher @ 3a0817f

envcompile-time (main)compile-time (PR)Δ%throughput (main)throughput (PR)Δ%peak-ram (main)peak-ram (PR)Δ%env-size (main)env-size (PR)Δ%constants (main)constants (PR)Δ%
InitStd3.916 s3.694 s-5.7% (1.06× faster) 🟢26.94K28.56K+6.0% (1.06× faster) 🟢3.68 GiB3.64 GiB-0.9%306.43 MiB309.05 MiB+0.9%105,492105,492+0.0%

1 env · 0 with regressions · 1 with improvements (|Δ| > 3.0% on any metric).

compile · Mathlib — main from: bencher @ 3a0817f

envcompile-time (main)compile-time (PR)Δ%throughput (main)throughput (PR)Δ%peak-ram (main)peak-ram (PR)Δ%env-size (main)env-size (PR)Δ%constants (main)constants (PR)Δ%
Mathlib45.269 s46.926 s+3.7% ⚠️16.27K15.70K-3.5% ⚠️18.74 GiB18.59 GiB-0.8%2.97 GiB2.99 GiB+0.6%736,618736,618+0.0%

1 env · 1 with regressions · 0 with improvements (|Δ| > 3.0% on any metric).

Workflow logs

@johnchandlerburnham
johnchandlerburnham merged commit 33c9cbb into mainJul 14, 2026
17 of 18 checks passed
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants

@johnchandlerburnham@samuelburnham@arthurpaulino
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Highlight search terms from Google/DuckDuckGo/Bing referrer\n(function() {\n var ref = document.referrer;\n var terms = [];\n \n if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) {\n var url = new URL(ref);\n var q = url.searchParams.get('q') || url.searchParams.get('p');\n if (q) {\n terms = q.split(/\\s+/).filter(function(t) { return t.length > 2; });\n }\n }\n \n if (terms.length === 0) return;\n \n var style = document.createElement('style');\n style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }';\n document.head.appendChild(style);\n \n function highlight(node) {\n if (node.nodeType === 3) { // text node\n var text = node.textContent;\n var found = false;\n terms.forEach(function(term) {\n var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\') + ')', 'gi');\n if (regex.test(text)) {\n found = true;\n var frag = document.createDocumentFragment();\n var parts = text.split(regex);\n parts.forEach(function(part, i) {\n if (i % 2 === 0) {\n frag.appendChild(document.createTextNode(part));\n } else {\n var span = document.createElement('span');\n span.className = 'userscript-highlight';\n span.textContent = part;\n frag.appendChild(span);\n }\n });\n node.parentNode.replaceChild(frag, node);\n }\n });\n } else if (node.nodeType === 1 && node.childNodes) { // element\n var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT'];\n if (!skipTags.includes(node.tagName)) {\n Array.from(node.childNodes).forEach(highlight);\n }\n }\n }\n \n highlight(document.body);\n \n // Re-highlight on dynamic content\n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1 || node.nodeType === 3) highlight(node);\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Highlight Search Terms"); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

Ixon: .ixe bundle format and diff tool - #474

Merged
johnchandlerburnham merged 8 commits into
mainfrom
jcb/ixe-improvements
Jul 14, 2026
Merged

Ixon: .ixe bundle format and diff tool#474
johnchandlerburnham merged 8 commits into
mainfrom
jcb/ixe-improvements

Conversation

@johnchandlerburnham

@johnchandlerburnhamjohnchandlerburnham commented Jul 5, 2026

Copy link
Copy Markdown
Member

Two related changes

  1. the .ixebundle format (a single env file that pins one Lean value as a self-contained, verifiable unit — the groundwork for Ixon as a client/server interchange format), plus its first producer CLI, ix pack
  2. ix diff — a structured, content-address-aware diff of two .ixe environments, with ripple root-causing (separating intrinsic edits from the content-address ripple) and a memory-lean --meta, validated at mathlib scale.

Part 1 — .ixe bundle format

Evolves the format in place (pre-alpha, Tag4(0xE,0) kept, no compat shims). A constant's address already pins its entire dependency DAG (refs tables are content addresses, recursively); this adds the transport half: a distinguished root, an explicit trust boundary for thin bundles, and closure helpers to produce/validate them.

[Tag4(0xE,0)] [consts merkle root : 32]
[main : u8 flag + 32? ] -- NEW: bundle root; readers enforce main ∈ consts
[assumptions : Tag0 count + 32*] -- NEW: strictly ascending; thin-bundle cut points
§1 blobs -- NEW: blake3(bytes) == addr verified per entry, every reader
§2 consts -- unchanged (already verified)
§3 anon_hints -- moved before metadata; ALWAYS written (derived from Named Def
-- metadata when the map is empty)
§4 names / §5 named / §6 comms -- metadata last
  • Metadata-last reorder: get_anon/get_anon_mmap read §1–§3 and stop — no more parse-and-discard of names/named/comms; the full readers reject trailing bytes.
  • §3 is the canonical hint channel: read-time hint harvesting is gone. merkle_root_canonical(assumptions) reproduces Claim.assumptions roots, so claim interop is derivable, not stored.
  • Integrity: blobs were previously unverified in every reader — a swapped blob silently changed a Nat/String literal under an otherwise-valid file. Lean getEnv also gains the per-const hash check it was missing.
  • Closure helpers: Env::bfs_closure (3-edge traversal: refs, projection → Muts block, block → member/ctor projection addresses), Env::prune_to_closure(main, assumed) (minimal closed bundle incl. display metadata), Env::validate_closed() (receiver check). kernel::anon_work::closure_addrs now delegates to bfs_closure.
  • Byte-identical mirrors (bonus fix): the Lean and Rust writers were never byte-identical (anonymous-name table entry 0; latent putIdxgetD 0 collision). Now locked by strict serEnv == rsSerEnv tests — byte-exact at 505,759,024 bytes on the whole-stdlib env.

ix pack — producing bundles

lake exe ix pack <env.ixe> <name> [--out <path>] [--assume <name|hex64,...>] [--assume-file <f>] [--verbose]

The first production consumer of prune_to_closure: resolves <name> (displayed form) against the env's named table, prunes to the self-contained closure — main set, reached cut points recorded in assumptions, display metadata carried to fixpoint — re-validates with validate_closed, and writes the bundle (default <name>.ixe). --assume entries (names or 64-hex constant addresses) declare thin-bundle trust boundaries. Euclid's theorem out of the 3.2 GB mathlib env:

❯ lake exe ix pack compilemathlib-8850ed93.ixe Nat.exists_infinite_primes --verbose
[rs_pack_env] main 091d37b69086bf163ecfb6c12233e1e741655b8ab09f76979f0bbbdc0c3176b5 (Nat.exists_infinite_primes)
[rs_pack_env] kept 3138/648914 consts, 10181/746073 named, 9504/329107 blobs, 0 assumption(s)
[rs_pack_env] wrote Nat.exists_infinite_primes.ixe (13006199 bytes)

3,138 constants (0.5% of mathlib), 13 MB, closed by construction — main's 32 bytes alone pin the value. (ix shard extract remains the non-bundle sibling: a general sub-env for the kernel-check pipeline, no main root.) Pack v1 reads with the full reader so metadata survives the prune; the 3.2 GB parse dominates its runtime — a leaner selective-metadata pack is future work on the §5 streaming machinery from Part 2.

Part 2 — ix diff

lake exe ix diff <old.ixe> <new.ixe> [--anon | --meta] [--verbose]

Content-addressing frames the semantics: a name "changed" ⇔ its constant address changed, and value-equality coincides with address-equality — so the interesting work is saying what changed, honestly.

[diff] old.ixe: 647127 consts, 743832 named, 328281 blobs, 0 comms
[diff] new.ixe: 648914 consts, 746073 named, 329107 blobs, 0 comms
named: 3763 added, 1522 removed, 143528 changed (4516 roots, 139012 rippled)
+ AddEquiv.withBotCongr 33ccd4f845d0…
~ AbsoluteValue.exists_partition_int defn a382796a804c… → f1cee63a8923… [type, value]
~ RecursiveIn iprj→defn 13b38a4e0f38… → 3fe097b1e646… [kind]
(139012 rippled rows hidden — address changes fully explained by dependency re-addressing; --verbose lists)
consts: 140144 only in old, 141931 only in new (mutual blocks/projections)
hints changed: 1

Exit codes follow GNU diff: 0 = no difference (in the selected mode), 1 = differences, 2 = error. Byte-equal files short-circuit to identical without parsing (mmap memcmp; 1.5 s on a 3 GB input).

Modes

  • --anon (default): compares only anonymous structure — name→addr changes with per-field classification, consts/blobs set differences, comms, main/assumptions, and reducibility hints (they live in anon §3 and drive kernel unfolding). Names are join/display keys only.
  • --meta: additionally compares Named metadata per component (meta.info with kind transitions, meta.sharing/refs/univs, original.added/removed/addr/meta) — a same-addr metadata-only change is its own report category.

Field-level classification (Rust engine, ixon::diff)

  • exprs_equal is an iterative lockstep walk that resolves Share/Sort/Ref/ Str/Nat/Prj indices through each side's own constant tables: identical expr bytes over different refs tables compare different; permuted tables with equal resolution compare equal. Pointer-pair memoized (shared subterms don't re-walk), Share-cycle-guarded, no recursion (kernel terms nest thousands deep).
  • Per-kind field labels (type, value, lvls, safety, rules[i].rhs, ctors[j].type, …). Kind changes report kind; an address change with no detected semantic difference reports encoding (representation churn) — never an empty list.
  • Projection block descent: inductives/ctors/recursors live only inside Muts blocks behind projections, so "block changed" alone would gut the tool. Same-kind projections with a changed block descend one level: changed member → block.* labels; untouched member → block-siblings (the block hash moved because a sibling changed — the common, most informative case).

Ripple root-causing (root vs rippled)

One edited constant re-addresses its whole reverse-dependency cone, so a content-addressed diff drowns the human edits in transitive re-addressings. Every changed row now carries a verdict: after the join, a second pass re-classifies each changed pair under a quotient where an (old, new) address pair compares equal when some changed name maps old→new. Rows whose residual labels are all encoding/block-siblings are rippled — fully explained by their dependencies' re-addressing; the rest are roots (intrinsic edits). A single-level map is complete because expression comparison only ever consults immediate-dependency addresses — composition across the DAG happens through the per-row verdicts, never M∘M.

  • fields keeps the strict classification; rippled : Bool is the orthogonal verdict. The default listing shows roots (plus, under --meta, rippled rows carrying metadata edits); --verbose lists everything.
  • The map is set-valued (name splits tolerated) and built from every changed row, kind changes included — so a iprj→defn refactor's dependents still ripple.
  • Block descent runs under the quotient too: an intrinsically edited ctor's projection is the root (block.ctor.type), its untouched siblings ripple, and blocks themselves (unnamed) never enter the map — descent is mandatory or block-internal roots would vanish.
  • Documented accepted edges, all fail-safe over-reports: induced re-elaboration (a dependency's universe/arity signature change alters dependents' terms beyond addresses) verdicts root; an intrinsically edited block member with no named projection has no root row of its own; the block fallback on assumption-cut bundles verdicts root.
  • Alias rows share one cached verdict per (old, new) pair; constants re-parse rather than stash (the deliberate LazyConstant no-cache policy keeps memory flat). The pass costs ~one extra classify sweep (+16 s at mathlib scale).

Memory: --meta without the OOM, and an mmap fast path

The first --meta design read both envs with the full reader — at mathlib scale that materializes every ConstantMeta twice and OOM'd a 124 GiB machine. Both modes now load via the lazy index, and meta mode compares metadata by streaming both files' §5 named sections in a lockstep merge-join (§5 is written in ascending name-hash order — exactly Name's Ord — in every file): parse one entry per side against its own §4 reverse index, compare, drop. Resident metadata is O(1) instead of everything-at-once.

Raw §5 byte windows are not comparable across files — metadata name references are file-relative §4 indices, so identical metadata serializes to different bytes over different name tables. The sweep therefore compares parsed, Address-valued ConstantMeta (index-independent), pinned by a §4 index-shift soundness regression test and full-reader parity tests.

The CLI also stopped reading files into Lean ByteArrays entirely: rs_diff_env_files mmaps both inputs (constant windows stay zero-copy mmap slices backed by the OS page cache) and rs_ixe_files_equal does the byte-equal fast path. Anon mode dropped from 54 GiB heap-resident to 8 GiB peak with a byte-identical report.

Progress reporting

Large inputs (≥ 100 MB) stream progress to stderr (stdout stays clean for piping): per-env parse timings, then live per-phase events — meta sweep (meta mode), named join, ripple pass:

❯ lake exe ix diff compilemathlib-4_29.ixe compilemathlib-8850ed93.ixe
[rs_diff_envs] parsing first env (3211 MB, lazy reader)...
[rs_diff_envs] first env parsed in 12.9s (647127 consts, 743832 named, 328281 blobs)
[rs_diff_envs] parsing second env (3222 MB, lazy reader)...
[rs_diff_envs] second env parsed in 12.1s (648914 consts, 746073 named, 329107 blobs)
[rs_diff_envs] named join: 100000/743832 (19300 changed so far)
...
[rs_diff_envs] named join: 743832/743832 (143528 changed so far)
[rs_diff_envs] ripple pass: 10000/143528 (317 roots so far)
...
[rs_diff_envs] ripple pass: 143528/143528 (4516 roots so far)
[rs_diff_env_files] diff computed in 44.1s

Mathlib-scale validation

Diffed the v4.29.0 tag env against 8850ed93 (the last mathlib master commit on the v4.29.0 toolchain — 5 days of master; the Benchmarks/Compile pin is bumped accordingly): two 3.2 GB envs, 744k named join. **Anon: 65 s wall, 8 GiB peak. --meta: 67 s, 8 GiB

[diff] compilemathlib-4_29.ixe: 647127 consts, 743832 named, 328281 blobs, 0 comms
[diff] compilemathlib-8850ed93.ixe: 648914 consts, 746073 named, 329107 blobs, 0 comms
named: 3763 added, 1522 removed, 143528 changed (4516 roots, 139012 rippled)
(synthetic mutual-block names: 627 added, 614 removed — --verbose lists)
+ AddAction.IsPretransitive.of_orbit 2ee60e47dad5…
+ AddEquiv.coe_withBotCongr 2c55b9612a26…
+ AddEquiv.withBotCongr 33ccd4f845d0…
...
- AddCommGroup.ofIsAddCommutative 5ec793909daf…
- AddCommMonoid.ofIsAddCommutative c4954ee67492…
...
~ AbsoluteValue.exists_partition_int defn a382796a804c… → f1cee63a8923… [type, value]
~ AddAction.isClosedMap_quotient defn dbba20e21c96… → 4deaf8b7fdc7… [value]
...
~ CategoryTheory.Classifier iprj→defn c632275bad82… → 28096c100344… [kind]
~ CategoryTheory.HasClassifier iprj→defn 2ba3ede041e1… → 46a6379b87bc… [kind]
~ LinearPMap.mk cprj 1df2b7a39e8a… → ae57a82f6164… [block.ctor.lvls, block.ctor.params, block.ctor.type]
~ OmegaCompletePartialOrder.Chain defn→iprj b6326c126d8f… → 04e157a9b4cd… [kind]
~ RecursiveIn iprj→defn 13b38a4e0f38… → 3fe097b1e646… [kind]
~ RecursiveIn.oracle cprj→defn 2796cba80450… → 3d3d403cff32… [kind]
...
(139012 rippled rows hidden — address changes fully explained by dependency re-addressing; --verbose lists)
consts: 140144 only in compilemathlib-4_29.ixe, 141931 only in compilemathlib-8850ed93.ixe (mutual blocks/projections; --verbose lists)
blobs: 678 only in compilemathlib-4_29.ixe, 1504 only in compilemathlib-8850ed93.ixe
hints changed: 1
38bfaaa99e15… abbrev → regular(2)
[diff] compilemathlib-4_29.ixe ≠ compilemathlib-8850ed93.ixe
  • 3,763 added / 1,522 removed / 143,528 changed, of which 4,516 roots and 139,012 rippled — 96.9% of the changed set is the content-address ripple, explained away by the quotient. Rippled label distribution confirms the shape: 120,941 [type, value] + 15,834 [value] pure re-addressings.
  • The roots are the human-edit surface: all 5 kind changes (CategoryTheory.Classifier/HasClassifieriprj→defn, OmegaCompletePartialOrder.Chaindefn→iprj, RecursiveIn/.oracle destructured), all 245 universe-signature changes ([lvls, type, value]), and 8 genuine constructor edits (e.g. LinearPMap.mk gaining block.ctor.lvls/params/type). The 613 block.ctor.type rows the strict classifier had surfaced turn out to be 8 real edits + 605 induced re-addressings — exactly the noise/signal split the verdict exists for.
  • Verdicts are conservative and checkable: zero rippled rows carry a scalar/coordinate label (machine-checked over all 139k), kind/lvls/cidx changes always root, and the report is deterministic across runs.
  • --meta additionally finds 7,998 names with differing metadata: 867 metadata-only rows (same address — binder/arena edits like …._proof_1 [meta.info]) and 7,131 changed rows with meta labels.
  • Zero [encoding] entries — compilation is deterministic end-to-end.
  • 1 hint change (abbrev → regular(2)) on an otherwise-unchanged constant, correctly absent from the named rows (hints live in §3 and don't re-address constants).

Breaking change

All existing .ixe files are stale — regenerate with ix compile (readers emit a "pre-bundle-format .ixe; recompile it" hint). FFI RawEnv ctor arity is 5→8, RawEnvLazy 3→5; NamedDiff gains a rippled : Bool scalar field.

Verification

  • Rust: full workspace tests green, clippy --all-features clean. ixon at 247 tests; the diff engine alone carries 34 (refs-shift guard, share-vs-inline, univ-table shift, memo-clearing regression, block descent, lazy/full parity, per-phase progress totals, 14 ripple-verdict tests incl. two-hop chains / name splits / renamed deps / induced re-elaboration, §5-sweep parity vs the full reader, the §4 index-shift soundness regression, mmap-side parity).
  • Lean: full lake test green; FFI tests incl. self-diff-empty properties over generated envs in both modes (pins marshaling slot order), a ripple-verdict marshaling test (pins the num_8 slot), file-based-vs-bytes-based diff parity, and six ix pack fixtures (closure bundle, removals-only diff subset, name/hex --assume cuts, error paths).
  • Cross-language: whole-stdlib env byte-exact between writers (505,759,024 bytes); ix check-rs 60,601/60,601 (meta) and 51,681/51,681 (anon); bench-typecheck executes and proves over the new format.

Follow-ups

  • prune_to_closure rescans env.named per fixpoint round — worth a reverse index if bulk pruning against mathlib-scale envs becomes common; the same §5 streaming machinery would also let ix pack skip the full-reader parse.
  • Bundle transport niceties (store sync protocol) remain future work.

Base automatically changed from jcb/compiler to mainJuly 7, 2026 23:12
@johnchandlerburnhamjohnchandlerburnham changed the title Ixon: .ixe bundle format — main/assumptions header, metadata-last sections, blob integrityIxon: .ixe bundle format and diff toolJul 10, 2026
@johnchandlerburnham
johnchandlerburnhamforce-pushed the jcb/ixe-improvements branch 3 times, most recently from 683ed55 to 359aeabCompareJuly 13, 2026 15:55
samuelburnham
samuelburnham previously approved these changes Jul 13, 2026
Comment threadBenchmarks/Compile/lakefile.toml Outdated
@johnchandlerburnham

Copy link
Copy Markdown
MemberAuthor

!benchmark compile

@johnchandlerburnham
johnchandlerburnham enabled auto-merge (squash) July 13, 2026 17:05
@argument-ci-bot

Copy link
Copy Markdown
Contributor

!benchmark — main vs 83867c8

backends: compile · envs: InitStd · set: primary · shard: 0

compile · InitStd — main from: bencher @ c8b5e47

envcompile-time (main)compile-time (PR)Δ%throughput (main)throughput (PR)Δ%peak-ram (main)peak-ram (PR)Δ%env-size (main)env-size (PR)Δ%constants (main)constants (PR)Δ%
InitStd3.936 s4.486 s+14.0% (1.14× slower) ⚠️26.80K23.52K-12.3% (1.14× slower) ⚠️3.64 GiB3.59 GiB-1.3%306.43 MiB309.16 MiB+0.9%105,492105,492+0.0%

1 env · 1 with regressions · 0 with improvements (|Δ| > 3.0% on any metric).

Workflow logs

@samuelburnham

Copy link
Copy Markdown
Member

!benchmark compile BENCH_ENVS=InitStd,Lean,FLT,Mathlib

@argument-ci-bot

Copy link
Copy Markdown
Contributor

!benchmark — main vs 83867c8

backends: compile · envs: InitStd,Lean,FLT,Mathlib · set: primary · shard: 0

compile · FLT — main from: bencher @ c8b5e47

envcompile-time (main)compile-time (PR)Δ%throughput (main)throughput (PR)Δ%peak-ram (main)peak-ram (PR)Δ%env-size (main)env-size (PR)Δ%constants (main)constants (PR)Δ%
FLT42.726 s47.188 s+10.4% (1.10× slower) ⚠️11.95K10.82K-9.5% (1.10× slower) ⚠️12.33 GiB12.50 GiB+1.4%1.70 GiB1.72 GiB+0.8%510,687510,687+0.0%

1 env · 1 with regressions · 0 with improvements (|Δ| > 3.0% on any metric).

compile · InitStd — main from: bencher @ c8b5e47

envcompile-time (main)compile-time (PR)Δ%throughput (main)throughput (PR)Δ%peak-ram (main)peak-ram (PR)Δ%env-size (main)env-size (PR)Δ%constants (main)constants (PR)Δ%
InitStd3.936 s4.486 s+14.0% (1.14× slower) ⚠️26.80K23.52K-12.3% (1.14× slower) ⚠️3.64 GiB3.59 GiB-1.3%306.43 MiB309.16 MiB+0.9%105,492105,492+0.0%

1 env · 1 with regressions · 0 with improvements (|Δ| > 3.0% on any metric).

compile · Lean — main from: bencher @ c8b5e47

envcompile-time (main)compile-time (PR)Δ%throughput (main)throughput (PR)Δ%peak-ram (main)peak-ram (PR)Δ%env-size (main)env-size (PR)Δ%constants (main)constants (PR)Δ%
Lean6.929 s7.290 s+5.2% (1.05× slower) ⚠️27.28K25.93K-5.0% (1.05× slower) ⚠️4.97 GiB4.96 GiB-0.1%458.03 MiB462.76 MiB+1.0%188,999188,999+0.0%

1 env · 1 with regressions · 0 with improvements (|Δ| > 3.0% on any metric).

compile · Mathlib — main from: bencher @ c8b5e47

envcompile-time (main)compile-time (PR)Δ%throughput (main)throughput (PR)Δ%peak-ram (main)peak-ram (PR)Δ%env-size (main)env-size (PR)Δ%constants (main)constants (PR)Δ%
Mathlib48.420 s49.268 s+1.8%15.21K14.95K-1.7%18.34 GiB18.40 GiB+0.3%2.97 GiB2.99 GiB+0.6%736,618736,618+0.0%

1 env · 0 with regressions · 0 with improvements (|Δ| > 3.0% on any metric).

Workflow logs

@samuelburnham

Copy link
Copy Markdown
Member

!benchmark compile BENCH_ENVS=InitStd,Lean,FLT,Mathlib

@argument-ci-bot

Copy link
Copy Markdown
Contributor

!benchmark — main vs 46a7673

backends: compile · envs: InitStd,Lean,FLT,Mathlib · set: primary · shard: 0

compile · FLT — main from: bencher @ 3a0817f

envcompile-time (main)compile-time (PR)Δ%throughput (main)throughput (PR)Δ%peak-ram (main)peak-ram (PR)Δ%env-size (main)env-size (PR)Δ%constants (main)constants (PR)Δ%
FLT44.314 s45.559 s+2.8%11.52K11.21K-2.7%12.57 GiB12.43 GiB-1.1%1.70 GiB1.72 GiB+0.7%510,687510,687+0.0%

1 env · 0 with regressions · 0 with improvements (|Δ| > 3.0% on any metric).

compile · InitStd — main from: bencher @ 3a0817f

envcompile-time (main)compile-time (PR)Δ%throughput (main)throughput (PR)Δ%peak-ram (main)peak-ram (PR)Δ%env-size (main)env-size (PR)Δ%constants (main)constants (PR)Δ%
InitStd3.916 s4.033 s+3.0%26.94K26.16K-2.9%3.68 GiB3.62 GiB-1.6%306.43 MiB309.05 MiB+0.9%105,492105,492+0.0%

1 env · 0 with regressions · 0 with improvements (|Δ| > 3.0% on any metric).

compile · Lean — main from: bencher @ 3a0817f

envcompile-time (main)compile-time (PR)Δ%throughput (main)throughput (PR)Δ%peak-ram (main)peak-ram (PR)Δ%env-size (main)env-size (PR)Δ%constants (main)constants (PR)Δ%
Lean6.558 s6.927 s+5.6% (1.06× slower) ⚠️28.82K27.28K-5.3% (1.06× slower) ⚠️4.91 GiB4.85 GiB-1.3%458.03 MiB462.55 MiB+1.0%188,999188,999+0.0%

1 env · 1 with regressions · 0 with improvements (|Δ| > 3.0% on any metric).

compile · Mathlib — main from: bencher @ 3a0817f

envcompile-time (main)compile-time (PR)Δ%throughput (main)throughput (PR)Δ%peak-ram (main)peak-ram (PR)Δ%env-size (main)env-size (PR)Δ%constants (main)constants (PR)Δ%
Mathlib45.269 s51.564 s+13.9% (1.14× slower) ⚠️16.27K14.29K-12.2% (1.14× slower) ⚠️18.74 GiB18.49 GiB-1.3%2.97 GiB2.99 GiB+0.6%736,618736,618+0.0%

1 env · 1 with regressions · 0 with improvements (|Δ| > 3.0% on any metric).

Workflow logs

johnchandlerburnhamand others added 8 commits July 13, 2026 17:43
…tions, blob integrity
Evolves .ixe in place (Tag4(0xE,0) kept, pre-alpha) so a single env file
can pin one Lean value as a self-contained bundle:
- Header gains main : Option Address (bundle root; writers and all
readers enforce main ∈ consts) and a strictly-ascending assumptions
list (thin-bundle trust boundary; merkle_root_canonical over the
leaves reproduces Claim.assumptions roots).
- Sections reordered hot-first: blobs, consts, anon_hints, names,
named, comms. get_anon/get_anon_mmap stop after §3 (no metadata
parse-and-discard); parse_lazy_index stops after §5; the full
readers reject trailing bytes.
- anon_hints promoted from optional trailer to an always-written §3,
derived from Named Def metadata when the map is empty; read-time
hint harvesting removed (EnvHandle::from_bytes post-pass dropped).
- Blob entries are hash-verified in every reader on both sides (a
swapped blob silently changed a Nat/String literal before); Lean
getEnv also gains the const hash check it was missing.
- New Env::bfs_closure follows all three edge kinds (Constant.refs,
Prj→block, Muts→member/ctor projections — a refs-only walk returns
just the root for projections); prune_to_closure builds bundles
(value closure + named-metadata pruning: name parent chains and
string blobs, DataValue payload blobs, meta_refs edges, aux_gen
originals, per-const hints); validate_closed is the receiver check.
kernel::anon_work::closure_addrs now delegates to bfs_closure.
- Lean topologicalSortNames emits the anonymous name at index 0,
matching Rust: serEnv and rsSerEnv are now byte-identical (verified
byte-exact on a 505 MB whole-stdlib env) and the putIdx getD-0
fallback now resolves to anon instead of colliding with the first
real name.
- FFI mirrors: RawEnv carries main/assumptions/anonHints (ctor arity
5→8), RawEnvLazy carries main/assumptions (3→5); the manual RawEnv
builders in ffi compile.rs populate the new slots via
set_raw_env_bundle_fields.
- Generators/fixtures content-address blobs and exercise the bundle
fields; tampering tests compute offsets programmatically instead of
hardcoding the header layout; docs/Ixon.md spec updated.
lake exe ix diff <old.ixe> <new.ixe> [--anon|--meta] [--verbose] compares
two serialized environments and exits 0/1/2 (GNU diff convention).
Engine (crates/ixon/src/diff.rs, host-gated like prune_to_closure):
- diff_envs(a, b, meta): named join on Name (added/removed/changed/
meta-only), consts/blobs set diffs, comms join, main/assumptions,
hints joined on shared consts. Deterministic ordering.
- exprs_equal: iterative lockstep walk resolving Share/Sort/Ref/Str/
Nat/Prj indices through each side's own Constant tables (same bytes
+ different tables => different; permuted tables with equal
resolution => equal), pointer-pair memoized, cycle-guarded.
- Per-kind field classification (type/value/lvls/safety/rules[i].rhs/
ctors[j].*, ...); kind changes label "kind"; representation-only
address churn labels "encoding". Projections descend one level into
their Muts block: changed member => block.* labels, untouched member
=> "block-siblings".
- Default anon mode compares only anonymous structure (hints included:
they live in anon §3); meta mode also compares ConstantMeta/original
per component.
FFI: rs_diff_envs (ByteArray -> ByteArray -> Bool -> Except String
EnvDiff) parses both inputs with the full reader (Named.original
participates) and marshals EnvStats/NamedDiff/EnvDiff (lean.rs layouts
num_64:4 / num_obj:7 / num_obj:17); names cross pre-rendered and
pre-sorted, addresses raw.
Tests: 15 engine unit tests (refs-shift guard, share-vs-inline,
univ-table shift, memo-clearing regression, block descent, ...) and 12
Lean FFI tests incl. self-diff-empty properties over genRawEnv in both
modes.
diff_envs_with adds a JoinProgress callback (fires every 100k
named-join entries and once at completion); diff_envs delegates with a
no-op. rs_diff_envs reports phases to stderr ([rs_diff_envs] ..., the
rs_compile_env idiom): per-env parse timings with reader label and
live join progress — emitted only when an input is >= 100 MB, so unit
tests, property tests, and small bundles stay silent.
Anon mode (the default) now loads both envs via parse_lazy_index +
Env::from_lazy_index — constant byte-windows, name->addr, verbatim §3
hints, §6 comms — never materializing ConstantMeta. Two full-reader
mathlib envs OOM a 124 GiB machine; the lazy path diffs two 3.2 GB
mathlib envs in ~62 s at 54 GiB peak with identical output (locked by
a lazy/full parity test). --meta keeps the full reader, since
Named.meta/original are the point of that mode.
parse_lazy_index now carries §3 hints and §6 comms verbatim on
LazyIndex (both tiny) and, since it consumes every section, enforces
EOF exactly like Env::get.
Mathlib-scale validation (v4.29.0 tag vs 8850ed93, 5 days of master):
3763 added / 1522 removed / 143528 changed names over 744k joined,
exit 1, deterministic — zero [encoding] entries; 5 genuine kind
changes and 1 hint change surface above the content-address ripple.
`lake exe ix pack <env.ixe> <name>` prunes a serialized env to the
self-contained bundle pinning one named constant and writes it as a
standalone .ixe — the first production consumer of
Env::prune_to_closure (sets main, records reached cut-points in
assumptions, carries display metadata to fixpoint) followed by the
receiver-side Env::validate_closed.
- New FFI rs_pack_env (crates/ffi/src/lean_ixon/pack.rs), mirroring
the rs_env_extract read→resolve→write shape: full reader, displayed
name→address resolution over env.named, --assume entries resolving
as names first then 64-hex addresses.
- New Ix/Cli/PackCmd.lean: --assume/--assume-file (ConstsFile.gather),
--out (default <name>.ixe), --verbose; exit 0/1.
- Tests/FFI/Ixon.lean: six directed IO tests over a temp-file fixture
(closure bundle, removals-only diff subset, name and hex assume
cuts, unknown-root and main-assumed errors). The fixture stores
name-string blobs via addNameComponentsWithBlobs — the compiler
convention prune_to_closure recreates via carry_name.
- docs/Ixon.md: pack workflow in the bundle section.
E2E: Nat.add out of the 324 MB stdlib env → 13 KB closed bundle
(17 consts, 43 blobs) in ~4 s; --assume Nat drops the cut constant
and records 1 assumption.
One edited constant re-addresses its whole reverse-dependency cone, so
a content-addressed diff drowns the human edits in transitive
re-addressings. Every changed row now carries a verdict: after the
named join, pass 2 re-classifies each changed pair under a quotient
where an (old, new) address pair compares equal when some changed name
maps old→new. Rows whose residual labels are all "encoding" /
"block-siblings" are rippled (fully explained by dependency
re-addressing); the rest are roots. A single-level map is complete
because expression comparison only consults immediate-dependency
addresses — composition happens through per-row verdicts.
Engine (ixon::diff): set-valued AddrMap built in the join loop (kind
and encoding rows included; tolerates name splits); ExprCmp/classify
parameterized by Option<&AddrMap> at the three address sites
(Ref/Str+Nat/Prj-expr); classify_prj block short-circuit stays strict
== and always descends (blocks are unnamed — a mapped shortcut would
hide block-internal roots); pass-2 verdict cache keyed on the (old,
new) pair so alias rows share one computation; constants re-parse
rather than stash (LazyConstant no-cache policy keeps peak RSS flat).
JoinProgress gains DiffPhase {NamedJoin, RippleClassify} with a 10k
ripple stride. NamedChange.rippled crosses the FFI as a num_8 scalar
(fresh scalar region; obj slots untouched).
CLI: the counts line reports "(N roots, M rippled)"; the default
listing shows roots only (plus, under --meta, rippled rows carrying
metadata edits — namedMetaOnly only covers same-addr rows); --verbose
lists rippled rows with a " (rippled)" suffix.
Documented semantic edges: induced re-elaboration (a dependency
universe/arity change alters dependents beyond addresses) verdicts
root; an intrinsically edited block member with no named projection
has no root row; the "block" fallback on assumption-cut bundles
verdicts root — all fail-safe over-reports.
Tests: 14 new engine tests (two-hop chain, block-ctor edit descent,
name splits, kind-row mapping, univ-arity root, renamed dep, literal
root, encoding ripple, alias cache, Prj-expr site, cidx root, …);
join-progress test now asserts per-phase; Lean FFI marshaling tests
pin the num_8 slot.
Mathlib validation (v4.29.0 ↔ 8850ed93, 744k joined names): 143,528
changed → 4,516 roots / 139,012 rippled (96.9% explained). All 5 kind
changes, all 245 lvls signature changes, and 8 genuine ctor-type edits
surface as roots; 605 induced block.ctor.type rows correctly ripple;
zero rippled rows carry scalar labels; deterministic across runs.
Ripple pass +16.5s (compute 30.8s vs 14.3s), peak RSS unchanged at
54 GiB.
…diff
--meta at mathlib scale previously OOM'd a 124 GiB machine (the full
reader materializes every ConstantMeta on both sides). It now runs in
8 GiB peak / 67 s on the 744k-name mathlib pair.
Streaming §5 sweep: both modes load via the lazy index; meta mode
additionally merge-joins the two files' §5 named sections with a pair
of streaming cursors (NamedMetaCursor), parsing each side's entry
against its own §4 reverse index, comparing, and dropping — resident
metadata is O(1) instead of everything-at-once. Raw §5 byte windows
are NOT comparable across files (metadata name references are
file-relative §4 indices; identical metadata serializes to different
bytes over different name tables), so the sweep compares parsed,
Address-valued ConstantMeta — pinned by a §4 index-shift soundness
regression test and full-reader parity tests. §5 arrives in ascending
name-hash order in both files (exactly Name's Ord), which makes the
lockstep merge-join sound. LazyIndex now records the §5 offset and
retains the §4 reverse index (~32 B/name); parse_lazy_index is
otherwise unchanged.
Engine API: diff_env_bytes (bytes-level, both modes lazy) and
diff_envs_lazy over LazySide {env, index, data}; diff_envs/
diff_envs_with keep the in-env metadata path for materialized envs.
DiffPhase gains MetaSweep (progress: "meta sweep: N/M (K differing)").
Mmap path FFI: rs_diff_env_files mmaps both files
(Env::from_lazy_index_mmap keeps constant windows as zero-copy mmap
slices via store_const_lazy_mmap) and rs_ixe_files_equal does the
byte-equal fast path (metadata length check, then mmap memcmp). The
CLI no longer reads files into Lean ByteArrays at all. The
ByteArray-based rs_diff_envs stays for property tests, now also on
the lazy path in both modes.
Mathlib validation (v4.29.0 ↔ 8850ed93): --meta completes at 8 GiB
peak / 67 s wall (sweep finds 7,998 differing names → 867
metadata-only rows + 7,131 changed rows with meta labels; structural
counts identical to anon: 143,528 changed, 4,516 roots). Anon through
the path FFI: 8 GiB peak (was 54 GiB heap-resident), report
byte-identical to the ByteArray-path baseline. Identical-file fast
path on a 3 GB input: 1.5 s.
docs/Ixon.md gains a "Diffing environments" section (semantics,
root/rippled, memory model).
Pack no longer uses the full reader. The source env is memory-mapped
and lazily loaded (constant windows stay zero-copy mmap slices), and
display metadata is carried by re-streaming §5 with a NamedMetaCursor
once per prune fixpoint round, materializing Named entries only for
carried constants — resident metadata is O(survivors) instead of
O(env). Packing Euclid out of the 3.2 GB mathlib env: 22 s / 5.3 GiB
peak (was ~60 s at full-reader tens-of-GiB), output byte-identical to
the full-reader bundle.
Engine: prune_to_closure refactored into prune_init +
prune_value_pass + carry_named_entry (the named-pass body,
parameterized over name/blob resolvers) — prune_to_closure_streaming
shares it verbatim, so the in-memory and streaming paths cannot
drift; a byte-identity test (with an aux-original fixture forcing a
second fixpoint round, a §4-resolved binder name, and a meta_refs
blob) locks it. parse_lazy_index_with_names returns the §4
Address→Name lookup the walk already builds (the plain variant keeps
dropping it).
--anon packs only anonymous structure via prune_to_closure_anon:
value closure + §3 hints, empty §4/§5 — the minimal artifact a
receiver needs to typecheck/evaluate the pinned value
(validate_closed checks the value pin only). Anon Euclid: 2.5 MB vs
13 MB (3,138 consts, 89 value blobs vs 9,504 with name strings),
13.6 s.
FFI rs_pack_env: mmap + lazy load, gains the anon flag (arity 5→6);
mmap_file shared with the diff module. CLI: ix pack --anon. Tests:
streaming/full byte parity (+ assumed-cut variant), anon value-only
contents + roundtrip, Lean FFI anon-bundle fixture; 249 ixon tests,
workspace + ffi/ixon suites green.
Remove the hints field from ConstantMeta::Def (Rust, Lean, the FFI
constructor slots, and the .ixe named-section encoding). Reducibility
hints now live only in the env-level anon_hints map, keyed by constant
address.
- The compiler is the sole producer: compile_definition records each
definition's hints per name (CompileState::def_hints), and
finalize_hints resolves them through the registered Named entries
into env.anon_hints via Env::register_hint once addresses are final.
The Lean compiler mirror (BlockState.defHints + Ixon.mergeHints)
does the same. Alias collisions merge order-independently (min by
(tag, height)), so parallel workers and both mirrors agree
byte-for-byte.
- Writers serialize the map directly; the derive-from-Named fallback
is deleted from Env::put / put_file / serialized_size_breakdown and
both Lean writers. This removes the serial full-decode of every
demoted Named entry that regressed ix compile by 5-14%: the
derivation loop called Named::meta() per entry, which re-decodes
demoted metadata (the IX_COMPILE_DEMOTE default) on every call.
- Kernel ingress reads hints from anon_hints in both anon and meta
modes; the hints_override parameter threading is gone. Decompile
(both languages) and the IxVM claim harness look hints up by
address. The lazy check path transports the hints section on
RawEnvLazy instead of fabricating per-name Def metas
(RawNamedLite.toConstMeta is removed).
- anon_hints becomes an IxonMap like the other env maps; Env now
derives Clone (the riscv64 IxonMap wrapper gained Clone, replacing
the manual per-map Clone impl).
Format change (pre-alpha, no compat shims): Def metadata loses its
hints byte in the named sections, so every hint is stored once instead
of two or three times (hints section + named meta + aux originals) —
the whole-stdlib env shrinks by ~232 KB. InitStd compile-time returns
to its pre-hints-section cost (median 7.3s -> 5.7s locally vs the
derivation code), with byte-exact Lean/Rust writer parity at
505,674,085 bytes on the whole-stdlib env.
Behavior note: a hint-only change now reports as a pure hintsChanged
row in ix diff; it previously also surfaced as a Def metadata diff.
@samuelburnham

Copy link
Copy Markdown
Member

!benchmark compile BENCH_ENVS=InitStd,Mathlib

@argument-ci-bot

Copy link
Copy Markdown
Contributor

!benchmark — main vs 250277d

backends: compile · envs: InitStd,Mathlib · set: primary · shard: 0

compile · InitStd — main from: bencher @ 3a0817f

envcompile-time (main)compile-time (PR)Δ%throughput (main)throughput (PR)Δ%peak-ram (main)peak-ram (PR)Δ%env-size (main)env-size (PR)Δ%constants (main)constants (PR)Δ%
InitStd3.916 s3.694 s-5.7% (1.06× faster) 🟢26.94K28.56K+6.0% (1.06× faster) 🟢3.68 GiB3.64 GiB-0.9%306.43 MiB309.05 MiB+0.9%105,492105,492+0.0%

1 env · 0 with regressions · 1 with improvements (|Δ| > 3.0% on any metric).

compile · Mathlib — main from: bencher @ 3a0817f

envcompile-time (main)compile-time (PR)Δ%throughput (main)throughput (PR)Δ%peak-ram (main)peak-ram (PR)Δ%env-size (main)env-size (PR)Δ%constants (main)constants (PR)Δ%
Mathlib45.269 s46.926 s+3.7% ⚠️16.27K15.70K-3.5% ⚠️18.74 GiB18.59 GiB-0.8%2.97 GiB2.99 GiB+0.6%736,618736,618+0.0%

1 env · 1 with regressions · 0 with improvements (|Δ| > 3.0% on any metric).

Workflow logs

@johnchandlerburnham
johnchandlerburnham merged commit 33c9cbb into mainJul 14, 2026
17 of 18 checks passed
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants

@johnchandlerburnham@samuelburnham@arthurpaulino
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Strip utm_, fbclid, gclid, etc. from all links on page\n(function() {\n var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content',\n 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid',\n 'ref', 'ref_src', 'source', 'medium', 'campaign'];\n \n function cleanUrl(url) {\n try {\n var u = new URL(url, window.location.origin);\n var changed = false;\n trackingParams.forEach(function(p) {\n if (u.searchParams.has(p)) {\n u.searchParams.delete(p);\n changed = true;\n }\n });\n return changed ? u.toString() : url;\n } catch (e) {\n return url;\n }\n }\n \n function cleanLinks() {\n document.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n \n cleanLinks();\n \n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1) {\n if (node.tagName === 'A') cleanLinks();\n node.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Remove Tracking Parameters from Links"); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + '
Skip to content

Ixon: .ixe bundle format and diff tool - #474

Merged
johnchandlerburnham merged 8 commits into
mainfrom
jcb/ixe-improvements
Jul 14, 2026
Merged

Ixon: .ixe bundle format and diff tool#474
johnchandlerburnham merged 8 commits into
mainfrom
jcb/ixe-improvements

Conversation

@johnchandlerburnham

@johnchandlerburnhamjohnchandlerburnham commented Jul 5, 2026

Copy link
Copy Markdown
Member

Two related changes

  1. the .ixebundle format (a single env file that pins one Lean value as a self-contained, verifiable unit — the groundwork for Ixon as a client/server interchange format), plus its first producer CLI, ix pack
  2. ix diff — a structured, content-address-aware diff of two .ixe environments, with ripple root-causing (separating intrinsic edits from the content-address ripple) and a memory-lean --meta, validated at mathlib scale.

Part 1 — .ixe bundle format

Evolves the format in place (pre-alpha, Tag4(0xE,0) kept, no compat shims). A constant's address already pins its entire dependency DAG (refs tables are content addresses, recursively); this adds the transport half: a distinguished root, an explicit trust boundary for thin bundles, and closure helpers to produce/validate them.

[Tag4(0xE,0)] [consts merkle root : 32]
[main : u8 flag + 32? ] -- NEW: bundle root; readers enforce main ∈ consts
[assumptions : Tag0 count + 32*] -- NEW: strictly ascending; thin-bundle cut points
§1 blobs -- NEW: blake3(bytes) == addr verified per entry, every reader
§2 consts -- unchanged (already verified)
§3 anon_hints -- moved before metadata; ALWAYS written (derived from Named Def
-- metadata when the map is empty)
§4 names / §5 named / §6 comms -- metadata last
  • Metadata-last reorder: get_anon/get_anon_mmap read §1–§3 and stop — no more parse-and-discard of names/named/comms; the full readers reject trailing bytes.
  • §3 is the canonical hint channel: read-time hint harvesting is gone. merkle_root_canonical(assumptions) reproduces Claim.assumptions roots, so claim interop is derivable, not stored.
  • Integrity: blobs were previously unverified in every reader — a swapped blob silently changed a Nat/String literal under an otherwise-valid file. Lean getEnv also gains the per-const hash check it was missing.
  • Closure helpers: Env::bfs_closure (3-edge traversal: refs, projection → Muts block, block → member/ctor projection addresses), Env::prune_to_closure(main, assumed) (minimal closed bundle incl. display metadata), Env::validate_closed() (receiver check). kernel::anon_work::closure_addrs now delegates to bfs_closure.
  • Byte-identical mirrors (bonus fix): the Lean and Rust writers were never byte-identical (anonymous-name table entry 0; latent putIdxgetD 0 collision). Now locked by strict serEnv == rsSerEnv tests — byte-exact at 505,759,024 bytes on the whole-stdlib env.

ix pack — producing bundles

lake exe ix pack <env.ixe> <name> [--out <path>] [--assume <name|hex64,...>] [--assume-file <f>] [--verbose]

The first production consumer of prune_to_closure: resolves <name> (displayed form) against the env's named table, prunes to the self-contained closure — main set, reached cut points recorded in assumptions, display metadata carried to fixpoint — re-validates with validate_closed, and writes the bundle (default <name>.ixe). --assume entries (names or 64-hex constant addresses) declare thin-bundle trust boundaries. Euclid's theorem out of the 3.2 GB mathlib env:

❯ lake exe ix pack compilemathlib-8850ed93.ixe Nat.exists_infinite_primes --verbose
[rs_pack_env] main 091d37b69086bf163ecfb6c12233e1e741655b8ab09f76979f0bbbdc0c3176b5 (Nat.exists_infinite_primes)
[rs_pack_env] kept 3138/648914 consts, 10181/746073 named, 9504/329107 blobs, 0 assumption(s)
[rs_pack_env] wrote Nat.exists_infinite_primes.ixe (13006199 bytes)

3,138 constants (0.5% of mathlib), 13 MB, closed by construction — main's 32 bytes alone pin the value. (ix shard extract remains the non-bundle sibling: a general sub-env for the kernel-check pipeline, no main root.) Pack v1 reads with the full reader so metadata survives the prune; the 3.2 GB parse dominates its runtime — a leaner selective-metadata pack is future work on the §5 streaming machinery from Part 2.

Part 2 — ix diff

lake exe ix diff <old.ixe> <new.ixe> [--anon | --meta] [--verbose]

Content-addressing frames the semantics: a name "changed" ⇔ its constant address changed, and value-equality coincides with address-equality — so the interesting work is saying what changed, honestly.

[diff] old.ixe: 647127 consts, 743832 named, 328281 blobs, 0 comms
[diff] new.ixe: 648914 consts, 746073 named, 329107 blobs, 0 comms
named: 3763 added, 1522 removed, 143528 changed (4516 roots, 139012 rippled)
+ AddEquiv.withBotCongr 33ccd4f845d0…
~ AbsoluteValue.exists_partition_int defn a382796a804c… → f1cee63a8923… [type, value]
~ RecursiveIn iprj→defn 13b38a4e0f38… → 3fe097b1e646… [kind]
(139012 rippled rows hidden — address changes fully explained by dependency re-addressing; --verbose lists)
consts: 140144 only in old, 141931 only in new (mutual blocks/projections)
hints changed: 1

Exit codes follow GNU diff: 0 = no difference (in the selected mode), 1 = differences, 2 = error. Byte-equal files short-circuit to identical without parsing (mmap memcmp; 1.5 s on a 3 GB input).

Modes

  • --anon (default): compares only anonymous structure — name→addr changes with per-field classification, consts/blobs set differences, comms, main/assumptions, and reducibility hints (they live in anon §3 and drive kernel unfolding). Names are join/display keys only.
  • --meta: additionally compares Named metadata per component (meta.info with kind transitions, meta.sharing/refs/univs, original.added/removed/addr/meta) — a same-addr metadata-only change is its own report category.

Field-level classification (Rust engine, ixon::diff)

  • exprs_equal is an iterative lockstep walk that resolves Share/Sort/Ref/ Str/Nat/Prj indices through each side's own constant tables: identical expr bytes over different refs tables compare different; permuted tables with equal resolution compare equal. Pointer-pair memoized (shared subterms don't re-walk), Share-cycle-guarded, no recursion (kernel terms nest thousands deep).
  • Per-kind field labels (type, value, lvls, safety, rules[i].rhs, ctors[j].type, …). Kind changes report kind; an address change with no detected semantic difference reports encoding (representation churn) — never an empty list.
  • Projection block descent: inductives/ctors/recursors live only inside Muts blocks behind projections, so "block changed" alone would gut the tool. Same-kind projections with a changed block descend one level: changed member → block.* labels; untouched member → block-siblings (the block hash moved because a sibling changed — the common, most informative case).

Ripple root-causing (root vs rippled)

One edited constant re-addresses its whole reverse-dependency cone, so a content-addressed diff drowns the human edits in transitive re-addressings. Every changed row now carries a verdict: after the join, a second pass re-classifies each changed pair under a quotient where an (old, new) address pair compares equal when some changed name maps old→new. Rows whose residual labels are all encoding/block-siblings are rippled — fully explained by their dependencies' re-addressing; the rest are roots (intrinsic edits). A single-level map is complete because expression comparison only ever consults immediate-dependency addresses — composition across the DAG happens through the per-row verdicts, never M∘M.

  • fields keeps the strict classification; rippled : Bool is the orthogonal verdict. The default listing shows roots (plus, under --meta, rippled rows carrying metadata edits); --verbose lists everything.
  • The map is set-valued (name splits tolerated) and built from every changed row, kind changes included — so a iprj→defn refactor's dependents still ripple.
  • Block descent runs under the quotient too: an intrinsically edited ctor's projection is the root (block.ctor.type), its untouched siblings ripple, and blocks themselves (unnamed) never enter the map — descent is mandatory or block-internal roots would vanish.
  • Documented accepted edges, all fail-safe over-reports: induced re-elaboration (a dependency's universe/arity signature change alters dependents' terms beyond addresses) verdicts root; an intrinsically edited block member with no named projection has no root row of its own; the block fallback on assumption-cut bundles verdicts root.
  • Alias rows share one cached verdict per (old, new) pair; constants re-parse rather than stash (the deliberate LazyConstant no-cache policy keeps memory flat). The pass costs ~one extra classify sweep (+16 s at mathlib scale).

Memory: --meta without the OOM, and an mmap fast path

The first --meta design read both envs with the full reader — at mathlib scale that materializes every ConstantMeta twice and OOM'd a 124 GiB machine. Both modes now load via the lazy index, and meta mode compares metadata by streaming both files' §5 named sections in a lockstep merge-join (§5 is written in ascending name-hash order — exactly Name's Ord — in every file): parse one entry per side against its own §4 reverse index, compare, drop. Resident metadata is O(1) instead of everything-at-once.

Raw §5 byte windows are not comparable across files — metadata name references are file-relative §4 indices, so identical metadata serializes to different bytes over different name tables. The sweep therefore compares parsed, Address-valued ConstantMeta (index-independent), pinned by a §4 index-shift soundness regression test and full-reader parity tests.

The CLI also stopped reading files into Lean ByteArrays entirely: rs_diff_env_files mmaps both inputs (constant windows stay zero-copy mmap slices backed by the OS page cache) and rs_ixe_files_equal does the byte-equal fast path. Anon mode dropped from 54 GiB heap-resident to 8 GiB peak with a byte-identical report.

Progress reporting

Large inputs (≥ 100 MB) stream progress to stderr (stdout stays clean for piping): per-env parse timings, then live per-phase events — meta sweep (meta mode), named join, ripple pass:

❯ lake exe ix diff compilemathlib-4_29.ixe compilemathlib-8850ed93.ixe
[rs_diff_envs] parsing first env (3211 MB, lazy reader)...
[rs_diff_envs] first env parsed in 12.9s (647127 consts, 743832 named, 328281 blobs)
[rs_diff_envs] parsing second env (3222 MB, lazy reader)...
[rs_diff_envs] second env parsed in 12.1s (648914 consts, 746073 named, 329107 blobs)
[rs_diff_envs] named join: 100000/743832 (19300 changed so far)
...
[rs_diff_envs] named join: 743832/743832 (143528 changed so far)
[rs_diff_envs] ripple pass: 10000/143528 (317 roots so far)
...
[rs_diff_envs] ripple pass: 143528/143528 (4516 roots so far)
[rs_diff_env_files] diff computed in 44.1s

Mathlib-scale validation

Diffed the v4.29.0 tag env against 8850ed93 (the last mathlib master commit on the v4.29.0 toolchain — 5 days of master; the Benchmarks/Compile pin is bumped accordingly): two 3.2 GB envs, 744k named join. **Anon: 65 s wall, 8 GiB peak. --meta: 67 s, 8 GiB

[diff] compilemathlib-4_29.ixe: 647127 consts, 743832 named, 328281 blobs, 0 comms
[diff] compilemathlib-8850ed93.ixe: 648914 consts, 746073 named, 329107 blobs, 0 comms
named: 3763 added, 1522 removed, 143528 changed (4516 roots, 139012 rippled)
(synthetic mutual-block names: 627 added, 614 removed — --verbose lists)
+ AddAction.IsPretransitive.of_orbit 2ee60e47dad5…
+ AddEquiv.coe_withBotCongr 2c55b9612a26…
+ AddEquiv.withBotCongr 33ccd4f845d0…
...
- AddCommGroup.ofIsAddCommutative 5ec793909daf…
- AddCommMonoid.ofIsAddCommutative c4954ee67492…
...
~ AbsoluteValue.exists_partition_int defn a382796a804c… → f1cee63a8923… [type, value]
~ AddAction.isClosedMap_quotient defn dbba20e21c96… → 4deaf8b7fdc7… [value]
...
~ CategoryTheory.Classifier iprj→defn c632275bad82… → 28096c100344… [kind]
~ CategoryTheory.HasClassifier iprj→defn 2ba3ede041e1… → 46a6379b87bc… [kind]
~ LinearPMap.mk cprj 1df2b7a39e8a… → ae57a82f6164… [block.ctor.lvls, block.ctor.params, block.ctor.type]
~ OmegaCompletePartialOrder.Chain defn→iprj b6326c126d8f… → 04e157a9b4cd… [kind]
~ RecursiveIn iprj→defn 13b38a4e0f38… → 3fe097b1e646… [kind]
~ RecursiveIn.oracle cprj→defn 2796cba80450… → 3d3d403cff32… [kind]
...
(139012 rippled rows hidden — address changes fully explained by dependency re-addressing; --verbose lists)
consts: 140144 only in compilemathlib-4_29.ixe, 141931 only in compilemathlib-8850ed93.ixe (mutual blocks/projections; --verbose lists)
blobs: 678 only in compilemathlib-4_29.ixe, 1504 only in compilemathlib-8850ed93.ixe
hints changed: 1
38bfaaa99e15… abbrev → regular(2)
[diff] compilemathlib-4_29.ixe ≠ compilemathlib-8850ed93.ixe
  • 3,763 added / 1,522 removed / 143,528 changed, of which 4,516 roots and 139,012 rippled — 96.9% of the changed set is the content-address ripple, explained away by the quotient. Rippled label distribution confirms the shape: 120,941 [type, value] + 15,834 [value] pure re-addressings.
  • The roots are the human-edit surface: all 5 kind changes (CategoryTheory.Classifier/HasClassifieriprj→defn, OmegaCompletePartialOrder.Chaindefn→iprj, RecursiveIn/.oracle destructured), all 245 universe-signature changes ([lvls, type, value]), and 8 genuine constructor edits (e.g. LinearPMap.mk gaining block.ctor.lvls/params/type). The 613 block.ctor.type rows the strict classifier had surfaced turn out to be 8 real edits + 605 induced re-addressings — exactly the noise/signal split the verdict exists for.
  • Verdicts are conservative and checkable: zero rippled rows carry a scalar/coordinate label (machine-checked over all 139k), kind/lvls/cidx changes always root, and the report is deterministic across runs.
  • --meta additionally finds 7,998 names with differing metadata: 867 metadata-only rows (same address — binder/arena edits like …._proof_1 [meta.info]) and 7,131 changed rows with meta labels.
  • Zero [encoding] entries — compilation is deterministic end-to-end.
  • 1 hint change (abbrev → regular(2)) on an otherwise-unchanged constant, correctly absent from the named rows (hints live in §3 and don't re-address constants).

Breaking change

All existing .ixe files are stale — regenerate with ix compile (readers emit a "pre-bundle-format .ixe; recompile it" hint). FFI RawEnv ctor arity is 5→8, RawEnvLazy 3→5; NamedDiff gains a rippled : Bool scalar field.

Verification

  • Rust: full workspace tests green, clippy --all-features clean. ixon at 247 tests; the diff engine alone carries 34 (refs-shift guard, share-vs-inline, univ-table shift, memo-clearing regression, block descent, lazy/full parity, per-phase progress totals, 14 ripple-verdict tests incl. two-hop chains / name splits / renamed deps / induced re-elaboration, §5-sweep parity vs the full reader, the §4 index-shift soundness regression, mmap-side parity).
  • Lean: full lake test green; FFI tests incl. self-diff-empty properties over generated envs in both modes (pins marshaling slot order), a ripple-verdict marshaling test (pins the num_8 slot), file-based-vs-bytes-based diff parity, and six ix pack fixtures (closure bundle, removals-only diff subset, name/hex --assume cuts, error paths).
  • Cross-language: whole-stdlib env byte-exact between writers (505,759,024 bytes); ix check-rs 60,601/60,601 (meta) and 51,681/51,681 (anon); bench-typecheck executes and proves over the new format.

Follow-ups

  • prune_to_closure rescans env.named per fixpoint round — worth a reverse index if bulk pruning against mathlib-scale envs becomes common; the same §5 streaming machinery would also let ix pack skip the full-reader parse.
  • Bundle transport niceties (store sync protocol) remain future work.

Base automatically changed from jcb/compiler to mainJuly 7, 2026 23:12
@johnchandlerburnhamjohnchandlerburnham changed the title Ixon: .ixe bundle format — main/assumptions header, metadata-last sections, blob integrityIxon: .ixe bundle format and diff toolJul 10, 2026
@johnchandlerburnham
johnchandlerburnhamforce-pushed the jcb/ixe-improvements branch 3 times, most recently from 683ed55 to 359aeabCompareJuly 13, 2026 15:55
samuelburnham
samuelburnham previously approved these changes Jul 13, 2026
Comment threadBenchmarks/Compile/lakefile.toml Outdated
@johnchandlerburnham

Copy link
Copy Markdown
MemberAuthor

!benchmark compile

@johnchandlerburnham
johnchandlerburnham enabled auto-merge (squash) July 13, 2026 17:05
@argument-ci-bot

Copy link
Copy Markdown
Contributor

!benchmark — main vs 83867c8

backends: compile · envs: InitStd · set: primary · shard: 0

compile · InitStd — main from: bencher @ c8b5e47

envcompile-time (main)compile-time (PR)Δ%throughput (main)throughput (PR)Δ%peak-ram (main)peak-ram (PR)Δ%env-size (main)env-size (PR)Δ%constants (main)constants (PR)Δ%
InitStd3.936 s4.486 s+14.0% (1.14× slower) ⚠️26.80K23.52K-12.3% (1.14× slower) ⚠️3.64 GiB3.59 GiB-1.3%306.43 MiB309.16 MiB+0.9%105,492105,492+0.0%

1 env · 1 with regressions · 0 with improvements (|Δ| > 3.0% on any metric).

Workflow logs

@samuelburnham

Copy link
Copy Markdown
Member

!benchmark compile BENCH_ENVS=InitStd,Lean,FLT,Mathlib

@argument-ci-bot

Copy link
Copy Markdown
Contributor

!benchmark — main vs 83867c8

backends: compile · envs: InitStd,Lean,FLT,Mathlib · set: primary · shard: 0

compile · FLT — main from: bencher @ c8b5e47

envcompile-time (main)compile-time (PR)Δ%throughput (main)throughput (PR)Δ%peak-ram (main)peak-ram (PR)Δ%env-size (main)env-size (PR)Δ%constants (main)constants (PR)Δ%
FLT42.726 s47.188 s+10.4% (1.10× slower) ⚠️11.95K10.82K-9.5% (1.10× slower) ⚠️12.33 GiB12.50 GiB+1.4%1.70 GiB1.72 GiB+0.8%510,687510,687+0.0%

1 env · 1 with regressions · 0 with improvements (|Δ| > 3.0% on any metric).

compile · InitStd — main from: bencher @ c8b5e47

envcompile-time (main)compile-time (PR)Δ%throughput (main)throughput (PR)Δ%peak-ram (main)peak-ram (PR)Δ%env-size (main)env-size (PR)Δ%constants (main)constants (PR)Δ%
InitStd3.936 s4.486 s+14.0% (1.14× slower) ⚠️26.80K23.52K-12.3% (1.14× slower) ⚠️3.64 GiB3.59 GiB-1.3%306.43 MiB309.16 MiB+0.9%105,492105,492+0.0%

1 env · 1 with regressions · 0 with improvements (|Δ| > 3.0% on any metric).

compile · Lean — main from: bencher @ c8b5e47

envcompile-time (main)compile-time (PR)Δ%throughput (main)throughput (PR)Δ%peak-ram (main)peak-ram (PR)Δ%env-size (main)env-size (PR)Δ%constants (main)constants (PR)Δ%
Lean6.929 s7.290 s+5.2% (1.05× slower) ⚠️27.28K25.93K-5.0% (1.05× slower) ⚠️4.97 GiB4.96 GiB-0.1%458.03 MiB462.76 MiB+1.0%188,999188,999+0.0%

1 env · 1 with regressions · 0 with improvements (|Δ| > 3.0% on any metric).

compile · Mathlib — main from: bencher @ c8b5e47

envcompile-time (main)compile-time (PR)Δ%throughput (main)throughput (PR)Δ%peak-ram (main)peak-ram (PR)Δ%env-size (main)env-size (PR)Δ%constants (main)constants (PR)Δ%
Mathlib48.420 s49.268 s+1.8%15.21K14.95K-1.7%18.34 GiB18.40 GiB+0.3%2.97 GiB2.99 GiB+0.6%736,618736,618+0.0%

1 env · 0 with regressions · 0 with improvements (|Δ| > 3.0% on any metric).

Workflow logs

@samuelburnham

Copy link
Copy Markdown
Member

!benchmark compile BENCH_ENVS=InitStd,Lean,FLT,Mathlib

@argument-ci-bot

Copy link
Copy Markdown
Contributor

!benchmark — main vs 46a7673

backends: compile · envs: InitStd,Lean,FLT,Mathlib · set: primary · shard: 0

compile · FLT — main from: bencher @ 3a0817f

envcompile-time (main)compile-time (PR)Δ%throughput (main)throughput (PR)Δ%peak-ram (main)peak-ram (PR)Δ%env-size (main)env-size (PR)Δ%constants (main)constants (PR)Δ%
FLT44.314 s45.559 s+2.8%11.52K11.21K-2.7%12.57 GiB12.43 GiB-1.1%1.70 GiB1.72 GiB+0.7%510,687510,687+0.0%

1 env · 0 with regressions · 0 with improvements (|Δ| > 3.0% on any metric).

compile · InitStd — main from: bencher @ 3a0817f

envcompile-time (main)compile-time (PR)Δ%throughput (main)throughput (PR)Δ%peak-ram (main)peak-ram (PR)Δ%env-size (main)env-size (PR)Δ%constants (main)constants (PR)Δ%
InitStd3.916 s4.033 s+3.0%26.94K26.16K-2.9%3.68 GiB3.62 GiB-1.6%306.43 MiB309.05 MiB+0.9%105,492105,492+0.0%

1 env · 0 with regressions · 0 with improvements (|Δ| > 3.0% on any metric).

compile · Lean — main from: bencher @ 3a0817f

envcompile-time (main)compile-time (PR)Δ%throughput (main)throughput (PR)Δ%peak-ram (main)peak-ram (PR)Δ%env-size (main)env-size (PR)Δ%constants (main)constants (PR)Δ%
Lean6.558 s6.927 s+5.6% (1.06× slower) ⚠️28.82K27.28K-5.3% (1.06× slower) ⚠️4.91 GiB4.85 GiB-1.3%458.03 MiB462.55 MiB+1.0%188,999188,999+0.0%

1 env · 1 with regressions · 0 with improvements (|Δ| > 3.0% on any metric).

compile · Mathlib — main from: bencher @ 3a0817f

envcompile-time (main)compile-time (PR)Δ%throughput (main)throughput (PR)Δ%peak-ram (main)peak-ram (PR)Δ%env-size (main)env-size (PR)Δ%constants (main)constants (PR)Δ%
Mathlib45.269 s51.564 s+13.9% (1.14× slower) ⚠️16.27K14.29K-12.2% (1.14× slower) ⚠️18.74 GiB18.49 GiB-1.3%2.97 GiB2.99 GiB+0.6%736,618736,618+0.0%

1 env · 1 with regressions · 0 with improvements (|Δ| > 3.0% on any metric).

Workflow logs

johnchandlerburnhamand others added 8 commits July 13, 2026 17:43
…tions, blob integrity
Evolves .ixe in place (Tag4(0xE,0) kept, pre-alpha) so a single env file
can pin one Lean value as a self-contained bundle:
- Header gains main : Option Address (bundle root; writers and all
readers enforce main ∈ consts) and a strictly-ascending assumptions
list (thin-bundle trust boundary; merkle_root_canonical over the
leaves reproduces Claim.assumptions roots).
- Sections reordered hot-first: blobs, consts, anon_hints, names,
named, comms. get_anon/get_anon_mmap stop after §3 (no metadata
parse-and-discard); parse_lazy_index stops after §5; the full
readers reject trailing bytes.
- anon_hints promoted from optional trailer to an always-written §3,
derived from Named Def metadata when the map is empty; read-time
hint harvesting removed (EnvHandle::from_bytes post-pass dropped).
- Blob entries are hash-verified in every reader on both sides (a
swapped blob silently changed a Nat/String literal before); Lean
getEnv also gains the const hash check it was missing.
- New Env::bfs_closure follows all three edge kinds (Constant.refs,
Prj→block, Muts→member/ctor projections — a refs-only walk returns
just the root for projections); prune_to_closure builds bundles
(value closure + named-metadata pruning: name parent chains and
string blobs, DataValue payload blobs, meta_refs edges, aux_gen
originals, per-const hints); validate_closed is the receiver check.
kernel::anon_work::closure_addrs now delegates to bfs_closure.
- Lean topologicalSortNames emits the anonymous name at index 0,
matching Rust: serEnv and rsSerEnv are now byte-identical (verified
byte-exact on a 505 MB whole-stdlib env) and the putIdx getD-0
fallback now resolves to anon instead of colliding with the first
real name.
- FFI mirrors: RawEnv carries main/assumptions/anonHints (ctor arity
5→8), RawEnvLazy carries main/assumptions (3→5); the manual RawEnv
builders in ffi compile.rs populate the new slots via
set_raw_env_bundle_fields.
- Generators/fixtures content-address blobs and exercise the bundle
fields; tampering tests compute offsets programmatically instead of
hardcoding the header layout; docs/Ixon.md spec updated.
lake exe ix diff <old.ixe> <new.ixe> [--anon|--meta] [--verbose] compares
two serialized environments and exits 0/1/2 (GNU diff convention).
Engine (crates/ixon/src/diff.rs, host-gated like prune_to_closure):
- diff_envs(a, b, meta): named join on Name (added/removed/changed/
meta-only), consts/blobs set diffs, comms join, main/assumptions,
hints joined on shared consts. Deterministic ordering.
- exprs_equal: iterative lockstep walk resolving Share/Sort/Ref/Str/
Nat/Prj indices through each side's own Constant tables (same bytes
+ different tables => different; permuted tables with equal
resolution => equal), pointer-pair memoized, cycle-guarded.
- Per-kind field classification (type/value/lvls/safety/rules[i].rhs/
ctors[j].*, ...); kind changes label "kind"; representation-only
address churn labels "encoding". Projections descend one level into
their Muts block: changed member => block.* labels, untouched member
=> "block-siblings".
- Default anon mode compares only anonymous structure (hints included:
they live in anon §3); meta mode also compares ConstantMeta/original
per component.
FFI: rs_diff_envs (ByteArray -> ByteArray -> Bool -> Except String
EnvDiff) parses both inputs with the full reader (Named.original
participates) and marshals EnvStats/NamedDiff/EnvDiff (lean.rs layouts
num_64:4 / num_obj:7 / num_obj:17); names cross pre-rendered and
pre-sorted, addresses raw.
Tests: 15 engine unit tests (refs-shift guard, share-vs-inline,
univ-table shift, memo-clearing regression, block descent, ...) and 12
Lean FFI tests incl. self-diff-empty properties over genRawEnv in both
modes.
diff_envs_with adds a JoinProgress callback (fires every 100k
named-join entries and once at completion); diff_envs delegates with a
no-op. rs_diff_envs reports phases to stderr ([rs_diff_envs] ..., the
rs_compile_env idiom): per-env parse timings with reader label and
live join progress — emitted only when an input is >= 100 MB, so unit
tests, property tests, and small bundles stay silent.
Anon mode (the default) now loads both envs via parse_lazy_index +
Env::from_lazy_index — constant byte-windows, name->addr, verbatim §3
hints, §6 comms — never materializing ConstantMeta. Two full-reader
mathlib envs OOM a 124 GiB machine; the lazy path diffs two 3.2 GB
mathlib envs in ~62 s at 54 GiB peak with identical output (locked by
a lazy/full parity test). --meta keeps the full reader, since
Named.meta/original are the point of that mode.
parse_lazy_index now carries §3 hints and §6 comms verbatim on
LazyIndex (both tiny) and, since it consumes every section, enforces
EOF exactly like Env::get.
Mathlib-scale validation (v4.29.0 tag vs 8850ed93, 5 days of master):
3763 added / 1522 removed / 143528 changed names over 744k joined,
exit 1, deterministic — zero [encoding] entries; 5 genuine kind
changes and 1 hint change surface above the content-address ripple.
`lake exe ix pack <env.ixe> <name>` prunes a serialized env to the
self-contained bundle pinning one named constant and writes it as a
standalone .ixe — the first production consumer of
Env::prune_to_closure (sets main, records reached cut-points in
assumptions, carries display metadata to fixpoint) followed by the
receiver-side Env::validate_closed.
- New FFI rs_pack_env (crates/ffi/src/lean_ixon/pack.rs), mirroring
the rs_env_extract read→resolve→write shape: full reader, displayed
name→address resolution over env.named, --assume entries resolving
as names first then 64-hex addresses.
- New Ix/Cli/PackCmd.lean: --assume/--assume-file (ConstsFile.gather),
--out (default <name>.ixe), --verbose; exit 0/1.
- Tests/FFI/Ixon.lean: six directed IO tests over a temp-file fixture
(closure bundle, removals-only diff subset, name and hex assume
cuts, unknown-root and main-assumed errors). The fixture stores
name-string blobs via addNameComponentsWithBlobs — the compiler
convention prune_to_closure recreates via carry_name.
- docs/Ixon.md: pack workflow in the bundle section.
E2E: Nat.add out of the 324 MB stdlib env → 13 KB closed bundle
(17 consts, 43 blobs) in ~4 s; --assume Nat drops the cut constant
and records 1 assumption.
One edited constant re-addresses its whole reverse-dependency cone, so
a content-addressed diff drowns the human edits in transitive
re-addressings. Every changed row now carries a verdict: after the
named join, pass 2 re-classifies each changed pair under a quotient
where an (old, new) address pair compares equal when some changed name
maps old→new. Rows whose residual labels are all "encoding" /
"block-siblings" are rippled (fully explained by dependency
re-addressing); the rest are roots. A single-level map is complete
because expression comparison only consults immediate-dependency
addresses — composition happens through per-row verdicts.
Engine (ixon::diff): set-valued AddrMap built in the join loop (kind
and encoding rows included; tolerates name splits); ExprCmp/classify
parameterized by Option<&AddrMap> at the three address sites
(Ref/Str+Nat/Prj-expr); classify_prj block short-circuit stays strict
== and always descends (blocks are unnamed — a mapped shortcut would
hide block-internal roots); pass-2 verdict cache keyed on the (old,
new) pair so alias rows share one computation; constants re-parse
rather than stash (LazyConstant no-cache policy keeps peak RSS flat).
JoinProgress gains DiffPhase {NamedJoin, RippleClassify} with a 10k
ripple stride. NamedChange.rippled crosses the FFI as a num_8 scalar
(fresh scalar region; obj slots untouched).
CLI: the counts line reports "(N roots, M rippled)"; the default
listing shows roots only (plus, under --meta, rippled rows carrying
metadata edits — namedMetaOnly only covers same-addr rows); --verbose
lists rippled rows with a " (rippled)" suffix.
Documented semantic edges: induced re-elaboration (a dependency
universe/arity change alters dependents beyond addresses) verdicts
root; an intrinsically edited block member with no named projection
has no root row; the "block" fallback on assumption-cut bundles
verdicts root — all fail-safe over-reports.
Tests: 14 new engine tests (two-hop chain, block-ctor edit descent,
name splits, kind-row mapping, univ-arity root, renamed dep, literal
root, encoding ripple, alias cache, Prj-expr site, cidx root, …);
join-progress test now asserts per-phase; Lean FFI marshaling tests
pin the num_8 slot.
Mathlib validation (v4.29.0 ↔ 8850ed93, 744k joined names): 143,528
changed → 4,516 roots / 139,012 rippled (96.9% explained). All 5 kind
changes, all 245 lvls signature changes, and 8 genuine ctor-type edits
surface as roots; 605 induced block.ctor.type rows correctly ripple;
zero rippled rows carry scalar labels; deterministic across runs.
Ripple pass +16.5s (compute 30.8s vs 14.3s), peak RSS unchanged at
54 GiB.
…diff
--meta at mathlib scale previously OOM'd a 124 GiB machine (the full
reader materializes every ConstantMeta on both sides). It now runs in
8 GiB peak / 67 s on the 744k-name mathlib pair.
Streaming §5 sweep: both modes load via the lazy index; meta mode
additionally merge-joins the two files' §5 named sections with a pair
of streaming cursors (NamedMetaCursor), parsing each side's entry
against its own §4 reverse index, comparing, and dropping — resident
metadata is O(1) instead of everything-at-once. Raw §5 byte windows
are NOT comparable across files (metadata name references are
file-relative §4 indices; identical metadata serializes to different
bytes over different name tables), so the sweep compares parsed,
Address-valued ConstantMeta — pinned by a §4 index-shift soundness
regression test and full-reader parity tests. §5 arrives in ascending
name-hash order in both files (exactly Name's Ord), which makes the
lockstep merge-join sound. LazyIndex now records the §5 offset and
retains the §4 reverse index (~32 B/name); parse_lazy_index is
otherwise unchanged.
Engine API: diff_env_bytes (bytes-level, both modes lazy) and
diff_envs_lazy over LazySide {env, index, data}; diff_envs/
diff_envs_with keep the in-env metadata path for materialized envs.
DiffPhase gains MetaSweep (progress: "meta sweep: N/M (K differing)").
Mmap path FFI: rs_diff_env_files mmaps both files
(Env::from_lazy_index_mmap keeps constant windows as zero-copy mmap
slices via store_const_lazy_mmap) and rs_ixe_files_equal does the
byte-equal fast path (metadata length check, then mmap memcmp). The
CLI no longer reads files into Lean ByteArrays at all. The
ByteArray-based rs_diff_envs stays for property tests, now also on
the lazy path in both modes.
Mathlib validation (v4.29.0 ↔ 8850ed93): --meta completes at 8 GiB
peak / 67 s wall (sweep finds 7,998 differing names → 867
metadata-only rows + 7,131 changed rows with meta labels; structural
counts identical to anon: 143,528 changed, 4,516 roots). Anon through
the path FFI: 8 GiB peak (was 54 GiB heap-resident), report
byte-identical to the ByteArray-path baseline. Identical-file fast
path on a 3 GB input: 1.5 s.
docs/Ixon.md gains a "Diffing environments" section (semantics,
root/rippled, memory model).
Pack no longer uses the full reader. The source env is memory-mapped
and lazily loaded (constant windows stay zero-copy mmap slices), and
display metadata is carried by re-streaming §5 with a NamedMetaCursor
once per prune fixpoint round, materializing Named entries only for
carried constants — resident metadata is O(survivors) instead of
O(env). Packing Euclid out of the 3.2 GB mathlib env: 22 s / 5.3 GiB
peak (was ~60 s at full-reader tens-of-GiB), output byte-identical to
the full-reader bundle.
Engine: prune_to_closure refactored into prune_init +
prune_value_pass + carry_named_entry (the named-pass body,
parameterized over name/blob resolvers) — prune_to_closure_streaming
shares it verbatim, so the in-memory and streaming paths cannot
drift; a byte-identity test (with an aux-original fixture forcing a
second fixpoint round, a §4-resolved binder name, and a meta_refs
blob) locks it. parse_lazy_index_with_names returns the §4
Address→Name lookup the walk already builds (the plain variant keeps
dropping it).
--anon packs only anonymous structure via prune_to_closure_anon:
value closure + §3 hints, empty §4/§5 — the minimal artifact a
receiver needs to typecheck/evaluate the pinned value
(validate_closed checks the value pin only). Anon Euclid: 2.5 MB vs
13 MB (3,138 consts, 89 value blobs vs 9,504 with name strings),
13.6 s.
FFI rs_pack_env: mmap + lazy load, gains the anon flag (arity 5→6);
mmap_file shared with the diff module. CLI: ix pack --anon. Tests:
streaming/full byte parity (+ assumed-cut variant), anon value-only
contents + roundtrip, Lean FFI anon-bundle fixture; 249 ixon tests,
workspace + ffi/ixon suites green.
Remove the hints field from ConstantMeta::Def (Rust, Lean, the FFI
constructor slots, and the .ixe named-section encoding). Reducibility
hints now live only in the env-level anon_hints map, keyed by constant
address.
- The compiler is the sole producer: compile_definition records each
definition's hints per name (CompileState::def_hints), and
finalize_hints resolves them through the registered Named entries
into env.anon_hints via Env::register_hint once addresses are final.
The Lean compiler mirror (BlockState.defHints + Ixon.mergeHints)
does the same. Alias collisions merge order-independently (min by
(tag, height)), so parallel workers and both mirrors agree
byte-for-byte.
- Writers serialize the map directly; the derive-from-Named fallback
is deleted from Env::put / put_file / serialized_size_breakdown and
both Lean writers. This removes the serial full-decode of every
demoted Named entry that regressed ix compile by 5-14%: the
derivation loop called Named::meta() per entry, which re-decodes
demoted metadata (the IX_COMPILE_DEMOTE default) on every call.
- Kernel ingress reads hints from anon_hints in both anon and meta
modes; the hints_override parameter threading is gone. Decompile
(both languages) and the IxVM claim harness look hints up by
address. The lazy check path transports the hints section on
RawEnvLazy instead of fabricating per-name Def metas
(RawNamedLite.toConstMeta is removed).
- anon_hints becomes an IxonMap like the other env maps; Env now
derives Clone (the riscv64 IxonMap wrapper gained Clone, replacing
the manual per-map Clone impl).
Format change (pre-alpha, no compat shims): Def metadata loses its
hints byte in the named sections, so every hint is stored once instead
of two or three times (hints section + named meta + aux originals) —
the whole-stdlib env shrinks by ~232 KB. InitStd compile-time returns
to its pre-hints-section cost (median 7.3s -> 5.7s locally vs the
derivation code), with byte-exact Lean/Rust writer parity at
505,674,085 bytes on the whole-stdlib env.
Behavior note: a hint-only change now reports as a pure hintsChanged
row in ix diff; it previously also surfaced as a Def metadata diff.
@samuelburnham

Copy link
Copy Markdown
Member

!benchmark compile BENCH_ENVS=InitStd,Mathlib

@argument-ci-bot

Copy link
Copy Markdown
Contributor

!benchmark — main vs 250277d

backends: compile · envs: InitStd,Mathlib · set: primary · shard: 0

compile · InitStd — main from: bencher @ 3a0817f

envcompile-time (main)compile-time (PR)Δ%throughput (main)throughput (PR)Δ%peak-ram (main)peak-ram (PR)Δ%env-size (main)env-size (PR)Δ%constants (main)constants (PR)Δ%
InitStd3.916 s3.694 s-5.7% (1.06× faster) 🟢26.94K28.56K+6.0% (1.06× faster) 🟢3.68 GiB3.64 GiB-0.9%306.43 MiB309.05 MiB+0.9%105,492105,492+0.0%

1 env · 0 with regressions · 1 with improvements (|Δ| > 3.0% on any metric).

compile · Mathlib — main from: bencher @ 3a0817f

envcompile-time (main)compile-time (PR)Δ%throughput (main)throughput (PR)Δ%peak-ram (main)peak-ram (PR)Δ%env-size (main)env-size (PR)Δ%constants (main)constants (PR)Δ%
Mathlib45.269 s46.926 s+3.7% ⚠️16.27K15.70K-3.5% ⚠️18.74 GiB18.59 GiB-0.8%2.97 GiB2.99 GiB+0.6%736,618736,618+0.0%

1 env · 1 with regressions · 0 with improvements (|Δ| > 3.0% on any metric).

Workflow logs

@johnchandlerburnham
johnchandlerburnham merged commit 33c9cbb into mainJul 14, 2026
17 of 18 checks passed
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants

@johnchandlerburnham@samuelburnham@arthurpaulino
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Auto-enable theater mode on YouTube\n(function() {\n function tryTheater() {\n var btn = document.querySelector('button[aria-label=\"Theater mode\"], ytd-player #player button[title=\"Theater mode\"]');\n if (btn && !btn.classList.contains('activated')) {\n btn.click();\n }\n }\n \n // Try immediately\n tryTheater();\n \n // Try after navigation (SPA)\n var lastUrl = location.href;\n setInterval(function() {\n if (location.href !== lastUrl) {\n lastUrl = location.href;\n setTimeout(tryTheater, 500);\n }\n }, 1000);\n \n // Also try on player load\n var observer = new MutationObserver(tryTheater);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "YouTube Theater Mode Default"); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

Ixon: .ixe bundle format and diff tool - #474

Merged
johnchandlerburnham merged 8 commits into
mainfrom
jcb/ixe-improvements
Jul 14, 2026
Merged

Ixon: .ixe bundle format and diff tool#474
johnchandlerburnham merged 8 commits into
mainfrom
jcb/ixe-improvements

Conversation

@johnchandlerburnham

@johnchandlerburnhamjohnchandlerburnham commented Jul 5, 2026

Copy link
Copy Markdown
Member

Two related changes

  1. the .ixebundle format (a single env file that pins one Lean value as a self-contained, verifiable unit — the groundwork for Ixon as a client/server interchange format), plus its first producer CLI, ix pack
  2. ix diff — a structured, content-address-aware diff of two .ixe environments, with ripple root-causing (separating intrinsic edits from the content-address ripple) and a memory-lean --meta, validated at mathlib scale.

Part 1 — .ixe bundle format

Evolves the format in place (pre-alpha, Tag4(0xE,0) kept, no compat shims). A constant's address already pins its entire dependency DAG (refs tables are content addresses, recursively); this adds the transport half: a distinguished root, an explicit trust boundary for thin bundles, and closure helpers to produce/validate them.

[Tag4(0xE,0)] [consts merkle root : 32]
[main : u8 flag + 32? ] -- NEW: bundle root; readers enforce main ∈ consts
[assumptions : Tag0 count + 32*] -- NEW: strictly ascending; thin-bundle cut points
§1 blobs -- NEW: blake3(bytes) == addr verified per entry, every reader
§2 consts -- unchanged (already verified)
§3 anon_hints -- moved before metadata; ALWAYS written (derived from Named Def
-- metadata when the map is empty)
§4 names / §5 named / §6 comms -- metadata last
  • Metadata-last reorder: get_anon/get_anon_mmap read §1–§3 and stop — no more parse-and-discard of names/named/comms; the full readers reject trailing bytes.
  • §3 is the canonical hint channel: read-time hint harvesting is gone. merkle_root_canonical(assumptions) reproduces Claim.assumptions roots, so claim interop is derivable, not stored.
  • Integrity: blobs were previously unverified in every reader — a swapped blob silently changed a Nat/String literal under an otherwise-valid file. Lean getEnv also gains the per-const hash check it was missing.
  • Closure helpers: Env::bfs_closure (3-edge traversal: refs, projection → Muts block, block → member/ctor projection addresses), Env::prune_to_closure(main, assumed) (minimal closed bundle incl. display metadata), Env::validate_closed() (receiver check). kernel::anon_work::closure_addrs now delegates to bfs_closure.
  • Byte-identical mirrors (bonus fix): the Lean and Rust writers were never byte-identical (anonymous-name table entry 0; latent putIdxgetD 0 collision). Now locked by strict serEnv == rsSerEnv tests — byte-exact at 505,759,024 bytes on the whole-stdlib env.

ix pack — producing bundles

lake exe ix pack <env.ixe> <name> [--out <path>] [--assume <name|hex64,...>] [--assume-file <f>] [--verbose]

The first production consumer of prune_to_closure: resolves <name> (displayed form) against the env's named table, prunes to the self-contained closure — main set, reached cut points recorded in assumptions, display metadata carried to fixpoint — re-validates with validate_closed, and writes the bundle (default <name>.ixe). --assume entries (names or 64-hex constant addresses) declare thin-bundle trust boundaries. Euclid's theorem out of the 3.2 GB mathlib env:

❯ lake exe ix pack compilemathlib-8850ed93.ixe Nat.exists_infinite_primes --verbose
[rs_pack_env] main 091d37b69086bf163ecfb6c12233e1e741655b8ab09f76979f0bbbdc0c3176b5 (Nat.exists_infinite_primes)
[rs_pack_env] kept 3138/648914 consts, 10181/746073 named, 9504/329107 blobs, 0 assumption(s)
[rs_pack_env] wrote Nat.exists_infinite_primes.ixe (13006199 bytes)

3,138 constants (0.5% of mathlib), 13 MB, closed by construction — main's 32 bytes alone pin the value. (ix shard extract remains the non-bundle sibling: a general sub-env for the kernel-check pipeline, no main root.) Pack v1 reads with the full reader so metadata survives the prune; the 3.2 GB parse dominates its runtime — a leaner selective-metadata pack is future work on the §5 streaming machinery from Part 2.

Part 2 — ix diff

lake exe ix diff <old.ixe> <new.ixe> [--anon | --meta] [--verbose]

Content-addressing frames the semantics: a name "changed" ⇔ its constant address changed, and value-equality coincides with address-equality — so the interesting work is saying what changed, honestly.

[diff] old.ixe: 647127 consts, 743832 named, 328281 blobs, 0 comms
[diff] new.ixe: 648914 consts, 746073 named, 329107 blobs, 0 comms
named: 3763 added, 1522 removed, 143528 changed (4516 roots, 139012 rippled)
+ AddEquiv.withBotCongr 33ccd4f845d0…
~ AbsoluteValue.exists_partition_int defn a382796a804c… → f1cee63a8923… [type, value]
~ RecursiveIn iprj→defn 13b38a4e0f38… → 3fe097b1e646… [kind]
(139012 rippled rows hidden — address changes fully explained by dependency re-addressing; --verbose lists)
consts: 140144 only in old, 141931 only in new (mutual blocks/projections)
hints changed: 1

Exit codes follow GNU diff: 0 = no difference (in the selected mode), 1 = differences, 2 = error. Byte-equal files short-circuit to identical without parsing (mmap memcmp; 1.5 s on a 3 GB input).

Modes

  • --anon (default): compares only anonymous structure — name→addr changes with per-field classification, consts/blobs set differences, comms, main/assumptions, and reducibility hints (they live in anon §3 and drive kernel unfolding). Names are join/display keys only.
  • --meta: additionally compares Named metadata per component (meta.info with kind transitions, meta.sharing/refs/univs, original.added/removed/addr/meta) — a same-addr metadata-only change is its own report category.

Field-level classification (Rust engine, ixon::diff)

  • exprs_equal is an iterative lockstep walk that resolves Share/Sort/Ref/ Str/Nat/Prj indices through each side's own constant tables: identical expr bytes over different refs tables compare different; permuted tables with equal resolution compare equal. Pointer-pair memoized (shared subterms don't re-walk), Share-cycle-guarded, no recursion (kernel terms nest thousands deep).
  • Per-kind field labels (type, value, lvls, safety, rules[i].rhs, ctors[j].type, …). Kind changes report kind; an address change with no detected semantic difference reports encoding (representation churn) — never an empty list.
  • Projection block descent: inductives/ctors/recursors live only inside Muts blocks behind projections, so "block changed" alone would gut the tool. Same-kind projections with a changed block descend one level: changed member → block.* labels; untouched member → block-siblings (the block hash moved because a sibling changed — the common, most informative case).

Ripple root-causing (root vs rippled)

One edited constant re-addresses its whole reverse-dependency cone, so a content-addressed diff drowns the human edits in transitive re-addressings. Every changed row now carries a verdict: after the join, a second pass re-classifies each changed pair under a quotient where an (old, new) address pair compares equal when some changed name maps old→new. Rows whose residual labels are all encoding/block-siblings are rippled — fully explained by their dependencies' re-addressing; the rest are roots (intrinsic edits). A single-level map is complete because expression comparison only ever consults immediate-dependency addresses — composition across the DAG happens through the per-row verdicts, never M∘M.

  • fields keeps the strict classification; rippled : Bool is the orthogonal verdict. The default listing shows roots (plus, under --meta, rippled rows carrying metadata edits); --verbose lists everything.
  • The map is set-valued (name splits tolerated) and built from every changed row, kind changes included — so a iprj→defn refactor's dependents still ripple.
  • Block descent runs under the quotient too: an intrinsically edited ctor's projection is the root (block.ctor.type), its untouched siblings ripple, and blocks themselves (unnamed) never enter the map — descent is mandatory or block-internal roots would vanish.
  • Documented accepted edges, all fail-safe over-reports: induced re-elaboration (a dependency's universe/arity signature change alters dependents' terms beyond addresses) verdicts root; an intrinsically edited block member with no named projection has no root row of its own; the block fallback on assumption-cut bundles verdicts root.
  • Alias rows share one cached verdict per (old, new) pair; constants re-parse rather than stash (the deliberate LazyConstant no-cache policy keeps memory flat). The pass costs ~one extra classify sweep (+16 s at mathlib scale).

Memory: --meta without the OOM, and an mmap fast path

The first --meta design read both envs with the full reader — at mathlib scale that materializes every ConstantMeta twice and OOM'd a 124 GiB machine. Both modes now load via the lazy index, and meta mode compares metadata by streaming both files' §5 named sections in a lockstep merge-join (§5 is written in ascending name-hash order — exactly Name's Ord — in every file): parse one entry per side against its own §4 reverse index, compare, drop. Resident metadata is O(1) instead of everything-at-once.

Raw §5 byte windows are not comparable across files — metadata name references are file-relative §4 indices, so identical metadata serializes to different bytes over different name tables. The sweep therefore compares parsed, Address-valued ConstantMeta (index-independent), pinned by a §4 index-shift soundness regression test and full-reader parity tests.

The CLI also stopped reading files into Lean ByteArrays entirely: rs_diff_env_files mmaps both inputs (constant windows stay zero-copy mmap slices backed by the OS page cache) and rs_ixe_files_equal does the byte-equal fast path. Anon mode dropped from 54 GiB heap-resident to 8 GiB peak with a byte-identical report.

Progress reporting

Large inputs (≥ 100 MB) stream progress to stderr (stdout stays clean for piping): per-env parse timings, then live per-phase events — meta sweep (meta mode), named join, ripple pass:

❯ lake exe ix diff compilemathlib-4_29.ixe compilemathlib-8850ed93.ixe
[rs_diff_envs] parsing first env (3211 MB, lazy reader)...
[rs_diff_envs] first env parsed in 12.9s (647127 consts, 743832 named, 328281 blobs)
[rs_diff_envs] parsing second env (3222 MB, lazy reader)...
[rs_diff_envs] second env parsed in 12.1s (648914 consts, 746073 named, 329107 blobs)
[rs_diff_envs] named join: 100000/743832 (19300 changed so far)
...
[rs_diff_envs] named join: 743832/743832 (143528 changed so far)
[rs_diff_envs] ripple pass: 10000/143528 (317 roots so far)
...
[rs_diff_envs] ripple pass: 143528/143528 (4516 roots so far)
[rs_diff_env_files] diff computed in 44.1s

Mathlib-scale validation

Diffed the v4.29.0 tag env against 8850ed93 (the last mathlib master commit on the v4.29.0 toolchain — 5 days of master; the Benchmarks/Compile pin is bumped accordingly): two 3.2 GB envs, 744k named join. **Anon: 65 s wall, 8 GiB peak. --meta: 67 s, 8 GiB

[diff] compilemathlib-4_29.ixe: 647127 consts, 743832 named, 328281 blobs, 0 comms
[diff] compilemathlib-8850ed93.ixe: 648914 consts, 746073 named, 329107 blobs, 0 comms
named: 3763 added, 1522 removed, 143528 changed (4516 roots, 139012 rippled)
(synthetic mutual-block names: 627 added, 614 removed — --verbose lists)
+ AddAction.IsPretransitive.of_orbit 2ee60e47dad5…
+ AddEquiv.coe_withBotCongr 2c55b9612a26…
+ AddEquiv.withBotCongr 33ccd4f845d0…
...
- AddCommGroup.ofIsAddCommutative 5ec793909daf…
- AddCommMonoid.ofIsAddCommutative c4954ee67492…
...
~ AbsoluteValue.exists_partition_int defn a382796a804c… → f1cee63a8923… [type, value]
~ AddAction.isClosedMap_quotient defn dbba20e21c96… → 4deaf8b7fdc7… [value]
...
~ CategoryTheory.Classifier iprj→defn c632275bad82… → 28096c100344… [kind]
~ CategoryTheory.HasClassifier iprj→defn 2ba3ede041e1… → 46a6379b87bc… [kind]
~ LinearPMap.mk cprj 1df2b7a39e8a… → ae57a82f6164… [block.ctor.lvls, block.ctor.params, block.ctor.type]
~ OmegaCompletePartialOrder.Chain defn→iprj b6326c126d8f… → 04e157a9b4cd… [kind]
~ RecursiveIn iprj→defn 13b38a4e0f38… → 3fe097b1e646… [kind]
~ RecursiveIn.oracle cprj→defn 2796cba80450… → 3d3d403cff32… [kind]
...
(139012 rippled rows hidden — address changes fully explained by dependency re-addressing; --verbose lists)
consts: 140144 only in compilemathlib-4_29.ixe, 141931 only in compilemathlib-8850ed93.ixe (mutual blocks/projections; --verbose lists)
blobs: 678 only in compilemathlib-4_29.ixe, 1504 only in compilemathlib-8850ed93.ixe
hints changed: 1
38bfaaa99e15… abbrev → regular(2)
[diff] compilemathlib-4_29.ixe ≠ compilemathlib-8850ed93.ixe
  • 3,763 added / 1,522 removed / 143,528 changed, of which 4,516 roots and 139,012 rippled — 96.9% of the changed set is the content-address ripple, explained away by the quotient. Rippled label distribution confirms the shape: 120,941 [type, value] + 15,834 [value] pure re-addressings.
  • The roots are the human-edit surface: all 5 kind changes (CategoryTheory.Classifier/HasClassifieriprj→defn, OmegaCompletePartialOrder.Chaindefn→iprj, RecursiveIn/.oracle destructured), all 245 universe-signature changes ([lvls, type, value]), and 8 genuine constructor edits (e.g. LinearPMap.mk gaining block.ctor.lvls/params/type). The 613 block.ctor.type rows the strict classifier had surfaced turn out to be 8 real edits + 605 induced re-addressings — exactly the noise/signal split the verdict exists for.
  • Verdicts are conservative and checkable: zero rippled rows carry a scalar/coordinate label (machine-checked over all 139k), kind/lvls/cidx changes always root, and the report is deterministic across runs.
  • --meta additionally finds 7,998 names with differing metadata: 867 metadata-only rows (same address — binder/arena edits like …._proof_1 [meta.info]) and 7,131 changed rows with meta labels.
  • Zero [encoding] entries — compilation is deterministic end-to-end.
  • 1 hint change (abbrev → regular(2)) on an otherwise-unchanged constant, correctly absent from the named rows (hints live in §3 and don't re-address constants).

Breaking change

All existing .ixe files are stale — regenerate with ix compile (readers emit a "pre-bundle-format .ixe; recompile it" hint). FFI RawEnv ctor arity is 5→8, RawEnvLazy 3→5; NamedDiff gains a rippled : Bool scalar field.

Verification

  • Rust: full workspace tests green, clippy --all-features clean. ixon at 247 tests; the diff engine alone carries 34 (refs-shift guard, share-vs-inline, univ-table shift, memo-clearing regression, block descent, lazy/full parity, per-phase progress totals, 14 ripple-verdict tests incl. two-hop chains / name splits / renamed deps / induced re-elaboration, §5-sweep parity vs the full reader, the §4 index-shift soundness regression, mmap-side parity).
  • Lean: full lake test green; FFI tests incl. self-diff-empty properties over generated envs in both modes (pins marshaling slot order), a ripple-verdict marshaling test (pins the num_8 slot), file-based-vs-bytes-based diff parity, and six ix pack fixtures (closure bundle, removals-only diff subset, name/hex --assume cuts, error paths).
  • Cross-language: whole-stdlib env byte-exact between writers (505,759,024 bytes); ix check-rs 60,601/60,601 (meta) and 51,681/51,681 (anon); bench-typecheck executes and proves over the new format.

Follow-ups

  • prune_to_closure rescans env.named per fixpoint round — worth a reverse index if bulk pruning against mathlib-scale envs becomes common; the same §5 streaming machinery would also let ix pack skip the full-reader parse.
  • Bundle transport niceties (store sync protocol) remain future work.

Base automatically changed from jcb/compiler to mainJuly 7, 2026 23:12
@johnchandlerburnhamjohnchandlerburnham changed the title Ixon: .ixe bundle format — main/assumptions header, metadata-last sections, blob integrityIxon: .ixe bundle format and diff toolJul 10, 2026
@johnchandlerburnham
johnchandlerburnhamforce-pushed the jcb/ixe-improvements branch 3 times, most recently from 683ed55 to 359aeabCompareJuly 13, 2026 15:55
samuelburnham
samuelburnham previously approved these changes Jul 13, 2026
Comment threadBenchmarks/Compile/lakefile.toml Outdated
@johnchandlerburnham

Copy link
Copy Markdown
MemberAuthor

!benchmark compile

@johnchandlerburnham
johnchandlerburnham enabled auto-merge (squash) July 13, 2026 17:05
@argument-ci-bot

Copy link
Copy Markdown
Contributor

!benchmark — main vs 83867c8

backends: compile · envs: InitStd · set: primary · shard: 0

compile · InitStd — main from: bencher @ c8b5e47

envcompile-time (main)compile-time (PR)Δ%throughput (main)throughput (PR)Δ%peak-ram (main)peak-ram (PR)Δ%env-size (main)env-size (PR)Δ%constants (main)constants (PR)Δ%
InitStd3.936 s4.486 s+14.0% (1.14× slower) ⚠️26.80K23.52K-12.3% (1.14× slower) ⚠️3.64 GiB3.59 GiB-1.3%306.43 MiB309.16 MiB+0.9%105,492105,492+0.0%

1 env · 1 with regressions · 0 with improvements (|Δ| > 3.0% on any metric).

Workflow logs

@samuelburnham

Copy link
Copy Markdown
Member

!benchmark compile BENCH_ENVS=InitStd,Lean,FLT,Mathlib

@argument-ci-bot

Copy link
Copy Markdown
Contributor

!benchmark — main vs 83867c8

backends: compile · envs: InitStd,Lean,FLT,Mathlib · set: primary · shard: 0

compile · FLT — main from: bencher @ c8b5e47

envcompile-time (main)compile-time (PR)Δ%throughput (main)throughput (PR)Δ%peak-ram (main)peak-ram (PR)Δ%env-size (main)env-size (PR)Δ%constants (main)constants (PR)Δ%
FLT42.726 s47.188 s+10.4% (1.10× slower) ⚠️11.95K10.82K-9.5% (1.10× slower) ⚠️12.33 GiB12.50 GiB+1.4%1.70 GiB1.72 GiB+0.8%510,687510,687+0.0%

1 env · 1 with regressions · 0 with improvements (|Δ| > 3.0% on any metric).

compile · InitStd — main from: bencher @ c8b5e47

envcompile-time (main)compile-time (PR)Δ%throughput (main)throughput (PR)Δ%peak-ram (main)peak-ram (PR)Δ%env-size (main)env-size (PR)Δ%constants (main)constants (PR)Δ%
InitStd3.936 s4.486 s+14.0% (1.14× slower) ⚠️26.80K23.52K-12.3% (1.14× slower) ⚠️3.64 GiB3.59 GiB-1.3%306.43 MiB309.16 MiB+0.9%105,492105,492+0.0%

1 env · 1 with regressions · 0 with improvements (|Δ| > 3.0% on any metric).

compile · Lean — main from: bencher @ c8b5e47

envcompile-time (main)compile-time (PR)Δ%throughput (main)throughput (PR)Δ%peak-ram (main)peak-ram (PR)Δ%env-size (main)env-size (PR)Δ%constants (main)constants (PR)Δ%
Lean6.929 s7.290 s+5.2% (1.05× slower) ⚠️27.28K25.93K-5.0% (1.05× slower) ⚠️4.97 GiB4.96 GiB-0.1%458.03 MiB462.76 MiB+1.0%188,999188,999+0.0%

1 env · 1 with regressions · 0 with improvements (|Δ| > 3.0% on any metric).

compile · Mathlib — main from: bencher @ c8b5e47

envcompile-time (main)compile-time (PR)Δ%throughput (main)throughput (PR)Δ%peak-ram (main)peak-ram (PR)Δ%env-size (main)env-size (PR)Δ%constants (main)constants (PR)Δ%
Mathlib48.420 s49.268 s+1.8%15.21K14.95K-1.7%18.34 GiB18.40 GiB+0.3%2.97 GiB2.99 GiB+0.6%736,618736,618+0.0%

1 env · 0 with regressions · 0 with improvements (|Δ| > 3.0% on any metric).

Workflow logs

@samuelburnham

Copy link
Copy Markdown
Member

!benchmark compile BENCH_ENVS=InitStd,Lean,FLT,Mathlib

@argument-ci-bot

Copy link
Copy Markdown
Contributor

!benchmark — main vs 46a7673

backends: compile · envs: InitStd,Lean,FLT,Mathlib · set: primary · shard: 0

compile · FLT — main from: bencher @ 3a0817f

envcompile-time (main)compile-time (PR)Δ%throughput (main)throughput (PR)Δ%peak-ram (main)peak-ram (PR)Δ%env-size (main)env-size (PR)Δ%constants (main)constants (PR)Δ%
FLT44.314 s45.559 s+2.8%11.52K11.21K-2.7%12.57 GiB12.43 GiB-1.1%1.70 GiB1.72 GiB+0.7%510,687510,687+0.0%

1 env · 0 with regressions · 0 with improvements (|Δ| > 3.0% on any metric).

compile · InitStd — main from: bencher @ 3a0817f

envcompile-time (main)compile-time (PR)Δ%throughput (main)throughput (PR)Δ%peak-ram (main)peak-ram (PR)Δ%env-size (main)env-size (PR)Δ%constants (main)constants (PR)Δ%
InitStd3.916 s4.033 s+3.0%26.94K26.16K-2.9%3.68 GiB3.62 GiB-1.6%306.43 MiB309.05 MiB+0.9%105,492105,492+0.0%

1 env · 0 with regressions · 0 with improvements (|Δ| > 3.0% on any metric).

compile · Lean — main from: bencher @ 3a0817f

envcompile-time (main)compile-time (PR)Δ%throughput (main)throughput (PR)Δ%peak-ram (main)peak-ram (PR)Δ%env-size (main)env-size (PR)Δ%constants (main)constants (PR)Δ%
Lean6.558 s6.927 s+5.6% (1.06× slower) ⚠️28.82K27.28K-5.3% (1.06× slower) ⚠️4.91 GiB4.85 GiB-1.3%458.03 MiB462.55 MiB+1.0%188,999188,999+0.0%

1 env · 1 with regressions · 0 with improvements (|Δ| > 3.0% on any metric).

compile · Mathlib — main from: bencher @ 3a0817f

envcompile-time (main)compile-time (PR)Δ%throughput (main)throughput (PR)Δ%peak-ram (main)peak-ram (PR)Δ%env-size (main)env-size (PR)Δ%constants (main)constants (PR)Δ%
Mathlib45.269 s51.564 s+13.9% (1.14× slower) ⚠️16.27K14.29K-12.2% (1.14× slower) ⚠️18.74 GiB18.49 GiB-1.3%2.97 GiB2.99 GiB+0.6%736,618736,618+0.0%

1 env · 1 with regressions · 0 with improvements (|Δ| > 3.0% on any metric).

Workflow logs

johnchandlerburnhamand others added 8 commits July 13, 2026 17:43
…tions, blob integrity
Evolves .ixe in place (Tag4(0xE,0) kept, pre-alpha) so a single env file
can pin one Lean value as a self-contained bundle:
- Header gains main : Option Address (bundle root; writers and all
readers enforce main ∈ consts) and a strictly-ascending assumptions
list (thin-bundle trust boundary; merkle_root_canonical over the
leaves reproduces Claim.assumptions roots).
- Sections reordered hot-first: blobs, consts, anon_hints, names,
named, comms. get_anon/get_anon_mmap stop after §3 (no metadata
parse-and-discard); parse_lazy_index stops after §5; the full
readers reject trailing bytes.
- anon_hints promoted from optional trailer to an always-written §3,
derived from Named Def metadata when the map is empty; read-time
hint harvesting removed (EnvHandle::from_bytes post-pass dropped).
- Blob entries are hash-verified in every reader on both sides (a
swapped blob silently changed a Nat/String literal before); Lean
getEnv also gains the const hash check it was missing.
- New Env::bfs_closure follows all three edge kinds (Constant.refs,
Prj→block, Muts→member/ctor projections — a refs-only walk returns
just the root for projections); prune_to_closure builds bundles
(value closure + named-metadata pruning: name parent chains and
string blobs, DataValue payload blobs, meta_refs edges, aux_gen
originals, per-const hints); validate_closed is the receiver check.
kernel::anon_work::closure_addrs now delegates to bfs_closure.
- Lean topologicalSortNames emits the anonymous name at index 0,
matching Rust: serEnv and rsSerEnv are now byte-identical (verified
byte-exact on a 505 MB whole-stdlib env) and the putIdx getD-0
fallback now resolves to anon instead of colliding with the first
real name.
- FFI mirrors: RawEnv carries main/assumptions/anonHints (ctor arity
5→8), RawEnvLazy carries main/assumptions (3→5); the manual RawEnv
builders in ffi compile.rs populate the new slots via
set_raw_env_bundle_fields.
- Generators/fixtures content-address blobs and exercise the bundle
fields; tampering tests compute offsets programmatically instead of
hardcoding the header layout; docs/Ixon.md spec updated.
lake exe ix diff <old.ixe> <new.ixe> [--anon|--meta] [--verbose] compares
two serialized environments and exits 0/1/2 (GNU diff convention).
Engine (crates/ixon/src/diff.rs, host-gated like prune_to_closure):
- diff_envs(a, b, meta): named join on Name (added/removed/changed/
meta-only), consts/blobs set diffs, comms join, main/assumptions,
hints joined on shared consts. Deterministic ordering.
- exprs_equal: iterative lockstep walk resolving Share/Sort/Ref/Str/
Nat/Prj indices through each side's own Constant tables (same bytes
+ different tables => different; permuted tables with equal
resolution => equal), pointer-pair memoized, cycle-guarded.
- Per-kind field classification (type/value/lvls/safety/rules[i].rhs/
ctors[j].*, ...); kind changes label "kind"; representation-only
address churn labels "encoding". Projections descend one level into
their Muts block: changed member => block.* labels, untouched member
=> "block-siblings".
- Default anon mode compares only anonymous structure (hints included:
they live in anon §3); meta mode also compares ConstantMeta/original
per component.
FFI: rs_diff_envs (ByteArray -> ByteArray -> Bool -> Except String
EnvDiff) parses both inputs with the full reader (Named.original
participates) and marshals EnvStats/NamedDiff/EnvDiff (lean.rs layouts
num_64:4 / num_obj:7 / num_obj:17); names cross pre-rendered and
pre-sorted, addresses raw.
Tests: 15 engine unit tests (refs-shift guard, share-vs-inline,
univ-table shift, memo-clearing regression, block descent, ...) and 12
Lean FFI tests incl. self-diff-empty properties over genRawEnv in both
modes.
diff_envs_with adds a JoinProgress callback (fires every 100k
named-join entries and once at completion); diff_envs delegates with a
no-op. rs_diff_envs reports phases to stderr ([rs_diff_envs] ..., the
rs_compile_env idiom): per-env parse timings with reader label and
live join progress — emitted only when an input is >= 100 MB, so unit
tests, property tests, and small bundles stay silent.
Anon mode (the default) now loads both envs via parse_lazy_index +
Env::from_lazy_index — constant byte-windows, name->addr, verbatim §3
hints, §6 comms — never materializing ConstantMeta. Two full-reader
mathlib envs OOM a 124 GiB machine; the lazy path diffs two 3.2 GB
mathlib envs in ~62 s at 54 GiB peak with identical output (locked by
a lazy/full parity test). --meta keeps the full reader, since
Named.meta/original are the point of that mode.
parse_lazy_index now carries §3 hints and §6 comms verbatim on
LazyIndex (both tiny) and, since it consumes every section, enforces
EOF exactly like Env::get.
Mathlib-scale validation (v4.29.0 tag vs 8850ed93, 5 days of master):
3763 added / 1522 removed / 143528 changed names over 744k joined,
exit 1, deterministic — zero [encoding] entries; 5 genuine kind
changes and 1 hint change surface above the content-address ripple.
`lake exe ix pack <env.ixe> <name>` prunes a serialized env to the
self-contained bundle pinning one named constant and writes it as a
standalone .ixe — the first production consumer of
Env::prune_to_closure (sets main, records reached cut-points in
assumptions, carries display metadata to fixpoint) followed by the
receiver-side Env::validate_closed.
- New FFI rs_pack_env (crates/ffi/src/lean_ixon/pack.rs), mirroring
the rs_env_extract read→resolve→write shape: full reader, displayed
name→address resolution over env.named, --assume entries resolving
as names first then 64-hex addresses.
- New Ix/Cli/PackCmd.lean: --assume/--assume-file (ConstsFile.gather),
--out (default <name>.ixe), --verbose; exit 0/1.
- Tests/FFI/Ixon.lean: six directed IO tests over a temp-file fixture
(closure bundle, removals-only diff subset, name and hex assume
cuts, unknown-root and main-assumed errors). The fixture stores
name-string blobs via addNameComponentsWithBlobs — the compiler
convention prune_to_closure recreates via carry_name.
- docs/Ixon.md: pack workflow in the bundle section.
E2E: Nat.add out of the 324 MB stdlib env → 13 KB closed bundle
(17 consts, 43 blobs) in ~4 s; --assume Nat drops the cut constant
and records 1 assumption.
One edited constant re-addresses its whole reverse-dependency cone, so
a content-addressed diff drowns the human edits in transitive
re-addressings. Every changed row now carries a verdict: after the
named join, pass 2 re-classifies each changed pair under a quotient
where an (old, new) address pair compares equal when some changed name
maps old→new. Rows whose residual labels are all "encoding" /
"block-siblings" are rippled (fully explained by dependency
re-addressing); the rest are roots. A single-level map is complete
because expression comparison only consults immediate-dependency
addresses — composition happens through per-row verdicts.
Engine (ixon::diff): set-valued AddrMap built in the join loop (kind
and encoding rows included; tolerates name splits); ExprCmp/classify
parameterized by Option<&AddrMap> at the three address sites
(Ref/Str+Nat/Prj-expr); classify_prj block short-circuit stays strict
== and always descends (blocks are unnamed — a mapped shortcut would
hide block-internal roots); pass-2 verdict cache keyed on the (old,
new) pair so alias rows share one computation; constants re-parse
rather than stash (LazyConstant no-cache policy keeps peak RSS flat).
JoinProgress gains DiffPhase {NamedJoin, RippleClassify} with a 10k
ripple stride. NamedChange.rippled crosses the FFI as a num_8 scalar
(fresh scalar region; obj slots untouched).
CLI: the counts line reports "(N roots, M rippled)"; the default
listing shows roots only (plus, under --meta, rippled rows carrying
metadata edits — namedMetaOnly only covers same-addr rows); --verbose
lists rippled rows with a " (rippled)" suffix.
Documented semantic edges: induced re-elaboration (a dependency
universe/arity change alters dependents beyond addresses) verdicts
root; an intrinsically edited block member with no named projection
has no root row; the "block" fallback on assumption-cut bundles
verdicts root — all fail-safe over-reports.
Tests: 14 new engine tests (two-hop chain, block-ctor edit descent,
name splits, kind-row mapping, univ-arity root, renamed dep, literal
root, encoding ripple, alias cache, Prj-expr site, cidx root, …);
join-progress test now asserts per-phase; Lean FFI marshaling tests
pin the num_8 slot.
Mathlib validation (v4.29.0 ↔ 8850ed93, 744k joined names): 143,528
changed → 4,516 roots / 139,012 rippled (96.9% explained). All 5 kind
changes, all 245 lvls signature changes, and 8 genuine ctor-type edits
surface as roots; 605 induced block.ctor.type rows correctly ripple;
zero rippled rows carry scalar labels; deterministic across runs.
Ripple pass +16.5s (compute 30.8s vs 14.3s), peak RSS unchanged at
54 GiB.
…diff
--meta at mathlib scale previously OOM'd a 124 GiB machine (the full
reader materializes every ConstantMeta on both sides). It now runs in
8 GiB peak / 67 s on the 744k-name mathlib pair.
Streaming §5 sweep: both modes load via the lazy index; meta mode
additionally merge-joins the two files' §5 named sections with a pair
of streaming cursors (NamedMetaCursor), parsing each side's entry
against its own §4 reverse index, comparing, and dropping — resident
metadata is O(1) instead of everything-at-once. Raw §5 byte windows
are NOT comparable across files (metadata name references are
file-relative §4 indices; identical metadata serializes to different
bytes over different name tables), so the sweep compares parsed,
Address-valued ConstantMeta — pinned by a §4 index-shift soundness
regression test and full-reader parity tests. §5 arrives in ascending
name-hash order in both files (exactly Name's Ord), which makes the
lockstep merge-join sound. LazyIndex now records the §5 offset and
retains the §4 reverse index (~32 B/name); parse_lazy_index is
otherwise unchanged.
Engine API: diff_env_bytes (bytes-level, both modes lazy) and
diff_envs_lazy over LazySide {env, index, data}; diff_envs/
diff_envs_with keep the in-env metadata path for materialized envs.
DiffPhase gains MetaSweep (progress: "meta sweep: N/M (K differing)").
Mmap path FFI: rs_diff_env_files mmaps both files
(Env::from_lazy_index_mmap keeps constant windows as zero-copy mmap
slices via store_const_lazy_mmap) and rs_ixe_files_equal does the
byte-equal fast path (metadata length check, then mmap memcmp). The
CLI no longer reads files into Lean ByteArrays at all. The
ByteArray-based rs_diff_envs stays for property tests, now also on
the lazy path in both modes.
Mathlib validation (v4.29.0 ↔ 8850ed93): --meta completes at 8 GiB
peak / 67 s wall (sweep finds 7,998 differing names → 867
metadata-only rows + 7,131 changed rows with meta labels; structural
counts identical to anon: 143,528 changed, 4,516 roots). Anon through
the path FFI: 8 GiB peak (was 54 GiB heap-resident), report
byte-identical to the ByteArray-path baseline. Identical-file fast
path on a 3 GB input: 1.5 s.
docs/Ixon.md gains a "Diffing environments" section (semantics,
root/rippled, memory model).
Pack no longer uses the full reader. The source env is memory-mapped
and lazily loaded (constant windows stay zero-copy mmap slices), and
display metadata is carried by re-streaming §5 with a NamedMetaCursor
once per prune fixpoint round, materializing Named entries only for
carried constants — resident metadata is O(survivors) instead of
O(env). Packing Euclid out of the 3.2 GB mathlib env: 22 s / 5.3 GiB
peak (was ~60 s at full-reader tens-of-GiB), output byte-identical to
the full-reader bundle.
Engine: prune_to_closure refactored into prune_init +
prune_value_pass + carry_named_entry (the named-pass body,
parameterized over name/blob resolvers) — prune_to_closure_streaming
shares it verbatim, so the in-memory and streaming paths cannot
drift; a byte-identity test (with an aux-original fixture forcing a
second fixpoint round, a §4-resolved binder name, and a meta_refs
blob) locks it. parse_lazy_index_with_names returns the §4
Address→Name lookup the walk already builds (the plain variant keeps
dropping it).
--anon packs only anonymous structure via prune_to_closure_anon:
value closure + §3 hints, empty §4/§5 — the minimal artifact a
receiver needs to typecheck/evaluate the pinned value
(validate_closed checks the value pin only). Anon Euclid: 2.5 MB vs
13 MB (3,138 consts, 89 value blobs vs 9,504 with name strings),
13.6 s.
FFI rs_pack_env: mmap + lazy load, gains the anon flag (arity 5→6);
mmap_file shared with the diff module. CLI: ix pack --anon. Tests:
streaming/full byte parity (+ assumed-cut variant), anon value-only
contents + roundtrip, Lean FFI anon-bundle fixture; 249 ixon tests,
workspace + ffi/ixon suites green.
Remove the hints field from ConstantMeta::Def (Rust, Lean, the FFI
constructor slots, and the .ixe named-section encoding). Reducibility
hints now live only in the env-level anon_hints map, keyed by constant
address.
- The compiler is the sole producer: compile_definition records each
definition's hints per name (CompileState::def_hints), and
finalize_hints resolves them through the registered Named entries
into env.anon_hints via Env::register_hint once addresses are final.
The Lean compiler mirror (BlockState.defHints + Ixon.mergeHints)
does the same. Alias collisions merge order-independently (min by
(tag, height)), so parallel workers and both mirrors agree
byte-for-byte.
- Writers serialize the map directly; the derive-from-Named fallback
is deleted from Env::put / put_file / serialized_size_breakdown and
both Lean writers. This removes the serial full-decode of every
demoted Named entry that regressed ix compile by 5-14%: the
derivation loop called Named::meta() per entry, which re-decodes
demoted metadata (the IX_COMPILE_DEMOTE default) on every call.
- Kernel ingress reads hints from anon_hints in both anon and meta
modes; the hints_override parameter threading is gone. Decompile
(both languages) and the IxVM claim harness look hints up by
address. The lazy check path transports the hints section on
RawEnvLazy instead of fabricating per-name Def metas
(RawNamedLite.toConstMeta is removed).
- anon_hints becomes an IxonMap like the other env maps; Env now
derives Clone (the riscv64 IxonMap wrapper gained Clone, replacing
the manual per-map Clone impl).
Format change (pre-alpha, no compat shims): Def metadata loses its
hints byte in the named sections, so every hint is stored once instead
of two or three times (hints section + named meta + aux originals) —
the whole-stdlib env shrinks by ~232 KB. InitStd compile-time returns
to its pre-hints-section cost (median 7.3s -> 5.7s locally vs the
derivation code), with byte-exact Lean/Rust writer parity at
505,674,085 bytes on the whole-stdlib env.
Behavior note: a hint-only change now reports as a pure hintsChanged
row in ix diff; it previously also surfaced as a Def metadata diff.
@samuelburnham

Copy link
Copy Markdown
Member

!benchmark compile BENCH_ENVS=InitStd,Mathlib

@argument-ci-bot

Copy link
Copy Markdown
Contributor

!benchmark — main vs 250277d

backends: compile · envs: InitStd,Mathlib · set: primary · shard: 0

compile · InitStd — main from: bencher @ 3a0817f

envcompile-time (main)compile-time (PR)Δ%throughput (main)throughput (PR)Δ%peak-ram (main)peak-ram (PR)Δ%env-size (main)env-size (PR)Δ%constants (main)constants (PR)Δ%
InitStd3.916 s3.694 s-5.7% (1.06× faster) 🟢26.94K28.56K+6.0% (1.06× faster) 🟢3.68 GiB3.64 GiB-0.9%306.43 MiB309.05 MiB+0.9%105,492105,492+0.0%

1 env · 0 with regressions · 1 with improvements (|Δ| > 3.0% on any metric).

compile · Mathlib — main from: bencher @ 3a0817f

envcompile-time (main)compile-time (PR)Δ%throughput (main)throughput (PR)Δ%peak-ram (main)peak-ram (PR)Δ%env-size (main)env-size (PR)Δ%constants (main)constants (PR)Δ%
Mathlib45.269 s46.926 s+3.7% ⚠️16.27K15.70K-3.5% ⚠️18.74 GiB18.59 GiB-0.8%2.97 GiB2.99 GiB+0.6%736,618736,618+0.0%

1 env · 1 with regressions · 0 with improvements (|Δ| > 3.0% on any metric).

Workflow logs

@johnchandlerburnham
johnchandlerburnham merged commit 33c9cbb into mainJul 14, 2026
17 of 18 checks passed
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants

@johnchandlerburnham@samuelburnham@arthurpaulino
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Remove or un-stick sticky/fixed headers that block content\n(function() {\n function unstick() {\n document.querySelectorAll('header, nav, [role=\"banner\"], .header, .navbar, .sticky, .fixed-top, [style*=\"position: fixed\"], [style*=\"position:sticky\"]').forEach(function(el) {\n if (el.style.position === 'fixed' || el.style.position === 'sticky' || \n getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') {\n el.style.position = 'static';\n el.style.top = 'auto';\n el.style.zIndex = 'auto';\n }\n });\n }\n \n unstick();\n \n var observer = new MutationObserver(unstick);\n observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] });\n})();", "Kill Sticky Headers"); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

Ixon: .ixe bundle format and diff tool - #474

Merged
johnchandlerburnham merged 8 commits into
mainfrom
jcb/ixe-improvements
Jul 14, 2026
Merged

Ixon: .ixe bundle format and diff tool#474
johnchandlerburnham merged 8 commits into
mainfrom
jcb/ixe-improvements

Conversation

@johnchandlerburnham

@johnchandlerburnhamjohnchandlerburnham commented Jul 5, 2026

Copy link
Copy Markdown
Member

Two related changes

  1. the .ixebundle format (a single env file that pins one Lean value as a self-contained, verifiable unit — the groundwork for Ixon as a client/server interchange format), plus its first producer CLI, ix pack
  2. ix diff — a structured, content-address-aware diff of two .ixe environments, with ripple root-causing (separating intrinsic edits from the content-address ripple) and a memory-lean --meta, validated at mathlib scale.

Part 1 — .ixe bundle format

Evolves the format in place (pre-alpha, Tag4(0xE,0) kept, no compat shims). A constant's address already pins its entire dependency DAG (refs tables are content addresses, recursively); this adds the transport half: a distinguished root, an explicit trust boundary for thin bundles, and closure helpers to produce/validate them.

[Tag4(0xE,0)] [consts merkle root : 32]
[main : u8 flag + 32? ] -- NEW: bundle root; readers enforce main ∈ consts
[assumptions : Tag0 count + 32*] -- NEW: strictly ascending; thin-bundle cut points
§1 blobs -- NEW: blake3(bytes) == addr verified per entry, every reader
§2 consts -- unchanged (already verified)
§3 anon_hints -- moved before metadata; ALWAYS written (derived from Named Def
-- metadata when the map is empty)
§4 names / §5 named / §6 comms -- metadata last
  • Metadata-last reorder: get_anon/get_anon_mmap read §1–§3 and stop — no more parse-and-discard of names/named/comms; the full readers reject trailing bytes.
  • §3 is the canonical hint channel: read-time hint harvesting is gone. merkle_root_canonical(assumptions) reproduces Claim.assumptions roots, so claim interop is derivable, not stored.
  • Integrity: blobs were previously unverified in every reader — a swapped blob silently changed a Nat/String literal under an otherwise-valid file. Lean getEnv also gains the per-const hash check it was missing.
  • Closure helpers: Env::bfs_closure (3-edge traversal: refs, projection → Muts block, block → member/ctor projection addresses), Env::prune_to_closure(main, assumed) (minimal closed bundle incl. display metadata), Env::validate_closed() (receiver check). kernel::anon_work::closure_addrs now delegates to bfs_closure.
  • Byte-identical mirrors (bonus fix): the Lean and Rust writers were never byte-identical (anonymous-name table entry 0; latent putIdxgetD 0 collision). Now locked by strict serEnv == rsSerEnv tests — byte-exact at 505,759,024 bytes on the whole-stdlib env.

ix pack — producing bundles

lake exe ix pack <env.ixe> <name> [--out <path>] [--assume <name|hex64,...>] [--assume-file <f>] [--verbose]

The first production consumer of prune_to_closure: resolves <name> (displayed form) against the env's named table, prunes to the self-contained closure — main set, reached cut points recorded in assumptions, display metadata carried to fixpoint — re-validates with validate_closed, and writes the bundle (default <name>.ixe). --assume entries (names or 64-hex constant addresses) declare thin-bundle trust boundaries. Euclid's theorem out of the 3.2 GB mathlib env:

❯ lake exe ix pack compilemathlib-8850ed93.ixe Nat.exists_infinite_primes --verbose
[rs_pack_env] main 091d37b69086bf163ecfb6c12233e1e741655b8ab09f76979f0bbbdc0c3176b5 (Nat.exists_infinite_primes)
[rs_pack_env] kept 3138/648914 consts, 10181/746073 named, 9504/329107 blobs, 0 assumption(s)
[rs_pack_env] wrote Nat.exists_infinite_primes.ixe (13006199 bytes)

3,138 constants (0.5% of mathlib), 13 MB, closed by construction — main's 32 bytes alone pin the value. (ix shard extract remains the non-bundle sibling: a general sub-env for the kernel-check pipeline, no main root.) Pack v1 reads with the full reader so metadata survives the prune; the 3.2 GB parse dominates its runtime — a leaner selective-metadata pack is future work on the §5 streaming machinery from Part 2.

Part 2 — ix diff

lake exe ix diff <old.ixe> <new.ixe> [--anon | --meta] [--verbose]

Content-addressing frames the semantics: a name "changed" ⇔ its constant address changed, and value-equality coincides with address-equality — so the interesting work is saying what changed, honestly.

[diff] old.ixe: 647127 consts, 743832 named, 328281 blobs, 0 comms
[diff] new.ixe: 648914 consts, 746073 named, 329107 blobs, 0 comms
named: 3763 added, 1522 removed, 143528 changed (4516 roots, 139012 rippled)
+ AddEquiv.withBotCongr 33ccd4f845d0…
~ AbsoluteValue.exists_partition_int defn a382796a804c… → f1cee63a8923… [type, value]
~ RecursiveIn iprj→defn 13b38a4e0f38… → 3fe097b1e646… [kind]
(139012 rippled rows hidden — address changes fully explained by dependency re-addressing; --verbose lists)
consts: 140144 only in old, 141931 only in new (mutual blocks/projections)
hints changed: 1

Exit codes follow GNU diff: 0 = no difference (in the selected mode), 1 = differences, 2 = error. Byte-equal files short-circuit to identical without parsing (mmap memcmp; 1.5 s on a 3 GB input).

Modes

  • --anon (default): compares only anonymous structure — name→addr changes with per-field classification, consts/blobs set differences, comms, main/assumptions, and reducibility hints (they live in anon §3 and drive kernel unfolding). Names are join/display keys only.
  • --meta: additionally compares Named metadata per component (meta.info with kind transitions, meta.sharing/refs/univs, original.added/removed/addr/meta) — a same-addr metadata-only change is its own report category.

Field-level classification (Rust engine, ixon::diff)

  • exprs_equal is an iterative lockstep walk that resolves Share/Sort/Ref/ Str/Nat/Prj indices through each side's own constant tables: identical expr bytes over different refs tables compare different; permuted tables with equal resolution compare equal. Pointer-pair memoized (shared subterms don't re-walk), Share-cycle-guarded, no recursion (kernel terms nest thousands deep).
  • Per-kind field labels (type, value, lvls, safety, rules[i].rhs, ctors[j].type, …). Kind changes report kind; an address change with no detected semantic difference reports encoding (representation churn) — never an empty list.
  • Projection block descent: inductives/ctors/recursors live only inside Muts blocks behind projections, so "block changed" alone would gut the tool. Same-kind projections with a changed block descend one level: changed member → block.* labels; untouched member → block-siblings (the block hash moved because a sibling changed — the common, most informative case).

Ripple root-causing (root vs rippled)

One edited constant re-addresses its whole reverse-dependency cone, so a content-addressed diff drowns the human edits in transitive re-addressings. Every changed row now carries a verdict: after the join, a second pass re-classifies each changed pair under a quotient where an (old, new) address pair compares equal when some changed name maps old→new. Rows whose residual labels are all encoding/block-siblings are rippled — fully explained by their dependencies' re-addressing; the rest are roots (intrinsic edits). A single-level map is complete because expression comparison only ever consults immediate-dependency addresses — composition across the DAG happens through the per-row verdicts, never M∘M.

  • fields keeps the strict classification; rippled : Bool is the orthogonal verdict. The default listing shows roots (plus, under --meta, rippled rows carrying metadata edits); --verbose lists everything.
  • The map is set-valued (name splits tolerated) and built from every changed row, kind changes included — so a iprj→defn refactor's dependents still ripple.
  • Block descent runs under the quotient too: an intrinsically edited ctor's projection is the root (block.ctor.type), its untouched siblings ripple, and blocks themselves (unnamed) never enter the map — descent is mandatory or block-internal roots would vanish.
  • Documented accepted edges, all fail-safe over-reports: induced re-elaboration (a dependency's universe/arity signature change alters dependents' terms beyond addresses) verdicts root; an intrinsically edited block member with no named projection has no root row of its own; the block fallback on assumption-cut bundles verdicts root.
  • Alias rows share one cached verdict per (old, new) pair; constants re-parse rather than stash (the deliberate LazyConstant no-cache policy keeps memory flat). The pass costs ~one extra classify sweep (+16 s at mathlib scale).

Memory: --meta without the OOM, and an mmap fast path

The first --meta design read both envs with the full reader — at mathlib scale that materializes every ConstantMeta twice and OOM'd a 124 GiB machine. Both modes now load via the lazy index, and meta mode compares metadata by streaming both files' §5 named sections in a lockstep merge-join (§5 is written in ascending name-hash order — exactly Name's Ord — in every file): parse one entry per side against its own §4 reverse index, compare, drop. Resident metadata is O(1) instead of everything-at-once.

Raw §5 byte windows are not comparable across files — metadata name references are file-relative §4 indices, so identical metadata serializes to different bytes over different name tables. The sweep therefore compares parsed, Address-valued ConstantMeta (index-independent), pinned by a §4 index-shift soundness regression test and full-reader parity tests.

The CLI also stopped reading files into Lean ByteArrays entirely: rs_diff_env_files mmaps both inputs (constant windows stay zero-copy mmap slices backed by the OS page cache) and rs_ixe_files_equal does the byte-equal fast path. Anon mode dropped from 54 GiB heap-resident to 8 GiB peak with a byte-identical report.

Progress reporting

Large inputs (≥ 100 MB) stream progress to stderr (stdout stays clean for piping): per-env parse timings, then live per-phase events — meta sweep (meta mode), named join, ripple pass:

❯ lake exe ix diff compilemathlib-4_29.ixe compilemathlib-8850ed93.ixe
[rs_diff_envs] parsing first env (3211 MB, lazy reader)...
[rs_diff_envs] first env parsed in 12.9s (647127 consts, 743832 named, 328281 blobs)
[rs_diff_envs] parsing second env (3222 MB, lazy reader)...
[rs_diff_envs] second env parsed in 12.1s (648914 consts, 746073 named, 329107 blobs)
[rs_diff_envs] named join: 100000/743832 (19300 changed so far)
...
[rs_diff_envs] named join: 743832/743832 (143528 changed so far)
[rs_diff_envs] ripple pass: 10000/143528 (317 roots so far)
...
[rs_diff_envs] ripple pass: 143528/143528 (4516 roots so far)
[rs_diff_env_files] diff computed in 44.1s

Mathlib-scale validation

Diffed the v4.29.0 tag env against 8850ed93 (the last mathlib master commit on the v4.29.0 toolchain — 5 days of master; the Benchmarks/Compile pin is bumped accordingly): two 3.2 GB envs, 744k named join. **Anon: 65 s wall, 8 GiB peak. --meta: 67 s, 8 GiB

[diff] compilemathlib-4_29.ixe: 647127 consts, 743832 named, 328281 blobs, 0 comms
[diff] compilemathlib-8850ed93.ixe: 648914 consts, 746073 named, 329107 blobs, 0 comms
named: 3763 added, 1522 removed, 143528 changed (4516 roots, 139012 rippled)
(synthetic mutual-block names: 627 added, 614 removed — --verbose lists)
+ AddAction.IsPretransitive.of_orbit 2ee60e47dad5…
+ AddEquiv.coe_withBotCongr 2c55b9612a26…
+ AddEquiv.withBotCongr 33ccd4f845d0…
...
- AddCommGroup.ofIsAddCommutative 5ec793909daf…
- AddCommMonoid.ofIsAddCommutative c4954ee67492…
...
~ AbsoluteValue.exists_partition_int defn a382796a804c… → f1cee63a8923… [type, value]
~ AddAction.isClosedMap_quotient defn dbba20e21c96… → 4deaf8b7fdc7… [value]
...
~ CategoryTheory.Classifier iprj→defn c632275bad82… → 28096c100344… [kind]
~ CategoryTheory.HasClassifier iprj→defn 2ba3ede041e1… → 46a6379b87bc… [kind]
~ LinearPMap.mk cprj 1df2b7a39e8a… → ae57a82f6164… [block.ctor.lvls, block.ctor.params, block.ctor.type]
~ OmegaCompletePartialOrder.Chain defn→iprj b6326c126d8f… → 04e157a9b4cd… [kind]
~ RecursiveIn iprj→defn 13b38a4e0f38… → 3fe097b1e646… [kind]
~ RecursiveIn.oracle cprj→defn 2796cba80450… → 3d3d403cff32… [kind]
...
(139012 rippled rows hidden — address changes fully explained by dependency re-addressing; --verbose lists)
consts: 140144 only in compilemathlib-4_29.ixe, 141931 only in compilemathlib-8850ed93.ixe (mutual blocks/projections; --verbose lists)
blobs: 678 only in compilemathlib-4_29.ixe, 1504 only in compilemathlib-8850ed93.ixe
hints changed: 1
38bfaaa99e15… abbrev → regular(2)
[diff] compilemathlib-4_29.ixe ≠ compilemathlib-8850ed93.ixe
  • 3,763 added / 1,522 removed / 143,528 changed, of which 4,516 roots and 139,012 rippled — 96.9% of the changed set is the content-address ripple, explained away by the quotient. Rippled label distribution confirms the shape: 120,941 [type, value] + 15,834 [value] pure re-addressings.
  • The roots are the human-edit surface: all 5 kind changes (CategoryTheory.Classifier/HasClassifieriprj→defn, OmegaCompletePartialOrder.Chaindefn→iprj, RecursiveIn/.oracle destructured), all 245 universe-signature changes ([lvls, type, value]), and 8 genuine constructor edits (e.g. LinearPMap.mk gaining block.ctor.lvls/params/type). The 613 block.ctor.type rows the strict classifier had surfaced turn out to be 8 real edits + 605 induced re-addressings — exactly the noise/signal split the verdict exists for.
  • Verdicts are conservative and checkable: zero rippled rows carry a scalar/coordinate label (machine-checked over all 139k), kind/lvls/cidx changes always root, and the report is deterministic across runs.
  • --meta additionally finds 7,998 names with differing metadata: 867 metadata-only rows (same address — binder/arena edits like …._proof_1 [meta.info]) and 7,131 changed rows with meta labels.
  • Zero [encoding] entries — compilation is deterministic end-to-end.
  • 1 hint change (abbrev → regular(2)) on an otherwise-unchanged constant, correctly absent from the named rows (hints live in §3 and don't re-address constants).

Breaking change

All existing .ixe files are stale — regenerate with ix compile (readers emit a "pre-bundle-format .ixe; recompile it" hint). FFI RawEnv ctor arity is 5→8, RawEnvLazy 3→5; NamedDiff gains a rippled : Bool scalar field.

Verification

  • Rust: full workspace tests green, clippy --all-features clean. ixon at 247 tests; the diff engine alone carries 34 (refs-shift guard, share-vs-inline, univ-table shift, memo-clearing regression, block descent, lazy/full parity, per-phase progress totals, 14 ripple-verdict tests incl. two-hop chains / name splits / renamed deps / induced re-elaboration, §5-sweep parity vs the full reader, the §4 index-shift soundness regression, mmap-side parity).
  • Lean: full lake test green; FFI tests incl. self-diff-empty properties over generated envs in both modes (pins marshaling slot order), a ripple-verdict marshaling test (pins the num_8 slot), file-based-vs-bytes-based diff parity, and six ix pack fixtures (closure bundle, removals-only diff subset, name/hex --assume cuts, error paths).
  • Cross-language: whole-stdlib env byte-exact between writers (505,759,024 bytes); ix check-rs 60,601/60,601 (meta) and 51,681/51,681 (anon); bench-typecheck executes and proves over the new format.

Follow-ups

  • prune_to_closure rescans env.named per fixpoint round — worth a reverse index if bulk pruning against mathlib-scale envs becomes common; the same §5 streaming machinery would also let ix pack skip the full-reader parse.
  • Bundle transport niceties (store sync protocol) remain future work.

Base automatically changed from jcb/compiler to mainJuly 7, 2026 23:12
@johnchandlerburnhamjohnchandlerburnham changed the title Ixon: .ixe bundle format — main/assumptions header, metadata-last sections, blob integrityIxon: .ixe bundle format and diff toolJul 10, 2026
@johnchandlerburnham
johnchandlerburnhamforce-pushed the jcb/ixe-improvements branch 3 times, most recently from 683ed55 to 359aeabCompareJuly 13, 2026 15:55
samuelburnham
samuelburnham previously approved these changes Jul 13, 2026
Comment threadBenchmarks/Compile/lakefile.toml Outdated
@johnchandlerburnham

Copy link
Copy Markdown
MemberAuthor

!benchmark compile

@johnchandlerburnham
johnchandlerburnham enabled auto-merge (squash) July 13, 2026 17:05
@argument-ci-bot

Copy link
Copy Markdown
Contributor

!benchmark — main vs 83867c8

backends: compile · envs: InitStd · set: primary · shard: 0

compile · InitStd — main from: bencher @ c8b5e47

envcompile-time (main)compile-time (PR)Δ%throughput (main)throughput (PR)Δ%peak-ram (main)peak-ram (PR)Δ%env-size (main)env-size (PR)Δ%constants (main)constants (PR)Δ%
InitStd3.936 s4.486 s+14.0% (1.14× slower) ⚠️26.80K23.52K-12.3% (1.14× slower) ⚠️3.64 GiB3.59 GiB-1.3%306.43 MiB309.16 MiB+0.9%105,492105,492+0.0%

1 env · 1 with regressions · 0 with improvements (|Δ| > 3.0% on any metric).

Workflow logs

@samuelburnham

Copy link
Copy Markdown
Member

!benchmark compile BENCH_ENVS=InitStd,Lean,FLT,Mathlib

@argument-ci-bot

Copy link
Copy Markdown
Contributor

!benchmark — main vs 83867c8

backends: compile · envs: InitStd,Lean,FLT,Mathlib · set: primary · shard: 0

compile · FLT — main from: bencher @ c8b5e47

envcompile-time (main)compile-time (PR)Δ%throughput (main)throughput (PR)Δ%peak-ram (main)peak-ram (PR)Δ%env-size (main)env-size (PR)Δ%constants (main)constants (PR)Δ%
FLT42.726 s47.188 s+10.4% (1.10× slower) ⚠️11.95K10.82K-9.5% (1.10× slower) ⚠️12.33 GiB12.50 GiB+1.4%1.70 GiB1.72 GiB+0.8%510,687510,687+0.0%

1 env · 1 with regressions · 0 with improvements (|Δ| > 3.0% on any metric).

compile · InitStd — main from: bencher @ c8b5e47

envcompile-time (main)compile-time (PR)Δ%throughput (main)throughput (PR)Δ%peak-ram (main)peak-ram (PR)Δ%env-size (main)env-size (PR)Δ%constants (main)constants (PR)Δ%
InitStd3.936 s4.486 s+14.0% (1.14× slower) ⚠️26.80K23.52K-12.3% (1.14× slower) ⚠️3.64 GiB3.59 GiB-1.3%306.43 MiB309.16 MiB+0.9%105,492105,492+0.0%

1 env · 1 with regressions · 0 with improvements (|Δ| > 3.0% on any metric).

compile · Lean — main from: bencher @ c8b5e47

envcompile-time (main)compile-time (PR)Δ%throughput (main)throughput (PR)Δ%peak-ram (main)peak-ram (PR)Δ%env-size (main)env-size (PR)Δ%constants (main)constants (PR)Δ%
Lean6.929 s7.290 s+5.2% (1.05× slower) ⚠️27.28K25.93K-5.0% (1.05× slower) ⚠️4.97 GiB4.96 GiB-0.1%458.03 MiB462.76 MiB+1.0%188,999188,999+0.0%

1 env · 1 with regressions · 0 with improvements (|Δ| > 3.0% on any metric).

compile · Mathlib — main from: bencher @ c8b5e47

envcompile-time (main)compile-time (PR)Δ%throughput (main)throughput (PR)Δ%peak-ram (main)peak-ram (PR)Δ%env-size (main)env-size (PR)Δ%constants (main)constants (PR)Δ%
Mathlib48.420 s49.268 s+1.8%15.21K14.95K-1.7%18.34 GiB18.40 GiB+0.3%2.97 GiB2.99 GiB+0.6%736,618736,618+0.0%

1 env · 0 with regressions · 0 with improvements (|Δ| > 3.0% on any metric).

Workflow logs

@samuelburnham

Copy link
Copy Markdown
Member

!benchmark compile BENCH_ENVS=InitStd,Lean,FLT,Mathlib

@argument-ci-bot

Copy link
Copy Markdown
Contributor

!benchmark — main vs 46a7673

backends: compile · envs: InitStd,Lean,FLT,Mathlib · set: primary · shard: 0

compile · FLT — main from: bencher @ 3a0817f

envcompile-time (main)compile-time (PR)Δ%throughput (main)throughput (PR)Δ%peak-ram (main)peak-ram (PR)Δ%env-size (main)env-size (PR)Δ%constants (main)constants (PR)Δ%
FLT44.314 s45.559 s+2.8%11.52K11.21K-2.7%12.57 GiB12.43 GiB-1.1%1.70 GiB1.72 GiB+0.7%510,687510,687+0.0%

1 env · 0 with regressions · 0 with improvements (|Δ| > 3.0% on any metric).

compile · InitStd — main from: bencher @ 3a0817f

envcompile-time (main)compile-time (PR)Δ%throughput (main)throughput (PR)Δ%peak-ram (main)peak-ram (PR)Δ%env-size (main)env-size (PR)Δ%constants (main)constants (PR)Δ%
InitStd3.916 s4.033 s+3.0%26.94K26.16K-2.9%3.68 GiB3.62 GiB-1.6%306.43 MiB309.05 MiB+0.9%105,492105,492+0.0%

1 env · 0 with regressions · 0 with improvements (|Δ| > 3.0% on any metric).

compile · Lean — main from: bencher @ 3a0817f

envcompile-time (main)compile-time (PR)Δ%throughput (main)throughput (PR)Δ%peak-ram (main)peak-ram (PR)Δ%env-size (main)env-size (PR)Δ%constants (main)constants (PR)Δ%
Lean6.558 s6.927 s+5.6% (1.06× slower) ⚠️28.82K27.28K-5.3% (1.06× slower) ⚠️4.91 GiB4.85 GiB-1.3%458.03 MiB462.55 MiB+1.0%188,999188,999+0.0%

1 env · 1 with regressions · 0 with improvements (|Δ| > 3.0% on any metric).

compile · Mathlib — main from: bencher @ 3a0817f

envcompile-time (main)compile-time (PR)Δ%throughput (main)throughput (PR)Δ%peak-ram (main)peak-ram (PR)Δ%env-size (main)env-size (PR)Δ%constants (main)constants (PR)Δ%
Mathlib45.269 s51.564 s+13.9% (1.14× slower) ⚠️16.27K14.29K-12.2% (1.14× slower) ⚠️18.74 GiB18.49 GiB-1.3%2.97 GiB2.99 GiB+0.6%736,618736,618+0.0%

1 env · 1 with regressions · 0 with improvements (|Δ| > 3.0% on any metric).

Workflow logs

johnchandlerburnhamand others added 8 commits July 13, 2026 17:43
…tions, blob integrity
Evolves .ixe in place (Tag4(0xE,0) kept, pre-alpha) so a single env file
can pin one Lean value as a self-contained bundle:
- Header gains main : Option Address (bundle root; writers and all
readers enforce main ∈ consts) and a strictly-ascending assumptions
list (thin-bundle trust boundary; merkle_root_canonical over the
leaves reproduces Claim.assumptions roots).
- Sections reordered hot-first: blobs, consts, anon_hints, names,
named, comms. get_anon/get_anon_mmap stop after §3 (no metadata
parse-and-discard); parse_lazy_index stops after §5; the full
readers reject trailing bytes.
- anon_hints promoted from optional trailer to an always-written §3,
derived from Named Def metadata when the map is empty; read-time
hint harvesting removed (EnvHandle::from_bytes post-pass dropped).
- Blob entries are hash-verified in every reader on both sides (a
swapped blob silently changed a Nat/String literal before); Lean
getEnv also gains the const hash check it was missing.
- New Env::bfs_closure follows all three edge kinds (Constant.refs,
Prj→block, Muts→member/ctor projections — a refs-only walk returns
just the root for projections); prune_to_closure builds bundles
(value closure + named-metadata pruning: name parent chains and
string blobs, DataValue payload blobs, meta_refs edges, aux_gen
originals, per-const hints); validate_closed is the receiver check.
kernel::anon_work::closure_addrs now delegates to bfs_closure.
- Lean topologicalSortNames emits the anonymous name at index 0,
matching Rust: serEnv and rsSerEnv are now byte-identical (verified
byte-exact on a 505 MB whole-stdlib env) and the putIdx getD-0
fallback now resolves to anon instead of colliding with the first
real name.
- FFI mirrors: RawEnv carries main/assumptions/anonHints (ctor arity
5→8), RawEnvLazy carries main/assumptions (3→5); the manual RawEnv
builders in ffi compile.rs populate the new slots via
set_raw_env_bundle_fields.
- Generators/fixtures content-address blobs and exercise the bundle
fields; tampering tests compute offsets programmatically instead of
hardcoding the header layout; docs/Ixon.md spec updated.
lake exe ix diff <old.ixe> <new.ixe> [--anon|--meta] [--verbose] compares
two serialized environments and exits 0/1/2 (GNU diff convention).
Engine (crates/ixon/src/diff.rs, host-gated like prune_to_closure):
- diff_envs(a, b, meta): named join on Name (added/removed/changed/
meta-only), consts/blobs set diffs, comms join, main/assumptions,
hints joined on shared consts. Deterministic ordering.
- exprs_equal: iterative lockstep walk resolving Share/Sort/Ref/Str/
Nat/Prj indices through each side's own Constant tables (same bytes
+ different tables => different; permuted tables with equal
resolution => equal), pointer-pair memoized, cycle-guarded.
- Per-kind field classification (type/value/lvls/safety/rules[i].rhs/
ctors[j].*, ...); kind changes label "kind"; representation-only
address churn labels "encoding". Projections descend one level into
their Muts block: changed member => block.* labels, untouched member
=> "block-siblings".
- Default anon mode compares only anonymous structure (hints included:
they live in anon §3); meta mode also compares ConstantMeta/original
per component.
FFI: rs_diff_envs (ByteArray -> ByteArray -> Bool -> Except String
EnvDiff) parses both inputs with the full reader (Named.original
participates) and marshals EnvStats/NamedDiff/EnvDiff (lean.rs layouts
num_64:4 / num_obj:7 / num_obj:17); names cross pre-rendered and
pre-sorted, addresses raw.
Tests: 15 engine unit tests (refs-shift guard, share-vs-inline,
univ-table shift, memo-clearing regression, block descent, ...) and 12
Lean FFI tests incl. self-diff-empty properties over genRawEnv in both
modes.
diff_envs_with adds a JoinProgress callback (fires every 100k
named-join entries and once at completion); diff_envs delegates with a
no-op. rs_diff_envs reports phases to stderr ([rs_diff_envs] ..., the
rs_compile_env idiom): per-env parse timings with reader label and
live join progress — emitted only when an input is >= 100 MB, so unit
tests, property tests, and small bundles stay silent.
Anon mode (the default) now loads both envs via parse_lazy_index +
Env::from_lazy_index — constant byte-windows, name->addr, verbatim §3
hints, §6 comms — never materializing ConstantMeta. Two full-reader
mathlib envs OOM a 124 GiB machine; the lazy path diffs two 3.2 GB
mathlib envs in ~62 s at 54 GiB peak with identical output (locked by
a lazy/full parity test). --meta keeps the full reader, since
Named.meta/original are the point of that mode.
parse_lazy_index now carries §3 hints and §6 comms verbatim on
LazyIndex (both tiny) and, since it consumes every section, enforces
EOF exactly like Env::get.
Mathlib-scale validation (v4.29.0 tag vs 8850ed93, 5 days of master):
3763 added / 1522 removed / 143528 changed names over 744k joined,
exit 1, deterministic — zero [encoding] entries; 5 genuine kind
changes and 1 hint change surface above the content-address ripple.
`lake exe ix pack <env.ixe> <name>` prunes a serialized env to the
self-contained bundle pinning one named constant and writes it as a
standalone .ixe — the first production consumer of
Env::prune_to_closure (sets main, records reached cut-points in
assumptions, carries display metadata to fixpoint) followed by the
receiver-side Env::validate_closed.
- New FFI rs_pack_env (crates/ffi/src/lean_ixon/pack.rs), mirroring
the rs_env_extract read→resolve→write shape: full reader, displayed
name→address resolution over env.named, --assume entries resolving
as names first then 64-hex addresses.
- New Ix/Cli/PackCmd.lean: --assume/--assume-file (ConstsFile.gather),
--out (default <name>.ixe), --verbose; exit 0/1.
- Tests/FFI/Ixon.lean: six directed IO tests over a temp-file fixture
(closure bundle, removals-only diff subset, name and hex assume
cuts, unknown-root and main-assumed errors). The fixture stores
name-string blobs via addNameComponentsWithBlobs — the compiler
convention prune_to_closure recreates via carry_name.
- docs/Ixon.md: pack workflow in the bundle section.
E2E: Nat.add out of the 324 MB stdlib env → 13 KB closed bundle
(17 consts, 43 blobs) in ~4 s; --assume Nat drops the cut constant
and records 1 assumption.
One edited constant re-addresses its whole reverse-dependency cone, so
a content-addressed diff drowns the human edits in transitive
re-addressings. Every changed row now carries a verdict: after the
named join, pass 2 re-classifies each changed pair under a quotient
where an (old, new) address pair compares equal when some changed name
maps old→new. Rows whose residual labels are all "encoding" /
"block-siblings" are rippled (fully explained by dependency
re-addressing); the rest are roots. A single-level map is complete
because expression comparison only consults immediate-dependency
addresses — composition happens through per-row verdicts.
Engine (ixon::diff): set-valued AddrMap built in the join loop (kind
and encoding rows included; tolerates name splits); ExprCmp/classify
parameterized by Option<&AddrMap> at the three address sites
(Ref/Str+Nat/Prj-expr); classify_prj block short-circuit stays strict
== and always descends (blocks are unnamed — a mapped shortcut would
hide block-internal roots); pass-2 verdict cache keyed on the (old,
new) pair so alias rows share one computation; constants re-parse
rather than stash (LazyConstant no-cache policy keeps peak RSS flat).
JoinProgress gains DiffPhase {NamedJoin, RippleClassify} with a 10k
ripple stride. NamedChange.rippled crosses the FFI as a num_8 scalar
(fresh scalar region; obj slots untouched).
CLI: the counts line reports "(N roots, M rippled)"; the default
listing shows roots only (plus, under --meta, rippled rows carrying
metadata edits — namedMetaOnly only covers same-addr rows); --verbose
lists rippled rows with a " (rippled)" suffix.
Documented semantic edges: induced re-elaboration (a dependency
universe/arity change alters dependents beyond addresses) verdicts
root; an intrinsically edited block member with no named projection
has no root row; the "block" fallback on assumption-cut bundles
verdicts root — all fail-safe over-reports.
Tests: 14 new engine tests (two-hop chain, block-ctor edit descent,
name splits, kind-row mapping, univ-arity root, renamed dep, literal
root, encoding ripple, alias cache, Prj-expr site, cidx root, …);
join-progress test now asserts per-phase; Lean FFI marshaling tests
pin the num_8 slot.
Mathlib validation (v4.29.0 ↔ 8850ed93, 744k joined names): 143,528
changed → 4,516 roots / 139,012 rippled (96.9% explained). All 5 kind
changes, all 245 lvls signature changes, and 8 genuine ctor-type edits
surface as roots; 605 induced block.ctor.type rows correctly ripple;
zero rippled rows carry scalar labels; deterministic across runs.
Ripple pass +16.5s (compute 30.8s vs 14.3s), peak RSS unchanged at
54 GiB.
…diff
--meta at mathlib scale previously OOM'd a 124 GiB machine (the full
reader materializes every ConstantMeta on both sides). It now runs in
8 GiB peak / 67 s on the 744k-name mathlib pair.
Streaming §5 sweep: both modes load via the lazy index; meta mode
additionally merge-joins the two files' §5 named sections with a pair
of streaming cursors (NamedMetaCursor), parsing each side's entry
against its own §4 reverse index, comparing, and dropping — resident
metadata is O(1) instead of everything-at-once. Raw §5 byte windows
are NOT comparable across files (metadata name references are
file-relative §4 indices; identical metadata serializes to different
bytes over different name tables), so the sweep compares parsed,
Address-valued ConstantMeta — pinned by a §4 index-shift soundness
regression test and full-reader parity tests. §5 arrives in ascending
name-hash order in both files (exactly Name's Ord), which makes the
lockstep merge-join sound. LazyIndex now records the §5 offset and
retains the §4 reverse index (~32 B/name); parse_lazy_index is
otherwise unchanged.
Engine API: diff_env_bytes (bytes-level, both modes lazy) and
diff_envs_lazy over LazySide {env, index, data}; diff_envs/
diff_envs_with keep the in-env metadata path for materialized envs.
DiffPhase gains MetaSweep (progress: "meta sweep: N/M (K differing)").
Mmap path FFI: rs_diff_env_files mmaps both files
(Env::from_lazy_index_mmap keeps constant windows as zero-copy mmap
slices via store_const_lazy_mmap) and rs_ixe_files_equal does the
byte-equal fast path (metadata length check, then mmap memcmp). The
CLI no longer reads files into Lean ByteArrays at all. The
ByteArray-based rs_diff_envs stays for property tests, now also on
the lazy path in both modes.
Mathlib validation (v4.29.0 ↔ 8850ed93): --meta completes at 8 GiB
peak / 67 s wall (sweep finds 7,998 differing names → 867
metadata-only rows + 7,131 changed rows with meta labels; structural
counts identical to anon: 143,528 changed, 4,516 roots). Anon through
the path FFI: 8 GiB peak (was 54 GiB heap-resident), report
byte-identical to the ByteArray-path baseline. Identical-file fast
path on a 3 GB input: 1.5 s.
docs/Ixon.md gains a "Diffing environments" section (semantics,
root/rippled, memory model).
Pack no longer uses the full reader. The source env is memory-mapped
and lazily loaded (constant windows stay zero-copy mmap slices), and
display metadata is carried by re-streaming §5 with a NamedMetaCursor
once per prune fixpoint round, materializing Named entries only for
carried constants — resident metadata is O(survivors) instead of
O(env). Packing Euclid out of the 3.2 GB mathlib env: 22 s / 5.3 GiB
peak (was ~60 s at full-reader tens-of-GiB), output byte-identical to
the full-reader bundle.
Engine: prune_to_closure refactored into prune_init +
prune_value_pass + carry_named_entry (the named-pass body,
parameterized over name/blob resolvers) — prune_to_closure_streaming
shares it verbatim, so the in-memory and streaming paths cannot
drift; a byte-identity test (with an aux-original fixture forcing a
second fixpoint round, a §4-resolved binder name, and a meta_refs
blob) locks it. parse_lazy_index_with_names returns the §4
Address→Name lookup the walk already builds (the plain variant keeps
dropping it).
--anon packs only anonymous structure via prune_to_closure_anon:
value closure + §3 hints, empty §4/§5 — the minimal artifact a
receiver needs to typecheck/evaluate the pinned value
(validate_closed checks the value pin only). Anon Euclid: 2.5 MB vs
13 MB (3,138 consts, 89 value blobs vs 9,504 with name strings),
13.6 s.
FFI rs_pack_env: mmap + lazy load, gains the anon flag (arity 5→6);
mmap_file shared with the diff module. CLI: ix pack --anon. Tests:
streaming/full byte parity (+ assumed-cut variant), anon value-only
contents + roundtrip, Lean FFI anon-bundle fixture; 249 ixon tests,
workspace + ffi/ixon suites green.
Remove the hints field from ConstantMeta::Def (Rust, Lean, the FFI
constructor slots, and the .ixe named-section encoding). Reducibility
hints now live only in the env-level anon_hints map, keyed by constant
address.
- The compiler is the sole producer: compile_definition records each
definition's hints per name (CompileState::def_hints), and
finalize_hints resolves them through the registered Named entries
into env.anon_hints via Env::register_hint once addresses are final.
The Lean compiler mirror (BlockState.defHints + Ixon.mergeHints)
does the same. Alias collisions merge order-independently (min by
(tag, height)), so parallel workers and both mirrors agree
byte-for-byte.
- Writers serialize the map directly; the derive-from-Named fallback
is deleted from Env::put / put_file / serialized_size_breakdown and
both Lean writers. This removes the serial full-decode of every
demoted Named entry that regressed ix compile by 5-14%: the
derivation loop called Named::meta() per entry, which re-decodes
demoted metadata (the IX_COMPILE_DEMOTE default) on every call.
- Kernel ingress reads hints from anon_hints in both anon and meta
modes; the hints_override parameter threading is gone. Decompile
(both languages) and the IxVM claim harness look hints up by
address. The lazy check path transports the hints section on
RawEnvLazy instead of fabricating per-name Def metas
(RawNamedLite.toConstMeta is removed).
- anon_hints becomes an IxonMap like the other env maps; Env now
derives Clone (the riscv64 IxonMap wrapper gained Clone, replacing
the manual per-map Clone impl).
Format change (pre-alpha, no compat shims): Def metadata loses its
hints byte in the named sections, so every hint is stored once instead
of two or three times (hints section + named meta + aux originals) —
the whole-stdlib env shrinks by ~232 KB. InitStd compile-time returns
to its pre-hints-section cost (median 7.3s -> 5.7s locally vs the
derivation code), with byte-exact Lean/Rust writer parity at
505,674,085 bytes on the whole-stdlib env.
Behavior note: a hint-only change now reports as a pure hintsChanged
row in ix diff; it previously also surfaced as a Def metadata diff.
@samuelburnham

Copy link
Copy Markdown
Member

!benchmark compile BENCH_ENVS=InitStd,Mathlib

@argument-ci-bot

Copy link
Copy Markdown
Contributor

!benchmark — main vs 250277d

backends: compile · envs: InitStd,Mathlib · set: primary · shard: 0

compile · InitStd — main from: bencher @ 3a0817f

envcompile-time (main)compile-time (PR)Δ%throughput (main)throughput (PR)Δ%peak-ram (main)peak-ram (PR)Δ%env-size (main)env-size (PR)Δ%constants (main)constants (PR)Δ%
InitStd3.916 s3.694 s-5.7% (1.06× faster) 🟢26.94K28.56K+6.0% (1.06× faster) 🟢3.68 GiB3.64 GiB-0.9%306.43 MiB309.05 MiB+0.9%105,492105,492+0.0%

1 env · 0 with regressions · 1 with improvements (|Δ| > 3.0% on any metric).

compile · Mathlib — main from: bencher @ 3a0817f

envcompile-time (main)compile-time (PR)Δ%throughput (main)throughput (PR)Δ%peak-ram (main)peak-ram (PR)Δ%env-size (main)env-size (PR)Δ%constants (main)constants (PR)Δ%
Mathlib45.269 s46.926 s+3.7% ⚠️16.27K15.70K-3.5% ⚠️18.74 GiB18.59 GiB-0.8%2.97 GiB2.99 GiB+0.6%736,618736,618+0.0%

1 env · 1 with regressions · 0 with improvements (|Δ| > 3.0% on any metric).

Workflow logs

@johnchandlerburnham
johnchandlerburnham merged commit 33c9cbb into mainJul 14, 2026
17 of 18 checks passed
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants

@johnchandlerburnham@samuelburnham@arthurpaulino
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Universal Dark Mode - works on any site\n(function() {\n var enabled = true;\n \n function applyDarkMode() {\n if (!enabled) return;\n \n // Create style element if it doesn't exist\n var style = document.getElementById('universal-dark-mode-style');\n if (!style) {\n style = document.createElement('style');\n style.id = 'universal-dark-mode-style';\n document.head.appendChild(style);\n }\n \n // Dark mode CSS - inverts colors but preserves images/video\n style.textContent = '\n /* Invert everything except media */\n html {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #1a1a2e !important;\n }\n \n /* Restore images, videos, iframes, canvas */\n img, video, iframe, canvas, svg, picture, [style*=\"background-image\"] {\n filter: invert(1) hue-rotate(180deg) !important;\n }\n \n /* Preserve specific elements that should not be inverted */\n .no-dark-mode, .no-dark-mode *,\n [data-theme=\"light\"], [data-theme=\"light\"],\n .ace_editor, .ace_editor *,\n .CodeMirror, .CodeMirror *,\n .monaco-editor, .monaco-editor *,\n .markdown-body pre, .markdown-body pre *,\n .highlight, .highlight *,\n pre code, pre code * {\n filter: none !important;\n }\n \n /* Fix common UI elements */\n .modal, .popup, .dropdown-menu, .tooltip, .popover {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #2d2d44 !important;\n border-color: #444 !important;\n }\n \n /* Scrollbars */\n ::-webkit-scrollbar { background: #1a1a2e !important; }\n ::-webkit-scrollbar-thumb { background: #444 !important; }\n ::-webkit-scrollbar-thumb:hover { background: #555 !important; }\n \n /* Selection */\n ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ';\n }\n \n function removeDarkMode() {\n var style = document.getElementById('universal-dark-mode-style');\n if (style) style.remove();\n }\n \n // Toggle with Alt+Shift+D\n document.addEventListener('keydown', function(e) {\n if (e.altKey && e.shiftKey && e.key === 'D') {\n e.preventDefault();\n enabled = !enabled;\n if (enabled) {\n applyDarkMode();\n console.log('[Universal Dark Mode] Enabled');\n } else {\n removeDarkMode();\n console.log('[Universal Dark Mode] Disabled');\n }\n }\n });\n \n // Apply on load\n applyDarkMode();\n \n // Re-apply on dynamic content\n var observer = new MutationObserver(function(mutations) {\n if (enabled && !document.getElementById('universal-dark-mode-style')) {\n applyDarkMode();\n }\n });\n observer.observe(document.head, { childList: true });\n \n console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle');\n})();", "Universal Dark Mode"); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })();
Skip to content

Ixon: .ixe bundle format and diff tool - #474

Merged
johnchandlerburnham merged 8 commits into
mainfrom
jcb/ixe-improvements
Jul 14, 2026
Merged

Ixon: .ixe bundle format and diff tool#474
johnchandlerburnham merged 8 commits into
mainfrom
jcb/ixe-improvements

Conversation

@johnchandlerburnham

@johnchandlerburnhamjohnchandlerburnham commented Jul 5, 2026

Copy link
Copy Markdown
Member

Two related changes

  1. the .ixebundle format (a single env file that pins one Lean value as a self-contained, verifiable unit — the groundwork for Ixon as a client/server interchange format), plus its first producer CLI, ix pack
  2. ix diff — a structured, content-address-aware diff of two .ixe environments, with ripple root-causing (separating intrinsic edits from the content-address ripple) and a memory-lean --meta, validated at mathlib scale.

Part 1 — .ixe bundle format

Evolves the format in place (pre-alpha, Tag4(0xE,0) kept, no compat shims). A constant's address already pins its entire dependency DAG (refs tables are content addresses, recursively); this adds the transport half: a distinguished root, an explicit trust boundary for thin bundles, and closure helpers to produce/validate them.

[Tag4(0xE,0)] [consts merkle root : 32]
[main : u8 flag + 32? ] -- NEW: bundle root; readers enforce main ∈ consts
[assumptions : Tag0 count + 32*] -- NEW: strictly ascending; thin-bundle cut points
§1 blobs -- NEW: blake3(bytes) == addr verified per entry, every reader
§2 consts -- unchanged (already verified)
§3 anon_hints -- moved before metadata; ALWAYS written (derived from Named Def
-- metadata when the map is empty)
§4 names / §5 named / §6 comms -- metadata last
  • Metadata-last reorder: get_anon/get_anon_mmap read §1–§3 and stop — no more parse-and-discard of names/named/comms; the full readers reject trailing bytes.
  • §3 is the canonical hint channel: read-time hint harvesting is gone. merkle_root_canonical(assumptions) reproduces Claim.assumptions roots, so claim interop is derivable, not stored.
  • Integrity: blobs were previously unverified in every reader — a swapped blob silently changed a Nat/String literal under an otherwise-valid file. Lean getEnv also gains the per-const hash check it was missing.
  • Closure helpers: Env::bfs_closure (3-edge traversal: refs, projection → Muts block, block → member/ctor projection addresses), Env::prune_to_closure(main, assumed) (minimal closed bundle incl. display metadata), Env::validate_closed() (receiver check). kernel::anon_work::closure_addrs now delegates to bfs_closure.
  • Byte-identical mirrors (bonus fix): the Lean and Rust writers were never byte-identical (anonymous-name table entry 0; latent putIdxgetD 0 collision). Now locked by strict serEnv == rsSerEnv tests — byte-exact at 505,759,024 bytes on the whole-stdlib env.

ix pack — producing bundles

lake exe ix pack <env.ixe> <name> [--out <path>] [--assume <name|hex64,...>] [--assume-file <f>] [--verbose]

The first production consumer of prune_to_closure: resolves <name> (displayed form) against the env's named table, prunes to the self-contained closure — main set, reached cut points recorded in assumptions, display metadata carried to fixpoint — re-validates with validate_closed, and writes the bundle (default <name>.ixe). --assume entries (names or 64-hex constant addresses) declare thin-bundle trust boundaries. Euclid's theorem out of the 3.2 GB mathlib env:

❯ lake exe ix pack compilemathlib-8850ed93.ixe Nat.exists_infinite_primes --verbose
[rs_pack_env] main 091d37b69086bf163ecfb6c12233e1e741655b8ab09f76979f0bbbdc0c3176b5 (Nat.exists_infinite_primes)
[rs_pack_env] kept 3138/648914 consts, 10181/746073 named, 9504/329107 blobs, 0 assumption(s)
[rs_pack_env] wrote Nat.exists_infinite_primes.ixe (13006199 bytes)

3,138 constants (0.5% of mathlib), 13 MB, closed by construction — main's 32 bytes alone pin the value. (ix shard extract remains the non-bundle sibling: a general sub-env for the kernel-check pipeline, no main root.) Pack v1 reads with the full reader so metadata survives the prune; the 3.2 GB parse dominates its runtime — a leaner selective-metadata pack is future work on the §5 streaming machinery from Part 2.

Part 2 — ix diff

lake exe ix diff <old.ixe> <new.ixe> [--anon | --meta] [--verbose]

Content-addressing frames the semantics: a name "changed" ⇔ its constant address changed, and value-equality coincides with address-equality — so the interesting work is saying what changed, honestly.

[diff] old.ixe: 647127 consts, 743832 named, 328281 blobs, 0 comms
[diff] new.ixe: 648914 consts, 746073 named, 329107 blobs, 0 comms
named: 3763 added, 1522 removed, 143528 changed (4516 roots, 139012 rippled)
+ AddEquiv.withBotCongr 33ccd4f845d0…
~ AbsoluteValue.exists_partition_int defn a382796a804c… → f1cee63a8923… [type, value]
~ RecursiveIn iprj→defn 13b38a4e0f38… → 3fe097b1e646… [kind]
(139012 rippled rows hidden — address changes fully explained by dependency re-addressing; --verbose lists)
consts: 140144 only in old, 141931 only in new (mutual blocks/projections)
hints changed: 1

Exit codes follow GNU diff: 0 = no difference (in the selected mode), 1 = differences, 2 = error. Byte-equal files short-circuit to identical without parsing (mmap memcmp; 1.5 s on a 3 GB input).

Modes

  • --anon (default): compares only anonymous structure — name→addr changes with per-field classification, consts/blobs set differences, comms, main/assumptions, and reducibility hints (they live in anon §3 and drive kernel unfolding). Names are join/display keys only.
  • --meta: additionally compares Named metadata per component (meta.info with kind transitions, meta.sharing/refs/univs, original.added/removed/addr/meta) — a same-addr metadata-only change is its own report category.

Field-level classification (Rust engine, ixon::diff)

  • exprs_equal is an iterative lockstep walk that resolves Share/Sort/Ref/ Str/Nat/Prj indices through each side's own constant tables: identical expr bytes over different refs tables compare different; permuted tables with equal resolution compare equal. Pointer-pair memoized (shared subterms don't re-walk), Share-cycle-guarded, no recursion (kernel terms nest thousands deep).
  • Per-kind field labels (type, value, lvls, safety, rules[i].rhs, ctors[j].type, …). Kind changes report kind; an address change with no detected semantic difference reports encoding (representation churn) — never an empty list.
  • Projection block descent: inductives/ctors/recursors live only inside Muts blocks behind projections, so "block changed" alone would gut the tool. Same-kind projections with a changed block descend one level: changed member → block.* labels; untouched member → block-siblings (the block hash moved because a sibling changed — the common, most informative case).

Ripple root-causing (root vs rippled)

One edited constant re-addresses its whole reverse-dependency cone, so a content-addressed diff drowns the human edits in transitive re-addressings. Every changed row now carries a verdict: after the join, a second pass re-classifies each changed pair under a quotient where an (old, new) address pair compares equal when some changed name maps old→new. Rows whose residual labels are all encoding/block-siblings are rippled — fully explained by their dependencies' re-addressing; the rest are roots (intrinsic edits). A single-level map is complete because expression comparison only ever consults immediate-dependency addresses — composition across the DAG happens through the per-row verdicts, never M∘M.

  • fields keeps the strict classification; rippled : Bool is the orthogonal verdict. The default listing shows roots (plus, under --meta, rippled rows carrying metadata edits); --verbose lists everything.
  • The map is set-valued (name splits tolerated) and built from every changed row, kind changes included — so a iprj→defn refactor's dependents still ripple.
  • Block descent runs under the quotient too: an intrinsically edited ctor's projection is the root (block.ctor.type), its untouched siblings ripple, and blocks themselves (unnamed) never enter the map — descent is mandatory or block-internal roots would vanish.
  • Documented accepted edges, all fail-safe over-reports: induced re-elaboration (a dependency's universe/arity signature change alters dependents' terms beyond addresses) verdicts root; an intrinsically edited block member with no named projection has no root row of its own; the block fallback on assumption-cut bundles verdicts root.
  • Alias rows share one cached verdict per (old, new) pair; constants re-parse rather than stash (the deliberate LazyConstant no-cache policy keeps memory flat). The pass costs ~one extra classify sweep (+16 s at mathlib scale).

Memory: --meta without the OOM, and an mmap fast path

The first --meta design read both envs with the full reader — at mathlib scale that materializes every ConstantMeta twice and OOM'd a 124 GiB machine. Both modes now load via the lazy index, and meta mode compares metadata by streaming both files' §5 named sections in a lockstep merge-join (§5 is written in ascending name-hash order — exactly Name's Ord — in every file): parse one entry per side against its own §4 reverse index, compare, drop. Resident metadata is O(1) instead of everything-at-once.

Raw §5 byte windows are not comparable across files — metadata name references are file-relative §4 indices, so identical metadata serializes to different bytes over different name tables. The sweep therefore compares parsed, Address-valued ConstantMeta (index-independent), pinned by a §4 index-shift soundness regression test and full-reader parity tests.

The CLI also stopped reading files into Lean ByteArrays entirely: rs_diff_env_files mmaps both inputs (constant windows stay zero-copy mmap slices backed by the OS page cache) and rs_ixe_files_equal does the byte-equal fast path. Anon mode dropped from 54 GiB heap-resident to 8 GiB peak with a byte-identical report.

Progress reporting

Large inputs (≥ 100 MB) stream progress to stderr (stdout stays clean for piping): per-env parse timings, then live per-phase events — meta sweep (meta mode), named join, ripple pass:

❯ lake exe ix diff compilemathlib-4_29.ixe compilemathlib-8850ed93.ixe
[rs_diff_envs] parsing first env (3211 MB, lazy reader)...
[rs_diff_envs] first env parsed in 12.9s (647127 consts, 743832 named, 328281 blobs)
[rs_diff_envs] parsing second env (3222 MB, lazy reader)...
[rs_diff_envs] second env parsed in 12.1s (648914 consts, 746073 named, 329107 blobs)
[rs_diff_envs] named join: 100000/743832 (19300 changed so far)
...
[rs_diff_envs] named join: 743832/743832 (143528 changed so far)
[rs_diff_envs] ripple pass: 10000/143528 (317 roots so far)
...
[rs_diff_envs] ripple pass: 143528/143528 (4516 roots so far)
[rs_diff_env_files] diff computed in 44.1s

Mathlib-scale validation

Diffed the v4.29.0 tag env against 8850ed93 (the last mathlib master commit on the v4.29.0 toolchain — 5 days of master; the Benchmarks/Compile pin is bumped accordingly): two 3.2 GB envs, 744k named join. **Anon: 65 s wall, 8 GiB peak. --meta: 67 s, 8 GiB

[diff] compilemathlib-4_29.ixe: 647127 consts, 743832 named, 328281 blobs, 0 comms
[diff] compilemathlib-8850ed93.ixe: 648914 consts, 746073 named, 329107 blobs, 0 comms
named: 3763 added, 1522 removed, 143528 changed (4516 roots, 139012 rippled)
(synthetic mutual-block names: 627 added, 614 removed — --verbose lists)
+ AddAction.IsPretransitive.of_orbit 2ee60e47dad5…
+ AddEquiv.coe_withBotCongr 2c55b9612a26…
+ AddEquiv.withBotCongr 33ccd4f845d0…
...
- AddCommGroup.ofIsAddCommutative 5ec793909daf…
- AddCommMonoid.ofIsAddCommutative c4954ee67492…
...
~ AbsoluteValue.exists_partition_int defn a382796a804c… → f1cee63a8923… [type, value]
~ AddAction.isClosedMap_quotient defn dbba20e21c96… → 4deaf8b7fdc7… [value]
...
~ CategoryTheory.Classifier iprj→defn c632275bad82… → 28096c100344… [kind]
~ CategoryTheory.HasClassifier iprj→defn 2ba3ede041e1… → 46a6379b87bc… [kind]
~ LinearPMap.mk cprj 1df2b7a39e8a… → ae57a82f6164… [block.ctor.lvls, block.ctor.params, block.ctor.type]
~ OmegaCompletePartialOrder.Chain defn→iprj b6326c126d8f… → 04e157a9b4cd… [kind]
~ RecursiveIn iprj→defn 13b38a4e0f38… → 3fe097b1e646… [kind]
~ RecursiveIn.oracle cprj→defn 2796cba80450… → 3d3d403cff32… [kind]
...
(139012 rippled rows hidden — address changes fully explained by dependency re-addressing; --verbose lists)
consts: 140144 only in compilemathlib-4_29.ixe, 141931 only in compilemathlib-8850ed93.ixe (mutual blocks/projections; --verbose lists)
blobs: 678 only in compilemathlib-4_29.ixe, 1504 only in compilemathlib-8850ed93.ixe
hints changed: 1
38bfaaa99e15… abbrev → regular(2)
[diff] compilemathlib-4_29.ixe ≠ compilemathlib-8850ed93.ixe
  • 3,763 added / 1,522 removed / 143,528 changed, of which 4,516 roots and 139,012 rippled — 96.9% of the changed set is the content-address ripple, explained away by the quotient. Rippled label distribution confirms the shape: 120,941 [type, value] + 15,834 [value] pure re-addressings.
  • The roots are the human-edit surface: all 5 kind changes (CategoryTheory.Classifier/HasClassifieriprj→defn, OmegaCompletePartialOrder.Chaindefn→iprj, RecursiveIn/.oracle destructured), all 245 universe-signature changes ([lvls, type, value]), and 8 genuine constructor edits (e.g. LinearPMap.mk gaining block.ctor.lvls/params/type). The 613 block.ctor.type rows the strict classifier had surfaced turn out to be 8 real edits + 605 induced re-addressings — exactly the noise/signal split the verdict exists for.
  • Verdicts are conservative and checkable: zero rippled rows carry a scalar/coordinate label (machine-checked over all 139k), kind/lvls/cidx changes always root, and the report is deterministic across runs.
  • --meta additionally finds 7,998 names with differing metadata: 867 metadata-only rows (same address — binder/arena edits like …._proof_1 [meta.info]) and 7,131 changed rows with meta labels.
  • Zero [encoding] entries — compilation is deterministic end-to-end.
  • 1 hint change (abbrev → regular(2)) on an otherwise-unchanged constant, correctly absent from the named rows (hints live in §3 and don't re-address constants).

Breaking change

All existing .ixe files are stale — regenerate with ix compile (readers emit a "pre-bundle-format .ixe; recompile it" hint). FFI RawEnv ctor arity is 5→8, RawEnvLazy 3→5; NamedDiff gains a rippled : Bool scalar field.

Verification

  • Rust: full workspace tests green, clippy --all-features clean. ixon at 247 tests; the diff engine alone carries 34 (refs-shift guard, share-vs-inline, univ-table shift, memo-clearing regression, block descent, lazy/full parity, per-phase progress totals, 14 ripple-verdict tests incl. two-hop chains / name splits / renamed deps / induced re-elaboration, §5-sweep parity vs the full reader, the §4 index-shift soundness regression, mmap-side parity).
  • Lean: full lake test green; FFI tests incl. self-diff-empty properties over generated envs in both modes (pins marshaling slot order), a ripple-verdict marshaling test (pins the num_8 slot), file-based-vs-bytes-based diff parity, and six ix pack fixtures (closure bundle, removals-only diff subset, name/hex --assume cuts, error paths).
  • Cross-language: whole-stdlib env byte-exact between writers (505,759,024 bytes); ix check-rs 60,601/60,601 (meta) and 51,681/51,681 (anon); bench-typecheck executes and proves over the new format.

Follow-ups

  • prune_to_closure rescans env.named per fixpoint round — worth a reverse index if bulk pruning against mathlib-scale envs becomes common; the same §5 streaming machinery would also let ix pack skip the full-reader parse.
  • Bundle transport niceties (store sync protocol) remain future work.

Base automatically changed from jcb/compiler to mainJuly 7, 2026 23:12
@johnchandlerburnhamjohnchandlerburnham changed the title Ixon: .ixe bundle format — main/assumptions header, metadata-last sections, blob integrityIxon: .ixe bundle format and diff toolJul 10, 2026
@johnchandlerburnham
johnchandlerburnhamforce-pushed the jcb/ixe-improvements branch 3 times, most recently from 683ed55 to 359aeabCompareJuly 13, 2026 15:55
samuelburnham
samuelburnham previously approved these changes Jul 13, 2026
Comment threadBenchmarks/Compile/lakefile.toml Outdated
@johnchandlerburnham

Copy link
Copy Markdown
MemberAuthor

!benchmark compile

@johnchandlerburnham
johnchandlerburnham enabled auto-merge (squash) July 13, 2026 17:05
@argument-ci-bot

Copy link
Copy Markdown
Contributor

!benchmark — main vs 83867c8

backends: compile · envs: InitStd · set: primary · shard: 0

compile · InitStd — main from: bencher @ c8b5e47

envcompile-time (main)compile-time (PR)Δ%throughput (main)throughput (PR)Δ%peak-ram (main)peak-ram (PR)Δ%env-size (main)env-size (PR)Δ%constants (main)constants (PR)Δ%
InitStd3.936 s4.486 s+14.0% (1.14× slower) ⚠️26.80K23.52K-12.3% (1.14× slower) ⚠️3.64 GiB3.59 GiB-1.3%306.43 MiB309.16 MiB+0.9%105,492105,492+0.0%

1 env · 1 with regressions · 0 with improvements (|Δ| > 3.0% on any metric).

Workflow logs

@samuelburnham

Copy link
Copy Markdown
Member

!benchmark compile BENCH_ENVS=InitStd,Lean,FLT,Mathlib

@argument-ci-bot

Copy link
Copy Markdown
Contributor

!benchmark — main vs 83867c8

backends: compile · envs: InitStd,Lean,FLT,Mathlib · set: primary · shard: 0

compile · FLT — main from: bencher @ c8b5e47

envcompile-time (main)compile-time (PR)Δ%throughput (main)throughput (PR)Δ%peak-ram (main)peak-ram (PR)Δ%env-size (main)env-size (PR)Δ%constants (main)constants (PR)Δ%
FLT42.726 s47.188 s+10.4% (1.10× slower) ⚠️11.95K10.82K-9.5% (1.10× slower) ⚠️12.33 GiB12.50 GiB+1.4%1.70 GiB1.72 GiB+0.8%510,687510,687+0.0%

1 env · 1 with regressions · 0 with improvements (|Δ| > 3.0% on any metric).

compile · InitStd — main from: bencher @ c8b5e47

envcompile-time (main)compile-time (PR)Δ%throughput (main)throughput (PR)Δ%peak-ram (main)peak-ram (PR)Δ%env-size (main)env-size (PR)Δ%constants (main)constants (PR)Δ%
InitStd3.936 s4.486 s+14.0% (1.14× slower) ⚠️26.80K23.52K-12.3% (1.14× slower) ⚠️3.64 GiB3.59 GiB-1.3%306.43 MiB309.16 MiB+0.9%105,492105,492+0.0%

1 env · 1 with regressions · 0 with improvements (|Δ| > 3.0% on any metric).

compile · Lean — main from: bencher @ c8b5e47

envcompile-time (main)compile-time (PR)Δ%throughput (main)throughput (PR)Δ%peak-ram (main)peak-ram (PR)Δ%env-size (main)env-size (PR)Δ%constants (main)constants (PR)Δ%
Lean6.929 s7.290 s+5.2% (1.05× slower) ⚠️27.28K25.93K-5.0% (1.05× slower) ⚠️4.97 GiB4.96 GiB-0.1%458.03 MiB462.76 MiB+1.0%188,999188,999+0.0%

1 env · 1 with regressions · 0 with improvements (|Δ| > 3.0% on any metric).

compile · Mathlib — main from: bencher @ c8b5e47

envcompile-time (main)compile-time (PR)Δ%throughput (main)throughput (PR)Δ%peak-ram (main)peak-ram (PR)Δ%env-size (main)env-size (PR)Δ%constants (main)constants (PR)Δ%
Mathlib48.420 s49.268 s+1.8%15.21K14.95K-1.7%18.34 GiB18.40 GiB+0.3%2.97 GiB2.99 GiB+0.6%736,618736,618+0.0%

1 env · 0 with regressions · 0 with improvements (|Δ| > 3.0% on any metric).

Workflow logs

@samuelburnham

Copy link
Copy Markdown
Member

!benchmark compile BENCH_ENVS=InitStd,Lean,FLT,Mathlib

@argument-ci-bot

Copy link
Copy Markdown
Contributor

!benchmark — main vs 46a7673

backends: compile · envs: InitStd,Lean,FLT,Mathlib · set: primary · shard: 0

compile · FLT — main from: bencher @ 3a0817f

envcompile-time (main)compile-time (PR)Δ%throughput (main)throughput (PR)Δ%peak-ram (main)peak-ram (PR)Δ%env-size (main)env-size (PR)Δ%constants (main)constants (PR)Δ%
FLT44.314 s45.559 s+2.8%11.52K11.21K-2.7%12.57 GiB12.43 GiB-1.1%1.70 GiB1.72 GiB+0.7%510,687510,687+0.0%

1 env · 0 with regressions · 0 with improvements (|Δ| > 3.0% on any metric).

compile · InitStd — main from: bencher @ 3a0817f

envcompile-time (main)compile-time (PR)Δ%throughput (main)throughput (PR)Δ%peak-ram (main)peak-ram (PR)Δ%env-size (main)env-size (PR)Δ%constants (main)constants (PR)Δ%
InitStd3.916 s4.033 s+3.0%26.94K26.16K-2.9%3.68 GiB3.62 GiB-1.6%306.43 MiB309.05 MiB+0.9%105,492105,492+0.0%

1 env · 0 with regressions · 0 with improvements (|Δ| > 3.0% on any metric).

compile · Lean — main from: bencher @ 3a0817f

envcompile-time (main)compile-time (PR)Δ%throughput (main)throughput (PR)Δ%peak-ram (main)peak-ram (PR)Δ%env-size (main)env-size (PR)Δ%constants (main)constants (PR)Δ%
Lean6.558 s6.927 s+5.6% (1.06× slower) ⚠️28.82K27.28K-5.3% (1.06× slower) ⚠️4.91 GiB4.85 GiB-1.3%458.03 MiB462.55 MiB+1.0%188,999188,999+0.0%

1 env · 1 with regressions · 0 with improvements (|Δ| > 3.0% on any metric).

compile · Mathlib — main from: bencher @ 3a0817f

envcompile-time (main)compile-time (PR)Δ%throughput (main)throughput (PR)Δ%peak-ram (main)peak-ram (PR)Δ%env-size (main)env-size (PR)Δ%constants (main)constants (PR)Δ%
Mathlib45.269 s51.564 s+13.9% (1.14× slower) ⚠️16.27K14.29K-12.2% (1.14× slower) ⚠️18.74 GiB18.49 GiB-1.3%2.97 GiB2.99 GiB+0.6%736,618736,618+0.0%

1 env · 1 with regressions · 0 with improvements (|Δ| > 3.0% on any metric).

Workflow logs

johnchandlerburnhamand others added 8 commits July 13, 2026 17:43
…tions, blob integrity
Evolves .ixe in place (Tag4(0xE,0) kept, pre-alpha) so a single env file
can pin one Lean value as a self-contained bundle:
- Header gains main : Option Address (bundle root; writers and all
readers enforce main ∈ consts) and a strictly-ascending assumptions
list (thin-bundle trust boundary; merkle_root_canonical over the
leaves reproduces Claim.assumptions roots).
- Sections reordered hot-first: blobs, consts, anon_hints, names,
named, comms. get_anon/get_anon_mmap stop after §3 (no metadata
parse-and-discard); parse_lazy_index stops after §5; the full
readers reject trailing bytes.
- anon_hints promoted from optional trailer to an always-written §3,
derived from Named Def metadata when the map is empty; read-time
hint harvesting removed (EnvHandle::from_bytes post-pass dropped).
- Blob entries are hash-verified in every reader on both sides (a
swapped blob silently changed a Nat/String literal before); Lean
getEnv also gains the const hash check it was missing.
- New Env::bfs_closure follows all three edge kinds (Constant.refs,
Prj→block, Muts→member/ctor projections — a refs-only walk returns
just the root for projections); prune_to_closure builds bundles
(value closure + named-metadata pruning: name parent chains and
string blobs, DataValue payload blobs, meta_refs edges, aux_gen
originals, per-const hints); validate_closed is the receiver check.
kernel::anon_work::closure_addrs now delegates to bfs_closure.
- Lean topologicalSortNames emits the anonymous name at index 0,
matching Rust: serEnv and rsSerEnv are now byte-identical (verified
byte-exact on a 505 MB whole-stdlib env) and the putIdx getD-0
fallback now resolves to anon instead of colliding with the first
real name.
- FFI mirrors: RawEnv carries main/assumptions/anonHints (ctor arity
5→8), RawEnvLazy carries main/assumptions (3→5); the manual RawEnv
builders in ffi compile.rs populate the new slots via
set_raw_env_bundle_fields.
- Generators/fixtures content-address blobs and exercise the bundle
fields; tampering tests compute offsets programmatically instead of
hardcoding the header layout; docs/Ixon.md spec updated.
lake exe ix diff <old.ixe> <new.ixe> [--anon|--meta] [--verbose] compares
two serialized environments and exits 0/1/2 (GNU diff convention).
Engine (crates/ixon/src/diff.rs, host-gated like prune_to_closure):
- diff_envs(a, b, meta): named join on Name (added/removed/changed/
meta-only), consts/blobs set diffs, comms join, main/assumptions,
hints joined on shared consts. Deterministic ordering.
- exprs_equal: iterative lockstep walk resolving Share/Sort/Ref/Str/
Nat/Prj indices through each side's own Constant tables (same bytes
+ different tables => different; permuted tables with equal
resolution => equal), pointer-pair memoized, cycle-guarded.
- Per-kind field classification (type/value/lvls/safety/rules[i].rhs/
ctors[j].*, ...); kind changes label "kind"; representation-only
address churn labels "encoding". Projections descend one level into
their Muts block: changed member => block.* labels, untouched member
=> "block-siblings".
- Default anon mode compares only anonymous structure (hints included:
they live in anon §3); meta mode also compares ConstantMeta/original
per component.
FFI: rs_diff_envs (ByteArray -> ByteArray -> Bool -> Except String
EnvDiff) parses both inputs with the full reader (Named.original
participates) and marshals EnvStats/NamedDiff/EnvDiff (lean.rs layouts
num_64:4 / num_obj:7 / num_obj:17); names cross pre-rendered and
pre-sorted, addresses raw.
Tests: 15 engine unit tests (refs-shift guard, share-vs-inline,
univ-table shift, memo-clearing regression, block descent, ...) and 12
Lean FFI tests incl. self-diff-empty properties over genRawEnv in both
modes.
diff_envs_with adds a JoinProgress callback (fires every 100k
named-join entries and once at completion); diff_envs delegates with a
no-op. rs_diff_envs reports phases to stderr ([rs_diff_envs] ..., the
rs_compile_env idiom): per-env parse timings with reader label and
live join progress — emitted only when an input is >= 100 MB, so unit
tests, property tests, and small bundles stay silent.
Anon mode (the default) now loads both envs via parse_lazy_index +
Env::from_lazy_index — constant byte-windows, name->addr, verbatim §3
hints, §6 comms — never materializing ConstantMeta. Two full-reader
mathlib envs OOM a 124 GiB machine; the lazy path diffs two 3.2 GB
mathlib envs in ~62 s at 54 GiB peak with identical output (locked by
a lazy/full parity test). --meta keeps the full reader, since
Named.meta/original are the point of that mode.
parse_lazy_index now carries §3 hints and §6 comms verbatim on
LazyIndex (both tiny) and, since it consumes every section, enforces
EOF exactly like Env::get.
Mathlib-scale validation (v4.29.0 tag vs 8850ed93, 5 days of master):
3763 added / 1522 removed / 143528 changed names over 744k joined,
exit 1, deterministic — zero [encoding] entries; 5 genuine kind
changes and 1 hint change surface above the content-address ripple.
`lake exe ix pack <env.ixe> <name>` prunes a serialized env to the
self-contained bundle pinning one named constant and writes it as a
standalone .ixe — the first production consumer of
Env::prune_to_closure (sets main, records reached cut-points in
assumptions, carries display metadata to fixpoint) followed by the
receiver-side Env::validate_closed.
- New FFI rs_pack_env (crates/ffi/src/lean_ixon/pack.rs), mirroring
the rs_env_extract read→resolve→write shape: full reader, displayed
name→address resolution over env.named, --assume entries resolving
as names first then 64-hex addresses.
- New Ix/Cli/PackCmd.lean: --assume/--assume-file (ConstsFile.gather),
--out (default <name>.ixe), --verbose; exit 0/1.
- Tests/FFI/Ixon.lean: six directed IO tests over a temp-file fixture
(closure bundle, removals-only diff subset, name and hex assume
cuts, unknown-root and main-assumed errors). The fixture stores
name-string blobs via addNameComponentsWithBlobs — the compiler
convention prune_to_closure recreates via carry_name.
- docs/Ixon.md: pack workflow in the bundle section.
E2E: Nat.add out of the 324 MB stdlib env → 13 KB closed bundle
(17 consts, 43 blobs) in ~4 s; --assume Nat drops the cut constant
and records 1 assumption.
One edited constant re-addresses its whole reverse-dependency cone, so
a content-addressed diff drowns the human edits in transitive
re-addressings. Every changed row now carries a verdict: after the
named join, pass 2 re-classifies each changed pair under a quotient
where an (old, new) address pair compares equal when some changed name
maps old→new. Rows whose residual labels are all "encoding" /
"block-siblings" are rippled (fully explained by dependency
re-addressing); the rest are roots. A single-level map is complete
because expression comparison only consults immediate-dependency
addresses — composition happens through per-row verdicts.
Engine (ixon::diff): set-valued AddrMap built in the join loop (kind
and encoding rows included; tolerates name splits); ExprCmp/classify
parameterized by Option<&AddrMap> at the three address sites
(Ref/Str+Nat/Prj-expr); classify_prj block short-circuit stays strict
== and always descends (blocks are unnamed — a mapped shortcut would
hide block-internal roots); pass-2 verdict cache keyed on the (old,
new) pair so alias rows share one computation; constants re-parse
rather than stash (LazyConstant no-cache policy keeps peak RSS flat).
JoinProgress gains DiffPhase {NamedJoin, RippleClassify} with a 10k
ripple stride. NamedChange.rippled crosses the FFI as a num_8 scalar
(fresh scalar region; obj slots untouched).
CLI: the counts line reports "(N roots, M rippled)"; the default
listing shows roots only (plus, under --meta, rippled rows carrying
metadata edits — namedMetaOnly only covers same-addr rows); --verbose
lists rippled rows with a " (rippled)" suffix.
Documented semantic edges: induced re-elaboration (a dependency
universe/arity change alters dependents beyond addresses) verdicts
root; an intrinsically edited block member with no named projection
has no root row; the "block" fallback on assumption-cut bundles
verdicts root — all fail-safe over-reports.
Tests: 14 new engine tests (two-hop chain, block-ctor edit descent,
name splits, kind-row mapping, univ-arity root, renamed dep, literal
root, encoding ripple, alias cache, Prj-expr site, cidx root, …);
join-progress test now asserts per-phase; Lean FFI marshaling tests
pin the num_8 slot.
Mathlib validation (v4.29.0 ↔ 8850ed93, 744k joined names): 143,528
changed → 4,516 roots / 139,012 rippled (96.9% explained). All 5 kind
changes, all 245 lvls signature changes, and 8 genuine ctor-type edits
surface as roots; 605 induced block.ctor.type rows correctly ripple;
zero rippled rows carry scalar labels; deterministic across runs.
Ripple pass +16.5s (compute 30.8s vs 14.3s), peak RSS unchanged at
54 GiB.
…diff
--meta at mathlib scale previously OOM'd a 124 GiB machine (the full
reader materializes every ConstantMeta on both sides). It now runs in
8 GiB peak / 67 s on the 744k-name mathlib pair.
Streaming §5 sweep: both modes load via the lazy index; meta mode
additionally merge-joins the two files' §5 named sections with a pair
of streaming cursors (NamedMetaCursor), parsing each side's entry
against its own §4 reverse index, comparing, and dropping — resident
metadata is O(1) instead of everything-at-once. Raw §5 byte windows
are NOT comparable across files (metadata name references are
file-relative §4 indices; identical metadata serializes to different
bytes over different name tables), so the sweep compares parsed,
Address-valued ConstantMeta — pinned by a §4 index-shift soundness
regression test and full-reader parity tests. §5 arrives in ascending
name-hash order in both files (exactly Name's Ord), which makes the
lockstep merge-join sound. LazyIndex now records the §5 offset and
retains the §4 reverse index (~32 B/name); parse_lazy_index is
otherwise unchanged.
Engine API: diff_env_bytes (bytes-level, both modes lazy) and
diff_envs_lazy over LazySide {env, index, data}; diff_envs/
diff_envs_with keep the in-env metadata path for materialized envs.
DiffPhase gains MetaSweep (progress: "meta sweep: N/M (K differing)").
Mmap path FFI: rs_diff_env_files mmaps both files
(Env::from_lazy_index_mmap keeps constant windows as zero-copy mmap
slices via store_const_lazy_mmap) and rs_ixe_files_equal does the
byte-equal fast path (metadata length check, then mmap memcmp). The
CLI no longer reads files into Lean ByteArrays at all. The
ByteArray-based rs_diff_envs stays for property tests, now also on
the lazy path in both modes.
Mathlib validation (v4.29.0 ↔ 8850ed93): --meta completes at 8 GiB
peak / 67 s wall (sweep finds 7,998 differing names → 867
metadata-only rows + 7,131 changed rows with meta labels; structural
counts identical to anon: 143,528 changed, 4,516 roots). Anon through
the path FFI: 8 GiB peak (was 54 GiB heap-resident), report
byte-identical to the ByteArray-path baseline. Identical-file fast
path on a 3 GB input: 1.5 s.
docs/Ixon.md gains a "Diffing environments" section (semantics,
root/rippled, memory model).
Pack no longer uses the full reader. The source env is memory-mapped
and lazily loaded (constant windows stay zero-copy mmap slices), and
display metadata is carried by re-streaming §5 with a NamedMetaCursor
once per prune fixpoint round, materializing Named entries only for
carried constants — resident metadata is O(survivors) instead of
O(env). Packing Euclid out of the 3.2 GB mathlib env: 22 s / 5.3 GiB
peak (was ~60 s at full-reader tens-of-GiB), output byte-identical to
the full-reader bundle.
Engine: prune_to_closure refactored into prune_init +
prune_value_pass + carry_named_entry (the named-pass body,
parameterized over name/blob resolvers) — prune_to_closure_streaming
shares it verbatim, so the in-memory and streaming paths cannot
drift; a byte-identity test (with an aux-original fixture forcing a
second fixpoint round, a §4-resolved binder name, and a meta_refs
blob) locks it. parse_lazy_index_with_names returns the §4
Address→Name lookup the walk already builds (the plain variant keeps
dropping it).
--anon packs only anonymous structure via prune_to_closure_anon:
value closure + §3 hints, empty §4/§5 — the minimal artifact a
receiver needs to typecheck/evaluate the pinned value
(validate_closed checks the value pin only). Anon Euclid: 2.5 MB vs
13 MB (3,138 consts, 89 value blobs vs 9,504 with name strings),
13.6 s.
FFI rs_pack_env: mmap + lazy load, gains the anon flag (arity 5→6);
mmap_file shared with the diff module. CLI: ix pack --anon. Tests:
streaming/full byte parity (+ assumed-cut variant), anon value-only
contents + roundtrip, Lean FFI anon-bundle fixture; 249 ixon tests,
workspace + ffi/ixon suites green.
Remove the hints field from ConstantMeta::Def (Rust, Lean, the FFI
constructor slots, and the .ixe named-section encoding). Reducibility
hints now live only in the env-level anon_hints map, keyed by constant
address.
- The compiler is the sole producer: compile_definition records each
definition's hints per name (CompileState::def_hints), and
finalize_hints resolves them through the registered Named entries
into env.anon_hints via Env::register_hint once addresses are final.
The Lean compiler mirror (BlockState.defHints + Ixon.mergeHints)
does the same. Alias collisions merge order-independently (min by
(tag, height)), so parallel workers and both mirrors agree
byte-for-byte.
- Writers serialize the map directly; the derive-from-Named fallback
is deleted from Env::put / put_file / serialized_size_breakdown and
both Lean writers. This removes the serial full-decode of every
demoted Named entry that regressed ix compile by 5-14%: the
derivation loop called Named::meta() per entry, which re-decodes
demoted metadata (the IX_COMPILE_DEMOTE default) on every call.
- Kernel ingress reads hints from anon_hints in both anon and meta
modes; the hints_override parameter threading is gone. Decompile
(both languages) and the IxVM claim harness look hints up by
address. The lazy check path transports the hints section on
RawEnvLazy instead of fabricating per-name Def metas
(RawNamedLite.toConstMeta is removed).
- anon_hints becomes an IxonMap like the other env maps; Env now
derives Clone (the riscv64 IxonMap wrapper gained Clone, replacing
the manual per-map Clone impl).
Format change (pre-alpha, no compat shims): Def metadata loses its
hints byte in the named sections, so every hint is stored once instead
of two or three times (hints section + named meta + aux originals) —
the whole-stdlib env shrinks by ~232 KB. InitStd compile-time returns
to its pre-hints-section cost (median 7.3s -> 5.7s locally vs the
derivation code), with byte-exact Lean/Rust writer parity at
505,674,085 bytes on the whole-stdlib env.
Behavior note: a hint-only change now reports as a pure hintsChanged
row in ix diff; it previously also surfaced as a Def metadata diff.
@samuelburnham

Copy link
Copy Markdown
Member

!benchmark compile BENCH_ENVS=InitStd,Mathlib

@argument-ci-bot

Copy link
Copy Markdown
Contributor

!benchmark — main vs 250277d

backends: compile · envs: InitStd,Mathlib · set: primary · shard: 0

compile · InitStd — main from: bencher @ 3a0817f

envcompile-time (main)compile-time (PR)Δ%throughput (main)throughput (PR)Δ%peak-ram (main)peak-ram (PR)Δ%env-size (main)env-size (PR)Δ%constants (main)constants (PR)Δ%
InitStd3.916 s3.694 s-5.7% (1.06× faster) 🟢26.94K28.56K+6.0% (1.06× faster) 🟢3.68 GiB3.64 GiB-0.9%306.43 MiB309.05 MiB+0.9%105,492105,492+0.0%

1 env · 0 with regressions · 1 with improvements (|Δ| > 3.0% on any metric).

compile · Mathlib — main from: bencher @ 3a0817f

envcompile-time (main)compile-time (PR)Δ%throughput (main)throughput (PR)Δ%peak-ram (main)peak-ram (PR)Δ%env-size (main)env-size (PR)Δ%constants (main)constants (PR)Δ%
Mathlib45.269 s46.926 s+3.7% ⚠️16.27K15.70K-3.5% ⚠️18.74 GiB18.59 GiB-0.8%2.97 GiB2.99 GiB+0.6%736,618736,618+0.0%

1 env · 1 with regressions · 0 with improvements (|Δ| > 3.0% on any metric).

Workflow logs

@johnchandlerburnham
johnchandlerburnham merged commit 33c9cbb into mainJul 14, 2026
17 of 18 checks passed
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants

@johnchandlerburnham@samuelburnham@arthurpaulino