Skip to content

About

Symbolica implementation of OneLOop scalar master integrals

Resources

Stars

0 stars

Watchers

0 watching

Forks

Latest commit

 

History

117 Commits

Folders and files

NameName
Last commit message
Last commit date
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

Native OneLOop expressions

An independent Rust library built on Symbolica. It does not link the Fortran wrapper or the standalone numerical Rust implementation. All integral algorithms are exact symbolic expressions; continuation definitions are transparent FunctionMap entries. The same expression graph also generates inspectable generic Rust arithmetic for direct numerical evaluation. Public master-Symbol hooks interpret those expressions by default, or use the generated Rust backend when generated-evaluators is enabled.

Development status: the expanded audit passes at 256 bits, but retains 14 fixed-binary64 B0/dB0 disagreements. The measured 1.5×-Fortran performance target is also not met across all workloads. See the native release audit and performance report. Full scalar parity is not established; this is not publication-ready. The 2026-10-17 report measures the current binary64 backend: all mixed scalar workloads, the gg → HHH top-loop pentagon (1.15–1.24× per point), the gg → HH box and the H → gg triangle are within 1.3× of OneLOop Fortran; the γγ → γγ top box is not (1.6–1.7× per diagram: its massless-leg D0 about 2.1×, C0 1.8×), and single real timelike D0 rows remain about 2.2×. The four-mass real-mass D0 rows with s12 · s23 < 0 that had a wrong real part are fixed in the expressions (tests/native_d0_four_mass_defect.rs), together with further continuation defects found by a OneLOop sweep (tests/oracle_sweep.py, tests/permutation_regressions.rs). Two of them are OneLOop 3.6's own: the sign of its zero-momentum B1111 when the second mass is the heavier one, and real-mass contour boxes whose value differs from OneLOop's vanishing-width limit (tests/data/real_mass_limits.txt); the expressions follow the limit. OneLOop's one-mass box with a complex mass (boxf1) is wrong too, at any width, in 131 of 3000 random such boxes (by 0.3 to 7 of the value; one row of the regression data by a factor of 6400): it takes the logarithms of roots normalized by the complex sqrt(m²), which can turn them across the cut. The expressions take Denner and Dittmaier's sheets there (arXiv:1005.2076, eq. (3.82)), checked against COLLIER in quadruple precision and a direct Feynman-parameter integral (tests/d0_one_mass_width.rs, tests/data/one_mass_width.txt). Its two-mass boxes with complex masses (boxf2, and boxf5 for opposite masses) are wrong in the same way, in 0.4% to 2.8% of random such boxes depending on how they are sampled (STATUS.md; by 0.6 to 17 of the value; next to a collinear limit by up to 9e4), also with one real mass and with |Im m²| down to 1e-9 of Re m²; the expressions take Denner and Dittmaier's two-mass sheets there (eqs. (3.78)-(3.80), tests/d0_two_mass_width.rs, tests/data/two_mass_width.txt). Where both massive lines sit at or next to their shells, two of those dilogarithm arguments approach one; their complements come from the channels' complements, since formed from the rounded arguments they lost up to 0.16 of the value in binary64 (tests/data/two_mass_shell.txt). Next to a collinear limit the one- and two-mass terms take the root nearer the branch cut across it, and a small mass-pair complement from the roots' offsets (one mass) or, in binary64, from the product (two masses): the boxes of tests/data/two_mass_conditioning.txt, which lost 1.2e-11 to 1.6e-9 in binary64, keep at most 5.7e-14 in every relabelling, and the one-mass boxes of tests/d0_one_mass_width.rs 1e-12. Dilogarithm divided differences of nearly coincident or small arguments are evaluated term by term, as OneLOop's dilog2_c (a two-mass C0 with nearly coincident roots was off by 2.4e-13 in binary64, now 2e-15 to 9e-15 on every route: tests/c0_coincident_roots.rs), and the contour box no longer loses a tiny invariant to cancellation in Float (tests/d0_tiny_invariant.rs). The two dilogarithms reach that choice as arguments of a registered function: Symbolica's evaluator calls a registered function anew at every occurrence of its call, and with the dilogarithm calls repeated inside the cancellation test the interpreted D0 and C0 took twice as long at 700 bits (tests/expression_construction.rs). Massless, one- and two-adjacent-mass boxes next to a collinear limit (a small invariant between two massless lines) take 1 - x of their dilogarithm arguments next to one from the kinematics: binary64 keeps 2e-13 down to invariants of 1e-14, where it lost up to O(1) as OneLOop does (tests/d0_collinear_limits.rs). Real-mass three-mass boxes use the contour representation wherever it supports the kinematics: OneLOop's real-mass boxf33 chart, which it takes outside timelike configurations, depends on the labelling, with a wrong real or imaginary part in some relabellings of about a quarter of three-mass boxes, OneLOop 3.6 included (tests/d0_real_mass_orderings.rs). Four-mass boxes keep OneLOop's selection, but its boxf4 chart (Denner-Nierste-Scharf), wrong in about 2% of the relabellings of random real four-mass boxes (OneLOop 3.6 included), is now Denner and Dittmaier's form of it (arXiv:1005.2076, eq. (3.61)): an invariant below its pseudo-threshold in the p23 slot, its r-function exactly real, x r13 principal with the explicit eta(x, r13) Log R term, and exact infinitesimal decisions (tests/d0_four_mass_orderings.rs). The continued dilogarithm's unit-circle test signed 1 - x conj(x) without taking its real part: in Float a rounding-level imaginary part made it invert small arguments (a 4 pi² jump on higher windings, seen at 128 to 256 bits in four-mass boxes above all their pseudo-thresholds); it now signs the real part. The contour box's edge quadratics take their discriminants as exact Källén functions and their roots at massless ends exactly, so a pseudo-threshold next to a massless line keeps 1e-14 in binary64 on both sides instead of losing 8 to 10 digits at it (or 7 next to it), also within an ulp of it (tests/d0_contour_thresholds.rs). Next to a vanishing Gram determinant (two external momenta nearly parallel, their invariant next to its threshold, as for two Higgs bosons nearly at rest relative to each other in gg → HHH) the contour box's line edges take the offset of a pole from their root from Vieta's product with the poles' own g + beta h: the NinjaRS gg → HHH box is now within 3.4e-14 on every binary64 route (3.6e-11 before), and of 200 random such boxes all but one are within 3.9e-12, where 34 were beyond 5e-12 and 22 beyond 1e-9 (tests/d0_vanishing_gram.rs). With the Gram determinant at the rounding level, a contour whose K rounds to zero takes its far pole 2^52 beyond the near one, and where the selected contour's g + beta h keeps fewer than 24 bits the other contour's chart is evaluated too and taken unless the two agree to 2^-26: nine such boxes, wrong by up to 140 times their value or non-finite at 09b2c74, keep 1.6e-11 (one of them 2.2e-9, SymJIT 4.9e-9) in every relabelling (tests/d0_contour_regressions.rs). Where no chart is regular, binary64 returns NaN unless the chart it uses keeps 10 bits when the line moves by one rounding unit (a flat chart) or 13 bits with its K at the rounding level (a K that rounds to zero), so that the boxes of tests/data/contour_unresolved.txt are non-finite, as at 09b2c74, rather than silently wrong (f2adce4: up to 1.9); DoubleFloat takes binary64's charts and Float the selected one, untested. Where the other chart's K is a rounding residue, the selected chart is kept if setting that K to its rounding level moves the other chart more than 2^10 times as much as a one-unit move of the line moves the selected chart, and if the selected chart keeps 20 bits under that line move (NaN otherwise); and the zero-external reflection is not taken into a chart whose flat contour has no regular alternative (tests/data/contour_fresh_sweep.txt). These hold on the measured sets only (STATUS.md). On the second verifier's 36,840-row sweep, 12 relabellings of one box still decide differently by route (finite and wrong by 2.9e-2 hand-written in 8 and 2.8e-2 generated in 4, NaN elsewhere, as wrong at 09b2c74), and 25 binary64 route entries on 12 rows are more than ten times worse than 09b2c74 (20 finite entries, none wrong by more than 1e-6, and 5 NaN). Two adversarial sweeps of 42,560 and 21,280 rows have far fewer wrong or non-finite values than 09b2c74 (most of the gain is on their soft boxes), but 18 and 3 of their binary64 boxes, outside the input-conditioning classes J and J* of STATUS.md (23 and 5 with them), are finite and wrong by more than 1e-6 where 09b2c74 was right or NaN (by up to 0.36), and so are 126 and 69 of their Float rows, usually at 1 to 4 tracked bits. STATUS.md lists them, with the counts per route and the follow-ups, as open work. The product is taken only where g + beta h itself cancels: where the conic nearly vanishes at the line's intersection with an edge, the line's and the conic's terms keep y - z from the same pole (three-mass boxes lost up to 3.9e-10 otherwise). Next to a corner of the Feynman-parameter triangle, a box with a zero external invariant and a massless line takes the labelling that puts the corner at the origin (unless that labelling's chart has a double contour direction), and the contour box's dilogarithms take their complements 1 - x from the poles: a box with an invariant one ulp from a squared mass, wrong by up to 130 times its value, is within 7.7e-12 in every relabelling (tests/d0_contour_regressions.rs); that labelling is taken only where a pole of the original chart lies poorly resolved next to the corner. A soft-divergent box with a massless line whose leg partners are on their mass shells is found soft only on the external invariants, as in OneLOop: the numerical D0 entry points (native code in every carrier, SymJIT, the interpreted expressions and the masters' numeric callbacks) evaluate it in a labelling of the same box that keeps those on-shell pairs off the channels, so that all 24 labellings give the poles and the finite part (tests/d0_infrared_relabelling.rs; pyAmpliCol's e+ e- → t t̄ h box with lines (m, m, 0, m) was finite without its soft pole in 16 of them). Symbolic uses of the D0 expressions (OneLoopExpressions::d0, crate::d0, d0_mapped and evaluators built from them) must relabel the point with oneloop::regular_d0_labelling first. STATUS.md lists the rows of the measured sets on which this version is less accurate than 09b2c74, each accepted and explained, the remaining tail of the adversarial sweeps (counts per route, examples and open work), and the verification against COLLIER in quadruple precision.

This work is based on Andreas van Hameren's OneLOop package. The original algorithms, analytic continuation conventions and Fortran reference results are his work; see Credits and references.

Inputs are squared invariants, squared masses with Im(m²) <= 0, and a positive real squared renormalization scale. External invariants are real. Coefficients are ordered [finite, 1/epsilon, 1/epsilon²], with OneLOop normalization. This is a Laurent expansion through the finite term, not an unexpanded function of epsilon.

Positive epsilon coefficients

A0(1, m2, mu2) and B0(1, p2, m02, m12, mu2) evaluate the coefficient of epsilon. The exact constructors are a0_epsilon and b0_epsilon; ScalarIntegral::laurent_orders() reports the supported range. Existing three-coefficient APIs still return [finite, simple pole, double pole].

Both positive orders use the common 1/r_Gamma normalization, where r_Gamma = Gamma(1+eps)*Gamma(1-eps)^2/Gamma(1-2*eps). In particular, A0's positive coefficient is m2*(1-L+L^2/2+pi^2/6), with L=log(m2/mu2). Replacing 1/r_Gamma by exp(gamma_E*eps) changes that coefficient by -pi^2*m2/12.

B0 uses an exact logarithm/dilogarithm expression for pi^2/6 + integral_0^1 log((m02*(1-x)+m12*x-p2*x*(1-x)-i0)/mu2)^2 dx/2, with separate scaleless, zero-momentum, massless and coincident-root limits. The public hooks and transparent function map support binary64 and arbitrary precision. Tests compare both mass orderings against independently integrated 90-digit parameter-space fixtures, including real-axis branch cuts.

Higher positive orders, and positive orders of dB0/C0/D0, remain unsupported. Adding these coefficients does not remove the existing numerical limitations of the finite-order evaluators documented below.

Development dependency

The direct Symbolica dependency remains versioned, but the Rust and both Python build roots pin Symbolica, Numerica and Graphica to one tested integration through [patch.crates-io]. The current integration is 9cdc2949 from alphal00p/symbolica, on ninjars-pin, the branch NinjaRS pins its Symbolica from (the same commit is claude/oneloopmaster-integration-float-products of ValentinHirschi/symbolica). It is the previous integration e679e8c1 (claude/oneloopmaster-integration-dilog) plus single-rounded Float products and exact factors of ±1 from PR97. Without them, exact branch tests in arbitrary precision took wrong branches (STATUS.md). e679e8c1 is in turn 2c06f245 (codex/oneloopmaster-integration) plus the binary64 dilogarithm kernel of PR89. Cargo fetches this source automatically; no sibling checkout is required for the standalone build.

This integration combines the recursive shared-function Dual support and vector branch-scoping repairs with the native polylog-at-zero series needed for the coincident dilogarithm neighborhood. It also includes exact Dual constant-pool interning, temporary-stack reuse for shared evaluator bodies, and caching of completed conditionals within their existing branch scope. Conditional reuse does not reorder arithmetic or evaluate inactive branches. The version string 3.0.1 alone does not identify these unreleased fixes. The constructor/Dual and Rust-emitter validation must use this integration or a tested successor containing those changes. See the combined conditional-cache/C0 validation for the current checks and the shared-helper integration validation for the preceding checkpoint.

An embedding workspace controls its own [patch.crates-io] table: patches in a dependency's manifest do not propagate to the workspace root. Select compatible Symbolica, Numerica and Graphica sources together to retain one shared kernel. The consolidated validation record documents exact revisions and test results.

OneLOop uses the public registered polylog(2,z) callbacks for numerical dilogarithms, retains its own exact function definitions for inspection, and serializes numerical evaluators through Symbolica's public bincode support. The migration notes track validation and any remaining runtime limitations. Historical audits describe the older patched dependency and do not establish results for this revision.

All three manifests disable Symbolica's default features and explicitly retain tracing_max_level_info, integer-gmp, float-mpfr, native_code_generation, and bincode. This excludes faster_alloc, its optional global mimalloc allocator; the standalone extension uses Rust's system allocator. The earlier Python sequential-thread crash was in mimalloc during PyO3 argument extraction, before evaluator construction. The unchanged reproducer passes with this configuration, without a cache-lifetime or mathematical change. Cargo features are additive: another dependency enabling Symbolica's defaults or faster_alloc can re-enable mimalloc in an embedding host. Audit the resolved feature graph at the consuming build root, not just this library's manifest.

Quick start

Clone this repository as oneloopmaster. Cargo fetches the versioned dependencies and the pinned Symbolica integration described above. A recent Rust toolchain supporting edition 2024 and a working Symbolica license/setup are required (the validated toolchain is Rust 1.98.1). No Fortran compiler is needed for ordinary use or tests.

git clone https://github.com/alphal00p/oneloopmaster.git
cd oneloopmaster
cargo run --example basic

examples/basic.rs is a complete runnable example. Its core is:

use symbolica::prelude::*;

let p = parse!("p");
let mass_squared = Atom::num(1);
let mu_squared = Atom::num(1);
let bubble = oneloop::b0(&p, &mass_squared, &mass_squared, &mu_squared);
let views = bubble.coefficients().each_ref().map(Atom::as_view);
let mut evaluator = Atom::evaluator_multiple(&views, &[p])
    .build()
    .expect("compile B0")
    .map_coeff(&|c| Complex::new(c.re.to_f64(), c.im.to_f64()));
let mut output = [Complex::new(0.0, 0.0); 3];
evaluator.evaluate(&[Complex::new(-1.0, 0.0)], &mut output);
println!("[finite, simple pole, double pole] = {output:?}");

At p²=-1, m0²=m1²=mu²=1, this gives approximately [-0.1520447048200202, 1, 0], with zero imaginary parts. The example also prints the exact A0 expressions. To evaluate complex masses, make the mass arguments symbolic parameters and supply Complex::new(real_part, nonpositive_imag_part). Use complex-valued evaluation even for real inputs: timelike integrals can have an imaginary part. See the precision and stack requirements below.

The scalar entry points are a0(m², mu²), b0(p², m0², m1², mu²), db0(p², m0², m1², mu²), c0([p1²,p2²,p3²], [m0²,m1²,m2²], mu²), and d0([p1²,p2²,p3²,p4²,s12,s23], [m0²,m1²,m2²,m3²], mu²). Here s12=(p1+p2)², s23=(p2+p3)²; ordering follows the original OneLOop API. db0 differentiates B0 with respect to p², not the renormalization scale. Arguments are references to Symbolica Atoms. The routines construct the three Laurent coefficients; they do not numerically integrate Feynman parameters.

Combining integrals

a0, an, b0, db0 and bubble tensor coefficients return plain native Laurent series. c0 and d0 return MappedLaurentSeries, including the definitions needed to evaluate them. Their _mapped names are aliases. For amplitudes, construct a single OneLoopExpressions context and share its definitions:

use oneloop::OneLoopExpressions;
use symbolica::prelude::*;

let context = OneLoopExpressions::new();
let p = [parse!("p1"), parse!("p2"), parse!("p3")];
let m = [parse!("m1"), parse!("m2"), parse!("m3")];
let mu2 = parse!("mu2");
let c = context.c0([&p[0], &p[1], &p[2]], [&m[0], &m[1], &m[2]], &mu2);
let combined = c.coefficients()[0].clone() + oneloop::a0(&m[0], &mu2).coefficients()[0].clone();
let parameters = [p[0].clone(), p[1].clone(), p[2].clone(),
                  m[0].clone(), m[1].clone(), m[2].clone(), mu2];
let exact_evaluator = context.evaluator(&[combined], &parameters)?;
// Map exact coefficients with map_coeff for f64, or map_coeff_with_prec for Float.

Keep the function map when exporting expressions or building an evaluator through Symbolica directly. Compact function calls alone are not self-contained formulas. The context caches the native definitions once per process; cyclic mass sectors reuse canonical formulas instead of constructing copies.

OneLoopExpressions::dual_evaluator(expressions, parameters, dual_shape) and MappedLaurentSeries::dual_evaluator(parameters, dual_shape) prepare local neighborhoods before invoking Symbolica's Dualizer. They infer the required total degree from a static Dual or dynamic HyperDual shape, without a fixed derivative-order limit. The resulting evaluator takes and returns flattened components in Symbolica's usual order; its coefficients can be mapped to the desired precision and its instructions can be evaluated in batches.

This currently covers the mapped sheet helper log(x)/(1-x) at x=1 on a sheet whose continued logarithm vanishes there. Its local polynomial is generated by Symbolica's Atom::series; a nonzero logarithm at one remains a pole. Equal nonzero sheet quotient arguments retain independent derivative components. The derivative constructor leaves an unresolved 0/0 quotient nonfinite instead of applying the historical scalar convention of assigning it one.

Mapped sheet dilogarithm divided differences also retain independently seeded arguments at coincidence. Symbolica generates the numerator series before cancellation, including the joint x=y=1 neighborhood; the same unit series preserves a single dilogarithm argument's jets at one. At coincidence the two arguments must carry matching sheet tags. Zero arguments, differing sheet tags, and wound unit points do not receive an analytic continuation from this constructor and remain nonfinite. The native polylogarithm series at zero requires Symbolica PR84. The local branches apply at exact coincidence. Nearby points use the ordinary continuation formulas and retain their cancellation and precision requirements.

Shared-body Dual evaluation requires Symbolica's recursive vectorizer (PR82) and its optimizer scope correction for branches with multiple results (PR83). Massive spacelike C0 controls cover unequal/equal positive masses, both Källén signs, nonlinear higher/mixed complex seeds, scale independence and mixed-branch batches at four fixed real kinematic points. Symbolica-derived scaling controls and converged 384-bit real-axis differences are independent oracles. See C0_DERIVATIVE_AUDIT.md for the precise scope and a distinction between unsupported complex scalar invariants and validated complex derivative seeds at real centers. These tests do not establish a general C0/D0 derivative domain.

Constructing a scalar evaluator and then dualizing it cannot recover derivatives discarded by a scalar limit. Other limits still need neighborhoods, including the separate plain-expression log-ratio and divided-difference helpers, and inspection formulas. Complete C0/D0 derivative-domain coverage is not established by these constructors; callers must validate their domain.

For a self-contained formula, oneloop::get_expression(master_call) (also family.get_expression(arguments) for a Rust ScalarIntegral) returns the three exact Laurent coefficients with every OneLOop helper expanded into Symbolica primitives. It preserves the supplied symbols' assumptions. get_expression_with_options sets explicit node and depth limits; exceeding a limit returns an error, not a truncated formula. Expansion can duplicate work and change numerical conditioning: use the compact function map for routine numerical compilation. See the inspection implementation, native source generator, and generated Rust families.

get_expression example: a parametric equal-mass B0

For B0(psq, m², m²; mu²=1), keep both the momentum and squared mass symbolic. The arguments are [p_squared, mass_0_squared, mass_1_squared, mu_squared]; the result is (finite, simple_pole, double_pole), not a numerical evaluation:

Use the shared-kernel Python build:

from symbolica import E, S, N, Replacement
from symbolica.community.hepkit import oneloop as olo

psq = S("psq", is_real=True)
m2 = S("mz_masses::m2", is_positive=True)  # m², not m
B0 = olo.B0
one_master = B0(psq, m2, m2, 1)  # mu²=1; no Laurent tag here
finite, pole, double_pole = olo.get_expression(one_master)
print(finite)       # Complete expression, including all analytic branches.
print(pole)
print(double_pole)  # 0
finite_only = olo.get_expression(one_master, coefficient=0)
assert finite_only == finite

# Arguments may contain composite expressions and ordinary Symbolica symbols:
m3, mu_r = S("m3", "mu_r")
other_master = olo.B0(psq, m2 + N(4), m3, mu_r)
other_coefficients = olo.get_expression(other_master)

get_expression accepts a master Expression, not a family string or a list of strings. It accepts composite kinematics and relies on Symbolica's own real/positive attributes and inference; there are no separate assumption lists. Inspection accepts oneloop:: as shorthand for oneloopmaster::; the numerical coefficient hooks remain registered in oneloopmaster::.

Select an analytic branch without substituting the kinematics

all_branches = (finite, pole, double_pole)
selected = olo.select_branch(all_branches, [
    Replacement(m2, N(1)),
    Replacement(psq, N("3.23")),
])
print(selected[0])  # Parametric in psq and m2, with no if left for this point.
assert selected[1:] == (N(1), N(0))

# Exact integer and fractional probes are also supported.
selected_integer = olo.select_branch(all_branches, [
    Replacement(m2, N(1)), Replacement(psq, N(3)),
])
selected_fraction = olo.select_branch(finite, [
    Replacement(m2, N(1)), Replacement(psq, E("1/2")),
])

For the sample psq=3.23, m2=1, the actual selected finite expression has no if, abs or conj. Naming its repeated roots, abbreviating the variables, and collecting the two constant terms gives:

r_plus  = (s + sqrt(s^2 - 4*s*M))/(2*s)
r_minus = (s - sqrt(s^2 - 4*s*M))/(2*s)
finite = 2 - log(M)
         - (1-r_plus)*log(1-1/r_plus)
         - (1-r_minus)*log(1-1/r_minus)
simple_pole = 1
double_pole = 0

This display applies to the selected below-threshold region M>0, 0<s<4*M. The verbatim before/after output and runnable Rust example preserve the actual expression without introducing root aliases; its selected finite string is 758 characters with the example's fully qualified symbol names.

Rules are applied only to temporary copies of the first arguments of native if(condition, yes, no) expressions. Resolved conditions select a branch; its kinematics stay symbolic. Every nested conditional in a retained branch or condition is visited, without a helper-imposed depth cutoff. Undecidable conditions remain parametric. Use the same namespaced symbols in replacements: S("m2") does not match S("mz_masses::m2").

This selects a formula valid in the probe's analytic region, not a globally equivalent replacement of the original expression. Boundary probes select the boundary formula. Do not reuse it across a branch cut without selecting again. Native if tests zero/nonzero (including complex nonzero), not positivity. Probe points must respect the input symbols' assumptions and the integral's physical domain. Finite floating-point probes preserve their exact represented values during condition substitution; temporary native guards defer arithmetic to numerical evaluation and do not appear in the returned expression.

For nonzero positive M=m², a compact mathematical equivalent, not a promise of the current raw formatter's algebraic form, is:

beta = sqrt(1 - 4*M/(s+i0))
finite = 2 - log(M) - beta*log((beta+1)/(beta-1))
simple_pole = 1
double_pole = 0

Here i0 denotes the analytic continuation, not a finite numerical regulator; principal roots/logs are understood with that prescription. At s=0, the finite limit is -log(M); at s=4*M, it is 2-log(M). Above threshold it has imaginary part +pi*sqrt(1-4*M/s). The emitted expression implements the continuation with native conditionals, not a symbolic i0 variable or opaque OneLOop functions. Before branch selection it retains the massless/scaleless cases and redundant guards: positive attributes are not used as proof of nonzero values. Omitting the mass attribute allows a complex squared mass, subject to Im(m²)<=0. See the inspection guide for Rust branch selection, assumptions, output examples and expansion limits. The standalone legacy numeric extension does not expose symbolic inspection: use one shared Symbolica kernel.

The massive C0 case can be generated completely before selecting a branch:

mass2, mass2B, mu2 = S("mass2", "mass2B", "mu2")
C0 = S("oneloopmaster::C0")
master = C0(0, -mass2, mass2, mass2, mass2, mass2B, mu2)
probes = [Replacement(mass2, N(2)), Replacement(mass2B, N(1)),
          Replacement(mu2, N(1))]
all_branches = olo.get_expression(master)  # ordinary Symbolica Expressions, with ifs
selected = olo.select_branch(all_branches, probes)
print(selected[0])  # Full native Symbolica expression, masses still parametric.
print(selected[0].evaluate({mass2: 2, mass2B: 1, mu2: 1},
                           decimal_digit_precision=60))

This family uses an exact four-dilogarithm representation, including zero-mass branches and the prescribed real-axis lips. Run the C0 example to verify the actual repository output against the known value and native backend.

For fully generic C0/D0, avoid duplicating every repeated root and branch subexpression: request complete shared storage, then select afterwards:

p1, p2, p3, m1, m2, m3, mu2 = S("p1", "p2", "p3", "m1", "m2", "m3", "mu2")
master = C0(p1, p2, p3, m1, m2, m3, mu2)
complete = olo.get_expression(master, shared=True)
print(complete[0])  # root, binding count, storage size; not a huge formula dump
# complete[0].root and every (alias, body) in .definitions are Symbolica Expressions.
probes = [Replacement(x, N(v)) for x, v in zip(
    [p1, p2, p3, m1, m2, m3, mu2], [-1, -2, -3, 4, 5, 6, 7])]
selected = olo.select_branch(complete, probes)  # plain, parametric Expressions

shared=True stores all branches, with every defining expression included. It uses Symbolica's native AliasedAtom, not master evaluation hooks or opaque OneLOop helpers. Rust exposes get_expression_shared(master) and select_branch_shared(coefficient, &rules, options); each shared coefficient supports native .evaluator(&parameters). Python exposes SharedExpression.root, .definitions, .num_definitions, .byte_size, and bounded .to_expression(). Do not evaluate the root alone without its bindings.

Release-mode construction of the generic shared D0 takes approximately 10–13 seconds on the development machine; subsequent complete branch selection takes about one second. The ordinary C0 case above takes milliseconds. These are construction timings, not numerical-backend benchmarks. Unrestricted fully duplicated generic C0/D0 trees can still exceed the resource budget; the shared form is the scalable complete-expression API. See the inspection guide and measurements and validation scope.

Early selection remains available as get_expression(master, branch_rules=probes) or Rust's get_expression_on_branch. Probe values never replace retained bodies. Partial probes may retain conditionals; flattening a large unresolved result can still exceed the budget.

Compact master Symbols

oneloop::{A0(), B0(), dB0(), C0(), D0()} return the Symbolica Symbols oneloopmaster::{A0,B0,dB0,C0,D0} with dedicated EvaluationInfo hooks. The crate registers a lightweight Symbolica initialize! hook, ordered after Symbolica's special-function initializer. It attaches master and JIT callback symbols only; parsing a master needs no prior accessor call. Numerical backends are prepared per family on first use. Transparent definitions are built only when an expression API requests them. Importing Python does not build formulas, load evaluator assets, or compile SymJIT code.

Call oneloop::initialize()? explicitly to warm up all definitions and all five Native and SymJIT backends. is_initialized() reports completion of this optional full warmup; it stays false during ordinary lazy use.

Each master call takes a leading Laurent-power tag 0, -1 or -2, then the same numeric arguments as the lowercase constructor, flattened into one list.

use symbolica::prelude::*;

let p = parse!("p");
let finite = oneloop::B0().call((0, &p, 1, 1, 1));
let pole = oneloop::B0().call((-1, &p, 1, 1, 1));
let context = oneloop::OneLoopExpressions::new();
let exact_evaluator = context.evaluator(&[finite, pole], &[p])?;

The context supplies transparent tagged native definitions; Symbolica gives them precedence over the hooks. Bare calls can instead evaluate through cached hooks for Complex<f64>, Complex<DoubleFloat> and Complex<Float>. These hooks use the expression interpreter by default, or generated Rust when generated-evaluators is enabled, independently of the manual backend selection. Both routes cache their evaluators rather than constructing them per point. They are opaque to the outer evaluator's optimizer, so prefer the context when optimization across combined expressions is wanted. They do not supply every Symbolica numeric domain or automatic error-controlled precision. Invalid tags/arities produce descriptive errors or panics; numeric callbacks have no Result return channel. Stack and license requirements below apply to either route. See MASTER_SYMBOL_AUDIT.md for verification status.

To compile the bare Symbols while retaining their cached evaluation hooks, omit the function map. This example keeps the squared scale as a runtime parameter:

use symbolica::prelude::*;

let parameters = [parse!("p"), parse!("m0"), parse!("m1"), parse!("mu_squared")];
let calls = [0, -1, -2].map(|tag| {
    oneloop::B0().call((tag, &parameters[0], &parameters[1],
                      &parameters[2], &parameters[3]))
});
let exact = Atom::evaluator_multiple(&calls, &parameters)
    .direct_translation(true).build()?;
let mut evaluator = exact.jit_compile::<Complex<f64>>(oneloop::jit_settings())?;
let input: Vec<_> = (0..1024).flat_map(|_| [-1., 1., 1., 4.])
    .map(|value| Complex::new(value, 0.)).collect();
let mut output = vec![Complex::new(0., 0.); 1024 * 3];
evaluator.batch_evaluate(&input, &mut output, 1024);

The outer evaluator is compiled here; each native family backend is prepared on its first numerical call. For transparent expressions, use OneLoopExpressions::jit_evaluator or MappedLaurentSeries::jit_evaluator. These methods lower complex square roots and branch conditions to registered numeric callbacks before JIT compilation. Calling Symbolica's jit_compile directly on an exact evaluator bypasses those compatibility fixes. The exact interpreter uses upstream Numerica logarithms and magnitudes directly; its mixed-precision fixes preserve component precision. Inspection retains the original formulas, and native code generation emits the existing logarithm primitive.

Numerical evaluators and batches

Each prepared family evaluator returns all three Laurent coefficients. Its parameters are runtime inputs, including mu_squared as the last argument of every row; the scale is not baked in.

API Numerical backend Numeric types
NativeEvaluator<T> Generated, ahead-of-time Rust arithmetic f64, DoubleFloat, Float
ScalarEvaluator Portable SymJIT O2, with scalar binary64 batches f64
PrecisionEvaluator Explicit Native or exact-expression interpreter Float

NativeEvaluator requires generated-evaluators and owns reusable constants and a workspace. Its arithmetic runs in generated Rust. Binary64 dilogarithms call Symbolica's transcendental::dilog_complex_f64 kernel directly (no callback lookup, no MPFR); DoubleFloat and Float use Symbolica's registered polylog callback, so first use can initialize Symbolica and the requested OneLOop backend. Rational constants are converted directly at construction. Clones have independent workspaces; batches run the generated scalar function for each row, without a SIMD promise or hidden worker threads. See the native evaluator and its generic primitives.

use oneloop::{NativeEvaluator, ScalarIntegral};
use symbolica::domains::float::{Complex, DoubleFloat};

let mut evaluator = NativeEvaluator::<DoubleFloat>::new(ScalarIntegral::B0)?;
let input = [-1., 1., 1., 4.].map(|x| {
    Complex::new(DoubleFloat::from(x), DoubleFloat::from(0.))
});
let zero = Complex::new(DoubleFloat::from(0.), DoubleFloat::from(0.));
let mut output = [zero; 3];
evaluator.evaluate(&input, &mut output)?;

Tensor coefficients

NativeTensorEvaluator<T> evaluates OneLOop's tensor coefficients together, in the same generic domains and with the same batch contract as NativeEvaluator (row-major input with mu² last and squared, every row validated before any output is written, shape/domain errors leave the output unchanged):

TensorIntegral Input row Output row (Laurent triples, in order) OneLOop routine
An [m², mu²] A0, A00, A0000 olo_an, rank 4
B11 [p², m0², m1², mu²] B0, B1, B11 olo_b11 (without B00)
B111 [p², m0², m1², mu²] B0, B1, B11, B111 olo_bn, rank 3

Bubble denominators are (q²-m0²)((q+p)²-m1²), with q^mu -> p^mu B1 and q^mu q^nu -> g^{mu nu} B00 + p^mu p^nu B11, in OneLOop's normalization; each coefficient is [finite, 1/epsilon, 1/epsilon²]. B11 is what a rank-n reduction such as Ninja's requests; B111 serves rank n+1, where Ninja's own Passarino-Veltman reconstruction divides by p⁶.

The arithmetic is a hand-written port of OneLOop's bub0-bub111, bnlog, solabc and tadpn (see the module): exact zero dispatch and explicit real-axis lips replace OneLOop's EPSN tolerances and IEPS regulator, and log(1-1/x) is shared by all ranks of a root, with OneLOop's logarithmic series of bnlog for |log(1-1/x)| < 1/2. It needs no generated code, so it is available without generated-evaluators. For f64 the same algorithm runs as a binary64 specialization (real arithmetic for real kinematics, Estrin's scheme for the series), at most 0.6x of OneLOop's olo_b11 time on every bubble call of Ninja's benchmark streams. The tensor tests compare all coefficients with original OneLOop (366 rows from the tensor oracle), with 320-bit evaluation, with the exact symbolic an/b0/b1/b11/b111 expressions at 256 bits, and with Ninja's rank-three formulas.

