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
8 changes: 7 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

- Add compilation targets matrix documentation (`book/compilation_targets.md`) ([#97])
- 6-option matrix: Compile/Proof x Debug/Release x with/without non-det operations
- Document FxHashMap non-deterministic iteration bug in `arena.rs` ([#97])

### Testing

Expand Down Expand Up @@ -218,6 +217,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

- ast: 98% memory reduction in `Location` struct by removing unused source field ([#69])

### Fixed

- Fix FxHashMap non-deterministic iteration in `Arena` — `filter_nodes()` and `list_nodes_cmp()` now sort by node ID, ensuring reproducible WASM function emission order
- Fix Drop instruction emission for nested non-det blocks — `parent_blocks_stack.last()` (innermost block) is now used instead of `.first()` (outermost block)
- Fix `lower_literal` to emit type-correct WASM const instructions — number literals now consult `TypedContext` and emit `i32.const` or `i64.const` based on inferred type instead of always emitting `i32.const`
- Fix `wasm_to_v` public API signature — parameter changed from `&Vec<u8>` to idiomatic `&[u8]`

---

## [0.0.1-alpha] - 2026-01-03
Expand Down
104 changes: 80 additions & 24 deletions core/ast/src/arena.rs
Original file line number Diff line number Diff line change
Expand Up @@ -190,11 +190,12 @@
.collect()
}

// BUG(reproducibility): Same non-deterministic iteration issue as `list_nodes_cmp`.
// See comment on `list_nodes_cmp` for details and required fix.
pub fn filter_nodes<T: Fn(&AstNode) -> bool>(&self, fn_predicate: T) -> Vec<AstNode> {
self.nodes
.values()
let mut entries: Vec<_> = self.nodes.iter().collect();
entries.sort_unstable_by_key(|(id, _)| *id);
entries
.into_iter()
.map(|(_, node)| node)
.filter(|node| fn_predicate(node))
.cloned()
.collect()
Expand All @@ -216,28 +217,83 @@
.unwrap_or_default()
}

// BUG(reproducibility): This method iterates over `FxHashMap`, whose iteration
// order is non-deterministic. Any caller that collects results into a Vec and
// processes them sequentially (e.g., `source_files()`, `functions()`,
// `list_type_definitions()`) will produce output in unpredictable order.
//
// This directly affects WASM output determinism: the codegen pipeline calls
// `typed_context.source_files()` → `source_file.function_definitions()` →
// `compiler.visit_function_definition()`, so function emission order in the
// WASM binary depends on HashMap iteration order.
//
// FIX REQUIRED: Either sort results by node ID before returning, use `IndexMap`
// to preserve insertion order, or use `BTreeMap` for guaranteed sorted iteration.
// Node IDs are assigned sequentially by the parser, so sorting by ID restores
// the source-order determinism needed for reproducible builds.
//
// See also: `filter_nodes()` which has the same issue.
/// Iterates over all nodes sorted by node ID, applying a filter-map function.
///
/// Node IDs are assigned sequentially by the parser, so sorting by ID
/// restores source-order determinism needed for reproducible builds.
fn list_nodes_cmp<'a, T, F>(&'a self, cmp: F) -> impl Iterator<Item = T> + 'a
where
F: Fn(&AstNode) -> Option<T> + Clone + 'a,
T: Clone + 'static,
F: Fn(&AstNode) -> Option<T> + 'a,
{
let cmp = cmp.clone();
self.nodes.iter().filter_map(move |(_, node)| cmp(node))
let mut ids: Vec<u32> = self.nodes.keys().copied().collect();
ids.sort_unstable();
ids.into_iter()
.filter_map(move |id| self.nodes.get(&id).and_then(&cmp))
}
}

#[cfg(test)]
mod arena_tests {
use super::*;
use crate::nodes::{Expression, Literal, Location, NumberLiteral};

fn make_number_literal_node(id: u32) -> AstNode {
AstNode::Expression(Expression::Literal(Literal::Number(Rc::new(
NumberLiteral {
id,
location: Location::default(),
value: id.to_string(),
},
))))
}

#[test]
fn filter_nodes_returns_ascending_node_id_order() {
let mut arena = Arena::default();
let ids: Vec<u32> = vec![50, 10, 40, 20, 30];
for &id in &ids {
let node = make_number_literal_node(id);
arena.add_node(node, u32::MAX);
}

let filtered = arena.filter_nodes(|_| true);

let result_ids: Vec<u32> = filtered.iter().map(AstNode::id).collect();
assert_eq!(result_ids, vec![10, 20, 30, 40, 50]);
}

#[test]
fn filter_nodes_preserves_order_with_predicate() {
let mut arena = Arena::default();
for id in [30, 10, 50, 20, 40] {
let node = make_number_literal_node(id);
arena.add_node(node, u32::MAX);
}

let filtered = arena.filter_nodes(|node| node.id() > 20);

let result_ids: Vec<u32> = filtered.iter().map(AstNode::id).collect();
assert_eq!(result_ids, vec![30, 40, 50]);
}

#[test]
fn list_nodes_cmp_returns_ascending_node_id_order() {
let mut arena = Arena::default();
for id in [30, 10, 50, 20, 40] {
let node = make_number_literal_node(id);
arena.add_node(node, u32::MAX);
}

let ids: Vec<u32> = arena
.list_nodes_cmp(|node| {
if let AstNode::Expression(Expression::Literal(Literal::Number(n))) = node {
Some(n.id)
} else {
None

Check warning on line 292 in core/ast/src/arena.rs

View check run for this annotation

Codecov / codecov/patch

core/ast/src/arena.rs#L292

Added line #L292 was not covered by tests
}
})
.collect();

assert_eq!(ids, vec![10, 20, 30, 40, 50]);
}
}
2 changes: 1 addition & 1 deletion core/cli/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -282,7 +282,7 @@
println!("WASM generated at: {}", wasm_file_path.to_string_lossy());
}
if args.generate_v_output {
match wasm_to_v(source_fname, &wasm_bytes.to_vec()) {
match wasm_to_v(source_fname, wasm_bytes) {

Check warning on line 285 in core/cli/src/main.rs

View check run for this annotation

Codecov / codecov/patch

core/cli/src/main.rs#L285

Added line #L285 was not covered by tests
Ok(v_output) => {
let v_file_path = output_path.join(format!("{source_fname}.v"));
if let Err(e) = fs::create_dir_all(&output_path) {
Expand Down
2 changes: 1 addition & 1 deletion core/inference/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,7 @@ The crate exposes five primary functions:
| [`type_check`] | `Arena` | `TypedContext` | Type check and infer types |
| [`analyze`] | `&TypedContext` | `()` | Semantic analysis (WIP) |
| [`codegen`] | `&TypedContext` | `Vec<u8>` | Generate WebAssembly bytecode |
| [`wasm_to_v`] | `&str`, `&Vec<u8>` | `String` | Translate WASM to Rocq |
| [`wasm_to_v`] | `&str`, `&[u8]` | `String` | Translate WASM to Rocq |

## Compilation Pipeline

Expand Down
12 changes: 6 additions & 6 deletions core/inference/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -125,7 +125,7 @@
//! let typed_context = type_check(arena)?;
//! let codegen_output = codegen(&typed_context)?;
//! // WASM bytes are directly available from codegen output:
//! // wasm_to_v("MyModule", &codegen_output.wasm().to_vec())
//! // wasm_to_v("MyModule", codegen_output.wasm())
//! # Ok::<(), anyhow::Error>(())
//! ```
//!
Expand Down Expand Up @@ -197,7 +197,7 @@
//! let arena = parse(source_code)?;
//! let typed_context = type_check(arena)?;
//! let codegen_output = codegen(&typed_context)?;
//! let rocq_code = wasm_to_v(module_name, &codegen_output.wasm().to_vec())?;
//! let rocq_code = wasm_to_v(module_name, codegen_output.wasm())?;
//! Ok(rocq_code)
//! }
//! ```
Expand Down Expand Up @@ -598,7 +598,7 @@
/// let arena = parse(source)?;
/// let typed_context = type_check(arena)?;
/// let codegen_output = codegen(&typed_context)?;
/// let rocq_code = wasm_to_v("EvenChecker", &codegen_output.wasm().to_vec())?;
/// let rocq_code = wasm_to_v("EvenChecker", codegen_output.wasm())?;
/// # Ok::<(), anyhow::Error>(())
/// ```
///
Expand All @@ -621,7 +621,7 @@
/// let typed_context = type_check(arena)?;
/// let codegen_output = codegen(&typed_context)?;
/// // WASM bytes are directly available from codegen output:
/// // wasm_to_v("CommutativityProof", &codegen_output.wasm().to_vec())
/// // wasm_to_v("CommutativityProof", codegen_output.wasm())
/// # Ok::<(), anyhow::Error>(())
/// ```
///
Expand Down Expand Up @@ -691,9 +691,9 @@
/// - [WebAssembly Specification](https://webassembly.github.io/spec/)
/// - [Inference Language Specification](https://github.com/Inferara/inference-language-spec)
/// - [`inference_wasm_to_v_translator`] for implementation details
pub fn wasm_to_v(mod_name: &str, wasm: &Vec<u8>) -> anyhow::Result<String> {
pub fn wasm_to_v(mod_name: &str, wasm: &[u8]) -> anyhow::Result<String> {

Check warning on line 694 in core/inference/src/lib.rs

View check run for this annotation

Codecov / codecov/patch

core/inference/src/lib.rs#L694

Added line #L694 was not covered by tests
if let Ok(v) =
inference_wasm_to_v_translator::wasm_parser::translate_bytes(mod_name, wasm.as_slice())
inference_wasm_to_v_translator::wasm_parser::translate_bytes(mod_name, wasm)

Check warning on line 696 in core/inference/src/lib.rs

View check run for this annotation

Codecov / codecov/patch

core/inference/src/lib.rs#L696

Added line #L696 was not covered by tests
{
Ok(v)
} else {
Expand Down
Loading