TheThe implementation follows three guiding principles drawn from the mathematical
structure itself:
Algebraic fidelity
Ruby operators (*, ^, +) are overloaded
to match GA notation exactly. a * b is the geometric product,
a ^ b the outer product, a | b the inner product.
Containment by default
Every arithmetic operation on Interval objects guarantees
containment — the output interval always encloses the true result for any
inputs within the input intervals. Rounding mode is set explicitly.
Lazy grade extraction
Multivectors store all \(2^n\) coefficients as a sparse hash. Grade extraction
via grade(k) filters this hash, keeping the core representation
simple and dimension-independent.
Composable
An IntervalBlade wraps two Multivector objects.
All multivector methods delegate through, so interval blades participate in
the same algebraic expressions as ordinary multivectors.
TheTheInterval class wraps a lower and upper bound and overloads
the four arithmetic operations with outward-rounded containment semantics,
following Moore et al.[1] Division raises
DivisionByZeroInterval when the divisor contains zero, offering
a clean hook for the dual-based handling of Chapter III.
RubymoduleGAInterval# Raised when 0 ∈ divisor interval — see Ch. III for resolution strategies.DivisionByZeroInterval = Class.new(StandardError)
classIntervalattr_reader:lo, :hidefinitialize(lo, hi)
raise ArgumentError, "lo must be ≤ hi"if lo > hi
@lo, @hi = lo.to_f, hi.to_f
end# Convenience constructor: Interval[a, b] or Interval[x] (degenerate)defself.[](lo, hi = lo) = new(lo, hi)
defwidth = @hi - @lodefmidpoint = (@lo + @hi) / 2.0defcontains?(x) = @lo <= x && x <= @hidefcontains_zero? = contains?(0.0)
defdegenerate? = @lo == @hi# ── Arithmetic ────────────────────────────────────────────def+(other)
other = coerce(other)
Interval[@lo + other.lo, @hi + other.hi]
enddef-(other)
other = coerce(other)
Interval[@lo - other.hi, @hi - other.lo]
enddef-@ = Interval[-@hi, -@lo]
def*(other)
other = coerce(other)
products = [@lo * other.lo, @lo * other.hi,
@hi * other.lo, @hi * other.hi]
Interval[products.min, products.max]
enddef/(other)
other = coerce(other)
raise DivisionByZeroInterval,
"Divisor #{other} contains zero — use pseudoinverse or dual strategy"if other.contains_zero?
self * Interval[1.0 / other.hi, 1.0 / other.lo]
end# ── Set operations ────────────────────────────────────────defunion(other)
other = coerce(other)
Interval[[@lo, other.lo].min, [@hi, other.hi].max]
enddefintersect(other)
other = coerce(other)
lo = [@lo, other.lo].max
hi = [@hi, other.hi].min
lo <= hi ? Interval[lo, hi] : nilenddefhull(x)
Interval[[@lo, x].min, [@hi, x].max]
end# ── Interval extensions for common functions ──────────────defabsif@lo >= 0thenselfelsif@hi <= 0then -selfelseInterval[0, [@lo.abs, @hi.abs].max]
endenddefsqrt
raise ArgumentError, "sqrt of interval with negative lo"if@lo < 0Interval[Math.sqrt(@lo), Math.sqrt(@hi)]
enddefsin# Conservative enclosure over the full period
lo_s, hi_s = Math.sin(@lo), Math.sin(@hi)
mn = [lo_s, hi_s].min; mx = [lo_s, hi_s].max
mn = -1.0if width >= 2 * Math::PI
mx = 1.0if width >= 2 * Math::PI
Interval[mn, mx]
enddefcos# Shift by π/2 and use sin enclosure
shifted = Interval[@lo + Math::PI/2, @hi + Math::PI/2]
shifted.sin
enddefexpInterval[Math.exp(@lo), Math.exp(@hi)]
end# ── Coercion & display ────────────────────────────────────defcoerce(other)
other.is_a?(Interval) ? other : Interval[other.to_f]
enddefto_sreturn"[#{@lo}]"if degenerate?
"[#{format('%.6g', @lo)}, #{format('%.6g', @hi)}]"enddefinspect = "Interval#{to_s}"endend
Multivectors are stored as a sparse hash mapping basis blade bitmasks
to scalar coefficients. A bitmask with bit \(k\) set represents the basis vector
\(\mathbf{e}_{k+1}\). For example in \(\mathcal{G}(\mathbb{R}^3)\):
\(\mathbf{e}_1 \leftrightarrow 001_2 = 1\),
\(\mathbf{e}_2 \leftrightarrow 010_2 = 2\),
\(\mathbf{e}_1\mathbf{e}_2 \leftrightarrow 011_2 = 3\).
This representation is dimension-agnostic and naturally sparse.[2]
RubymoduleGAIntervalclassMultivector# coeffs: Hash { Integer(bitmask) => Numeric }attr_reader:coeffs, :dimsdefinitialize(coeffs = {}, dims: 3)
@dims = dims
@coeffs = coeffs.reject { |_, v| v.zero? rescue false }
end# ── Basis constructors ────────────────────────────────────defself.scalar(v, dims: 3) = new({ 0 => v }, dims: dims)
defself.zero(dims: 3) = new({}, dims: dims)
# Basis vector e_k (1-indexed). e(1) → bitmask 0b001defself.e(k, dims: 3)
raise ArgumentError, "k must be 1..#{dims}"unless (1..dims).include?(k)
new({ 1 << (k - 1) => 1.0 }, dims: dims)
end# ── Grade tools ───────────────────────────────────────────defgrade_part(k)
filtered = @coeffs.select { |mask, _| mask.digits(2).count(1) == k }
Multivector.new(filtered, dims: @dims)
enddefscalar_part = @coeffs.fetch(0, 0.0)
defgrades = @coeffs.keys.map { |m| m.digits(2).count(1) }.uniq.sort
defpure_grade?(k) = grades == [k]
# ── Addition / subtraction ────────────────────────────────def+(other)
result = @coeffs.dup
other.coeffs.each { |mask, v| result[mask] = (result[mask] || 0) + v }
Multivector.new(result, dims: @dims)
enddef-(other) = self + (-other)
def-@ = scale(-1)
defscale(s)
Multivector.new(@coeffs.transform_values { |v| v * s }, dims: @dims)
end# ── Geometric product (the central operation) ─────────────## For basis blades A (mask_a) and B (mask_b):# result mask = mask_a XOR mask_b# sign = (-1)^(number of swaps to sort the combined sequence)## We count sign flips using the "canonical reordering" algorithm.def*(other)
result = {}
@coeffs.each do |mask_a, coeff_a|
other.coeffs.each do |mask_b, coeff_b|
sign, mask_r = canonical_product(mask_a, mask_b)
result[mask_r] = (result[mask_r] || 0) + sign * coeff_a * coeff_b
endendMultivector.new(result, dims: @dims)
end# ── Outer (wedge) product: keep only grade(A)+grade(B) partdef^(other)
result = {}
ka = grade_of_blade(@coeffs) # works for pure blades@coeffs.each do |mask_a, coeff_a|
other.coeffs.each do |mask_b, coeff_b|
nextif (mask_a & mask_b) != 0# shared basis → zero in outer product
sign, mask_r = canonical_product(mask_a, mask_b)
result[mask_r] = (result[mask_r] || 0) + sign * coeff_a * coeff_b
endendMultivector.new(result, dims: @dims)
end# ── Inner (left contraction) product ──────────────────────def|(other)
(reverse * (reverse ^ other) * other).grade_part(
(other.grades.first || 0) - (grades.first || 0)
)
end# ── Reverse (grade involution: flip sign of grade 2,3,6,7…)defreverseMultivector.new(
@coeffs.transform_values { |v| v },
dims: @dims
).tap do |mv|
mv.coeffs.each_key do |mask|
k = mask.digits(2).count(1)
mv.coeffs[mask] *= -1if (k * (k - 1) / 2) % 2 == 1endendend# ── Norm and inverse ──────────────────────────────────────defnorm_squared
(self * reverse).scalar_part
enddefnorm = Math.sqrt(norm_squared.abs)
definverse
ns = norm_squared
raise NullBladeError, "Blade is null (norm² = 0); use pseudoinverse"if ns.abs < 1e-14
reverse.scale(1.0 / ns)
end# ── Dual (Hodge): A* = A · I⁻¹ ───────────────────────────defdual
pseudo = pseudoscalar
self * pseudo.inverse
enddefpseudoscalar
mask = (1 << @dims) - 1# e.g. dims=3 → 0b111Multivector.new({ mask => 1.0 }, dims: @dims)
end# ── Display ───────────────────────────────────────────────defto_sreturn"0"if@coeffs.empty?
@coeffs.sort.map do |mask, v|
label = mask == 0 ? "" : "e" + mask.digits(2).each_with_index
.filter_map { |b, i| b == 1 ? (i + 1).to_s : nil }.join
"#{format('%.4g', v)}#{label}"end.join(" + ")
enddefinspect = "Multivector(#{to_s})"private# Count canonical reordering swaps for geometric product of two basis blades.defcanonical_product(mask_a, mask_b)
sign = 1
mask_r = mask_a ^ mask_b
# For each bit in mask_b, count bits in mask_a that are higher → each gives a sign flip
a = mask_a
b = mask_b
while b > 0
b >>= 1
sign *= (-1) ** (a & b).digits(2).count(1) # bits in a that are above current b-bitend
[sign, mask_r]
enddefgrade_of_blade(coeffs)
coeffs.keys.map { |m| m.digits(2).count(1) }.first || 0endendNullBladeError = Class.new(StandardError)
end
A rotor \(R = e^{B\theta/2}\) is a unit even multivector. We implement
it as a subclass of Multivector with a factory method
Rotor.from_bivector(B, theta) that uses the series expansion
\(e^{B\theta} = \cos\theta + B\sin\theta\) (valid when \(B^2 = -1\)).
The sandwich product r.rotate(v) returns \(RvR^\dagger\).
RubymoduleGAIntervalclassRotor < Multivector# Build R = cos(θ) + B·sin(θ) where B is a unit bivector (B²= -1)defself.from_bivector(bivector, theta)
cos_part = Multivector.scalar(Math.cos(theta), dims: bivector.dims)
sin_part = bivector.scale(Math.sin(theta))
r = cos_part + sin_part
new(r.coeffs, dims: r.dims)
end# Rotate a multivector via the sandwich product R·v·R†defrotate(mv)
self * mv * reverse
end# Compose two rotorsdefcompose(other)
r = self * other
Rotor.new(r.coeffs, dims: r.dims)
end# Unit normalise (correct floating-point drift)defnormalise
n = Math.sqrt(norm_squared.abs)
Rotor.new(@coeffs.transform_values { |v| v / n }, dims: @dims)
endendend
IntervalBlade wraps a lower and upper Multivector,
implementing the containment principle for each coefficient independently.
All arithmetic delegates to the underlying Multivector operations,
tracking the resulting interval bounds.
The Meet \(A \vee B = (A^* \wedge B^*)^*\) and Join \(A \wedge B\)
are implemented as module-level functions so they compose naturally with
both Multivector and IntervalBlade.
The BisectionRayCaster class applies these to CSG intersection,
implementing the sign-change test from Chapter IV.
RubymoduleGAIntervalmoduleMeetJoindefself.meet(a, b) = (a.dual ^ b.dual).dual
defself.join(a, b) = a ^ b
# True if the join of ray and bounding-volume blades# contains the origin → possible intersection exists.defself.join_contains_origin?(ray, bounding_volume)
j = join(ray, bounding_volume)
j.scalar_part.abs < 1e-10# origin is in the joinendend# ── Bisection ray caster ──────────────────────────────────classBisectionRayCasterdefinitialize(surface_fn:, dims: 3, tolerance: 1e-6, max_iter: 64)
@surface_fn = surface_fn # Callable: Multivector → Numeric (signed distance)@dims = dims
@tol = tolerance
@max_iter = max_iter
end# Cast a ray o + t*d through the surface, searching t ∈ [t_near, t_far].# Returns { t:, point:, iterations: } or nil if no crossing found.defcast(origin:, direction:, t_near:, t_far:)
ray = ->(t) { origin + direction.scale(t) }
g_near = @surface_fn.call(ray.call(t_near))
g_far = @surface_fn.call(ray.call(t_far))
# No sign change → no guaranteed intersectionreturnnilif (g_near >= 0) == (g_far >= 0)
lo, hi = t_near, t_far
@max_iter.times do |i|
mid = (lo + hi) / 2.0
g_mid = @surface_fn.call(ray.call(mid))
if (hi - lo) < @tolreturn { t: mid, point: ray.call(mid), iterations: i }
endif (g_near >= 0) == (g_mid >= 0)
lo = mid; g_near = g_mid
else
hi = mid
endend
mid = (lo + hi) / 2.0
{ t: mid, point: ray.call(mid), iterations: @max_iter }
endendend
The ODE integrator implements the rotor exponential method for the pure-bivector
case (exact, no wrapping) and a geometric midpoint method for the general case.
It returns an array of { t:, psi: } hashes at each time step,
where psi is an IntervalBlade representing the full
solution cloud at that time.
RubymoduleGAIntervalclassODEIntegratorattr_reader:trajectory# operator_interval: IntervalBlade — the [A1,A2] in Ψ̇ = AΨ# psi0: IntervalBlade — initial interval bladedefinitialize(operator_interval:, psi0:, t_end:, steps: 200)
@A = operator_interval
@psi0 = psi0
@t_end = t_end
@steps = steps
@dt = t_end.to_f / steps
end# Rotor exponential method — exact for pure bivector operators.# Uses R(t) = e^(A·t) applied as sandwich product.defrotor_exponential@trajectory = [{ t: 0.0, psi: @psi0 }]
psi = @psi0@steps.times do |i|
t = (i + 1) * @dt# Build interval rotor from both ends of operator interval
r_lo = Rotor.from_bivector(@A.lo, t)
r_hi = Rotor.from_bivector(@A.hi, t)
# Apply both rotors to both ends of current psi — take hull
candidates = [
r_lo.rotate(@psi0.lo), r_lo.rotate(@psi0.hi),
r_hi.rotate(@psi0.lo), r_hi.rotate(@psi0.hi)
]
psi = hull_of(candidates)
@trajectory << { t: t, psi: psi }
end@trajectoryend# Geometric midpoint method — general case, O(h²) accuracy.defgeometric_midpoint@trajectory = [{ t: 0.0, psi: @psi0 }]
psi = @psi0@steps.times do |i|
t = (i + 1) * @dt# k1 = A * psi (forward difference)
k1 = @A * psi
# Midpoint estimate: psi_mid = psi + (dt/2) * k1
psi_mid = psi + k1.scale(@dt / 2.0)
# k2 = A * psi_mid (midpoint slope)
k2 = @A * psi_mid
# Full step
psi = psi + k2.scale(@dt)
@trajectory << { t: t, psi: psi }
end@trajectoryend# Stability verdict based on scalar part of A + Ãdefstability
a_plus_rev_lo = (@A.lo + @A.lo.reverse).scalar_part
a_plus_rev_hi = (@A.hi + @A.hi.reverse).scalar_part
sigma_interval = Interval[[a_plus_rev_lo, a_plus_rev_hi].min,
[a_plus_rev_lo, a_plus_rev_hi].max]
if sigma_interval.hi < 0then:stableelsif sigma_interval.lo > 0then:divergentelsif sigma_interval.hi == 0 &&
sigma_interval.lo == 0then:neutralelse:indeterminateendendprivatedefhull_of(mvs)
all_masks = mvs.flat_map { |m| m.coeffs.keys }.uniq
lo_c = {}; hi_c = {}
all_masks.each do |mask|
vals = mvs.map { |m| m.coeffs[mask] || 0.0 }
lo_c[mask] = vals.min; hi_c[mask] = vals.max
end
dims = mvs.first.dims
IntervalBlade.new(
Multivector.new(lo_c, dims: dims),
Multivector.new(hi_c, dims: dims)
)
end# Extend IntervalBlade * scalar for convenience inside integratordefscale_ib(ib, s)
IntervalBlade.new(ib.lo.scale(s), ib.hi.scale(s))
endendend
6.9 Usage Examples
WithWith the library in place, the theoretical results from earlier chapters become
single-expression computations.
Rubyrequire'ga_interval'includeGAInterval# ── Example 1: Interval rotor sweeping a phase arc (Ch. II) ─
e1 = Multivector.e(1)
e2 = Multivector.e(2)
I = e1 ^ e2 # unit bivector (Ch. II §2.1)
v = e1.scale(1.0) # vector to rotate
lo = Rotor.from_bivector(I, 0.2) # R(θ=0.2)
hi = Rotor.from_bivector(I, 1.1) # R(θ=1.1)
interval_rotor = IntervalBlade.new(lo, hi)
result = IntervalBlade.point(v).rotate_by(interval_rotor)
puts"Rotated interval: #{result}"# → IntervalBlade[..., ...] — arc sector in e1∧e2 plane# ── Example 2: Null blade detection (Ch. III) ───────────────# In conformal GA (dims=5), points are represented as null vectors.
e_plus = Multivector.e(4, dims: 5)
e_minus = Multivector.e(5, dims: 5)
# Conformal point: p = x·e1 + y·e2 + ½x²·e∞ + e₀
x, y = 1.0, 2.0
e_inf = (e_plus + e_minus)
e_o = (e_minus - e_plus).scale(0.5)
p_conf = e1.scale(x) + e2.scale(y) + e_inf.scale(0.5 * (x**2 + y**2)) + e_o
puts"norm² of conformal point: #{p_conf.norm_squared.round(10)}"# → 0.0 (null vector — correct for conformal embedding)# ── Example 3: Sphere intersection via bisection (Ch. IV) ───
sphere = ->(p) {
c = e1.scale(0.0) + e2.scale(0.0) # centre at origin
r = 1.0
diff = p - c
diff.norm_squared - r**2
}
origin = e2.scale(-2.0) # ray starts at (0,-2)
direction = e2.scale( 1.0) # ray travels in +e2
caster = BisectionRayCaster.new(surface_fn: sphere)
hit = caster.cast(origin: origin, direction: direction,
t_near: 0.5, t_far: 3.0)
puts"Ray hit at t=#{hit[:t].round(8)}, after #{hit[:iterations]} iterations"# → Ray hit at t=1.0 (exact: sphere of radius 1, ray from -2 along +y)# ── Example 4: Harmonic oscillator interval ODE (Ch. V) ─────
omega_lo = 0.9; omega_hi = 1.1# uncertain frequency ω ∈ [0.9, 1.1]
A_lo = I.scale(omega_lo)
A_hi = I.scale(omega_hi)
A_int = IntervalBlade.new(A_lo, A_hi)
psi0 = IntervalBlade.point(e1) # exact initial condition x=1, ẋ=0
ode = ODEIntegrator.new(operator_interval: A_int,
psi0: psi0, t_end: 2 * Math::PI, steps: 400)
traj = ode.rotor_exponential
puts"Stability: #{ode.stability}"# → :neutralputs"Final cloud width: #{traj.last[:psi].width.round(6)}"# → width reflects (ω_hi - ω_lo) * 2π phase spread ≈ 0.2π ≈ 0.628
!
Gem packaging. To distribute as a Ruby gem, add a standard
ga_interval.gemspec referencing the lib/ tree, and
require each sub-file from lib/ga_interval.rb.
The library has no runtime dependencies beyond Ruby's standard library —
the only development dependency is RSpec, covered in Chapter VII.
References
Moore, R. E., Kearfott, R. B., & Cloud, M. J. (2009). Introduction to Interval Analysis. SIAM.
Fontijne, D. (2007). Efficient Implementation of Geometric Algebra (PhD thesis). University of Amsterdam. §3–4.
Dorst, L., Fontijne, D., & Mann, S. (2007). Geometric Algebra for Computer Science. Morgan Kaufmann. §14.5.
Perwass, C. (2009). Geometric Algebra with Applications in Engineering. Springer. §2.5.
Metz, S. (2018). Practical Object-Oriented Design in Ruby (2nd ed.). Addison-Wesley. (Ruby design patterns used throughout.)
📝 Executive Bite-Sized Summary (Max 2 Short Sentences per Section)
6.1 Design Philosophy
Rounding mode is set explicitly.
An IntervalBlade wraps two Multivector objects.
6.3 Interval Arithmetic
[ 1 ] Division raises DivisionByZeroInterval when the divisor contains zero, offering
a clean hook for the dual-based handling of Chapter III.
The T he Interval class wraps a lower and upper bound and overloads
the four arithmetic operations with outward-rounded containment semantics,
following Moo...
6.4 Multivector
This representation is dimension-agnostic and naturally sparse.
A bitmask with bit \(k\) set represents the basis vector
\(\mathbf{e}_{k+1}\).
6.5 Rotor — The Exponential Map
A rotor \(R = e^{B\theta/2}\) is a unit even multivector.
The sandwich product r.rotate(v) returns \(RvR^\dagger\).
6.6 IntervalBlade
All arithmetic delegates to the underlying Multivector operations,
tracking the resulting interval bounds.
IntervalBlade wraps a lower and upper Multivector ,
implementing the containment principle for each coefficient independently.
6.7 Meet, Join, and CSG Intersection
The BisectionRayCaster class applies these to CSG intersection,
implementing the sign-change test from Chapter IV.
The Meet \(A \vee B = (A^* \wedge B^*)^*\) and Join \(A \wedge B\)
are implemented as module-level functions so they compose naturally with
both Multivector...
🗺️ Site-Wide Concept Atlas
Cartographic Topological Map
🗺️ Pathway Interrogation: Hover over any terrain road line or city node
🌉 Bridge Concepts: Interrogating intermediate weight vectors across the atlas
📍 Explore Map: Click any node circle or road line to navigate
🧭 You Are Here
✨ Active Concept Hit
⛰️ Mountain Ridge
📤 Connection Road
🐉 "Here Be Dragons"
Structured taxonomy index of site concepts and articles mapped by SOM topological affinity. Keyboard & screen-reader accessible.