use oneloop::{NativeTensorEvaluator, TensorIntegral};
use symbolica::domains::float::Complex;

let mut bubbles = NativeTensorEvaluator::<f64>::new(TensorIntegral::B11)?;
let input = [15625., 29929., 29929., 10000.].map(|x| Complex::new(x, 0.));
let mut output = [Complex::new(0., 0.); 9]; // B0, B1, B11
bubbles.evaluate(&input, &mut output)?;

For NativeEvaluator, fixed precisions are 53 bits (f64) and 106 bits (DoubleFloat); Float defaults to 128 bits or accepts explicit working bits through with_binary_precision. Basic DoubleFloat arithmetic and elementary functions retain both components. Its Li2 primitive alone uses Symbolica Float at 160 bits and converts back without narrowing the input to f64.

The API also preserves manual expression construction and provides OneLoopExpressions::jit_evaluator / MappedLaurentSeries::jit_evaluator for compiling combinations to SymJIT O2.

use oneloop::{ScalarEvaluator, ScalarIntegral};
use symbolica::domains::float::Complex;

oneloop::initialize()?; // All symbols and enabled numeric backends ready.
// Clone the prepared O2 backend; no additional IR loading or JIT compilation.
let mut evaluator = ScalarEvaluator::prebuilt(ScalarIntegral::B0)?;
let rows = [-1., 1., 1., 1.,  // p², m0², m1², mu²
            -1., 1., 1., 4.]; // same kinematics, another squared scale
