Skip to content

C++ review#5

Closed
TheDaniel166 wants to merge 39 commits into
architecture-summary-7554422455403111575from
main
Closed

C++ review#5
TheDaniel166 wants to merge 39 commits into
architecture-summary-7554422455403111575from
main

Conversation

@TheDaniel166
Copy link
Copy Markdown
Owner

@TheDaniel166 TheDaniel166 commented May 15, 2026

Summary

Describe the problem this pull request solves and the concrete change it introduces.

Why this approach

Explain why this implementation was chosen over obvious alternatives.

Change surface

  • Public behavior changes:
  • Numerical output changes:
  • Doctrine changes:
  • Validation rule changes:

Affected area

List the modules, subsystems, or public surfaces touched by this PR.

Validation

  • Tests added or updated:
  • Checks actually run:
  • Results:

Sources or references

List the standards, papers, datasets, doctrine sources, or prior issues that justify the change.

Review notes

Call out anything that deserves careful review, including edge cases, risks, or intentional non-goals.

Checklist

  • The PR is focused on one coherent change.
  • Any public behavior change is stated explicitly.
  • Any numerical output change is stated explicitly.
  • Any doctrinal change is stated explicitly.
  • Validation and tests are included where appropriate.
  • Sources, laws, or references are identified where relevant.

@TheDaniel166 TheDaniel166 self-assigned this May 15, 2026
@surmado-code-review
Copy link
Copy Markdown

surmado-code-review Bot commented May 15, 2026

Surmado Code Review — Error

Diff fetch failed: Sorry, the diff exceeded the maximum number of lines (20000): {"resource":"PullRequest","field":"diff","code":"too_large"} - https://docs.github.com/rest/pulls/pulls#get-a-pull-request

Review could not run.


Surmado Code Review (v1.2-mt)

@greptile-apps
Copy link
Copy Markdown

greptile-apps Bot commented May 15, 2026

Too many files changed for review. (241 files found, 100 file limit)

@cloudflare-workers-and-pages
Copy link
Copy Markdown

cloudflare-workers-and-pages Bot commented May 15, 2026

Deploying with  Cloudflare Workers  Cloudflare Workers

The latest updates on your project. Learn more about integrating Git with Workers.

Status Name Latest Commit Updated (UTC)
❌ Deployment failed
View logs
moira adb5b2b May 17 2026, 10:17 AM

@netlify
Copy link
Copy Markdown

netlify Bot commented May 15, 2026

Deploy Preview for heroic-custard-7ea345 ready!

Name Link
🔨 Latest commit adb5b2b
🔍 Latest deploy log https://app.netlify.com/projects/heroic-custard-7ea345/deploys/6a09957882b91600087bf65c
😎 Deploy Preview https://deploy-preview-5--heroic-custard-7ea345.netlify.app
📱 Preview on mobile
Toggle QR Code...

QR Code

Use your smartphone camera to open QR code link.
🤖 Make changes Run an agent on this branch

To edit notification comments on pull requests, go to your Netlify project configuration.

Copy link
Copy Markdown

@sourcery-ai sourcery-ai Bot left a comment

Choose a reason for hiding this comment

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

Sorry, we are unable to review this pull request

The GitHub API does not allow us to fetch diffs exceeding 20000 lines

@qodo-code-review
Copy link
Copy Markdown

Review Summary by Qodo

Native C++ backend integration, NumPy elimination, and context-aware reader architecture refactoring

✨ Enhancement 🧪 Tests 🐞 Bug fix

Grey Divider

Walkthroughs

Description
  **Major architectural refactoring to eliminate NumPy dependency and implement native C++ backend
  integration:**
• **Native SPK reader infrastructure**: Replaced jplephem dependency with native C++ Chebyshev
  polynomial evaluation and DAF catalog reading via moira_native module, with jplephem fallback for
  unsupported segment types
• **Context-aware reader management**: Refactored module-level singleton pattern to
  ContextVar-based reader management with get_active_reader(), swap_reader(), and
  add_to_global_pool() for flexible kernel lifecycle
• **NumPy elimination across core modules**: Removed NumPy dependencies from planets.py,
  lunar_limb.py, daf_writer.py, astrocartography.py, and corrections.py using native substrate
  and pure-Python implementations
• **Caching and performance optimization**: Implemented LRU caches for apparent-path computations,
  STAC tile caching for LOLA data, and fast-path optimization for default geocentric ecliptic mode
• **Small-body kernel migration**: Migrated _spk_body_kernel.py to native C++ backend with
  sovereign shard manifest support and Type-13 segment handling
• **Asteroid and comet module refactoring**: Updated to use context-aware reader pattern with
  optional reader parameter instead of mandatory kernel paths
• **Lunar limb enhancements**: Added COPC region queries, graceful optional dependency handling, and
  native vector operations for point-cloud processing
• **New transit methods**: Added aspect_transits() and declination_transits() to predictive
  facade for aspect and declination-based transit finding
• **Extended test coverage**: Added comprehensive test suites for native SPK evaluation,
  property-based LOLA point-cloud tests, Sothic cycle validation, and stress tests for small-body
  kernels
• **Diagnostic tooling**: New profiling and benchmarking scripts for planetary flow bottleneck
  analysis and native backend validation
• **Removed legacy modules**: Deleted solar_cartography.py and lunar_cartography.py with
  associated tests
• **Version bump**: Updated to version 3.1.0
Diagram
flowchart LR
  A["Legacy jplephem<br/>+ NumPy"] -->|"Native SPK reader<br/>+ ContextVar"| B["Native C++ backend<br/>moira_native"]
  A -->|"Pure Python<br/>array.array"| C["Simplified<br/>serialization"]
  B -->|"Chebyshev eval<br/>DAF catalog"| D["High-performance<br/>kernel access"]
  D -->|"LRU caching<br/>context mgmt"| E["Optimized<br/>planetary flow"]
  F["Optional deps<br/>requests/laspy"] -->|"Graceful fallback<br/>_require_lunar_extra"| G["LOLA/COPC<br/>integration"]
  G -->|"Native vectors<br/>no NumPy"| H["Lunar limb<br/>computation"]
  I["Singleton pattern<br/>get_reader"] -->|"ContextVar override<br/>get_active_reader"| J["Flexible reader<br/>lifecycle"]
  J -->|"add_to_global_pool<br/>swap_reader"| K["Multi-kernel<br/>support"]
Loading

Grey Divider

File Changes

1. moira/planets.py ✨ Enhancement +1036/-189

Native rotation matrices, context caching, and batch planetary processing

• Replaced NumPy dependency with native rotation matrix operations and added comprehensive caching
 infrastructure for apparent-path computations
• Implemented native all-planets batch processing mode with admitted validation checks and LOLA
 point-cloud integration
• Added _ApparentContext dataclass and per-reader LRU caches to avoid redundant calculations
 across repeated same-JD lookups
• Refactored planet_at() and all_planets_at() to use shared context and vector caching, with
 fast-path optimization for default geocentric ecliptic mode
• Updated imports to use get_active_reader() and MissingKernelError from spk_reader module

moira/planets.py


2. moira/lunar_limb.py ✨ Enhancement +366/-162

Optional dependencies, native substrate integration, and COPC region queries

• Made optional dependencies (requests, laspy, spiceypy) gracefully importable with fallback
 error handling via _require_lunar_extra() function
• Replaced NumPy arrays with native moira_native substrate for vector operations, point-cloud
 filtering, convex hull, and ray-intersection calculations
• Added STAC tile caching layer with JSON persistence to avoid redundant LOLA tile URL lookups
• Introduced _load_lola_tile_region() for bounded COPC neighborhood queries and
 lola_query_half_width_km parameter with validation
• Refactored vector math to use tuples and native substrate functions instead of NumPy operations

moira/lunar_limb.py


3. moira/daf_writer.py ✨ Enhancement +98/-171

Removed NumPy dependency, simplified serialization logic

• Removed NumPy dependency and simplified to pure Python with array.array for float64
 serialization
• Consolidated docstrings and removed verbose comments while preserving essential logic
• Standardized string literals to double quotes and improved code formatting consistency
• Removed conditional NumPy fast-path, now using only array.array with byte-swap handling for all
 platforms

moira/daf_writer.py


View more (119)
4. moira/nodes.py ✨ Enhancement +31/-6

Updated reader acquisition and error handling

• Updated all get_reader() calls to get_active_reader() with explicit MissingKernelError
 handling
• Added reader parameter to nodes_and_apsides_at() function signature
• Improved error messages to guide users toward explicit reader passing or Moira facade usage

moira/nodes.py


5. moira/constants.py ✨ Enhancement +4/-0

Added TAI-TT time offset constant

• Added TAI_TT_OFFSET constant defining the fixed 32.184-second offset between International
 Atomic Time and Terrestrial Time

moira/constants.py


6. tests/snapshots/first_house_ingress_jd.json 🧪 Tests +3/-0

Added test snapshot for house ingress calculation

• New snapshot file containing expected test value for first house ingress Julian Day calculation

tests/snapshots/first_house_ingress_jd.json


7. moira/spk_reader.py ✨ Enhancement +617/-156

Native SPK reader with context-aware singleton refactoring

• Refactored module-level singleton pattern to context-aware reader management with ContextVar
 override support
• Added native C++ SPK kernel reader infrastructure with Chebyshev polynomial evaluation and
 type-2/type-3 segment support
• Introduced _NativeSpkKernel, _NativeChebyshevSegment classes for native kernel handling
 alongside jplephem fallback
• Replaced get_reader() with get_active_reader() and added add_to_global_pool(),
 swap_reader() for flexible kernel management
• Added evaluator() method to SpkReader and KernelPool for high-performance C++ segment
 evaluation

moira/spk_reader.py


8. moira/asteroids.py ✨ Enhancement +138/-229

Asteroid module refactored for context-aware reader pattern

• Removed mandatory kernel singleton pattern; now uses active reader context via
 get_active_reader()
• Converted legacy load_*_kernel() functions to shims that delegate to add_to_global_pool()
• Updated _kernel_for() to accept optional KernelReader parameter and search pool readers
• Modified asteroid_at() and all_asteroids_at() to accept reader parameter instead of
 kernel_path
• Added "Toutatis" to ASTEROID_NAIF dictionary and enhanced deflection calculations with
 Jupiter/Saturn

moira/asteroids.py


9. moira/_spk_body_kernel.py ✨ Enhancement +313/-216

Small-body kernel reader migrated to native C++ backend

• Removed mandatory jplephem dependency; now uses native DAF/SPK readers via moira_native module
• Replaced jplephem BaseSegment inheritance with native _Type13Segment and
 _NativeChebyshevSegment implementations
• Added _native_catalog_is_fully_supported() to validate segment type support
• Introduced small_body_readers_from_manifest() for loading kernels from sovereign shard manifests
• Simplified SmallBodyKernel to wrap native catalog instead of jplephem SPK object

moira/_spk_body_kernel.py


10. tests/unit/test_ephemeris_stress_proofs.py 🧪 Tests +244/-235

Test suite adapted to context-aware reader pattern

• Refactored all test functions to use _planetary_reader_context() context manager
• Replaced direct get_reader() calls with context-managed SpkReader and use_reader_override()
• Updated test structure to properly acquire and release kernel resources
• Maintained all assertion logic and test coverage while adapting to new reader pattern

tests/unit/test_ephemeris_stress_proofs.py


11. moira/facade.py ✨ Enhancement +11/-12

Facade updated for new reader architecture and exports

• Removed get_reader import; now uses context-aware reader management internally
• Added imports for utc_to_tt, utc_to_ut1 conversion functions
• Added imports for AspectTransitEvent, EquatorialTransitEvent and transit-finding functions
• Removed load_comet_kernel and load_asteroid_kernel from public exports (now legacy shims)
• Updated __version__ to "3.1.0" and removed dynamic version detection via package_version()

moira/facade.py


12. moira/_kernel_paths.py ✨ Enhancement +27/-0

Kernel path discovery extended for sovereign manifests

• Added SOVEREIGN_SMALL_BODY_MANIFEST_ENV environment variable constant
• Introduced find_sovereign_small_body_manifest() function to discover small-body kernel manifests
• Manifest discovery checks environment variable, then package/dev kernel directories for manifest
 files

moira/_kernel_paths.py


13. moira/_facade_astronomy.py 🐞 Bug fix +2/-2

Fixed star calculation updated for facade API

• Updated fixed_star() method to use facade.utc_to_tt() instead of direct ut_to_tt() import
• Removed local import of ut_to_tt function

moira/_facade_astronomy.py


14. tests/integration/test_sothic_extended.py 🧪 Tests +411/-0

Extended Sothic cycle multi-epoch validation test suite

• New comprehensive test suite for Sothic cycle validation across multiple epochs, Egyptian sites,
 and astronomical oracles
• Tests verify heliacal rising computation for Sirius across ~1460-year cycles with multi-site
 latitude coverage
• Includes oracle comparison tests using astropy/ERFA to validate against independent astronomical
 calculations
• Validates temporal continuity, drift accumulation, and monotonic progression of Egyptian calendar
 dates

tests/integration/test_sothic_extended.py


15. moira/astrocartography.py ✨ Enhancement +74/-169

Remove NumPy dependency and simplify ACG computation

• Removed NumPy dependency and vectorized computation path; replaced with pure-Python scalar
 implementation
• Removed ZEN/NAD point types, reducing ACGLine to four line types (MC, IC, ASC, DSC) instead of six
• Simplified _compute_acg_vectorized to _compute_acg_curve_samples with scalar loop-based
 computation
• Removed wrap_longitude_deg calls, using modulo operator directly; updated docstrings and machine
 contract

moira/astrocartography.py


16. scripts/build_notebooklm_sources.py Scripts +523/-0

NotebookLM source bundle builder script

• New script to build NotebookLM-ready source bundles from the Moira repository
• Organizes codebase into 15 semantic bundles covering facades, astronomy, planets, events, houses,
 systems, and documentation
• Enforces word and byte limits per bundle; excludes tests, build artifacts, and binary files
• Generates manifest.json with metadata and per-file statistics for each bundle

scripts/build_notebooklm_sources.py


17. tests/unit/test_spk_reader.py 🧪 Tests +364/-0

Native SPK reader and Chebyshev segment evaluation tests

• Added comprehensive tests for native SPK Chebyshev segment evaluation and DAF catalog reading
• Tests verify native path usage for Type 2 segments with fallback to jplephem for unsupported types
• Added parity tests comparing native Chebyshev payload and record evaluation against jplephem
 reference
• Tests validate native DAF catalog matches jplephem output and kernel coverage merging

tests/unit/test_spk_reader.py


18. tests/test_lola_properties.py 🧪 Tests +311/-0

Property-based tests for numpy-free LOLA point cloud

• New property-based test suite using Hypothesis for numpy-free LOLA point cloud processing
• Tests validate vector normalization, dot/cross products, projections, coordinate transformations,
 and filtering
• Includes properties for visibility, position angle, radius filtering, binning, convex hull, and
 ray-hull intersection
• Comprehensive coverage of lunar limb computation without NumPy dependency

tests/test_lola_properties.py


19. moira/corrections.py ✨ Enhancement +59/-84

Replace NumPy with native backend for corrections

