Planning Note: JAX Backend, Jacobians, and Optimizer

Status (2026-07-11): Phases A–D of this plan are implemented (eval_jax.py, can_lower_jax_2d, fit_model_jax + Dfun plumbing); Phase E (optimizer replacement) is deferred for lack of evidence that lmfit is the bottleneck. This note is preserved as the planning-time rationale.

Summary

The current NumPy GIR backend is ready to be treated as complete for the current lowered-evaluator scope. A JAX port should proceed as a new track, not as unfinished work that must land before GIR / eval / scheduler can be called “ready”.

That conclusion rests on the current architecture:

What remains is real backend work, not “finish the GIR architecture” work.

Current readiness

The current implementation already has several properties that make a JAX track feasible:

  • The 2D evaluator is already organized as a pure-looking function of (plan, theta) -> spectrum, even though the current implementation uses NumPy scratch buffers and mutation internally.

  • The hot loops are plan-driven, not theta-driven. In practice this means the branch structure is fixed once schedule_2d(graph) has run, which is a good fit for a compiled backend.

  • The scheduler has already done the hard semantic work: expression binding, dynamics grouping, convolution lowering, profile lowering, and subcycle lowering all happen before the evaluator runs.

  • The current lmfit boundary is narrow. residual_fun calls a fit function, and fit_model_gir already converts the full parameter vector into the compact optimizer vector expected by the evaluator.

This is enough to start a JAX branch immediately.

It is not necessary to keep the current GIR branch open for additional “JAX readiness” cleanup before declaring the NumPy backend complete.

What still blocks a full JAX path

1. Backend-agnostic prep

These items are not blockers for calling the NumPy GIR work done, but they are useful cleanup before or during a JAX port:

  • Flatten expression storage. ScheduledPlan2D.expr_programs and ScheduledPlan2D.profile_expr_programs are still Python lists of ExprProgram objects. They should move to fully packed arrays (for example CSR-style instruction storage plus per-program offsets).

  • Keep schedule data fully array-native. The main plan is already close to this ideal; the remaining goal is to avoid Python-wrapped structures in the hot path altogether.

  • Preserve a stable theta contract. The evaluator boundary evaluate(plan, theta) is already the right API. New work should protect that contract rather than pushing JAX concerns back into model objects or fit-time parsing.

  • Kernel-matrix convolution has landed (2026-07): the lowered convolution path now evaluates registry kernels elementwise on a precomputed dt matrix and applies a quadrature-weighted matmul (kernel-matrix-convolution.md). All array shapes in the convolution path are theta-independent, so the former jit blocker (per-theta kernel support lengths) is gone.

Of these, expression flattening is the one prep item most worth doing even if the eventual backend choice changes again.

2. JAX evaluator port

The main technical work is in the evaluator itself:

  • Mutation-heavy NumPy code must become functional JAX code. The current 2D evaluator uses .copy(), slice assignment, and in-place accumulation over scratch arrays. A jitted JAX version will need explicit functional updates or loop-carried state.

  • Python callable dispatch must become JAX-native dispatch. The current evaluator relies on Python dispatch tables (OP_DISPATCH, DYNAMICS_DISPATCH, PROFILE_DISPATCH). A jitted backend will need either explicit JAX control flow or trace-time unrolling over the scheduled ops.

  • Host-side checks must stay outside the jitted region. Shape checks and Python exceptions are fine at the outer boundary, but should not live inside the compiled path.

  • SciPy-dependent kernels need JAX-compatible replacements. The kernel-matrix change (kernel-matrix-convolution.md) retired the SciPy convolution utilities from the lowered path, and the removal of voigtCONV/lorentzCONV (2026-07) retired wofz from functions/time.py entirely — the conv path is now JAX-expressible (erfc for the Gaussian edge masses exists in jax.scipy.special). The energy-domain Voigt profile still uses wofz and remains the one special-function gap.

3. Jacobian and optimizer work

Even after a working JAX evaluator exists, Jacobian / optimizer work is still its own layer:

  • fitlib.py currently constructs lmfit.Minimizer without a Jacobian hook. If we want analytic Jacobians while keeping lmfit, we need explicit Dfun plumbing.

  • A fully custom JAX optimizer is a larger decision than “use JAX for derivatives”. It also means deciding how to replace or re-scope:

    • existing result objects and reporting,

    • two-stage fitting workflow,

    • confidence-interval tooling,

    • MCMC integration,

    • parameter bounds / transformations,

    • writeback into the current model/result surface.

Because of that, “JAX evaluator + analytic Jacobian” and “replace lmfit” should be treated as separate milestones, not one first step.

Recommendation on optimizer strategy

The recommended first optimizer milestone is:

  1. build a JAX evaluator,

  2. derive a Jacobian from it,

  3. keep lmfit as the outer optimizer at first.

Reasons:

  • It isolates the performance question. We learn whether JAX + Jacobian is actually worthwhile before committing to a larger optimizer rewrite.

  • It preserves the existing user-facing fit workflow while the new backend proves itself.

  • It avoids bundling backend correctness risk with optimizer-behavior risk in the same first implementation.

A custom JAX optimizer may still be the right long-term direction, but it should come after we have:

  • a parity-validated JAX evaluator,

  • a working analytic Jacobian path,

  • and concrete evidence that lmfit itself is the next real bottleneck.

What does not belong in the closing GIR branch

The following should be treated as next-track work, not as unfinished business for the current lowered-evaluator branch:

  • JAX backend porting work

  • Jacobian plumbing

  • optimizer replacement

  • project-level fit lowering

  • mixed-backend execution

Those are legitimate follow-ons, but they are not prerequisites for calling the current NumPy GIR evaluator complete.

Success criteria for the JAX track

A JAX branch should be considered successful when all of the following are true:

  • A JAX evaluator matches the NumPy GIR evaluator to practical floating-point tolerance on the supported subset.

  • The widened JAX path covers the same intended feature surface as the current NumPy GIR backend, or clearly documents any temporary exclusions.

  • Fit-level benchmarks show a meaningful win from JAX alone or from JAX + analytic Jacobian.

  • The existing NumPy GIR path remains available as the conservative reference implementation until the JAX path has earned default status.

Bottom line

The current scheduler / evaluator design is already good enough to start a JAX project now.

There is still meaningful implementation work ahead, but it belongs on a new backend branch. It should not be framed as “we cannot call GIR ready until we do this.” The right framing is: the NumPy GIR path is the completed reference backend, and JAX is the next backend experiment built on top of it.