Skip to content

Partition-law hook and strong-partition limit for phase-aware volatiles#67

Open
maraattia wants to merge 11 commits into
mainfrom
ma/volatile-partitioning
Open

Partition-law hook and strong-partition limit for phase-aware volatiles#67
maraattia wants to merge 11 commits into
mainfrom
ma/volatile-partitioning

Conversation

@maraattia

Copy link
Copy Markdown

Description

Closes #65.

Adds a configurable partition-law hook upstream of VolatileProfile and ships the strong-partition limit as the first physical rule wired through it. The hook maps a bulk interior mass fraction $X_i$ and a partition rule into the $(w_\mathrm{liquid}, w_\mathrm{solid})$ pair that VolatileProfile already consumes, so dissolved volatiles can live in the silicate melt instead of being smeared uniformly across the mantle. The new path is opt-in: a new config key partition_rule defaults to "uniform", which reproduces the existing single-EOS-string mantle behavior byte-for-byte, so every existing config keeps running unchanged. Setting partition_rule = "strong" activates the new path.

What landed, by commit:

  • e9c9938 — partition-law hook signature and the partition_rule config key, registered in config.py with default "uniform" and valid values ("uniform", "strong", "D_const", "solubility"). The "D_const" and "solubility" rules raise NotImplementedError; they are the documented extension points for the next issues.
  • f40799f — strong-partition physics wired end-to-end: apply_partition_rule returns numerical values ($w_\mathrm{solid} = 0$, $w_\mathrm{liquid} = X_i / \phi_\mathrm{avg}$), build_partition_profile constructs a VolatileProfile from a mantle LayerMixture, calculate_mixed_density and its batch variant gained an optional volatile_profile kwarg for per-shell phi-aware blending, and solver.solve_strong_partition is the outer self-consistency loop on $\phi_\mathrm{avg}$. Dispatch happens in output.post_processing.
  • 09bf448, c28c5d2 — the $\phi_\mathrm{avg}$ floor that keeps $w_\mathrm{liquid} \le 1$ is derived internally from $X_\mathrm{bulk}$ as $\mathrm{safety} \times \sum X_\mathrm{bulk}$ rather than passed as a kwarg; below the floor the rule falls back to uniform spread. The safety factor is a numerical margin ($1.01$), not a user-tunable knob.
  • df22c9cdocs/Explanations/mixing.md documents the hook, the strong-partition rule, and where future partition rules plug in.
  • 850501d — registers outer_solver and wall_timeout as [IterativeProcess] config keys and adds partition_rule, outer_solver, wall_timeout, relative_tolerance, and absolute_tolerance to the tools/grids/run_grid parameter map. The Newton outer solver already existed from refactor Interior refactor: JAX path, Newton outer, robustness hardening #59 but those keys were not TOML-settable; exposing them is what made the strong-partition validation deterministic.
  • bd24597 — removes the strong-partition validation grid from the repo; it is a validation artifact, not a shipped input.

Key touchpoints: src/zalmoxis/mixing.py (hook, VolatileProfile blend, build_partition_profile), src/zalmoxis/solver.py (solve_strong_partition), src/zalmoxis/output.py (dispatch), src/zalmoxis/config.py (partition_rule registration), src/zalmoxis/structure_model.py, and input/default.toml (the single-fraction EOS strings stay as the uniform path; the strong path is opt-in).

Out of scope, tracked separately on #64: the $\mathrm{H_2}$ melt-partition / binodal-miscibility handoff, physically motivated partition coefficients and solubility laws, and the PROTEUS-side wrapper changes.

Validation of changes

Test configuration: Linux (kernel 6.8), Python 3.12, conda proteus environment.

Automated tests (all green locally):

  • Unit tier (@pytest.mark.unit): tests/test_mixing.py, tests/test_config_validation.py, and tests/test_config_loaders.py cover strong-partition mass conservation against the mantle integral, the fully-molten and fully-solid limits, the ill-conditioned thin-melt-shell fallback, the partition_rule = "uniform" byte-for-byte no-regression against the current code path, and config validation accepting both rule names while rejecting unknown values.
  • Smoke tier: tests/test_solve_strong_partition.py::TestSolveStrongPartitionSmoke runs a single 1 $M_\oplus$ full-solver case at 10% $\mathrm{H_2O}$ on partition_rule = "strong" end-to-end and converges the outer $\phi_\mathrm{avg}$ loop.

