From 20873d2c6db08d44227e03eaeb94cccc6f825b08 Mon Sep 17 00:00:00 2001 From: Florian Pfaff <6773539+FlorianPfaff@users.noreply.github.com> Date: Tue, 4 Aug 2026 14:03:54 +0800 Subject: [PATCH 1/2] Add one-shot workflow for SE2 real-input fix --- .../workflows/apply-se2-real-input-fix.yml | 167 ++++++++++++++++++ 1 file changed, 167 insertions(+) create mode 100644 .github/workflows/apply-se2-real-input-fix.yml diff --git a/.github/workflows/apply-se2-real-input-fix.yml b/.github/workflows/apply-se2-real-input-fix.yml new file mode 100644 index 0000000000..5426e1682f --- /dev/null +++ b/.github/workflows/apply-se2-real-input-fix.yml @@ -0,0 +1,167 @@ +name: Apply SE2 real-input fix + +on: + push: + branches: + - agent/reject-complex-se2-ukf-inputs + +permissions: + contents: write + +jobs: + apply-fix: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Patch validation and add regression tests + shell: bash + run: | + python - <<'PY' + from pathlib import Path + + source_path = Path("src/pyrecest/filters/se2_ukf.py") + source = source_path.read_text(encoding="utf-8") + + old_helper_anchor = '''def _to_python_bool(value): + """Convert scalar backend booleans to Python bools for validation.""" + if isinstance(value, bool): + return value + if hasattr(value, "item"): + return bool(value.item()) + return bool(value) + + + def _normalize_rotation_columns(rotation_samples, fallback_rotation): + ''' + new_helper_anchor = '''def _to_python_bool(value): + """Convert scalar backend booleans to Python bools for validation.""" + if isinstance(value, bool): + return value + if hasattr(value, "item"): + return bool(value.item()) + return bool(value) + + + def _is_complex_array(value): + """Return whether a NumPy/JAX array or PyTorch tensor has complex dtype.""" + dtype = getattr(value, "dtype", None) + if getattr(dtype, "kind", None) == "c": + return True + is_complex = getattr(value, "is_complex", None) + return bool(is_complex()) if callable(is_complex) else False + + + def _normalize_rotation_columns(rotation_samples, fallback_rotation): + ''' + if old_helper_anchor not in source: + raise SystemExit("SE2 helper anchor not found") + source = source.replace(old_helper_anchor, new_helper_anchor, 1) + + old_gaussian = ''' mu = asarray(distribution.mu) + covariance = asarray(distribution.C) + if mu.shape != (4,): + ''' + new_gaussian = ''' mu = asarray(distribution.mu) + covariance = asarray(distribution.C) + if _is_complex_array(mu): + raise ValueError(f"{role} mean must be real-valued.") + if _is_complex_array(covariance): + raise ValueError(f"{role} covariance must be real-valued.") + if mu.shape != (4,): + ''' + if old_gaussian not in source: + raise SystemExit("SE2 Gaussian validation anchor not found") + source = source.replace(old_gaussian, new_gaussian, 1) + + old_measurement = '''def _validate_se2_measurement(z): + measurement = asarray(z) + if measurement.shape != (4,): + ''' + new_measurement = '''def _validate_se2_measurement(z): + measurement = asarray(z) + if _is_complex_array(measurement): + raise ValueError("measurement z must be real-valued.") + if measurement.shape != (4,): + ''' + if old_measurement not in source: + raise SystemExit("SE2 measurement validation anchor not found") + source = source.replace(old_measurement, new_measurement, 1) + source_path.write_text(source, encoding="utf-8") + + test_path = Path("tests/filters/test_se2_ukf_real_inputs.py") + test_path.write_text( + '''"""Regression tests for real-valued SE(2) UKF inputs.""" + + import unittest + + import numpy.testing as npt + + # pylint: disable=no-name-in-module,no-member + import pyrecest.backend + from pyrecest.backend import array, eye, to_numpy + from pyrecest.distributions import GaussianDistribution + from pyrecest.filters.se2_ukf import SE2UKF + + + @unittest.skipIf( + pyrecest.backend.__backend_name__ == "jax", + reason="SE2UKF update is not supported on JAX", + ) + class TestSE2UKFRealInputs(unittest.TestCase): + @staticmethod + def _noise_distribution(): + return GaussianDistribution( + array([1.0, 0.0, 0.0, 0.0]), + 0.1 * eye(4), + ) + + def test_update_rejects_complex_measurement_without_mutating_state(self): + current_filter = SE2UKF() + original_mean = to_numpy(current_filter.filter_state.mu).copy() + original_covariance = to_numpy(current_filter.filter_state.C).copy() + + with self.assertRaisesRegex(ValueError, "real-valued"): + current_filter.update_identity( + self._noise_distribution(), + array([1.0 + 0.0j, 0.0, 1.0j, 0.0]), + ) + + npt.assert_allclose( + to_numpy(current_filter.filter_state.mu), original_mean + ) + npt.assert_allclose( + to_numpy(current_filter.filter_state.C), original_covariance + ) + + def test_filter_state_rejects_complex_mean_direction(self): + current_filter = SE2UKF() + invalid_state = GaussianDistribution( + array([1.0, 0.0, 0.0, 0.0]), + eye(4), + ) + invalid_state.mu = array([1.0 + 0.0j, 0.0, 1.0j, 0.0]) + + with self.assertRaisesRegex(ValueError, "real-valued"): + current_filter.filter_state = invalid_state + + + if __name__ == "__main__": + unittest.main() + ''', + encoding="utf-8", + ) + + Path(".github/workflows/apply-se2-real-input-fix.yml").unlink() + PY + + - name: Commit patch + shell: bash + run: | + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + git add src/pyrecest/filters/se2_ukf.py tests/filters/test_se2_ukf_real_inputs.py .github/workflows/apply-se2-real-input-fix.yml + git commit -m "Reject complex SE2 UKF inputs" + git push origin HEAD:${GITHUB_REF_NAME} From 0f7aaf0ef44229530274e3c44c4c9ce8e43a4ae5 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Tue, 4 Aug 2026 06:04:10 +0000 Subject: [PATCH 2/2] Reject complex SE2 UKF inputs --- .../workflows/apply-se2-real-input-fix.yml | 167 ------------------ src/pyrecest/filters/se2_ukf.py | 15 ++ tests/filters/test_se2_ukf_real_inputs.py | 57 ++++++ 3 files changed, 72 insertions(+), 167 deletions(-) delete mode 100644 .github/workflows/apply-se2-real-input-fix.yml create mode 100644 tests/filters/test_se2_ukf_real_inputs.py diff --git a/.github/workflows/apply-se2-real-input-fix.yml b/.github/workflows/apply-se2-real-input-fix.yml deleted file mode 100644 index 5426e1682f..0000000000 --- a/.github/workflows/apply-se2-real-input-fix.yml +++ /dev/null @@ -1,167 +0,0 @@ -name: Apply SE2 real-input fix - -on: - push: - branches: - - agent/reject-complex-se2-ukf-inputs - -permissions: - contents: write - -jobs: - apply-fix: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - with: - fetch-depth: 0 - - - name: Patch validation and add regression tests - shell: bash - run: | - python - <<'PY' - from pathlib import Path - - source_path = Path("src/pyrecest/filters/se2_ukf.py") - source = source_path.read_text(encoding="utf-8") - - old_helper_anchor = '''def _to_python_bool(value): - """Convert scalar backend booleans to Python bools for validation.""" - if isinstance(value, bool): - return value - if hasattr(value, "item"): - return bool(value.item()) - return bool(value) - - - def _normalize_rotation_columns(rotation_samples, fallback_rotation): - ''' - new_helper_anchor = '''def _to_python_bool(value): - """Convert scalar backend booleans to Python bools for validation.""" - if isinstance(value, bool): - return value - if hasattr(value, "item"): - return bool(value.item()) - return bool(value) - - - def _is_complex_array(value): - """Return whether a NumPy/JAX array or PyTorch tensor has complex dtype.""" - dtype = getattr(value, "dtype", None) - if getattr(dtype, "kind", None) == "c": - return True - is_complex = getattr(value, "is_complex", None) - return bool(is_complex()) if callable(is_complex) else False - - - def _normalize_rotation_columns(rotation_samples, fallback_rotation): - ''' - if old_helper_anchor not in source: - raise SystemExit("SE2 helper anchor not found") - source = source.replace(old_helper_anchor, new_helper_anchor, 1) - - old_gaussian = ''' mu = asarray(distribution.mu) - covariance = asarray(distribution.C) - if mu.shape != (4,): - ''' - new_gaussian = ''' mu = asarray(distribution.mu) - covariance = asarray(distribution.C) - if _is_complex_array(mu): - raise ValueError(f"{role} mean must be real-valued.") - if _is_complex_array(covariance): - raise ValueError(f"{role} covariance must be real-valued.") - if mu.shape != (4,): - ''' - if old_gaussian not in source: - raise SystemExit("SE2 Gaussian validation anchor not found") - source = source.replace(old_gaussian, new_gaussian, 1) - - old_measurement = '''def _validate_se2_measurement(z): - measurement = asarray(z) - if measurement.shape != (4,): - ''' - new_measurement = '''def _validate_se2_measurement(z): - measurement = asarray(z) - if _is_complex_array(measurement): - raise ValueError("measurement z must be real-valued.") - if measurement.shape != (4,): - ''' - if old_measurement not in source: - raise SystemExit("SE2 measurement validation anchor not found") - source = source.replace(old_measurement, new_measurement, 1) - source_path.write_text(source, encoding="utf-8") - - test_path = Path("tests/filters/test_se2_ukf_real_inputs.py") - test_path.write_text( - '''"""Regression tests for real-valued SE(2) UKF inputs.""" - - import unittest - - import numpy.testing as npt - - # pylint: disable=no-name-in-module,no-member - import pyrecest.backend - from pyrecest.backend import array, eye, to_numpy - from pyrecest.distributions import GaussianDistribution - from pyrecest.filters.se2_ukf import SE2UKF - - - @unittest.skipIf( - pyrecest.backend.__backend_name__ == "jax", - reason="SE2UKF update is not supported on JAX", - ) - class TestSE2UKFRealInputs(unittest.TestCase): - @staticmethod - def _noise_distribution(): - return GaussianDistribution( - array([1.0, 0.0, 0.0, 0.0]), - 0.1 * eye(4), - ) - - def test_update_rejects_complex_measurement_without_mutating_state(self): - current_filter = SE2UKF() - original_mean = to_numpy(current_filter.filter_state.mu).copy() - original_covariance = to_numpy(current_filter.filter_state.C).copy() - - with self.assertRaisesRegex(ValueError, "real-valued"): - current_filter.update_identity( - self._noise_distribution(), - array([1.0 + 0.0j, 0.0, 1.0j, 0.0]), - ) - - npt.assert_allclose( - to_numpy(current_filter.filter_state.mu), original_mean - ) - npt.assert_allclose( - to_numpy(current_filter.filter_state.C), original_covariance - ) - - def test_filter_state_rejects_complex_mean_direction(self): - current_filter = SE2UKF() - invalid_state = GaussianDistribution( - array([1.0, 0.0, 0.0, 0.0]), - eye(4), - ) - invalid_state.mu = array([1.0 + 0.0j, 0.0, 1.0j, 0.0]) - - with self.assertRaisesRegex(ValueError, "real-valued"): - current_filter.filter_state = invalid_state - - - if __name__ == "__main__": - unittest.main() - ''', - encoding="utf-8", - ) - - Path(".github/workflows/apply-se2-real-input-fix.yml").unlink() - PY - - - name: Commit patch - shell: bash - run: | - git config user.name "github-actions[bot]" - git config user.email "41898282+github-actions[bot]@users.noreply.github.com" - git add src/pyrecest/filters/se2_ukf.py tests/filters/test_se2_ukf_real_inputs.py .github/workflows/apply-se2-real-input-fix.yml - git commit -m "Reject complex SE2 UKF inputs" - git push origin HEAD:${GITHUB_REF_NAME} diff --git a/src/pyrecest/filters/se2_ukf.py b/src/pyrecest/filters/se2_ukf.py index 5dc7df5b11..41939b5dfe 100644 --- a/src/pyrecest/filters/se2_ukf.py +++ b/src/pyrecest/filters/se2_ukf.py @@ -47,6 +47,15 @@ def _to_python_bool(value): return bool(value) +def _is_complex_array(value): + """Return whether a NumPy/JAX array or PyTorch tensor has complex dtype.""" + dtype = getattr(value, "dtype", None) + if getattr(dtype, "kind", None) == "c": + return True + is_complex = getattr(value, "is_complex", None) + return bool(is_complex()) if callable(is_complex) else False + + def _normalize_rotation_columns(rotation_samples, fallback_rotation): """Normalize 2-D rotation columns, replacing undefined zero directions.""" rotation_samples = asarray(rotation_samples) @@ -72,6 +81,10 @@ def _validate_se2_gaussian(distribution, role): mu = asarray(distribution.mu) covariance = asarray(distribution.C) + if _is_complex_array(mu): + raise ValueError(f"{role} mean must be real-valued.") + if _is_complex_array(covariance): + raise ValueError(f"{role} covariance must be real-valued.") if mu.shape != (4,): raise ValueError(f"{role} mean must be a 4-D vector.") if covariance.shape != (4, 4): @@ -95,6 +108,8 @@ def _validate_se2_gaussian(distribution, role): def _validate_se2_measurement(z): measurement = asarray(z) + if _is_complex_array(measurement): + raise ValueError("measurement z must be real-valued.") if measurement.shape != (4,): raise ValueError("measurement z must be a 4-D vector.") if not _to_python_bool(backend_all(isfinite(measurement))): diff --git a/tests/filters/test_se2_ukf_real_inputs.py b/tests/filters/test_se2_ukf_real_inputs.py new file mode 100644 index 0000000000..ea2e25c617 --- /dev/null +++ b/tests/filters/test_se2_ukf_real_inputs.py @@ -0,0 +1,57 @@ +"""Regression tests for real-valued SE(2) UKF inputs.""" + +import unittest + +import numpy.testing as npt + +# pylint: disable=no-name-in-module,no-member +import pyrecest.backend +from pyrecest.backend import array, eye, to_numpy +from pyrecest.distributions import GaussianDistribution +from pyrecest.filters.se2_ukf import SE2UKF + + +@unittest.skipIf( + pyrecest.backend.__backend_name__ == "jax", + reason="SE2UKF update is not supported on JAX", +) +class TestSE2UKFRealInputs(unittest.TestCase): + @staticmethod + def _noise_distribution(): + return GaussianDistribution( + array([1.0, 0.0, 0.0, 0.0]), + 0.1 * eye(4), + ) + + def test_update_rejects_complex_measurement_without_mutating_state(self): + current_filter = SE2UKF() + original_mean = to_numpy(current_filter.filter_state.mu).copy() + original_covariance = to_numpy(current_filter.filter_state.C).copy() + + with self.assertRaisesRegex(ValueError, "real-valued"): + current_filter.update_identity( + self._noise_distribution(), + array([1.0 + 0.0j, 0.0, 1.0j, 0.0]), + ) + + npt.assert_allclose( + to_numpy(current_filter.filter_state.mu), original_mean + ) + npt.assert_allclose( + to_numpy(current_filter.filter_state.C), original_covariance + ) + + def test_filter_state_rejects_complex_mean_direction(self): + current_filter = SE2UKF() + invalid_state = GaussianDistribution( + array([1.0, 0.0, 0.0, 0.0]), + eye(4), + ) + invalid_state.mu = array([1.0 + 0.0j, 0.0, 1.0j, 0.0]) + + with self.assertRaisesRegex(ValueError, "real-valued"): + current_filter.filter_state = invalid_state + + +if __name__ == "__main__": + unittest.main()