# Derivative Backend Policy — Design Document **Status:** DESIGN-FROZEN in v0.30a; **`finite_difference` v1 IMPLEMENTED in v0.30c** along with the `compute_derivatives(backend="auto")` dispatcher. Boundary-aware readiness warnings and the `residual_domain_policy` reporting field also land in v0.30c. **Residual evaluator auto-dispatch and interior-only diagnostics landed in v0.30d**: Heat, Burgers, advection-diffusion, and reaction-diffusion evaluators now route through `compute_derivatives(backend="auto")` and consume `recommended_residual_domain_policy` / `recommended_boundary_trim_width` from the `DerivativeBatch.config`. KdV and the weak evaluators remain periodic-only per v0.30 scope. This document defines the policies governing PDELie's derivative backends after v0.30. The runtime in v0.30a is unchanged. ## Backends covered After v0.30, two derivative backends exist: - `spectral_fd` (existing): FFT-based spectral spatial derivatives + 2nd-order centered finite differences in time. Periodic-x only. - `finite_difference` (new in v0.30 proper): centered finite differences with one-sided stencils at boundaries. Nonperiodic-x only. A third name, `compute_derivatives(..., backend="auto")`, is a dispatcher, not a backend. ## `spectral_fd` policy **Unchanged from current behavior.** - Periodic-x is required. The check at `src/pdelie/derivatives/spectral_fd.py:32-33` stays in place. - Spatial derivatives are computed via FFT phase factors `(ik)^n`. This is mathematically correct only for periodic data; loosening the check would silently produce garbage at the boundaries due to Gibbs ringing. - Output is unchanged: `DerivativeBatch(backend="spectral_fd", ...)`. After v0.30 the BC check reads `metadata["boundary_conditions"]["x"]["type"] == "periodic"` against the new structured spec, not the legacy string. The semantics are identical for callers. ## `finite_difference` policy (v0.30 proper) A new module `src/pdelie/derivatives/finite_difference.py` will implement `compute_finite_difference_derivatives(field, *, max_spatial_order=2)`. ### Accepted BCs - `"dirichlet"` - `"neumann"` - `"open_unknown"` Periodic data passed to `finite_difference` raises `ScopeValidationError`. Periodic users must use `spectral_fd`. The dispatcher (below) handles the auto-routing. ### Order limits The stable v0.30 surface supports: - `u_t` via `np.gradient(values, dt, axis=t_axis, edge_order=2)` (unchanged from the time-derivative computation in `spectral_fd`) - `u_x` via `np.gradient(values, dx, axis=x_axis, edge_order=2)` - `u_xx` via `np.gradient(u_x, dx, axis=x_axis, edge_order=2)` `u_xxx` and `u_xxxx` are **not** part of the stable v0.30 surface on nonperiodic data. Calling `compute_finite_difference_derivatives(field, max_spatial_order=3)` or higher raises `ScopeValidationError` unless an `experimental_high_order=True` flag is set, and even then the output is documented as advisory and may change. #### Why no high-order FD on nonperiodic data Repeated `np.gradient` propagates boundary error. The one-sided edge stencil at order 2 has the same asymptotic O(h²) interior accuracy as centered FD, but a larger constant. Each derivative step pulls the boundary error one stencil width into the interior. By `u_xxxx` on N≈64-point grids, the boundary-corrupted region dominates the interior region; the residual statistics become unreliable. For periodic data the same chain works because FFT-based derivatives have no boundary problem at all. For nonperiodic data the right answer is a higher-order one-sided stencil family (Fornberg-style), or weak/Galerkin integration to bypass derivative estimation entirely. Neither is in scope for v0.30; both are candidates for later releases. ### `DerivativeBatch.config` requirements The backend must populate: ``` { "spatial_method": "finite_difference_centered", "temporal_method": "finite_difference", "temporal_edge_order": 2, "spatial_edge_order": 2, "spatial_max_order": , "boundary_handling": "dirichlet" | "neumann" | "open_unknown", "boundary_left_specified": , "boundary_right_specified": , } ``` `boundary_left_specified` and `boundary_right_specified` read directly from the structured `BoundaryConditionSpec` faces. They allow downstream consumers (residual evaluators, readiness reports) to flag derivative results that depended on an unspecified boundary. `DerivativeBatch.boundary_assumptions` records `" in x; finite differences in time"` for human inspection. ## Dispatcher: `compute_derivatives(field, *, backend="auto", max_spatial_order=2)` A new helper, exported from `pdelie.derivatives`, selects the right backend based on the field's BC. ### Dispatch table - `field.metadata["boundary_conditions"]["x"]["type"] == "periodic"` → `spectral_fd` - `field.metadata["boundary_conditions"]["x"]["type"] in {"dirichlet", "neumann", "open_unknown"}` → `finite_difference` ### Explicit backend selection - `backend="spectral_fd"` on nonperiodic data → `ScopeValidationError` - `backend="finite_difference"` on periodic data → `ScopeValidationError` - `backend="auto"` always succeeds for any supported BC type ### Never silent The dispatcher must record its selection in `DerivativeBatch.config`: ``` { ..., "backend_selected_by_boundary_condition": True, "backend_selection_reason": "periodic_x_boundary" | "nonperiodic_x_boundary", } ``` This is non-negotiable. A user reading a `DerivativeBatch` from disk must be able to tell which backend produced it without re-deriving the choice from `boundary_conditions`. ### Order capping under dispatch When `backend="auto"` resolves to `finite_difference`, requesting `max_spatial_order > 2` raises `ScopeValidationError` with the same message as the direct path. The dispatcher does not silently downgrade orders. ## Residual evaluation domain policy The existing residual evaluators (`HeatResidualEvaluator`, `BurgersResidualEvaluator`, `AdvectionDiffusionResidualEvaluator`, etc.) currently compute residuals over the full grid. On nonperiodic FD-derived data, near-boundary residuals are dominated by one-sided-stencil error and are not representative of interior residual quality. ### New summary field `summarize_residual_batch` (in `src/pdelie/reporting/summaries.py`) gains a new field `"residual_domain_policy"` with vocabulary: - `"full_grid"` — RMS / max over all grid points (the historical behavior for periodic data) - `"interior_only"` — RMS / max over the interior excluding a boundary band - `"drop_boundary_width_k"` — explicit boundary width `k` dropped from both ends ### Default policy - Periodic data: `"full_grid"`. No boundary problem exists for spectral derivatives, so no need to trim. - Nonperiodic FD-derived data: `"interior_only"` with default boundary width `k = 4`. The choice of `k = 4` matches the effective stencil width of `u_xx` computed as `np.gradient(np.gradient(u))`: centered second derivative consumes a 5-point stencil, edge-order-2 handling at each boundary consumes up to 3 extra points of error propagation; `k = 4` provides a safe margin. ### Reporting both When a residual is computed on nonperiodic data, the summary SHOULD include both `"residual_domain_policy": "interior_only"` (the primary statistic) and a nested `"full_grid_diagnostic"` block with the same metrics computed over the full grid, so users can see the boundary contamination explicitly. This is recommended, not required, in v0.30 proper. ## Translation finite-transform policy on nonperiodic data `verify_translation_generator` at `src/pdelie/verification/finite_transform.py:64` and `_apply_uniform_translation` at `:21` remain **periodic-only**. The FFT phase-shift method requires periodicity; there is no correct alternative on a finite domain without a domain policy. ### What is explicitly rejected A `_apply_uniform_translation_open` that uses `np.roll` + extrapolation is **not approved**. `np.roll` does not translate a non-periodic function — it wraps values that have no physical meaning at the wrap point, and any extrapolation scheme (constant, linear, reflective) introduces O(1) artifacts in the residual. ### What is deferred The correct approach for nonperiodic translation is overlap-crop: 1. Apply translation `ε`: shift the coordinate axis by `ε`. 2. Crop the field to the overlap interval `[a + |ε|, b − |ε|]` where `[a, b]` is the original domain. 3. Compute the residual on the cropped interior only. This requires a domain policy (how to handle multiple overlapping translations, what minimum overlap is acceptable, how to compare orbits of different lengths). The full design is deferred to **v0.31.5 — Nonperiodic Orbit/Action Scope Decision** in the roadmap. ## Numerical sanity tests required for v0.30 proper These are not implemented in v0.30a (it ships no runtime code). They are specified here so v0.30 proper has a clear acceptance contract. ### Manufactured analytic functions For each backend × order combination, test against closed-form derivatives of: - `u(t, x) = sin(k·x − ω·t)` on `[0, 2π]` (periodic check for spectral; reflected to `[0, π]` for FD where periodicity does not apply) - `u(t, x) = tanh(α·x − v·t)` on `[−5, 5]` (smooth, non-periodic, Dirichlet boundaries known) - `u(t, x) = x³ − 3xt²` on `[−1, 1]` (polynomial, exact closed-form derivatives at all orders) For each test: - Compare `compute_finite_difference_derivatives` outputs against the closed-form `u_t`, `u_x`, `u_xx` evaluated at the grid points. - Assert max-absolute error in the interior decreases at rate O(h²) as the grid is refined. - Assert max-absolute error in the **boundary band** (first/last `k` points for `k = 4`) is bounded but does **not** assert O(h²) — boundary stencils have a larger error constant. ### What not to test **Do not test against `np.gradient`.** The new backend is built on `np.gradient` internally; testing it against itself is meaningless. Tests must use closed-form analytic derivatives. ### Convergence study For at least one manufactured solution, run the backend at `N = 32, 64, 128, 256` and assert the interior-only max error halves to within 10% per grid doubling (the expected O(h²) order). The boundary-band max error must not blow up — a coarse bound like `< 10× interior_max` is sufficient. ## Implementation pointer (for v0.30 proper) The most surgical path: 1. Add `src/pdelie/derivatives/finite_difference.py` with `compute_finite_difference_derivatives` mirroring the structure of `src/pdelie/derivatives/spectral_fd.py`. 2. Add `compute_derivatives` to `src/pdelie/derivatives/__init__.py` as a thin dispatcher. 3. Update `src/pdelie/residuals/heat_1d.py`, `burgers_1d.py`, `advection_diffusion_1d.py`, `reaction_diffusion_1d.py` to call `compute_derivatives(..., backend="auto")` instead of `compute_spectral_fd_derivatives(...)` when no derivatives are supplied. KdV stays periodic-only (its evaluator already requires `equation == "kdv_normalized"`, which only the periodic generator produces). 4. Update `summarize_residual_batch` to surface `residual_domain_policy`. 5. Add `compute_finite_difference_derivatives` to `tests/test_public_api.py::test_runtime_package_api_is_importable` and `tests/test_api_stability_audit.py::_ROOT_RUNTIME_NAMES`-style negative checks (it must not be a root export). 6. Update `docs/specs/API_STABILITY.md` with a "Runtime public API for the frozen `v0.30` Milestone 2 slice" subsection describing `compute_finite_difference_derivatives` and `compute_derivatives`. The lazy-import pattern at `src/pdelie/discovery/pysindy_adapter.py:12-20` does not apply: `finite_difference` uses only `numpy`, which is already a core dependency.