diff --git a/bootstrap/src/compiler.rs b/bootstrap/src/compiler.rs index 7feb2dbc4d..813492a041 100644 --- a/bootstrap/src/compiler.rs +++ b/bootstrap/src/compiler.rs @@ -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`. diff --git a/bootstrap/src/use_resolve.rs b/bootstrap/src/use_resolve.rs index 9630324381..b5792921cf 100644 --- a/bootstrap/src/use_resolve.rs +++ b/bootstrap/src/use_resolve.rs @@ -471,6 +471,12 @@ pub fn resolve(input_path: &Path, source: &str) -> String { } while !frontier.is_empty() { let mut next: HashSet = 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; @@ -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]) diff --git a/bootstrap/stage0/FROZEN_HASH b/bootstrap/stage0/FROZEN_HASH index c3527cb6dc..d1b759268a 100644 --- a/bootstrap/stage0/FROZEN_HASH +++ b/bootstrap/stage0/FROZEN_HASH @@ -1 +1 @@ -e4081dfc5d25511094205c8dec2735ead48cd6495875b89f17cf7c54bacd382a bootstrap/src/compiler.rs +fa7f44546531946823206c2f9796e8ada32a3efb9ffaa7f150e92da623084a89 bootstrap/src/compiler.rs diff --git a/bootstrap/tests/deterministic_output.rs b/bootstrap/tests/deterministic_output.rs new file mode 100644 index 0000000000..a6db5f7128 --- /dev/null +++ b/bootstrap/tests/deterministic_output.rs @@ -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> = 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" + ); +} diff --git a/docs/now/2026-09-08-the-same-spec-twice-was-not-the-same-bytes.md b/docs/now/2026-09-08-the-same-spec-twice-was-not-the-same-bytes.md new file mode 100644 index 0000000000..2699e6fa82 --- /dev/null +++ b/docs/now/2026-09-08-the-same-spec-twice-was-not-the-same-bytes.md @@ -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.