let input: Vec<_> = rows.into_iter().map(|x| Complex::new(x, 0.)).collect();
let mut output = vec![Complex::new(0., 0.); 2 * 3];
evaluator.evaluate_batch(&input, &mut output, 2)?;
// output[0..3] belongs to the first row; output[3..6] to the second.

evaluate handles one point; evaluate_batch accepts flat row-major arrays and checks their exact dimensions, including empty batches. SymJIT currently runs scalar code for each row; its upstream SIMD path fails zero-input batches. For default manual evaluation without constructing an evaluator object, oneloop::evaluate(family, input, output) and oneloop::evaluate_batch(family, input, output, rows) use DEFAULT_BACKEND, which is EvaluationBackend::Expression by default and EvaluationBackend::Native with generated-evaluators enabled. Select a route explicitly with evaluate_with_backend(family, input, output, EvaluationBackend::Native) or evaluate_batch_with_backend(family, input, output, rows, backend); Expression selects Symbolica's native expression interpreter. These convenience routes prepare only the requested backend and family. The historical measurements below used an older patched SIMD backend and do not establish performance for this revision. The selector does not control the registered Symbol hooks. ScalarEvaluator::cached(family) clones the prepared SymJIT backend into an independently reusable workspace; an explicitly compiled custom expression keeps its own evaluator. See the backend selectors. to_bytes / from_bytes serialize numerical evaluator instructions and nested function definitions, not machine code or process pointers. Loading recompiles every level with the same strict JIT settings. Cache compatibility is tied to the Symbolica IR schema and compilation policy; no backend machine code is reused across SymJIT versions. Load only trusted artifacts. Float/arbitrary-precision evaluators use PrecisionEvaluator or the original exact-expression API, not these f64 blobs. Those evaluators are constructed only for the requested precision. Keep SYMJIT_TOML unset and do not place symjit.toml in the working directory when building/loading portable caches: these overrides can pin SymJIT to a particular machine architecture, so the portable API rejects them. Register any custom numerical Symbol callbacks before loading a manually composed cache.

