Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
46 changes: 46 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,52 @@ All notable changes to LOOM will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## [1.1.1] - 2026-05-22

**Housekeeping + an ægraph commutativity bug fix.** A patch release
clearing the v1.1.0 Track-3 carry-forward and fixing a real
operand-ordering bug that blocked commutative identity folds.

### Fixed

- **ægraph commutativity normalization.** `EGraph::canonicalize_commutative`
ordered operands purely by union-find class id, so when a constant
operand happened to be inserted (and numbered) before its variable
sibling, `Add(0, x)` stayed constant-left and the `(wild, Const)`
identity rules could not match it. The sort key is now
`(is_constant, uf-root id)` — constants always move to the right,
matching every identity rule's LHS shape. The previously `#[ignore]`'d
`test_commutativity_zero_plus_x_folds` is now a passing positive
witness; `test_commutativity_idempotent` confirms the new order is
still a fixpoint.

### Housekeeping (v1.1.0 Track D)

- `Instruction` and `BlockType` now derive `Eq + Hash` (previously
`PartialEq` only). Lets downstream passes key hash sets/maps on
instructions structurally instead of via `Debug`-formatted strings.
- `AdapterInfo` and its fields lifted from module-private to
`pub(crate)` for cross-module reuse.
- `optimize_module` no longer discards `FusedOptimizationStats` or
silently swallows fused-optimization outcomes: it now logs a
one-line summary of what the fused passes did on success (positive
signal they ran) and keeps the non-fatal warning on failure.

### Tests

379 loom-core lib tests pass (was 378 + 1 ignored; the commutativity
test is now un-ignored and green).

### Deferred

- **Track E** — real meld-fused multi-component fixture. `meld` v0.9.0
is now installed and working, but a fixture that exercises the
cross-memory adapter passes needs a memory-sharing component pair
that does not exist ready-made in either repo.
- **Rocq CI** — `Rocq Formal Proofs` stays red pending upstream
`rules_rocq_rust` PR #34 (`rules_rust` toolchain migration, still
draft). When it merges, bump the `MODULE.bazel` pin.

## [1.1.0] - 2026-05-20

**ægraph substrate goes production + first mechanized roundtrip
Expand Down
2 changes: 1 addition & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ members = [
]

[workspace.package]
version = "1.1.0"
version = "1.1.1"
authors = ["PulseEngine <https://github.com/pulseengine>"]
edition = "2024"
license = "Apache-2.0"
Expand Down
60 changes: 42 additions & 18 deletions loom-core/src/egraph.rs
Original file line number Diff line number Diff line change
Expand Up @@ -750,20 +750,38 @@ impl EGraph {
total
}

/// Sort key for one operand of a commutative e-node:
/// `(is_constant, union-find root id)`. A constant operand sorts
/// after a non-constant one (`false < true`), so ordering operands
/// by this key ascending pushes any constant to the right —
/// matching the `(wild, Const)` LHS shape of every identity rule.
fn operand_order_key(&mut self, id: EClassId) -> (bool, u32) {
let root = self.uf.find(id);
let is_const = matches!(
self.nodes[root.0 as usize].op,
Op::Const(_) | Op::Const64(_)
);
(is_const, root.0)
}

