Last updated: 2026-09-26

M
Masters level
APL
Applied / Methodological — Knowledge with a 5–10 year half-life — stable practice

Testing & Validation: Proving the Containment Property

7.1 Testing Philosophy for Interval Arithmetic

Testing an interval library differs from testing ordinary numerical code in one fundamental respect: the correct answer is a set, not a number. A test that checks result == 3.14159 is asking the wrong question. The correct test asks: does the output interval contain all values it is supposed to contain? cf. the wider testing-technique map

This requires a testing strategy based on the containment property (Definition 1.1 from Chapter I): for any sample points within the input intervals, the function value must lie within the output interval. We verify this using property-based testing with random sampling.[1] passing tests don't prove no bugs

Test strategy

For each operation \(\circ\), a containment test:

1. Sample \(n\) random points \((x_i, y_i)\) with \(x_i \in [a,b]\), \(y_i \in [c,d]\).
2. Compute the true value \(z_i = x_i \circ y_i\).
3. Assert \(z_i \in [a,b] \circ [c,d]\) for all \(i\).
A single counter-example is a library bug.

7.2 Interval Arithmetic Spec

RSpec require 'spec_helper' require 'ga_interval' RSpec.describe GAInterval::Interval do # Helper: sample n random values within [lo,hi] def samples(interval, n = 200) n.times.map { interval.lo + rand * interval.width } end describe '#+' do it 'satisfies the containment property' do a = GAInterval::Interval[-2.0, 3.0] b = GAInterval::Interval[ 1.0, 4.0] result = a + b samples(a).zip(samples(b)).each do |x, y| expect(result).to contain_value(x + y) end end end describe '#*' do it 'satisfies the containment property' do a = GAInterval::Interval[-1.5, 2.5] b = GAInterval::Interval[-2.0, 1.0] result = a * b samples(a).zip(samples(b)).each do |x, y| expect(result).to contain_value(x * y) end end it 'handles mixed-sign intervals correctly' do a = GAInterval::Interval[-3.0, -1.0] b = GAInterval::Interval[ 2.0, 4.0] result = a * b expect(result.lo).to be_within(1e-10).of(-12.0) expect(result.hi).to be_within(1e-10).of(-2.0) end end describe '#/' do it 'raises DivisionByZeroInterval when divisor contains 0' do a = GAInterval::Interval[1.0, 2.0] b = GAInterval::Interval[-1.0, 1.0] expect { a / b }.to raise_error(GAInterval::DivisionByZeroInterval) end it 'divides correctly when divisor excludes 0' do a = GAInterval::Interval[2.0, 4.0] b = GAInterval::Interval[1.0, 2.0] result = a / b expect(result.lo).to be_within(1e-10).of(1.0) expect(result.hi).to be_within(1e-10).of(4.0) samples(a).zip(samples(b)).each do |x, y| expect(result).to contain_value(x / y) if y.abs > 0.01 end end end describe '#sin and #cos' do it 'encloses all sine values in the interval' do a = GAInterval::Interval[0.3, 2.1] result = a.sin samples(a, 500).each { |x| expect(result).to contain_value(Math.sin(x)) } end it 'returns [-1,1] for a full-period interval' do a = GAInterval::Interval[0, 2 * Math::PI] expect(a.sin.lo).to be_within(1e-10).of(-1.0) expect(a.sin.hi).to be_within(1e-10).of(1.0) end end end

7.3 Multivector Algebraic Identity Spec

The geometric product must satisfy several fundamental identities. Failure of any of these indicates a bug in the canonical_product sign computation.