Standalone grid validation (closed 2026-05-26, Newton-tier verdict): a 96-cell grid spanning 1–10 $M_\oplus$ × {0, 5, 10, 20}% $\mathrm{H_2O}$ × {2000, 3000, 4000} K was run with the Newton outer solver (relative_tolerance = 1e-9, wall_timeout = 1200). All 96 cells converged tightly, with mantle mass-conservation error at most $7.1 \times 10^{-5}$ (90th percentile $3.7 \times 10^{-5}$). All 12 dry-pair null cells ($X_{\mathrm{H_2O}} = 0$, where the strong path short-circuits to the uniform path) reproduce $R_\mathrm{strong} = R_\mathrm{uniform}$ to machine precision. In the active-subset cells where the strong rule cleared the $\phi$ floor, $|R_\mathrm{strong} - R_\mathrm{uniform}| \le 1.06 \times 10^{-6}$ $R_\oplus$, and the $X_{\mathrm{H_2O}}$ and $T_\mathrm{surf}$ monotonicity checks pass at the $10^{-4}$ $R_\oplus$ tolerance.

The grid was run with the earlier floor $\phi_\mathrm{floor} = 1.2 \sum X$. The subsequent reduction to the shipped $1.01 \sum X$ was checked directly: only the $X_{\mathrm{H_2O}} = 0.20$ column had cells in the affected band ($1.01 X \le \phi_\mathrm{avg} < 1.2 X$), and rerunning the two candidates ($M = 5$ $M_\oplus$ / $T_\mathrm{surf} = 3000$ K and $M = 10$ $M_\oplus$ / $T_\mathrm{surf} = 4000$ K) leaves both bit-identical between the uniform and strong paths, since their self-consistent strong melt fraction settles below the $1.01$ floor. The floor reduction flips no cell, so the active subset, the published $\Delta R$ table, and every conclusion above carry over unchanged.

Conclusion: the strong-partition implementation is verified correct, namely deterministic, mass-conserving, monotonic, and stable at low melt fraction. The partition law's signal does not live in the structural radius at the present EOS fidelity (the volume-additive density closure is approximately linear in volatile mass fraction); its impact is expected to surface in the chemistry coupling (surface speciation and melt-driven outgassing), not in $R(M)$. This motivates the coupled-evolution direction rather than indicating a problem with the path.

The figures come from a focused follow-up I ran to check that this small signal is intrinsic, not an artifact of where the validation grid happened to sample. I first worked out analytically where the strong-partition radius signal should be largest. At fixed total water mass, moving water into the melt changes the planet volume in proportion to the mantle-mass-weighted covariance between the melt fraction $\phi(r)$ and the water–silicate specific-volume contrast, multiplied by a suppression factor that excludes vapor-phase water from the structural mixing. A nonzero signal therefore requires three conditions to hold in the same shells at once: a genuine melt-fraction gradient (a partially molten mantle, neither fully molten nor fully solid), water that is condensed rather than vapor (denser than the suppression-gate threshold), and water that is genuinely lighter than silicate at the local pressure. That argument, referred to as the heuristic below, predicts the signal is maximized for a low-mass planet (lower mantle pressures keep condensed water lighter relative to silicate), a partial-melt surface temperature (a real melt gradient rather than a uniformly molten mantle), and the largest water fraction that still clears the activation floor. That corner of parameter space is what the optimal region refers to; the two figures below probe it directly, and both confirm the signal stays far below any practical threshold there.

mechanism

The two figures attached to this PR, from the optimal-region investigation, show why the radius signal is intrinsically small rather than merely under-sampled. The figure above traces the best active in-grid case (1 $M_\oplus$, $T_\mathrm{surf} = 2000$ K, $X_{\mathrm{H_2O}} = 0.20$). Across a single outer shell the water density jumps from vapor (about 0.1 kg m⁻³, one near-massless shell, below the 322 kg m⁻³ suppression gate) to condensed (about 470 kg m⁻³), then tracks within roughly 30% of silicate down to the core-mantle boundary where both exceed 12000 kg m⁻³, while the melt fraction falls from 1 at the surface to about 0.22 at depth. The mass-weighted signal contribution (bottom panel) is therefore confined to a thin shell at $P < 5$ GPa, where water has just condensed above the gate yet is still lighter than silicate, and it collapses to zero by about 10 GPa. There is essentially no mantle mass that is simultaneously molten, condensed, and volumetrically distinct from silicate, so concentrating water in the melt rearranges almost nothing the radius integral can see.