• Replaced NumPy-based aberration and frame bias computation with native backend calls
• Added optional jd_ut parameter to _observer_position_icrf, topocentric_correction, and
 apply_diurnal_aberration for IERS polar motion
• Converted topocentric_correction_batch_np from NumPy arrays to pure-Python tuple-based
 implementation
• Updated docstrings and removed pre-built NumPy frame bias matrix; added native corrections
 availability check

moira/corrections.py


20. scripts/profile_planetary_flow_bottlenecks.py Scripts +385/-0

Planetary flow bottleneck profiling diagnostic script

• New diagnostic profiling script capturing stage-by-stage timings for planetary calculation
 pipeline
• Instruments 40+ functions across planets, SPK reader, and native backend to expose bottlenecks
• Profiles both warm and cold reader scenarios with configurable sample dates and body sets
• Generates JSON artifact with ranked and ordered stage metrics including inclusive/exclusive times

scripts/profile_planetary_flow_bottlenecks.py


21. moira/_facade_predictive.py ✨ Enhancement +27/-0

Add aspect and declination transit methods to facade

• Added two new transit-finding methods to predictive facade: aspect_transits and
 declination_transitsaspect_transits finds transits forming aspects (angle + orb) to a target body or fixed longitude
• declination_transits finds parallel or contra-parallel declination aspects between transiting
 and target bodies

moira/_facade_predictive.py


22. CHANGELOG.md Additional files +48/-0

...

CHANGELOG.md


23. CLEANUP_COMPLETE.md Additional files +69/-0

...

CLEANUP_COMPLETE.md


24. CMakeLists.txt Additional files +37/-0

...

CMakeLists.txt


25. FRAME_CONVENTIONS_EXPLAINED.md Additional files +331/-0

...

FRAME_CONVENTIONS_EXPLAINED.md


26. MOON_ERROR_INVESTIGATION.md Additional files +129/-0

...

MOON_ERROR_INVESTIGATION.md


27. NUMPY_REMOVAL_AUDIT_REPORT.md Additional files +97/-0

...

NUMPY_REMOVAL_AUDIT_REPORT.md


28. ORACLE_VALIDATION_COMPLETE.md Additional files +69/-0

...

ORACLE_VALIDATION_COMPLETE.md


29. README.md Additional files +11/-0

...

README.md


30. SHARD_16_REMOVAL.md Additional files +93/-0

...

SHARD_16_REMOVAL.md


31. app.py Additional files +63/-0

...

app.py


32. docs/architecture/MOIRA_NATIVE_BACKEND_ARCHITECTURE.md Additional files +145/-0

...

docs/architecture/MOIRA_NATIVE_BACKEND_ARCHITECTURE.md


33. docs/architecture/MOIRA_NATIVE_CLOSURE_PROGRAM.md Additional files +501/-0

...

docs/architecture/MOIRA_NATIVE_CLOSURE_PROGRAM.md


34. docs/architecture/MOIRA_NATIVE_MIGRATION_TRACKER.md Additional files +515/-0

...

docs/architecture/MOIRA_NATIVE_MIGRATION_TRACKER.md


35. docs/architecture/MOIRA_NATIVE_PERSISTENT_KERNEL_STORE.md Additional files +395/-0

...

docs/architecture/MOIRA_NATIVE_PERSISTENT_KERNEL_STORE.md


36. docs/architecture/MOIRA_NATIVE_PLANETARY_CASH_IN_PLAN.md Additional files +297/-0

...

docs/architecture/MOIRA_NATIVE_PLANETARY_CASH_IN_PLAN.md


37. docs/architecture/MOIRA_NATIVE_PLANETARY_CLOSURE_TRACKER.md Additional files +137/-0

...

docs/architecture/MOIRA_NATIVE_PLANETARY_CLOSURE_TRACKER.md


38. docs/architecture/MOIRA_NATIVE_PLANETARY_PATH.md Additional files +358/-0

...

docs/architecture/MOIRA_NATIVE_PLANETARY_PATH.md


39. docs/architecture/MOIRA_NATIVE_PLANETARY_RETROSPECTIVE.md Additional files +222/-0

...

docs/architecture/MOIRA_NATIVE_PLANETARY_RETROSPECTIVE.md


40. docs/architecture/MOIRA_NATIVE_PUBLIC_PLANETARY_EVALUATOR.md Additional files +356/-0

...

docs/architecture/MOIRA_NATIVE_PUBLIC_PLANETARY_EVALUATOR.md


41. docs/architecture/MOIRA_NATIVE_PUBLIC_PLANETARY_EVALUATOR_SPEC.md Additional files +315/-0

...

docs/architecture/MOIRA_NATIVE_PUBLIC_PLANETARY_EVALUATOR_SPEC.md


42. docs/architecture/MOIRA_NUMPY_SPICE_DEPENDENCY_MAP.md Additional files +190/-0

...

docs/architecture/MOIRA_NUMPY_SPICE_DEPENDENCY_MAP.md


43. docs/architecture/MOIRA_SOVEREIGN_SMALL_BODY_KERNEL_PLAN.md Additional files +191/-0

...

docs/architecture/MOIRA_SOVEREIGN_SMALL_BODY_KERNEL_PLAN.md


44. docs/architecture/MOIRA_SPICEYPY_REMOVAL_PLAN.md Additional files +191/-0

...

docs/architecture/MOIRA_SPICEYPY_REMOVAL_PLAN.md


45. llms-full.txt Additional files +35/-0

...

llms-full.txt


46. llms.txt Additional files +27/-0

...

llms.txt


47. moira/__init__.py Additional files +0/-30

...

moira/init.py


48. moira/_facade_core.py Additional files +8/-5

...

moira/_facade_core.py


49. moira/_facade_kernel.py Additional files +30/-4

...

moira/_facade_kernel.py


50. moira/comets.py Additional files +69/-69

...

moira/comets.py


51. moira/coordinates.py Additional files +25/-9

...

moira/coordinates.py


52. moira/data/iers_eop.txt Additional files +19853/-0

...

moira/data/iers_eop.txt


53. moira/data/iers_polar_motion.txt Additional files +19863/-0

...

moira/data/iers_polar_motion.txt


54. moira/data/leap_seconds.py Additional files +36/-0

...

moira/data/leap_seconds.py


55. moira/dispatch.py Additional files +51/-0

...

moira/dispatch.py


56. moira/julian.py Additional files +106/-7

...

moira/julian.py


57. moira/lunar_cartography.py Additional files +0/-418

...

moira/lunar_cartography.py


58. moira/moira_native.py Additional files +50/-0

...

moira/moira_native.py


59. moira/nutation_2000a.py Additional files +25/-95

...

moira/nutation_2000a.py


60. moira/polar_motion.py Additional files +156/-0

...

moira/polar_motion.py


61. moira/sky/time.py Additional files +6/-1

...

moira/sky/time.py


62. moira/solar_cartography.py Additional files +0/-950

...

moira/solar_cartography.py


63. moira/star_types.py Additional files +2/-0

...

moira/star_types.py


64. moira/stars.py Additional files +105/-0

...

moira/stars.py


65. moira/tools/iers_sync.py Additional files +161/-0

...

moira/tools/iers_sync.py


66. moira/transits_aspects.py Additional files +275/-0

...

moira/transits_aspects.py


67. moira/transits_equatorial.py Additional files +210/-0

...

moira/transits_equatorial.py


68. moira/transits_houses.py Additional files +128/-0

...

moira/transits_houses.py


69. pyproject.toml Additional files +20/-5

...

pyproject.toml


70. scratch/benchmark_heliacal_performance.py Additional files +50/-0

...

scratch/benchmark_heliacal_performance.py


71. scratch/debug_coords.py Additional files +23/-0

...

scratch/debug_coords.py


72. scratch/debug_jd_roundtrip.py Additional files +14/-0

...

scratch/debug_jd_roundtrip.py


73. scratch/test_aspect_transits.py Additional files +13/-0

...

scratch/test_aspect_transits.py


74. scratch/test_batch.py Additional files +58/-0

...

scratch/test_batch.py


75. scripts/_APOLLO_FIX_SUMMARY.md Additional files +87/-0

...

scripts/_APOLLO_FIX_SUMMARY.md


76. scripts/_check_apollo_in_official.py Additional files +55/-0

...

scripts/_check_apollo_in_official.py


77. scripts/_check_apollo_solutions.py Additional files +42/-0

...

scripts/_check_apollo_solutions.py


78. scripts/_check_existing_apollo.py Additional files +44/-0

...

scripts/_check_existing_apollo.py


79. scripts/_check_horizons_csv_format.py Additional files +56/-0

...

scripts/_check_horizons_csv_format.py


80. scripts/_check_kernel_types.py Additional files +50/-0

...

scripts/_check_kernel_types.py


81. scripts/_check_moon_segments.py Additional files +34/-0

...

scripts/_check_moon_segments.py


82. scripts/_investigate_moon_error.py Additional files +181/-0

...

scripts/_investigate_moon_error.py


83. scripts/_list_asteroid_comet_bodies.py Additional files +57/-0

...

scripts/_list_asteroid_comet_bodies.py


84. scripts/_moon_apparent_vs_geometric.py Additional files +157/-0

...

scripts/_moon_apparent_vs_geometric.py


85. scripts/_moon_error_simple.py Additional files +166/-0

...

scripts/_moon_error_simple.py


86. scripts/_propose_apollo_fix.md Additional files +62/-0

...

scripts/_propose_apollo_fix.md


87. scripts/_test_apollo_fix.py Additional files +35/-0

...

scripts/_test_apollo_fix.py


88. scripts/_test_validated_chunking.py Additional files +26/-0

...

scripts/_test_validated_chunking.py


89. scripts/_trace_chunk_boundary.py Additional files +114/-0

...

scripts/_trace_chunk_boundary.py


90. scripts/_validate_shard_18.py Additional files +124/-0

...

scripts/_validate_shard_18.py


91. scripts/audit_phase3_search.py Additional files +71/-0

...

scripts/audit_phase3_search.py


92. scripts/audit_phase4_edge_cases.py Additional files +42/-0

...

scripts/audit_phase4_edge_cases.py


93. scripts/benchmark_native_eclipse.py Additional files +125/-0

...

scripts/benchmark_native_eclipse.py


94. scripts/benchmark_native_phase1_sidereal.py Additional files +202/-0

...

scripts/benchmark_native_phase1_sidereal.py


95. scripts/benchmark_native_phase2_all_planets.py Additional files +157/-0

...

scripts/benchmark_native_phase2_all_planets.py


96. scripts/benchmark_native_phase2_catalog.py Additional files +70/-0

...

scripts/benchmark_native_phase2_catalog.py


97. scripts/benchmark_native_phase2_ephemeris.py Additional files +134/-0

...

scripts/benchmark_native_phase2_ephemeris.py


98. scripts/benchmark_native_phase2_planet_at.py Additional files +157/-0

...

scripts/benchmark_native_phase2_planet_at.py


99. scripts/benchmark_native_phase2_segments.py Additional files +124/-0

...

scripts/benchmark_native_phase2_segments.py


100. scripts/benchmark_native_phase2_segments_series_eval.py Additional files +130/-0

...

scripts/benchmark_native_phase2_segments_series_eval.py


101. scripts/benchmark_native_phase2_small_bodies.py Additional files +164/-0

...

scripts/benchmark_native_phase2_small_bodies.py


102. scripts/benchmark_swiss_planetary_reference.py Additional files +112/-0

...

scripts/benchmark_swiss_planetary_reference.py


103. scripts/build_custom_type13_asteroid_kernel.py Additional files +198/-0

...

scripts/build_custom_type13_asteroid_kernel.py


104. scripts/build_planetary_benchmark_comparison_map.py Additional files +135/-0

...

scripts/build_planetary_benchmark_comparison_map.py


105. scripts/build_sb441_type13_shards.py Additional files +215/-0

...

scripts/build_sb441_type13_shards.py


106. scripts/capture_lunar_limb_oracle.py Additional files +63/-0

...

scripts/capture_lunar_limb_oracle.py


107. scripts/diag_apollo.py Additional files +47/-0

...

scripts/diag_apollo.py


108. scripts/diag_center.py Additional files +29/-0

...

scripts/diag_center.py


109. scripts/diag_csv.py Additional files +15/-0

...

scripts/diag_csv.py


110. scripts/find_ids.py Additional files +10/-0

...

scripts/find_ids.py


111. scripts/fix_shards.py Additional files +86/-0

...

scripts/fix_shards.py


112. scripts/profile_planetary_flow_stage_timing.py Additional files +353/-0

...

scripts/profile_planetary_flow_stage_timing.py


113. scripts/run_absolute_oracle_check_public_today.py Additional files +287/-0

...

scripts/run_absolute_oracle_check_public_today.py


114. scripts/stress_test_phase3.py Additional files +98/-0

...

scripts/stress_test_phase3.py


115. scripts/trace_hermite.py Additional files +58/-0

...

scripts/trace_hermite.py


116. scripts/validate_native_solvers.py Additional files +171/-0

...

scripts/validate_native_solvers.py


117. scripts/validate_phase4_events.py Additional files +55/-0

...

scripts/validate_phase4_events.py


118. scripts/verify_sovereign_shards.py Additional files +79/-0

...

scripts/verify_sovereign_shards.py


119. src/native/bindings/moira_native.cpp Additional files +1971/-0

...

src/native/bindings/moira_native.cpp


120. src/native/include/cartography.hpp Additional files +1153/-0

...

src/native/include/cartography.hpp


121. src/native/include/constants.hpp Additional files +42/-0

...

src/native/include/constants.hpp


122. Additional files not shown Additional files +0/-0

...

Additional files not shown


Grey Divider

Qodo Logo

@qodo-code-review
Copy link
Copy Markdown

qodo-code-review Bot commented May 15, 2026

Code Review by Qodo

🐞 Bugs (5) 📘 Rule violations (0)

Grey Divider


Action required

1. Hardcoded Windows CMake paths 🐞 Bug ☼ Reliability
Description
CMakeLists.txt hardcodes absolute Windows include/library paths and forces a ".pyd" suffix, which
will break builds on other machines/OSes and in CI. It also bakes a specific local user path into
the build configuration.
Code

CMakeLists.txt[R7-34]

+# Explicitly include Python and pybind11 directories to bypass config failures
+include_directories("C:/Python314/Include")
+include_directories("C:/Users/nilad/AppData/Roaming/Python/Python314/site-packages/pybind11/include")
+include_directories(src/native/include)
+
+# Define the extension module manually under a private backend name.
+add_library(_moira_native MODULE 
+    src/native/bindings/moira_native.cpp
+    src/native/src/lola.cpp
+)
+
+# Link against Python library
+target_link_libraries(_moira_native PRIVATE "C:/Python314/libs/python314.lib")
+
+# Set target properties to match Python's extension requirements
+set_target_properties(_moira_native PROPERTIES
+    PREFIX ""
+    SUFFIX ".pyd"
+    OUTPUT_NAME "_moira_native"
+    LIBRARY_OUTPUT_DIRECTORY "${CMAKE_SOURCE_DIR}/moira"
+    RUNTIME_OUTPUT_DIRECTORY "${CMAKE_SOURCE_DIR}/moira"
+    LIBRARY_OUTPUT_DIRECTORY_RELEASE "${CMAKE_SOURCE_DIR}/moira"
+    RUNTIME_OUTPUT_DIRECTORY_RELEASE "${CMAKE_SOURCE_DIR}/moira"
+    LIBRARY_OUTPUT_DIRECTORY_DEBUG "${CMAKE_SOURCE_DIR}/moira"
+    RUNTIME_OUTPUT_DIRECTORY_DEBUG "${CMAKE_SOURCE_DIR}/moira"
+    CXX_VISIBILITY_PRESET hidden
+    VISIBILITY_INLINES_HIDDEN ON
+)
Evidence
The build file directly references C:/Python314/... and C:/Users/nilad/... and sets `SUFFIX
".pyd"`, which is Windows-specific and not portable.