RSpec RSpec.describe GAInterval::Multivector do let(:e1) { GAInterval::Multivector.e(1) } let(:e2) { GAInterval::Multivector.e(2) } let(:e3) { GAInterval::Multivector.e(3) } let(:I) { e1 ^ e2 ^ e3 } describe 'basis vector products' do it 'satisfies eᵢ² = 1 (Euclidean signature)' do [e1, e2, e3].each do |e| expect((e * e).scalar_part).to be_within(1e-12).of(1.0) end end it 'satisfies eᵢeⱼ = -eⱼeᵢ for i≠j (anticommutativity)' do [[e1,e2],[e1,e3],[e2,e3]].each do |a, b| prod_ab = a * b prod_ba = b * a prod_ab.coeffs.each do |mask, v| expect(prod_ba.coeffs[mask] || 0).to be_within(1e-12).of(-v) end end end it 'satisfies I² = -1 for 3D pseudoscalar' do expect((I * I).scalar_part).to be_within(1e-12).of(-1.0) end end describe 'geometric product decomposition' do it 'ab = a·b + a∧b for vectors' do a = e1.scale(2.0) + e2.scale(3.0) b = e1.scale(1.0) + e2.scale(-1.0) geo_prod = a * b inner = a | b # scalar part: a·b outer = a ^ b # bivector part: a∧b reconstructed = inner + outer geo_prod.coeffs.each do |mask, v| expect(reconstructed.coeffs[mask] || 0).to be_within(1e-12).of(v) end end it 'outer product is grade-raising' do result = e1 ^ e2 expect(result.pure_grade?(2)).to be true end it 'outer product of parallel vectors is zero' do a = e1.scale(3.0) b = e1.scale(2.0) result = a ^ b expect(result.coeffs).to be_empty end end describe '#inverse' do it 'satisfies v * v⁻¹ = 1 for non-null vectors' do v = e1.scale(3.0) + e2.scale(-2.0) + e3.scale(1.0) identity = v * v.inverse expect(identity.scalar_part).to be_within(1e-12).of(1.0) expect(identity.grade_part(2).coeffs).to be_empty end it 'raises NullBladeError for zero blade' do zero = GAInterval::Multivector.zero expect { zero.inverse }.to raise_error(GAInterval::NullBladeError) end end describe '#dual' do it '(A*)* = ±A (double dual is identity up to sign)' do a = e1.scale(2.0) + e2.scale(3.0) dd = a.dual.dual # In 3D: (A*)* = (-1)^(n(n-1)/2) A = -A for n=3 a.coeffs.each do |mask, v| expect((dd.coeffs[mask] || 0).abs).to be_within(1e-12).of(v.abs) end end end end

7.4 Rotor Spec

RSpec RSpec.describe GAInterval::Rotor do let(:e1) { GAInterval::Multivector.e(1) } let(:e2) { GAInterval::Multivector.e(2) } let(:I) { e1 ^ e2 } it 'rotates e1 to e2 by π/2' do r = GAInterval::Rotor.from_bivector(I, Math::PI / 2) result = r.rotate(e1) expect(result.coeffs[0b01] || 0).to be_within(1e-10).of(0.0) # e1 component ≈ 0 expect(result.coeffs[0b10] || 0).to be_within(1e-10).of(1.0) # e2 component ≈ 1 end it 'preserves vector magnitude under rotation' do v = e1.scale(3.0) + e2.scale(4.0) # |v| = 5 r = GAInterval::Rotor.from_bivector(I, 1.23) result = r.rotate(v) expect(result.norm).to be_within(1e-10).of(v.norm) end it 'composes rotations correctly: R(α+β) = R(α)R(β)' do alpha = 0.5; beta = 0.7 r_ab = GAInterval::Rotor.from_bivector(I, alpha + beta) r_a = GAInterval::Rotor.from_bivector(I, alpha) r_b = GAInterval::Rotor.from_bivector(I, beta) r_composed = r_a.compose(r_b) r_ab.coeffs.each do |mask, v| expect(r_composed.coeffs[mask] || 0).to be_within(1e-10).of(v) end end it 'has unit norm (RR̃ = 1)' do r = GAInterval::Rotor.from_bivector(I, 1.23) expect(r.norm_squared).to be_within(1e-10).of(1.0) end end

7.5 IntervalBlade Containment Spec