lever_sweep

The figure above confirms this is not a sampling accident. Sweeping the three levers the heuristic identifies (planet mass 0.3 vs 1.0 $M_\oplus$, surface temperature 2000 vs 2500 K, suppression gate 322 vs 50 kg m⁻³) at fixed $X_{\mathrm{H_2O}} = 0.20$ leaves every configuration at $|R_\mathrm{strong} - R_\mathrm{uniform}| \le 2.3 \times 10^{-7}$ $R_\oplus$, at or below the integrator-noise floor and at least three orders of magnitude under the $10^{-3}$ $R_\oplus$ modeling bar. The fully molten 0.3 $M_\oplus$ / 2500 K cells collapse to about $10^{-10}$ $R_\oplus$ because the strong rule reproduces uniform once $\phi(r) = 1$ everywhere, and relaxing the gate from 322 to 50 kg m⁻³ changes nothing, since the vapor-to-condensed transition is a near-discontinuity in this EOS and the density window the relaxed gate would rescue holds no mantle mass. The actionable consequence is that a measurable melt-localized-volatile radius signature needs a treatment that keeps dissolved water volumetrically distinct from silicate at mantle conditions (an explicit low-density hydrous-melt phase or a partial-molar-volume model), which is exactly what the deferred D_const and solubility rules are positioned to carry.

Checklist

  • I have followed the contributing guidelines
  • My code follows the style guidelines of this project
  • I have performed a self-review of my code
  • My changes generate no new warnings or errors
  • I have checked that the tests still pass on my computer
  • I have updated the docs, as appropriate
  • I have added tests for these changes, as appropriate
  • I have checked that all dependencies have been updated, as required

@timlichtenberg

maraattia and others added 8 commits May 22, 2026 15:26
Introduce the partition-law hook upstream of VolatileProfile so a
future strong-partition rule (w_liquid = X_i / phi_avg, w_solid = 0)
and the deferred D_const and solubility prescriptions share one
signature. This first commit ships the function shell, the config
key, the validator, and the tests; the physics for partition_rule
!= "uniform" lands in subsequent commits on this branch.

- mixing.apply_partition_rule(rule, X_bulk, phi_avg, *, pressure,
  temperature, melt_fraction, D_const, solubility_fn, phi_floor)
  returns (w_liquid, w_solid) compatible with VolatileProfile.
  "uniform" returns (X_bulk, X_bulk) so the blend collapses to a
  constant per-shell fraction; "strong", "D_const", "solubility"
  raise NotImplementedError until wired in subsequent commits.
- config.py registers "partition_rule" in [EOS] with default
  "uniform"; validator accepts ("uniform", "strong", "D_const",
  "solubility") and rejects unknown values.
- input/default.toml documents the four rules; the key itself stays
  commented out so existing TOMLs are byte-for-byte unchanged.
- 19 unit tests cover the dispatch, the dormant-path round-trip
  through VolatileProfile.blend, the loader default, and an
  explicit "strong" round-trip.

python -m zalmoxis -c input/default.toml produces planet_profile.txt
identical to the baseline; full unit tier (1124 passed, 1 skipped)
is green; ruff clean.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Replace the NotImplementedError stub on partition_rule = "strong" with
the strong-partition limit (w_liquid = X_i / phi_avg, w_solid = 0) and
plumb the resulting VolatileProfile through the per-shell density
evaluator so the rule actually affects the structure solve. Bulk X_i is
read from the existing mantle EOS string fractions, so no new TOML keys
are needed beyond the partition_rule itself.

- mixing.apply_partition_rule grows the strong-partition branch with a
  graceful uniform fallback below phi_floor for ill-conditioned thin
  partially-molten shells.