Build size and optional generated evaluators

The default build enables prebuilt and uses the expression interpreter. The opt-in generated-evaluators feature compiles the ahead-of-time Rust formulas for f64, DoubleFloat, and Float. These generated formulas, especially the box integral, account for most of OneLOop's machine code. To enable them for faster evaluation at the cost of a larger library:

oneloop = { git = "https://github.com/alphal00p/oneloopmaster", branch = "main", features = ["generated-evaluators"] }

Without generated-evaluators, automatic evaluation and the registered master callbacks use the expression interpreter. Arbitrary precision stays arbitrary precision, and DoubleFloat stays DoubleFloat. Explicit NativeEvaluator construction or EvaluationBackend::Native returns a configuration error. Rust callers can query EvaluationBackend::Native.is_available() first. SymJIT remains explicitly selectable. This trades throughput and first-use formula preparation for a smaller library; it does not change the integral API.

The Python adapter and development host also omit generated evaluators by default and forward both features. To opt into generated evaluators, use:

cd python
maturin build --release --features generated-evaluators

Community hosts must ensure no dependency enables generated-evaluators to keep the smaller build; Cargo combines features enabled by every caller. Enable community,prebuilt on the adapter for expression interoperability and cached SymJIT support. A host can expose its own opt-in feature forwarding oneloop/generated-evaluators and oneloop-python/generated-evaluators. Disabling prebuilt separately also removes the embedded SymJIT caches, at the cost of generating them on first use.