RSpec RSpec.describe GAInterval::IntervalBlade do include GAInterval let(:e1) { Multivector.e(1) } let(:e2) { Multivector.e(2) } # Interpolate between two multivectors by scalar t ∈ [0,1] def lerp(a, b, t) = a.scale(1-t) + b.scale(t) it 'geometric product interval encloses all sampled products' do a_lo = e1.scale(1.0) a_hi = e1.scale(3.0) b_lo = e2.scale(0.5) b_hi = e2.scale(2.0) ib_a = IntervalBlade.new(a_lo, a_hi) ib_b = IntervalBlade.new(b_lo, b_hi) product_interval = ib_a * ib_b 100.times do ta, tb = rand, rand a = lerp(a_lo, a_hi, ta) b = lerp(b_lo, b_hi, tb) true_product = a * b true_product.coeffs.each do |mask, v| lo_c = product_interval.lo.coeffs[mask] || 0 hi_c = product_interval.hi.coeffs[mask] || 0 expect(v).to be >= lo_c - 1e-12 expect(v).to be <= hi_c + 1e-12 end end end it 'detects interval blades containing null' do zero_mv = Multivector.zero nonzero = e1.scale(1.0) ib = IntervalBlade.new(zero_mv, nonzero) expect(ib.contains_null?).to be true end it 'midpoint is within the interval' do lo_mv = e1.scale(1.0) + e2.scale(2.0) hi_mv = e1.scale(3.0) + e2.scale(4.0) ib = IntervalBlade.new(lo_mv, hi_mv) mid = ib.midpoint mid.coeffs.each do |mask, v| expect(v).to be >= (ib.lo.coeffs[mask] || 0) - 1e-12 expect(v).to be <= (ib.hi.coeffs[mask] || 0) + 1e-12 end end end

7.6 ODE Integrator Convergence Spec

RSpec RSpec.describe GAInterval::ODEIntegrator do include GAInterval let(:e1) { Multivector.e(1) } let(:e2) { Multivector.e(2) } let(:I) { e1 ^ e2 } describe 'rotor_exponential for harmonic oscillator' do it 'returns psi to initial state after full period (exact frequency)' do omega = 1.0 A = IntervalBlade.point(I.scale(omega)) psi0 = IntervalBlade.point(e1) ode = ODEIntegrator.new(operator_interval: A, psi0: psi0, t_end: 2*Math::PI, steps: 1000) traj = ode.rotor_exponential final = traj.last[:psi].midpoint expect(final.coeffs[0b01] || 0).to be_within(1e-8).of(1.0) # e1 back to 1 expect(final.coeffs[0b10] || 0).to be_within(1e-8).of(0.0) # e2 back to 0 end it 'phase spread grows linearly with time for uncertain frequency' do delta_omega = 0.2 A = IntervalBlade.new(I.scale(1.0 - delta_omega/2), I.scale(1.0 + delta_omega/2)) psi0 = IntervalBlade.point(e1) ode = ODEIntegrator.new(operator_interval: A, psi0: psi0, t_end: Math::PI, steps: 500) traj = ode.rotor_exponential # Width at t=π should be ≈ delta_omega * π (Ch. V §5.6) expected_width = delta_omega * Math::PI actual_width = traj.last[:psi].width expect(actual_width).to be_within(0.05).of(expected_width) end it 'identifies neutral stability for pure bivector operator' do A = IntervalBlade.point(I.scale(1.5)) ode = ODEIntegrator.new(operator_interval: A, psi0: IntervalBlade.point(e1), t_end: 1.0) expect(ode.stability).to eq(:neutral) end end end

7.7 BisectionRayCaster Spec

RSpec RSpec.describe GAInterval::BisectionRayCaster do include GAInterval let(:e1) { Multivector.e(1, dims: 2) } let(:e2) { Multivector.e(2, dims: 2) } # Unit circle centred at origin: |p|² - 1 = 0 let(:sphere) { ->(p) { p.norm_squared - 1.0 } } subject(:caster) do BisectionRayCaster.new(surface_fn: sphere, dims: 2, tolerance: 1e-9) end it 'finds the intersection of a ray along +e2 with the unit circle' do hit = caster.cast(origin: e2.scale(-2.0), direction: e2, t_near: 0.5, t_far: 3.0) expect(hit).not_to be_nil expect(hit[:t]).to be_within(1e-7).of(1.0) end it 'returns nil when ray misses the surface' do # Ray parallel to surface, well outside hit = caster.cast(origin: e1.scale(5.0), direction: e2, t_near: 0.0, t_far: 10.0) expect(hit).to be_nil end it 'converges in O(log(range/tol)) iterations' do hit = caster.cast(origin: e2.scale(-2.0), direction: e2, t_near: 0.1, t_far: 3.0) range = 3.0 - 0.1 tol = 1e-9 max_iters = (Math.log2(range / tol)).ceil expect(hit[:iterations]).to be <= max_iters end end