- mixing.split_mantle_volatile_inventory and mixing.build_partition_profile
  convert a parsed mantle LayerMixture into a VolatileProfile via the hook.
- mixing.calculate_mixed_density and calculate_mixed_density_batch take
  an optional volatile_profile; when provided, per-component fractions
  are phi-blended per shell via VolatileProfile.apply_to_mixture against
  compute_melt_fraction(P, T, solidus, liquidus). Default behavior is
  byte-for-byte unchanged.
- structure_model.solve_structure and coupled_odes thread the profile
  through, gated to the mantle layer. The JAX fast path forces a numpy
  fallback when a profile is set.
- solver.py forwards volatile_profile at both solve_structure call sites
  and the Picard density-update batch site. New solver.solve_strong_partition
  mirrors solve_miscible_interior: it builds the profile from the current
  phi_avg guess, runs main(), integrates a mass-weighted mantle phi_avg
  from the result, and iterates to convergence on |delta phi_avg| <
  phi_tolerance.
- output.post_processing dispatches to solve_strong_partition when
  partition_rule = "strong"; the default "uniform" path keeps calling
  main() directly.
- Unit tests: 15 in TestApplyPartitionRule (strong-partition algebra,
  fully-molten and below-floor limits, mass conservation through
  VolatileProfile.blend), 6 in TestSplitMantleVolatileInventory, 4 in
  TestBuildPartitionProfile, 5 in TestSolveStrongPartitionOuterLoop
  (mocked main(), covering convergence, non-convergence, missing mantle,
  and main() failure).
- Smoke (@pytest.mark.smoke) for a 1 M_earth wet-mantle run on the
  strong-partition path: outer loop converges in ~5 min with PALEOS data.
- New input/grids/strong_partition.toml for the standalone 1-10 M_earth
  validation sweep.

Verification:
- python -m zalmoxis -c input/default.toml: planet_profile.txt
  byte-for-byte identical to the baseline on the default "uniform"
  config (the new code path is dormant).
- Full unit tier: 1144 passed, 1 skipped (+20 net new tests).
- ruff check and ruff format --check clean across src/ and tests/.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
The previous floor (phi_floor=0.05) was a heuristic with no principled
bound: for X_total >= 0.05 the strong rule would push w_liquid above
the mass-fraction bound w_liquid <= 1 before the fallback engaged. For
the smoke at 1 M_earth + 10% H2O the rule happened to stay safe because
phi_avg lived near 1 throughout, but mostly-solid mantles (phi_avg
approaching X_total) would have produced unphysical w_liquid > 1.

Drop the phi_floor kwarg from apply_partition_rule, build_partition_profile,
and solve_strong_partition; compute the effective floor internally as
_STRONG_PARTITION_PHI_SAFETY * sum(X_bulk.values()). The constant is
1.2, which keeps the per-shell volatile fraction at most 1/1.2 ~ 0.83
in the most-molten shells. The floor scales with the bulk inventory, so
a heavier volatile load (X_total = 0.4) raises the fallback boundary to
phi_avg < 0.48 automatically.

Three new unit tests pin the X-derived floor: the pivot is at
safety * X_total, the floor scales with X_total (a phi_avg that
activates the strong rule for light loads falls back for heavy ones),
and an empty X_bulk yields a zero floor.

Verification:
- python -m zalmoxis -c input/default.toml: planet_profile.txt
  byte-for-byte identical to the prior commit (the new code path is
  reached only on partition_rule = "strong", which the default config
  does not exercise).
- Full unit tier: 1146 passed, 1 skipped (+2 net new tests vs the
  prior commit).