CMakeLists.txt[7-34]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

### Issue description
`CMakeLists.txt` hardcodes developer-specific Windows paths for Python/pybind11 and forces the extension suffix to `.pyd`, making the build non-portable and likely to fail in CI or on non-Windows platforms.

### Issue Context
The native extension should be built using CMake’s Python discovery and pybind11 tooling (or equivalent), with platform-correct extension suffixes and no absolute user paths.

### Fix Focus Areas
- CMakeLists.txt[7-34]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


2. Cartography binding OOB risk 🐞 Bug ⛨ Security
Description
solar_cartography_grid_sweep_py and lunar_cartography_grid_sweep_py pass raw pointers to native
routines using lats_deg.size()/jds.size() without validating that lons_deg and output arrays
have matching sizes, enabling out-of-bounds reads/writes and crashes on mismatched inputs. The
vectors variant in the same file does validate lengths, indicating the scalar variant is missing
required checks.
Code

src/native/bindings/moira_native.cpp[R960-1036]

+void solar_cartography_grid_sweep_py(
+    const IEvaluator& sun,
+    const IEvaluator& moon,
+    py::array_t<double> jds,
+    py::array_t<double> gasts_deg,
+    py::array_t<double> lats_deg,
+    py::array_t<double> lons_deg,
+    double sun_radius_km,
+    double moon_radius_km,
+    py::array_t<double> overlap_max,
+    py::array_t<double> central_max,
+    py::array_t<double> magnitude_max
+) {
+    auto jd_ptr = jds.data();
+    auto gast_ptr = gasts_deg.data();
+    auto lat_ptr = lats_deg.data();
+    auto lon_ptr = lons_deg.data();
+    auto overlap_ptr = overlap_max.mutable_data();
+    auto central_ptr = central_max.mutable_data();
+    auto magnitude_ptr = magnitude_max.mutable_data();
+
+    solar_cartography_grid_sweep(
+        sun, moon, jd_ptr, gast_ptr, jds.size(),
+        lat_ptr, lon_ptr, lats_deg.size(),
+        sun_radius_km, moon_radius_km,
+        overlap_ptr, central_ptr, magnitude_ptr
+    );
+}
+
+void lunar_cartography_grid_sweep_py(
+    const IEvaluator& sun,
+    const IEvaluator& moon,
+    py::array_t<double> jds,
+    py::array_t<double> gasts_deg,
+    py::array_t<double> magnitudes_base,
+    py::array_t<double> lats_deg,
+    py::array_t<double> lons_deg,
+    py::array_t<double> penumbral_max,
+    py::array_t<double> partial_max,
+    py::array_t<double> total_max,
+    py::array_t<double> magnitude_max,
+    py::object u1_u4_obj,
+    py::object u2_u3_obj
+) {
+    auto jd_ptr = jds.data();
+    auto gast_ptr = gasts_deg.data();
+    auto mag_base_ptr = magnitudes_base.data();
+    auto lat_ptr = lats_deg.data();
+    auto lon_ptr = lons_deg.data();
+    auto pen_ptr = penumbral_max.mutable_data();
+    auto par_ptr = partial_max.mutable_data();
+    auto tot_ptr = total_max.mutable_data();
+    auto mag_ptr = magnitude_max.mutable_data();
+
+    double u1_u4[2], u2_u3[2];
+    double* p_u1_u4 = nullptr;
+    double* p_u2_u3 = nullptr;
+
+    if (!u1_u4_obj.is_none()) {
+        auto arr = u1_u4_obj.cast<py::array_t<double>>();
+        u1_u4[0] = arr.at(0);
+        u1_u4[1] = arr.at(1);
+        p_u1_u4 = u1_u4;
+    }
+    if (!u2_u3_obj.is_none()) {
+        auto arr = u2_u3_obj.cast<py::array_t<double>>();
+        u2_u3[0] = arr.at(0);
+        u2_u3[1] = arr.at(1);
+        p_u2_u3 = u2_u3;
+    }
+
+    lunar_cartography_grid_sweep(
+        sun, moon, jd_ptr, gast_ptr, mag_base_ptr, jds.size(),
+        lat_ptr, lon_ptr, lats_deg.size(),
+        pen_ptr, par_ptr, tot_ptr, mag_ptr,
+        p_u1_u4, p_u2_u3
+    );
Evidence
The scalar grid sweep functions compute n from lats_deg.size() and then use lon_ptr and output
pointers without checking sizes. A nearby implementation (solar_cartography_grid_sweep_vectors_py)
explicitly checks length consistency, demonstrating the intended safety guardrails.

src/native/bindings/moira_native.cpp[960-1036]
src/native/bindings/moira_native.cpp[1119-1124]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

### Issue description
The pybind entrypoints `solar_cartography_grid_sweep_py` and `lunar_cartography_grid_sweep_py` assume input/output NumPy arrays are compatible in length and then use raw pointers. If a caller passes arrays of different lengths (e.g., `lats_deg` and `lons_deg` differ), native code may read/write past buffer bounds.

### Issue Context
The `*_vectors_py` variants already check dimensionality and length consistency; the non-vector variants should enforce similar validation (ndim==1, equal lengths for paired arrays, and output buffers sized to the expected point count).

### Fix Focus Areas
- src/native/bindings/moira_native.cpp[960-1036]
- src/native/bindings/moira_native.cpp[1119-1124]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


3. Flask debug mode enabled 🐞 Bug ⛨ Security
Description
app.py runs Flask with debug=True, which enables the interactive debugger and can allow remote
code execution if the service is reachable from an untrusted network. This should not be enabled by
default in a checked-in entrypoint.
Code

app.py[R62-63]

+if __name__ == "__main__":
+    app.run(port=5000, debug=True)
Evidence
The entrypoint explicitly passes debug=True to app.run().

app.py[62-63]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

### Issue description
The Flask app is launched with `debug=True`, which is unsafe if the server is ever exposed beyond localhost.

### Issue Context
Debug mode should be controlled via environment/config and default to off.

### Fix Focus Areas
- app.py[62-63]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools



Remediation recommended

4. Kernels packaged as module 🐞 Bug ⚙ Maintainability
Description
pyproject.toml adds moira.kernels to the explicit packages = [...] list even though kernels
are configured as package-data under the moira package (kernels/*.bsp). This is inconsistent and
can cause packaging confusion or failure if moira/kernels is not an importable Python package.
Code

pyproject.toml[R88-92]

[tool.setuptools]
-packages = ["moira", "moira.data", "moira.compat", "moira.compat.nasa", "moira.constellations", "moira.primary_directions", "moira.harmograms", "moira.bridges"]
-license-files = ["LICENSE"]
+packages = ["moira", "moira.data", "moira.compat", "moira.compat.nasa", "moira.constellations", "moira.primary_directions", "moira.harmograms", "moira.bridges", "moira.kernels"]

[tool.setuptools.package-data]
moira = [
Evidence
The packaging config simultaneously lists moira.kernels as a Python package and kernels/*.bsp as
moira package-data, while kernel resolution code treats kernels as a filesystem directory under
moira.

pyproject.toml[88-97]
moira/_kernel_paths.py[36-60]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

### Issue description
`pyproject.toml` treats kernels both as a Python package (`moira.kernels`) and as data files under `moira` (`kernels/*.bsp`). Only one approach should be used; if kernels are data, they should not be listed as a Python package.

### Issue Context
The runtime code resolves kernels via filesystem paths relative to the `moira` package directory, not via importing `moira.kernels`.

### Fix Focus Areas
- pyproject.toml[88-97]
- moira/_kernel_paths.py[36-60]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools



Advisory comments

5. Windows-only native fallback 🐞 Bug ☼ Reliability
Description
moira/moira_native.py fallback loader only searches for _moira_native.pyd, so if the normal
relative import fails, in-place dev builds on non-Windows platforms (e.g., .so/.dylib) won’t be
discovered. This reduces cross-platform reliability of the fallback mechanism.
Code

moira/moira_native.py[R17-26]

+def _load_backend():
+    try:
+        return import_module("._moira_native", __package__)
+    except ImportError:
+        package_dir = Path(__file__).resolve().parent
+        candidates = (
+            package_dir / "_moira_native.pyd",
+            package_dir / "Release" / "_moira_native.pyd",
+            package_dir / "Debug" / "_moira_native.pyd",
+        )
Evidence
The fallback candidate list is hard-coded to .pyd files and does not include other platform
extension suffixes.

moira/moira_native.py[17-26]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

### Issue description
The fallback loader only considers `.pyd` artifacts.

### Issue Context
The primary import (`import_module("._moira_native", __package__)`) may be sufficient when installed properly, but the fallback is intended to help with local/in-place builds; it should look for platform-appropriate extension suffixes.

### Fix Focus Areas
- moira/moira_native.py[17-26]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


Grey Divider

Qodo Logo

Copy link
Copy Markdown

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 4a7b593c4f

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread moira/_facade_core.py
Comment on lines 100 to 102
planets = facade.all_planets_at(
jd,
jd_tt,
bodies=bodies,
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Use UT input when calling all_planets_at

all_planets_at() expects a UT/UT1 Julian date and already performs its own ut_to_tt() conversion internally, but this call now passes jd_tt. That applies the UTC→TT offset twice, shifting every planet calculation in chart() by roughly another ΔT (tens of seconds), which is large enough to skew fast-moving bodies and downstream chart outputs.

Useful? React with 👍 / 👎.

@getfoyer
Copy link
Copy Markdown

getfoyer Bot commented May 15, 2026

Foyer PR Review

  • Speeds up planet calculations and search operations by adding a high-performance background engine written in C++.
  • Removes the requirement for optional math libraries during normal use, making the core engine easier to install with fewer dependencies.
  • Adds new astrology features: a Decennials time-period system, a way to check if a planet is Cazimi or Combust, eclipse-hit lists for natal charts, derived houses, East Point, solar return chart, and the ability to cast a chart for a different location.

Verdict: Needs changes before merging — 2 issues that need attention

Intent source: PR description (medium confidence)

Large PR — consider splitting. 43104 changed lines across 241 files.
Reviewers (and the intent-fidelity judge) tend to miss issues in oversized diffs. Foyer still ran the gate, but breaking this into smaller PRs before merge would help.

Did it do what was asked
No verifiable requirements extracted from intent text
Evidence: intent text

Code quality — 2 issues that need attention

  • Needs attention moira/planets.py:1490 — planetary position functions may fail with a missing kernel error, even when the kernel file exists on disk

    the code used to find the kernel file automatically; after this change it throws an error unless a kernel was set up first through the Moira class or a special configuration step

  • Needs attention moira/_facade_kernel.py:100 — the Moira engine may crash during startup if the optional native acceleration library is not installed

    the code tries to load asteroid and comet reader helpers that need the native library, but the error is not caught, so the program stops even if only planet positions were requested

Security check
No secrets, PII, or vulnerability patterns detected in diff
Evidence: scanned the diff

What to address first:
2 likely bugs (2 P1)


Repair packets (for Claude Code / Codex / Cursor / Windsurf)

Code review:

Diagnosis

The PR’s de‑singleton refactor replaced get_reader() (which auto‑discovered kernels) with get_active_reader() in planetary-entry functions such as planet_at. As a result, when no reader is injected and no global reader has been initialised, the functions immediately raise MissingKernelError instead of searching for the planetary kernel on disk (see moira/planets.py:1490). Separately, the KernelFacadeMixin unconditionally imports SmallBodyKernel from moira._spk_body_kernel at module level (source line 11, accessed during the _try_initialize_reader block around moira/_facade_kernel.py:100). If the native C++ extension is absent, _spk_body_kernel fails to import because it requires _moira_native, and the resulting ImportError is not caught by the surrounding except (FileNotFoundError, MissingKernelError) clause, crashing the entire facade even for planetary-only work.

Fix plan

  1. moira/planets.py – In planet_at, all_planets_at, sky_position_at, and similar public-entry functions that now use get_active_reader(), add a fallback: when the return is None, attempt automatic kernel discovery by calling get_reader() (which still carries the auto‑discovery legacy) or directly invoke find_planetary_kernel() + SpkReader + use_reader_override. This restores the pre‑PR behaviour of loading the kernel on first use without requiring a caller‑injected reader.
  2. moira/_facade_kernel.py – Make the import of SmallBodyKernel and small_body_readers_from_manifest lazy by moving it inside the _try_initialize_reader method, wrapped in a try: … except ImportError: guard. If the native extension is unavailable, skip the supplemental kernel loading but allow the facade to continue with the planetary kernel only; optionally log a warning. Ensure that the unconditional module‑level import is removed.

Acceptance check

Run the exact command that produced the failure signal (the original code‑review check):

pytest -k "test_planet_at_no_reader or test_facade_without_native" --maxfail=1

Alternatively, execute the specific test scenarios that validate the reported bugs:

  • A standalone call planet_at("Sun", jd) without a prior Moira facade instantiation should succeed when a planetary kernel is present on disk.
  • from moira import Moira should succeed without a native extension, and Moira().chart(...) should work using the planetary kernel only.
Context
  • signal: code_review
  • repository: TheDaniel166/moira
  • pr_number: 5
  • head_sha: 01252341df6a7eff8dea669befad5a831c22e384
  • base_sha: cb248490d84e9d588af77e3eb37331f957d3f50a
  • source: llm
--- Foyer checked this PR · 1 passed, 1 flagged
Coverage: 3 of 9 signals (reduced for Python)

Foyer's tests, build, dead-code, dependency-hygiene, hallucinated-imports, template-cruft, plan-fidelity, deploy-build, and browser-smoke signals require JavaScript/TypeScript tooling. Native support for Python is on our roadmap.

For a fully verified PR on Python, watch this space or reach out at hello@foyer.dev.

Comment thread CMakeLists.txt
Comment on lines +7 to +34
# Explicitly include Python and pybind11 directories to bypass config failures
include_directories("C:/Python314/Include")
include_directories("C:/Users/nilad/AppData/Roaming/Python/Python314/site-packages/pybind11/include")
include_directories(src/native/include)

# Define the extension module manually under a private backend name.
add_library(_moira_native MODULE
src/native/bindings/moira_native.cpp
src/native/src/lola.cpp
)

# Link against Python library
target_link_libraries(_moira_native PRIVATE "C:/Python314/libs/python314.lib")

# Set target properties to match Python's extension requirements
set_target_properties(_moira_native PROPERTIES
PREFIX ""
SUFFIX ".pyd"
OUTPUT_NAME "_moira_native"
LIBRARY_OUTPUT_DIRECTORY "${CMAKE_SOURCE_DIR}/moira"
RUNTIME_OUTPUT_DIRECTORY "${CMAKE_SOURCE_DIR}/moira"
LIBRARY_OUTPUT_DIRECTORY_RELEASE "${CMAKE_SOURCE_DIR}/moira"
RUNTIME_OUTPUT_DIRECTORY_RELEASE "${CMAKE_SOURCE_DIR}/moira"
LIBRARY_OUTPUT_DIRECTORY_DEBUG "${CMAKE_SOURCE_DIR}/moira"
RUNTIME_OUTPUT_DIRECTORY_DEBUG "${CMAKE_SOURCE_DIR}/moira"
CXX_VISIBILITY_PRESET hidden
VISIBILITY_INLINES_HIDDEN ON
)
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Action required

1. Hardcoded windows cmake paths 🐞 Bug ☼ Reliability

CMakeLists.txt hardcodes absolute Windows include/library paths and forces a ".pyd" suffix, which
will break builds on other machines/OSes and in CI. It also bakes a specific local user path into
the build configuration.
Agent Prompt
### Issue description
`CMakeLists.txt` hardcodes developer-specific Windows paths for Python/pybind11 and forces the extension suffix to `.pyd`, making the build non-portable and likely to fail in CI or on non-Windows platforms.

### Issue Context
The native extension should be built using CMake’s Python discovery and pybind11 tooling (or equivalent), with platform-correct extension suffixes and no absolute user paths.

### Fix Focus Areas
- CMakeLists.txt[7-34]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools

Comment on lines +960 to +1036
void solar_cartography_grid_sweep_py(
const IEvaluator& sun,
const IEvaluator& moon,
py::array_t<double> jds,
py::array_t<double> gasts_deg,
py::array_t<double> lats_deg,
py::array_t<double> lons_deg,
double sun_radius_km,
double moon_radius_km,
py::array_t<double> overlap_max,
py::array_t<double> central_max,
py::array_t<double> magnitude_max
) {
auto jd_ptr = jds.data();
auto gast_ptr = gasts_deg.data();
auto lat_ptr = lats_deg.data();
auto lon_ptr = lons_deg.data();
auto overlap_ptr = overlap_max.mutable_data();
auto central_ptr = central_max.mutable_data();
auto magnitude_ptr = magnitude_max.mutable_data();

solar_cartography_grid_sweep(
sun, moon, jd_ptr, gast_ptr, jds.size(),
lat_ptr, lon_ptr, lats_deg.size(),
sun_radius_km, moon_radius_km,
overlap_ptr, central_ptr, magnitude_ptr
);
}

void lunar_cartography_grid_sweep_py(
const IEvaluator& sun,
const IEvaluator& moon,
py::array_t<double> jds,
py::array_t<double> gasts_deg,
py::array_t<double> magnitudes_base,
py::array_t<double> lats_deg,
py::array_t<double> lons_deg,
py::array_t<double> penumbral_max,
py::array_t<double> partial_max,
py::array_t<double> total_max,
py::array_t<double> magnitude_max,
py::object u1_u4_obj,
py::object u2_u3_obj
) {
auto jd_ptr = jds.data();
auto gast_ptr = gasts_deg.data();
auto mag_base_ptr = magnitudes_base.data();
auto lat_ptr = lats_deg.data();
auto lon_ptr = lons_deg.data();
auto pen_ptr = penumbral_max.mutable_data();
auto par_ptr = partial_max.mutable_data();
auto tot_ptr = total_max.mutable_data();
auto mag_ptr = magnitude_max.mutable_data();

double u1_u4[2], u2_u3[2];
double* p_u1_u4 = nullptr;
double* p_u2_u3 = nullptr;

if (!u1_u4_obj.is_none()) {
auto arr = u1_u4_obj.cast<py::array_t<double>>();
u1_u4[0] = arr.at(0);
u1_u4[1] = arr.at(1);
p_u1_u4 = u1_u4;
}
if (!u2_u3_obj.is_none()) {
auto arr = u2_u3_obj.cast<py::array_t<double>>();
u2_u3[0] = arr.at(0);
u2_u3[1] = arr.at(1);
p_u2_u3 = u2_u3;
}

lunar_cartography_grid_sweep(
sun, moon, jd_ptr, gast_ptr, mag_base_ptr, jds.size(),
lat_ptr, lon_ptr, lats_deg.size(),
pen_ptr, par_ptr, tot_ptr, mag_ptr,
p_u1_u4, p_u2_u3
);
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Action required

2. Cartography binding oob risk 🐞 Bug ⛨ Security

solar_cartography_grid_sweep_py and lunar_cartography_grid_sweep_py pass raw pointers to native
routines using lats_deg.size()/jds.size() without validating that lons_deg and output arrays
have matching sizes, enabling out-of-bounds reads/writes and crashes on mismatched inputs. The
vectors variant in the same file does validate lengths, indicating the scalar variant is missing
required checks.
Agent Prompt
### Issue description
The pybind entrypoints `solar_cartography_grid_sweep_py` and `lunar_cartography_grid_sweep_py` assume input/output NumPy arrays are compatible in length and then use raw pointers. If a caller passes arrays of different lengths (e.g., `lats_deg` and `lons_deg` differ), native code may read/write past buffer bounds.

### Issue Context
The `*_vectors_py` variants already check dimensionality and length consistency; the non-vector variants should enforce similar validation (ndim==1, equal lengths for paired arrays, and output buffers sized to the expected point count).

### Fix Focus Areas
- src/native/bindings/moira_native.cpp[960-1036]
- src/native/bindings/moira_native.cpp[1119-1124]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools

Comment thread app.py
Comment on lines +62 to +63
if __name__ == "__main__":
app.run(port=5000, debug=True)
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Action required

3. Flask debug mode enabled 🐞 Bug ⛨ Security

app.py runs Flask with debug=True, which enables the interactive debugger and can allow remote
code execution if the service is reachable from an untrusted network. This should not be enabled by
default in a checked-in entrypoint.
Agent Prompt
### Issue description
The Flask app is launched with `debug=True`, which is unsafe if the server is ever exposed beyond localhost.

### Issue Context
Debug mode should be controlled via environment/config and default to off.

### Fix Focus Areas
- app.py[62-63]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools

transits_aspects and transits_equatorial used a hard top-level
import of moira_native, unlike every other module which wraps
the import in try/except ImportError. This caused the entire
moira import chain to fail in CI where the C++ extension is
absent, breaking tests that don't use native functionality at all.

Also replaced mn.IEvaluator | None return annotations (evaluated
eagerly at import time) with object | None.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
@qodo-code-review
Copy link
Copy Markdown

CI Feedback 🧐

A test triggered by this PR failed. Here is an AI-generated analysis of the failure:

Action: Release Acceptance Matrix Gate

Failed stage: Hermetic decans acceptance (kernel-free subset) [❌]

Failed test name: ""

Failure summary:

  • Pytest fails during session setup (before any assertions run) because importing the moira package
    raises a NameError.
  • Root cause: in moira/timelords.py at line 1201, the function definition def
    _decennial_supported_max_level(policy: DecennialPolicy) -> int: references the type DecennialPolicy,
    but DecennialPolicy is not defined/imported at runtime, causing NameError: name 'DecennialPolicy' is
    not defined.
  • This error occurs while the autouse fixture _bootstrap_kernel_singleton in tests/conftest.py:372
    imports moira._kernel_paths, which triggers moira/__init__.pymoira/facade.py
    moira/timelords.py, so nearly all tests error out at setup.
Relevant error logs:
1:  ##[group]Runner Image Provisioner
2:  Hosted Compute Agent
...

298:  �[36;1mpython -m pytest tests/unit/test_hermetic_decans.py -q \�[0m
299:  �[36;1m  -k "not decan_hours_start_matches_mc_decan_at_sunset and not usno_one_day_boundary_agreement"�[0m
300:  shell: /usr/bin/bash -e {0}
301:  env:
302:  MOIRA_NO_DOWNLOAD: 1
303:  MOIRA_TEST_MODE: 1
304:  pythonLocation: /opt/hostedtoolcache/Python/3.12.13/x64
305:  PKG_CONFIG_PATH: /opt/hostedtoolcache/Python/3.12.13/x64/lib/pkgconfig
306:  Python_ROOT_DIR: /opt/hostedtoolcache/Python/3.12.13/x64
307:  Python2_ROOT_DIR: /opt/hostedtoolcache/Python/3.12.13/x64
308:  Python3_ROOT_DIR: /opt/hostedtoolcache/Python/3.12.13/x64
309:  LD_LIBRARY_PATH: /opt/hostedtoolcache/Python/3.12.13/x64/lib
310:  ##[endgroup]
311:  EEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEE [ 87%]
312:  EEEEEEEEEE                                                               [100%]
313:  ==================================== ERRORS ====================================
314:  ________ ERROR at setup of test_solar_declination_ra_uses_tt_obliquity _________
315:  @pytest.fixture(scope="session", autouse=True)
316:  def _bootstrap_kernel_singleton() -> None:
317:  """Configure the global SpkReader singleton once per test session.
318:  When a planetary kernel is available, calling set_kernel_path() here ensures
319:  that module-level functions (phase.apparent_magnitude, etc.) which call
320:  get_reader() directly can reuse the same initialized singleton without each
321:  test needing to go through the Moira facade.
322:  No-ops when no kernel is installed; requires_ephemeris tests skip naturally.
323:  """
324:  >       from moira._kernel_paths import find_planetary_kernel
325:  tests/conftest.py:372: 
326:  _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ 
327:  moira/__init__.py:30: in <module>
328:  from .facade import Chart, MissingEphemerisKernelError, Moira, __author__, __version__
329:  moira/facade.py:540: in <module>
...

657:  """
658:  level:        int           # 1 = major period, 2 = sub-period
659:  planet:       str
660:  start_jd:     float
661:  end_jd:       float
662:  years:        float
663:  # Phase 1: preserved generative context
664:  major_planet: str | None = None   # for level=2: the level-1 lord this sub-period belongs to
665:  is_day_chart: bool | None = None  # diurnal (True) or nocturnal (False) chart sect
666:  variant:      str | None = None   # "standard" or "bonatti" sequence variant
667:  # Phase 2: typed classification
668:  sequence_kind:  str | None = None  # FirdarSequenceKind constant
669:  is_node_period: bool = False        # True when planet is North Node or South Node
670:  def __post_init__(self) -> None:
671:  if self.level not in (1, 2):
672:  raise ValueError(f"FirdarPeriod.level must be 1 or 2, got {self.level}")
673:  if not math.isfinite(self.start_jd) or not math.isfinite(self.end_jd):
674:  raise ValueError("FirdarPeriod start_jd and end_jd must be finite")
675:  if self.end_jd <= self.start_jd:
676:  raise ValueError("FirdarPeriod end_jd must be greater than start_jd")
677:  if self.years <= 0:
678:  raise ValueError("FirdarPeriod years must be positive")
679:  # --- Phase 3: inspectability ---
...

744:  Responsibilities:
745:  - Carry one level-1 major period and its level-2 sub-periods.
746:  - Enforce level correctness and chronological ordering.
747:  - Provide an inspectable relational surface for Firdaria groups.
748:  Non-responsibilities:
749:  - Computing major or sub-period boundaries.
750:  - Resolving active periods from a query JD.
751:  Dependencies:
752:  - Populated by `group_firdaria()`.
753:  - Consumes `FirdarPeriod` vessels produced by `firdaria()`.
754:  Structural invariants:
755:  - `major.level == 1`
756:  - every member of `subs` has `level == 2`
757:  - `subs` are in chronological order
758:  Failure behavior:
759:  - Raises `ValueError` when level or ordering invariants are broken.
760:  Canon: Demetra George, "Ancient Astrology in Theory and Practice" Vol.II
...

768:  "internal": ["__post_init__"]
769:  },
770:  "state": {"mutable": true, "owners": ["group_firdaria"]},
771:  "effects": {"signals_emitted": [], "io": []},
772:  "concurrency": {"thread": "pure_computation", "cross_thread_calls": "safe_read_only"},
773:  "failures": {"policy": "raise"},
774:  "succession": {"stance": "terminal"},
775:  "agent": {"autofix": "allowed", "requires_human_for": ["api_change"]}
776:  }
777:  [/MACHINE_CONTRACT]
778:  """
779:  major: FirdarPeriod
780:  subs:  list[FirdarPeriod]
781:  def __post_init__(self) -> None:
782:  if self.major.level != 1:
783:  raise ValueError(
784:  f"FirdarMajorGroup.major must be a level-1 period, got level {self.major.level}"
785:  )
786:  for sub in self.subs:
787:  if sub.level != 2:
788:  raise ValueError(
789:  f"FirdarMajorGroup.subs must contain only level-2 periods, got level {sub.level}"
790:  )
791:  # Phase 6 hardening — chronological ordering
792:  for i in range(len(self.subs) - 1):
793:  if self.subs[i].start_jd >= self.subs[i + 1].start_jd:
794:  raise ValueError(
795:  "FirdarMajorGroup.subs must be in chronological order"
...

854:  # major lord in a Firdaria sequence, so major_planet is a unique key.
855:  # JD-range filtering is intentionally omitted — floating-point
856:  # accumulation can push the last sub-period's end_jd fractionally
857:  # past the major's end_jd, causing a false exclusion.
858:  subs = [s for s in sub_periods if s.major_planet == major.planet]
859:  groups.append(FirdarMajorGroup(major=major, subs=subs))
860:  return groups
861:  # ---------------------------------------------------------------------------
862:  # Firdaria calculation
863:  # ---------------------------------------------------------------------------
864:  def _resolve_firdaria_sequence(
865:  is_day_chart: bool,
866:  variant: str,
867:  ) -> list[tuple[str, int]]:
868:  if variant not in {"standard", "bonatti"}:
869:  raise ValueError("firdaria variant must be 'standard' or 'bonatti'")
870:  if is_day_chart:
...

899:  Firdaria sequence variant: ``"standard"`` (default) or ``"bonatti"``.
900:  Only affects nocturnal charts; diurnal charts always use FIRDARIA_DIURNAL.
901:  include_node_subperiods : bool
902:  When True, North Node and South Node major periods are also subdivided
903:  into 7 sub-periods. Default False (nodes produce no sub-periods).
904:  policy : TimelordComputationPolicy | None
905:  Computation policy governing the Julian year constant. Uses
906:  DEFAULT_TIMELORD_POLICY when None.
907:  Returns
908:  -------
909:  list[FirdarPeriod]
910:  All major periods, each immediately followed by their 7 sub-periods,
911:  in chronological order.
912:  Raises
913:  ------
914:  ValueError
915:  If natal_jd is not finite.
916:  If variant is not ``"standard"`` or ``"bonatti"``.
917:  """
918:  if not math.isfinite(natal_jd):
919:  raise ValueError(f"firdaria: natal_jd must be finite, got {natal_jd!r}")
920:  pol = _resolve_timelord_policy(policy)
...

979:  Find the Firdaria major and sub-period active at a given date.
980:  Parameters
981:  ----------
982:  natal_jd : float
983:  Julian Day (UT) of birth.
984:  current_jd : float
985:  Julian Day (UT) of the date to evaluate.
986:  is_day_chart : bool
987:  True for a diurnal chart; False for a nocturnal chart.
988:  Returns
989:  -------
990:  tuple[FirdarPeriod, FirdarPeriod]
991:  (major_period, sub_period) active at current_jd.
992:  Raises
993:  ------
994:  ValueError
995:  If current_jd falls outside the 75-year Firdaria cycle.
996:  """
997:  if not math.isfinite(natal_jd):
998:  raise ValueError(f"current_firdaria: natal_jd must be finite, got {natal_jd!r}")
999:  if not math.isfinite(current_jd):
1000:  raise ValueError(f"current_firdaria: current_jd must be finite, got {current_jd!r}")
1001:  all_periods = firdaria(
1002:  natal_jd,
1003:  is_day_chart,
1004:  variant=variant,
1005:  include_node_subperiods=include_node_subperiods,
1006:  policy=policy,
1007:  )
1008:  major_periods = [p for p in all_periods if p.level == 1]
1009:  sub_periods   = [p for p in all_periods if p.level == 2]
1010:  active_major: FirdarPeriod | None = None
1011:  for p in major_periods:
1012:  if p.start_jd <= current_jd < p.end_jd:
1013:  active_major = p
1014:  break
1015:  if active_major is None:
1016:  raise ValueError(
1017:  f"current_jd {current_jd} falls outside the 75-year Firdaria cycle "
1018:  f"starting at natal_jd {natal_jd}."
1019:  )
1020:  active_sub: FirdarPeriod | None = None
1021:  for p in sub_periods:
1022:  if p.start_jd <= current_jd < p.end_jd:
1023:  active_sub = p
1024:  break
1025:  if active_sub is None:
1026:  if not _should_subdivide_firdaria_major(active_major.planet, include_node_subperiods):
1027:  return active_major, active_major
1028:  for p in sub_periods:
1029:  if p.start_jd == active_major.start_jd:
1030:  active_sub = p
1031:  break
1032:  if active_sub is None:
1033:  raise ValueError("Could not determine active Firdaria sub-period.")
1034:  return active_major, active_sub
...

1065:  major_planet: str | None = None
1066:  parent_planet: str | None = None
1067:  parent_level: int | None = None
1068:  is_day_chart: bool | None = None
1069:  sect_light: str | None = None
1070:  sequence_kind: str | None = None
1071:  deep_subdivision_method: str | None = None
1072:  sequence: tuple[str, ...] = field(default_factory=tuple)
1073:  ancestor_planets: tuple[str, ...] = field(default_factory=tuple)
1074:  major_index: int = 0
1075:  sub_index: int | None = None
1076:  major_month_total: float = float(_DECENNIAL_MAJOR_MONTHS)
1077:  month_basis_days: float = _DECENNIAL_MONTH_DAYS
1078:  def __post_init__(self) -> None:
1079:  if self.level not in (1, 2, 3, 4):
1080:  raise ValueError(f"DecennialPeriod.level must be 1, 2, 3, or 4, got {self.level}")
1081:  if self.planet not in _DECENNIAL_PLANETS:
1082:  raise ValueError(f"DecennialPeriod.planet must be a classical planet, got {self.planet!r}")
1083:  if not math.isfinite(self.start_jd) or not math.isfinite(self.end_jd):
1084:  raise ValueError("DecennialPeriod start_jd and end_jd must be finite")
1085:  if self.end_jd <= self.start_jd:
1086:  raise ValueError("DecennialPeriod end_jd must be greater than start_jd")
1087:  if self.years <= 0:
1088:  raise ValueError("DecennialPeriod years must be positive")
1089:  if self.months <= 0:
1090:  raise ValueError("DecennialPeriod months must be positive")
1091:  if self.sect_light is not None and self.sect_light not in {"Sun", "Moon"}:
1092:  raise ValueError("DecennialPeriod sect_light must be Sun, Moon, or None")
1093:  if self.sequence_kind is not None and self.sequence_kind not in {
1094:  DecennialSequenceKind.DIURNAL_SOLAR,
1095:  DecennialSequenceKind.NOCTURNAL_LUNAR,
1096:  }:
1097:  raise ValueError("DecennialPeriod sequence_kind must be a supported DecennialSequenceKind")
1098:  if self.parent_planet is not None and self.parent_planet not in _DECENNIAL_PLANETS:
1099:  raise ValueError("DecennialPeriod parent_planet must be a classical planet or None")
1100:  if self.parent_level is not None and self.parent_level not in (1, 2, 3):
1101:  raise ValueError("DecennialPeriod parent_level must be 1, 2, 3, or None")
1102:  if self.deep_subdivision_method is not None and self.deep_subdivision_method not in _DECENNIAL_DEEP_METHODS:
1103:  raise ValueError("DecennialPeriod deep_subdivision_method must be 'valens', 'hephaistio', or None")
1104:  if self.sequence and set(self.sequence) != set(_DECENNIAL_PLANETS):
1105:  raise ValueError("DecennialPeriod sequence must contain the seven classical planets exactly once")
1106:  if self.major_index < 0:
1107:  raise ValueError("DecennialPeriod major_index must be non-negative")
1108:  if self.sub_index is not None and self.sub_index < 0:
1109:  raise ValueError("DecennialPeriod sub_index must be non-negative when set")
1110:  if self.major_month_total <= 0:
1111:  raise ValueError("DecennialPeriod major_month_total must be positive")
1112:  if self.month_basis_days <= 0:
1113:  raise ValueError("DecennialPeriod month_basis_days must be positive")
1114:  if self.level == 1 and self.major_planet is not None:
1115:  raise ValueError("DecennialPeriod level-1 periods must not set major_planet")
1116:  if self.level == 1 and self.parent_planet is not None:
1117:  raise ValueError("DecennialPeriod level-1 periods must not set parent_planet")
1118:  if self.level == 1 and self.parent_level is not None:
1119:  raise ValueError("DecennialPeriod level-1 periods must not set parent_level")
1120:  if self.level == 1 and self.sub_index is not None:
1121:  raise ValueError("DecennialPeriod level-1 periods must not set sub_index")
1122:  if self.level == 1 and self.ancestor_planets:
1123:  raise ValueError("DecennialPeriod level-1 periods must not set ancestor_planets")
1124:  if self.level >= 2 and not self.major_planet:
1125:  if self.level == 2:
1126:  raise ValueError("DecennialPeriod level-2 periods must preserve major_planet")
1127:  raise ValueError("DecennialPeriod subordinate periods must preserve major_planet")
1128:  if self.level >= 2 and self.sub_index is None:
1129:  if self.level == 2:
1130:  raise ValueError("DecennialPeriod level-2 periods must preserve sub_index")
1131:  raise ValueError("DecennialPeriod subordinate periods must preserve sub_index")
1132:  if self.level >= 2 and not self.parent_planet:
1133:  raise ValueError("DecennialPeriod subordinate periods must preserve parent_planet")
1134:  if self.level >= 2 and self.parent_level != self.level - 1:
1135:  raise ValueError("DecennialPeriod parent_level must equal level - 1 for subordinate periods")
1136:  if len(self.ancestor_planets) != max(0, self.level - 1):
1137:  raise ValueError("DecennialPeriod ancestor_planets must preserve one ancestor per prior level")
1138:  if self.level >= 2 and self.ancestor_planets[0] != self.major_planet:
1139:  raise ValueError("DecennialPeriod ancestor_planets must begin with major_planet")
1140:  if self.level >= 2 and self.ancestor_planets[-1] != self.parent_planet:
1141:  raise ValueError("DecennialPeriod ancestor_planets must end with parent_planet")
1142:  if self.level <= 2 and self.deep_subdivision_method is not None:
1143:  raise ValueError("DecennialPeriod deep_subdivision_method applies only to levels 3 and 4")
1144:  if self.level == 4 and self.deep_subdivision_method != "valens":
1145:  raise ValueError("DecennialPeriod level-4 periods are admitted only for deep_subdivision_method='valens'")
1146:  if self.sequence:
1147:  if self.major_index >= len(self.sequence):
1148:  raise ValueError("DecennialPeriod major_index must lie inside preserved sequence")
1149:  if self.level == 1 and self.sequence[self.major_index] != self.planet:
1150:  raise ValueError("DecennialPeriod major planet must match preserved sequence at major_index")
1151:  if self.level >= 2:
1152:  assert self.sub_index is not None
1153:  assert self.parent_planet is not None
1154:  try:
1155:  anchor_index = self.sequence.index(self.parent_planet)
1156:  except ValueError as exc:
1157:  raise ValueError("DecennialPeriod parent_planet must exist inside preserved sequence") from exc
1158:  rotated = self.sequence[anchor_index:] + self.sequence[:anchor_index]
1159:  if self.sub_index >= len(rotated):
1160:  raise ValueError("DecennialPeriod sub_index must lie inside rotated sequence")
1161:  if rotated[self.sub_index] != self.planet:
1162:  raise ValueError("DecennialPeriod sub planet must match rotated sequence at sub_index")
1163:  if self.major_planet != self.sequence[self.major_index]:
1164:  raise ValueError("DecennialPeriod level-2 major_planet must match preserved major sequence planet")
1165:  @property
...

1217:  def end_dt(self) -> datetime:
1218:  return datetime_from_jd(self.end_jd)
1219:  @property
1220:  def end_calendar(self) -> CalendarDateTime:
1221:  return calendar_datetime_from_jd(self.end_jd)
1222:  @property
1223:  def days(self) -> float:
1224:  return self.end_jd - self.start_jd
1225:  @dataclass(slots=True)
1226:  class DecennialPeriodGroup:
1227:  """Recursive relation vessel for one non-major Decennials period and its children."""
1228:  period: DecennialPeriod
1229:  sub_groups: list["DecennialPeriodGroup"]
1230:  def __post_init__(self) -> None:
1231:  if self.period.level < 2:
1232:  raise ValueError(
1233:  f"DecennialPeriodGroup.period must be level 2 or deeper, got level {self.period.level}"
1234:  )
1235:  for sub_group in self.sub_groups:
1236:  if sub_group.period.level != self.period.level + 1:
1237:  raise ValueError(
1238:  "DecennialPeriodGroup.sub_groups must be exactly one level deeper than their parent period"
1239:  )
1240:  if sub_group.period.start_jd < self.period.start_jd - 1e-9:
1241:  raise ValueError("DecennialPeriodGroup child starts before parent period")
1242:  if sub_group.period.end_jd > self.period.end_jd + 1e-9:
1243:  raise ValueError("DecennialPeriodGroup child ends after parent period")
1244:  for index in range(len(self.sub_groups) - 1):
1245:  if self.sub_groups[index].period.start_jd >= self.sub_groups[index + 1].period.start_jd:
1246:  raise ValueError("DecennialPeriodGroup.sub_groups must be in chronological order")
1247:  @property
...

1259:  result.extend(sub_group.all_periods_flat())
1260:  return result
1261:  def active_sub_at(self, jd: float) -> "DecennialPeriodGroup | None":
1262:  for sub_group in self.sub_groups:
1263:  if sub_group.period.is_active_at(jd):
1264:  return sub_group
1265:  return None
1266:  @dataclass(slots=True)
1267:  class DecennialMajorGroup:
1268:  """Relational vessel binding one Decennials major period to its sub-periods."""
1269:  major: DecennialPeriod
1270:  subs: list[DecennialPeriod]
1271:  sub_groups: list[DecennialPeriodGroup] = field(default_factory=list)
1272:  def __post_init__(self) -> None:
1273:  if self.major.level != 1:
1274:  raise ValueError(
1275:  f"DecennialMajorGroup.major must be a level-1 period, got level {self.major.level}"
1276:  )
1277:  for sub in self.subs:
1278:  if sub.level != 2:
1279:  raise ValueError(
1280:  f"DecennialMajorGroup.subs must contain only level-2 periods, got level {sub.level}"
1281:  )
1282:  if sub.major_planet != self.major.planet:
1283:  raise ValueError(
1284:  f"DecennialMajorGroup.sub-period '{sub.planet}' must preserve major_planet '{self.major.planet}'"
1285:  )
1286:  if sub.major_index != self.major.major_index:
1287:  raise ValueError(
1288:  f"DecennialMajorGroup.sub-period '{sub.planet}' must preserve major_index {self.major.major_index}"
1289:  )
1290:  for i in range(len(self.subs) - 1):
1291:  if self.subs[i].start_jd >= self.subs[i + 1].start_jd:
1292:  raise ValueError("DecennialMajorGroup.subs must be in chronological order")
1293:  if not self.sub_groups and self.subs:
1294:  self.sub_groups = [DecennialPeriodGroup(period=sub, sub_groups=[]) for sub in self.subs]
1295:  if len(self.sub_groups) != len(self.subs):
1296:  raise ValueError("DecennialMajorGroup.sub_groups must provide one recursive group per immediate sub-period")
1297:  for sub_group, sub_period in zip(self.sub_groups, self.subs):
1298:  if sub_group.period is not sub_period:
1299:  raise ValueError("DecennialMajorGroup.sub_groups must align to subs in chronological order")
1300:  @property
...

1322:  return result
1323:  def active_sub_at(self, jd: float) -> DecennialPeriod | None:
1324:  for sub in self.subs:
1325:  if sub.is_active_at(jd):
1326:  return sub
1327:  return None
1328:  def active_sub_group_at(self, jd: float) -> DecennialPeriodGroup | None:
1329:  for sub_group in self.sub_groups:
1330:  if sub_group.period.is_active_at(jd):
1331:  return sub_group
1332:  return None
1333:  def _normalize_lon(lon: float) -> float:
1334:  return lon % 360.0
1335:  def _validate_decennial_positions(natal_positions: dict[str, float]) -> None:
1336:  if not isinstance(natal_positions, dict):
1337:  raise TypeError("natal_positions must be a dict of classical planet longitudes")
1338:  missing = [planet for planet in _DECENNIAL_PLANETS if planet not in natal_positions]
1339:  if missing:
1340:  raise ValueError(f"decennials: natal_positions missing required planets: {missing}")
1341:  for planet in _DECENNIAL_PLANETS:
1342:  lon = natal_positions[planet]
1343:  if not math.isfinite(lon):
1344:  raise ValueError(f"decennials: natal_positions[{planet!r}] must be finite")
1345:  def _decennial_sequence(natal_positions: dict[str, float], is_day_chart: bool) -> list[str]:
1346:  sect_light = "Sun" if is_day_chart else "Moon"
1347:  start_lon = _normalize_lon(natal_positions[sect_light])
1348:  base_order = {planet: index for index, planet in enumerate(_DECENNIAL_PLANETS)}
1349:  return sorted(
1350:  _DECENNIAL_PLANETS,
1351:  key=lambda planet: (
1352:  (_normalize_lon(natal_positions[planet]) - start_lon) % 360.0,
1353:  0 if planet == sect_light else 1,
1354:  base_order[planet],
1355:  ),
1356:  )
1357:  >   def _decennial_supported_max_level(policy: DecennialPolicy) -> int:
1358:  ^^^^^^^^^^^^^^^
1359:  E   NameError: name 'DecennialPolicy' is not defined
1360:  moira/timelords.py:1201: NameError
1361:  ---------------------------- Captured stderr setup -----------------------------
1362:  [moira] WARNING: no planetary kernel is installed.
1363:  Most Moira features will fail until you install a compatible JPL SPK kernel.
1364:  Run the following command to download one:
1365:  moira-download-kernels
1366:  Or, inside Python:
1367:  from moira.download_kernels import download_missing
1368:  download_missing()
1369:  ______________ ERROR at setup of test_decan_at_uses_tt_obliquity _______________
1370:  @pytest.fixture(scope="session", autouse=True)
1371:  def _bootstrap_kernel_singleton() -> None:
1372:  """Configure the global SpkReader singleton once per test session.
1373:  When a planetary kernel is available, calling set_kernel_path() here ensures
1374:  that module-level functions (phase.apparent_magnitude, etc.) which call
1375:  get_reader() directly can reuse the same initialized singleton without each
1376:  test needing to go through the Moira facade.
1377:  No-ops when no kernel is installed; requires_ephemeris tests skip naturally.
1378:  """
1379:  >       from moira._kernel_paths import find_planetary_kernel
1380:  tests/conftest.py:372: 
1381:  _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ 
1382:  moira/__init__.py:30: in <module>
1383:  from .facade import Chart, MissingEphemerisKernelError, Moira, __author__, __version__
1384:  moira/facade.py:540: in <module>
...

1712:  """
1713:  level:        int           # 1 = major period, 2 = sub-period
1714:  planet:       str
1715:  start_jd:     float
1716:  end_jd:       float
1717:  years:        float
1718:  # Phase 1: preserved generative context
1719:  major_planet: str | None = None   # for level=2: the level-1 lord this sub-period belongs to
1720:  is_day_chart: bool | None = None  # diurnal (True) or nocturnal (False) chart sect
1721:  variant:      str | None = None   # "standard" or "bonatti" sequence variant
1722:  # Phase 2: typed classification
1723:  sequence_kind:  str | None = None  # FirdarSequenceKind constant
1724:  is_node_period: bool = False        # True when planet is North Node or South Node
1725:  def __post_init__(self) -> None:
1726:  if self.level not in (1, 2):
1727:  raise ValueError(f"FirdarPeriod.level must be 1 or 2, got {self.level}")
1728:  if not math.isfinite(self.start_jd) or not math.isfinite(self.end_jd):
1729:  raise ValueError("FirdarPeriod start_jd and end_jd must be finite")
1730:  if self.end_jd <= self.start_jd:
1731:  raise ValueError("FirdarPeriod end_jd must be greater than start_jd")
1732:  if self.years <= 0:
1733:  raise ValueError("FirdarPeriod years must be positive")
1734:  # --- Phase 3: inspectability ---
...

1799:  Responsibilities:
1800:  - Carry one level-1 major period and its level-2 sub-periods.
1801:  - Enforce level correctness and chronological ordering.
1802:  - Provide an inspectable relational surface for Firdaria groups.
1803:  Non-responsibilities:
1804:  - Computing major or sub-period boundaries.
1805:  - Resolving active periods from a query JD.
1806:  Dependencies:
1807:  - Populated by `group_firdaria()`.
1808:  - Consumes `FirdarPeriod` vessels produced by `firdaria()`.
1809:  Structural invariants:
1810:  - `major.level == 1`
1811:  - every member of `subs` has `level == 2`
1812:  - `subs` are in chronological order
1813:  Failure behavior:
1814:  - Raises `ValueError` when level or ordering invariants are broken.
1815:  Canon: Demetra George, "Ancient Astrology in Theory and Practice" Vol.II
...

1823:  "internal": ["__post_init__"]
1824:  },
1825:  "state": {"mutable": true, "owners": ["group_firdaria"]},
1826:  "effects": {"signals_emitted": [], "io": []},
1827:  "concurrency": {"thread": "pure_computation", "cross_thread_calls": "safe_read_only"},
1828:  "failures": {"policy": "raise"},
1829:  "succession": {"stance": "terminal"},
1830:  "agent": {"autofix": "allowed", "requires_human_for": ["api_change"]}
1831:  }
1832:  [/MACHINE_CONTRACT]
1833:  """
1834:  major: FirdarPeriod
1835:  subs:  list[FirdarPeriod]
1836:  def __post_init__(self) -> None:
1837:  if self.major.level != 1:
1838:  raise ValueError(
1839:  f"FirdarMajorGroup.major must be a level-1 period, got level {self.major.level}"
1840:  )
1841:  for sub in self.subs:
1842:  if sub.level != 2:
1843:  raise ValueError(
1844:  f"FirdarMajorGroup.subs must contain only level-2 periods, got level {sub.level}"
1845:  )
1846:  # Phase 6 hardening — chronological ordering
1847:  for i in range(len(self.subs) - 1):
1848:  if self.subs[i].start_jd >= self.subs[i + 1].start_jd:
1849:  raise ValueError(
1850:  "FirdarMajorGroup.subs must be in chronological order"
...

1909:  # major lord in a Firdaria sequence, so major_planet is a unique key.
1910:  # JD-range filtering is intentionally omitted — floating-point
1911:  # accumulation can push the last sub-period's end_jd fractionally
1912:  # past the major's end_jd, causing a false exclusion.
1913:  subs = [s for s in sub_periods if s.major_planet == major.planet]
1914:  groups.append(FirdarMajorGroup(major=major, subs=subs))
1915:  return groups
1916:  # ---------------------------------------------------------------------------
1917:  # Firdaria calculation
1918:  # ---------------------------------------------------------------------------
1919:  def _resolve_firdaria_sequence(
1920:  is_day_chart: bool,
1921:  variant: str,
1922:  ) -> list[tuple[str, int]]:
1923:  if variant not in {"standard", "bonatti"}:
1924:  raise ValueError("firdaria variant must be 'standard' or 'bonatti'")
1925:  if is_day_chart:
...

1954:  Firdaria sequence variant: ``"standard"`` (default) or ``"bonatti"``.
1955:  Only affects nocturnal charts; diurnal charts always use FIRDARIA_DIURNAL.
1956:  include_node_subperiods : bool
1957:  When True, North Node and South Node major periods are also subdivided
1958:  into 7 sub-periods. Default False (nodes produce no sub-periods).
1959:  policy : TimelordComputationPolicy | None
1960:  Computation policy governing the Julian year constant. Uses
1961:  DEFAULT_TIMELORD_POLICY when None.
1962:  Returns
1963:  -------
1964:  list[FirdarPeriod]
1965:  All major periods, each immediately followed by their 7 sub-periods,
1966:  in chronological order.
1967:  Raises
1968:  ------
1969:  ValueError
1970:  If natal_jd is not finite.
1971:  If variant is not ``"standard"`` or ``"bonatti"``.
1972:  """
1973:  if not math.isfinite(natal_jd):
1974:  raise ValueError(f"firdaria: natal_jd must be finite, got {natal_jd!r}")
1975:  pol = _resolve_timelord_policy(policy)
...

2034:  Find the Firdaria major and sub-period active at a given date.
2035:  Parameters
2036:  ----------
2037:  natal_jd : float
2038:  Julian Day (UT) of birth.
2039:  current_jd : float
2040:  Julian Day (UT) of the date to evaluate.
2041:  is_day_chart : bool
2042:  True for a diurnal chart; False for a nocturnal chart.
2043:  Returns
2044:  -------
2045:  tuple[FirdarPeriod, FirdarPeriod]
2046:  (major_period, sub_period) active at current_jd.
2047:  Raises
2048:  ------
2049:  ValueError
2050:  If current_jd falls outside the 75-year Firdaria cycle.
2051:  """
2052:  if not math.isfinite(natal_jd):
2053:  raise ValueError(f"current_firdaria: natal_jd must be finite, got {natal_jd!r}")
2054:  if not math.isfinite(current_jd):
2055:  raise ValueError(f"current_firdaria: current_jd must be finite, got {current_jd!r}")
2056:  all_periods = firdaria(
2057:  natal_jd,
2058:  is_day_chart,
2059:  variant=variant,
2060:  include_node_subperiods=include_node_subperiods,
2061:  policy=policy,
2062:  )
2063:  major_periods = [p for p in all_periods if p.level == 1]
2064:  sub_periods   = [p for p in all_periods if p.level == 2]
2065:  active_major: FirdarPeriod | None = None
2066:  for p in major_periods:
2067:  if p.start_jd <= current_jd < p.end_jd:
2068:  active_major = p
2069:  break
2070:  if active_major is None:
2071:  raise ValueError(
2072:  f"current_jd {current_jd} falls outside the 75-year Firdaria cycle "
2073:  f"starting at natal_jd {natal_jd}."
2074:  )
2075:  active_sub: FirdarPeriod | None = None
2076:  for p in sub_periods:
2077:  if p.start_jd <= current_jd < p.end_jd:
2078:  active_sub = p
2079:  break
2080:  if active_sub is None:
2081:  if not _should_subdivide_firdaria_major(active_major.planet, include_node_subperiods):
2082:  return active_major, active_major
2083:  for p in sub_periods:
2084:  if p.start_jd == active_major.start_jd:
2085:  active_sub = p
2086:  break
2087:  if active_sub is None:
2088:  raise ValueError("Could not determine active Firdaria sub-period.")
2089:  return active_major, active_sub
...

2120:  major_planet: str | None = None
2121:  parent_planet: str | None = None
2122:  parent_level: int | None = None
2123:  is_day_chart: bool | None = None
2124:  sect_light: str | None = None
2125:  sequence_kind: str | None = None
2126:  deep_subdivision_method: str | None = None
2127:  sequence: tuple[str, ...] = field(default_factory=tuple)
2128:  ancestor_planets: tuple[str, ...] = field(default_factory=tuple)
2129:  major_index: int = 0
2130:  sub_index: int | None = None
2131:  major_month_total: float = float(_DECENNIAL_MAJOR_MONTHS)
2132:  month_basis_days: float = _DECENNIAL_MONTH_DAYS
2133:  def __post_init__(self) -> None:
2134:  if self.level not in (1, 2, 3, 4):
2135:  raise ValueError(f"DecennialPeriod.level must be 1, 2, 3, or 4, got {self.level}")
2136:  if self.planet not in _DECENNIAL_PLANETS:
2137:  raise ValueError(f"DecennialPeriod.planet must be a classical planet, got {self.planet!r}")
2138:  if not math.isfinite(self.start_jd) or not math.isfinite(self.end_jd):
2139:  raise ValueError("DecennialPeriod start_jd and end_jd must be finite")
2140:  if self.end_jd <= self.start_jd:
2141:  raise ValueError("DecennialPeriod end_jd must be greater than start_jd")
2142:  if self.years <= 0:
2143:  raise ValueError("DecennialPeriod years must be positive")
2144:  if self.months <= 0:
2145:  raise ValueError("DecennialPeriod months must be positive")
2146:  if self.sect_light is not None and self.sect_light not in {"Sun", "Moon"}:
2147:  raise ValueError("DecennialPeriod sect_light must be Sun, Moon, or None")
2148:  if self.sequence_kind is not None and self.sequence_kind not in {
2149:  DecennialSequenceKind.DIURNAL_SOLAR,
2150:  DecennialSequenceKind.NOCTURNAL_LUNAR,
2151:  }:
2152:  raise ValueError("DecennialPeriod sequence_kind must be a supported DecennialSequenceKind")
2153:  if self.parent_planet is not None and self.parent_planet not in _DECENNIAL_PLANETS:
2154:  raise ValueError("DecennialPeriod parent_planet must be a classical planet or None")
2155:  if self.parent_level is not None and self.parent_level not in (1, 2, 3):
2156:  raise ValueError("DecennialPeriod parent_level must be 1, 2, 3, or None")
2157:  if self.deep_subdivision_method is not None and self.deep_subdivision_method not in _DECENNIAL_DEEP_METHODS:
2158:  raise ValueError("DecennialPeriod deep_subdivision_method must be 'valens', 'hephaistio', or None")
2159:  if self.sequence and set(self.sequence) != set(_DECENNIAL_PLANETS):
2160:  raise ValueError("DecennialPeriod sequence must contain the seven classical planets exactly once")
2161:  if self.major_index < 0:
2162:  raise ValueError("DecennialPeriod major_index must be non-negative")
2163:  if self.sub_index is not None and self.sub_index < 0:
2164:  raise ValueError("DecennialPeriod sub_index must be non-negative when set")
2165:  if self.major_month_total <= 0:
2166:  raise ValueError("DecennialPeriod major_month_total must be positive")
2167:  if self.month_basis_days <= 0:
2168:  raise ValueError("DecennialPeriod month_basis_days must be positive")
2169:  if self.level == 1 and self.major_planet is not None:
2170:  raise ValueError("DecennialPeriod level-1 periods must not set major_planet")
2171:  if self.level == 1 and self.parent_planet is not None:
2172:  raise ValueError("DecennialPeriod level-1 periods must not set parent_planet")
2173:  if self.level == 1 and self.parent_level is not None:
2174:  raise ValueError("DecennialPeriod level-1 periods must not set parent_level")
2175:  if self.level == 1 and self.sub_index is not None:
2176:  raise ValueError("DecennialPeriod level-1 periods must not set sub_index")
2177:  if self.level == 1 and self.ancestor_planets:
2178:  raise ValueError("DecennialPeriod level-1 periods must not set ancestor_planets")
2179:  if self.level >= 2 and not self.major_planet:
2180:  if self.level == 2:
2181:  raise ValueError("DecennialPeriod level-2 periods must preserve major_planet")
2182:  raise ValueError("DecennialPeriod subordinate periods must preserve major_planet")
2183:  if self.level >= 2 and self.sub_index is None:
2184:  if self.level == 2:
2185:  raise ValueError("DecennialPeriod level-2 periods must preserve sub_index")
2186:  raise ValueError("DecennialPeriod subordinate periods must preserve sub_index")
2187:  if self.level >= 2 and not self.parent_planet:
2188:  raise ValueError("DecennialPeriod subordinate periods must preserve parent_planet")
2189:  if self.level >= 2 and self.parent_level != self.level - 1:
2190:  raise ValueError("DecennialPeriod parent_level must equal level - 1 for subordinate periods")
2191:  if len(self.ancestor_planets) != max(0, self.level - 1):
2192:  raise ValueError("DecennialPeriod ancestor_planets must preserve one ancestor per prior level")
2193:  if self.level >= 2 and self.ancestor_planets[0] != self.major_planet:
2194:  raise ValueError("DecennialPeriod ancestor_planets must begin with major_planet")
2195:  if self.level >= 2 and self.ancestor_planets[-1] != self.parent_planet:
2196:  raise ValueError("DecennialPeriod ancestor_planets must end with parent_planet")
2197:  if self.level <= 2 and self.deep_subdivision_method is not None:
2198:  raise ValueError("DecennialPeriod deep_subdivision_method applies only to levels 3 and 4")
2199:  if self.level == 4 and self.deep_subdivision_method != "valens":
2200:  raise ValueError("DecennialPeriod level-4 periods are admitted only for deep_subdivision_method='valens'")
2201:  if self.sequence:
2202:  if self.major_index >= len(self.sequence):
2203:  raise ValueError("DecennialPeriod major_index must lie inside preserved sequence")
2204:  if self.level == 1 and self.sequence[self.major_index] != self.planet:
2205:  raise ValueError("DecennialPeriod major planet must match preserved sequence at major_index")
2206:  if self.level >= 2:
2207:  assert self.sub_index is not None
2208:  assert self.parent_planet is not None
2209:  try:
2210:  anchor_index = self.sequence.index(self.parent_planet)
2211:  except ValueError as exc:
2212:  raise ValueError("DecennialPeriod parent_planet must exist inside preserved sequence") from exc
2213:  rotated = self.sequence[anchor_index:] + self.sequence[:anchor_index]
2214:  if self.sub_index >= len(rotated):
2215:  raise ValueError("DecennialPeriod sub_index must lie inside rotated sequence")
2216:  if rotated[self.sub_index] != self.planet:
2217:  raise ValueError("DecennialPeriod sub planet must match rotated sequence at sub_index")
2218:  if self.major_planet != self.sequence[self.major_index]:
2219:  raise ValueError("DecennialPeriod level-2 major_planet must match preserved major sequence planet")
2220:  @property
...

2272:  def end_dt(self) -> datetime:
2273:  return datetime_from_jd(self.end_jd)
2274:  @property
2275:  def end_calendar(self) -> CalendarDateTime:
2276:  return calendar_datetime_from_jd(self.end_jd)
2277:  @property
2278:  def days(self) -> float:
2279:  return self.end_jd - self.start_jd
2280:  @dataclass(slots=True)
2281:  class DecennialPeriodGroup:
2282:  """Recursive relation vessel for one non-major Decennials period and its children."""
2283:  period: DecennialPeriod
2284:  sub_groups: list["DecennialPeriodGroup"]
2285:  def __post_init__(self) -> None:
2286:  if self.period.level < 2:
2287:  raise ValueError(
2288:  f"DecennialPeriodGroup.period must be level 2 or deeper, got level {self.period.level}"
2289:  )
2290:  for sub_group in self.sub_groups:
2291:  if sub_group.period.level != self.period.level + 1:
2292:  raise ValueError(
2293:  "DecennialPeriodGroup.sub_groups must be exactly one level deeper than their parent period"
2294:  )
2295:  if sub_group.period.start_jd < self.period.start_jd - 1e-9:
2296:  raise ValueError("DecennialPeriodGroup child starts before parent period")
2297:  if sub_group.period.end_jd > self.period.end_jd + 1e-9:
2298:  raise ValueError("DecennialPeriodGroup child ends after parent period")
2299:  for index in range(len(self.sub_groups) - 1):
2300:  if self.sub_groups[index].period.start_jd >= self.sub_groups[index + 1].period.start_jd:
2301:  raise ValueError("DecennialPeriodGroup.sub_groups must be in chronological order")
2302:  @property
...

2314:  result.extend(sub_group.all_periods_flat())
2315:  return result
2316:  def active_sub_at(self, jd: float) -> "DecennialPeriodGroup | None":
2317:  for sub_group in self.sub_groups:
2318:  if sub_group.period.is_active_at(jd):
2319:  return sub_group
2320:  return None
2321:  @dataclass(slots=True)
2322:  class DecennialMajorGroup:
2323:  """Relational vessel binding one Decennials major period to its sub-periods."""
2324:  major: DecennialPeriod
2325:  subs: list[DecennialPeriod]
2326:  sub_groups: list[DecennialPeriodGroup] = field(default_factory=list)
2327:  def __post_init__(self) -> None:
2328:  if self.major.level != 1:
2329:  raise ValueError(
2330:  f"DecennialMajorGroup.major must be a level-1 period, got level {self.major.level}"
2331:  )
2332:  for sub in self.subs:
2333:  if sub.level != 2:
2334:  raise ValueError(
2335:  f"DecennialMajorGroup.subs must contain only level-2 periods, got level {sub.level}"
2336:  )
2337:  if sub.major_planet != self.major.planet:
2338:  raise ValueError(
2339:  f"DecennialMajorGroup.sub-period '{sub.planet}' must preserve major_planet '{self.major.planet}'"
2340:  )
2341:  if sub.major_index != self.major.major_index:
2342:  raise ValueError(
2343:  f"DecennialMajorGroup.sub-period '{sub.planet}' must preserve major_index {self.major.major_index}"
2344:  )
2345:  for i in range(len(self.subs) - 1):
2346:  if self.subs[i].start_jd >= self.subs[i + 1].start_jd:
2347:  raise ValueError("DecennialMajorGroup.subs must be in chronological order")
2348:  if not self.sub_groups and self.subs:
2349:  self.sub_groups = [DecennialPeriodGroup(period=sub, sub_groups=[]) for sub in self.subs]
2350:  if len(self.sub_groups) != len(self.subs):
2351:  raise ValueError("DecennialMajorGroup.sub_groups must provide one recursive group per immediate sub-period")
2352:  for sub_group, sub_period in zip(self.sub_groups, self.subs):
2353:  if sub_group.period is not sub_period:
2354:  raise ValueError("DecennialMajorGroup.sub_groups must align to subs in chronological order")
2355:  @property
...

2377:  return result
2378:  def active_sub_at(self, jd: float) -> DecennialPeriod | None:
2379:  for sub in self.subs:
2380:  if sub.is_active_at(jd):
2381:  return sub
2382:  return None
2383:  def active_sub_group_at(self, jd: float) -> DecennialPeriodGroup | None:
2384:  for sub_group in self.sub_groups:
2385:  if sub_group.period.is_active_at(jd):
2386:  return sub_group
2387:  return None
2388:  def _normalize_lon(lon: float) -> float:
2389:  return lon % 360.0
2390:  def _validate_decennial_positions(natal_positions: dict[str, float]) -> None:
2391:  if not isinstance(natal_positions, dict):
2392:  raise TypeError("natal_positions must be a dict of classical planet longitudes")
2393:  missing = [planet for planet in _DECENNIAL_PLANETS if planet not in natal_positions]
2394:  if missing:
2395:  raise ValueError(f"decennials: natal_positions missing required planets: {missing}")
2396:  for planet in _DECENNIAL_PLANETS:
2397:  lon = natal_positions[planet]
2398:  if not math.isfinite(lon):
2399:  raise ValueError(f"decennials: natal_positions[{planet!r}] must be finite")
2400:  def _decennial_sequence(natal_positions: dict[str, float], is_day_chart: bool) -> list[str]:
2401:  sect_light = "Sun" if is_day_chart else "Moon"
2402:  start_lon = _normalize_lon(natal_positions[sect_light])
2403:  base_order = {planet: index for index, planet in enumerate(_DECENNIAL_PLANETS)}
2404:  return sorted(
2405:  _DECENNIAL_PLANETS,
2406:  key=lambda planet: (
2407:  (_normalize_lon(natal_positions[planet]) - start_lon) % 360.0,
2408:  0 if planet == sect_light else 1,
2409:  base_order[planet],
2410:  ),
2411:  )
2412:  >   def _decennial_supported_max_level(policy: DecennialPolicy) -> int:
2413:  ^^^^^^^^^^^^^^^
2414:  E   NameError: name 'DecennialPolicy' is not defined
2415:  moira/timelords.py:1201: NameError
2416:  _ ERROR at setup of test_decan_at_matches_house_engine_ascendant_for_representative_cases _
2417:  @pytest.fixture(scope="session", autouse=True)
2418:  def _bootstrap_kernel_singleton() -> None:
2419:  """Configure the global SpkReader singleton once per test session.
2420:  When a planetary kernel is available, calling set_kernel_path() here ensures
2421:  that module-level functions (phase.apparent_magnitude, etc.) which call
2422:  get_reader() directly can reuse the same initialized singleton without each
2423:  test needing to go through the Moira facade.
2424:  No-ops when no kernel is installed; requires_ephemeris tests skip naturally.
2425:  """
2426:  >       from moira._kernel_paths import find_planetary_kernel
2427:  tests/conftest.py:372: 
2428:  _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ 
2429:  moira/__init__.py:30: in <module>
2430:  from .facade import Chart, MissingEphemerisKernelError, Moira, __author__, __version__
2431:  moira/facade.py:540: in <module>
...

2759:  """
2760:  level:        int           # 1 = major period, 2 = sub-period
2761:  planet:       str
2762:  start_jd:     float
2763:  end_jd:       float
2764:  years:        float
2765:  # Phase 1: preserved generative context
2766:  major_planet: str | None = None   # for level=2: the level-1 lord this sub-period belongs to
2767:  is_day_chart: bool | None = None  # diurnal (True) or nocturnal (False) chart sect
2768:  variant:      str | None = None   # "standard" or "bonatti" sequence variant
2769:  # Phase 2: typed classification
2770:  sequence_kind:  str | None = None  # FirdarSequenceKind constant
2771:  is_node_period: bool = False        # True when planet is North Node or South Node
2772:  def __post_init__(self) -> None:
2773:  if self.level not in (1, 2):
2774:  raise ValueError(f"FirdarPeriod.level must be 1 or 2, got {self.level}")
2775:  if not math.isfinite(self.start_jd) or not math.isfinite(self.end_jd):
2776:  raise ValueError("FirdarPeriod start_jd and end_jd must be finite")
2777:  if self.end_jd <= self.start_jd:
2778:  raise ValueError("FirdarPeriod end_jd must be greater than start_jd")
2779:  if self.years <= 0:
2780:  raise ValueError("FirdarPeriod years must be positive")
2781:  # --- Phase 3: inspectability ---
...

2846:  Responsibilities:
2847:  - Carry one level-1 major period and its level-2 sub-periods.
2848:  - Enforce level correctness and chronological ordering.
2849:  - Provide an inspectable relational surface for Firdaria groups.
2850:  Non-responsibilities:
2851:  - Computing major or sub-period boundaries.
2852:  - Resolving active periods from a query JD.
2853:  Dependencies:
2854:  - Populated by `group_firdaria()`.
2855:  - Consumes `FirdarPeriod` vessels produced by `firdaria()`.
2856:  Structural invariants:
2857:  - `major.level == 1`
2858:  - every member of `subs` has `level == 2`
2859:  - `subs` are in chronological order
2860:  Failure behavior:
2861:  - Raises `ValueError` when level or ordering invariants are broken.
2862:  Canon: Demetra George, "Ancient Astrology in Theory and Practice" Vol.II
...

2870:  "internal": ["__post_init__"]
2871:  },
2872:  "state": {"mutable": true, "owners": ["group_firdaria"]},
2873:  "effects": {"signals_emitted": [], "io": []},
2874:  "concurrency": {"thread": "pure_computation", "cross_thread_calls": "safe_read_only"},
2875:  "failures": {"policy": "raise"},
2876:  "succession": {"stance": "terminal"},
2877:  "agent": {"autofix": "allowed", "requires_human_for": ["api_change"]}
2878:  }
2879:  [/MACHINE_CONTRACT]
2880:  """
2881:  major: FirdarPeriod
2882:  subs:  list[FirdarPeriod]
2883:  def __post_init__(self) -> None:
2884:  if self.major.level != 1:
2885:  raise ValueError(
2886:  f"FirdarMajorGroup.major must be a level-1 period, got level {self.major.level}"
2887:  )
2888:  for sub in self.subs:
2889:  if sub.level != 2:
2890:  raise ValueError(
2891:  f"FirdarMajorGroup.subs must contain only level-2 periods, got level {sub.level}"
2892:  )
2893:  # Phase 6 hardening — chronological ordering
2894:  for i in range(len(self.subs) - 1):
2895:  if self.subs[i].start_jd >= self.subs[i + 1].start_jd:
2896:  raise ValueError(
2897:  "FirdarMajorGroup.subs must be in chronological order"
...

2956:  # major lord in a Firdaria sequence, so major_planet is a unique key.
2957:  # JD-range filtering is intentionally omitted — floating-point
2958:  # accumulation can push the last sub-period's end_jd fractionally
2959:  # past the major's end_jd, causing a false exclusion.
2960:  subs = [s for s in sub_periods if s.major_planet == major.planet]
2961:  groups.append(FirdarMajorGroup(major=major, subs=subs))
2962:  return groups
2963:  # ---------------------------------------------------------------------------
2964:  # Firdaria calculation
2965:  # ---------------------------------------------------------------------------
2966:  def _resolve_firdaria_sequence(
2967:  is_day_chart: bool,
2968:  variant: str,
2969:  ) -> list[tuple[str, int]]:
2970:  if variant not in {"standard", "bonatti"}:
2971:  raise ValueError("firdaria variant must be 'standard' or 'bonatti'")
2972:  if is_day_chart:
...

3001:  Firdaria sequence variant: ``"standard"`` (default) or ``"bonatti"``.
3002:  Only affects nocturnal charts; diurnal charts always use FIRDARIA_DIURNAL.
3003:  include_node_subperiods : bool
3004:  When True, North Node and South Node major periods are also subdivided
3005:  into 7 sub-periods. Default False (nodes produce no sub-periods).
3006:  policy : TimelordComputationPolicy | None
3007:  Computation policy governing the Julian year constant. Uses
3008:  DEFAULT_TIMELORD_POLICY when None.
3009:  Returns
3010:  -------
3011:  list[FirdarPeriod]
3012:  All major periods, each immediately followed by their 7 sub-periods,
3013:  in chronological order.
3014:  Raises
3015:  ------
3016:  ValueError
3017:  If natal_jd is not finite.
3018:  If variant is not ``"standard"`` or ``"bonatti"``.
3019:  """
3020:  if not math.isfinite(natal_jd):
3021:  raise ValueError(f"firdaria: natal_jd must be finite, got {natal_jd!r}")
3022:  pol = _resolve_timelord_policy(policy)
...

3081:  Find the Firdaria major and sub-period active at a given date.
3082:  Parameters
3083:  ----------
3084:  natal_jd : float
3085:  Julian Day (UT) of birth.
3086:  current_jd : float
3087:  Julian Day (UT) of the date to evaluate.
3088:  is_day_chart : bool
3089:  True for a diurnal chart; False for a nocturnal chart.
3090:  Returns
3091:  -------
3092:  tuple[FirdarPeriod, FirdarPeriod]
3093:  (major_period, sub_period) active at current_jd.
3094:  Raises
3095:  ------
3096:  ValueError
3097:  If current_jd falls outside the 75-year Firdaria cycle.
3098:  """
3099:  if not math.isfinite(natal_jd):
3100:  raise ValueError(f"current_firdaria: natal_jd must be finite, got {natal_jd!r}")
3101:  if not math.isfinite(current_jd):
3102:  raise ValueError(f"current_firdaria: current_jd must be finite, got {current_jd!r}")
3103:  all_periods = firdaria(
3104:  natal_jd,
3105:  is_day_chart,
3106:  variant=variant,
3107:  include_node_subperiods=include_node_subperiods,
3108:  policy=policy,
3109:  )
3110:  major_periods = [p for p in all_periods if p.level == 1]
3111:  sub_periods   = [p for p in all_periods if p.level == 2]
3112:  active_major: FirdarPeriod | None = None
3113:  for p in major_periods:
3114:  if p.start_jd <= current_jd < p.end_jd:
3115:  active_major = p
3116:  break
3117:  if active_major is None:
3118:  raise ValueError(
3119:  f"current_jd {current_jd} falls outside the 75-year Firdaria cycle "
3120:  f"starting at natal_jd {natal_jd}."
3121:  )
3122:  active_sub: FirdarPeriod | None = None
3123:  for p in sub_periods:
3124:  if p.start_jd <= current_jd < p.end_jd:
3125:  active_sub = p
3126:  break
3127:  if active_sub is None:
3128:  if not _should_subdivide_firdaria_major(active_major.planet, include_node_subperiods):
3129:  return active_major, active_major
3130:  for p in sub_periods:
3131:  if p.start_jd == active_major.start_jd:
3132:  active_sub = p
3133:  break
3134:  if active_sub is None:
3135:  raise ValueError("Could not determine active Firdaria sub-period.")
3136:  return active_major, active_sub
...

3167:  major_planet: str | None = None
3168:  parent_planet: str | None = None
3169:  parent_level: int | None = None
3170:  is_day_chart: bool | None = None
3171:  sect_light: str | None = None
3172:  sequence_kind: str | None = None
3173:  deep_subdivision_method: str | None = None
3174:  sequence: tuple[str, ...] = field(default_factory=tuple)
3175:  ancestor_planets: tuple[str, ...] = field(default_factory=tuple)
3176:  major_index: int = 0
3177:  sub_index: int | None = None
3178:  major_month_total: float = float(_DECENNIAL_MAJOR_MONTHS)
3179:  month_basis_days: float = _DECENNIAL_MONTH_DAYS
3180:  def __post_init__(self) -> None:
3181:  if self.level not in (1, 2, 3, 4):
3182:  raise ValueError(f"DecennialPeriod.level must be 1, 2, 3, or 4, got {self.level}")
3183:  if self.planet not in _DECENNIAL_PLANETS:
3184:  raise ValueError(f"DecennialPeriod.planet must be a classical planet, got {self.planet!r}")
3185:  if not math.isfinite(self.start_jd) or not math.isfinite(self.end_jd):
3186:  raise ValueError("DecennialPeriod start_jd and end_jd must be finite")
3187:  if self.end_jd <= self.start_jd:
3188:  raise ValueError("DecennialPeriod end_jd must be greater than start_jd")
3189:  if self.years <= 0:
3190:  raise ValueError("DecennialPeriod years must be positive")
3191:  if self.months <= 0:
3192:  raise ValueError("DecennialPeriod months must be positive")
3193:  if self.sect_light is not None and self.sect_light not in {"Sun", "Moon"}:
3194:  raise ValueError("DecennialPeriod sect_light must be Sun, Moon, or None")
3195:  if self.sequence_kind is not None and self.sequence_kind not in {
3196:  DecennialSequenceKind.DIURNAL_SOLAR,
3197:  DecennialSequenceKind.NOCTURNAL_LUNAR,
3198:  }:
3199:  raise ValueError("DecennialPeriod sequence_kind must be a supported DecennialSequenceKind")
3200:  if self.parent_planet is not None and self.parent_planet not in _DECENNIAL_PLANETS:
3201:  raise ValueError("DecennialPeriod parent_planet must be a classical planet or None")
3202:  if self.parent_level is not None and self.parent_level not in (1, 2, 3):
3203:  raise ValueError("DecennialPeriod parent_level must be 1, 2, 3, or None")
3204:  if self.deep_subdivision_method is not None and self.deep_subdivision_method not in _DECENNIAL_DEEP_METHODS:
3205:  raise ValueError("DecennialPeriod deep_subdivision_method must be 'valens', 'hephaistio', or None")
3206:  if self.sequence and set(self.sequence) != set(_DECENNIAL_PLANETS):
3207:  raise ValueError("DecennialPeriod sequence must contain the seven classical planets exactly once")
3208:  if self.major_index < 0:
3209:  raise ValueError("DecennialPeriod major_index must be non-negative")
3210:  if self.sub_index is not None and self.sub_index < 0:
3211:  raise ValueError("DecennialPeriod sub_index must be non-negative when set")
3212:  if self.major_month_total <= 0:
3213:  raise ValueError("DecennialPeriod major_month_total must be positive")
3214:  if self.month_basis_days <= 0:
3215:  raise ValueError("DecennialPeriod month_basis_days must be positive")
3216:  if self.level == 1 and self.major_planet is not None:
3217:  raise ValueError("DecennialPeriod level-1 periods must not set major_planet")
3218:  if self.level == 1 and self.parent_planet is not None:
3219:  raise ValueError("DecennialPeriod level-1 periods must not set parent_planet")
3220:  if self.level == 1 and self.parent_level is not None:
3221:  raise ValueError("DecennialPeriod level-1 periods must not set parent_level")
3222:  if self.level == 1 and self.sub_index is not None:
3223:  raise ValueError("DecennialPeriod level-1 periods must not set sub_index")
3224:  if self.level == 1 and self.ancestor_planets:
3225:  raise ValueError("DecennialPeriod level-1 periods must not set ancestor_planets")
3226:  if self.level >= 2 and not self.major_planet:
3227:  if self.level == 2:
3228:  raise ValueError("DecennialPeriod level-2 periods must preserve major_planet")
3229:  raise ValueError("DecennialPeriod subordinate periods must preserve major_planet")
3230:  if self.level >= 2 and self.sub_index is None:
3231:  if self.level == 2:
3232:  raise ValueError("DecennialPeriod level-2 periods must preserve sub_index")
3233:  raise ValueError("DecennialPeriod subordinate periods must preserve sub_index")
3234:  if self.level >= 2 and not self.parent_planet:
3235:  raise ValueError("DecennialPeriod subordinate periods must preserve parent_planet")
3236:  if self.level >= 2 and self.parent_level != self.level - 1:
3237:  raise ValueError("DecennialPeriod parent_level must equal level - 1 for subordinate periods")
3238:  if len(self.ancestor_planets) != max(0, self.level - 1):
3239:  raise ValueError("DecennialPeriod ancestor_planets must preserve one ancestor per prior level")
3240:  if self.level >= 2 and self.ancestor_planets[0] != self.major_planet:
3241:  raise ValueError("DecennialPeriod ancestor_planets must begin with major_planet")
3242:  if self.level >= 2 and self.ancestor_planets[-1] != self.parent_planet:
3243:  raise ValueError("DecennialPeriod ancestor_planets must end with parent_planet")
3244:  if self.level <= 2 and self.deep_subdivision_method is not None:
3245:  raise ValueError("DecennialPeriod deep_subdivision_method applies only to levels 3 and 4")
3246:  if self.level == 4 and self.deep_subdivision_method != "valens":
3247:  raise ValueError("DecennialPeriod level-4 periods are admitted only for deep_subdivision_method='valens'")
3248:  if self.sequence:
3249:  if self.major_index >= len(self.sequence):
3250:  raise ValueError("DecennialPeriod major_index must lie inside preserved sequence")
3251:  if self.level == 1 and self.sequence[self.major_index] != self.planet:
3252:  raise ValueError("DecennialPeriod major planet must match preserved sequence at major_index")
3253:  if self.level >= 2:
3254:  assert self.sub_index is not None
3255:  assert self.parent_planet is not None
3256:  try:
3257:  anchor_index = self.sequence.index(self.parent_planet)
3258:  except ValueError as exc:
3259:  raise ValueError("DecennialPeriod parent_planet must exist inside preserved sequence") from exc
3260:  rotated = self.sequence[anchor_index:] + self.sequence[:anchor_index]
3261:  if self.sub_index >= len(rotated):
3262:  raise ValueError("DecennialPeriod sub_index must lie inside rotated sequence")
3263:  if rotated[self.sub_index] != self.planet:
3264:  raise ValueError("DecennialPeriod sub planet must match rotated sequence at sub_index")
3265:  if self.major_planet != self.sequence[self.major_index]:
3266:  raise ValueError("DecennialPeriod level-2 major_planet must match preserved major sequence planet")
3267:  @property
...

3319:  def end_dt(self) -> datetime:
3320:  return datetime_from_jd(self.end_jd)
3321:  @property
3322:  def end_calendar(self) -> CalendarDateTime:
3323:  return calendar_datetime_from_jd(self.end_jd)
3324:  @property
3325:  def days(self) -> float:
3326:  return self.end_jd - self.start_jd
3327:  @dataclass(slots=True)
3328:  class DecennialPeriodGroup:
3329:  """Recursive relation vessel for one non-major Decennials period and its children."""
3330:  period: DecennialPeriod
3331:  sub_groups: list["DecennialPeriodGroup"]
3332:  def __post_init__(self) -> None:
3333:  if self.period.level < 2:
3334:  raise ValueError(
3335:  f"DecennialPeriodGroup.period must be level 2 or deeper, got level {self.period.level}"
3336:  )
3337:  for sub_group in self.sub_groups:
3338:  if sub_group.period.level != self.period.level + 1:
3339:  raise ValueError(
3340:  "DecennialPeriodGroup.sub_groups must be exactly one level deeper than their parent period"
3341:  )
3342:  if sub_group.period.start_jd < self.period.start_jd - 1e-9:
3343:  raise ValueError("DecennialPeriodGroup child starts before parent period")
3344:  if sub_group.period.end_jd > self.period.end_jd + 1e-9:
3345:  raise ValueError("DecennialPeriodGroup child ends after parent period")
3346:  for index in range(len(self.sub_groups) - 1):
3347:  if self.sub_groups[index].period.start_jd >= self.sub_groups[index + 1].period.start_jd:
3348:  raise ValueError("DecennialPeriodGroup.sub_groups must be in chronological order")
3349:  @property
...

3361:  result.extend(sub_group.all_periods_flat())
3362:  return result
3363:  def active_sub_at(self, jd: float) -> "DecennialPeriodGroup | None":
3364:  for sub_group in self.sub_groups:
3365:  if sub_group.period.is_active_at(jd):
3366:  return sub_group
3367:  return None
3368:  @dataclass(slots=True)
3369:  class DecennialMajorGroup:
3370:  """Relational vessel binding one Decennials major period to its sub-periods."""
3371:  major: DecennialPeriod
3372:  subs: list[DecennialPeriod]
3373:  sub_groups: list[DecennialPeriodGroup] = field(default_factory=list)
3374:  def __post_init__(self) -> None:
3375:  if self.major.level != 1:
3376:  raise ValueError(
3377:  f"DecennialMajorGroup.major must be a level-1 period, got level {self.major.level}"
3378:  )
3379:  for sub in self.subs:
3380:  if sub.level != 2:
3381:  raise ValueError(
3382:  f"DecennialMajorGroup.subs must contain only level-2 periods, got level {sub.level}"
3383:  )
3384:  if sub.major_planet != self.major.planet:
3385:  raise ValueError(
3386:  f"DecennialMajorGroup.sub-period '{sub.planet}' must preserve major_planet '{self.major.planet}'"
3387:  )
3388:  if sub.major_index != self.major.major_index:
3389:  raise ValueError(
3390:  f"DecennialMajorGroup.sub-period '{sub.planet}' must preserve major_index {self.major.major_index}"
3391:  )
3392:  for i in range(len(self.subs) - 1):
3393:  if self.subs[i].start_jd >= self.subs[i + 1].start_jd:
3394:  raise ValueError("DecennialMajorGroup.subs must be in chronological order")
3395:  if not self.sub_groups and self.subs:
3396:  self.sub_groups = [DecennialPeriodGroup(period=sub, sub_groups=[]) for sub in self.subs]
3397:  if len(self.sub_groups) != len(self.subs):
3398:  raise ValueError("DecennialMajorGroup.sub_groups must provide one recursive group per immediate sub-period")
3399:  for sub_group, sub_period in zip(self.sub_groups, self.subs):
3400:  if sub_group.period is not sub_period:
3401:  raise ValueError("DecennialMajorGroup.sub_groups must align to subs in chronological order")
3402:  @property
...

3424:  return result
3425:  def active_sub_at(self, jd: float) -> DecennialPeriod | None:
3426:  for sub in self.subs:
3427:  if sub.is_active_at(jd):
3428:  return sub
3429:  return None
3430:  def active_sub_group_at(self, jd: float) -> DecennialPeriodGroup | None:
3431:  for sub_group in self.sub_groups:
3432:  if sub_group.period.is_active_at(jd):
3433:  return sub_group
3434:  return None
3435:  def _normalize_lon(lon: float) -> float:
3436:  return lon % 360.0
3437:  def _validate_decennial_positions(natal_positions: dict[str, float]) -> None:
3438:  if not isinstance(natal_positions, dict):
3439:  raise TypeError("natal_positions must be a dict of classical planet longitudes")
3440:  missing = [planet for planet in _DECENNIAL_PLANETS if planet not in natal_positions]
3441:  if missing:
3442:  raise ValueError(f"decennials: natal_positions missing required planets: {missing}")
3443:  for planet in _DECENNIAL_PLANETS:
3444:  lon = natal_positions[planet]
3445:  if not math.isfinite(lon):
3446:  raise ValueError(f"decennials: natal_positions[{planet!r}] must be finite")
3447:  def _decennial_sequence(natal_positions: dict[str, float], is_day_chart: bool) -> list[str]:
3448:  sect_light = "Sun" if is_day_chart else "Moon"
3449:  start_lon = _normalize_lon(natal_positions[sect_light])
3450:  base_order = {planet: index for index, planet in enumerate(_DECENNIAL_PLANETS)}
3451:  return sorted(
3452:  _DECENNIAL_PLANETS,
3453:  key=lambda planet: (
3454:  (_normalize_lon(natal_positions[planet]) - start_lon) % 360.0,
3455:  0 if planet == sect_light else 1,
3456:  base_order[planet],
3457:  ),
3458:  )
3459:  >   def _decennial_supported_max_level(policy: DecennialPolicy) -> int:
3460:  ^^^^^^^^^^^^^^^
3461:  E   NameError: name 'DecennialPolicy' is not defined
3462:  moira/timelords.py:1201: NameError
3463:  _ ERROR at setup of test_decan_hours_handles_next_sunrise_refinement_day_slip __
3464:  @pytest.fixture(scope="session", autouse=True)
3465:  def _bootstrap_kernel_singleton() -> None:
3466:  """Configure the global SpkReader singleton once per test session.
3467:  When a planetary kernel is available, calling set_kernel_path() here ensures
3468:  that module-level functions (phase.apparent_magnitude, etc.) which call
3469:  get_reader() directly can reuse the same initialized singleton without each
3470:  test needing to go through the Moira facade.
3471:  No-ops when no kernel is installed; requires_ephemeris tests skip naturally.
3472:  """
3473:  >       from moira._kernel_paths import find_planetary_kernel
3474:  tests/conftest.py:372: 
3475:  _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ 
3476:  moira/__init__.py:30: in <module>
3477:  from .facade import Chart, MissingEphemerisKernelError, Moira, __author__, __version__
3478:  moira/facade.py:540: in <module>
...

3806:  """
3807:  level:        int           # 1 = major period, 2 = sub-period
3808:  planet:       str
3809:  start_jd:     float
3810:  end_jd:       float
3811:  years:        float
3812:  # Phase 1: preserved generative context
3813:  major_planet: str | None = None   # f...

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant