The compiler bugs we found by writing Vx's core library

TL;DR: Vx v0.0.2 includes a brand new stdlib/core/ library. It contains eight modules (cmp, option, result, num, ops, iter, clone, and default) and a reproducible random-number library (std::rand). Every line is written in pure Vx with zero extern calls (external C functions). Building this library tested our compiler across many features at once. This post covers what we built, how we tested it, and the compiler bugs we fixed along the way.

Writing a standard library tests a compiler far more thoroughly than small unit tests. Unit tests usually check one feature at a time, such as matching an enum or monomorphizing a generic function (generating concrete code for each type). In contrast, standard library code combines many features together. Generic traits, default methods, enums, and hardware intrinsics all interact in the same code.

Building Vx's core library in one week revealed bugs across our parser, type checker, and code generators. Here is what we learned from that process.

The architectural split: Why core matters

We split the Vx standard library into three distinct layers: core, alloc, and std. This tiered design follows the model used by Rust.

Previously, Vx had a single 1,358-line std library that mixed different levels of abstraction. Basic data types like Option<T> lived next to operating system networking code like TcpStream. Even Result<i32, i32> depended on six foreign C symbols in a Rust crate.

The new structure separates these concerns into clear tiers:

The golden rule: Zero extern calls

Every module in core forbids extern blocks. An extern block declares foreign functions, such as functions provided by an external C runtime library.

Vx is a heterogeneous systems programming language, designed to target different types of processors in a single program. Code written in core must run on an x86 host CPU, inside an accelerator region with spawn on(Topology::GPU[0]), on the Apple Neural Engine, or on a bare-metal microcontroller like an ARM Cortex-M7 with no C runtime. If Option::unwrap or a bitwise count called an external C symbol, executing that code on an accelerator would fail immediately.

Rust's core relies on compiler intrinsics (core::intrinsics) for low-level operations. Vx uses mlir! blocks instead. MLIR (Multi-Level Intermediate Representation) is an extensible compiler framework. An mlir! block lets library authors write inline MLIR instructions directly in Vx source code. For example, core::num implements count_ones (a population count of set bits) directly in MLIR:

impl u64 {
  fn count_ones(self : u64) -> u64 {
    return mlir!( inputs : (%a = self : i64), returns : u64, dialects : ["math"]) {
      %r = math.ctpop %a : i64
      macro.yield %r : i64
    };
  }
}

Every backend in vxc supports the MLIR math dialect. This single implementation compiles portably to native LLVM IR on the CPU, NVVM/PTX on NVIDIA GPUs, and hardware-specific instructions across all targets. It adds zero foreign runtime overhead.

What landed in stdlib/core/

The new stdlib/core/ library ships with eight fundamental modules, accompanied by a reproducible random-number library in std::rand:

ModuleWhat is inside
core::numFull integer arithmetic and bit manipulation across all widths (i8–i64, u8–u64): bit counts, rotates, pow, ilog2, next_power_of_two, plus the checked_* and saturating_* families.
core::cmpComparison traits (PartialEq and Ord). Implementing eq and cmp automatically provides default implementations for ne, lt, le, gt, ge, min, max, and clamp. Also includes Ordering with reverse and then.
core::optionOption<T> for optional values, with functional combinators: map, and_then, filter, map_or, unwrap_or_else, is_some_and, and pattern matching.
core::resultPure-Vx Result<T, E> for error handling, with ok, err, map, map_err, and and_then. The old foreign C symbols are gone.
core::iterThe Iterator<Item> trait for sequence traversal. Implementing next provides count, last, nth, any, all, find, position, and for_each. Includes composable lazy adaptors: range, map, filter, take, and skip.
core::cloneThe Clone trait, enabling explicit, predictable deep duplication of values without hidden runtime cost.
core::defaultThe Default trait, providing standard, boilerplate-free fallback initialization.
core::opsFundamental operator traits (arithmetic, bitwise, index, and dereference).
std::randA pure xoshiro256** pseudorandom generator with struct-encapsulated state, uniform floats in [0, 1), unbiased integer ranges via rejection sampling, Box-Muller normal distributions, and buffer fills.

A trait defines shared behavior that types can implement. When a trait provides default method bodies, types only need to define a small set of core methods. In core::cmp, defining just eq and cmp gives a type ten comparison methods for free:

import core::cmp;

fn main() -> i32 {
  let a : i32 = 3;
  let b : i32 = 9;
  print(a.max(b));
  print(b.clamp(0, 5));
  if a.cmp(&b).then(b.cmp(&a)).is_lt() {
    print(1);
  }
  return 0;
}

In addition, for loops now work directly with any type that implements the Iterator trait, including chained adaptors:

import core::iter;

fn main() -> i32 {
  let mut evens = filter(range(0i64, 10i64), | x : i64 | x % 2i64 == 0i64);
  for x in evens {
    print(x);
  }
  return 0;
}

Random numbers that can replay anywhere

The new std::rand library provides reproducible, deterministic random numbers across all execution targets.

Our old generator was SplitMix64 hidden behind external C runtime symbols that shared a single global counter. In concurrent or multi-device programs, threads drew from the shared counter in unpredictable order, making debugging non-deterministic.

The rewritten std::rand implements the xoshiro256** algorithm in pure Vx. It encapsulates the full generator state inside an Rng struct. Two generator instances seeded with the same value produce identical pseudorandom streams on any device:

import std::rand;

fn main() -> i32 {
  let mut r = Rng::seeded(7);
  print(r.next_f32());               // uniform in [0, 1)
  print(r.range_i64(-5, 5));         // unbiased, by rejection sampling
  print(r.normal_around(0.0, 0.02)); // polar Box-Muller transform
  let mut again = Rng::seeded(7);
  print(again.next_f32());           // exactly identical first draw
  return 0;
}

Deterministic random generation enables differential testing. Differential testing runs the same inputs through two different implementations, such as an optimized CUDA kernel on an NVIDIA A100 GPU and a reference implementation on a CPU. Comparing their outputs confirms that the optimized code matches the reference. If random inputs vary between runs or backends, verifying correctness becomes impossible.

Native support for normal (Gaussian) distributions also supports deep learning workloads. Real neural network weights and activations cluster around zero. Testing half-precision (f16/bf16) numeric kernels with uniform noise misses tail underflow and overflow bugs that cause silent training divergence.

What the compiler learned (The bugs that dogfooding found)

Writing standard library code served as an end-to-end stress test for the Vx compiler. Developing each module required fixing compiler bugs as language features interacted in practice.

Some fixes were straightforward additions for missing features:

Other issues exposed deeper architectural bugs in the compiler pipeline. Four notable bugs are detailed below.

1. The linter with amnesia (The nested call bug)

A bug in the semantic analyzer caused the compiler to issue false warnings about unused function parameters during nested calls. Importing std::rand flooded the console with fourteen false warnings for parameters that were clearly used:

struct Counter {
  n : f64,
}

impl Counter {
  fn next(self : &mut Counter) -> f64 {
    self.n = self.n + 1.0;
    return self.n;
  }

  // Used to trigger: unused parameter 'lo', unused parameter 'hi'
  fn range(self : &mut Counter, lo : f64, hi : f64) -> f64 {
    return lo + (hi - lo) * self.next();
  }
}

The semantic analyzer tracks which variable names are read. It previously used a single global HashSet of used names for the entire compilation session, clearing the set after checking each function. When the type checker analyzed a call inside range (such as self.next()), it paused checking range to check the called function (next) first. When next finished, the analyzer cleared the shared global set. This wiped out every recorded variable name read in range before that call. The compiler then reported those parameters as unused.

This bug appeared intermittent because only the first caller to invoke a function triggered the nested check. Subsequent callers found the function already checked. Scoping used-name tracking to individual function frames instead of a shared global set resolved the issue.

2. The non-deterministic dice roll (Hash map dispatch)

The compiler could produce non-deterministic builds when resolving method calls between traits with overlapping method names.

Consider a type that implements two distinct traits with the same method name:

trait Loud {
  fn describe(self : &i64) -> i32;
}

trait Quiet {
  fn describe(self : &i64) -> i32;
}

impl Loud for i64 {
  fn describe(self : &i64) -> i32 {
    return 1;
  }
}

impl Quiet for i64 {
  fn describe(self : &i64) -> i32 {
    return 2;
  }
}

When compiling a program that called x.describe() across multiple builds, the resulting binary printed 1, 1, and then 2. Method resolution iterated through an unsorted Rust HashMap of trait implementations and picked whichever match appeared first in map iteration order.

To ensure deterministic builds, we established explicit precedence rules:

Error[E3035]: Method 'describe' on 'i64' is defined by more than one impl (Loud, Quiet);
a call cannot choose between them

3. The invisible static methods

Static method calls (such as Type::method()) previously failed when invoked on traits or primitive scalar types.

When implementing core::default and core::clone, calls like Default::default() and T::default() failed to compile. The compiler's static method lookup inspected only inherent impl blocks, ignoring traits entirely. In addition, the lookup only searched structs and enums by name. This meant static methods on primitive types, such as i32::min_value(), were rejected as non-existent.

Updating static method resolution to search both inherent and trait tables across all types resolved the problem. This change enabled standard patterns like Default::default() and primitive helper methods.

4. The 64-bit sign flip

Large integer literals and negated numeric expressions exposed type inference bugs across our code generation pipelines.

In core::num, unsigned integer literals larger than i64::MAX (such as 0x8000_0000_0000_0000u64) caused issues. They crashed our default bytecode lowering path and printed as negative numbers in our legacy code generator.

In addition, the lexer typed negated numeric literals purely from their surface syntax rather than semantic context. As a result, an expression like -2.0 * ln(x) failed type checking when x : f64, even though 2.0 * ln(x) compiled successfully.

Implementing context-sensitive numeric typing resolved both problems across both compiler backends.

Testing by sabotage: Mutation testing in action

We used mutation testing to verify that our test suite catches real bugs. Mutation testing introduces small intentional bugs (mutations) into source code to verify that the test suite fails as expected.

Our testing principle is simple: never trust a test you have not watched fail. A passing test confirms that the code ran without error. Injecting deliberate mutations confirms that the test detects incorrect behavior.

To verify std::rand, we introduced 29 distinct single-edit mutations in rand.vx. These edits included altering a shift distance, flipping an addition to a subtraction, and swapping the update order of state words. We ran each mutation against our integration test suite. Every mutation was expected to trigger a specific failure.

The first run revealed a gap: one mutation did not cause any test to fail. The rejection-sampling loop in range_i64 never executed because the chosen test bounds never triggered a rejection. The test suite passed completely, leaving this algorithmic branch untested. We updated the test bounds to sit just above half the valid range. This forced the rejection loop to execute, requiring seven draws to produce the number and properly exercising the code.

Mutation testing also uncovered a compiler bug while testing Option::clone. When we deleted the None branch from a match expression, the compiler compiled the code without reporting an exhaustiveness error. At runtime, the compiled function reached the end without a value and returned invalid memory. Mutation testing caught this missing exhaustiveness check before release.

What comes next

With stdlib/core/ in place, our roadmap focuses on three next steps:

All eight core modules and std::rand are now live in stdlib/core and shipped in Vx v0.0.2. You can browse every signature in the Standard Library Reference, read the Core Library Implementation Plan, or install Vx today to try it yourself.