Skip to content

♻️ refactor: share LocalFlowOrderer's chord attachment across both dispatches - #137

Merged
nstarman merged 3 commits into
GalacticDynamics:mainfrom
nstarman:claude/fix-localflow-quantity-drift-71
Sep 28, 2026
Merged

nstarman merged 3 commits into
GalacticDynamics:mainfrom
nstarman:claude/fix-localflow-quantity-drift-71

Conversation

@nstarman

Copy link
Copy Markdown
Contributor

The recurring bug

LocalFlowOrderer.order's Quantity dispatch (in _interop/interop_unxt.py) reaches algorithm._local_flow_walk directly, rather than delegating to the plain dispatch the way MSTOrderer/SOMOrderer do. That keeps it genuinely unit-aware via quax.quaxify, but it means any postprocessing step added to the plain path has to be separately re-added to the Quantity path, with nothing to catch a forgotten copy.

Three fields have drifted this way in turn:

added to the plain path unit-ful behaviour before the second fix
velocity_aware (#56) always False — a chained SOMOrderer silently dropped to position-only
chord (#67) always None — the encoder silently used its old inline fallback
start_idx from init (#70) None reached the walk as a start index — 13 tests failed

Only the third failed loudly.

What's already unified, and what wasn't

velocity_aware and start_idx turned out to already be fixed on main: the former is derived once inside _local_flow_walk itself from the shared config (so both paths get it automatically), and the latter already goes through the shared _resolve_start_idx helper. chord was the one piece still duplicated — _with_chord in the plain dispatch, a second inline chord_along_ordering call in the Quantity dispatch.

The fix (option 1 + 3 from the issue)

Renamed _with_chord to _finalize and gave it a positions parameter instead of always reading result.positions, so both dispatches can call the same function — the plain one directly, the Quantity one on unit-stripped positions before reattaching the unit. A postprocessing step landing only in one of the two call sites is exactly the drift this closes off.

Paired with test_localflow_quantity_agrees_with_plain_field_by_field, which compares every field of the result generically via dataclasses.fields rather than naming a fixed set — so a future field missing from the Quantity path fails this test instead of shipping silently, which is the actual acceptance criterion in #71. Verified it fails when the _finalize call is reverted (confirmed: crashes trying to unit-convert chord=None) and passes with it restored.

Full test suite: 1006 passed, 1 skipped.

Closes #71.

🤖 Generated with Claude Code

…spatches

`LocalFlowOrderer.order`'s Quantity dispatch reaches `algorithm._local_flow_walk`
directly rather than delegating to the plain dispatch, so it is genuinely
unit-aware via `quax.quaxify` -- but it means any postprocessing step added to
the plain path has to be separately re-added here, with nothing to catch a
forgotten copy. Three fields already drifted this way in turn: `velocity_aware`
(GalacticDynamics#56), `chord` (GalacticDynamics#67), and the `init`-derived start index (GalacticDynamics#70) -- the first two
silently, producing plausible-but-wrong results.

`velocity_aware` and `start_idx` are already unified (the former is derived
once inside `_local_flow_walk` itself from the shared `config`; the latter
already goes through the shared `_resolve_start_idx`). `chord` was the one
piece still duplicated: `_with_chord` in the plain path, a second
`chord_along_ordering` call inline in the Quantity path.

Renamed `_with_chord` to `_finalize` and gave it a `positions` parameter
instead of always reading `result.positions`, so both dispatches can call it
-- the plain one directly, the Quantity one on unit-stripped positions before
reattaching the unit. A postprocessing step landing only in one of the two
calls is exactly the drift this closes off.

Paired with a parity test (`test_localflow_quantity_agrees_with_plain_field_by_field`)
that compares every field of the result generically, via `dataclasses.fields`,
rather than naming a fixed set -- so a *future* field missing from the Quantity
path fails this test instead of shipping silently. Verified it fails when the
`_finalize` call is reverted and passes with it.

Closes GalacticDynamics#71.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Copilot AI lite review requested due to automatic review settings September 28, 2026 04:40
@github-actions github-actions Bot added ✅ Tests Add, update, or pass tests. ♻️ Refactor code Refactor code. labels Sep 28, 2026

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot review overview

🟢 Approval recommended

The refactor unifies previously duplicated logic with a strong regression test to prevent recurrence, and no correctness or API issues were found in the reviewed changes.

Review effort: Lite
Findings: 1 Low severity

Open (1)
What changed in this PR

Refactors LocalFlowOrderer’s post-processing so both the plain-array and unxt.Quantity dispatches share the same chord-attachment logic, preventing future drift between the two execution paths in phasecurvefit’s ordering pipeline.

Changes:

  • Replaces the plain-path-only _with_chord helper with a shared _finalize(result, positions) function.
  • Updates the Quantity dispatch to call _finalize on unit-stripped positions, then reattach units to the resulting chord.
  • Adds a regression test that compares all result fields between plain and Quantity runs via dataclasses.fields, to catch any future divergence automatically.
File Description
tests/​unit/​test_orderers_unxt.py Adds a generic field-by-field parity test between plain vs Quantity LocalFlow results to prevent silent drift.
src/​phasecurvefit/​_src/​orderers/​localflow.py Introduces _finalize(result, positions) and uses it in the plain dispatch to centralize post-processing.
src/​phasecurvefit/​_interop/​interop_unxt.py Switches LocalFlow’s Quantity dispatch to reuse _finalize and then unit-reattach the chord.

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment thread tests/unit/test_orderers_unxt.py
@codecov

codecov Bot commented Sep 28, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
⚠️ Please upload report for BASE (main@2385f16). Learn more about missing BASE report.

Additional details and impacted files
@@           Coverage Diff           @@
##             main     #137   +/-   ##
=======================================
  Coverage        ?   91.97%           
=======================================
  Files           ?       34           
  Lines           ?     1982           
  Branches        ?      109           
=======================================
  Hits            ?     1823           
  Misses          ?      129           
  Partials        ?       30           

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

Copilot review on GalacticDynamics#137: init_plain/init_q are derived from MSTOrderer without
checking they agree, so a future divergence in MST's own Quantity dispatch
would fail this test with a misleading signal about LocalFlowOrderer instead.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Copilot AI review requested due to automatic review settings September 28, 2026 05:12

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot review overview

🟡 Changes recommended

The newly added generic field-by-field test is currently brittle for legitimate NaNs and future tuple/list fields, which can cause false failures even when both dispatches agree.

Review effort: Lite
Findings: 1 Medium severity

Open (1)
Resolved since last review (1)
Previously missed (1)

In code that hasn't changed since last review

Low severity Recursively strip tuple/list fields for comparison

tests/​unit/​test_orderers_unxt.py:145

_strip_for_comparison claims to handle tuple/list-shaped fields, but it currently returns tuples/lists unchanged. If a future result field is a tuple/list containing arrays, the final assert plain_val == q_val will raise due to ambiguous truth value from array element comparisons. Strip tuple/list contents recursively to keep the test future-proof.

Comment thread tests/unit/test_orderers_unxt.py
Copilot review on GalacticDynamics#137: chord's own contract is nan for unvisited
observations, but assert_allclose without equal_nan=True would fail on a
matching nan in both paths as if they disagreed.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Copilot AI review requested due to automatic review settings September 28, 2026 05:20

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot review overview

🟢 Approval recommended

The refactor unifies chord finalization across dispatches and the new generic field-by-field test provides a strong guard against future drift.

Review effort: Lite
Findings: None

Resolved since last review (1)

@nstarman nstarman added this to the v0.4.0 milestone Sep 28, 2026
@nstarman
nstarman merged commit c2bef5b into GalacticDynamics:main Sep 28, 2026
20 checks passed
nstarman added a commit that referenced this pull request Sep 28, 2026
…140)

test_localflow_quantity_agrees_with_plain_field_by_field (#137) manually
iterated dataclasses.fields and dispatched on each value's shape (dict,
array, or scalar) to compare the plain and Quantity results. eqx.tree_equal
already does exactly this over a whole pytree in one call, checking static
fields (gamma_range, velocity_aware) as well as array leaves.

Strips result_q's Quantity-valued fields back to plain arrays first, so both
results share one pytree structure -- only then can tree_equal walk them
together. nan_to_num on both sides first, since tree_equal has no equal_nan
option and chord's own contract is nan for unvisited observations.

Verified this still fails the same way the manual version did when the
_finalize call is removed (a NotFoundLookupError trying to unit-convert
chord=None), and passes with it restored.

* ✅ test: check the nan mask agrees before neutralising it

Copilot review on #140: nan_to_num alone could mask a real divergence -- a
genuine nan on one side and a coincidentally-equal finite value (e.g. 0.0)
on the other would both become 0.0 and read as agreement. Comparing the nan
masks first, separately, catches exactly that case before nan_to_num ever
runs; verified empirically that it returns False for nan-vs-0.0 and True for
nan-vs-nan.

Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

♻️ Refactor code Refactor code. ✅ Tests Add, update, or pass tests.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

LocalFlowOrderer's Quantity dispatch re-implements the plain one, and keeps drifting from it

2 participants