# The kernel contract `solprop.translate.problem.KernelProblem` is the only object that crosses from the physics and translation layers into a backend. It is a frozen dataclass of plain NumPy arrays and scalars, validated, versioned, and documented here field by field. A backend may assume everything stated here and nothing else; the translation layer promises everything stated here and nothing else. Changing this file and the dataclass together is the only way to extend the contract, and a test checks that the fields listed here and the fields of the dataclass agree. Axis names follow [conventions.md](conventions.md): `n_batch`, `n_modes`, `n_pol`, `n_omega` (coarse spectral), `n_omega_fine`, `n_t`, `n_to` (fine time), `n_nodes`, `n_z`, `n_intervals`, `n_deg` (Chebyshev degree + 1). The algorithms behind the fields — the cumulative operator, the quadrature, the compiled responses — are in [algorithms.md](algorithms.md). **Sign convention.** Every complex array in this contract and in `Observation` is in the **FFT convention** of [conventions.md §2](conventions.md) (raw forward DFTs, propagation `e^{i(ωt − βz)}`, linear operator `−iβ`). The translation layer supplies `field0` and `noise0` already converted from the optics-convention user inputs; the output layer converts observations back. Backends never conjugate. **Current kernel schema: 36; saved-output schema: 25.** These versions are independent. Internal modal amplitudes are vacuum normalised; the launch and observation boundaries carry the true-power conversion, and every backend reads the modal power weight (§3) in its statistics alone. Radial columns are Hankel frequencies and Cartesian columns are transverse FFT frequencies, the latter flattened as `ix + n_x*iy`. Every scalar transverse transform is applied independently to each state polarisation component. **Agreement is a rounding bound.** The contract fixes quantities, not operation order. A backend reproduces the reference's results within a rounding bound fixed from its operation and reduction counts, never bit for bit ([SEP 25](seps/archive/sep-0025-rounding-level-reproducibility.md)). That includes the plasma column's running sums against `plasma_scan` and `plasma_current`, under either running-integral rule (§5); the Raman pass against `RamanResponse.evaluate` and `raman_spectrum` against `RamanKernels.spectrum`; and the statistics evaluated on one state. What the contract states exactly stays exact: integer and identity fields, hard-mask zeros, and explicitly exact zeros and no-ops. ## 1. Identity and validation | field | type | meaning | |---|---|---| | `schema_version` | `int` | incremented on every change to this contract, to the protected surface of `StepDriver` or to the stage bundles of `translate/stage.py` (both frozen in `tests/backends/test_driver.py`) | | `grid_kind` | `"field" \| "envelope"` | selects r2c/c2r vs c2c transforms and the response formulas | | `shape` | `(n_batch, n_modes, n_pol, n_omega)` | canonical state shape | | `flags` | frozen dataclass of static flags and counts | `single_mode`, `scalar_pol`, `single_batch`, `has_plasma`, `has_raman`, `has_spectral_responses`, `has_noise`, `has_temporal_absorber`, `constant_linop`, `mode_averaged`, `n_channels`, `n_raman` — kernels specialise on these at compile time. Derived from the content, never passed in | | `grids` | frozen dataclass | section 2 (`translate.masks.Grids`) | | `linear` | frozen dataclass | section 3 (`translate.linop.LinearOperator`) | | `transform` | frozen dataclass | section 4 (`translate.modal.Transform`) | | `responses` | frozen dataclass | section 5 (`translate.responses.CompiledResponses`) | | `initial` | frozen dataclass | section 6 (`translate.problem.Initial`) | | `stepper` | frozen dataclass | section 7 (`translate.problem.StepperSettings`) | | `tdse` | `TDSEDescriptor` or `None` | immutable atomic specification, resolved response lattice and scientific identity; section 5 | | `current_numerator` | `(n_modes,n_pol,n_omega) float64` or `None` | independent current coefficient numerator, `-crop_scale/(2 epsilon_0 c)` on active bins; divided by the stage real effective index | | `flux` | `FluxPlan` or `None` | a spatial run's plane measure ([SEP 34](seps/archive/sep-0034-medium-flux.md), `translate.spatial.FluxPlan`): `base`, the separable coefficients with the entrance material square, whose principal root gives the dimensionless axial admittance `c k_z/omega` of every column and bin (zero on nonpropagating, inactive and nonpositive-frequency bins); `material`, the z table of the complex material square `(omega n/c)²` for a varying radial medium, `None` for a uniform one; and `admission`, the varying path's adiabatic certificate. `at(z)` gives a plane's coefficients. Every spatial statistic weights its columns by the real admittance at its plane (energy, spectra, absorbed powers) or synthesises the closure `G` with the complex one (fluence, intensities, on-axis spectrum). Required with spatial geometry and `None` otherwise; its active mask equals `grids.active_mask`. Added in version 35 | | `precision` | `translate.precision.Precision` | the arithmetic the run is held in ([SEP 33](seps/archive/sep-0033-single-precision-mlx.md), ADR-054): `state` and `source`, each `"double"` or `"single"` and equal (a mixed record is refused). Every table here is binary64 either way; a backend that admits single precision narrows its tables on upload, each with its own power-of-two exponent, and holds the state at the launch's exact power-of-two scale. Under `"single"`, `validate` refuses the general modal route, the hybrid planar kind, coupled flows, TDSE responses, the dispersive electronic response, the far-field statistics and noise models; `numba_cpu` and `native` refuse a single-precision problem by name. Default `DOUBLE`. Added in version 36 | `KernelProblem` also carries one **property**, which is not a field and so not a row above: `device_ready` is true when Φ (§3) and the prefactor (§5) are each a weighted sum of a fixed set of arrays, and false when either took the z table. **How a stage is evaluated, and why `device_ready` exists.** Three of the quantities a stage needs are *arrays* that depend on z — the propagator's exponent ΔΦ, the response prefactor, and the noise's unitary phase — and a handful are *scalars*: the number density, the pre-ionisation, the mode set's taper, the Raman decay rates. The seam splits them in two, and `translate.stage` is the split: - **terms** (`stage.terms(problem)`), the fixed arrays, uploaded once per run. A constant quantity is one term; an expansion is `n_terms` of them. `StageTerms.separable` carries the shared spatial coefficients instead when both array term groups are absent. Its stage weights are the distance, position and unit prefactor weight. - **scalars** (`stage.scalars(problem, z0, z1)`), a small frozen record of host floats per stage: the weights that multiply the terms, and every scalar path value the responses read. `stage.anchor(problem, z0)` hoists the part every stage of one step shares. A backend evaluates `ΔΦ = Σ_j w_j · terms_j` (`stage.combine`) and knows nothing else about the representation. It is the arithmetic of `delta_phi` and `prefactor_at`, rearranged, so a backend that goes through the bundle reproduces them to rounding. **The z table has no such split** — its evaluation is a Clenshaw pass over an array of the state's size times the degree. NumPy evaluates it directly; Numba uses preallocated host stage arrays and the same Clenshaw order. The native CPU build holds the coefficients resident and evaluates the table at each native call's distinct positions into term slots, which the stage weights then select (`+-1` for an increment, one-hot for a value), so the bundle's arithmetic is unchanged and no field of the contract changes (SEP 29 section 6.6). A device build calls `stage.require_device_ready(problem, name)` and refuses a table, naming its size and, for a mixture, the labels whose independent profiles exceed the qualified tensor expansion. A total-degree expansion is not part of the contract: it failed the joint memory and build-cost criterion on the short fixture. The current version is **36** (`solprop.translate.problem.SCHEMA_VERSION`), and the tables of this document describe it. A version bump that adds an optional field leaves every problem that does not use it unchanged, `schema_version` itself being the one thing that moves. Removing an admitted value needs no bump, because no consumer can misread a problem that no longer carries it. **Batches and device memory.** `BatchBackend` batches independently translated and loaded member backends, which carry their own parameter tables and noise across construction; successful batch construction adopts their lifetime. `observe_members()` returns one ordinary host `Observation` per member, preserving independent error histories and periodic-statistics rows. `member_metadata()` returns each member's resolved policies plus batch settings. `Backend.available_device_memory()` returns current free device bytes, excluding live allocations and allocator reserves, or `None` for host execution; `StepDriver` provides the host default and a device backend overrides it. Common batch admission uses that probe before allocating a complete group, with 20% headroom and a conservative per-member upper bound from the complete memory ledger. Only compatible layouts under the same explicit fixed schedule can share a batch; mixed backend names are refused. The batch's `max_batch` is its constructed member count. Calling its single-observation API is refused because it would discard the independent diagnostic histories. The `StepDriver` signatures, stage bundles and singleton output schema are those of a singleton run. **The general-modal leaves.** `linear.coupled_flow` and `linear.modal_metric` (§3), `transform.modal_tables` (§4) and `responses.regional_responses` (§5) are optional. Each defaults to `None`, and a problem with all four `None` carries no field-sized or dense buffer for them. The leaves carry `int32` region and support indices beside float64, complex128, bool and intp. **A backend refuses by name each leaf it does not execute**, through `solprop.backends.driver.refuse_general_leaves(..., admits=...)`, or its own equivalent check in a compiled backend's adapter (as `native`'s does), because the version check alone cannot fire for a backend compiled against the same constant. The combinations a backend executes are one shared list, `solprop.backends.numpy_ref.admission.refuse_general_route`, run under the caller's name with the section methods it executes and whether it evaluates moving maps (`moving_maps=False` by default), so every backend refuses the same problems in the same words. On the general route `response_order` is `(accumulate,)` alone — the response is the regional leaf and not a fused-pass stage — and the prefactor row is the real crop scale. What a backend reads across the seam on that route is the leaves' arrays, `grids.crop_index`, `grids.pad_scale`, `n_to` and the prefactor row. **Stage-bundle rows of a coupled flow.** `StageScalars.flow_section` is which section of `linear.coupled_flow` the step's **anchor** lies in — every stage of one step carries the same value, because the exponential the interaction picture is built on is frozen for the whole step — and `flow_weights`, float64 of shape `(n_intervals * n_deg,)`, the weights the flow's own `(n_intervals, n_deg)` coefficient block is combined over at the stage's position, flattened in that order and zero outside the one section the stage is evaluated on. A problem carrying no coupled flow reports `flow_section = -1` and `flow_weights = None`. The rows are the seam's own evaluation form applied to the flow: a backend evaluates `G(z₁) − C(z₁) = Σ_j w_j · terms_j` exactly as it evaluates `ΔΦ`, and the explicit residual is that combination minus the frozen block `flow_section` names. **Which section a stage on a section edge belongs to is normative**: it is the step's own whenever the position lies in that section's closed interval, matched within 1e-10 of the flow's span, and the section the stage *closes* in otherwise. A section edge is a kink — the declared taper corner the sections were placed on — so a stage arriving at one must read the side it came from; reading the opening side instead drops a whole run from fifth order to **first** (measured 2.1e-3 at ten steps of a qualification taper, against 3.45e-9 once the tie is broken this way). A `"cf4"` section stores exactly what a `"frozen_residual"` one does — the same Chebyshev coefficients of `G` and `C` along z — and differs in how a backend integrates it and in what its rows mean: no explicit residual (`residual_norm_bound` is zero) and a pair bound for `inverse_condition_bound`. A CF4 step of length `l` from `z` exponentiates the whole generator `L = G − C` at the two Gauss nodes `z + c_j l`, which are **not** stage positions, so the stage bundle's `flow_weights` cannot supply them; `numpy_ref` builds them from the leaf itself — `solprop.translate.coupled.flow_weights` of the node against the step's `flow_section`, the same Chebyshev evaluation the bundle row is, from `z_edges` and the `n_deg` axis the leaf already carries. A compiled backend would need up to twelve such positions per step (two nodes at each of six distinct stage fractions); no bundle row carries them, which is why CF4 runs on `numpy_ref` alone. The Schur factors of a section's frozen block and the actions formed from them are backend-local and never cross the seam. The compiled backends form the actions in their own kernels, from the eigenbasis of each section's triangular factor (`solprop.translate.coupled.schur_eigenbasis`, host-side at construction and shared) or by the reference's scaling and squaring on the bins where that basis is ill-conditioned, so their actions are within a derived formation bound of the reference's rather than bitwise its (`tests/flow_parity.py`); the certificate is checked per step, on a compiled measurement, by the reference's rule. FSAL is **absent** on a flow rather than disabled by a flag: a coupled flow would need the analogous certified forward action at the old endpoint before any backend could reuse a stage ([stepper.md §2](stepper.md)). The vector wall block is the same `"constant_schur"` (or, on a varying guide, `"frozen_residual"`) leaf with a different dense generator — one azimuthal order of a capillary rather than one `HE_1m` family — and stores **no factored wall pair**, because its wall term is rank two and the pair's `w (v x v)` is rank one. Its `gauge_id` names the order it was built for (`"capillary azimuthal order nu=1"`) rather than `"capillary HE_1m"`, which is how a resume tells the two blocks apart. **Stage-bundle row of moving modal maps.** `StageScalars.transform_weights`, float64 of shape `(n_intervals * n_deg,)`, holds the weights `transform.modal_tables`' `synthesis` and `projection` are combined over at the stage's position `z1`, their `(n_intervals, n_deg)` coefficient axes flattened in that order and zero outside the one section `z1` lies in (`solprop.translate.modal_path.transform_weights`). A backend evaluates `S(z1) = Σ_j w_j S_j` and `Q(z1) = Σ_j w_j Q_j` in the seam's own evaluation form, exactly as it evaluates `ΔΦ`. The row is **separate from `flow_weights`**, so that the transform's sectioning is its own: the moving route puts the two on the same edges, which is natural and not required. **No one-sided rule** is needed for it, unlike `flow_weights`: the maps are continuous across a section edge even at a kink of the geometry, where only their slope jumps, so a position on an edge takes the section starting there (within 1e-10 of the span) and the value differs from the other side's by the representation error alone. A problem whose maps do not move — no modal tables, or maps of one coefficient — reports `None`. A backend that does not evaluate moving maps refuses such a problem by name through the shared admission list. **The frozen literals and the contract tuples.** `tests/backends/test_driver.py` freezes `DRIVER_SURFACE`, `LINEAR_OPERATOR_FIELDS` (`dataclasses.fields(LinearOperator)`), `STAGE_TERMS_FIELDS`, `STAGE_SCALARS_FIELDS` and `SEPARABLE_OPERATOR_FIELDS` under `SCHEMA_VERSION`; a bump rewrites the literal it changes. `solprop-native` exports its own tuples of the names it relies on — `LINEAR_OPERATOR_FIELDS`, `TRANSFORM_FIELDS`, `RESPONSE_FIELDS`, `STAGE_SCALARS_FIELDS`, and `MODAL_TABLES_FIELDS` (`electric`, `omega_fine`, `projection`, `region_index`, `source_frequency_factor`, `synthesis`), `REGIONAL_RESPONSE_FIELDS` (`n_terms`) and `COUPLED_FLOW_FIELDS` (`connection`, `generator`, `inverse_condition_bound`, `n_deg`, `n_modes`, `section_methods`) for the members of `ModalTransformTables`, `RegionalResponseTables` and `CoupledFlowTables` its adapter reads itself — and its `check_build()` refuses a solprop without one (a dataclass field, or a property for the derived counts), so a renamed leaf or member fails there with a message naming it rather than as a missing attribute inside the adapter. The two sets are separate objects that this document keeps in step. Members read only inside solprop's own derivations the adapter calls (`node_kerr_tensor`, `node_delayed_maps`, `metric_at`, `frozen_generator`, `schur_blocks`) are solprop's to keep consistent and are not listed; for that reason `ModalMetricTables` has no tuple, the adapter handing the leaf to `metric_at` whole. The dataclass tree is exactly `KernelProblem` and the six array groups `grids`, `linear`, `transform`, `responses`, `initial` and `stepper`; every other value a field holds — a `Flags`, a `StatsSpec`, a `Tableau`, a `PIController`, a `RateTable`, a `ZProfile` table — is a leaf whose own fields are not rows of this document. Fields whose name begins with an underscore are implementation detail and are outside the contract too. The test that compares this document with the dataclass walks the tree by that rule, in both directions; a second test checks the flags and counts named in the `flags` row of this section against the fields of `Flags`. **Source reservations in the shared Adams march.** The protected hooks `StepDriver._reserve_source_calls(count)` and `StepDriver._release_source_calls()` bound the source evaluations of a trial. Before starting a trial, the shared march reserves its required source evaluations through these hooks; on every exit it releases unused capacity. The ordinary backend implementation needs no atomic reservation. NumPy's TDSE implementation translates the reservation into the required persistent-plan chunks, while provider attempts remain charged before invocation, including failures. Logical source-call and atomic-attempt ledgers remain separate. The base driver owns `_adams`, initially `None`, and exports and restores its logical ledger on every Adams backend. Loading a checkpoint resets numerical history but preserves charged calls; startup is charged again. Atomic ledger restoration remains exclusive to the bound NumPy TDSE path. **The lab-frame source of the exponential formulation.** `StepDriver._adams_lab_source(lab, position, *, purpose)` is the complete source assembly at a lab-frame field and position -- the `modified` noise draw advanced to that position's absolute phase, the in-stage absorbers and, on the reference backend, the TDSE adapter with its labels -- returned in the lab frame, with no propagator applied. The exponential Adams formulation ([SEP 16](seps/archive/sep-0016-unified-multistep.md)) stores these untransported samples and integrates their polynomial against the linear flow; the base driver's hook refuses, naming the backend, so a backend that has not implemented it refuses the formulation rather than stepping it with another method. `Multistep.formulation` is `"interaction"` or `"exponential"`. **The Adams engine.** With `StepDriver._adams_engine(method, compensation)`, `AdamsMarch` keeps the controller -- orders, slots, rejection, restarts, the call budget and the ledger -- and hands every state-sized operation of its fused and exponential paths to an engine (`solprop.backends.multistep.AdamsEngine`): the startup sample, a trial's predictor, source, corrector and scored defect norms, the adoption and transport of an accepted step, holding and restoring the accepted state around a dense observation, and the observation itself. Only scalars, the coefficient tables and the norms cross the interface. The default returns `None`, and the march uses `HostHistory`, the host engine, over the other Adams hooks; `native` returns one resident in its native state. The general path (PECE and the comparison variants) keeps host arrays. `StepDriver._adams_source(lab, anchor, position, *, purpose)` takes a `TDSEPurpose` label so startup, predictor, corrected-field and diagnostic observations retain their actual provenance. This is a host seam operation: the shared march neither accesses NumPy's private binding nor adds atomic physics to a compiled backend. Numba and native continue to refuse TDSE by name. A corrected-field diagnostic still needs an identical cached response or a separately charged solve; a predictor label never implies accepted-field data. If the corrected-field solve fails after candidate adoption, `_adams_restore` restores the previous accepted field and position before a checkpoint is written. **A `ZProfile` table** is how anything that varies along z crosses the seam: a scalar-valued `translate.chebyshev.PiecewiseChebyshev` over the same z range as `Phi`, built by `translate.linop.tabulate`. A backend evaluates it with one Clenshaw pass on the host and never sees a Python `ZProfile`. Where a path is exactly zero at an interval edge (a declared vacuum), `translate.linop.profile_table` makes its table exactly zero there too, within the table's tolerance, so the non-negativity check on density tables never meets the fit's rounding. `validate()` checks: all arrays finite, C-contiguous, read-only, and of the stated dtype (float64, complex128, bool and intp everywhere, and `int32` at exactly three declared index leaves — `transform.modal_tables.region_index`, `responses.regional_responses.response_region` and `responses.regional_responses.kernel_valid_samples`, named in `translate.problem._INT32_LEAVES`; the dtype rule applies to every leaf the tree walk reaches, so `int32` is admitted by name and not globally) and shape; masks Boolean; breakpoints strictly increasing and covering `[0, z_max]`; hard kinks are breakpoints; every `ZProfile` table covers the same z range as `Phi`; every general-modal leaf tabulated along z covers `[0, z_max]` and carries `grids.omega` itself; a replayed schedule lands on every save plane and hard kink; `schema_version` equals the backend's expected version. A failed check raises `TranslationError` naming the field. Quadrature weights and mode matrices are checked in `modes.quadrature`; physical modal weights are also carried as `transform.node_weights` for species inventories. ## 2. Grids and transforms | field | shape / dtype | meaning | |---|---|---| | `omega` | `(n_omega,) float64` | coarse angular frequencies (field: non-negative half spectrum; envelope: FFT order about `omega0`) | | `omega0`, `dt`, `dt_fine` | float | reference frequency, coarse and fine time steps | | `n_t`, `n_to`, `ffac` | int | coarse and fine time lengths, oversampling factor | | `crop_index` | `(n_omega,) intp` or slice descriptor | where coarse bins sit in the fine spectrum (field: leading bins; envelope: leading and trailing halves) | | `pad_scale`, `crop_scale` | float | the scaling factors of the zero-pad/crop operations | | `inband_mask` | `(n_omega,) bool` | physical frequencies inside the wavelength limits, the requested band | | `active_mask` | `(n_omega,) bool` | Luna's `sidx`, `ω_min/2 < ω < 1.1 ω_max`: the hard band mask (0/1 per accepted step); `D` and the prefactor are zero outside it | | `omega_window` | `(n_omega,) float64` | the taper applied to the nonlinear polarisation in the spectral collar: the grid's collar profile (1 in band) when `assemble(..., polarisation_taper=True)`, all ones by default. The rate absorber on the field is inside `Phi` either way; the taper is Luna's `ωwin`, kept as an option because it sets a floor of the size of the field's content in the collar | | `t_window_fine` | `(n_to,) float64` | temporal window applied inside the response pass | | `t_window_fine_derivative` | `(n_to,) float64` | analytic derivative of the temporal collar, s⁻¹; the held field-plasma source uses this for its second current stream | | `carrier_2w0` | `(n_to,) complex128` or `None` | `exp(2 i ω₀ t)` on the fine time axis, present exactly when a third-harmonic Kerr term is compiled on an envelope grid: the constant term with `thg`, or the dispersive term's full cubic (§5), whose one-into-three group takes its conjugate. That term sits an offset `2ω₀` from the carrier the envelope is written against, so the response pass needs the factor; built from `nonlinear.responses.thg_carrier` and the grid's own `t_fine`, because a backend deriving it from `omega0`, `n_to` and `dt_fine` would be carrying a piece of physics | | `norm_mask` | `(n_omega,) bool` | the bins the error norm scores: `active_mask` by default, `inband_mask` when the controller's `region` is `"band"`. Every backend's norm, the Adams engines' state magnitude and a frozen-residual flow's residual bins read it | ## 3. Linear operator The scalar parameters an expansion runs over are named by the mode set's `parameter_paths`: a capillary filled with one gas declares `core_radius` and `density`; a fill of several species at one total pressure declares `core_radius` and `pressure`, so the two-parameter expansion still applies and a one-component fill stays exactly the single-gas case; independent per-species density profiles declare one `density: