From db854c0f7cdf5137e377273499f0ac5b6053b752 Mon Sep 17 00:00:00 2001 From: Emily Boudreaux Date: Fri, 18 Sep 2026 12:13:53 -0400 Subject: [PATCH] Record audited damping and eligibility results through six Newton updates --- .../DAMPED_COUPLED_TRIAL_2026-09-18.md | 294 +++++++++++++- experiments/check_damped_coupled_newton.py | 382 ++++++++++++++++++ .../test_damped_coupled_newton_checker.py | 165 ++++++++ 3 files changed, 839 insertions(+), 2 deletions(-) create mode 100644 experiments/check_damped_coupled_newton.py create mode 100644 experiments/test_damped_coupled_newton_checker.py diff --git a/experiments/DAMPED_COUPLED_TRIAL_2026-09-18.md b/experiments/DAMPED_COUPLED_TRIAL_2026-09-18.md index b6cfe1f..30358f4 100644 --- a/experiments/DAMPED_COUPLED_TRIAL_2026-09-18.md +++ b/experiments/DAMPED_COUPLED_TRIAL_2026-09-18.md @@ -6,6 +6,16 @@ bounded damping of one fully coupled correction, and reserving that correction solve for a trial whose merit is reasonably close to its affine prediction. The previous full-step policy remains available and the default solver is unchanged. + +The combined opt-in policy gives useful measured progress, but does not yet +establish convergence. On the matched six-update n=1.5,J=0 trajectory it reaches +F=1.24583e-5 versus 1.49720e-5 for gravity correction alone: 16.79% lower residual +for 22.08% more Newton iteration time. After their identical first update, the +residual reductions are 22.85% versus 7.29%, giving 2.53 times greater decrease +per iteration-second over that bounded interval. Both runs hit the six-update +cap. Endpoint enthalpy-based virials remain below 1e-5, while negative density +and local EOS defects remain unresolved. The default solver remains unchanged. + ## Algorithm and architecture At accepted state x, capture F0=F(x), the actual Newton direction p and J0 p. @@ -65,5 +75,285 @@ commit refresh, artifact output and physical replay; search wall time also includes artifact output. Callback vector copies remain in the measured transaction. The no-correction control captures the same tangent for a comparable screen, so capture cost is reported separately and is not ordinary Newton work -for that control. All builds and numerical cases run serially; artifact audits -run after timed FEM work. +for that control. Builds, timed numerical cases and artifact audits do not overlap; numerical +cases run one at a time. + +## Frozen sequential trial results + +The independent artifact audit passed all nine cases in `screen_v1` (see +`screen_v1_audit.json` in the evidence directory below). This audits the recorded transaction algebra and true-defect certificates +from saved vectors, provenance, and recorded restoration evidence; it does not +certify a converged or physically admissible stellar model. + +### What improved + +The late-state screen uses the same saved state and Newton direction, a halving +line search beginning at alpha=1, one shared correction-solve budget, and the +first accepted trial. The initial full residual norm is 1.3966991044e-5. + +| Path | Policy | Accepted alpha | Residual decrease | Search operation time | +| --- | --- | ---: | ---: | ---: | +| Raw | No coupled correction | 1/512 | 0.116541% | 49.04 s | +| Raw | Full correction, every rejection eligible | 1/512 | 0.116541% | 69.00 s | +| Raw | Damped correction, every rejection eligible | 1/512 | 0.116541% | 95.33 s | +| Raw | Damped correction, near-acceptance eligibility | 1/256 | 0.253536% | 80.11 s | +| Gravity-prepared | No coupled correction | 1/512 | 0.047127% | 116.06 s | +| Gravity-prepared | Full correction, every rejection eligible | 1/512 | 0.047127% | Excluded: clock anomaly | +| Gravity-prepared | Damped correction, every rejection eligible | 1/512 | 0.047127% | 168.80 s | +| Gravity-prepared | Damped correction, near-acceptance eligibility | 1/256 | 0.189199% | 128.75 s | + +These are original-screen operation timings, excluding artifact writes, +initialization, physical replay, and the separately measured base capture. +The three timing repeats below use the identical diagnostic binary. The excluded +original gravity/full timing is retained as evidence, without rescaling. + +The demonstrated accepted-step gain comes from **eligibility timing**. The near +gate defers the prepared rejections at 1/32, 1/64, and 1/128, preserving the sole +solve for 1/256. Both accepted corrections use beta=1 and stop after one +candidate. Damping is therefore not responsible for their accepted improvement. +Without this gate, the correction budget is spent at 1/32, and the eventual +accepted state equals the corresponding baseline. Gravity damping does improve +the rejected 1/32 candidate at beta=1/2, but it remains unacceptable. + +The nominal accepted alpha improves **twofold, not fourfold**. The earlier +independent-alpha screen omitted 1/512 and did not reproduce a shared-budget +search. The present residual decreases and costs describe one frozen search; +they establish neither sustained convergence nor a nonlinear speedup. They also +do not establish that the chosen eligibility threshold is effective at other +states, polytropic indices, or rotation rates. + +### Seed control + +The seed's independently verified outer defect is 0.0281993. Its first trial, +alpha=1, already passes after existing gravity preparation: the residual falls +from 2.736592176e-4 to 1.614841549e-5, a 94.0991% decrease. The new policy performs +zero coupled solves, beta evaluations, or deferrals. This confirms nonintervention +at this sampled seed; it is not a complete trajectory or performance control. + +### Physical interpretation + +All selected snapshots replay exactly, with accepted buffers, direction, +normalization factors, residual, and Jacobian action restored. The q18 +enthalpy-pressure energy virial errors are 4.059–4.068e-6 for the late candidates +and 4.3646e-6 for the seed candidate. Force virial errors are 2.565–2.572e-6 and +2.8455e-6, respectively. Raising quadrature from q14 to q18 changes either +enthalpy-based virial diagnostic by only 1.828–1.834e-9. Mass errors are +3.13–3.71e-6, Jz is zero, and sampled mapping determinants remain above 0.9858. + +However, every selected snapshot retains negative density: q18 minima range +from -8.25e-4 to -8.64e-4. Density-derived pressure is not defined everywhere, +and its reported virial diagnostics are NaN. The q18 negative-density mass +fraction is about 2.90e-5, **29–30% larger** than its q14 estimate. The sampled +density-minimum magnitude increases 15–17%, and the maximum scaled EOS mismatch +increases about 17%; the RMS scaled EOS mismatch changes about 0.21%. + +Thus the enthalpy-based global virial diagnostic meets 1e-5 and is stable under +this quadrature comparison, while local density/EOS defects remain unresolved. +This comparison is neither a mesh-convergence study nor a pointwise positivity +certificate. Frozen Armijo acceptance must not be presented as model convergence +or complete physical validation. + +## Timing repeats + +The first gravity/full case had inconsistent elapsed clocks: 531.747 s UTC +versus 236.576 s Python monotonic, with a 325.024 s C++ auxiliary solve. It is +excluded from performance comparisons. The runner now records child user/system +CPU time, UTC elapsed time and monotonic elapsed time independently. A temporary +idle-sleep guard covers each subsequent campaign; no phase time is rescaled. + +| Repeat | Search operation time | Total elapsed / child CPU | Result | +| --- | ---: | ---: | --- | +| Gravity/full | 149.17 s | 227.30 / 226.58 s | Same baseline endpoint; 53 auxiliary iterations | +| Raw/none | 45.76 s | 123.67 / 123.57 s | Same alpha=1/512 endpoint | +| Raw/near | 74.90 s | 153.11 / 153.05 s | Same alpha=1/256, beta=1 endpoint | + +UTC and monotonic times differ by at most 0.0053 s in these repeats. Every saved +vector file has the same hash as its original run (68, 55 and 54 files, +respectively), and the independent vector audit passes all three. The raw +progress per operation-second ratio is 1.332 initially and 1.329 in the paired +repeat. These support about 33% greater fractional residual decrease per measured +operation-second at this frozen state, despite an approximately 7% timing shift +between campaigns. The gravity near/none ratio is 3.619 in the +one original pair; it has not received an additional paired timing repeat. + +These ratios use fractional residual decrease, not merit decrease. They compare +one fixed state/direction and exclude ordinary outer linear solve, commit +refresh, initialization and physical replay. They are not Newton speedup factors. +The correction also changes the actual displacement, so a factor-two nominal +alpha is not a factor-two displacement claim. + +## Matched live Newton follow-up + +The paired six-update runs use the same seed, mesh, n=1.5,J=0 specification, +no central constraint, gravity-Schur preconditioner, restart 160 and outer true +defect tolerance 0.03. Only the trial policy changes: `curvature` versus +`curvature-backtracked-coupled`. Initial physical observations and layout hashes +match exactly, as do the first accepted residual, alpha, linear diagnostics and +block residuals. First-step state vectors were not saved, so these are recorded +diagnostic matches rather than a separate bitwise state comparison. + +| Accepted update | Control alpha | Control F | New alpha | New F | Cumulative Newton seconds, control / new | +| ---: | ---: | ---: | ---: | ---: | ---: | +| 1 | 1 | 1.614842e-5 | 1 | 1.614842e-5 | 39.25 / 39.11 | +| 2 | 1/16 | 1.579569e-5 | 1/8 | 1.614552e-5 | 173.68 / 194.99 | +| 3 | 1/16 | 1.553144e-5 | 1/4 | 1.595479e-5 | 248.91 / 249.76 | +| 4 | 1/16 | 1.514564e-5 | 1/2 | 1.574591e-5 | 324.13 / 305.06 | +| 5 | 1/32 | 1.499075e-5 | 1/2 | 1.273240e-5 | 419.19 / 431.45 | +| 6 | 1/32 | 1.497197e-5 | 1/8 | 1.245830e-5 | 502.21 / 613.09 | + +Both runs complete six accepted updates and report nonconvergence at the +iteration limit (exit 3). The original strict residual tolerance is unchanged. +The combined policy produces 16.79% lower final residual, at 22.08% more +iteration time. Excluding the identical first update, residual decreases from +that common value are 7.285% in 462.95 s (control) and 22.851% in 573.98 s (new), +a 2.530 ratio of residual decrease per second over this fixed interval. This is +a bounded progress-rate comparison, not a speedup to a converged model. + +The new method initially lags. First recorded crossings of fixed residual +thresholds illustrate the tradeoff without interpolating between states: + +| F threshold | Control Newton seconds | New Newton seconds | +| ---: | ---: | ---: | +| 1.60e-5 | 173.68 | 249.76 | +| 1.55e-5 | 324.13 | 431.45 | +| 1.50e-5 | 419.19 | 431.45 | +| 1.40e-5 | Not reached within six updates | 431.45 | + +Total process elapsed/child CPU times are 561.92/561.22 s (control) and +671.98/671.86 s (new). UTC and monotonic elapsed times differ by 0.0014 s and +-0.0318 s, respectively. The total process includes initialization, endpoint +physical diagnostics and artifact output; the iteration totals exclude these. +There is one live timing pair, not a statistical performance distribution. + +### What the live corrections actually did + +The control uses 354 outer Krylov iterations; the new run uses 341 outer plus +163 auxiliary iterations across three correction solves. The new gate defers +five trials, and auxiliary preparation adds 11 preparations, including one +best-candidate replay. Auxiliary solve/preparation/verification/refresh counters +are 84.35/64.03/0.67/4.77 s; these are nested work already included in iteration +time and must not be added to it. Ten beta candidates are evaluated. + +At update 2 (zero-based iteration 1), alpha=1/8 has an eligible excess ratio +0.35155. The unscaled solve passes its true defect gate (relative 0.02570) and +cap (correction/cap=0.63369). Beta=1 worsens the merit; beta=1/2 passes ordinary +acceptance. It reduces residual by only 0.01795%, versus 2.18426% from the +control's next ordinary backtrack at alpha=1/16. Thus an accepted rescue need +not have the best immediate progress. It realizes only 0.1533% of the nominal +affine predicted merit decrease, despite satisfying Armijo by a factor 28.7. + +Unlike the successful frozen rescues, this accepted live correction genuinely +uses damping. It changes the following trajectory: updates 3 and 4 accept +ordinary alpha=1/4 and 1/2 trials, and update 5 achieves a large residual drop. +At updates 5 and 6 the auxiliary searches keep their best improvements at +beta=1/8 and 1/4, respectively, but both still fail outer acceptance. The next +ordinary trial is accepted instead. Therefore three `kept` diagnostic events +mean only **one committed coupled rescue**. No later rejected correction is +carried into the accepted state. + +A minimum realized-versus-predicted decrease guard is a plausible separate +investigation, not an established improvement: it would reject the initially +weak rescue that precedes this better final trajectory. A beta-aware prediction +F(y)+beta J(y)c would distinguish a damped candidate from the full target L +without another FEM action. Any guard must control final candidate admission +and fallback, not just beta-loop stopping. Comparing with one ordinary further +backtrack is another useful control, at one extra preparation cost. Neither +change is implemented or claimed beneficial here. + +### Live endpoint physics + +| q18 observation | Control | New | +| --- | ---: | ---: | +| Enthalpy-pressure energy virial error | 4.22590e-6 | 3.78554e-6 | +| Enthalpy-pressure force virial error | 2.71367e-6 | 2.31894e-6 | +| Absolute mass error | 3.41685e-6 | 2.53899e-6 | +| Absolute J error | 0 | 0 | +| Minimum sampled mapping determinant | 0.989275 | 0.989068 | +| Minimum density | -7.73185e-4 | -7.85522e-4 | +| Negative-density mass fraction | 2.89683e-5 | 2.88557e-5 | +| Scaled RMS EOS density mismatch | 1.27948e-4 | 1.27721e-4 | +| Scaled maximum EOS density mismatch | 9.63952e-4 | 9.73858e-4 | + +Both endpoints meet the primary enthalpy-based virial milestone at q14 and q18; +the quadrature change is about 1.83–1.84e-9. Density-derived pressure and its +virials remain undefined because of negative samples. The new endpoint improves +the global measures while its sampled density minimum and maximum EOS defect +are slightly worse. No positivity, mesh convergence, or complete local-physics +claim follows from the virial result. The separate conservative solver/defined- +virial checker returns its expected nonpassing result for both endpoints. + +### Implication and next experiment + +Keep the new policy opt-in. The implementation and its cost bounds are tested; +the combined policy now has evidence of beneficial nonlinear trajectory changes +as well as frozen-step improvement. It has not yet demonstrated sustained +convergence or any higher-n/rotating result. The next useful test is a longer +matched n=1.5 trajectory from the same seed, preserving the physical diagnostics +and work accounting, before expanding to higher n and J. Do not promote the +policy or tune a new quality threshold solely from the early marginal rescue. + +## Validation + +Production and experiment targets build successfully with the configured release +Clang/C++23 module build. Existing duplicate-library linker warnings remain. + +- Core correction tests: 23 serial cases / 1,129 assertions; the same 23 cases + on two MPI ranks pass 1,134 assertions per rank. +- Broader Newton, numerical-strategy, gravity and FGMRES regression tests: + 35 cases / 357,016 assertions. Together the serial groups cover 58 cases. +- Python artifact-checker tests: 25 frozen-search cases, 27 prior cases and + 4 live-checker regressions, all 56 passing. +- Actual frozen campaigns: nine original cases and three repeats, all passing + the independent recorded-vector/provenance audit. +- Matched live pair: 1,083 scalar-decision/provenance checks pass. These check + recorded true-defect observations rather than independently reapplying FEM. + +Coverage includes smaller-beta acceptance after full-step rejection, preparation +failure followed by success, first-accepted stopping, best-candidate replay, +complete rejection, later-candidate/replay exceptions, restoration, near-gate +budget preservation, zero predicted decrease, already accepted originals, invalid +and rank-mismatched options, rank-local rejection, and typed recipe composition. +Real-FEM production tests cover the original and new policies on raw and gravity +paths, preservation of the outer linear report, and checkpoint restoration. + +The checker reconstructs the affine prediction, auxiliary RHS, unscaled full-J +defect and cap, beta state updates and remainder identity from saved vectors. +It verifies shared budget use and first-accept stopping, checks recorded fresh +selected-state replay and restoration evidence, and checks accepted-state and +normalization invariants and input/output hashes. It does +not independently rerun FEM or physical quadrature. + +## Evidence and reproduction + +Evidence root (outside the source tree): + +`/Users/tboudreaux/.codex/.chatgpt-projects/g-p-6a6b3f2194ec8191bebcd59075897f1d/diagnostics/damped_coupled_2026-09-18` + +The original frozen screen binds to source `25816d4`; the repeats and matched +Newton cases bind to `1876229`. Their diagnostic binary hashes are recorded in +each campaign's `provenance.json`. The repeats use the same binary hash as the +screen. Test provenance and logs are in the evidence root. Tests were compiled +at `422c46b`; the subsequent source change `25816d4` only hardened the frozen +experiment's finite-metric guards. + +Versioned cases: + +- `damped_coupled_screen_2026-09-18.json` +- `damped_coupled_repeat_2026-09-18.json` +- `damped_coupled_newton_2026-09-18.json` + +Use `run_plateau_campaign.py` with a clean committed source, its configured build, +`--mesh`, `--cases`, and a fresh `--output`. The build copies an immutable +executable before any case runs. Case manifests currently name the retained +absolute snapshot paths; relocate those explicitly when reproducing elsewhere. + +After timed cases finish, run: + +```sh +python3 experiments/check_damped_coupled_trial.py EVIDENCE/screen_v1 --output EVIDENCE/screen_v1_audit.json +python3 experiments/check_damped_coupled_trial.py EVIDENCE/repeat_v1 --output EVIDENCE/repeat_v1_audit.json +python3 experiments/check_damped_coupled_newton.py EVIDENCE/newton_v1 --output EVIDENCE/newton_v1_review.json +``` + +`TIMING_NOTE.md`, `repeat_comparison.json`, the per-case run records and all raw +CSVs/vectors preserve the timing exclusion and reproducibility evidence. diff --git a/experiments/check_damped_coupled_newton.py b/experiments/check_damped_coupled_newton.py new file mode 100644 index 0000000..16f01ee --- /dev/null +++ b/experiments/check_damped_coupled_newton.py @@ -0,0 +1,382 @@ +#!/usr/bin/env python3 +"""Audit the paired six-step live experiment without loading state vectors. + +Hashes bind the producer's observations. Scalar identities check those recorded +observations; they do not supply a fresh FEM action, a pointwise certificate, or +an unavailable corrected displacement. Nonconvergence and physical defects are +reported separately from artifact/decision consistency. +""" +import argparse +import csv +import datetime +import hashlib +import json +import math +from pathlib import Path +import sys + +EPS = sys.float_info.epsilon +ARMIJO = 1.e-4 # The live driver's default; this is not a configurable CLI flag. +COUNTERS = ('attempts', 'kept', 'fallbacks', 'iterations', 'preparations', 'restorations', + 'solve_seconds', 'verification_seconds', 'preparation_seconds', 'refresh_seconds') +REQUIRED = {'metadata.txt', 'layout.csv', 'initial_physical.csv', 'final_physical.csv', + 'newton.csv', 'blocks.csv', 'trials.csv', 'coupled_trials.csv', 'coupled_search.csv', + 'coupled_candidates.csv', 'trial_state_consistency.csv', 'numerical_strategies.csv', + 'accepted_state.txt'} + + +def digest(path): + result = hashlib.sha256() + with path.open('rb') as stream: + for chunk in iter(lambda: stream.read(1024 * 1024), b''): + result.update(chunk) + return result.hexdigest() + + +def rows(path): + with path.open() as stream: + result = list(csv.DictReader(stream)) + if any(None in row or any(value is None for value in row.values()) for row in result): + raise ValueError(f'Incomplete CSV row: {path}') + return result + + +def close(a, b, scale=0.): + return math.isfinite(a) and math.isfinite(b) and abs(a-b) <= 1.e-10*max(abs(a), abs(b)) + 256*EPS*abs(scale) + + +def safe(value): + if isinstance(value, float) and not math.isfinite(value): + return None + if isinstance(value, dict): + return {key: safe(item) for key, item in value.items()} + if isinstance(value, (list, tuple)): + return [safe(item) for item in value] + return value + + +def key(row): + return int(row['iteration']), int(row['trial']) + + +def indexed(records): + result = {key(row): row for row in records} + if len(result) != len(records): + raise ValueError('Duplicate iteration/trial observations') + return result + + +def physical(path, metadata, check): + data = rows(path) + values = {(int(row['quadrature']), row['metric']): float(row['value']) for row in data} + check(path.name+':unique_metrics', len(values) == len(data)) + check(path.name+':quadratures', {q for q, _ in values} == {14, 18}) + result = {} + for q in (14, 18): + m = {metric: value for (order, metric), value in values.items() if order == q} + label = f'{path.name}:{q}:' + check(label+'model', m['polytropic_index'] == float(metadata['n']) and m['polytropic_constant'] == float(metadata['K'])) + complete = m['complete_volume_measurement'] == 1 + if complete: + check(label+'mass_error', close(m['mass_absolute_error'], abs(m['mass']-float(metadata['M'])), abs(m['mass']))) + check(label+'angular_momentum_error', close(m['angular_momentum_absolute_error'], abs(m['angular_momentum_z']-float(metadata['J'])), 1.)) + virials = {} + for pressure_prefix, defined_key, pressure_key in ( + ('', 'pressure_from_density_defined_everywhere', 'pressure_integral'), + ('enthalpy_', 'pressure_from_enthalpy_defined_everywhere', 'enthalpy_pressure_integral')): + defined = m[defined_key] == 1 and complete and math.isfinite(m['binding_energy']) and m['binding_energy'] < 0 + for force, binding_key in (('', 'binding_energy'), ('force_', 'force_binding_energy')): + name = pressure_prefix+force+'virial_' + signed, error = m[name+'signed'], m[name+'error'] + if defined: + numerator = 2*m['kinetic_energy']+m[binding_key]+3*m[pressure_key] + scale = (2*abs(m['kinetic_energy'])+abs(m[binding_key])+3*abs(m[pressure_key]))/abs(m['binding_energy']) + check(label+name+'identity', close(signed, numerator/abs(m['binding_energy']), scale) and close(error, abs(signed), scale)) + else: + check(label+name+'undefined', math.isnan(signed) and math.isnan(error)) + virials[name+'within_1e5'] = error <= 1.e-5 if defined and math.isfinite(error) else None + result[str(q)] = {'metrics': m, 'virial_milestones': virials, + 'pressure_from_density_defined': m['pressure_from_density_defined_everywhere'] == 1, + 'sampled_negative_density': m['negative_density_samples'] > 0, + 'sampled_negative_enthalpy': m['negative_enthalpy_samples'] > 0, + 'global_enthalpy_certificate': 'not_measured'} + return result + + +def inspect_case(folder, record, check): + metadata = dict(line.split('=', 1) for line in (folder/'metadata.txt').read_text().splitlines() if '=' in line) + history, trials = rows(folder/'newton.csv'), rows(folder/'trials.csv') + coupled, searches = indexed(rows(folder/'coupled_trials.csv')), indexed(rows(folder/'coupled_search.csv')) + consistency = indexed(rows(folder/'trial_state_consistency.csv')) + trial_map = indexed(trials) + check('trial_joins', set(trial_map) == set(coupled) == set(searches) == set(consistency)) + candidates = {} + for row in rows(folder/'coupled_candidates.csv'): + candidates.setdefault(key(row), []).append(row) + check('candidate_trial_coverage', set(candidates) <= set(trial_map)) + strategy_rows = rows(folder/'numerical_strategies.csv') + strategy = {row['metric']: float(row['value']) for row in strategy_rows} + check('unique_strategy_summary', len(strategy) == len(strategy_rows)) + initial = physical(folder/'initial_physical.csv', metadata, check) + endpoint = physical(folder/'final_physical.csv', metadata, check) + before = initial['18']['metrics']['normalized_bordered_residual'] + initial_residual = before + check('six_step_control', metadata['max_newton'] == '6' and float(metadata['linear_tol']) == .03) + check('single_auxiliary_budget', metadata['coupled_attempts_per_iteration'] == '1') + check('outer_iteration_sequence', [int(row['iteration']) for row in history] == list(range(len(history))) and len(history) <= 6) + block_data = rows(folder/'blocks.csv') + residual_blocks = {row['block'] for row in rows(folder/'layout.csv') if row['kind'] == 'residual'} + blocks = {(int(row['iteration']), row['stage'], row['block']): float(row['normalized_norm']) for row in block_data} + check('unique_block_observations', len(blocks) == len(block_data)) + expected_blocks = {(int(row['iteration']), stage, block) for row in history for stage in ('before', 'after') for block in residual_blocks} + check('block_coverage', set(blocks) == expected_blocks) + previous = dict.fromkeys(COUNTERS, 0.) + previous_deferred = 0. + accepted_count = 0 + cumulative_seconds = 0. + cumulative_linear_iterations = 0 + step_results = [] + used_trials = set() + for row in history: + iteration = int(row['iteration']) + iteration_trials = [trial for trial in trials if int(trial['iteration']) == iteration] + check(f'{iteration}:trial_sequence', [int(trial['trial']) for trial in iteration_trials] == list(range(len(iteration_trials)))) + check(f'{iteration}:trial_count', int(row['trials']) == len(iteration_trials)) + for stage, value in (('before', before), ('after', float(row['F']))): + block_norm = math.hypot(*(blocks[(iteration, stage, block)] for block in residual_blocks)) + check(f'{iteration}:{stage}:block_norm', close(block_norm, value)) + check(f'{iteration}:linear_rhs_matches_base', close(float(row['linear_rhs_norm']), before)) + outer_gate = math.isfinite(float(row['linear_true'])) and float(row['linear_true']) <= .03 + step_accepted = row['accepted'] == '1' + check(f'{iteration}:accepted_boolean', row['accepted'] in ('0', '1')) + accepted_trials = [trial for trial in iteration_trials if trial['disposition'] == '0'] + check(f'{iteration}:accepted_trial_count', len(accepted_trials) == int(step_accepted)) + if step_accepted: + accepted_count += 1 + check(f'{iteration}:accepted_linear_gate', row['linear_status'] == '0' and outer_gate) + check(f'{iteration}:strict_residual_decrease', 0 <= float(row['F']) < before) + check(f'{iteration}:accepted_trial_binding', accepted_trials[0] == iteration_trials[-1] and + close(float(accepted_trials[0]['F']), float(row['F'])) and accepted_trials[0]['alpha'] == row['alpha']) + else: + check(f'{iteration}:failed_state_unchanged', close(float(row['F']), before)) + increments = dict.fromkeys(COUNTERS, 0.) + deferred_increment = 0. + observations = [] + for trial in iteration_trials: + address = key(trial) + used_trials.add(address) + label = f'{iteration}:{address[1]}:' + c, search, roundtrip = coupled[address], searches[address], consistency[address] + alpha = float(trial['alpha']) + check(label+'alpha_binding', 0 < alpha <= 1 and float(c['alpha']) == alpha and float(search['alpha']) == alpha) + check(label+'diagnostics_available', c['diagnostics_available'] == '1' and c['status'] == search['status']) + check(label+'observer_roundtrip', math.isfinite(float(roundtrip['relative_error'])) and + 0 <= float(roundtrip['relative_error']) <= 64*EPS and math.isfinite(float(roundtrip['normalized_physical_roundtrip_error'])) and + float(roundtrip['normalized_physical_roundtrip_error']) >= 0) + change = {} + for counter in COUNTERS: + current = float(c[counter+'_total']) + check(label+counter+':monotone', math.isfinite(current) and current >= previous[counter]) + change[counter] = current-previous[counter] + increments[counter] += change[counter] + previous[counter] = current + deferred = float(search['deferred_total']) + check(label+'deferred_monotone', math.isfinite(deferred) and deferred >= previous_deferred) + deferred_change = deferred-previous_deferred + deferred_increment += deferred_change + previous_deferred = deferred + has_solve = c['has_solve'] == '1' + status = int(c['status']) + check(label+'known_status', 0 <= status <= 9 and c['has_solve'] in ('0', '1')) + check(label+'attempt_accounting', change['attempts'] == int(has_solve) and change['iterations'] == int(c['linear_iterations'])) + check(label+'selection_accounting', change['kept'] == int(status == 3) and + change['fallbacks'] == int(has_solve and status != 3) and deferred_change == int(status == 9)) + proposals = candidates.get(address, []) + check(label+'candidate_count', len(proposals) == int(search['candidate_count']) and len(proposals) <= int(metadata['coupled_beta_steps'])) + check(label+'candidate_sequence', [int(item['candidate']) for item in proposals] == list(range(len(proposals)))) + merit_limit = (1-ARMIJO*alpha)*.5*before*before + if trial['disposition'] == '0': + check(label+'ordinary_merit_acceptance', math.isfinite(float(trial['F'])) and .5*float(trial['F'])**2 <= merit_limit) + for index, proposal in enumerate(proposals): + beta, merit = float(proposal['beta']), float(proposal['merit']) + check(label+f'beta{index}:grid', close(beta, float(metadata['coupled_beta_reduction'])**index)) + check(label+f'beta{index}:status', proposal['status'] in ('0', '1', '2') and proposal['accepted'] in ('0', '1')) + valid = proposal['status'] == '0' + check(label+f'beta{index}:metric', math.isfinite(merit) and merit >= 0 if valid else math.isnan(merit)) + accepted = valid and merit < float(c['original_merit']) and merit <= merit_limit + check(label+f'beta{index}:ordinary_acceptance', (proposal['accepted'] == '1') == accepted) + if accepted: + check(label+'first_accepted_beta_stops_search', index == len(proposals)-1) + check(label+f'beta{index}:time', math.isfinite(float(proposal['preparation_seconds'])) and float(proposal['preparation_seconds']) >= 0) + if has_solve: + rhs, threshold, verified = float(c['rhs_norm']), float(c['threshold']), float(c['verified_true_norm']) + check(label+'auxiliary_work_bound', 0 <= int(c['linear_iterations']) <= int(metadata['coupled_iterations'])) + if math.isfinite(rhs) and math.isfinite(threshold): + check(label+'auxiliary_threshold', close(threshold, max(1.e-14, float(metadata['coupled_tolerance'])*rhs))) + if math.isfinite(verified) and math.isfinite(threshold): + check(label+'auxiliary_true_gate', (c['verified_gate'] == '1') == (verified <= threshold)) + if status == 3: + selected_beta = float(search['selected_beta']) + chosen = [item for item in proposals if float(item['beta']) == selected_beta] + check(label+'retained_auxiliary_certificate', has_solve and c['linear_status'] == '0' and c['verified_gate'] == '1' and + math.isfinite(float(c['verified_true_norm'])) and float(c['verified_true_norm']) <= float(c['threshold']) and + 0 <= float(c['correction_norm']) <= float(c['maximum_correction_norm'])) + check(label+'retained_beta_observed', len(chosen) == 1 and chosen[0]['status'] == '0') + if len(chosen) == 1: + check(label+'retained_metric_replay', close(float(chosen[0]['merit']), float(c['corrected_merit']))) + check(label+'retained_merit_improves', 0 <= float(c['corrected_merit']) < float(c['original_merit'])) + check(label+'retained_residual_binding', close(.5*float(trial['F'])**2, float(c['corrected_merit']))) + else: + check(label+'no_stale_selection', math.isnan(float(search['selected_beta']))) + if not has_solve: + check(label+'no_stale_auxiliary_report', c['linear_status'] == '-1' and c['verified_gate'] == '0' and not proposals) + if status == 9 or has_solve: + if metadata['strategy'] == 'curvature-backtracked-coupled': + base_merit, predicted = float(search['base_merit']), float(search['predicted_merit']) + drop = float(search['predicted_decrease']) + check(label+'prediction_base_binding', close(base_merit, .5*before*before)) + check(label+'predicted_decrease_identity', close(drop, base_merit-predicted, base_merit)) + if drop > 0: + ratio = (float(c['original_merit'])-base_merit)/drop + check(label+'excess_ratio_identity', close(float(search['excess_ratio']), ratio, 1.)) + check(label+'near_gate_decision', (ratio <= float(metadata['coupled_excess_ratio'])) == has_solve) + else: + check(label+'nonpositive_prediction_defers', status == 9 and not has_solve) + observations.append({'trial': address[1], 'alpha': alpha, 'disposition': int(trial['disposition']), + 'rejection_source': trial['source'], 'F': float(trial['F']), 'coupled_status': status, + 'selected_beta': float(search['selected_beta']), 'auxiliary_changes': change, + 'deferrals': deferred_change, 'candidates': proposals}) + check(f'{iteration}:auxiliary_attempt_budget', increments['attempts'] <= 1) + for field in ('seconds', 'linear_seconds', 'prepare_seconds', 'refresh_seconds'): + check(f'{iteration}:{field}', math.isfinite(float(row[field])) and float(row[field]) >= 0) + cumulative_seconds += float(row['seconds']) + cumulative_linear_iterations += int(row['linear_iterations']) + step_results.append({'iteration': iteration, 'accepted': step_accepted, 'F_before': before, 'F': float(row['F']), + 'alpha': float(row['alpha']), 'outer_true_relative': float(row['linear_true']), 'outer_true_gate': outer_gate, + 'outer_linear_iterations': int(row['linear_iterations']), 'iteration_seconds': float(row['seconds']), + 'cumulative_iteration_seconds': cumulative_seconds, 'cumulative_outer_linear_iterations': cumulative_linear_iterations, + 'linear_seconds': float(row['linear_seconds']), 'reported_trial_preparation_seconds': float(row['prepare_seconds']), + 'outer_refresh_seconds': float(row['refresh_seconds']), 'auxiliary_changes': increments, + 'auxiliary_deferrals': deferred_increment, 'trials': observations}) + before = float(row['F']) + check('all_trials_accounted', used_trials == set(trial_map)) + check('accepted_steps_metadata', accepted_count == int(metadata['accepted_steps'])) + converged = metadata['converged'] == '1' + check('termination_record', metadata['converged'] in ('0', '1') and record['exit_code'] == (0 if converged else 3) and + (converged or bool(metadata.get('failure')))) + check('bounded_outcome', accepted_count == 6 or converged or bool(metadata.get('failure'))) + for counter, total in previous.items(): + check('final_auxiliary_'+counter, close(strategy['coupled_'+counter], total)) + check('final_deferred_count', strategy['coupled_deferred_attempts'] == previous_deferred) + for q in ('14', '18'): + check('endpoint_residual_'+q, close(endpoint[q]['metrics']['normalized_bordered_residual'], before)) + return {'metadata': metadata, 'converged': converged, 'failure': metadata.get('failure'), 'accepted_steps': accepted_count, + 'initial_residual': initial_residual, 'final_residual': before, 'steps': step_results, + 'summed_iteration_seconds': math.fsum(float(row['seconds']) for row in history), + 'summed_outer_linear_iterations': sum(int(row['linear_iterations']) for row in history), + 'auxiliary_totals': previous, 'auxiliary_deferrals_total': previous_deferred, + 'initial_physical': initial, 'endpoint_physical': endpoint, + 'actual_corrected_displacement': 'not_logged; alpha and beta do not determine its norm', + 'first_accepted_record': next((row for row in history if row['accepted'] == '1'), None), + 'first_after_blocks': [row for row in block_data if row['iteration'] == '0' and row['stage'] == 'after']} + + +def audit(campaign): + campaign = campaign.resolve() + checks, cases = [], {} + def check(name, value): + checks.append({'check': name, 'passed': bool(value)}) + try: + provenance = json.loads((campaign/'provenance.json').read_text()) + specifications = provenance['cases'] + check('paired_manifest', len(specifications) == 2 and len({case['id'] for case in specifications}) == 2 and + specifications == json.loads((campaign/'cases.json').read_text())) + check('immutable_executable', digest(campaign/'plateau_diagnostic') == provenance['binary_sha256']) + check('mesh_identity', digest(Path(provenance['mesh'])) == provenance['mesh_sha256']) + for specification in specifications: + name, arguments = specification['id'], specification['arguments'] + folder = campaign/name + record_path = campaign/(name+'_run.json') + record = json.loads(record_path.read_text()) + case_check = lambda label, value: check(name+':'+label, value) + case_check('completed_observation', bool(record.get('finished_utc')) and not record.get('timed_out', False) and record.get('exit_code') in (0, 3)) + case_check('command_identity', record['id'] == name and record['command'] == + [str(campaign/'plateau_diagnostic'), *arguments, '--mesh', provenance['mesh'], '--output', str(folder)]) + for path, expected in record['input_state_hashes'].items(): + case_check('input_hash:'+path, digest(Path(path)) == expected) + outputs = record['output_hashes'] + case_check('required_hashes', REQUIRED <= outputs.keys()) + observed_files = {str(path.relative_to(folder)) for path in folder.rglob('*') if path.is_file() and path.suffix in ('.txt', '.csv', '.json')} + case_check('complete_hash_manifest', observed_files == set(outputs)) + for path, expected in outputs.items(): + case_check('output_hash:'+path, digest(folder/path) == expected) + result = inspect_case(folder, record, case_check) + metadata = result['metadata'] + for option, field in {'--index': 'n', '--angular-momentum': 'J', '--max-newton': 'max_newton', '--restart': 'restart', + '--max-linear': 'max_linear', '--linear-tol': 'linear_tol', '--rtol': 'rtol', '--atol': 'atol', + '--schur-sweeps': 'schur_correction_sweeps', '--schur-relaxation': 'schur_relaxation'}.items(): + if option in arguments: + case_check('requested_'+field, float(metadata[field]) == float(arguments[arguments.index(option)+1])) + for option, field in (('--strategy', 'strategy'), ('--preconditioner-variant', 'preconditioner_variant')): + case_check('requested_'+field, metadata[field] == arguments[arguments.index(option)+1]) + case_check('no_central', '--no-central' in arguments and metadata['central_phase'] == '0') + elapsed = (datetime.datetime.fromisoformat(record['finished_utc'])-datetime.datetime.fromisoformat(record['started_utc'])).total_seconds() + case_check('utc_elapsed_identity', close(record['utc_elapsed_seconds'], elapsed)) + case_check('clock_difference_identity', close(record['utc_minus_monotonic_seconds'], elapsed-record['seconds'], elapsed)) + case_check('child_cpu_identity', close(record['child_cpu_seconds'], record['child_user_cpu_seconds']+record['child_system_cpu_seconds'])) + for field in ('seconds', 'utc_elapsed_seconds', 'child_user_cpu_seconds', 'child_system_cpu_seconds', 'child_cpu_seconds'): + case_check('timing_'+field, math.isfinite(record[field]) and record[field] >= 0) + result['timing'] = {field: record[field] for field in ('started_utc', 'finished_utc', 'seconds', 'utc_elapsed_seconds', + 'utc_minus_monotonic_seconds', 'child_user_cpu_seconds', 'child_system_cpu_seconds', 'child_cpu_seconds')} + result['timing']['clock_disagreement_observed'] = abs(record['utc_minus_monotonic_seconds']) > max(.5, .01*record['seconds']) + result['run_record_sha256'] = digest(record_path) + result['output_hashes'] = outputs + cases[name] = result + first, second = specifications + def without_strategy(arguments): + values = list(arguments) + position = values.index('--strategy') + del values[position:position+2] + return values + check('pair:only_strategy_differs', without_strategy(first['arguments']) == without_strategy(second['arguments'])) + a, b = cases[first['id']], cases[second['id']] + check('pair:strategies', {a['metadata']['strategy'], b['metadata']['strategy']} == {'curvature', 'curvature-backtracked-coupled'}) + excluded = {'strategy', 'converged', 'accepted_steps', 'failure'} + check('pair:matching_metadata', {k: v for k, v in a['metadata'].items() if k not in excluded} == + {k: v for k, v in b['metadata'].items() if k not in excluded}) + for path in ('initial_physical.csv', 'layout.csv'): + check('pair:exact_'+path, a['output_hashes'][path] == b['output_hashes'][path]) + both_first = a['first_accepted_record'] is not None and b['first_accepted_record'] is not None + if both_first: + fields = ('iteration', 'F', 'alpha', 'trials', 'linear_status', 'linear_iterations', 'linear_true', + 'central_border', 'unbordered_F', 'linear_initial_residual', 'linear_rhs_norm', 'linear_initial_over_rhs') + check('pair:first_accepted_record_exact', all(a['first_accepted_record'][k] == b['first_accepted_record'][k] for k in fields)) + check('pair:first_accepted_blocks_exact', a['first_after_blocks'] == b['first_after_blocks']) + passed = all(item['passed'] for item in checks) + return safe({'passed': passed, 'observations_bound_to_valid_artifacts': passed, 'checks': checks, 'cases': cases, + 'source_commit': provenance['commit'], 'provenance_sha256': digest(campaign/'provenance.json'), + 'analysis_script_sha256': digest(Path(__file__)), 'both_first_accepted_steps_observed': both_first, + 'scope': 'Recorded decision/scalar-identity audit, with streaming artifact hashes. Outer and auxiliary true defects are producer observations, not recomputed FEM actions. Matching first-step diagnostics are not bitwise state equality. Numerical and physical outcomes are separate; no speedup-to-convergence claim. Auxiliary phase counters are differenced, not summed repeatedly; nested phase timings must not be added to total iteration time.'}) + except (OSError, ValueError, KeyError, TypeError, IndexError, OverflowError) as error: + check('complete_readable_campaign', False) + return safe({'passed': False, 'checks': checks, 'error': str(error), 'cases': cases, + 'interpretation_valid': False}) + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument('campaign', type=Path) + parser.add_argument('--output', type=Path, required=True) + args = parser.parse_args() + if args.output.resolve() == args.campaign.resolve() or args.campaign.resolve() in args.output.resolve().parents: + parser.error('Write analysis outside the immutable campaign.') + result = audit(args.campaign) + args.output.write_text(json.dumps(result, indent=2, allow_nan=False)+'\n') + print('PASS' if result['passed'] else 'FAIL', args.output) + for name, case in result['cases'].items(): + print(name, 'accepted=', case['accepted_steps'], 'converged=', case['converged'], 'final F=', case['final_residual']) + return 0 if result['passed'] else 1 + + +if __name__ == '__main__': + raise SystemExit(main()) diff --git a/experiments/test_damped_coupled_newton_checker.py b/experiments/test_damped_coupled_newton_checker.py new file mode 100644 index 0000000..c79aa3a --- /dev/null +++ b/experiments/test_damped_coupled_newton_checker.py @@ -0,0 +1,165 @@ +#!/usr/bin/env python3 +"""Tiny recorded-trial examples distinguish artifact validity from success. + +These fixtures exercise live scalar joins and counter accounting, without +pretending to contain FEM states or independently computed Jacobian actions. +""" +import csv +import math +from pathlib import Path +import tempfile +import unittest + +import check_damped_coupled_newton as audit + + +def write_rows(path, records): + with path.open('w', newline='') as stream: + writer = csv.DictWriter(stream, fieldnames=list(records[0])) + writer.writeheader() + writer.writerows(records) + + +class LiveCoupledCheckerTests(unittest.TestCase): + def setUp(self): + self.temporary = tempfile.TemporaryDirectory() + self.addCleanup(self.temporary.cleanup) + self.folder = Path(self.temporary.name) + + def fixture(self, accepted=True, false_gate=False): + """One bounded iteration with two beta observations and one solve.""" + final = .9 if accepted else 1. + trial_f = .9 if accepted else 1.05 + corrected_merit = .5*trial_f**2 + metadata = { + 'n': '1.5', 'K': '1', 'M': '1', 'J': '0', 'max_newton': '6', 'linear_tol': '.03', + 'coupled_attempts_per_iteration': '1', 'coupled_beta_steps': '2', 'coupled_beta_reduction': '.5', + 'coupled_iterations': '160', 'coupled_tolerance': '.03', 'coupled_excess_ratio': '1', + 'strategy': 'curvature-backtracked-coupled', 'accepted_steps': str(int(accepted)), + 'converged': '0', 'failure': 'Explicit synthetic bounded failure.'} + (self.folder/'metadata.txt').write_text('\n'.join(f'{k}={v}' for k, v in metadata.items())) + self.write('newton', [dict(iteration=0, accepted=int(accepted), F=final, alpha=1 if accepted else 0, + trials=1, linear_status=0, linear_iterations=2, linear_true=.02, seconds=2, linear_seconds=.5, + prepare_seconds=.8, refresh_seconds=.1, linear_rhs_norm=1)]) + self.write('layout', [dict(kind='residual', block='balance', offset=0, size=1, scale=1)]) + self.write('blocks', [dict(iteration=0, stage=stage, block='balance', normalized_norm=value) + for stage, value in [('before', 1), ('after', final)]]) + self.write('trials', [dict(iteration=0, trial=0, alpha=1, disposition=0 if accepted else 4, + source='' if accepted else 'residual metric', F=trial_f)]) + totals = dict(attempts=1, kept=1, fallbacks=0, iterations=2, preparations=2, restorations=0, + solve_seconds=.2, verification_seconds=.01, preparation_seconds=.5, refresh_seconds=.05) + self.write('coupled_trials', [dict(iteration=0, trial=0, alpha=1, diagnostics_available=1, status=3, + **{key+'_total': value for key, value in totals.items()}, original_merit=.6, + corrected_merit=corrected_merit, has_solve=1, linear_status=0, linear_iterations=2, rhs_norm=.5, + reported_true_norm=.01, verified_true_norm=.01, threshold=.015, verified_gate=0 if false_gate else 1, + correction_norm=.8, maximum_correction_norm=1)]) + self.write('coupled_search', [dict(iteration=0, trial=0, alpha=1, status=3, deferred_total=0, + selected_beta=.5, candidate_count=2, base_merit=.5, predicted_merit=.25, + predicted_decrease=.25, excess_ratio=.4)]) + self.write('coupled_candidates', [dict(iteration=0, trial=0, candidate=i, beta=beta, status=0, + merit=merit, accepted=int(passed), preparation_seconds=.2) + for i, beta, merit, passed in [(0, 1, .8, False), (1, .5, corrected_merit, accepted)]]) + self.write('trial_state_consistency', [dict(iteration=0, trial=0, + normalized_physical_roundtrip_error=0, relative_error=0)]) + self.write('numerical_strategies', [dict(metric='coupled_'+key, value=value) + for key, value in {**totals, 'deferred_attempts': 0}.items()]) + for stage, residual in [('initial', 1.), ('final', final)]: + measurements = [] + for quadrature in (14, 18): + metrics = dict(polytropic_index=1.5, polytropic_constant=1, complete_volume_measurement=1, + mass_absolute_error=0, mass=1, angular_momentum_z=0, angular_momentum_absolute_error=0, + binding_energy=-3, force_binding_energy=-3, kinetic_energy=0, pressure_integral=math.nan, + enthalpy_pressure_integral=1, pressure_from_density_defined_everywhere=0, + pressure_from_enthalpy_defined_everywhere=1, negative_density_samples=1, + negative_enthalpy_samples=0, normalized_bordered_residual=residual) + for pressure in ('', 'enthalpy_'): + for force in ('', 'force_'): + metrics[pressure+force+'virial_signed'] = 0 if pressure else math.nan + metrics[pressure+force+'virial_error'] = 0 if pressure else math.nan + measurements.extend(dict(quadrature=quadrature, metric=key, value=value) + for key, value in metrics.items()) + self.write(stage+'_physical', measurements) + + def write(self, name, records): + write_rows(self.folder/(name+'.csv'), records) + + def inspect(self): + checks = [] + result = audit.inspect_case(self.folder, {'exit_code': 3}, + lambda name, value: checks.append((name, bool(value)))) + return result, [name for name, passed in checks if not passed] + + def test_two_beta_observations_still_describe_one_auxiliary_solve(self): + self.fixture() + result, failed = self.inspect() + self.assertFalse(failed, failed) + self.assertEqual(result['accepted_steps'], 1) + self.assertEqual(result['auxiliary_totals']['attempts'], 1) + self.assertEqual(result['auxiliary_totals']['iterations'], 2) + self.assertEqual(result['auxiliary_totals']['preparations'], 2) + self.assertEqual(result['steps'][0]['cumulative_iteration_seconds'], 2) + self.assertFalse(result['converged']) + + def test_two_iterations_difference_cumulative_counters_instead_of_summing_them(self): + self.fixture() + for name in ('newton', 'trials', 'coupled_trials', 'coupled_search', 'coupled_candidates', 'trial_state_consistency', 'blocks'): + records = audit.rows(self.folder/(name+'.csv')) + second = [{**row, 'iteration': 1} for row in records] + for row in second: + if name in ('newton', 'trials'): + row['F'] = .81 + if name == 'newton': + row['linear_rhs_norm'] = .9 + if name == 'blocks': + row['normalized_norm'] = .9 if row['stage'] == 'before' else .81 + if name == 'coupled_trials': + for counter in audit.COUNTERS: + row[counter+'_total'] = 2*float(row[counter+'_total']) + row['original_merit'] = .6*.81 + row['corrected_merit'] = .5*.81**2 + if name == 'coupled_search': + for metric in ('base_merit', 'predicted_merit', 'predicted_decrease'): + row[metric] = .81*float(row[metric]) + if name == 'coupled_candidates': + row['merit'] = float(row['merit'])*.81 + self.write(name, records+second) + summary = audit.rows(self.folder/'numerical_strategies.csv') + for row in summary: + row['value'] = 2*float(row['value']) + self.write('numerical_strategies', summary) + final = audit.rows(self.folder/'final_physical.csv') + for row in final: + if row['metric'] == 'normalized_bordered_residual': + row['value'] = .81 + self.write('final_physical', final) + metadata = self.folder/'metadata.txt' + metadata.write_text(metadata.read_text().replace('accepted_steps=1', 'accepted_steps=2')) + result, failed = self.inspect() + self.assertFalse(failed, failed) + self.assertEqual(result['auxiliary_totals']['attempts'], 2) + self.assertEqual(result['auxiliary_totals']['iterations'], 4) + self.assertEqual(result['steps'][1]['auxiliary_changes']['attempts'], 1) + self.assertEqual(result['steps'][1]['cumulative_iteration_seconds'], 4) + + def test_retained_but_outer_rejected_correction_is_honest_failure_evidence(self): + self.fixture(accepted=False) + result, failed = self.inspect() + self.assertFalse(failed, failed) + self.assertEqual(result['accepted_steps'], 0) + self.assertEqual(result['auxiliary_totals']['kept'], 1) + self.assertFalse(result['converged']) + physical = result['endpoint_physical']['18'] + self.assertFalse(physical['pressure_from_density_defined']) + self.assertIsNone(physical['virial_milestones']['virial_within_1e5']) + self.assertTrue(physical['virial_milestones']['enthalpy_virial_within_1e5']) + self.assertEqual(physical['global_enthalpy_certificate'], 'not_measured') + + def test_false_retained_auxiliary_gate_is_rejected(self): + self.fixture(false_gate=True) + _, failed = self.inspect() + self.assertIn('0:0:retained_auxiliary_certificate', failed) + self.assertIn('0:0:auxiliary_true_gate', failed) + + +if __name__ == '__main__': + unittest.main()