7.8 Custom RSpec Matchers

A small spec_helper defines the contain_value matcher used throughout the interval specs, and the be_within_interval matcher for blade tests.

RSpec — spec/spec_helper.rb require 'ga_interval' RSpec::Matchers.define :contain_value do |x| match do |interval| interval.lo <= x + 1e-12 && x - 1e-12 <= interval.hi end failure_message do |interval| "expected #{interval} to contain #{x}, " \ "but #{x} is #{[interval.lo - x, x - interval.hi].max.round(6)} outside" end end RSpec::Matchers.define :be_grade do |k| match { |mv| mv.pure_grade?(k) } failure_message { |mv| "expected grade #{k}, got grades #{mv.grades}" } end RSpec.configure do |config| config.order = :random config.formatter = :documentation end

7.9 Expected Test Output

Running bundle exec rspec from the project root should produce:

GAInterval::Interval #+ ✓ satisfies the containment property #* ✓ satisfies the containment property ✓ handles mixed-sign intervals correctly #/ ✓ raises DivisionByZeroInterval when divisor contains 0 ✓ divides correctly when divisor excludes 0 #sin and #cos ✓ encloses all sine values in the interval ✓ returns [-1,1] for a full-period interval GAInterval::Multivector basis vector products ✓ satisfies eᵢ² = 1 (Euclidean signature) ✓ satisfies eᵢeⱼ = -eⱼeᵢ for i≠j (anticommutativity) ✓ satisfies I² = -1 for 3D pseudoscalar geometric product decomposition ✓ ab = a·b + a∧b for vectors ✓ outer product is grade-raising ✓ outer product of parallel vectors is zero #inverse ✓ satisfies v * v⁻¹ = 1 for non-null vectors ✓ raises NullBladeError for zero blade #dual ✓ (A*)* = ±A (double dual is identity up to sign) GAInterval::Rotor ✓ rotates e1 to e2 by π/2 ✓ preserves vector magnitude under rotation ✓ composes rotations correctly: R(α+β) = R(α)R(β) ✓ has unit norm (RR̃ = 1) GAInterval::IntervalBlade ✓ geometric product interval encloses all sampled products ✓ detects interval blades containing null ✓ midpoint is within the interval GAInterval::ODEIntegrator rotor_exponential for harmonic oscillator ✓ returns psi to initial state after full period (exact frequency) ✓ phase spread grows linearly with time for uncertain frequency ✓ identifies neutral stability for pure bivector operator GAInterval::BisectionRayCaster ✓ finds the intersection of a ray along +e2 with the unit circle ✓ returns nil when ray misses the surface ✓ converges in O(log(range/tol)) iterations Finished in 0.4 seconds (files took 0.3 seconds to load) 27 examples, 0 failures

References

  1. Claessen, K., & Hughes, J. (2000). QuickCheck: a lightweight tool for random testing of Haskell programs. ACM SIGPLAN Notices 35(9), 268–279. (Property-based testing methodology adapted here for Ruby/RSpec.)
  2. Chelimsky, D., et al. (2010). The RSpec Book. Pragmatic Bookshelf.
  3. Moore, R. E., Kearfott, R. B., & Cloud, M. J. (2009). Introduction to Interval Analysis. SIAM. §3 — Containment property and its testing.
  4. Nedialkov, N. S., Jackson, K. R., & Corliss, G. F. (1999). Validated solutions of initial value problems for ordinary differential equations. Applied Mathematics and Computation 105(1), 21–68.