- ruff check and ruff format clean.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
The mass-fraction bound w_liquid <= 1 requires phi_avg >= sum(X_bulk).
The previous 1.2 safety factor added 20% headroom as if the bound were
physical; it is not. Reduce _STRONG_PARTITION_PHI_SAFETY to 1.01 so the
fallback floor sits just above the true bound, keeping only enough
headroom to avoid the w_liquid = 1 singularity. Update the floor-value
assertions and docstrings in test_mixing.py accordingly.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Add a "Phase-aware volatile partitioning" section to the mixing docs.
Frame partitioning phenomenologically: the apportionment of a volatile
between solid and molten silicate is a modeling choice (partition_rule)
expressed as a per-phase (w_liquid, w_solid) split that VolatileProfile
blends per shell. Two rules are implemented (uniform, strong) with their
constraints, plus D_const and solubility as reserved hooks. Note that
the uniform and strong prescriptions give almost identical radii at the
present EOS fidelity, so the rule matters for the chemistry coupling
rather than for structure. Closes the docs item on issue #65.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The Newton outer solver exists in the solver (refactor #59) but its
config keys were never readable from TOML. Register outer_solver and
wall_timeout as optional [IterativeProcess] keys, and add partition_rule,
outer_solver, wall_timeout, relative_tolerance, and absolute_tolerance to
the run_grid parameter map. This is what made the strong-partition
validation grid deterministic (the Picard run carried a ~0.16 R_earth
non-determinism noise floor that buried the signal).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The strong-partition validation grid is a one-off analysis artifact, not
a shipped input. Move it (with its Newton and optimal-region variants and
the Newton base config) into the project workspace under
prj_zalmoxis/grid_validation_strong_partition/grids/, where the full
analysis lives. The #65 PR references that analysis rather than carrying
the grid in the solver repo.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Resolve _PARAM_MAP conflict in run_grid.py by keeping both main's
target_surface_pressure entry and the branch's IterativeProcess keys.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@maraattia maraattia self-assigned this Jun 9, 2026
Copilot AI review requested due to automatic review settings June 9, 2026 16:21
@maraattia maraattia requested a review from a team as a code owner June 9, 2026 16:21
@maraattia maraattia added enhancement New feature or request import Interra labels Jun 9, 2026
@maraattia maraattia requested a review from timlichtenberg June 9, 2026 16:22

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Pull request overview

This PR adds an opt-in partition-law interface upstream of VolatileProfile so mantle volatiles can be distributed between melt and solid phases as a function of melt fraction, and wires in the “strong partition” limit as the first implemented physical rule. It keeps backward compatibility by defaulting partition_rule to "uniform" (preserving the existing uniform mantle-fraction behavior).

Changes:

  • Introduces partition_rule config plumbing ("uniform" default) and dispatch in output.post_processing to route "strong" to a dedicated outer solver loop.
  • Implements phi-aware mantle blending by threading an optional volatile_profile into density mixing and the structure solve; adds solve_strong_partition() to converge self-consistent mantle-averaged melt fraction.
  • Adds unit + smoke tests for the partition hook, strong-partition outer loop behavior, and config loading/validation; updates docs to explain the new interface and rule set.

Reviewed changes

Copilot reviewed 12 out of 12 changed files in this pull request and generated 3 comments.

Show a summary per file
File Description
tools/grids/run_grid.py Exposes new config keys in grid runner parameter mapping.
tests/test_solve_strong_partition.py Adds unit tests for the outer phi_avg loop and a PALEOS-gated smoke test.
tests/test_mixing.py Adds unit coverage for partition hook, mantle inventory splitting, and profile builder.
tests/test_config_validation.py Validates partition_rule accepted values and errors.
tests/test_config_loaders.py Ensures loader defaults partition_rule to "uniform" and round-trips "strong".
src/zalmoxis/structure_model.py Threads volatile_profile into ODE RHS and forces numpy fallback when JAX can’t support it.
src/zalmoxis/solver.py Adds strong-partition outer loop and uses volatile_profile in density iteration paths.
src/zalmoxis/output.py Dispatches solves based on partition_rule.
src/zalmoxis/mixing.py Adds partition hook + builder and applies profile-driven per-shell fractions in mixing.
src/zalmoxis/config.py Registers/loads partition_rule and additional iterative-process keys.
input/default.toml Documents the new partition_rule option (opt-in).
docs/Explanations/mixing.md Explains the partition-law hook, strong-partition rule, and extension points.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread src/zalmoxis/mixing.py Outdated
Comment thread src/zalmoxis/output.py
Comment thread src/zalmoxis/solver.py
maraattia and others added 3 commits June 9, 2026 18:28
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

enhancement New feature or request import Interra

Projects

Status: Next Up

Development

Successfully merging this pull request may close these issues.

Partition-law interface in VolatileProfile and strong-partition limit

3 participants