diff --git a/.gitignore b/.gitignore index d0c4d6d..77a75a6 100644 --- a/.gitignore +++ b/.gitignore @@ -236,3 +236,9 @@ STYLE.md *.bib.bak* figures/ references/ + +# Curated appendix reproduction inputs; other paper PDFs stay ignored. +!/data/appendix/reference_figures/*.pdf + +# Saved-array exports are generated, never source inputs. +/out/ diff --git a/README.md b/README.md index 9df3ad6..ee006e5 100644 --- a/README.md +++ b/README.md @@ -32,6 +32,16 @@ python e1_kuramoto.py --smoke --device cpu --no-mirror This runs a 20-iteration workflow check on a small grid and network. Results appear under `out_1/`, including `untwist.gif`, `kernel_time.gif`, and `metrics.json`. +## Reproduce the appendix + +Regenerate the existing appendix figures and table statistics from the bundled original results, without training or simulation: + +```bash +python reproduce_appendix.py --manifest appendix_manifest.json --output out/appendix-regenerated +``` + +See [appendix reproduction](docs/APPENDIX_REPRODUCTION.md) for the archived runs, verification checks, and noise-data limitation. + ## Code | File | Contents | diff --git a/appendix_manifest.json b/appendix_manifest.json new file mode 100644 index 0000000..27590f0 --- /dev/null +++ b/appendix_manifest.json @@ -0,0 +1,61 @@ +{ + "schema_version": 1, + "description": "Original runs for the existing Miller\u2013Su numerical appendix. Historical inputs retained unchanged; no retraining.", + "runs": { + "bc": { + "path": "data/appendix/runs/e2_eps1_T10", + "sha256": { + "config.json": "d1ca4cb1465da0066238d5aad0f6a8db19db384930861453fc69b4dac952ed3c", + "metrics.json": "5eb0369d0221b785a7ad2df440651030e663cc0c74b602f1bcb6ebaf403dd4b7", + "trajectories.npz": "994ee45d108fa67ca96859322fe1839ecc326693fffa93d7f02645f071d28f9a" + } + }, + "k1": { + "path": "data/appendix/runs/e1_k1_T5", + "sha256": { + "config.json": "18fe4364db0476d7456f309a0f633c0b537431aa0f3f5207c117f8238ce2013f", + "metrics.json": "b42fcc26c74ab045557b2cefb5e9c3aa8b41ba06735208f7dcdd746d04a01bc6", + "trajectories.npz": "f91fa4e9bd80514d2f5f1a4ca2d4b947af0bff483a1600611987b6199d7b04a7" + } + }, + "k2": { + "path": "data/appendix/runs/e1_k2_T12", + "sha256": { + "config.json": "8fc0b04e36aca41d50332ed1344f4093650ffd148185a1e612ca73b311759731", + "metrics.json": "5787be6e8cf28f4bc39997318705abf71424fdc92578b0e7a4e07cd3d3e70b44", + "trajectories.npz": "d982c7da096d611948220f7ee22e8caede394f7a63b9b91b3e4a6c47e2602032" + } + }, + "k3": { + "path": "data/appendix/runs/e1_k3_T20", + "sha256": { + "config.json": "4a13f2ec59bd6b06e03dc6027b9a9c1d99f1ff8f27d58a100f6d66f85553c0c6", + "metrics.json": "78e161f2bbef70e20100b145eee8fae1e34051542a37ca5057b24d0d883fec63", + "trajectories.npz": "d599a52abc17b22ead159bd527096afedc081b5286ff9e2c661de59be97ed15b" + } + }, + "noise": { + "path": "data/appendix/runs/e1_k2_T12_sig03", + "sha256": { + "config.json": "0e3e703619b9876159949ebdf13a389a570400c11d26e580a48e25a9d214e77c", + "metrics.json": "31212452d9d34e9bba01b772aecf8e1e6eef2cc464c0133f1254fa8644fadf2d", + "trajectories.npz": "58c6322f096cbd34c240a25119efb2b0b76368c502801bae986dff625dd0f9b7" + } + } + }, + "output_dir": "out/appendix-reference", + "figures": { + "bc_opinion_traj.pdf": "data/appendix/reference_figures/bc_opinion_traj.pdf", + "bc_kernel_strip.pdf": "data/appendix/reference_figures/bc_kernel_strip.pdf", + "kur_rw_panel.pdf": "data/appendix/reference_figures/kur_rw_panel.pdf", + "kur_kernel_strip.pdf": "data/appendix/reference_figures/kur_kernel_strip.pdf", + "kur_noise_panel.pdf": "data/appendix/reference_figures/kur_noise_panel.pdf" + }, + "figures_sha256": { + "bc_opinion_traj.pdf": "13fea9efb675fe284756047ecfe6b40d14d0c7bb5efd537ca944c0599253c253", + "bc_kernel_strip.pdf": "ae25938cb270330ab7a12264432c168c66c2d97f9e07ec9c758a6350d7d2e139", + "kur_rw_panel.pdf": "8fbd92e2bed0ac2524c648999752e915a760ad3ab9e86d0ccd624dd6219d47b3", + "kur_kernel_strip.pdf": "75e93c47be3df16626003550ec8eaf8d1957d6f2041d751281edb91c6f9160ae", + "kur_noise_panel.pdf": "54db656b1fd1c2f48fd8367a0b63af6f25a66c276af53b4c13b2ce57d82b86ea" + } +} diff --git a/data/appendix/reference_figures/bc_kernel_strip.pdf b/data/appendix/reference_figures/bc_kernel_strip.pdf new file mode 100644 index 0000000..21f6911 Binary files /dev/null and b/data/appendix/reference_figures/bc_kernel_strip.pdf differ diff --git a/data/appendix/reference_figures/bc_opinion_traj.pdf b/data/appendix/reference_figures/bc_opinion_traj.pdf new file mode 100644 index 0000000..b99810f Binary files /dev/null and b/data/appendix/reference_figures/bc_opinion_traj.pdf differ diff --git a/data/appendix/reference_figures/kur_kernel_strip.pdf b/data/appendix/reference_figures/kur_kernel_strip.pdf new file mode 100644 index 0000000..c35c21c Binary files /dev/null and b/data/appendix/reference_figures/kur_kernel_strip.pdf differ diff --git a/data/appendix/reference_figures/kur_noise_panel.pdf b/data/appendix/reference_figures/kur_noise_panel.pdf new file mode 100644 index 0000000..b03c680 Binary files /dev/null and b/data/appendix/reference_figures/kur_noise_panel.pdf differ diff --git a/data/appendix/reference_figures/kur_rw_panel.pdf b/data/appendix/reference_figures/kur_rw_panel.pdf new file mode 100644 index 0000000..87d82bb Binary files /dev/null and b/data/appendix/reference_figures/kur_rw_panel.pdf differ diff --git a/data/appendix/runs/e1_k1_T5/config.json b/data/appendix/runs/e1_k1_T5/config.json new file mode 100644 index 0000000..21ff86a --- /dev/null +++ b/data/appendix/runs/e1_k1_T5/config.json @@ -0,0 +1,24 @@ +{ + "k": 1, + "jitter": 0.1, + "ring_range": 0.1, + "N": 64, + "T": 5.0, + "dt": 0.05, + "sigma": 0.0, + "beta": 10.0, + "lambda_terminal": 0.0, + "gamma_target": 100.0, + "x_target": 0.0, + "batch_size": 16, + "num_steps": 2000, + "lr": 0.001, + "grad_clip": 1.0, + "num_fourier_freqs": 8, + "embed_dim": 64, + "hidden_embed": 128, + "hidden_score": 128, + "seed": 0, + "device": "cpu", + "time_chunk": 50 +} \ No newline at end of file diff --git a/data/appendix/runs/e1_k1_T5/metrics.json b/data/appendix/runs/e1_k1_T5/metrics.json new file mode 100644 index 0000000..e2702d4 --- /dev/null +++ b/data/appendix/runs/e1_k1_T5/metrics.json @@ -0,0 +1,48 @@ +{ + "k": 1, + "costs": { + "meanfield": { + "total": 66.44532775878906, + "state_cost": 16.44532585144043, + "l2_reg_cost": 50.0, + "terminal_var": 3.28906512260437, + "terminal_target": 1.3877787807814457e-15, + "terminal_mean": 3.725290298461914e-09 + }, + "ring": { + "total": 353.7691955566406, + "state_cost": 16.44532585144043, + "l2_reg_cost": 337.3238830566406, + "terminal_var": 3.28906512260437, + "terminal_target": 1.3877787807814457e-15, + "terminal_mean": 3.725290298461914e-09 + }, + "trained": { + "total": 57.51552963256836, + "state_cost": 5.354661464691162, + "l2_reg_cost": 52.1602668762207, + "terminal_var": 0.010279948823153973, + "terminal_target": 0.0006043039611540735, + "terminal_mean": -0.0024582594633102417 + } + }, + "r_initial": 2.6371218098120153e-08, + "r_final": { + "meanfield": 2.6371218098120153e-08, + "ring": 2.6371218098120153e-08, + "trained": 0.9948722720146179 + }, + "var_final": { + "meanfield": 3.28906512260437, + "ring": 3.28906512260437, + "trained": 0.010279948823153973 + }, + "terminal_mean_trained": -0.0024582594633102417, + "vxi": { + "trained_max": 3.740018129348755, + "trained_final": 0.06472654640674591, + "ring": 43.59681701660156, + "meanfield": 0.0 + }, + "value_gap_meanfield_minus_trained": 8.929798126220703 +} \ No newline at end of file diff --git a/data/appendix/runs/e1_k1_T5/trajectories.npz b/data/appendix/runs/e1_k1_T5/trajectories.npz new file mode 100644 index 0000000..8baa4ce Binary files /dev/null and b/data/appendix/runs/e1_k1_T5/trajectories.npz differ diff --git a/data/appendix/runs/e1_k2_T12/config.json b/data/appendix/runs/e1_k2_T12/config.json new file mode 100644 index 0000000..729111d --- /dev/null +++ b/data/appendix/runs/e1_k2_T12/config.json @@ -0,0 +1,24 @@ +{ + "k": 2, + "jitter": 0.1, + "ring_range": 0.1, + "N": 64, + "T": 12.0, + "dt": 0.05, + "sigma": 0.0, + "beta": 10.0, + "lambda_terminal": 0.0, + "gamma_target": 100.0, + "x_target": 0.0, + "batch_size": 16, + "num_steps": 2000, + "lr": 0.001, + "grad_clip": 1.0, + "num_fourier_freqs": 8, + "embed_dim": 64, + "hidden_embed": 128, + "hidden_score": 128, + "seed": 0, + "device": "cpu", + "time_chunk": 50 +} \ No newline at end of file diff --git a/data/appendix/runs/e1_k2_T12/metrics.json b/data/appendix/runs/e1_k2_T12/metrics.json new file mode 100644 index 0000000..56447da --- /dev/null +++ b/data/appendix/runs/e1_k2_T12/metrics.json @@ -0,0 +1,48 @@ +{ + "k": 2, + "costs": { + "meanfield": { + "total": 277.8751220703125, + "state_cost": 157.87513732910156, + "l2_reg_cost": 120.0, + "terminal_var": 13.15626049041748, + "terminal_target": 5.551115123125783e-15, + "terminal_mean": 7.450580596923828e-09 + }, + "ring": { + "total": 967.452392578125, + "state_cost": 157.87513732910156, + "l2_reg_cost": 809.5772705078125, + "terminal_var": 13.15626049041748, + "terminal_target": 5.551115123125783e-15, + "terminal_mean": 7.450580596923828e-09 + }, + "trained": { + "total": 176.0917205810547, + "state_cost": 32.88923263549805, + "l2_reg_cost": 143.1961212158203, + "terminal_var": 1.5446259567397647e-05, + "terminal_target": 0.006367044057697058, + "terminal_mean": -0.00797937624156475 + } + }, + "r_initial": 2.8638169169425964e-08, + "r_final": { + "meanfield": 2.8638169169425964e-08, + "ring": 2.8638169169425964e-08, + "trained": 0.999992311000824 + }, + "var_final": { + "meanfield": 13.15626049041748, + "ring": 13.15626049041748, + "trained": 1.5446259567397647e-05 + }, + "terminal_mean_trained": -0.00797937624156475, + "vxi": { + "trained_max": 12.010483741760254, + "trained_final": 0.5435858964920044, + "ring": 43.59681701660156, + "meanfield": 0.0 + }, + "value_gap_meanfield_minus_trained": 101.78340148925781 +} \ No newline at end of file diff --git a/data/appendix/runs/e1_k2_T12/trajectories.npz b/data/appendix/runs/e1_k2_T12/trajectories.npz new file mode 100644 index 0000000..f3391fd Binary files /dev/null and b/data/appendix/runs/e1_k2_T12/trajectories.npz differ diff --git a/data/appendix/runs/e1_k2_T12_sig03/config.json b/data/appendix/runs/e1_k2_T12_sig03/config.json new file mode 100644 index 0000000..1dd06a8 --- /dev/null +++ b/data/appendix/runs/e1_k2_T12_sig03/config.json @@ -0,0 +1,25 @@ +{ + "k": 2, + "jitter": 0.1, + "ring_range": 0.1, + "x0_level": 0, + "N": 64, + "T": 12.0, + "dt": 0.05, + "sigma": 0.3, + "beta": 10.0, + "lambda_terminal": 0.0, + "gamma_target": 100.0, + "x_target": 0.0, + "batch_size": 16, + "num_steps": 2000, + "lr": 0.001, + "grad_clip": 1.0, + "num_fourier_freqs": 8, + "embed_dim": 64, + "hidden_embed": 128, + "hidden_score": 128, + "seed": 0, + "device": "cpu", + "time_chunk": 50 +} \ No newline at end of file diff --git a/data/appendix/runs/e1_k2_T12_sig03/metrics.json b/data/appendix/runs/e1_k2_T12_sig03/metrics.json new file mode 100644 index 0000000..04ea1a8 --- /dev/null +++ b/data/appendix/runs/e1_k2_T12_sig03/metrics.json @@ -0,0 +1,61 @@ +{ + "k": 2, + "costs": { + "meanfield": { + "total": 277.8751220703125, + "state_cost": 157.87513732910156, + "l2_reg_cost": 120.0, + "terminal_var": 13.15626049041748, + "terminal_target": 5.551115123125783e-15, + "terminal_mean": 7.450580596923828e-09 + }, + "ring": { + "total": 967.452392578125, + "state_cost": 157.87513732910156, + "l2_reg_cost": 809.5772705078125, + "terminal_var": 13.15626049041748, + "terminal_target": 5.551115123125783e-15, + "terminal_mean": 7.450580596923828e-09 + }, + "trained": { + "total": 190.85398864746094, + "state_cost": 31.176782608032227, + "l2_reg_cost": 159.62045288085938, + "terminal_var": 8.732880814932287e-06, + "terminal_target": 0.05675093084573746, + "terminal_mean": -0.02382245473563671 + } + }, + "r_initial": 2.8638169169425964e-08, + "r_final": { + "meanfield": 2.8638169169425964e-08, + "ring": 2.8638169169425964e-08, + "trained": 0.9999955892562866 + }, + "var_final": { + "meanfield": 13.15626049041748, + "ring": 13.15626049041748, + "trained": 8.732880814932287e-06 + }, + "winding_final": { + "meanfield": 2.0, + "ring": 2.0, + "trained": -7.589098771632052e-08 + }, + "terminal_mean_trained": -0.02382245473563671, + "vxi": { + "trained_max": 14.46484088897705, + "trained_final": 0.5247852206230164, + "ring": 43.59681701660156, + "meanfield": 0.0 + }, + "value_gap_meanfield_minus_trained": 87.02113342285156, + "noisy_eval": { + "sigma": 0.3, + "n_replicates": 32, + "r_final_mean": 0.9742251634597778, + "r_final_q10": 0.9684785008430481, + "r_final_q90": 0.9789402484893799, + "winding_final_mean": -8.300576226361045e-09 + } +} \ No newline at end of file diff --git a/data/appendix/runs/e1_k2_T12_sig03/trajectories.npz b/data/appendix/runs/e1_k2_T12_sig03/trajectories.npz new file mode 100644 index 0000000..a36feb7 Binary files /dev/null and b/data/appendix/runs/e1_k2_T12_sig03/trajectories.npz differ diff --git a/data/appendix/runs/e1_k3_T20/config.json b/data/appendix/runs/e1_k3_T20/config.json new file mode 100644 index 0000000..d723d8e --- /dev/null +++ b/data/appendix/runs/e1_k3_T20/config.json @@ -0,0 +1,25 @@ +{ + "k": 3, + "jitter": 0.1, + "ring_range": 0.1, + "x0_level": 0, + "N": 64, + "T": 20.0, + "dt": 0.05, + "sigma": 0.0, + "beta": 10.0, + "lambda_terminal": 0.0, + "gamma_target": 100.0, + "x_target": 0.0, + "batch_size": 16, + "num_steps": 3000, + "lr": 0.001, + "grad_clip": 1.0, + "num_fourier_freqs": 8, + "embed_dim": 64, + "hidden_embed": 128, + "hidden_score": 128, + "seed": 0, + "device": "cpu", + "time_chunk": 50 +} \ No newline at end of file diff --git a/data/appendix/runs/e1_k3_T20/metrics.json b/data/appendix/runs/e1_k3_T20/metrics.json new file mode 100644 index 0000000..e293025 --- /dev/null +++ b/data/appendix/runs/e1_k3_T20/metrics.json @@ -0,0 +1,53 @@ +{ + "k": 3, + "costs": { + "meanfield": { + "total": 792.03173828125, + "state_cost": 592.03173828125, + "l2_reg_cost": 200.0, + "terminal_var": 29.60158348083496, + "terminal_target": 2.7200464103316335e-13, + "terminal_mean": 5.21540641784668e-08 + }, + "ring": { + "total": 1941.3271484375, + "state_cost": 592.03173828125, + "l2_reg_cost": 1349.29541015625, + "terminal_var": 29.60158348083496, + "terminal_target": 2.7200464103316335e-13, + "terminal_mean": 5.21540641784668e-08 + }, + "trained": { + "total": 368.6206359863281, + "state_cost": 108.70561981201172, + "l2_reg_cost": 259.89556884765625, + "terminal_var": 3.602856324391723e-08, + "terminal_target": 0.0194246806204319, + "terminal_mean": 0.01393724512308836 + } + }, + "r_initial": 6.3773923919541176e-09, + "r_final": { + "meanfield": 6.3773923919541176e-09, + "ring": 6.3773923919541176e-09, + "trained": 0.9999999403953552 + }, + "var_final": { + "meanfield": 29.60158348083496, + "ring": 29.60158348083496, + "trained": 3.602856324391723e-08 + }, + "winding_final": { + "meanfield": 3.000000238418579, + "ring": 3.000000238418579, + "trained": -3.794549385816026e-08 + }, + "terminal_mean_trained": 0.01393724512308836, + "vxi": { + "trained_max": 24.421634674072266, + "trained_final": 1.3595699071884155, + "ring": 43.59681701660156, + "meanfield": 0.0 + }, + "value_gap_meanfield_minus_trained": 423.4111022949219 +} \ No newline at end of file diff --git a/data/appendix/runs/e1_k3_T20/trajectories.npz b/data/appendix/runs/e1_k3_T20/trajectories.npz new file mode 100644 index 0000000..7e8a5c5 Binary files /dev/null and b/data/appendix/runs/e1_k3_T20/trajectories.npz differ diff --git a/data/appendix/runs/e2_eps1_T10/config.json b/data/appendix/runs/e2_eps1_T10/config.json new file mode 100644 index 0000000..f0ad3c2 --- /dev/null +++ b/data/appendix/runs/e2_eps1_T10/config.json @@ -0,0 +1,30 @@ +{ + "eps": 1.0, + "gain": 4.0, + "phi_form": "poly", + "span": 6.0, + "amp": 0.5, + "modes": 4, + "jitter": 0.1, + "band_range": 0.1, + "N": 64, + "T": 10.0, + "dt": 0.05, + "sigma": 0.0, + "beta": 1.0, + "lambda_terminal": 0.0, + "gamma_target": 100.0, + "x_target": 0.0, + "batch_size": 16, + "num_steps": 2000, + "lr": 0.001, + "grad_clip": 1.0, + "num_fourier_freqs": 8, + "embed_dim": 64, + "hidden_embed": 128, + "hidden_score": 128, + "seed": 0, + "device": "cpu", + "time_chunk": 50, + "hk_ref": false +} \ No newline at end of file diff --git a/data/appendix/runs/e2_eps1_T10/metrics.json b/data/appendix/runs/e2_eps1_T10/metrics.json new file mode 100644 index 0000000..7069850 --- /dev/null +++ b/data/appendix/runs/e2_eps1_T10/metrics.json @@ -0,0 +1,70 @@ +{ + "eps": 1.0, + "gain": 4.0, + "span": 6.0, + "beta": 1.0, + "costs": { + "meanfield": { + "total": 38.07704544067383, + "state_cost": 28.077045440673828, + "l2_reg_cost": 10.0, + "terminal_var": 2.8000376224517822, + "terminal_target": 1.6792123247455493e-13, + "terminal_mean": -4.0978193283081055e-08 + }, + "band": { + "total": 98.60784912109375, + "state_cost": 27.191635131835938, + "l2_reg_cost": 71.41621398925781, + "terminal_var": 2.7036962509155273, + "terminal_target": 2.220446049250313e-14, + "terminal_mean": -1.4901161193847656e-08 + }, + "trained": { + "total": 17.929262161254883, + "state_cost": 4.267096519470215, + "l2_reg_cost": 13.661956787109375, + "terminal_var": 5.283037136253677e-20, + "terminal_target": 0.00020805203530471772, + "terminal_mean": -0.001442400855012238 + } + }, + "var_final": { + "meanfield": 2.8000376224517822, + "band": 2.7036962509155273, + "trained": 5.283037136253677e-20 + }, + "K_final": { + "meanfield": 4, + "band": 4, + "trained": 1 + }, + "clusters_meanfield_T": [ + { + "center": -2.2444586753845215, + "mass": 0.25 + }, + { + "center": -0.7499877214431763, + "mass": 0.25 + }, + { + "center": 0.7499878406524658, + "mass": 0.25 + }, + { + "center": 2.2444581985473633, + "mass": 0.25 + } + ], + "freeze_time_meanfield": 1.85, + "vxi": { + "band": 43.284034729003906, + "meanfield": 0.0, + "trained_max": 46.334083557128906, + "trained_final": 1.622012972831726 + }, + "consensus_time_trained": 4.0, + "terminal_mean_trained": -0.001442400855012238, + "value_gap_meanfield_minus_trained": 20.147783279418945 +} \ No newline at end of file diff --git a/data/appendix/runs/e2_eps1_T10/trajectories.npz b/data/appendix/runs/e2_eps1_T10/trajectories.npz new file mode 100644 index 0000000..9728ce6 Binary files /dev/null and b/data/appendix/runs/e2_eps1_T10/trajectories.npz differ diff --git a/docs/APPENDIX_REPRODUCTION.md b/docs/APPENDIX_REPRODUCTION.md new file mode 100644 index 0000000..0b7d98a --- /dev/null +++ b/docs/APPENDIX_REPRODUCTION.md @@ -0,0 +1,79 @@ +# Reproducing the existing numerical appendix + +The original saved results are bundled in `data/appendix/` (about 17 MB). +`appendix_manifest.json` selects the five runs and historical figures by path +and SHA-256. Reproduction uses saved arrays only: no training, checkpoint +loading, or integration of the dynamics. + +| Manifest key | Original run | Appendix result | +| --- | --- | --- | +| `bc` | `e2_eps1_T10` | bounded-confidence table and two figures | +| `k1` | `e1_k1_T5` | depth-comparison table, k=1 | +| `k2` | `e1_k2_T12` | depth-comparison table and noiseless reference, k=2 | +| `k3` | `e1_k3_T20` | headline Kuramoto table and two figures, k=3 | +| `noise` | `e1_k2_T12_sig03` | noisy Kuramoto ensemble panel | + +Each run includes its unchanged `config.json`, `metrics.json`, and +`trajectories.npz`. Checkpoints and training histories are unnecessary for +this workflow and are omitted. The reference folder contains only the five +numerical figure PDFs; manuscript sources are not included. + +All runs record N=64, dt=0.05, seed 0 and training jitter 0.1. Deterministic +evaluations use the exact initial profiles stated in the appendix. BC and the +k=1/2 Kuramoto runs use 2,000 Adam updates; k=3 uses 3,000. The archived results +match the rounded entries in the three appendix tables. + +## Regenerate from saved arrays + +Install the package from the repository root as described in +[DEVELOPMENT.md](DEVELOPMENT.md), then run: + +```sh +python reproduce_appendix.py \ + --manifest appendix_manifest.json \ + --output out/appendix-regenerated +``` + +The command checks input hashes before creating a new output directory and +writes five figure PDFs plus: + +- `tables.csv`: recomputed statistics for both paper arms in all four deterministic runs. +- `verification.json`: 131 checks against archived metrics and diagnostic arrays, plus objective rounding checks. +- `noise_summary.json`: retained ensemble statistics and their limitations. +- `provenance.json`: input and output hashes for this export. + +Diagnostics and objectives are recomputed by arithmetic on archived state and +kernel arrays. Small differences from stored metrics arise when float64 +reductions replace the original float32 reductions. The tests also compare the +other rounded table entries with the bundled results. Existing output +directories are refused; choose a new name for another export. Generated +outputs under `out/` are ignored by Git. + +## Recover the exact historical PDFs + +```sh +./collect_paper_figures.sh appendix_manifest.json +``` + +This copies the selected historical PDFs into `out/appendix-reference/`. +The reference PDFs match the manuscript assets byte-for-byte. The original +BC run's kernel PDF differed from its figure mirror only in one creation-date +byte; the bundle retains the matching mirror. Regenerated figures match the +reference renders at 100 dpi in the tested environment. PDF creation dates and +rendering-library versions can change file bytes; use the reference copies +when identical PDF files are required. + +The standalone combined-panel exporter is also available: + +```sh +python export_appendix_panel.py \ + --run data/appendix/runs/e1_k3_T20 \ + --output out/appendix-panel-review/kur_rw_panel.pdf +``` + +## Archive limitation + +The noisy run retains ensemble means and 10–90% quantiles, not the 32 individual +paths. Its panel and terminal mean r=0.974 are reproducible from those aggregates. +The statement that every individual path has terminal winding zero cannot be +independently checked from the retained files. Missing paths are not synthesized. diff --git a/export_appendix_panel.py b/export_appendix_panel.py new file mode 100644 index 0000000..a88407e --- /dev/null +++ b/export_appendix_panel.py @@ -0,0 +1,135 @@ +"""Export a combined Kuramoto panel from an explicitly selected saved run. + +Only config.json and trajectories.npz are read. This command neither identifies +a run as a manuscript result nor reconstructs missing numerical data. +""" + +import argparse +import hashlib +import io +import json +import os +from pathlib import Path +import tempfile + +import numpy as np + +from graphon_control.experiments.kuramoto_plots import fig_rw_panel + + +ARMS = ("meanfield", "trained") + + +def _vector(value, name): + if np.iscomplexobj(value): + raise ValueError(f"{name} must be real") + value = np.asarray(value, dtype=float) + if value.ndim != 1 or not np.isfinite(value).all(): + raise ValueError(f"{name} must be a finite vector") + return value + + +def export_panel(run_dir, output): + """Write a new PDF and export sidecar, preserving the selected source run.""" + run_dir = Path(run_dir).expanduser().resolve() + inputs = {"config": run_dir / "config.json", + "trajectories": run_dir / "trajectories.npz"} + output = Path(output).expanduser().absolute() + sidecar = output.with_suffix(output.suffix + ".provenance.json") + for destination in (output, sidecar): + if destination.resolve() in {source.resolve() for source in inputs.values()}: + raise ValueError(f"Output collides with a source artifact: {destination}") + if destination.exists() or destination.is_symlink(): + raise FileExistsError(f"Output already exists: {destination}") + if output.suffix.lower() != ".pdf": + raise ValueError("--output must name a new .pdf file") + + # Read once: hashes describe the same bytes used to load the figure inputs. + payloads = {name: source.read_bytes() for name, source in inputs.items()} + config = json.loads(payloads["config"]) + if not isinstance(config, dict): + raise ValueError("config.json must contain a JSON object") + missing = {"k", "N", "T", "dt"} - set(config) + if missing: + raise ValueError(f"Missing configuration fields: {sorted(missing)}") + k, N = config["k"], config["N"] + if (isinstance(k, bool) or not isinstance(k, int) or k < 1 + or isinstance(N, bool) or not isinstance(N, int) or N <= 2 * k): + raise ValueError("Configuration must have integer k >= 1 and N > 2*k") + for name in ("T", "dt"): + value = config[name] + if (isinstance(value, bool) or not isinstance(value, (int, float)) + or not np.isfinite(value) or value <= 0): + raise ValueError(f"Configuration {name} must be finite and positive") + T, dt = config["T"], config["dt"] + steps = T / dt + if not np.isfinite(steps) or not np.isclose(steps, round(steps), rtol=1e-10, atol=1e-10): + raise ValueError("Configuration T/dt must be an integer") + NT = round(steps) + keys = {"t", "xi", *(f"{name}_{arm}" for name in ("r", "winding") for arm in ARMS)} + with np.load(io.BytesIO(payloads["trajectories"]), allow_pickle=False) as archive: + missing = keys - set(archive.files) + if missing: + raise ValueError(f"Missing saved arrays: {sorted(missing)}") + data = {key: _vector(archive[key], key) for key in keys} + t, xi = data["t"], data["xi"] + if (t.shape != (NT + 1,) or len(t) < 2 or not np.all(np.diff(t) > 0) + or not np.allclose(t, np.arange(NT + 1) * dt, rtol=1e-7, atol=1e-8)): + raise ValueError("Saved t grid does not match configuration T and dt") + if (xi.shape != (N,) or not np.all(np.diff(xi) > 0) + or np.any((xi < 0) | (xi > 1))): + raise ValueError("Saved xi grid does not match configuration N on [0, 1]") + for name in ("r", "winding"): + for arm in ARMS: + values = data[f"{name}_{arm}"] + if values.shape != t.shape: + raise ValueError(f"Saved {name}_{arm} does not match the time grid") + if name == "r" and np.any((values < -1e-6) | (values > 1 + 1e-6)): + raise ValueError(f"Saved r_{arm} must lie in [0, 1]") + if name == "winding" and not np.isclose(values[0], k, rtol=0, atol=1e-5): + raise ValueError(f"Saved winding_{arm} does not start at configuration k") + + provenance = { + "schema_version": 1, + "operation": "saved_array_export", + "inputs": {name: {"path": str(inputs[name]), + "sha256": hashlib.sha256(payload).hexdigest()} + for name, payload in payloads.items()}, + "selection": {"arms": list(ARMS), "k": k, "N": N, "T": T, "dt": dt}, + } + output.parent.mkdir(parents=True, exist_ok=True) + with tempfile.TemporaryDirectory(prefix=".appendix-panel-", dir=output.parent) as stage: + staged_pdf = Path(stage) / output.name + fig_rw_panel(t, {arm: data[f"r_{arm}"] for arm in ARMS}, + {arm: data[f"winding_{arm}"] for arm in ARMS}, k, staged_pdf) + provenance["output"] = {"path": str(output), + "sha256": hashlib.sha256(staged_pdf.read_bytes()).hexdigest()} + staged_sidecar = Path(stage) / sidecar.name + staged_sidecar.write_text(json.dumps(provenance, indent=2, sort_keys=True, + allow_nan=False) + "\n", encoding="utf-8") + # Exclusive publication also refuses destinations created after preflight. + os.link(staged_pdf, output) + try: + os.link(staged_sidecar, sidecar) + except OSError: + if output.exists() and output.samefile(staged_pdf): + output.unlink() + raise + return output, sidecar + + +def main(argv=None): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--run", type=Path, required=True, help="existing archived run directory") + parser.add_argument("--output", type=Path, required=True, help="new PDF path") + args = parser.parse_args(argv) + try: + paths = export_panel(args.run, args.output) + except (OSError, ValueError) as exc: + parser.exit(1, f"{exc}\n") + for path in paths: + print(path) + + +if __name__ == "__main__": + main() diff --git a/graphon_control/experiments/kuramoto_plots.py b/graphon_control/experiments/kuramoto_plots.py index 2eae907..e7fd7a1 100644 --- a/graphon_control/experiments/kuramoto_plots.py +++ b/graphon_control/experiments/kuramoto_plots.py @@ -1,5 +1,6 @@ """Model-specific figures, loaded only when rendering archived runs.""" import math +from collections.abc import Mapping from typing import Dict, List import numpy as np import torch @@ -56,6 +57,64 @@ def fig_r_combined(t_grid, r_series, k: int, path: str, arms: List[str] = None): plt.close(fig) +def fig_rw_panel(t_grid, r_series: Dict[str, np.ndarray], + winding_series: Dict[str, np.ndarray], k: int, path: str): + """Plot archived order parameters and winding numbers without a rollout. + + Both mappings must contain mean-field and trained curves and the same arms. + Every supplied arm is shown; the optional ring baseline retains its style. + """ + if isinstance(k, bool) or not isinstance(k, (int, np.integer)) or k < 1: + raise ValueError("k must be a positive integer") + t_grid = np.asarray(t_grid, dtype=float) + if (t_grid.ndim != 1 or len(t_grid) < 2 or not np.isfinite(t_grid).all() + or not np.all(np.diff(t_grid) > 0)): + raise ValueError("t_grid must be a finite, strictly increasing vector") + if not isinstance(r_series, Mapping) or not isinstance(winding_series, Mapping): + raise ValueError("r_series and winding_series must be arm mappings") + if set(r_series) != set(winding_series): + raise ValueError("Order-parameter and winding mappings must contain the same arms") + if not set(PAPER_ARMS).issubset(r_series): + raise ValueError("Both meanfield and trained arms are required") + unknown = set(r_series) - set(ARM_LABELS) + if unknown: + raise ValueError(f"Unknown arms: {sorted(unknown)}") + arms = [arm for arm in ARM_LABELS if arm in r_series] + curves = [] + for name, series in (("r", r_series), ("winding", winding_series)): + values = {} + for arm in arms: + value = np.asarray(series[arm], dtype=float) + if value.shape != t_grid.shape or not np.isfinite(value).all(): + raise ValueError(f"Invalid {name} curve for {arm}: expected finite values matching t_grid") + if name == "r" and np.any((value < -1e-6) | (value > 1 + 1e-6)): + raise ValueError(f"Order parameter for {arm} must lie in [0, 1]") + values[arm] = value + curves.append(values) + + fig, axes = plt.subplots(1, 2, figsize=(11, 3.8)) + try: + for ax, series in zip(axes, curves): + for arm in arms: + ax.plot(t_grid, series[arm], color=ARM_COLORS[arm], lw=2.0, + label=ARM_LABELS[arm]) + ax.set_xlabel(r"$t$") + ax.grid(alpha=0.2) + axes[0].set_ylabel(r"order parameter $r(t)$") + axes[0].set_ylim(-0.03, 1.03) + for q in range(k + 1): + axes[1].axhline(q, color="0.85", lw=0.8, zorder=0) + axes[1].set_ylabel(r"winding number $\mathcal{W}(t)$") + axes[1].set_ylim(min(-0.25, min(v.min() for v in curves[1].values()) - 0.05), + max(k + 0.25, max(v.max() for v in curves[1].values()) + 0.05)) + axes[0].legend(loc="upper left", fontsize=9) + fig.suptitle(rf"Untwisting the $k = {k}$ twisted state ($T = {t_grid[-1]:g}$)", y=1.02) + fig.tight_layout() + fig.savefig(path, bbox_inches="tight") + finally: + plt.close(fig) + + def fig_winding(t_grid, wind_series: Dict[str, np.ndarray], k: int, path: str, arms: List[str] = None): fig, ax = plt.subplots(figsize=(6.4, 4.0)) diff --git a/reproduce_appendix.py b/reproduce_appendix.py new file mode 100644 index 0000000..0b12192 --- /dev/null +++ b/reproduce_appendix.py @@ -0,0 +1,322 @@ +"""Export the existing appendix from a hash-pinned archive, without simulation. + +The manifest records the original five runs. Only JSON and NPZ contents are +interpreted; optional checkpoint, history, and summary files are hashed as bytes. +This command never deserializes a controller or executes training or dynamics. +""" + +import argparse +import csv +import hashlib +import io +import json +import os +from pathlib import Path +import shutil +import tempfile + +import numpy as np + +RUN_NAMES = ("bc", "k1", "k2", "k3", "noise") +INPUT_FILES = ("config.json", "metrics.json", "trajectories.npz") +ARMS = ("meanfield", "trained") +RTOL, ATOL = 2e-5, 1e-6 # archived computations used float32 + + +def sha256(data): + return hashlib.sha256(data).hexdigest() + + +def _json(path, value): + path.write_text(json.dumps(value, indent=2, allow_nan=False) + "\n", encoding="utf-8") + + +def load_manifest(manifest_path): + """Capture and hash every input before parsing arrays or creating outputs.""" + manifest_path = Path(manifest_path).expanduser().resolve() + raw_manifest = manifest_path.read_bytes() + manifest = json.loads(raw_manifest) + if manifest.get("schema_version") != 1 or set(manifest.get("runs", {})) != set(RUN_NAMES): + raise ValueError("Manifest must have schema_version 1 and runs bc, k1, k2, k3, noise") + captured, inputs = {}, {} + for name in RUN_NAMES: + spec = manifest["runs"][name] + directory = Path(spec["path"]).expanduser() + if not directory.is_absolute(): + directory = manifest_path.parent / directory + directory = directory.resolve() + if not set(INPUT_FILES).issubset(spec.get("sha256", {})): + raise ValueError(f"{name}: manifest must pin all three input files") + captured[name], inputs[name] = {}, {} + for filename in spec["sha256"]: + if Path(filename).name != filename: + raise ValueError(f"{name}: hash keys must be plain filenames") + path = directory / filename + raw = path.read_bytes() + digest = sha256(raw) + if digest != spec["sha256"][filename]: + raise ValueError(f"SHA-256 mismatch: {name}/{filename}") + if filename in INPUT_FILES: + captured[name][filename] = raw + inputs[name][filename] = {"path": str(path), "sha256": digest, "bytes": len(raw)} + runs = {} + for name in RUN_NAMES: + payload = captured[name] + with np.load(io.BytesIO(payload["trajectories.npz"]), allow_pickle=False) as archive: + arrays = {key: archive[key] for key in archive.files} + runs[name] = {"config": json.loads(payload["config.json"]), + "metrics": json.loads(payload["metrics.json"]), "arrays": arrays} + return runs, {"manifest": {"path": str(manifest_path), "sha256": sha256(raw_manifest)}, + "inputs": inputs} + + +def _require_config(name, config): + expected = {"N": 64, "dt": .05, "lambda_terminal": 0., "gamma_target": 100., + "x_target": 0., "jitter": .1, "batch_size": 16, "lr": .001, + "num_fourier_freqs": 8, "embed_dim": 64, "hidden_embed": 128, + "hidden_score": 128, "num_steps": 3000 if name == "k3" else 2000} + if name == "bc": + expected.update(T=10., sigma=0., beta=1., eps=1., gain=4., phi_form="poly", + span=6., amp=.5, modes=4) + else: + k = 2 if name == "noise" else int(name[1:]) + expected.update(k=k, T={1: 5., 2: 12., 3: 20.}[k], beta=10., + sigma=.3 if name == "noise" else 0.) + for field, value in expected.items(): + if field not in config or isinstance(config[field], bool) or config[field] != value: + raise ValueError(f"{name}: original scientific setting {field} must be recorded as {value!r}") + if config.get("score_bound") is not None or config.get("uncontrolled_only", False): + raise ValueError(f"{name}: configuration differs from the archived original protocol") + + +def _array(data, key, shape, name): + if key not in data: + raise ValueError(f"{name}: missing saved array {key}") + value = np.asarray(data[key]) + if value.shape != shape or value.dtype.kind not in "fiu" or not np.isfinite(value).all(): + raise ValueError(f"{name}: invalid saved array {key}; expected finite numeric {shape}") + return value.astype(np.float64) + + +def _check(name, field, actual, recorded, checks): + actual = np.asarray(actual) + recorded = np.asarray(recorded) + if actual.shape != recorded.shape or not np.isfinite(recorded).all(): + raise ValueError(f"{name}: invalid recorded metric {field}") + error = float(np.max(np.abs(actual - recorded))) + passed = bool(np.allclose(actual, recorded, rtol=RTOL, atol=ATOL)) + checks.append({"field": field, "passed": passed, "max_absolute_error": error}) + if not passed: + raise ValueError(f"{name}: saved arrays disagree with {field} (max error {error:g})") + + +def winding(x): + gaps = np.roll(x, -1, axis=-1) - x + return (((gaps + np.pi) % (2 * np.pi)) - np.pi).sum(axis=-1) / (2 * np.pi) + + +def diagnostics(name, run): + """Recompute objective and observables directly from the archived arrays.""" + c, m, d = run["config"], run["metrics"], run["arrays"] + _require_config(name, c) + nt, n, dt = round(c["T"] / c["dt"]), c["N"], c["dt"] + checks, report = [], {} + t = _array(d, "t", (nt + 1,), name) + if not np.allclose(t, np.arange(nt + 1) * dt, rtol=0, atol=1e-10): + raise ValueError(f"{name}: time grid does not match recorded T and dt") + tw = _array(d, "t_W", (nt,), name) + if not np.allclose(tw, t[:-1], rtol=0, atol=1e-10): + raise ValueError(f"{name}: kernel time grid does not match saved states") + xi = _array(d, "xi", (n,), name) + if not np.allclose(xi, (np.arange(n) + .5) / n, rtol=0, atol=1e-7): + raise ValueError(f"{name}: expected original midpoint label grid") + ws = _array(d, "W_trained", (nt, n, n), name) + if np.min(ws) < -1e-7 or not np.allclose(ws.sum(-1), 1, rtol=0, atol=1e-5): + raise ValueError(f"{name}: archived trained kernels are not row stochastic") + vxi = np.sqrt(np.mean((n * np.diff(ws, axis=1)) ** 2, axis=-1)).sum(-1) + _check(name, "vxi_trained", vxi, _array(d, "vxi_trained", (nt,), name), checks) + _check(name, "vxi.trained_max", vxi.max(), m["vxi"]["trained_max"], checks) + _check(name, "vxi.trained_final", vxi[-1], m["vxi"]["trained_final"], checks) + for arm in ARMS: + x = _array(d, "x_" + arm, (nt + 1, n), name) + expected_initial = (c["span"] * (xi - .5) + c["amp"] * np.sin(2 * np.pi * c["modes"] * xi) + if name == "bc" else 2 * np.pi * c["k"] * (xi - .5)) + if not np.allclose(x[0], expected_initial, rtol=1e-6, atol=1e-6): + raise ValueError(f"{name}: archived initial state does not match the appendix profile") + means = x.mean(-1) + var = ((x - means[:, None]) ** 2).mean(-1) + _check(name, "var_" + arm, var, _array(d, "var_" + arm, (nt + 1,), name), checks) + _check(name, "var_final." + arm, var[-1], m["var_final"][arm], checks) + state = float(dt * var[:-1].sum()) + l2 = float(dt * c["beta"] * np.square(ws).sum()) if arm == "trained" else float(c["beta"] * c["T"]) + target = float(c["gamma_target"] * (means[-1] - c["x_target"]) ** 2) + values = {"total": state + l2 + c["lambda_terminal"] * var[-1] + target, + "state_cost": state, "l2_reg_cost": l2, "terminal_var": float(var[-1]), + "terminal_target": target, "terminal_mean": float(means[-1])} + for field, value in values.items(): + _check(name, f"costs.{arm}.{field}", value, m["costs"][arm][field], checks) + result = {**values, "max_vxi": float(vxi.max()) if arm == "trained" else 0.} + if name != "bc": + r, w = np.abs(np.exp(1j * x).mean(-1)), winding(x) + _check(name, "r_" + arm, r, _array(d, "r_" + arm, (nt + 1,), name), checks) + _check(name, "winding_" + arm, w, _array(d, "winding_" + arm, (nt + 1,), name), checks) + _check(name, "r_final." + arm, r[-1], m["r_final"][arm], checks) + if "winding_final" in m: + _check(name, "winding_final." + arm, w[-1], m["winding_final"][arm], checks) + result.update(r_final=float(r[-1]), winding_final=float(w[-1])) + else: + clusters = 1 + int(np.count_nonzero(np.diff(np.sort(x[-1])) > c["eps"])) + saved_clusters = _array(d, "K_" + arm, (nt + 1,), name) + _check(name, "K_" + arm + "[-1]", clusters, saved_clusters[-1], checks) + if "K_final" in m: + _check(name, "K_final." + arm, clusters, m["K_final"][arm], checks) + result["clusters_final"] = clusters + report[arm] = result + if name == "noise": + for key in ("r", "winding"): + for suffix in ("mean", "q10", "q90"): + a = _array(d, f"{key}_noisy_{suffix}", (nt + 1,), name) + if key == "r" and (a.min() < -1e-6 or a.max() > 1 + 1e-6): + raise ValueError("noise: order parameter must lie in [0, 1]") + if np.any(d[f"{key}_noisy_q10"] > d[f"{key}_noisy_q90"]): + raise ValueError(f"noise: inverted {key} quantile band") + for metric, array in (("r_final_mean", "r_noisy_mean"), ("r_final_q10", "r_noisy_q10"), + ("r_final_q90", "r_noisy_q90"), ("winding_final_mean", "winding_noisy_mean")): + _check(name, "noisy_eval." + metric, float(d[array][-1]), m["noisy_eval"][metric], checks) + if m["noisy_eval"]["sigma"] != .3 or m["noisy_eval"]["n_replicates"] != 32: + raise ValueError("noise: expected recorded sigma=.3 and 32 replicates") + expected_j = {"bc": (38.08, 17.93), "k1": (66.45, 57.52), "k2": (277.88, 176.09), + "k3": (792.03, 368.62)} + rounding = {} + if name in expected_j: + for arm, expected in zip(ARMS, expected_j[name]): + rounding["J_" + arm] = {"expected": expected, "rounded_recomputed": round(report[arm]["total"], 2), + "matches": bool(round(report[arm]["total"], 2) == expected)} + rounding_matches = all(check["matches"] for check in rounding.values()) + return {"checks": checks, "recomputed": report, "saved_array_checks_passed": True, + "passed": rounding_matches, "appendix_rounding": rounding, + "status": "verified" if rounding_matches else "appendix_rounding_mismatch"} + + +def _noise_figure(noisy, clean, path): + # Same saved aggregate series and layout as e1_noise_figure; no file reloads. + import matplotlib + matplotlib.use("Agg") + import matplotlib.pyplot as plt + d, ref = noisy["arrays"], clean["arrays"] + c = noisy["config"] + fig, axes = plt.subplots(1, 2, figsize=(11, 3.8)) + try: + for ax, (key, ylabel, ylim) in zip(axes, [ + ("r", r"order parameter $r(t)$", (-.03, 1.03)), + ("winding", r"winding number $\mathcal{W}(t)$", (-.25, c["k"] + .25))]): + ax.fill_between(d["t"], d[f"{key}_noisy_q10"], d[f"{key}_noisy_q90"], + color="tab:blue", alpha=.25, label="noisy ensemble, 10–90%") + ax.plot(d["t"], d[f"{key}_noisy_mean"], color="tab:blue", lw=2, + label=rf"noisy ensemble mean ($\sigma = {c['sigma']:g}$)") + ax.plot(ref["t"], ref[f"{key}_trained"], color=".25", lw=1.8, ls="--", + label=r"deterministic ($\sigma = 0$)") + if key == "winding": + for q in range(c["k"] + 1): + ax.axhline(q, color=".85", lw=.8, zorder=0) + ax.set(xlabel=r"$t$", ylabel=ylabel, ylim=ylim) + ax.grid(alpha=.2) + axes[0].legend(loc="lower right", fontsize=9) + fig.suptitle(rf"Trained control under noise ($k = {c['k']}$, $T = {c['T']:g}$)", y=1.02) + fig.tight_layout() + fig.savefig(path, bbox_inches="tight") + finally: + plt.close(fig) + + +def export_appendix(manifest_path, output): + output = Path(output).expanduser().absolute() + if output.exists() or output.is_symlink(): + raise FileExistsError(f"Output directory already exists: {output}") + runs, provenance = load_manifest(manifest_path) + verification = {name: diagnostics(name, run) for name, run in runs.items()} + # Compare recorded data directly: historical configs did not all record x0_level. + for arm in ARMS: + if not np.array_equal(runs["noise"]["arrays"]["x_" + arm][0], + runs["k2"]["arrays"]["x_" + arm][0]): + raise ValueError("Noise and clean reference have different archived initial data") + noise_summary = { + **runs["noise"]["metrics"]["noisy_eval"], + "source": "archived ensemble mean and 10th/90th percentiles", + "individual_noisy_paths_saved": False, + "all_paths_zero_terminal_winding_verified": False, + "limitation": "Individual path arrays are absent; aggregate winding statistics cannot verify every path.", + } + from graphon_control.experiments.bounded_confidence import E2Config + from graphon_control.experiments import bounded_confidence_plots as bc_plots + from graphon_control.experiments import kuramoto_plots as kur_plots + import torch + output.parent.mkdir(parents=True, exist_ok=True) + stage = Path(tempfile.mkdtemp(prefix=".appendix-stage-", dir=output.parent)) + try: + bc, k3 = runs["bc"]["arrays"], runs["k3"]["arrays"] + cfg = E2Config(**runs["bc"]["config"]) + bc_plots.fig_opinion_traj(bc["t"], bc["xi"], {a: bc["x_" + a] for a in ARMS}, + list(ARMS), cfg, str(stage / "bc_opinion_traj.pdf")) + bc_plots.fig_kernel_strip(torch.from_numpy(bc["W_trained"]), cfg.dt, + str(stage / "bc_kernel_strip.pdf"), times=[0, .1*cfg.T, .2*cfg.T, .3*cfg.T, .4*cfg.T, cfg.T]) + kur_plots.fig_rw_panel(k3["t"], {a: k3["r_" + a] for a in ARMS}, + {a: k3["winding_" + a] for a in ARMS}, 3, str(stage / "kur_rw_panel.pdf")) + kur_plots.fig_kernel_strip(torch.from_numpy(k3["W_trained"]), runs["k3"]["config"]["dt"], + str(stage / "kur_kernel_strip.pdf")) + _noise_figure(runs["noise"], runs["k2"], stage / "kur_noise_panel.pdf") + rows = [] + for name in ("bc", "k1", "k2", "k3"): + for arm in ARMS: + d = verification[name]["recomputed"][arm] + rows.append({"run": name, "arm": arm, "J": d["total"], "terminal_variance": d["terminal_var"], + "terminal_mean": d["terminal_mean"], "max_Vxi": d["max_vxi"], + "r_final": d.get("r_final", ""), "winding_final": d.get("winding_final", ""), + "clusters_final": d.get("clusters_final", "")}) + with (stage / "tables.csv").open("w", newline="", encoding="utf-8") as stream: + writer = csv.DictWriter(stream, fieldnames=list(rows[0])) + writer.writeheader() + writer.writerows(rows) + _json(stage / "noise_summary.json", noise_summary) + _json(stage / "verification.json", {"operation": "saved_array_verification", "new_experiments": False, + "float32_comparison_tolerance": {"rtol": RTOL, "atol": ATOL}, "runs": verification, + "noise_individual_paths": noise_summary["limitation"]}) + provenance.update(operation="saved_array_appendix_export", new_experiments=False, + checkpoint_loads=False, outputs={p.name: {"sha256": sha256(p.read_bytes()), "bytes": p.stat().st_size} + for p in sorted(stage.iterdir())}) + _json(stage / "provenance.json", provenance) + # mkdir is exclusive, including when another writer races after validation. + output.mkdir() + published = [] + try: + for path in stage.iterdir(): + target = output / path.name + os.link(path, target) + published.append(target) + except BaseException: + for path in published: + path.unlink() + try: + output.rmdir() + except OSError: + pass # preserve unrelated concurrent contents + raise + finally: + shutil.rmtree(stage) + return output + + +def main(argv=None): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--manifest", required=True, type=Path) + parser.add_argument("--output", required=True, type=Path, help="new directory; existing outputs are never replaced") + args = parser.parse_args(argv) + try: + output = export_appendix(args.manifest, args.output) + except (OSError, ValueError, KeyError, TypeError) as exc: + parser.exit(1, f"{exc}\n") + print(f"Exported saved appendix data to {output}") + + +if __name__ == "__main__": + main() diff --git a/tests/test_appendix_panel_cli.py b/tests/test_appendix_panel_cli.py new file mode 100644 index 0000000..245f478 --- /dev/null +++ b/tests/test_appendix_panel_cli.py @@ -0,0 +1,174 @@ +"""Saved-array CLI checks; all numerical values below are handwritten fixtures.""" + +import hashlib +import json + +import numpy as np +import pytest +import torch + +import export_appendix_panel as exporter +from graphon_control import artifacts, dynamics, training + + +def _write_run(directory, *, missing_config=None, missing_array=None, update=None): + directory.mkdir() + config = {"k": 1, "N": 8, "T": 1.0, "dt": 0.5} + data = {"t": np.array([0.0, 0.5, 1.0]), "xi": (np.arange(8) + 0.5) / 8, + "r_meanfield": np.zeros(3), "r_trained": np.array([0.0, 0.6, 1.0]), + "winding_meanfield": np.ones(3), "winding_trained": np.array([1.0, 1.0, 0.0]), + "r_ring": np.zeros(3), "winding_ring": np.ones(3)} + if missing_config: + del config[missing_config] + if missing_array: + del data[missing_array] + if update: + update(config, data) + (directory / "config.json").write_text(json.dumps(config)) + np.savez(directory / "trajectories.npz", **data) + return directory + + +def _hashes(directory): + return {path.name: hashlib.sha256(path.read_bytes()).hexdigest() + for path in directory.iterdir() if path.is_file()} + + +@pytest.fixture(autouse=True) +def forbid_experiment_calls(monkeypatch): + def forbidden(*args, **kwargs): + raise AssertionError("Saved-array export must not train, evaluate dynamics, or load a checkpoint") + + monkeypatch.setattr(training, "train_controller", forbidden) + monkeypatch.setattr(dynamics, "rollout_with_Ws", forbidden) + monkeypatch.setattr(dynamics, "rollout_dynamics_w1", forbidden) + monkeypatch.setattr(dynamics, "precompute_Ws", forbidden) + monkeypatch.setattr(artifacts, "load_controller", forbidden) + monkeypatch.setattr(torch, "load", forbidden) + + +def test_cli_exports_selected_saved_curves_and_new_operation_provenance(tmp_path, monkeypatch): + run = _write_run(tmp_path / "original-run") + before = _hashes(run) + output = tmp_path / "exports" / "fixture-panel.pdf" + selected = [] + original_plot = exporter.fig_rw_panel + + def plot(t, r_series, winding_series, k, path): + selected.append((set(r_series), set(winding_series), k)) + return original_plot(t, r_series, winding_series, k, path) + + monkeypatch.setattr(exporter, "fig_rw_panel", plot) + exporter.main(["--run", str(run), "--output", str(output)]) + sidecar = output.with_suffix(".pdf.provenance.json") + record = json.loads(sidecar.read_text()) + assert selected == [({"meanfield", "trained"}, {"meanfield", "trained"}, 1)] + assert output.read_bytes().startswith(b"%PDF-") + assert record["operation"] == "saved_array_export" + assert record["inputs"]["config"]["sha256"] == before["config.json"] + assert record["inputs"]["trajectories"]["sha256"] == before["trajectories.npz"] + assert record["output"]["sha256"] == hashlib.sha256(output.read_bytes()).hexdigest() + assert record["output"]["path"] == str(output) + assert record["selection"] == {"arms": ["meanfield", "trained"], + "k": 1, "N": 8, "T": 1.0, "dt": 0.5} + assert _hashes(run) == before + assert set(_hashes(output.parent)) == {output.name, sidecar.name} + + +@pytest.mark.parametrize("key", ["k", "N", "T", "dt"]) +def test_missing_config_fields_never_default_or_write_outputs(tmp_path, key): + run = _write_run(tmp_path / "run", missing_config=key) + before = _hashes(run) + output = tmp_path / "exports" / "panel.pdf" + with pytest.raises(ValueError, match="Missing configuration fields"): + exporter.export_panel(run, output) + assert not output.parent.exists() + assert _hashes(run) == before + + +@pytest.mark.parametrize("key", ["t", "xi", "r_meanfield", "r_trained", + "winding_meanfield", "winding_trained"]) +def test_missing_saved_arrays_never_reconstruct_or_write_outputs(tmp_path, key): + run = _write_run(tmp_path / "run", missing_array=key) + before = _hashes(run) + output = tmp_path / "exports" / "panel.pdf" + with pytest.raises(ValueError, match="Missing saved arrays"): + exporter.export_panel(run, output) + assert not output.parent.exists() + assert _hashes(run) == before + + +@pytest.mark.parametrize("case", ["invalid_k", "invalid_N", "invalid_dt", "nonintegral_T", + "wrong_N", "wrong_t", "wrong_shape", "nonfinite_curve", + "wrong_initial_winding", "invalid_order_parameter", "object_array"]) +def test_config_and_saved_array_mismatches_fail_before_writes(tmp_path, case): + def update(config, data): + if case == "invalid_k": + config["k"] = True + elif case == "invalid_N": + config["N"] = 2 + elif case == "invalid_dt": + config["dt"] = 0.0 + elif case == "nonintegral_T": + config["T"] = 1.1 + elif case == "wrong_N": + config["N"] = 9 + elif case == "wrong_t": + data["t"][1] = 0.4 + elif case == "wrong_shape": + data["r_trained"] = np.ones(2) + elif case == "nonfinite_curve": + data["r_trained"][1] = np.nan + elif case == "wrong_initial_winding": + data["winding_trained"][0] = 0.0 + elif case == "invalid_order_parameter": + data["r_trained"][1] = 1.1 + elif case == "object_array": + data["r_trained"] = np.array([0.0, 0.6, 1.0], dtype=object) + + run = _write_run(tmp_path / "run", update=update) + before = _hashes(run) + output = tmp_path / "exports" / "panel.pdf" + with pytest.raises(ValueError): + exporter.export_panel(run, output) + assert not output.parent.exists() + assert _hashes(run) == before + + +@pytest.mark.parametrize("existing", ["pdf", "sidecar", "pdf_symlink", "sidecar_symlink"]) +def test_existing_outputs_and_dangling_symlinks_are_preserved(tmp_path, existing): + run = _write_run(tmp_path / "run") + output = tmp_path / "panel.pdf" + sidecar = output.with_suffix(".pdf.provenance.json") + occupied = sidecar if existing.startswith("sidecar") else output + if existing.endswith("symlink"): + occupied.symlink_to(tmp_path / "missing-target") + else: + occupied.write_bytes(b"preserve existing output") + with pytest.raises(FileExistsError): + exporter.export_panel(run, output) + if existing.endswith("symlink"): + assert occupied.is_symlink() + assert not (tmp_path / "missing-target").exists() + else: + assert occupied.read_bytes() == b"preserve existing output" + other = output if occupied == sidecar else sidecar + assert not other.exists() + + +@pytest.mark.parametrize("source", ["config.json", "trajectories.npz"]) +def test_source_output_collisions_are_rejected(tmp_path, source): + run = _write_run(tmp_path / "run") + before = _hashes(run) + with pytest.raises(ValueError, match="collides with a source artifact"): + exporter.export_panel(run, run / source) + assert _hashes(run) == before + + +def test_cli_reports_missing_arrays_without_creating_files(tmp_path): + run = _write_run(tmp_path / "run", missing_array="r_trained") + output = tmp_path / "exports" / "panel.pdf" + with pytest.raises(SystemExit) as error: + exporter.main(["--run", str(run), "--output", str(output)]) + assert error.value.code == 1 + assert not output.parent.exists() diff --git a/tests/test_appendix_rw_panel.py b/tests/test_appendix_rw_panel.py new file mode 100644 index 0000000..1355a90 --- /dev/null +++ b/tests/test_appendix_rw_panel.py @@ -0,0 +1,105 @@ +"""Appendix panel checks using handwritten arrays, without an experiment.""" + +import matplotlib.pyplot as plt +from matplotlib.figure import Figure +import numpy as np +import pytest + +from graphon_control import artifacts, dynamics, training +from graphon_control.experiments.kuramoto_plots import ( + ARM_COLORS, ARM_LABELS, fig_rw_panel, +) + + +def _curves(include_ring=False): + t = np.array([0.0, 1.0, 2.0, 3.0]) + r = {"meanfield": np.zeros(4), "trained": np.array([0.0, 0.3, 0.8, 1.0])} + winding = {"meanfield": np.full(4, 3.0), "trained": np.array([3.0, 2.0, 1.0, 0.0])} + if include_ring: + r["ring"] = np.array([0.0, 0.1, 0.1, 0.1]) + winding["ring"] = np.full(4, 3.0) + return t, r, winding + + +@pytest.mark.parametrize("include_ring", [False, True]) +def test_rw_panel_preserves_both_diagnostics_and_all_arms(tmp_path, monkeypatch, include_ring): + def forbidden(*args, **kwargs): + raise AssertionError("An archived-array panel must not run an experiment or load a checkpoint") + + monkeypatch.setattr(training, "train_controller", forbidden) + monkeypatch.setattr(dynamics, "rollout_with_Ws", forbidden) + monkeypatch.setattr(artifacts, "load_controller", forbidden) + t, r, winding = _curves(include_ring) + before = [value.copy() for value in [t, *r.values(), *winding.values()]] + captured = [] + original_savefig = Figure.savefig + + def savefig(figure, *args, **kwargs): + captured.append(figure) + return original_savefig(figure, *args, **kwargs) + + monkeypatch.setattr(Figure, "savefig", savefig) + output = tmp_path / "kur_rw_panel.pdf" + fig_rw_panel(t, r, winding, 3, output) + + assert output.read_bytes().startswith(b"%PDF-") + assert len(captured) == 1 + figure = captured[0] + assert len(figure.axes) == 2 + for ax, expected in zip(figure.axes, (r, winding)): + curves = {line.get_label(): line for line in ax.lines if line.get_label() in ARM_LABELS.values()} + assert set(curves) == {ARM_LABELS[arm] for arm in expected} + if ax is figure.axes[0]: + assert {text.get_text() for text in ax.get_legend().get_texts()} == set(curves) + else: + assert ax.get_legend() is None + for arm, values in expected.items(): + line = curves[ARM_LABELS[arm]] + np.testing.assert_array_equal(line.get_xdata(), t) + np.testing.assert_array_equal(line.get_ydata(), values) + assert line.get_color() == ARM_COLORS[arm] + assert figure.axes[0].get_ylabel() == r"order parameter $r(t)$" + assert figure.axes[1].get_ylabel() == r"winding number $\mathcal{W}(t)$" + assert not plt.fignum_exists(figure.number) + for actual, expected in zip([t, *r.values(), *winding.values()], before): + np.testing.assert_array_equal(actual, expected) + + +@pytest.mark.parametrize("case", [ + "invalid_k", "short_grid", "nonmonotone_grid", "nonfinite_grid", + "not_mapping", "missing_required_arm", "mismatched_arms", "unknown_arm", + "wrong_shape", "nonfinite_curve", "invalid_order_parameter", +]) +def test_rw_panel_rejects_invalid_inputs_before_writing(tmp_path, case): + t, r, winding = _curves() + k = 3 + if case == "invalid_k": + k = True + elif case == "short_grid": + t = np.array([0.0]) + elif case == "nonmonotone_grid": + t[2] = t[1] + elif case == "nonfinite_grid": + t[1] = np.nan + elif case == "not_mapping": + r = [] + elif case == "missing_required_arm": + del r["meanfield"] + del winding["meanfield"] + elif case == "mismatched_arms": + del winding["meanfield"] + elif case == "unknown_arm": + r["other"] = np.zeros(4) + winding["other"] = np.zeros(4) + elif case == "wrong_shape": + r["trained"] = np.zeros((4, 1)) + elif case == "nonfinite_curve": + winding["trained"][1] = np.inf + elif case == "invalid_order_parameter": + r["trained"][1] = 1.1 + output = tmp_path / "invalid.pdf" + figures_before = plt.get_fignums() + with pytest.raises(ValueError): + fig_rw_panel(t, r, winding, k, output) + assert not output.exists() + assert plt.get_fignums() == figures_before diff --git a/tests/test_reproduce_appendix.py b/tests/test_reproduce_appendix.py new file mode 100644 index 0000000..f67a822 --- /dev/null +++ b/tests/test_reproduce_appendix.py @@ -0,0 +1,276 @@ +"""Saved-array fixtures only: no dynamics, optimization, or checkpoint loading.""" + +import csv +import hashlib +import json +from pathlib import Path + +import numpy as np +import pytest +import torch + +import reproduce_appendix as exporter +from graphon_control import artifacts, dynamics, training + + +def _hash(path): + return hashlib.sha256(path.read_bytes()).hexdigest() + + +def _dump(path, data): + path.write_text(json.dumps(data)) + + +@pytest.fixture(scope="module") +def archived_fixture(tmp_path_factory): + root = tmp_path_factory.mktemp("appendix-handwritten-array-fixture") + manifest = {"schema_version": 1, "runs": {}} + for name in exporter.RUN_NAMES: + directory = root / name + directory.mkdir() + cfg = {"N": 64, "dt": .05, "lambda_terminal": 0., "gamma_target": 100., + "x_target": 0., "jitter": .1, "batch_size": 16, "lr": .001, + "num_fourier_freqs": 8, "embed_dim": 64, "hidden_embed": 128, + "hidden_score": 128, "num_steps": 3000 if name == "k3" else 2000} + if name == "bc": + cfg.update(T=10., sigma=0., beta=1., eps=1., gain=4., phi_form="poly", + span=6., amp=.5, modes=4) + else: + k = 2 if name == "noise" else int(name[1:]) + cfg.update(k=k, T={1: 5., 2: 12., 3: 20.}[k], beta=10., sigma=.3 if name == "noise" else 0.) + n, nt = cfg["N"], round(cfg["T"] / cfg["dt"]) + xi = (np.arange(n) + .5) / n + initial = (6*(xi-.5) + .5*np.sin(8*np.pi*xi) if name == "bc" + else 2*np.pi*cfg["k"]*(xi-.5)) + # Algebraic test arrays, deliberately not the result of a solver. + data = {"t": np.arange(nt+1)*cfg["dt"], "t_W": np.arange(nt)*cfg["dt"], "xi": xi, + "W_trained": np.full((nt,n,n), 1/n), "vxi_trained": np.zeros(nt)} + metrics = {"vxi": {"trained_max": 0., "trained_final": 0.}, "costs": {}, "var_final": {}} + if name != "bc": + metrics["r_final"], metrics["winding_final"] = {}, {} + for arm in exporter.ARMS: + factor = np.ones(nt+1) if arm == "meanfield" else np.linspace(1., .1, nt+1) + x = factor[:,None] * initial + var, mean = x.var(axis=1), x.mean(axis=1) + data["x_"+arm], data["var_"+arm] = x, var + cost = {"state_cost": cfg["dt"]*var[:-1].sum(), "l2_reg_cost": cfg["beta"]*cfg["T"], + "terminal_var": var[-1], "terminal_target": cfg["gamma_target"]*mean[-1]**2, + "terminal_mean": mean[-1]} + cost["total"] = cost["state_cost"] + cost["l2_reg_cost"] + cost["terminal_target"] + metrics["costs"][arm], metrics["var_final"][arm] = cost, var[-1] + if name != "bc": + r, w = np.abs(np.exp(1j*x).mean(-1)), exporter.winding(x) + data["r_"+arm], data["winding_"+arm] = r, w + metrics["r_final"][arm], metrics["winding_final"][arm] = r[-1], w[-1] + else: + data["K_"+arm] = np.ones(nt+1, dtype=int) + if name == "noise": + for key in ("r", "winding"): + for suffix in ("mean", "q10", "q90"): + data[f"{key}_noisy_{suffix}"] = data[f"{key}_trained"].copy() + metrics["noisy_eval"] = {"sigma": .3, "n_replicates": 32, + "r_final_mean": data["r_noisy_mean"][-1], "r_final_q10": data["r_noisy_q10"][-1], + "r_final_q90": data["r_noisy_q90"][-1], "winding_final_mean": data["winding_noisy_mean"][-1]} + _dump(directory/"config.json", cfg) + _dump(directory/"metrics.json", metrics) + np.savez_compressed(directory/"trajectories.npz", **data) + # Optional pinned files are hashed only, never deserialized. + (directory/"model.pt").write_bytes(b"not a torch checkpoint") + manifest["runs"][name] = {"path": str(directory), "sha256": {p.name: _hash(p) for p in directory.iterdir()}} + manifest_path = root/"manifest.json" + _dump(manifest_path, manifest) + return manifest_path + + +@pytest.fixture(autouse=True) +def forbid_experiment_calls(monkeypatch): + def forbidden(*args, **kwargs): + raise AssertionError("No training, dynamics, or checkpoint loads are authorized") + monkeypatch.setattr(training, "train_controller", forbidden) + for name in ("rollout_with_Ws", "rollout_dynamics_w1", "precompute_Ws"): + monkeypatch.setattr(dynamics, name, forbidden) + monkeypatch.setattr(artifacts, "load_controller", forbidden) + monkeypatch.setattr(torch, "load", forbidden) + + +def test_saved_array_export_preserves_inputs_and_lists_all_outputs(archived_fixture, tmp_path): + manifest = json.loads(archived_fixture.read_text()) + before = {p: _hash(Path(p)) for spec in manifest["runs"].values() + for p in (str(Path(spec["path"])/filename) for filename in spec["sha256"])} + output = tmp_path/"saved-array-output" + exporter.main(["--manifest", str(archived_fixture), "--output", str(output)]) + expected = {"bc_opinion_traj.pdf", "bc_kernel_strip.pdf", "kur_rw_panel.pdf", "kur_kernel_strip.pdf", + "kur_noise_panel.pdf", "tables.csv", "noise_summary.json", "verification.json", "provenance.json"} + assert {p.name for p in output.iterdir()} == expected + for pdf in output.glob("*.pdf"): + assert pdf.read_bytes().startswith(b"%PDF-") + provenance = json.loads((output/"provenance.json").read_text()) + assert provenance["new_experiments"] is False + assert provenance["checkpoint_loads"] is False + assert provenance["manifest"]["sha256"] == _hash(archived_fixture) + for filename, record in provenance["outputs"].items(): + assert record["sha256"] == _hash(output/filename) + assert {p: _hash(Path(p)) for p in before} == before + verification = json.loads((output/"verification.json").read_text()) + assert all(run["saved_array_checks_passed"] for run in verification["runs"].values()) + assert verification["runs"]["bc"]["passed"] is False + assert verification["runs"]["bc"]["status"] == "appendix_rounding_mismatch" + # Fixture data must not be passed off as the published values. + assert not verification["runs"]["bc"]["appendix_rounding"]["J_trained"]["matches"] + summary = json.loads((output/"noise_summary.json").read_text()) + assert summary["all_paths_zero_terminal_winding_verified"] is False + assert summary["individual_noisy_paths_saved"] is False + with (output/"tables.csv").open() as stream: + rows = list(csv.DictReader(stream)) + assert len(rows) == 8 + assert {(r["run"], r["arm"]) for r in rows} == {(r,a) for r in ("bc","k1","k2","k3") for a in exporter.ARMS} + assert not list(tmp_path.glob(".appendix-stage-*")) + + +def test_bundled_original_results_match_appendix(): + """Verify the published statistics against the actual archived inputs.""" + root = Path(__file__).resolve().parents[1] + manifest_path = root / "appendix_manifest.json" + runs, _ = exporter.load_manifest(manifest_path) + results = {name: exporter.diagnostics(name, run) for name, run in runs.items()} + assert all(result["passed"] for result in results.values()) + bc = results["bc"]["recomputed"] + assert bc["meanfield"]["clusters_final"] == 4 + assert bc["trained"]["clusters_final"] == 1 + assert abs(bc["meanfield"]["terminal_mean"]) < .0005 + assert f'{bc["trained"]["terminal_mean"]:.3f}' == "-0.001" + assert f'{bc["meanfield"]["terminal_var"]:.3f}' == "2.800" + assert bc["trained"]["terminal_var"] < 1e-12 + assert f'{bc["trained"]["max_vxi"]:.1f}' == "46.3" + for name, variance, peak in (("k1", "1.0e-02", "3.7"), + ("k2", "1.5e-05", "12.0"), + ("k3", "3.6e-08", "24.4")): + result = results[name]["recomputed"] + assert f'{result["trained"]["terminal_var"]:.1e}' == variance + assert f'{result["trained"]["max_vxi"]:.1f}' == peak + assert result["meanfield"]["max_vxi"] == 0 + k3 = results["k3"]["recomputed"] + assert f'{k3["meanfield"]["terminal_var"]:.3f}' == "29.602" + assert f'{k3["meanfield"]["r_final"]:.3f}' == "0.000" + assert f'{k3["trained"]["r_final"]:.3f}' == "1.000" + noisy = runs["noise"]["metrics"]["noisy_eval"] + assert noisy["n_replicates"] == 32 + assert f'{noisy["r_final_mean"]:.3f}' == "0.974" + manifest = json.loads(manifest_path.read_text()) + for name, source in manifest["figures"].items(): + assert _hash(root / source) == manifest["figures_sha256"][name] + + +def _mutated_manifest(source, tmp_path, name, filename, mutate): + manifest = json.loads(source.read_text()) + original = Path(manifest["runs"][name]["path"])/filename + directory = tmp_path/"mutated" + directory.mkdir() + spec = manifest["runs"][name] + # All remaining files stay read-only in the shared fixture directory. + for key in spec["sha256"]: + target = directory/key + if key != filename: + target.symlink_to(Path(spec["path"])/key) + raw = original.read_bytes() + if filename.endswith(".npz"): + with np.load(original) as z: + arrays = {key: z[key] for key in z.files} + mutate(arrays) + np.savez_compressed(directory/filename, **arrays) + else: + value = json.loads(raw) + mutate(value) + _dump(directory/filename, value) + spec["path"] = str(directory) + spec["sha256"][filename] = _hash(directory/filename) + path = tmp_path/"manifest.json" + _dump(path, manifest) + return path + + +def test_tampered_hash_fails_before_any_output(archived_fixture, tmp_path): + manifest = json.loads(archived_fixture.read_text()) + manifest["runs"]["noise"]["sha256"]["metrics.json"] = "0"*64 + path = tmp_path/"manifest.json" + _dump(path, manifest) + output = tmp_path/"absent-parent"/"out" + with pytest.raises(ValueError, match="SHA-256 mismatch"): + exporter.export_appendix(path, output) + assert not output.parent.exists() + + +@pytest.mark.parametrize("key", ["x_trained", "W_trained", "vxi_trained", "t", "r_trained", "winding_noisy_q90"]) +def test_missing_saved_arrays_never_reconstruct(archived_fixture, tmp_path, key): + path = _mutated_manifest(archived_fixture, tmp_path, "noise", "trajectories.npz", lambda d: d.pop(key)) + output = tmp_path/"exports"/"out" + with pytest.raises(ValueError, match="missing saved array"): + exporter.export_appendix(path, output) + assert not output.parent.exists() + + +@pytest.mark.parametrize("field,value", [("N", 128), ("dt", .1), ("num_steps", 10), ("jitter", .2)]) +def test_other_experiments_refused(archived_fixture, tmp_path, field, value): + path = _mutated_manifest(archived_fixture, tmp_path, "bc", "config.json", lambda d: d.update({field:value})) + output = tmp_path/"out" + with pytest.raises(ValueError, match="original scientific setting"): + exporter.export_appendix(path, output) + assert not output.exists() + + +def test_missing_scientific_config_never_defaults(archived_fixture, tmp_path): + path = _mutated_manifest(archived_fixture, tmp_path, "bc", "config.json", lambda d: d.pop("beta")) + with pytest.raises(ValueError, match="beta must be recorded"): + exporter.export_appendix(path, tmp_path/"out") + assert not (tmp_path/"out").exists() + + +def test_metrics_mismatch_refused(archived_fixture, tmp_path): + path = _mutated_manifest(archived_fixture, tmp_path, "k1", "metrics.json", lambda d: d["costs"]["trained"].update(total=0)) + with pytest.raises(ValueError, match="saved arrays disagree"): + exporter.export_appendix(path, tmp_path/"out") + assert not (tmp_path/"out").exists() + + +def test_existing_output_is_never_replaced(archived_fixture, tmp_path): + output = tmp_path/"out" + output.mkdir() + (output/"sentinel").write_text("keep") + with pytest.raises(FileExistsError): + exporter.export_appendix(archived_fixture, output) + assert (output/"sentinel").read_text() == "keep" + + +def test_output_created_during_render_is_never_overwritten(archived_fixture, tmp_path, monkeypatch): + output = tmp_path/"out" + original_mkdir = Path.mkdir + def racing_mkdir(path, *args, **kwargs): + if path == output: + original_mkdir(path) + (path/"other-writer").write_text("preserve") + raise FileExistsError("raced output") + return original_mkdir(path, *args, **kwargs) + monkeypatch.setattr(Path, "mkdir", racing_mkdir) + with pytest.raises(FileExistsError, match="raced output"): + exporter.export_appendix(archived_fixture, output) + assert {p.name for p in output.iterdir()} == {"other-writer"} + assert not list(tmp_path.glob(".appendix-stage-*")) + + +def test_publication_failure_preserves_unrelated_concurrent_content(archived_fixture, tmp_path, monkeypatch): + output = tmp_path/"out" + original_link = exporter.os.link + calls = 0 + def racing_link(source, target): + nonlocal calls + calls += 1 + if calls == 2: + (output/"other-writer").write_text("preserve") + raise OSError("simulated publication failure") + return original_link(source, target) + monkeypatch.setattr(exporter.os, "link", racing_link) + with pytest.raises(OSError, match="simulated publication failure"): + exporter.export_appendix(archived_fixture, output) + assert {p.name for p in output.iterdir()} == {"other-writer"} + assert (output/"other-writer").read_text() == "preserve" + assert not list(tmp_path.glob(".appendix-stage-*"))