To compare warm numerical throughput in all three numeric domains, run cargo run --release --features generated-evaluators --example numeric_performance -- 100 5 native and repeat with expression as the final argument. The survey validates the independent Fortran fixtures before timing, and excludes construction and input conversion.

The 2026-10-02 Linux measurements compare against commit 06a72bf: sharing DoubleFloat arithmetic in the large generated graphs reduces generated machine code from 27.10 to 12.38 MiB. The standalone adapter wheel shrinks from 17.19 to 13.47 MiB with generated evaluators enabled, or 9.97 MiB with the current defaults (generated evaluators disabled and prebuilt retained). These are local unstripped x86-64 release builds, not total community-wheel sizes. Small-family arithmetic stays inline to avoid adding call overhead to A0/B0/dB0.

The prebuilt feature is enabled by default and embeds all five evaluators using include_bytes!. Asset provenance, sizes and hashes are in assets/evaluators/README.md. To rebuild explicitly, call ScalarEvaluator::rebuild or run:

cargo run --release --no-default-features --example rebuild_evaluators -- assets/evaluators

Without prebuilt, first use builds the requested SymJIT family from native expressions. An optional final family argument writes only that rebuilt asset. Applications can explicitly replace a shared SymJIT manual cache with rebuild_cached_evaluator(ScalarIntegral::B0); this does not replace the direct native Symbol-hook cache. Native Rust source is generated by cargo run --release --features rust-codegen --example generate_native and compiled with the crate, not loaded from the portable blobs. Cache regeneration and fresh-load batch checks must pass for each dependency update. A batch method does not guarantee a speedup for divergent conditional branches.

The opt-in rust-codegen feature also exposes the same generator as oneloop::rust_codegen::RustEmitter. It accepts an exact Symbolica evaluator, retains shared functions and lazy branches, and returns a Rust module with any entry output count. constants_source() emits its shared exact constant table; constants() exposes the descriptors for inspection. Failed exports leave previously emitted constant indices unchanged. The generate_native example uses this API for all five scalar families.

Generated child modules use the parent imports oneloop::rust_codegen::runtime::{C, Context, NativeFloat, ConstantSpec, primitives}. Construct Context::new(generated_constants::CONSTANTS, bits) once, then call each child's evaluate. Constants are parsed directly at working precision; inputs are neither converted nor padded. The caller validates input counts and precision. emit emits scalar arithmetic; wrapping that entry in a row loop remains a scalar control. Shared helper outputs must be scalar, and unsupported external functions return an export error.

RustEmitter::emit_batch emits the scalar entry above and an additional instruction-major evaluate_batch(input, output, rows, ctx, workspace) entry. Re-export BatchWorkspace and batch from the same runtime in its parent module, and construct BatchWorkspace::new(prototype) once. Public buffers are row-major; retained scratch columns are slot-major. Each instruction runs across its active rows, including inside shared helpers. Storage follows Symbolica's exported parameter, temporary and output slot counts, rather than the number of emitted statements. Empty batches do no arithmetic; single-row calls use the scalar entry without scratch packing or allocation. Dimension/size errors leave output unchanged.

Conditional graphs retain Symbolica's lazy forward branches: inactive singular arms do no arithmetic. Shared helpers receive the parent's stride and active lane indices, and consecutive joins preserve the selected component values. Active/resume scratch is retained per canonical helper. Entirely branch-free graphs keep their existing contiguous loop path. Malformed or backward control flow is rejected before export changes the constant table. Larger batches never fall back to scalar row loops. The existing scalar master evaluator backend still uses its established row loops; emitting lazy batch kernels does not automatically replace that backend.

Scratch initialization uses the explicit prototype, while inputs and constants retain their own precision (including different Float precision per component). Context still owns exact cached constants. A workspace can be reused across modules, contexts and batch sizes. Symbolica's Dualizer supplies flattened higher/mixed Taylor components in its original ordering; no Dual components are dropped and no new differentiation rules are implemented. The runtime uses existing native primitives, so Float operations may allocate even when the outer scratch storage is warm. No SIMD or speedup guarantee is implied.

Run cargo test --features rust-codegen --test rust_batch_codegen -- --nocapture for compiled f64/DoubleFloat/Float and flattened-Dual comparisons, an explicit instruction-major primitive trace, warmed f64 allocation checks, and generated source-size/optimized-compilation measurements.

SymJIT has a compatible minimum requirement of 2.27.3; the consuming application's Cargo.lock selects the concrete version. The six previously reported standalone defects passed their regressions in 2.26.0. The newer minimum supports configurable call argument storage: portable evaluators size it from all nested function calls, counting two real slots per complex argument, and reject requests above the backend's accepted capacity. Source cache loading recomputes this size before compiling on the receiving host. Production retains ordinary compilation, packed complex arithmetic, fastmath=false and scalar batches while the broader backend matrix is audited. Complex square-root callbacks remain necessary for extreme scales. See the dependency audit for reproduced remaining limitations and the standalone cases.

UV and IR pole selection

NativeEvaluator and NativeTensorEvaluator accept a per-instance PoleSelection. BOTH is the default and retains the existing numerical results. UV, IR and NONE affect only the simple and double poles; the finite coefficient, including finite rational terms, is unchanged.

use oneloop::{NativeTensorEvaluator, PoleSelection, TensorIntegral};
use symbolica::domains::float::Complex;

let mut bubbles = NativeTensorEvaluator::<f64>::new(TensorIntegral::B1111)?
    .with_pole_selection(PoleSelection::UV);
let input = [Complex::new(0., 0.), Complex::new(0., 0.),
             Complex::new(0., 0.), Complex::new(1., 0.)];
let mut output = [Complex::new(0., 0.); 15];
bubbles.evaluate(&input, &mut output)?;
// B0, B1, B11, B111, B1111 UV residues: 1, -1/2, 1/3, -1/4, 1/5.
bubbles.set_pole_selection(PoleSelection::IR);
bubbles.evaluate(&input, &mut output)?;
// The same scaleless integral has the opposite IR residues.
# Ok::<(), String>(())

The scalar and tensor interfaces support f64, DoubleFloat and Float; fractions are evaluated in the selected numerical precision. A0 and its tensor coefficients have only UV poles. B0 and longitudinal bubble moments have UV residues (-1)^rank/(rank+1), including the scaleless limit. The derivative dB0 has no UV pole (its on-shell IR pole is retained). Scalar C0 and D0 have only IR poles. The double pole is always IR. No second loop reduction is required to select these residues.

