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
6 changes: 6 additions & 0 deletions bootstrap/src/compiler.rs
Original file line number Diff line number Diff line change
Expand Up @@ -8386,6 +8386,12 @@ impl Codegen {
"double" => "f64",
"int" => "i32",
"uint" => "u32",
// W591's family again, and the value is not a guess: the Rust
// backend maps `GF16` to `u16` and the C backend to `uint16_t`, so
// Zig was the one column disagreeing. Measured over the emitted
// corpus: a bare `GF16` reaches 9 Zig files at 95 sites, where it
// is not a type Zig knows.
"GF16" | "gf16" => "u16",
other => other,
};
// W588: a SCOPED type name in a type position -- `const PHI: gf16::GF16`.
Expand Down
15 changes: 14 additions & 1 deletion bootstrap/src/use_resolve.rs
Original file line number Diff line number Diff line change
Expand Up @@ -471,6 +471,12 @@ pub fn resolve(input_path: &Path, source: &str) -> String {
}
while !frontier.is_empty() {
let mut next: HashSet<String> = HashSet::new();
// The frontier is walked in HashSet order, which Rust randomises per
// process. Sorting it here was tried and REMOVED: with the `distinct`
// sort below in place and this one gone, 0 of the 492 importing specs
// are non-deterministic over four runs each. A guard whose removal
// changes nothing measurable is decoration, and the comment justifying
// it would outlive the reason for it.
for name in frontier {
if local.contains(&name) || pulled_names.contains(&name) {
continue;
Expand All @@ -484,7 +490,14 @@ pub fn resolve(input_path: &Path, source: &str) -> String {
for d in candidates {
by_origin.entry(d.origin.as_str()).or_insert(d);
}
by_origin.into_values().collect()
// Same reason as the frontier above: `into_values()` on a
// HashMap is randomised, and `distinct[0]` below is the
// declaration that actually gets emitted whenever the
// candidates agree. Agreement is judged on NORMALISED text, so
// two agreeing declarations can still differ byte for byte.
let mut v: Vec<&Decl> = by_origin.into_values().collect();
v.sort_by(|a, b| (&a.origin, &a.name).cmp(&(&b.origin, &b.name)));
v
};
let chosen: Option<&Decl> = if distinct.len() == 1 || all_agree(&distinct) {
Some(distinct[0])
Expand Down
2 changes: 1 addition & 1 deletion bootstrap/stage0/FROZEN_HASH
Original file line number Diff line number Diff line change
@@ -1 +1 @@
e4081dfc5d25511094205c8dec2735ead48cd6495875b89f17cf7c54bacd382a bootstrap/src/compiler.rs
fa7f44546531946823206c2f9796e8ada32a3efb9ffaa7f150e92da623084a89 bootstrap/src/compiler.rs
84 changes: 84 additions & 0 deletions bootstrap/tests/deterministic_output.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
//! The same spec, the same binary, twice: the same bytes.
//!
//! `use_resolve` walked its frontier as a `HashSet` and picked among agreeing
//! declarations out of a `HashMap`, and Rust randomises both per process. Four
//! specs therefore emitted different output on different runs of one binary --
//! same line count every time, a pure permutation, with an imported function
//! landing in a different place.
//!
//! Measured before the repair, six runs each:
//!
//! igla/coder/_tmp_pipeline_import 3 distinct (10 runs), gen-c 4 of 5
//! physics/sacred_verification 3 distinct
//! physics/zamolodchikov_4d_conjecture 2 distinct
//! igla/coder/pipeline varied
//!
//! The code claimed otherwise. `pulled.sort_by(..)` carries the comment
//! "Deterministic order: by origin, then by name, so regenerating a spec twice
//! produces byte-identical output" -- and the two random walks upstream of it
//! decided WHICH declaration reached that sort.
//!
//! This matters beyond tidiness: a project whose stated property is that four
//! backends agree cannot check that claim against output that changes between
//! runs, and no seal over generated code means anything if the code is not a
//! function of its input.

use std::process::Command;

/// Every backend that lowers a whole spec. `gen-verilog` was already
/// deterministic on these inputs and is here so a future regression in it is
/// caught too.
const BACKENDS: [&str; 4] = ["gen", "gen-rust", "gen-c", "gen-verilog"];

/// Specs that actually reproduced the defect. A synthetic fixture would have to
/// recreate an ambiguous cross-module import to exercise the same path, and a
/// fixture that failed to would pass against a compiler that never fixed it.
const SPECS: [&str; 3] = [
"specs/igla/coder/_tmp_pipeline_import.t27",
"specs/physics/sacred_verification.t27",
"specs/physics/zamolodchikov_4d_conjecture.t27",
];

fn repo_root() -> std::path::PathBuf {
// CARGO_MANIFEST_DIR is bootstrap/; the specs live beside it.
std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
.parent()
.expect("bootstrap has a parent")
.to_path_buf()
}

#[test]
fn generating_the_same_spec_twice_gives_the_same_bytes() {
let root = repo_root();
let mut checked = 0;
for spec in SPECS {
let path = root.join(spec);
if !path.exists() {
// Loudly, not quietly: an absent input is not a passing test.
eprintln!("SKIP {spec}: not in this tree");
continue;
}
for backend in BACKENDS {
let mut seen: std::collections::HashSet<Vec<u8>> = std::collections::HashSet::new();
for _ in 0..6 {
let out = Command::new(env!("CARGO_BIN_EXE_t27c"))
.arg(backend)
.arg(&path)
.output()
.expect("run t27c");
seen.insert(out.stdout);
}
assert_eq!(
seen.len(),
1,
"{backend} on {spec} produced {} distinct outputs in 6 runs",
seen.len()
);
checked += 1;
}
}
assert!(
checked > 0,
"no spec was checked -- the test measured nothing"
);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
# NOW -- The same spec twice was not the same bytes (2026-09-08)

## The same spec twice was not the same bytes (Closes #3410, Refs #3408)

- `t27c gen` on one spec, ten runs of ONE binary: **three distinct outputs**. Same line count every time -- a pure permutation, verified by `sort`-comparing the two, with an imported function landing elsewhere. `gen-c` gave **4 distinct of 5**, `gen-rust` 3 of 5, `gen-verilog` 1. Four specs affected: `igla/coder/_tmp_pipeline_import`, `igla/coder/pipeline`, `physics/sacred_verification` (3 of 6), `physics/zamolodchikov_4d_conjecture` (2 of 6).
- Cause in `use_resolve.rs`: `by_origin.into_values()` on a HashMap is randomised, and `distinct[0]` right below it is the declaration that actually gets emitted whenever the candidates "agree". Agreement is judged on NORMALISED text, so two agreeing declarations can still differ byte for byte. Twenty lines further down the code says *"Deterministic order: by origin, then by name, so regenerating a spec twice produces byte-identical output"* -- that sort is real, and it cannot undo a random choice made upstream of it.
- **Which of the two random walks matters was measured, not assumed.** Removing the `distinct` sort fails the test with `3 distinct outputs in 6 runs`. Removing a frontier sort I had also written, with the `distinct` sort kept, leaves **0 of 492** importing specs non-deterministic. So the repair is ONE sort; the second was measured at zero and **removed rather than shipped**, because a guard whose removal changes nothing is decoration and its comment would outlive its reason.
- Found while chasing something else: I was comparing two Zig corpora and 85 diff lines mentioned neither of the types I had changed. The discrepancy between the instruments was the clue.
- Also in this pass: `GF16` was missing from the Zig type mapper, reaching **9 files at 95 sites** as a bare name Zig does not know. Not a guess -- the Rust backend maps it to `u16` and the C backend to `uint16_t`, so Zig was the one column disagreeing. Same named class as the `float`/W591 and `f32`/W583 gaps already recorded in that mapper.
- Recorded, not fixed: `float` is **f64 in Rust and Zig, 32-bit in C, 32-bit in Verilog**; `double` is f64 in three and **32-bit in Verilog**; `tri` is unmapped in Rust, C and Zig alike. Those are language decisions about what the spellings MEAN, not gaps with a determined answer, and are filed rather than guessed.
- rustc acceptance unchanged at 433 of 651; the Zig corpus changes in 15 files, 11 of them GF16-only.
Loading