Heterodyne Anti-Degeneracy System (4 layers)¶
Heterodyne uses a 4-layer anti-degeneracy defense, mirroring homodyne’s system minus L5 (shear-sensitivity weighting).
Per-Angle Modes¶
xpcsjax heterodyne supports the same three per_angle_mode values as
homodyne (constant / averaged / individual, resolved from the
auto default; see
https://homodyne.readthedocs.io/en/latest/theory/anti_degeneracy.html
for full theory):
Mode |
Optimizer params (K=2, n_phi) |
Notes |
|---|---|---|
|
|
beta, offset pre-estimated from quantile and frozen |
|
depends on n_phi: n_phi < 3 -> individual n_phi >= 3 -> averaged |
recommended default |
|
|
free per-angle scaling |
Joint global escapes¶
Beyond the per-angle defense layers, heterodyne provides two joint-fit
global escapes that search the full [physics | scaling] vector. Both are
real global escapes (no longer the Phase-6 minimal stubs):
_fit_joint_cmaes_multi_phi(enable_cmaes=True)A seed-pinned CMA-ES global search over the joint vector, reusing the shared
xpcsjax.optimization.nlsq.cmaes_wrapper.fit_with_cmaes()._fit_joint_multistart(multistart=True)A seed-pinned Latin-Hypercube multistart sweep over the joint vector, reusing the shared
run_multistart_nlsq(each start re-runs the plain joint fit seeded atx_start).
Both escapes are keep-better — they are accepted only if their data-only
SSR beats the plain NLSQ joint fit — and best-effort fall back to the plain
joint fit on failure. This is the joint-fit global escape; the per-angle
escapes were already real. Together these closed the joint-escape parity gap
with laminar_flow. The escapes are strategy-level and do not touch the
anti-degeneracy controller.
An escape result is tagged nlsq_diagnostics["global_escape"] ("cmaes" or
"multistart"; the key is absent on a plain joint fit) and, by
construction, carries NaN covariance / parameter_uncertainties and
n_iterations=0 — the escape returns a pre-accepted (compared-and-kept)
vector with no covariance solve, so consumers needing uncertainties should
detect an escape result via the global_escape tag.
Defense Layers¶
The table below summarises which layers are active on each heterodyne solve
path. “Gated” means the layer runs only for the listed
per_angle_mode values.
Layer |
Standard joint-fit path |
STREAMING path |
Notes |
|---|---|---|---|
L1 |
all modes |
all modes |
averaged/individual/constant reparameterization of the scaling tail |
L2 |
|
|
Standard and STREAMING paths gate L2 on opposite per-angle modes – see the L2 prose below |
L3 |
all modes |
whenever there is a scaling tail (group-variance config on plain
branch; |
Mode-aware group indices |
L4 |
all modes |
constructed whenever there is a scaling tail, but only checked
on the L2 (hierarchical) branch’s |
Strictly observational where it runs; monitor-on == monitor-off objective |
L5 |
not applicable |
not applicable |
|
- L1: Mode-level reparameterization
Selected by
per_angle_mode. Removes the flat optimization direction algebraically. Active on both the standard joint-fit path and the STREAMING path for all modes exceptconstant(which freezes the scaling tail entirely inside the JIT closure).- L2: Hierarchical optimization
The standard path and the STREAMING path gate L2 on opposite per-angle modes – this is a real, non-obvious asymmetry, not a typo:
Standard path: activated by
config.enable_hierarchicalfor theconstant/averagedjoint solve (_build_joint_problem/_fit_joint_multi_phi). Runs as a two-stage solve: stage 1 fits the physics-only parameters with quantile-frozen scaling (the same path the standaloneconstantmode uses); stage 2 warm-starts the joint solve from the stage-1 estimate. Stage-1 χ² and the stage-1/stage-2 χ² ratio are recorded inresult.nlsq_diagnostics['hierarchical']. This is an inline two-stage implementation, not a delegation to the sharedHierarchicalOptimizer(used by the STREAMING path below) — see the follow-up tracking item for unifying with homodyne’s helper.For
individualmode, the standard path does not go through the joint solver at all – it runs a separate per-angle sequential fit where each angle’s scaling is already fixed at its pre-computed value (the per-angle equivalent of stage 1). A second joint refine across angles is precisely whatindividualmode declines to do, so there is no stage 2;result.nlsq_diagnosticsreportshierarchical_scope='individual_mode_no_stage2'whenenable_hierarchicalwas requested anyway.STREAMING path: wired via
HierarchicalOptimizerand gated toindividualmode (i.e.not use_constant— exactly mirroring laminar_flow’s streaming gate).averagedandconstantmodes skip L2 because they have at most 2 per-angle DoF, so gradient-cancellation degeneracy cannot arise.The STREAMING path builds its parameter vector directly as
[contrast, offset, physics](per-angle-scaling-first) – this is already the[per_angle_params, physical_params]layout thatHierarchicalOptimizerexpects, so no permutation is needed; the vector is passed straight through asp0. Covariance on this path is computed from the Hessian of the loss function (2 s^2 (H^{-1}), or a pseudo-inverse ifHis singular); it becomes an identity placeholder (info["covariance_is_placeholder"] = True) only when the Hessian computation itself raises. In that fallback case the post-solve condition number is reported asNaNrather than the meaninglesscond(I) = 1. The L4 monitor is applied inside the L2 branch’s owngrad_fnclosure, which callsgradient_monitor.check(...)on every hierarchical-solver gradient evaluation.- L3: Adaptive CV regularization
Activated by
config.regularization_mode != 'none'. Instantiatesadaptive_regularization.AdaptiveRegularizerkeyed to the per-angle scaling groups (contrast + offset) and appends JAX-traceable penalty rows to the augmented residual. The'adaptive'mode uses the relative/CV penalty branch;'absolute'uses the absolute branch. Active group indices and penalty weights are recorded inresult.nlsq_diagnostics['regularization'].- L4: Gradient collapse monitor
Activated by
config.enable_gradient_monitoring. Implemented as a per-iteration gradient-collapse monitor shared with homodyne:build_gradient_collapse_callbackbuilds an NLSQcurve_fitcallback that, each iteration, feeds the physical/per-angle gradient ratio to aGradientCollapseMonitor. The monitor is strictly diagnostic — it observes the solve and never mutates it, so a fit with monitoring enabled is bit-identical to one with it disabled (including the homodynertol=1e-10characterization baselines).When the solver callback never fires (e.g. a solve path that does not forward per-iteration callbacks), L4 falls back to a post-solve covariance-condition check: the joint solver’s covariance matrix is decomposed via SVD and the condition number
σ_max / σ_minis used as a proxy for gradient collapse. The two paths are distinguished by themechanismfield of the diagnostics block (see below).config.gradient_consecutive_triggersis now effective: it sets the number of consecutive low-ratio iterations required beforecollapse_detectedis raised.The L4 block lives at
result.nlsq_diagnostics['gradient_monitor']and carries these keys:collapse_detectedboolWhether gradient collapse was confirmed.
trigger_countintNumber of recorded collapse events.
min_gradient_ratio/max_gradient_ratiofloatExtremes of the observed physical/per-angle gradient ratio (
nanwhen no observations were recorded).n_observationsintNumber of per-iteration observations the callback recorded.
ratio_thresholdfloatThe configured collapse ratio threshold.
consecutive_triggersintThe configured consecutive-trigger count (
config.gradient_consecutive_triggers).mechanismstrWhich path produced the block —
'per_iteration_gradient_ratio'when the callback recorded ≥ 1 observation, or'post_solve_fallback'when it recorded none and the post-solve covariance-condition check ran instead.
- L5: Shear-sensitivity weighting (laminar_flow ONLY)
L5 up-weights data near the flow direction phi0 to exploit laminar_flow’s shear-sensitivity peak (
d g1_shear / d gamma_dot ~ cos(phi0 - phi)). It is tied to laminar_flow’s shear-rate (gamma_dot) kernel and is active forlaminar_flowonly — the static modes have no flow term, so L5 is gated off for them too (see_LAYER_GATESinanti_degeneracy_controller.py).The heterodyne two-component model has its OWN velocity/flow term (
v0,v_offset,phi0_het), but it is structurally different from laminar_flow’s shear rate, so laminar_flow’s shear-sensitivity weighting does not transfer: the relative weight of the two components already varies with phi in an informative way, so an additionalcos(phi0 - phi)weight has no physics motivation. xpcsjax heterodyne therefore omits L5; seeresult.nlsq_diagnostics['shear_weighting'] == 'not_applicable_heterodyne'for the explicit marker.This is a structural decision, not a TODO.
Symmetric diagnostics contract¶
Both laminar_flow and two_component emit the same top-level
nlsq_diagnostics activation keys —
{hierarchical_active, regularization_active, shear_weighting}, plus
gradient_monitor when L4 ran, plus per_angle_mode on paths that
expose it — via the shared assembler
xpcsjax.optimization.nlsq.anti_degeneracy_diagnostics.assemble_anti_degeneracy_diagnostics.
The *_active flags are always present, taking the value False when
the corresponding layer did not run, so a caller can read the same key set
regardless of mode. The shear_weighting value is mode- and path-appropriate:
'not_applicable_heterodyne' on the two_component in-memory (standard
joint-fit) path, and 'laminar_flow_inactive' on the two_component
streaming path (matching laminar_flow’s inactive sentinel on the
stratified/streaming paths).
These flat top-level activation keys are emitted on every laminar path —
in-memory, HYBRID_STREAMING, stratified-LS (≥1 M points), sequential, and
out-of-core — as well as on all heterodyne paths, including the STREAMING path.
The values are honest per path: HYBRID_STREAMING (both homodyne and heterodyne)
reports the real active L2/L3 it ran, while stratified-LS, sequential, and
out-of-core report inactive markers (hierarchical_active=False,
regularization_active=False, shear_weighting='laminar_flow_inactive')
because those layers do not run on those paths. Activation is never fabricated.
Parity contract: mechanism + objective, not rtol=1e-10¶
The parity contract for heterodyne streaming anti-degeneracy is mechanism +
objective parity with laminar_flow streaming — not the rtol=1e-10
bit-identical gate used by the homodyne characterization baselines. The
rtol=1e-10 gate is homodyne-specific: it tests whether xpcsjax’s JAX rewrite
reproduces the upstream homodyne package’s numerical output to near
machine precision. Heterodyne fits a structurally different model, so the
equivalent contract is:
Mechanism parity — the same defense layers (L1–L4) are wired on the streaming path, with the same gating rules (L2 only for
individual; L5 omitted).Objective parity — optimized-scaling SSR ≤ frozen-scaling baseline (
info["ssr"] <= info["ssr_frozen_baseline"]), verified intests/optimization/test_heterodyne_hybrid_streaming.py.