For an interpreted or external evaluator, apply PoleSelection::apply(PoleCoefficient, &mut [finite, single, double]) after a successful combined evaluation. PoleCoefficient::Scalar identifies a scalar family, TadpoleTensor a tadpole coefficient, and BubbleMoment { rank } a longitudinal bubble coefficient through rank four. Selection must occur after the full evaluation, since internal recurrences can use ordinary poles to construct finite terms. Native evaluators preserve nonfinite failure outputs; the public projection helper assumes that its caller already validated success.

Consumers performing integrand reduction must retain scaleless-bubble coefficients when requesting separated poles. Discarding such bubbles before calling this library loses the individually nonzero UV and IR contributions. This option concerns unrenormalized loop integrals; it does not classify or change model counterterms.

Python (PyO3)

The separate Python adapter supplies scalar and batched complex-number evaluation through PyO3. It adds no Python/PyO3 dependency to the core Rust crate. The recommended development host builds one symbolica.core containing both Symbolica and OneLOop, so all symbolic APIs use actual Expression and Replacement objects. It is a local host, not an upstream community-repository registration. The HEP integration assessment records Feynkit type reuse and the host registration interface. Install it in an isolated environment without a competing Symbolica distribution:

cd python/host
maturin develop --release

For example, on an adequately stacked calling thread:

from symbolica import S
from symbolica.community.hepkit import oneloop as olo

bubble = olo.Evaluator(olo.B0)  # Reuses a direct Rust numeric workspace.
values = bubble.evaluate_batch([
    [-1.0, 1.0, 1.0, 1.0],
    [3.0, 0.7 - 0.03j, 1.4 - 0.08j, 4.0],
])
# Each row returns (finite, simple_pole, double_pole); final input is mu_squared.

In the community module, olo.A0, B0, dB0, C0, and D0 are callable Symbolica Expressions exported from this crate's primitive master accessors. An untagged call such as olo.A0(m2, mu2) is suitable for get_expression; olo.A0(0, m2, mu2).evaluate({m2: 2, mu2: 1}) evaluates its finite coefficient through the existing native Rust hook. Tags -1 and -2 select the simple and double poles. Supply the squared scale explicitly as the last argument when constructing a symbolic call. These exports resolve and cache on first attribute access without building formulas or numerical backends.

Complete tagged calls automatically normalize to numerical Expressions when all kinematic arguments are numbers and at least one is floating point. For example, olo.A0(0, 2.0, 1) evaluates the finite coefficient during construction. Mixed exact and floating inputs use the maximum supplied floating-point precision, including complex components and the scale, without a binary64 conversion of exact inputs. All-exact calls such as olo.A0(0, 2, 1) retain their symbolic meaning; request .evaluate({}) to choose numerical evaluation. Untagged calls, partially symbolic calls, and invalid tags, arities, or numerical domains remain symbolic. Nonfinite numerical results also leave the call intact.

The optional legacy build, cd python && maturin develop --release, exposes numeric-only oneloop_native; uppercase names remain numerical aliases there. Its family-name selectors remain a compatibility interface, but it no longer accepts or returns symbolic strings. See the API audit for this change's checks and remaining limits.

Numerical functions olo.a0/b0/db0/c0/d0(..., mu_squared=1, prec=16) also accept rebuild=True and backend="auto". Reusable evaluators and their methods likewise accept a backend choice: "native", "symjit" or "expression". prec counts decimal digits. With "auto", ordinary Python numbers at the default precision use binary64 arithmetic and return Python complex results. Higher precision, large integers, Decimal inputs, or DecimalComplex inputs use Float and return DecimalComplex coefficients. Automatic calls use the expression interpreter unless generated-evaluators enables direct Rust. backend="expression" selects the transparent expression interpreter; "symjit" selects the embedded O2 backend and rejects arbitrary-precision requests. Use backend="symjit", rebuild=True to recompile that backend. Rebuilding a Native evaluator only prepares fresh constants and a numeric workspace.

from decimal import Decimal
import oneloop_native as olo

mass = olo.DecimalComplex(Decimal("2.0000000000000000000000000000001"),
                          Decimal("-0.0000000000000000000000000000003"))
finite, pole, double_pole = olo.a0(
    mass, mu_squared=Decimal("4"), prec=1000, backend="native")
print(finite.real, finite.imag)  # Decimal components, not binary64 approximations.

bubble = olo.Evaluator("B0", prec=32, backend="native")
values = bubble.evaluate_batch([
    [Decimal("-1"), Decimal("1"), Decimal("1"), Decimal("1")],
    [Decimal("-2"), Decimal("1"), Decimal("1"), Decimal("4")],
])

Supply decimal text to Decimal, not an already rounded Python float. Decimal construction and conversion preserve the supplied digits independently of the global decimal context; subsequent arithmetic on returned Decimal components uses Python's context as usual. See the Python precision guide for return types, per-call overrides, and complex inputs. Arbitrary precision uses Complex<Float> through the selected Native or Expression backend, not SymJIT; the binary64 performance measurements below do not bound high-precision runtime. The optional Python performance survey defaults to 1024-row batches and records Python conversion/allocation overhead separately from initialization. The completed 1024-row survey includes the one-row tail, all three outputs, and matched Rust/Fortran measurements; see the performance report.

Stack and precision

Explicit full warmup and generic C0/D0 compilation traverse a large conditional expression graph. The Rust regression tests run the entire Symbolica workload on a thread with a 128 MiB stack. For a standalone program, create that thread before constructing any Symbolica values, and run construction/evaluation inside it. A restricted Symbolica license permits only one active Symbolica thread per user; do not overlap Symbolica work across threads or processes under that license. Applications already using Symbolica must arrange an adequate calling-thread stack rather than silently spawning another Symbolica worker. Both Python cold startup checks pass on main threads capped at 8 MiB, but this is not a general stack bound for arbitrary expressions or explicit rebuilding. Sequential thread handoff is separately tested; the initializing thread need not remain alive for the verified cached-evaluator use case. Raw NativeEvaluator<T> uses the calling thread and its owned numeric workspace. Its first dilogarithm resolves Symbolica's public polylog callback and can trigger global initialization. The same calling-thread stack and license requirements therefore apply to that first use, as well as Symbol access and Python import.

PrecisionEvaluator evaluates the same formulas at a fixed arbitrary working precision, with explicit Native or Expression selection and no additional direct dependency. new defaults to Expression, or Native when generated-evaluators is enabled; SymJIT is binary64-only. An explicit direct native request looks like:

use oneloop::{EvaluationBackend, PrecisionEvaluator, ScalarIntegral};
use symbolica::domains::float::{Complex, Float};

let mut evaluator = PrecisionEvaluator::with_backend(
    ScalarIntegral::B0, 1000, EvaluationBackend::Native)?;
let bits = evaluator.binary_precision();
let input: Vec<_> = ["-1", "1", "1", "4"].into_iter().map(|text| {
    Complex::new(Float::parse(text, Some(bits)).unwrap(), Float::new(bits))
}).collect();
let mut output = core::array::from_fn::<_, 3, _>(|_| {
    Complex::new(Float::new(bits), Float::new(bits))
});
evaluator.evaluate(&input, &mut output)?;
println!("{} + ({}) i", output[0].re, output[0].im);

The complete arbitrary-precision example arranges the calling-thread stack and evaluates two rows; run it with cargo run --release --example arbitrary_precision -- 1000.