/// Canonicalize the operand order of every commutative e-node
/// (per [`Op::is_commutative`]) so that the smaller union-find root
/// id comes first. After this pass:
/// (per [`Op::is_commutative`]) so that the operands are ordered by
/// the key `(is_constant, union-find root id)` ascending — i.e. a
/// constant operand always moves to the right (`children[1]`), and
/// ties are broken by the smaller union-find root id. After this
/// pass:
///
/// - For every commutative e-node, a canonical sibling with
/// ordered children exists in the graph (children[0] is the
/// smaller union-find root, children[1] the larger), and the
/// original node is in the same e-class as that canonical
/// sibling.
/// - Subsequent positional rule matching (e.g. `Add(?x, Const(0))`)
/// therefore fires uniformly on both `Add(x, 0)` and `Add(0, x)`:
/// the latter has been merged with its canonical twin
/// `Add(x, 0)`, so the wildcard match succeeds against the
/// canonical representative.
/// - For every commutative e-node, a canonical sibling with ordered
/// children exists in the graph, and the original node is in the
/// same e-class as that canonical sibling.
/// - The identity rules are all stated in the `(wild, Const)`
/// operand order (e.g. `Add(?x, Const(0))`). Because
/// canonicalization forces every constant operand to the right,
/// subsequent positional rule matching fires uniformly on both
/// `Add(x, 0)` and `Add(0, x)`: the latter is merged with its
/// canonical twin `Add(x, 0)`, so the wildcard match succeeds
/// against the canonical representative — regardless of which
/// operand happened to be inserted (and thus numbered) first.
///
/// Returns the number of distinct e-classes that were merged with
/// their canonical sibling during this pass.
Expand Down Expand Up @@ -797,15 +815,22 @@ impl EGraph {
if node.children.len() != 2 {
continue;
}
let r0 = self.uf.find(node.children[0]);
let r1 = self.uf.find(node.children[1]);
// Already canonical: smaller root id on the left.
if r0 <= r1 {
let (c0, c1, op) = (node.children[0], node.children[1], node.op);
// Sort key per operand: `(is_constant, uf-root id)`. A
// constant sorts after a non-constant, so the canonical
// form always has any constant operand on the right —
// which is the operand order every identity rule's LHS is
// written in. Ties (both/neither constant) fall back to the
// smaller root id for a deterministic total order.
let k0 = self.operand_order_key(c0);
let k1 = self.operand_order_key(c1);
// Already canonical: left operand's key <= right operand's.
if k0 <= k1 {
continue;
}
// Schedule materialization of the swapped sibling outside
// the immutable borrow.
let swapped = ENode::new(node.op, vec![node.children[1], node.children[0]]);
let swapped = ENode::new(op, vec![c1, c0]);
pending.push((EClassId(idx as u32), swapped));
}
let mut total = 0usize;
Expand Down Expand Up @@ -1571,7 +1596,6 @@ mod tests {
/// positive witness for v1.1.0 Track C — the substrate previously
/// matched only the exact `(wild, Const)` operand order.
#[test]
#[ignore = "v1.1.1 follow-up: commutativity normalization not invoked at insertion time"]
fn test_commutativity_zero_plus_x_folds() {
let mut g = EGraph::new();
let zero = g.add(ENode::new(Op::Const(0), vec![])).unwrap();
Expand Down
6 changes: 3 additions & 3 deletions loom-core/src/fused_optimizer.rs
Original file line number Diff line number Diff line change
Expand Up @@ -104,11 +104,11 @@ pub struct FusedOptimizationStats {
/// with the same signature. Callers of the adapter can safely call the target
/// directly, eliminating the trampoline overhead.
#[derive(Debug, Clone)]
struct AdapterInfo {
pub(crate) struct AdapterInfo {
/// Index of the adapter function (in module.functions, not accounting for imports)
func_index: usize,
pub(crate) func_index: usize,
/// The target function index that the adapter forwards to (absolute index)
target_func_idx: u32,
pub(crate) target_func_idx: u32,
}

/// Run all fused module optimization passes.
Expand Down
38 changes: 33 additions & 5 deletions loom-core/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -229,7 +229,7 @@ pub enum ValueType {
}

/// Block type for control flow structures
#[derive(Debug, Clone, PartialEq, Eq)]
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub enum BlockType {
/// No parameters, no results
Empty,
Expand All @@ -245,7 +245,7 @@ pub enum BlockType {
}

/// WebAssembly instructions
#[derive(Debug, Clone, PartialEq)]
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub enum Instruction {
/// i32.const
I32Const(i32),
Expand Down Expand Up @@ -6813,9 +6813,37 @@ pub mod optimize {

// Phase 0: Fused component optimizations (adapter devirtualization, type/import
// dedup, dead function elimination). These are safe no-ops on non-fused modules.
if let Err(e) = super::fused_optimizer::optimize_fused_module(module) {
// Non-fatal: fused optimization is best-effort, but log so failures are visible
eprintln!("Warning: fused optimization failed (non-fatal): {e}");
// Best-effort and non-fatal, but the outcome is always reported — on success
// with a one-line summary of what the fused passes did (so there is positive
// signal they ran), on failure with a warning. Never silently swallowed.
match super::fused_optimizer::optimize_fused_module(module) {
Ok(stats) => {
let touched = stats.adapters_detected
+ stats.calls_devirtualized
+ stats.scalar_adapters_inlined
+ stats.function_bodies_deduplicated
+ stats.dead_functions_eliminated
+ stats.types_deduplicated
+ stats.imports_deduplicated
+ stats.memory_imports_deduplicated
+ stats.same_memory_adapters_collapsed
+ stats.trivial_calls_eliminated;
if touched > 0 {
eprintln!(
"fused optimization: {} adapters detected, {} calls devirtualized, \
{} scalar adapters inlined, {} bodies deduped, {} dead functions removed",
stats.adapters_detected,
stats.calls_devirtualized,
stats.scalar_adapters_inlined,
stats.function_bodies_deduplicated,
stats.dead_functions_eliminated,
);
}
}
Err(e) => {
// Non-fatal: fused optimization is best-effort, but log so failures are visible.
eprintln!("Warning: fused optimization failed (non-fatal): {e}");
}
}

// Phase 1: Function inlining (unlocks cross-function optimization)
Expand Down
Loading