From a57420a8183569390e675c5baf4e12a3b3cbb279 Mon Sep 17 00:00:00 2001 From: timlichtenberg Date: Fri, 11 Sep 2026 21:08:00 +0200 Subject: [PATCH 1/4] Add an unmocked test for crystallization restore on resume A resume must restore the crystallization flag from the whole stored history, not just the resumed row. Without that, a mantle that crystallized and later remelted comes back molten on resume and degasses trapped volatiles on the first resumed step. This adds an integration test that builds a remelting history on disk, an early solid row followed by a molten resume row, then resumes on all-dummy backends with volatile freezing on and escape drawing from the bulk reservoir. It checks that the flag comes back set, that the resumed leg stays molten so only the restore can set it, that escape runs over the resumed leg, and that the atmospheric hydrogen inventory does not grow across the resume seam. The test fails when the restore reads only the resumed row and passes when it reads the whole history. --- tests/integration/test_integration_resume.py | 148 +++++++++++++++++++ 1 file changed, 148 insertions(+) diff --git a/tests/integration/test_integration_resume.py b/tests/integration/test_integration_resume.py index a3abdca78..a1b9265cd 100644 --- a/tests/integration/test_integration_resume.py +++ b/tests/integration/test_integration_resume.py @@ -813,3 +813,151 @@ def test_resume_drops_retired_helpfile_column(tmp_path): f'rows appended after resume hold NaN in {nan_columns}; the retired column ' 'rode along into the concat instead of being dropped first' ) + + +# Leg 1 stays molten so the resume row is hot; leg 2 cools but stops short of +# the solidus, so only the on-resume restore can set the flag. +CRYST_LEG1_STOP_TIME = 3.0e3 +CRYST_LEG2_STOP_TIME = 1.2e4 + +# Bulk escape rate for the scenario [kg s-1]. Nonzero so the escape branch +# runs and the frozen path (atmosphere-only escape) is exercised, small +# enough that its per-step mass loss is negligible against the atmosphere. +CRYST_ESCAPE_RATE = 1.0e6 + +# Largest fractional growth of the atmospheric hydrogen inventory the frozen +# run may show after resume. A frozen mantle does not degas, so escape only +# removes from the atmosphere and it does not grow; a misread-as-molten run +# degasses the melt and grows it several-fold, so this bound separates them. +CRYST_ATM_GROWTH_TOL = 0.5 + + +def _make_freeze_runner(output_dir, stop_time): + """Build a runner that freezes volatiles once the mantle is solid. + + Extends :func:`_make_runner` with the three settings the + crystallization-on-resume path needs: volatile freezing enabled, the + escape reservoir moved off its ``'outgas'`` default to ``'bulk'``, and a + nonzero dummy escape rate so the escape branch actually runs. The bug the + resume restore guards against only bites when volatile freezing is on and + the reservoir is off its default, so both are set here. + + Parameters + ---------- + output_dir : pathlib.Path + Absolute run directory, shared by both legs. + stop_time : float + Maximum simulation time for this leg [yr]. + + Returns + ------- + Proteus + Configured runner; the caller starts it. + """ + runner = _make_runner(output_dir, stop_time) + runner.config.params.stop.solid.freeze_volatiles = True + runner.config.escape.reservoir = 'bulk' + runner.config.escape.dummy.rate = CRYST_ESCAPE_RATE + return runner + + +@pytest.mark.integration +def test_resume_restores_crystallization_across_remelting(tmp_path): + """A resume restores the crystallization flag from the whole history. + + Physical scenario: a magma-ocean planet crystallizes early, then remelts, + so its stored history holds a solid row followed by molten ones with the + final row molten. With volatile freezing enabled, a mantle that once + crystallized stays frozen for the rest of the run, so the restored state + on resume must reflect the whole history and not only the resumed row. + + Reading only the resumed row clears the flag in exactly this case: the + resume row is molten, so the flag comes back False, the volatile branch + treats the mantle as molten again, and the melt degasses into the + atmosphere. The restore reads the whole loaded history instead, keeps the + flag set, and the frozen mantle stops feeding the atmosphere. + + Verifies, on unmocked dummy backends with no patching in the call chain: + - The stored history is a genuine remelting record: an early row is below + the crystallization fraction while the resume row is above it. + - The crystallization flag comes back set after the resume. + - The downstream consequence holds: the atmospheric hydrogen inventory + does not grow across the resume seam, because the frozen mantle no + longer degasses. A resume that cleared the flag would run outgassing + and grow it several-fold, which this bound rejects. + - Escape is active over the resumed leg, so the frozen escape path + (atmosphere-only, reservoir forced off ``'bulk'``) is exercised rather + than skipped. + + Scope and boundary. The dummy interior splits melt and solid by an + algebraic rule and writes no interior snapshot, so this test covers the + control-flow and reservoir-bookkeeping consequence of restoring the flag, + not the magnitude of a physically faithful melt/solid partition. The + remelting history is constructed on disk because the dummy cools + monotonically and cannot remelt itself, the same on-disk-construction + approach as :func:`test_resume_drops_retired_helpfile_column`. Coverage + of the partition magnitude belongs with an interior module that resolves + melt/solid volatile exchange and is not attempted here. + """ + outdir = tmp_path / 'crystallization_resume' + leg1 = _make_freeze_runner(outdir, CRYST_LEG1_STOP_TIME) + phi_crit = float(leg1.config.params.stop.solid.phi_crit) + leg1.start(resume=False, offline=True) + n_leg1 = len(leg1.hf_all) + assert n_leg1 > leg1.loops['init_loops'] + 1, ( + f'first leg produced only {n_leg1} rows, too short to resume from' + ) + + helpfile = outdir / 'runtime_helpfile.csv' + assert helpfile.is_file(), 'first leg left no helpfile on disk' + stored = pd.read_csv(helpfile, sep=r'\s+') + # Inject the solid marker on the first post-init evolution row (Time>0), + # not an init row (Time=0.0), so it stands for real early crystallization. + evolution_rows = stored.index[stored['Time'] > 0.0] + assert len(evolution_rows) > 1 and int(evolution_rows[0]) < len(stored) - 1, ( + f'stored history has too few post-init rows ({len(evolution_rows)}) to ' + 'inject an early solid row and keep a later molten resume row' + ) + inject_row = int(evolution_rows[0]) + # The dummy cools too little over the short first leg to crystallize on + # its own, so force one early row solid to build the remelting history. + stored.loc[inject_row, 'Phi_global'] = phi_crit / 2.0 + injected_phi = float(stored['Phi_global'].iloc[inject_row]) + resume_phi = float(stored['Phi_global'].iloc[-1]) + assert injected_phi <= phi_crit < resume_phi, ( + f'injection did not build a remelting history: injected Phi {injected_phi} ' + f'and resume Phi {resume_phi} do not straddle phi_crit {phi_crit}' + ) + stored.to_csv(helpfile, index=False, sep='\t', float_format='%.10e') + + leg2 = _make_freeze_runner(outdir, CRYST_LEG2_STOP_TIME) + leg2.start(resume=True, offline=True) + + appended = leg2.hf_all.iloc[n_leg1:] + assert len(appended) > 0, 'resume appended no rows to advance from' + # The resumed leg cools but must stay above the solidus, so nothing during + # leg 2 latches the flag; only the on-resume restore can set it. + assert (appended['Phi_global'] > phi_crit).all(), ( + 'the resumed leg crystallized on its own, so a set flag no longer ' + 'isolates the on-resume restore' + ) + + assert leg2.crystallized, ( + 'resume cleared the crystallization flag; the restore read only the ' + 'resumed row instead of the whole history' + ) + + assert (appended['esc_step_kg'] > 0).any(), ( + 'escape never ran over the resumed leg, so the frozen escape path was ' + 'not exercised' + ) + + # The frozen mantle no longer degasses, so the atmospheric hydrogen + # inventory holds at its resume-seam value. Measure growth from the last + # pre-resume row so a first-step degas is caught, not divided away. + atm_baseline = float(leg2.hf_all['H_kg_atm'].iloc[n_leg1 - 1]) + atm_growth = float(appended['H_kg_atm'].max()) / atm_baseline - 1.0 + assert atm_growth <= CRYST_ATM_GROWTH_TOL, ( + f'atmospheric hydrogen grew by {atm_growth:.2f} after resume; the frozen ' + 'mantle should not degas, so the crystallization flag was not restored' + ) From 2f8c3320a3b613c0bfd5c57e8455b9ba7cf7e140 Mon Sep 17 00:00:00 2001 From: timlichtenberg Date: Fri, 11 Sep 2026 21:56:15 +0200 Subject: [PATCH 2/4] Apply ruff formatting to the resume crystallization test --- tests/integration/test_integration_resume.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/tests/integration/test_integration_resume.py b/tests/integration/test_integration_resume.py index a1b9265cd..41b05b7f9 100644 --- a/tests/integration/test_integration_resume.py +++ b/tests/integration/test_integration_resume.py @@ -948,8 +948,7 @@ def test_resume_restores_crystallization_across_remelting(tmp_path): ) assert (appended['esc_step_kg'] > 0).any(), ( - 'escape never ran over the resumed leg, so the frozen escape path was ' - 'not exercised' + 'escape never ran over the resumed leg, so the frozen escape path was not exercised' ) # The frozen mantle no longer degasses, so the atmospheric hydrogen From a393f60cc908280e178655e96c6ab34cc553538c Mon Sep 17 00:00:00 2001 From: timlichtenberg Date: Tue, 15 Sep 2026 10:11:49 +0200 Subject: [PATCH 3/4] Strengthen atmosphere checks in crystallization-resume test The frozen-mantle atmosphere check now asserts that the atmospheric hydrogen inventory does not rise across the resume seam, with a float-noise band, and adds a zero-baseline precondition and a collapse floor. This replaces a single loose growth tolerance that only separated a fully broken restore from a correct one and let a partial degassing leak pass. The freeze-runner helper no longer sets escape.reservoir to 'bulk'. A frozen mantle forces atmosphere-only escape whatever the reservoir setting is, so the override was not needed to reach the escape path under test; the helper docstring now states this. Mark the test physics_invariant, since it asserts a conservation bound on the atmospheric hydrogen inventory. --- tests/integration/test_integration_resume.py | 71 +++++++++++++------- 1 file changed, 47 insertions(+), 24 deletions(-) diff --git a/tests/integration/test_integration_resume.py b/tests/integration/test_integration_resume.py index 41b05b7f9..98a103759 100644 --- a/tests/integration/test_integration_resume.py +++ b/tests/integration/test_integration_resume.py @@ -825,22 +825,31 @@ def test_resume_drops_retired_helpfile_column(tmp_path): # enough that its per-step mass loss is negligible against the atmosphere. CRYST_ESCAPE_RATE = 1.0e6 -# Largest fractional growth of the atmospheric hydrogen inventory the frozen -# run may show after resume. A frozen mantle does not degas, so escape only -# removes from the atmosphere and it does not grow; a misread-as-molten run -# degasses the melt and grows it several-fold, so this bound separates them. -CRYST_ATM_GROWTH_TOL = 0.5 +# Float-noise band for the across-seam non-increase check on the atmospheric +# hydrogen inventory. A frozen mantle does not degas, so the inventory only +# falls by escape; on a correct restore the largest across-seam step is a fall +# of about 9e-9 relative, so this rejects any degassing rise while absorbing +# round-off. +CRYST_ATM_NOISE_RTOL = 1.0e-6 + +# Largest fraction of the resume-seam atmosphere the frozen run may lose. +# Escape removes only a negligible fraction here (about 8e-6 of the baseline), +# so a loss past this bound means the atmosphere collapsed rather than that +# escape ran. This is the lower-bound guard; a cleared flag instead grows the +# inventory and trips the non-increase check above. +CRYST_ATM_LOSS_BOUND = 1.0e-2 def _make_freeze_runner(output_dir, stop_time): """Build a runner that freezes volatiles once the mantle is solid. - Extends :func:`_make_runner` with the three settings the - crystallization-on-resume path needs: volatile freezing enabled, the - escape reservoir moved off its ``'outgas'`` default to ``'bulk'``, and a - nonzero dummy escape rate so the escape branch actually runs. The bug the - resume restore guards against only bites when volatile freezing is on and - the reservoir is off its default, so both are set here. + Extends :func:`_make_runner` with the two settings the + crystallization-on-resume scenario needs: volatile freezing enabled, so a + mantle that once crystallized stays frozen for the rest of the run, and a + nonzero dummy escape rate, so the escape branch runs and the frozen escape + path is exercised. The escape reservoir stays at its ``'outgas'`` default; + a frozen mantle forces atmosphere-only escape whatever the reservoir + setting is, so no override is needed to reach that path. Parameters ---------- @@ -856,12 +865,12 @@ def _make_freeze_runner(output_dir, stop_time): """ runner = _make_runner(output_dir, stop_time) runner.config.params.stop.solid.freeze_volatiles = True - runner.config.escape.reservoir = 'bulk' runner.config.escape.dummy.rate = CRYST_ESCAPE_RATE return runner @pytest.mark.integration +@pytest.mark.physics_invariant def test_resume_restores_crystallization_across_remelting(tmp_path): """A resume restores the crystallization flag from the whole history. @@ -882,12 +891,13 @@ def test_resume_restores_crystallization_across_remelting(tmp_path): the crystallization fraction while the resume row is above it. - The crystallization flag comes back set after the resume. - The downstream consequence holds: the atmospheric hydrogen inventory - does not grow across the resume seam, because the frozen mantle no - longer degasses. A resume that cleared the flag would run outgassing - and grow it several-fold, which this bound rejects. + changes by escape only across the resume seam, so it does not grow and + loses at most a negligible fraction. A resume that cleared the flag + would run outgassing and grow it several-fold, which the non-increase + check rejects; the loss bound rejects a collapse to near zero. - Escape is active over the resumed leg, so the frozen escape path - (atmosphere-only, reservoir forced off ``'bulk'``) is exercised rather - than skipped. + (atmosphere-only escape, forced by the frozen mantle) is exercised + rather than skipped. Scope and boundary. The dummy interior splits melt and solid by an algebraic rule and writes no interior snapshot, so this test covers the @@ -951,12 +961,25 @@ def test_resume_restores_crystallization_across_remelting(tmp_path): 'escape never ran over the resumed leg, so the frozen escape path was not exercised' ) - # The frozen mantle no longer degasses, so the atmospheric hydrogen - # inventory holds at its resume-seam value. Measure growth from the last - # pre-resume row so a first-step degas is caught, not divided away. + # The frozen mantle no longer degasses, so run_crystallized changes the + # atmospheric hydrogen inventory by escape only: across the resume seam it + # falls and never rises, and it loses only a negligible fraction. atm_baseline = float(leg2.hf_all['H_kg_atm'].iloc[n_leg1 - 1]) - atm_growth = float(appended['H_kg_atm'].max()) / atm_baseline - 1.0 - assert atm_growth <= CRYST_ATM_GROWTH_TOL, ( - f'atmospheric hydrogen grew by {atm_growth:.2f} after resume; the frozen ' - 'mantle should not degas, so the crystallization flag was not restored' + assert atm_baseline > 0.0, ( + 'resume-seam atmosphere holds no hydrogen, so the inventory check has ' + 'no baseline to measure against' + ) + # Include the last pre-resume row so the first post-resume step is measured + # against the seam value, not divided away. + seam_atm = leg2.hf_all['H_kg_atm'].iloc[n_leg1 - 1:].to_numpy() + atm_steps = np.diff(seam_atm) + assert (atm_steps <= atm_baseline * CRYST_ATM_NOISE_RTOL).all(), ( + f'atmospheric hydrogen rose by up to {float(atm_steps.max()):.3e} kg across ' + 'the resume seam; the frozen mantle degassed, so the crystallization flag ' + 'was not restored' + ) + assert float(seam_atm.min()) >= atm_baseline * (1.0 - CRYST_ATM_LOSS_BOUND), ( + f'atmospheric hydrogen fell to {float(seam_atm.min()):.3e} kg after resume, ' + f'below {1.0 - CRYST_ATM_LOSS_BOUND:g} of the {atm_baseline:.3e} kg baseline; ' + 'escape-only removal from a frozen mantle cannot lose this much' ) From e34238eede5bad95c9f01a6763604147ab720d2c Mon Sep 17 00:00:00 2001 From: timlichtenberg Date: Tue, 15 Sep 2026 10:14:19 +0200 Subject: [PATCH 4/4] Apply ruff format to crystallization-resume test slice --- tests/integration/test_integration_resume.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/integration/test_integration_resume.py b/tests/integration/test_integration_resume.py index 98a103759..de8929650 100644 --- a/tests/integration/test_integration_resume.py +++ b/tests/integration/test_integration_resume.py @@ -971,7 +971,7 @@ def test_resume_restores_crystallization_across_remelting(tmp_path): ) # Include the last pre-resume row so the first post-resume step is measured # against the seam value, not divided away. - seam_atm = leg2.hf_all['H_kg_atm'].iloc[n_leg1 - 1:].to_numpy() + seam_atm = leg2.hf_all['H_kg_atm'].iloc[n_leg1 - 1 :].to_numpy() atm_steps = np.diff(seam_atm) assert (atm_steps <= atm_baseline * CRYST_ATM_NOISE_RTOL).all(), ( f'atmospheric hydrogen rose by up to {float(atm_steps.max()):.3e} kg across '