new / with_backend add fixed guard bits to the requested decimal precision; with_binary_precision / with_binary_precision_and_backend instead use exactly the supplied bit count. Raw NativeEvaluator::<Float>::with_binary_precision also uses exactly its supplied bits, without eager symbolic startup. The API supports reusable scalar and flat row-major batched evaluation with the same argument order as ScalarEvaluator. Exact rational coefficients are converted directly at working precision. Public master-Symbol hooks support Complex<Float> with native workspaces keyed by the maximum input-component precision (including the squared scale), and Complex<DoubleFloat> at 106 bits. Map a bare-Symbol evaluator with map_coeff_with_prec to use Float hooks, or map the transparent expressions directly.

Use exact rational or decimal-text inputs when testing precision convergence. Mapping coefficients and supplying values as Complex<f64> cannot recover digits already rounded out of the input. Increasing precision provides more working digits, not a guarantee of that many accurate output digits at every kinematic point. Outputs retain Symbolica/Numerica's computed precision; we do not pad lost digits back to the requested precision. No custom adaptive precision or near-degeneracy gate is implemented here. See PRECISION.md for the precision contract and validation coverage.

Verification

Run local checks from this directory:

cargo test -- --test-threads=1
cargo clippy --all-targets -- -D warnings
cargo fmt --check

The original acceptance table has 293 points / 879 Laurent coefficients. The broader retained audit has 342 accepted Fortran points / 1,026 coefficients; run its fixed-f64 comparison explicitly with:

ONELOOP_AUDIT_FIXTURES=tests/data/scalar_audit.txt cargo test --test parity \
  audit_extra_fortran_fixtures -- --ignored --test-threads=1 --nocapture

The earlier generated-evaluator release suite passed 114 tests, with six ignored entries (four opt-in diagnostics and two subprocess helpers invoked by parent tests). The direct native backend passes the broader table at 256 bits; its explicit binary64 diagnostic still fails on 14 small-momentum B0/dB0 coefficients:

cargo test --release --test native native_expanded_binary64_diagnostic -- \
  --ignored --test-threads=1 --nocapture

This is a retained failing test, not an accepted tolerance exception. No comparison tolerance has been relaxed and no disagreement is converted into a passing assertion. Passing the sampled tables or default suite is not a full-parity certificate. The acceptance and expanded tables have 613 unique ordered inputs, not 635 independent points. See the exceptional-vacuum audit for the exact disputed permutations, independent analytic targets, and gaps in region coverage. Analytic symmetry takes precedence over inconsistent reference outputs there. See the dated audit for the latest completed runs and additional exact-boundary findings. The restored strict-v3 scalar caches separately pass all three outputs for the 293 acceptance rows and 31 benchmark rows. The final release Python suite passes all 27 tests, including native/SymJIT selection, arbitrary precision, Decimal inputs, inspection and 1024-row batches. Arbitrary-precision, callback, mapped-expression, portable-cache and batched routes are tested separately, not inferred from one another. Bare master calls compiled without a FunctionMap pass every acceptance row in canonical tag order, scalar and five mixed/repeated batch layouts, before and after portable restoration. Five additional tag orders cover controls from each family. The exact repeated-root box regression passes at 128/256 bits; the nearby width-1e-30 diagnostic at 128 bits asserts finiteness only, with its target comparison performed at 256 bits.

The performance survey compares the same 31 numeric fixtures, all three Laurent outputs, and 256 warmup calls against the unchanged original Fortran source. It separates explicit full warmup from warmed evaluation, checks uniform and mixed rows, and reports batches 1/4/32/256/1024 individually:

python3 tests/benchmark_fortran.py /path/to/OneLOop --iterations 2000 \
  --warmup 256 --repetitions 7 --output /tmp/oneloop-fortran.json
cargo run --release --example performance_survey -- 2000 7 all jit \
  > /tmp/oneloop-native.tsv
python3 tests/compare_performance.py /tmp/oneloop-fortran.json /tmp/oneloop-native.tsv

Use all native instead of all jit to measure the generated Rust evaluator; its batch loop is scalar. The current report measures this backend and native-only hooks separately from manual SymJIT. Use all symbols to compile public master calls with their transparent native FunctionMap, using the same batch sizes. Use all hooks-jit to JIT-compile bare master calls without that map, invoking the automatically registered direct native callbacks. all hooks keeps that bare outer evaluator interpreted and is scalar-only (compare with --batch 1). These are distinct measurements; mapped compilation is not the automatic callback path. Binary64 hooks reuse one backend result for distinct Laurent tags at bitwise-identical inputs. A bounded four-entry FIFO accommodates tag-major SIMD lanes, including identical points, but each tag is consumed only once per group: repeated same-tag calls cannot be memoized away. Seven counting-backend tests cover every tag order, successive 1/2/4-lane groups, signed zeros, changed inputs, eviction, explicit invalidation and failures during evaluation. More heavily interleaved points or threads can reduce reuse. The outer JIT callbacks still enter the scalar family backend per point; they do not combine those callbacks into a family SIMD batch.

The optional fifth survey argument selects batches, for example 4097 7 all jit 1,1024. This evaluates four full 1024-row batches plus a one-row tail per repetition. Compare each selected batch separately with --batch. The Python survey in python/performance_survey.py defaults to batches of 1024; its timings include Python input conversion and output-object allocation, unlike the Rust-only route. Compare runs on the same machine without competing benchmark/build jobs, with matching call counts and repetitions. The comparator rejects incomplete or invalid measurements and returns exit 2 if any selected median exceeds 1.5 times the Fortran time. The 1.5× target is not met across all workloads; full measurements and limitations are in the performance report. The comparator's independent protocol tests run with python3 -m unittest discover -s tests -p test_compare_performance.py -v.

The permanent Fortran fixture generator is standard-library-only Python. Compile the small test driver against an existing, unchanged original OneLOop build:

gfortran -I /path/to/OneLOop tests/support/oracle.f90 \
  /path/to/OneLOop/libavh_olo.a -o /tmp/oneloop-oracle
python3 tests/generate_fixtures.py /tmp/oneloop-oracle

It prints the fixture table to standard output and rejects rejected/incomplete or nonfinite Fortran results. The original example program does not print the full A0/B0/dB0 outputs needed by this table, so use this test driver. The original library is not a production dependency. Set its compiler runtime library path if required by your local toolchain. The checked-in fixture table makes normal tests independent of Fortran, Python, and the standalone numerical port.

For a whole-table high-precision exploratory run, also set ONELOOP_AUDIT_BITS=256 (or another precision) in the audit command above. This selects one numeric domain for every point, not an error-triggered fallback. branch_limits, triangle_limits and box_limits separately retain focused precision/branch regressions; see the dated audit for which checks pass.

See AUDIT.md and STATUS.md for verified coverage and known incorrect or unsupported cases. This is a development implementation, not a claim of global Fortran equivalence. Cargo publishing remains disabled. See COMMUNITY_INTEGRATION.md for the inspected Spenso-style integration plan.

Citations

In the shared Python host, symbolica.get_citations() returns the package credit and the two papers below after OneLoopMaster is used, alongside Symbolica’s own citation. Entries are Symbolica Citation objects. Use citation.to_bibtex() to export an entry. Reading citations does not initialize numerical backends.

Credits and references

The mathematical and algorithmic basis of this transcription is OneLOop, by Andreas van Hameren. The original Fortran implementation is the independent reference used by the scalar parity tests. This repository is a separate, still-incomplete Rust / Symbolica transcription, not an official replacement or a claim of endorsement by the original author. The original OneLOopBridge wrapper remains unchanged.

Please credit the original work when using or building on these expressions:

Symbolica supplies the symbolic expressions, function maps and numerical evaluation infrastructure. It has its own licensing terms. Permission for the intended distribution/relicensing of this transcription is being handled separately; it has not been recorded as granted here. The manifest's license identifier is provisional, not a grant of rights to the original code. Cargo publication is disabled pending permission and technical readiness.

About

Symbolica implementation of OneLOop scalar master integrals

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages