Add Function
Shared source of truth for validating a newly added spectral, time, or profile function and generating the standard test class. Use this after the function implementation already exists in the appropriate source module.
This guide covers both:
making the function available on the MCP/reference path
checking whether additional GIR work is needed for compiled execution
Arguments
Format:
<function_name> <module>function_name: the Python function name (e.g.GLP,expFun,pGauss,gaussCONV)module: one ofenergy,time,profile
Module mapping
module |
source file |
|---|---|
|
|
|
|
|
|
Test file mapping (note: CONV kernels live in time.py but test in a separate
file):
Energy functions ->
tests/test_functions_energy.pyTime dynamics functions ->
tests/test_functions_time.pyTime convolution kernels (
*CONV) ->tests/test_functions_convolution.pyProfile functions ->
tests/test_functions_profile.py
When routing to the test file, check the function name first: if it ends with
CONV, always use test_functions_convolution.py regardless of module.
1. Check for duplicates
Read all existing functions in the same module. Compare the new function’s formula against existing ones. Flag if another function computes an equivalent or near-equivalent result (e.g. same shape with different parameterization). If a duplicate is found, stop and ask the user whether to proceed or reuse the existing function.
2. Validate the function
Read the source file and find the function. Verify:
[ ] Function exists in the expected module
[ ] Has NumPy-style docstring (Parameters, Returns, Notes if physics context)
[ ] Empty line after docstring
[ ]
#\nbefore function definition[ ] No underscores in function name (enforced by guard test)
[ ] Profile functions start with
pprefix[ ] Convolution kernels end with
CONVsuffix and are elementwise in their first argument (they are evaluated on the 2D dt matrix of the kernel-matrix convolution operator)[ ] Convolution kernels have a private edge-mass companion
_<name>_edge_mass(dt_left, dt_right, *params)registered inCONV_EDGE_MASS(infunctions/time.py). It must return the exact analytic integrals of the kernel body — including its normalization — over(-inf, t[0]]and[t[-1], inf), using cancellation-safe tail forms (erfc / direct exp / clip, neverG(inf) - G(x)CDF differences). Companions must validate their parameters via_validate_kernel_par(strictly positive, finite) — they are the runtime backstop for expression-driven kernel parameters that bypass model-load bound checks. A kernel without a companion fails model validation (mcp) and scheduling (GIR). Kernels whose body has no closed-form antiderivative do not fit this contract (this is whyvoigtCONVwas removed).
Note: these functions do NOT use * for keyword-only args because the
framework calls them via self.fct(x, **parameters).
Additionally, verify the function’s signature matches its registration:
[ ] If the last parameter is
spectrum-> it’s a background function -> verify it is listed inbackground_functions()insrc/trspecfit/config/functions.py. If missing, flag it.[ ] If the last parameter is NOT
spectrum-> verify it is NOT listed inbackground_functions(). A mismatch means either the signature or the registration is wrong -> flag it.
Registration notes:
Background functions are manually listed in
src/trspecfit/config/functions.py::background_functions().Convolution kernels are discovered dynamically by their
CONVsuffix inconfig/functions.py(no manual registry there), but additionally require two manual entries: theCONV_EDGE_MASScompanion registry infunctions/time.py(mcp path; missing entry fails model validation) and, for GIR support,CONV_EDGE_MASS_DISPATCHineval_2d.pyalongsideCONV_KERNEL_DISPATCH(missing entry fails scheduling). A completeness test asserts kernels and companions stay in lockstep.
Module-specific guidance:
For time dynamics functions in
src/trspecfit/functions/time.pythat are defined as zero beforet0and active afterward, prefernp.whereovernp.concatenatefor the piecewise definition.
Finally, verify the function is discoverable by the framework:
[ ] Function name does NOT start with
_(the framework discovers functions viadir(module)filtering out private names inconfig/functions.py::all_functions())[ ] No other callable with the same name exists in a different module (would cause ambiguity in function lookup)
Report any issues and fix them before proceeding.
2.5. Make GIR support the default when feasible
Adding a function to functions/*.py makes it available to the MCP/reference
path, but that does not automatically make it available to the compiled
GIR path.
Default policy:
[ ] Try to make the new function work on GIR when it fits the current compiled architecture.
[ ] If the function does not fit cleanly, fall back to MCP intentionally and explain why GIR support failed.
[ ] When GIR support fails, give at least one concrete recommendation for how the function signature or behavior could be adjusted to make GIR support practical in a follow-up change.
Use MCP-only / fallback-only as the exception, not the default.
When attempting GIR support, check the relevant pieces below.
Energy functions:
[ ] Add lowering support in
src/trspecfit/graph_ir.pyif the function needs a new opcode or name mapping.[ ] Update the compiled evaluator path if the new opcode needs explicit runtime dispatch or special handling.
[ ] Verify
can_lower_1d()/can_lower_2d()behavior is still correct for the new function.[ ] If lowering fails, explain whether the blocker is the function shape, unsupported argument pattern, or missing opcode/dispatch support.
Time dynamics functions:
[ ] Add the function to the dynamics enum/name mapping in
src/trspecfit/graph_ir.py.[ ] Add runtime dispatch in
src/trspecfit/eval_2d.py(DYNAMICS_DISPATCH).[ ] Verify the parameter count matches between lowering and evaluator dispatch.
[ ] Verify
can_lower_2d()accepts the new function where intended.[ ] If lowering fails, recommend a signature that matches the compiled dynamics pattern (typically
func(t, par1, ..., t0)with vectorized NumPy math and no extra runtime context).
Convolution kernels:
[ ] Add the kernel to the convolution enum/name mapping in
src/trspecfit/graph_ir.py.[ ] Add runtime dispatch in
src/trspecfit/eval_2d.py(CONV_KERNEL_DISPATCHandCONV_EDGE_MASS_DISPATCH— the edge-mass companion, keyed by the same enum; scheduling fails without it).[ ] Verify the kernel is elementwise in its first argument (it is evaluated on the deduplicated dt values; no shape assumptions, no normalization requirements — the operator row-normalizes; the edge-mass companion must match the body’s normalization though).
[ ] Verify unsupported kernels still fall back cleanly to MCP.
[ ] If lowering fails, recommend a kernel signature and behavior compatible with the current compiled path (pure elementwise kernel function, explicit parameter list, no hidden state).
Profile functions:
[ ] Add the function to the profile enum/name mapping in
src/trspecfit/graph_ir.py.[ ] Verify the lowered 1D/2D profile evaluators can execute it without extra special-casing.
[ ] Verify
can_lower_1d()/can_lower_2d()behavior is still correct for profiled models using the new function.[ ] If lowering fails, recommend a vectorized signature that matches the current profile model (
func(x, par1, ...)returning broadcast-friendly NumPy arrays).
GIR parity test (required when GIR support lands)
The unit tests in §3 call the function directly, so they do NOT exercise the
compiled enum/dispatch/param-count wiring. A wrong DYNAMICS_DISPATCH /
CONV_KERNEL_DISPATCH param count, a missing enum entry, or a dispatch typo
passes every unit and signature-discovery test and only breaks when the
function is actually lowered. So when the function works on GIR:
[ ] Add an end-to-end parity case that drives the function through the compiled path and compares against the MCP interpreter, via the shared
_compare_evaluator_vs_interpreter+_perturb_thetahelpers. Usetests/test_evaluate_1d.pyand/ortests/test_evaluate_2d.pyas applicable, matching whichever ofcan_lower_1d()/can_lower_2d()the function targets above. Dynamics drivers and convolution kernels have parametrized lists to extend in the 2D file (_DYN_DRIVER_PARITY_CASES,_KERNEL_PARITY_CASES); energy/profile functions have per-function parity tests to mirror.[ ] Where the function has several parameters, perturb more than one so a parameter-order swap can’t slip through. Watch for init values that mask an ordering bug: if two args are both 0 at init, set one nonzero so a swap actually changes the output.
Before finishing, explicitly state one of:
Function is available on MCP only; GIR falls back intentionally.Function is available on both MCP and GIR paths.
If you report MCP-only / fallback-only, also include:
the specific GIR blocker
one recommended signature or behavior change that would make future GIR support easier
3. Generate the test class
Add a test class to the appropriate test file. The class name is
Test<FunctionName>. Follow the testing rules in CLAUDE.md.
Include all applicable tests from the checklist below.
Test checklist by function type
Energy functions (peak-like: has A, x0 parameters):
test_peak_at_center -- result at x0 equals A (analytical)
test_zero_amplitude -- A=0 returns all zeros, no NaN
test_zero_width -- width param -> 0 returns finite, no NaN/Inf
test_symmetry -- symmetric about x0 (if applicable)
test_fwhm -- independent FWHM check (not formula reimplementation!)
test_pure_limit -- if mixing param exists, check pure Gaussian/Lorentzian limits
Not all peak-like tests apply to every function. Skip tests that don’t fit:
Asymmetric lineshapes (e.g. DS):
Amay be a scaling factor, not the peak value -> skiptest_peak_at_center. Replacetest_symmetrywithtest_asymmetry.test_fwhmmay not have a clean closed form -> skip unless it does. Addtest_peak_near_center(argmax ~= x0) instead.
Energy functions (background-like: Offset, Shirley, LinBack):
test_basic_shape -- output has expected shape and is finite
test_analytical_value -- check against known analytical value
test_zero_params -- zero parameters produce expected output
Time dynamics functions:
test_value_at_t0 -- check value at t=t0 (analytical)
test_asymptotic_behavior -- check long-time limit (analytical)
test_zero_amplitude -- A=0 returns all zeros
test_monotonicity -- monotonically increasing/decreasing where expected
Convolution kernels (*CONV suffix):
test_peak_at_center -- peak is at x=0
test_peak_value_is_one -- peak value equals 1.0
test_half_max_at_half_width -- FWHM check using independent property, NOT formula reimplementation
test_zero_for_negative_x -- (causal kernels only: expDecayCONV)
test_zero_for_positive_x -- (anti-causal kernels only: expRiseCONV)
test_decays_monotonically -- monotonic decay away from peak
Additionally, register the kernel in _KERNEL_TEST_PARAMS in
tests/test_functions_convolution.py: the parametrized
TestConvEdgeMassCompanions suite iterates CONV_EDGE_MASS and checks
every companion against scipy.integrate.quad of the kernel body, plus
registry/dispatch completeness — verify the new kernel is picked up
(the sync test fails until the params entry exists).
Profile functions:
test_value_at_zero -- check pFunc(0, ...) analytically
test_monotonicity -- if monotonic, verify with np.diff
test_zero_params -- zero amplitude/slope returns zeros
Critical test rule
Never reimplement the formula being tested as the expected value. Use independent analytical properties instead (peak=A, FWHM=W, symmetry, limiting cases, monotonicity).
For pure-limit / cross-check tests (e.g. GLS(m=1) vs Lorentz), the
parameter mapping between functions may not be 1:1 (e.g. DS F maps to Lorentz
W=2F). Verify the mapping numerically before writing the test.
4. Review and consolidate
After adding tests, review ALL tests in the class (both pre-existing and new) against these quality criteria:
Remove tests that:
Reimplement the formula as the expected value (e.g. computing
A * exp(-x**2 / (2*SD**2))and comparing — that just mirrors the source)Duplicate another test’s assertion (e.g. two tests both checking peak = A at
x0with different parameter values — keep one with the most general params)Test the same property as another test but with a trivial parameter change (e.g.
test_peak_value_m0andtest_peak_value_nonzero_m— onetest_peak_at_centerwithm != 0covers both)
Keep tests that verify independent analytical properties:
Peak value, FWHM, symmetry, monotonicity, limiting cases, zero params
Pure-limit tests that compare against another function in the library (e.g.
GLS(m=1)vsLorentz) — these are cross-checks, not reimplementations
Target: one test per independent property, no redundancy. Aim for the checklist count (typically 4-7 tests per function).
5. Run tests
Run the test file to verify all new tests pass, then run the full suite:
pytest tests/<test_file>.py -q
pytest -q
6. Summary
Print a summary of what was created:
Function: <name> in <module>
Tests: <test_file> -- class Test<Name> (N tests)
Status: All tests passing (M total)