Skip to content
Closed
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
137 changes: 137 additions & 0 deletions .github/workflows/differential.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,137 @@
name: Differential

# Differential-test the Rust expression engine against the Python OpenJD
# reference implementation on every pull request and on merges to main. The
# harness runs each input through both implementations and asserts they agree
# (equal typed value, or both error) — catching the silent-wrong-value class of
# bug that neither the fuzzer (which only checks "does not crash") nor a human
# reading the code reliably finds.
#
# Two gates run here:
# * conformance + regressions — deterministic corpora (fast; the real gate)
# * generative — a grammar-aware generator, time-/count-boxed so it covers
# broadly without slowing the merge queue (see DIFF_GEN_CASES below)
#
# The differential crate (differential/) is deliberately outside the root
# workspace and depends on an out-of-tree Python checkout, so it lives in its
# own workflow rather than the stable CI matrix.

on:
push:
branches: [main]
pull_request:
branches: [main, release, "patch_*"]
workflow_dispatch:

concurrency:
group: differential-${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: ${{ github.event_name == 'pull_request' }}

permissions:
contents: read

env:
CARGO_INCREMENTAL: 0
CARGO_NET_RETRY: 10
RUSTUP_MAX_RETRIES: 10
RUST_BACKTRACE: 1
# A missing/broken Python reference is a HARD failure in CI (never a silent
# skip): the whole point of this job is to run the oracle.
OPENJD_DIFF_REQUIRED: "1"
# Per-run generative budget: enough cases to exercise the evaluator broadly
# while keeping the step near ~20s including the one-time build. The generator
# runs ~6000 cases/sec once built, so this is generous headroom.
DIFF_GEN_CASES: "20000"

jobs:
differential:
name: Differential (expr vs Python reference)
runs-on: ubuntu-latest
steps:
- name: Check out openjd-rs
uses: actions/checkout@v6
with:
path: openjd-rs

# The Python reference lives in the mwiebe fork on the `expr` branch (the
# same checkout the eval-crate skill uses). Pin it beside the Rust repo so
# the harness's default side-by-side resolution would also work; we set
# OPENJD_PYTHON_REF_SRC explicitly regardless.
- name: Check out Python reference (openjd-model-for-python @ expr)
uses: actions/checkout@v6
with:
repository: mwiebe/openjd-model-for-python
ref: expr
path: openjd-model-for-python

- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: "3.12"

# The reference is a pure-Python namespace package; importing it only
# needs its `src/` on PYTHONPATH (the harness sets this per-process). No
# pip install required for the expr module, but install its runtime deps
# in case the imported modules pull them in.
- name: Install Python reference dependencies
working-directory: openjd-model-for-python
run: |
python -m pip install --upgrade pip
# Editable install wires up the package + its deps; falls back to a
# no-op-safe requirements install if the project layout changes.
pip install -e . || pip install -r requirements-development.txt || true

- name: Sanity-check the reference imports
working-directory: openjd-model-for-python
run: PYTHONPATH=src python -c "from openjd.expr._eval import evaluate_expression as e; print(e('1+2').to_string())"

- name: Install Rust stable
run: |
rustup toolchain install stable --profile minimal
rustup override set stable --path openjd-rs

- name: Compute rustc hash
id: rustc
run: echo "hash=$(rustc --version --verbose | sha256sum | cut -c1-16)" >> "$GITHUB_OUTPUT"

- name: Restore cargo cache
uses: actions/cache@v5
with:
path: |
~/.cargo/registry/index
~/.cargo/registry/cache
~/.cargo/git/db
openjd-rs/differential/target
key: differential-${{ steps.rustc.outputs.hash }}-${{ hashFiles('openjd-rs/differential/Cargo.lock', 'openjd-rs/differential/Cargo.toml') }}
restore-keys: |
differential-${{ steps.rustc.outputs.hash }}-
differential-

# Point the harness at the checked-out reference's src/ directory.
- name: Set reference source path
run: echo "OPENJD_PYTHON_REF_SRC=${{ github.workspace }}/openjd-model-for-python/src" >> "$GITHUB_ENV"

# Gate 1 — deterministic corpora. Fast; this is the real per-PR gate. Any
# unallowed divergence between Rust and Python fails the build.
- name: Conformance + regression differential
working-directory: openjd-rs/differential
run: cargo test --test differential conformance_and_regressions -- --nocapture

# Gate 2 — grammar-aware generative differential, count-boxed via
# DIFF_GEN_CASES so it covers broadly in a single ~20s step. Runs on PRs
# and on merges to main. Uses the crate's fixed default seed for
# reproducibility; a divergence prints a ready-to-freeze corpus line and
# writes reproducers to target/ (uploaded below).
- name: Generative differential
working-directory: openjd-rs/differential
env:
OPENJD_DIFF_GEN_CASES: ${{ env.DIFF_GEN_CASES }}
run: cargo test --test differential generative_differential -- --nocapture

- name: Upload generative divergence reproducers
if: failure()
uses: actions/upload-artifact@v7
with:
name: differential-divergences
path: openjd-rs/differential/target/generative-divergences*.txt
if-no-files-found: ignore
52 changes: 47 additions & 5 deletions crates/openjd-expr/src/functions/arithmetic.rs
Original file line number Diff line number Diff line change
Expand Up @@ -107,8 +107,14 @@ pub fn pow_int(_: Ctx, a: &[ExprValue]) -> R {
"Cannot raise zero to a negative power",
));
}
let exp32 = i32::try_from(*exp).unwrap_or(i32::MIN);
return Ok(ExprValue::Float(Float64::new((*base as f64).powi(exp32))?));
// Use `powf`, not `powi`: `powi` evaluates via repeated squaring
// and accumulates a last-ulp error (e.g. `956 ** -74` came out
// as ...9224e-221 instead of ...924e-221), whereas Python
// computes int-base/negative-exponent power in float and matches
// `powf` exactly.
return Ok(ExprValue::Float(Float64::new(
(*base as f64).powf(*exp as f64),
)?));
}
// Guard: exponent > 63 with |base| > 1 always overflows i64
if *exp > 63 && !matches!(*base, -1..=1) {
Expand Down Expand Up @@ -185,7 +191,33 @@ pub fn floordiv_float(_: Ctx, a: &[ExprValue]) -> R {
if r == 0.0 {
return Err(ExpressionError::division_by_zero("Division"));
}
let v = (l / r).floor();
// Float floor-division is NOT `(l / r).floor()`: because the true quotient
// is only approximated in f64, plain flooring gives the wrong integer at
// ties (e.g. `205 // 0.1` → 2050 instead of Python's 2049, since 0.1 is
// slightly more than 1/10). Reproduce CPython's `float_divmod` exactly —
// derive the quotient from the `fmod` remainder, apply the floored-division
// sign correction, then round with CPython's `> 0.5` nudge.
let v = {
let modv = l % r; // fmod: remainder with the dividend's sign
let mut div = (l - modv) / r;
// Floored-division correction: when the remainder's sign disagrees with
// the divisor's, the true quotient is one less than the truncated one.
// (CPython also adjusts `mod` here, but floordiv only needs `div`.)
if modv != 0.0 && (r < 0.0) != (modv < 0.0) {
div -= 1.0;
}
if div != 0.0 {
let floordiv = div.floor();
if div - floordiv > 0.5 {
floordiv + 1.0
} else {
floordiv
}
} else {
// Sign of a zero quotient follows l/r; as an integer it is just 0.
0.0
}
};
if !float_fits_i64(v) {
return Err(ExpressionError::integer_overflow());
}
Expand All @@ -197,8 +229,18 @@ pub fn mod_float(_: Ctx, a: &[ExprValue]) -> R {
if r == 0.0 {
return Err(ExpressionError::division_by_zero("Modulo"));
}
// Python uses floored modulo: l - r * floor(l / r)
Ok(ExprValue::Float(Float64::new(l - r * (l / r).floor())?))
// Python's float `%` is floored (the result takes the *divisor's* sign),
// but computing it as `l - r * floor(l / r)` loses all precision when
// `|l/r|` is huge: `9.2e18 % 0.1` rounds `l/r` to ~9.2e19 and cancels to
// `0.0` instead of `2.84e-14`. Match CPython exactly: start from C `fmod`
// (`f64::rem`, which is truncated toward zero and numerically exact), then
// adjust by one divisor when the remainder's sign disagrees with the
// divisor's. See CPython `float___mod__`.
let mut m = l % r;
if m != 0.0 && (m < 0.0) != (r < 0.0) {
m += r;
}
Ok(ExprValue::Float(Float64::new(m)?))
}

pub fn pow_float(_: Ctx, a: &[ExprValue]) -> R {
Expand Down
7 changes: 6 additions & 1 deletion crates/openjd-expr/src/functions/conversion.rs
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,12 @@ pub fn int_from_string(_: Ctx, a: &[ExprValue]) -> R {

pub fn float_from_float(_: Ctx, a: &[ExprValue]) -> R {
match &a[0] {
ExprValue::Float(f) => Ok(ExprValue::Float(Float64::new(f.value())?)),
// Identity — return the value *unchanged*, preserving any original
// literal string. Rebuilding via `Float64::new` would drop the
// preserved source text, reformatting e.g. `float(1e308)` from the
// literal `1e308` to the computed form `1e+308` and diverging from
// Python's `_float_identity` (which returns the value as-is).
ExprValue::Float(f) => Ok(ExprValue::Float(f.clone())),
_ => Err(ExpressionError::type_error("type error")),
}
}
Expand Down
Loading