diff --git a/.cursorrules b/.cursorrules index be4233d58..81af038be 100644 --- a/.cursorrules +++ b/.cursorrules @@ -15,8 +15,11 @@ mechanics fidelity, differentiability boundaries, and reproducible examples. - Avoid in-place tensor mutations in autograd-sensitive code. Prefer out-of-place expressions, `torch.where`, `scatter_add`, sparse operators, and vectorized batch operations. -- Do not add inverse, hybrid/deep-learning, paper-2, paper-4, private HPC, or - proprietary-solver artifacts to the public surface. +- Keep paper-specific reproduction commands in `reproduction/paper1` with an + explicit input catalogue and separate replot and simulation-run status. + These commands preserve historical experiments rather than adding a general + inverse API. Keep unrelated inverse studies, hybrid/deep-learning research, + paper-2, paper-4, private HPC and proprietary-solver artifacts separate. - Keep public examples flat, runnable, and reproducible: `config.yaml`, optional fluent setup, standard manifests, CSV histories, lightweight visuals, and documented result bundles. diff --git a/CHANGELOG.md b/CHANGELOG.md index 66f70ff06..18021d31a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,15 @@ ## Unreleased +### Paper reproduction + +- Add a manuscript and supplement catalogue with separate records for figures, + tables and numerical statements. +- Add portable retained-data checks and selected figure-generation commands. + Record data replotting separately from complete simulation reproduction. +- Preserve the existing example folders and configuration schema. Large fields + and historical execution sources remain in the companion data archive. + ### Experimental tetrahedral geometry - Add validated TET4 connectivity, orientation repair, volumes and scalar diff --git a/README.md b/README.md index 8b19e0cd9..791adff7d 100644 --- a/README.md +++ b/README.md @@ -222,6 +222,7 @@ print(result.history_names()) |---|---| | `src/phast/` | Core PyTorch solver packages, mechanics/damage kernels, and CLI entry points. | | `examples/` | Runnable examples, their YAML inputs, and lightweight reference outputs. | +| [`reproduction/paper1/`](reproduction/paper1/README.md) | Manuscript and supplement catalogue, retained-data checks and figure reproduction commands. | | `configs/` | Runnable benchmark decks, the YAML reference template and schema, and explicitly labelled reproducibility contracts. | | `docs/` | Sphinx documentation, capability matrices, tutorials, and user guides. | | `assets/` | Lightweight visual assets for repository documentation. | diff --git a/docs/index.md b/docs/index.md index 153f59c02..75cecf860 100644 --- a/docs/index.md +++ b/docs/index.md @@ -172,6 +172,7 @@ user_guide/yaml_workflow user_guide/results_visualization user_guide/learned_damage performance-reproducibility +paper-reproduction ``` ```{toctree} diff --git a/docs/paper-reproduction.md b/docs/paper-reproduction.md new file mode 100644 index 000000000..add0d9b21 --- /dev/null +++ b/docs/paper-reproduction.md @@ -0,0 +1,48 @@ +# Paper reproduction + +The [paper reproduction directory](https://github.com/CEMS-Lab/PhAST/tree/main/reproduction/paper1) +contains the manuscript and supplement catalogue and its commands. The +catalogue follows the paper's order and uses stable figure and table labels. +It also maps numerical statements in the text to their supporting studies. + +The existing example folders and YAML configurations remain the starting +point for using PhAST. Each study records the relationship between its exact +paper configuration and the related public example. Large numerical fields, +meshes and historical execution sources belong in the companion archive. +Its publication details will be added after the archive is complete. + +From the repository root, list the paper entries and check their mappings. + +```bash +python reproduction/paper1/reproduce.py list +python reproduction/paper1/reproduce.py check +``` + +With the retained data available locally, check its file hashes and regenerate +the selected quasi-static holed-plate figure. + +```bash +python reproduction/paper1/reproduce.py check --data /path/to/phast-paper-reproduction +python reproduction/paper1/reproduce.py replot q2 --data /path/to/phast-paper-reproduction --output runs/paper-q2 +``` + +The `forward` group covers standing-wave and damage-update-interval plots. The +`inverse` group covers the selected scalar, cross-mesh, joint, finite-difference +and memory plots. Install the separate plotting requirements described in the +reproduction directory before running these commands. Output directories must +be fresh and separate from the retained data. + +The commands check the archive manifest and document-edition hashes against +the catalogue before using the files. Use a trusted data archive because the +plotting adapters load its preserved experiment code. + +A checksum check establishes input integrity. Replotting checks the recorded +data and figure-generation route. Complete simulation reproduction also +requires the executed solver version, all numerical inputs, the environment +and a verified run command. These statuses are recorded separately for each +study, including inputs still being collected. + +This separation of the solver and experiment dependencies follows +[Firedrake's reproduction guidance](https://www.firedrakeproject.org/zenodo.html). +The problem-based ordering also follows the approach used in the +[DOLFINx demonstrations](https://docs.fenicsproject.org/dolfinx/main/python/demos.html). diff --git a/docs/user_guide/capability_matrix.md b/docs/user_guide/capability_matrix.md index f57640a00..b73abec33 100644 --- a/docs/user_guide/capability_matrix.md +++ b/docs/user_guide/capability_matrix.md @@ -66,6 +66,12 @@ For the current plasticity, cohesive interface, and PF-CZM technical-preview bou | Public inverse-analysis examples | Scaffold | `examples/inverse_problems_beta/` is a README-only landing zone until promoted inverse examples include configs, losses, retained lightweight outputs, and validation notes. | | General-purpose inverse-calibration framework | Unsupported | The public release does not provide a turnkey inverse-problem framework for arbitrary observations, priors, or optimizers. | +Selected paper-specific inverse figures have separate +[retained-data reproduction commands](../paper-reproduction.md). Their +catalogue distinguishes replotting saved results from running the inverse +calculation. These utilities preserve the scope of the recorded studies while +the complete companion archive is prepared. + ## Declarative YAML Workflows | Capability | Status | Public Statement | diff --git a/examples/inverse_problems_beta/README.md b/examples/inverse_problems_beta/README.md index d4673661d..81669b009 100644 --- a/examples/inverse_problems_beta/README.md +++ b/examples/inverse_problems_beta/README.md @@ -4,6 +4,11 @@ possible future inverse-analysis example based on differentiable PhAST forward operations. It does not contain a runnable inverse benchmark. +For the paper's selected inverse figures, use the separate +[retained-data reproduction commands](../../reproduction/paper1/README.md). +Their catalogue records plotting and simulation-rerun status individually. +The companion numerical archive is being prepared. + The current public release foregrounds forward phase-field fracture examples. Inverse workflows should be added here only when they include: diff --git a/llms.txt b/llms.txt index d096d028f..c5ef726ed 100644 --- a/llms.txt +++ b/llms.txt @@ -29,9 +29,10 @@ derived documentation, examples, or release material. - Completed runs expose machine-readable artifacts such as `run_manifest.json`, `run_lockfile.json`, `run_metadata.json`, `summary.json`, and CSV telemetry. - Use `phast.load_result(path)` for read-only result inspection. -- Advanced inverse, hybrid/deep-learning, future paper-specific workflows, raw - HPC logs, proprietary files, and heavy trajectory stores are not part of the - public release surface. +- Selected paper-specific retained-data checks and inverse replots are + documented in `reproduction/paper1`. General inverse and hybrid research, + raw HPC logs, proprietary files and heavy trajectory stores stay separate + from the public solver release. ## Quick Start For Agents @@ -75,6 +76,9 @@ python -m phast explain-config examples/quasistatic/notched_holed_plate/config.y - [`examples/`](examples/) - curated public YAML examples and retained visual result bundles. - [`examples/README.md`](examples/README.md) - public example overview. +- [`reproduction/paper1/README.md`](reproduction/paper1/README.md) - manuscript + and supplement catalogue, retained-data verification and bounded replots. + Historical experiment sources and large fields use a separate data archive. - [`configs/`](configs/) - benchmark decks, schemas, reference templates, and reusable configuration material. - [`docs/`](docs/) - Sphinx/user documentation source. diff --git a/reproduction/paper1/README.md b/reproduction/paper1/README.md new file mode 100644 index 000000000..d33ae2d83 --- /dev/null +++ b/reproduction/paper1/README.md @@ -0,0 +1,105 @@ +# Paper 1 Reproduction + +This directory indexes the PhAST manuscript and supplement. It provides a +bounded retained-data route, not a claim that every paper simulation can +currently be rerun. + +The **data record is in preparation**. No data DOI or download URL is assigned +here. Large fields, historical execution sources and environments remain +external to this repository. + +## Commands + +Run from the PhAST repository root. `list` needs no external data: + +```bash +python reproduction/paper1/reproduce.py list +python reproduction/paper1/reproduce.py check --data DIR +python reproduction/paper1/reproduce.py replot q2 --data DIR --output OUTPUT_DIR +python reproduction/paper1/reproduce.py replot inverse --data DIR --output OUTPUT_DIR +python reproduction/paper1/reproduce.py replot forward --data DIR --output OUTPUT_DIR +``` + +`list` and `check` use only the Python standard library. Before replotting, +create a separate plotting environment from the repository root: + +```bash +python3.10 -m venv .venv-paper1-replot +source .venv-paper1-replot/bin/activate +python -m pip install -r reproduction/paper1/requirements-replot.txt +``` + +The retained-data checks were tested on macOS arm64 with Python 3.10.18 and +the versions in `requirements-replot.txt`. This is a replot-only environment, +not a reconstruction of historical simulation, CUDA or timing environments. +Other platforms have not been verified for this route. + +Replace `DIR` with the local extracted `phast-paper-reproduction` data root, +containing `manifest.json`, `document_identity.json`, `forward/`, `inverse/`, +`verification/`, `sources/` and `shared/`. Use a different fresh `OUTPUT_DIR` +outside the data tree for each replot. Data are supplied explicitly; these +commands do not download a record or launch full simulations. Replotting +executes selected archived Python code, so use only a trusted extraction. +Hash matching binds the input identity; it does not sandbox that code. + +`check --data DIR` verifies the manifest and document identity against the +catalogue's pinned SHA-256 hashes, then checks the listed payload sizes and +hashes. Another self-consistent dataset is not a substitute for this audited +edition. This checks selected-file integrity, not completeness of catalogue +coverage, missing historical inputs, or full simulation reproduction. +Replots write generated figures and verification records under the requested +output directory without replacing the input data. + +## Current Status + +| Route | Recorded retained-data checks | Full simulation | +| --- | --- | --- | +| `q2` | Force curve and three damage panels regenerated; 200 saved fields checked | Pending | +| `inverse` | Six figures regenerated from 487 exact inputs, covering scalar, six-start, cross-mesh, joint, memory and finite-difference studies | Pending; original inputs or environments remain incomplete for some studies | +| `forward` | Standing-wave and subcycling figures regenerated; observed orders, selected terminal metrics and timing summaries checked | Pending; this command does not cover all forward studies | +| Other studies | Inputs mapped, with individual restrictions in the catalogue | Pending or not applicable | + +These are preparation records, not certification of every metric or a new +solver execution. Original perforated-plate native fields, five-mesh impact +inputs, complete AT1 energy-integration states, historical execution identities +and several study environments remain unresolved. Photograph reuse permission +and reference-curve provenance are separate restrictions. + +The new terminal AT2 profile statement reports local/gradient contributions +of **1.53/0.26**, versus **0.5/0.5** for the static profile. Its exact source +SHA-256 is recorded under `prose_at2_terminal_profile`. The calculation script, +mesh/state choice, integration region, quadrature and normalisation are pending; +no independent analysis is claimed. + +## Catalogue + +[`catalogue.json`](catalogue.json) uses schema version 1, independently of the +existing [public example contract](../../docs/user_guide/example_contract.md): + +- `data_identity` binds checks and replots to the tested frozen data extraction's + manifest and document-identity file hashes. +- `documents` identifies the indexed manuscript and supplement by stable labels + and PDF SHA-256 hashes, not local paths. +- `items` contains 38 figures/tables in document order: 14 figures and 8 tables + in the manuscript, followed by 14 figures and 2 tables in the supplement. + Each entry maps labels to studies and selected archive-relative assets. +- `results` contains 36 grouped text mappings plus the pending AT2 profile + statement. A mapping is not independent numerical recomputation. Source edits + after the indexed edition require a final scope refresh before publication. +- `studies` separates `replot_status`, `rerun_status`, `required_inputs` and + `unresolved_gaps`. `replot_group` is `q2`, `inverse`, `forward` or `null`; + `null` means no route in this CLI. Archive paths are relative to `--data`. + +`retained_data_checked` applies only to the bounded checks described above. +`pending_full_run` means a fresh complete simulation has not been verified; +`pending_inputs` additionally identifies missing or unresolved dependencies. +Rights/provenance holds are not numerical failures. Empty `archive_assets` means +a table is represented by study records or a composite is explicitly excluded; +it does not imply completion. Asset paths preserve exact historical filenames. + +`related_example` names an existing public example, **not an identical paper +run**. In particular, paper B1 relates to `B3_dynamic_sent`, paper B3 to +`B7_dynamic_crack_branching_comsol`, and paper B4a-d to the `B6_perforated_*` +family. Public example names, schemas and inputs are unchanged. The paper's +inverse replot route does not promote the documentation-only public inverse +example into a runnable inverse solver. diff --git a/reproduction/paper1/catalogue.json b/reproduction/paper1/catalogue.json new file mode 100644 index 000000000..da724d08b --- /dev/null +++ b/reproduction/paper1/catalogue.json @@ -0,0 +1,714 @@ +{ + "schema_version": 1, + "data_identity": { + "manifest_sha256": "1b174a73e0c4ae91574b033d652a18fc947d1e616581155a4e857ba441eb8f52", + "document_identity_sha256": "8a8121123d9c27f665252a31f661998ff98ba1bfae67e0a559875de6624e6b59" + }, + "documents": { + "manuscript": { + "label": "Paper 1 manuscript", + "pdf_sha256": "4c56788d3f10ef76e6b201b401e070db67eba7b4afd83bf2425f194041e8be97", + "figure_count": 14, + "table_count": 8, + "identity_status": "Indexed PDF edition; later source changes require a final scope refresh." + }, + "supplement": { + "label": "Paper 1 supplement", + "pdf_sha256": "d847c11a578564871c853f0a33c4a935f88b481e31b5ce7f3fe9423bedbbbd6d", + "figure_count": 14, + "table_count": 2, + "identity_status": "Indexed PDF edition; later source changes require a final scope refresh." + } + }, + "items": [ + { + "id": "manuscript.figure.1", "document": "manuscript", "kind": "figure", "number": "1", + "labels": ["fig:regularization_schematic"], + "studies": ["documentation/regularization_schematic"], + "archive_assets": ["illustrations/regularization/figures/journal/regularization_schematic.pdf"] + }, + { + "id": "manuscript.table.1", "document": "manuscript", "kind": "table", "number": "1", + "labels": ["tab:damage_cadence"], + "studies": ["forward/B1_dynamic_sent/checks/subcycling"], + "archive_assets": [] + }, + { + "id": "manuscript.table.2", "document": "manuscript", "kind": "table", "number": "2", + "labels": ["tab:benchmark_overview"], + "studies": ["forward/B1_dynamic_sent", "forward/B2_kalthoff_winkler", "forward/B3_dynamic_branching", "forward/B4a_30holes", "forward/B4b_10holes", "forward/B4c_single_hole_near", "forward/B4d_single_hole_far", "forward/Q1_quasistatic_sent", "forward/Q2_holed_plate"], + "archive_assets": [] + }, + { + "id": "manuscript.table.3", "document": "manuscript", "kind": "table", "number": "3", + "labels": ["tab:sent_refinement"], + "studies": ["forward/B1_dynamic_sent/checks/mesh_refinement"], + "archive_assets": [] + }, + { + "id": "manuscript.figure.2", "document": "manuscript", "kind": "figure", "number": "2", + "labels": ["fig:dsent", "fig:b1_sent_kinematics"], + "studies": ["forward/B1_dynamic_sent"], + "archive_assets": ["forward/B1_dynamic_sent/figures/journal/b1_sent_kinematics_curves_compact.pdf", "forward/B1_dynamic_sent/figures/journal/dsent_damage_compact.pdf"] + }, + { + "id": "manuscript.figure.3", "document": "manuscript", "kind": "figure", "number": "3", + "labels": ["fig:kalthoff_snapshots"], + "studies": ["forward/B2_kalthoff_winkler"], + "archive_assets": [], + "unresolved_gaps": ["Photograph-containing composite excluded pending reuse permission; numerical frames are separate inputs."] + }, + { + "id": "manuscript.table.4", "document": "manuscript", "kind": "table", "number": "4", + "labels": ["tab:kalthoff"], + "studies": ["forward/B2_kalthoff_winkler", "forward/B2_kalthoff_winkler/checks/threshold"], + "archive_assets": [] + }, + { + "id": "manuscript.figure.4", "document": "manuscript", "kind": "figure", "number": "4", + "labels": ["fig:borden_branching_damage", "fig:b3_post_ramp_balance"], + "studies": ["forward/B3_dynamic_branching"], + "archive_assets": ["forward/B3_dynamic_branching/figures/version_2/b3_damage_evolution.pdf", "forward/B3_dynamic_branching/figures/version_2/b3_post_ramp_balance_manuscript.pdf"] + }, + { + "id": "manuscript.figure.5", "document": "manuscript", "kind": "figure", "number": "5", + "labels": ["fig:perf_15holes"], + "studies": ["forward/B4e_offset_loading_matrix"], + "archive_assets": ["forward/B4e_offset_loading_matrix/figures/journal/perf_15h_off05_dU004_damage.pdf", "forward/B4e_offset_loading_matrix/figures/journal/perf_15h_off05_dU005_damage.pdf", "forward/B4e_offset_loading_matrix/figures/journal/perf_15h_off06_dU004_damage.pdf", "forward/B4e_offset_loading_matrix/figures/journal/perf_15h_off06_dU005_damage.pdf"] + }, + { + "id": "manuscript.figure.6", "document": "manuscript", "kind": "figure", "number": "6", + "labels": ["fig:q1_quasistatic"], + "studies": ["forward/Q1_quasistatic_sent"], + "archive_assets": ["forward/Q1_quasistatic_sent/figures/journal/q1_certified_damage.pdf", "forward/Q1_quasistatic_sent/figures/journal/q1_certified_load_displacement.pdf"], + "unresolved_gaps": ["Reference-curve provenance and redistribution permission remain unresolved."] + }, + { + "id": "manuscript.figure.7", "document": "manuscript", "kind": "figure", "number": "7", + "labels": ["fig:q2_quasistatic"], + "studies": ["forward/Q2_holed_plate"], + "archive_assets": ["forward/Q2_holed_plate/figures/q2_completed.pdf"] + }, + { + "id": "manuscript.figure.8", "document": "manuscript", "kind": "figure", "number": "8", + "labels": ["fig:cross_code_sent", "fig:cross_code_sent_tip_speed"], + "studies": ["forward/B1_dynamic_sent/comparisons/cross_code"], + "archive_assets": ["forward/B1_dynamic_sent/comparisons/cross_code/figures/version_2/cross_code_sent_composite.pdf"] + }, + { + "id": "manuscript.table.5", "document": "manuscript", "kind": "table", "number": "5", + "labels": ["tab:cross_code_sent_agreement"], + "studies": ["forward/B1_dynamic_sent/comparisons/cross_code"], + "archive_assets": [] + }, + { + "id": "manuscript.figure.9", "document": "manuscript", "kind": "figure", "number": "9", + "labels": ["fig:cross_code_kalthoff_fields", "fig:cross_code_kalthoff_tip"], + "studies": ["forward/B2_kalthoff_winkler/comparisons/cross_code"], + "archive_assets": ["forward/B2_kalthoff_winkler/comparisons/cross_code/figures/journal/kalthoff_four_route_final_damage.pdf", "forward/B2_kalthoff_winkler/comparisons/cross_code/figures/journal/kalthoff_four_route_tip_histories_manuscript.pdf"] + }, + { + "id": "manuscript.table.6", "document": "manuscript", "kind": "table", "number": "6", + "labels": ["tab:cross_code_kalthoff_agreement"], + "studies": ["forward/B2_kalthoff_winkler/comparisons/cross_code"], + "archive_assets": [] + }, + { + "id": "manuscript.figure.10", "document": "manuscript", "kind": "figure", "number": "10", + "labels": ["fig:inversion", "fig:inversion_alumina", "fig:controlled_multistart_recovery"], + "studies": ["inverse/scalar_glass_alumina", "inverse/six_start"], + "archive_assets": ["inverse/scalar_gc/figures/scalar_recovery_protocol_comparison.pdf"] + }, + { + "id": "manuscript.table.7", "document": "manuscript", "kind": "table", "number": "7", + "labels": ["tab:cross_mesh_ad_brent"], + "studies": ["inverse/cross_mesh"], + "archive_assets": [] + }, + { + "id": "manuscript.figure.11", "document": "manuscript", "kind": "figure", "number": "11", + "labels": ["fig:inverse_observations_meshes", "fig:prospective_dic_operator"], + "studies": ["inverse/cross_mesh"], + "archive_assets": ["inverse/cross_mesh_clean_sent/figures/inverse_observations_meshes.pdf"] + }, + { + "id": "manuscript.figure.12", "document": "manuscript", "kind": "figure", "number": "12", + "labels": ["fig:cross_mesh_gc_recovery"], + "studies": ["inverse/cross_mesh"], + "archive_assets": ["inverse/cross_mesh_clean_sent/figures/cross_mesh_outcomes.pdf"] + }, + { + "id": "manuscript.figure.13", "document": "manuscript", "kind": "figure", "number": "13", + "labels": ["fig:joint_gc_l0_damage_recovery"], + "studies": ["inverse/joint_gc_l0"], + "archive_assets": ["inverse/joint_gc_l0/figures/joint_gc_l0_recovery.pdf"] + }, + { + "id": "manuscript.figure.14", "document": "manuscript", "kind": "figure", "number": "14", + "labels": ["fig:at2_backward_memory"], + "studies": ["inverse/implicit_memory"], + "archive_assets": ["verification/at2_memory/figures/r2_2_at2_isolated_memory_117684.pdf"] + }, + { + "id": "manuscript.table.8", "document": "manuscript", "kind": "table", "number": "8", + "labels": ["tab:cross_code_sent_optimised_timing"], + "studies": ["forward/B1_dynamic_sent/comparisons/cpu_optimisation"], + "archive_assets": [] + }, + { + "id": "supplement.figure.S1", "document": "supplement", "kind": "figure", "number": "S1", + "labels": ["fig:standing_wave_verification"], + "studies": ["verification/standing_wave"], + "archive_assets": ["verification/standing_wave/figures/journal/standing_wave_verification.pdf"] + }, + { + "id": "supplement.figure.S2", "document": "supplement", "kind": "figure", "number": "S2", + "labels": ["fig:gpu_repeatability"], + "studies": ["forward/B2_kalthoff_winkler/checks/repeatability"], + "archive_assets": ["forward/B2_kalthoff_winkler/checks/repeatability/figures/journal/kalthoff_repeatability_117675.pdf"] + }, + { + "id": "supplement.figure.S3", "document": "supplement", "kind": "figure", "number": "S3", + "labels": ["fig:at2_bounds"], + "studies": ["forward/B1_dynamic_sent/checks/damage_bounds"], + "archive_assets": ["forward/B1_dynamic_sent/checks/damage_bounds/presentation/journal_20260904/retained_v2/at2_bounds.pdf"] + }, + { + "id": "supplement.figure.S4", "document": "supplement", "kind": "figure", "number": "S4", + "labels": ["fig:damage_reaction_mass"], + "studies": ["forward/B1_dynamic_sent/checks/reaction_mass"], + "archive_assets": ["forward/B1_dynamic_sent/checks/reaction_mass/figures/version_2/reaction_mass.pdf"] + }, + { + "id": "supplement.figure.S5", "document": "supplement", "kind": "figure", "number": "S5", + "labels": ["fig:damage_cadence"], + "studies": ["forward/B1_dynamic_sent/checks/subcycling"], + "archive_assets": ["forward/B1_dynamic_sent/checks/subcycling/presentation/journal/update_interval_20260907/damage_cadence.pdf"] + }, + { + "id": "supplement.figure.S6", "document": "supplement", "kind": "figure", "number": "S6", + "labels": ["fig:sent_refinement_trends"], + "studies": ["forward/B1_dynamic_sent/checks/mesh_refinement"], + "archive_assets": ["forward/B1_dynamic_sent/checks/mesh_refinement/figures/journal/straight_sent_refinement_damage.pdf", "forward/B1_dynamic_sent/checks/mesh_refinement/figures/journal/straight_sent_refinement_metrics.pdf"] + }, + { + "id": "supplement.figure.S7", "document": "supplement", "kind": "figure", "number": "S7", + "labels": ["fig:sent_traction_energy_audit"], + "studies": ["forward/B1_dynamic_sent/checks/energy_linear_traction"], + "archive_assets": ["forward/B1_dynamic_sent/checks/traction_work/figures/journal/sent_energy_balance_job111155.pdf"] + }, + { + "id": "supplement.table.S1", "document": "supplement", "kind": "table", "number": "S1", + "labels": ["tab:supp_cross_mesh_criteria"], + "studies": ["inverse/cross_mesh"], + "archive_assets": [] + }, + { + "id": "supplement.figure.S8", "document": "supplement", "kind": "figure", "number": "S8", + "labels": ["fig:kalthoff_threshold_sensitivity"], + "studies": ["forward/B2_kalthoff_winkler/checks/threshold"], + "archive_assets": ["forward/B2_kalthoff_winkler/checks/threshold/figures/journal/reader_polish_20260907/kalthoff_threshold_sensitivity.pdf"] + }, + { + "id": "supplement.figure.S9", "document": "supplement", "kind": "figure", "number": "S9", + "labels": ["fig:b1_full_energy"], + "studies": ["forward/B1_dynamic_sent/checks/energy_c2_displacement"], + "archive_assets": ["forward/B1_dynamic_sent/checks/c2_work/figures/journal/r1_9_full_boundary_energy_main.pdf"] + }, + { + "id": "supplement.figure.S10", "document": "supplement", "kind": "figure", "number": "S10", + "labels": ["fig:b1_boundary_support"], + "studies": ["forward/B1_dynamic_sent/checks/energy_c2_displacement"], + "archive_assets": ["forward/B1_dynamic_sent/checks/c2_work/figures/journal/r1_9_full_boundary_energy_support.pdf"] + }, + { + "id": "supplement.table.S2", "document": "supplement", "kind": "table", "number": "S2", + "labels": ["tab:perforated"], + "studies": ["forward/B4a_30holes", "forward/B4b_10holes", "forward/B4c_single_hole_near", "forward/B4d_single_hole_far", "forward/B4e_offset_loading_matrix"], + "archive_assets": [] + }, + { + "id": "supplement.figure.S11", "document": "supplement", "kind": "figure", "number": "S11", + "labels": ["fig:perf_constrained", "fig:perf_30holes_diag"], + "studies": ["forward/B4a_30holes", "forward/B4b_10holes"], + "archive_assets": ["forward/B4a_30holes/figures/journal/perf_30holes_velocity_compact.pdf", "forward/B4a_30holes/figures/perf_30holes_damage.png", "forward/B4b_10holes/figures/perf_10holes_damage.png"] + }, + { + "id": "supplement.figure.S12", "document": "supplement", "kind": "figure", "number": "S12", + "labels": ["fig:perf_1hole"], + "studies": ["forward/B4c_single_hole_near", "forward/B4d_single_hole_far"], + "archive_assets": ["forward/B4c_single_hole_near/figures/journal/perf_1hole_near_damage_insets.pdf", "forward/B4d_single_hole_far/figures/journal/perf_1hole_far_damage_insets.pdf"] + }, + { + "id": "supplement.figure.S13", "document": "supplement", "kind": "figure", "number": "S13", + "labels": ["fig:b4_energy_controls"], + "studies": ["forward/B4a_30holes/checks/at1_energy_restoration"], + "archive_assets": ["forward/B4a_30holes/checks/energy_controls/figures/version_2/b4_energy_controls.pdf"] + }, + { + "id": "supplement.figure.S14", "document": "supplement", "kind": "figure", "number": "S14", + "labels": ["fig:alumina_fd_active_set"], + "studies": ["inverse/alumina_fd"], + "archive_assets": ["verification/alumina_fd/figures/alumina_fd_active_set_job111131.pdf"] + } + ], + "results": [ + { + "id": "prose_P01", "studies": ["forward/B2_kalthoff_winkler/checks/repeatability"], + "description": "Five CUDA replicas: nodal and field agreement, thresholded overlap, angle percentiles and first extension.", + "status": "mapped_not_recomputed" + }, + { + "id": "prose_P02", "studies": ["forward/B1_dynamic_sent/checks/damage_bounds"], + "description": "Two 6116-step AT2 bound-treatment runs: terminal fields, crack tip, energy, feasibility and irreversibility.", + "status": "mapped_not_recomputed" + }, + { + "id": "prose_P03", "studies": ["forward/B1_dynamic_sent/checks/reaction_mass"], + "description": "Consistent versus row-sum-lumped damage reaction: terminal field differences and clipped support area.", + "status": "mapped_not_recomputed" + }, + { + "id": "prose_P04", "studies": ["forward/B1_dynamic_sent/checks/subcycling"], + "description": "Damage cadences 1, 2 and 3: threshold-crossing delays, tips, energy differences and three CPU timing repeats per cadence.", + "status": "selected_metrics_checked_full_run_pending" + }, + { + "id": "prose_P05", "studies": ["verification/standing_wave"], + "description": "Four joint space/time refinements: analytical standing-wave errors and observed orders recomputed from retained errors.", + "status": "selected_metrics_checked_full_run_pending" + }, + { + "id": "prose_P06", "studies": ["forward/B1_dynamic_sent/checks/mesh_refinement"], + "description": "Fixed-length-scale SENT refinement: dissipation, speed, threshold and post-ramp interval sensitivity across three meshes.", + "status": "mapped_not_recomputed" + }, + { + "id": "prose_P07", "studies": ["forward/B1_dynamic_sent"], + "description": "Linear-ramp SENT boundary arrival and median crack speeds from sampled tip histories.", + "status": "mapped_not_recomputed" + }, + { + "id": "prose_P08", "studies": ["forward/B1_dynamic_sent/checks/energy_c2_displacement"], + "description": "C2 displacement-ramp work balance, boundary inertia and terminal damage; distinct from the linear-ramp benchmark.", + "status": "mapped_not_recomputed" + }, + { + "id": "prose_P09", "studies": ["forward/B1_dynamic_sent/checks/energy_linear_traction"], + "description": "Separate 57482-step constant-traction SENT calculation and recorded normalised energy imbalance.", + "status": "mapped_not_recomputed" + }, + { + "id": "prose_P10", "studies": ["forward/B2_kalthoff_winkler", "forward/B2_kalthoff_winkler/checks/threshold"], + "description": "Five-mesh half-plate impact: initiation, final orientation and threshold sensitivity.", + "status": "mapped_with_unresolved_inputs" + }, + { + "id": "prose_P11", "studies": ["forward/B3_dynamic_branching"], + "description": "Branching energy accounting at 3676 saved post-ramp states, excluding the ramp, between-state extrema and final unsaved updates.", + "status": "mapped_with_unresolved_inputs" + }, + { + "id": "prose_P12", "studies": ["forward/B4a_30holes", "forward/B4b_10holes", "forward/B4c_single_hole_near", "forward/B4d_single_hole_far"], + "description": "Historical perforated-plate geometry and qualitative crack interactions; the cited 10-hole velocity trace is external literature.", + "status": "mapped_with_unresolved_inputs" + }, + { + "id": "prose_P13", "studies": ["forward/B4a_30holes"], + "description": "30-hole retained speed records and specified clipping/filtering; the filtered peak is not an unfiltered physical peak speed.", + "status": "mapped_with_unresolved_inputs" + }, + { + "id": "prose_P14", "studies": ["forward/B4e_offset_loading_matrix"], + "description": "Four offset/loading combinations: 14 holes inside the plate despite legacy 15-hole filenames; selected morphology only.", + "status": "mapped_with_unresolved_inputs" + }, + { + "id": "prose_P15", "studies": ["forward/B4a_30holes/checks/at1_energy_restoration"], + "description": "AT1 replay energy accounting over 1859 saved states, including the reported 9.9947% largest drift and -7.4893% final imbalance.", + "status": "mapped_with_unresolved_inputs" + }, + { + "id": "prose_P16", "studies": ["forward/B4a_30holes/checks/at1_energy_restoration"], + "description": "Cadence, maximum-history and current-energy controls, including non-improving controls; not independent constitutive validation or one convergence order.", + "status": "mapped_with_unresolved_inputs" + }, + { + "id": "prose_P17", "studies": ["forward/Q1_quasistatic_sent"], + "description": "Q1: 350 accepted targets, convergence measures and load peak comparison using a retrospectively recovered runtime mesh.", + "status": "mapped_with_unresolved_inputs" + }, + { + "id": "prose_P18", "studies": ["forward/Q2_holed_plate"], + "description": "Q2: 200 accepted targets, force curve, residual and tangent-direction records. Replotting and saved-field checks do not independently recompute all these metrics.", + "status": "mapped_not_recomputed" + }, + { + "id": "prose_P19", "studies": ["forward/B1_dynamic_sent/comparisons/cross_code"], + "description": "Matched SENT: three-code protocol and five-implementation field-parity extension; extension timing is outside the reported CPU timing study.", + "status": "mapped_not_recomputed" + }, + { + "id": "prose_P20", "studies": ["forward/B2_kalthoff_winkler/comparisons/cross_code"], + "description": "Matched four-implementation Kalthoff fields, convergence, connected fronts, speed and threshold-dependent extension times.", + "status": "mapped_not_recomputed" + }, + { + "id": "prose_P21", "studies": ["forward/B1_dynamic_sent/comparisons/cpu_optimisation"], + "description": "Five balanced single-thread CPU processes: operator counts, steady-state timing, compilation and full-run costs on the recorded host.", + "status": "mapped_not_recomputed" + }, + { + "id": "prose_P22", "studies": ["forward/B1_dynamic_sent/comparisons/cpu_optimisation"], + "description": "Timing-run convergence and terminal field agreement, including compiled versus eager execution.", + "status": "mapped_not_recomputed" + }, + { + "id": "prose_P23", "studies": ["inverse/scalar_glass_alumina"], + "description": "Scalar glass recovery, initial AD/FD comparison and accepted-state counts; pre-update closure losses are not post-update parameter losses.", + "status": "mapped_with_unresolved_inputs" + }, + { + "id": "prose_P24", "studies": ["inverse/scalar_glass_alumina"], + "description": "Scalar alumina recovery and the unresolved 5.1% initial central-difference discrepancy; later sweeps do not replace the baseline.", + "status": "mapped_with_unresolved_inputs" + }, + { + "id": "prose_P25", "studies": ["inverse/six_start"], + "description": "Six noisy bounded starts: five meet the criterion, the upper-bound start fails; separate scalar Brent reference.", + "status": "mapped_with_unresolved_inputs" + }, + { + "id": "prose_P26", "studies": ["inverse/cross_mesh"], + "description": "Cross-mesh target and three inverse meshes, fitting/check observations, Brent scan and distinct AD initialisation.", + "status": "mapped_not_recomputed" + }, + { + "id": "prose_P27", "studies": ["inverse/cross_mesh"], + "description": "Cross-mesh parameter and observation errors, evaluation counts and stopping outcomes, including fine-mesh AD line-search failure.", + "status": "mapped_with_unresolved_inputs" + }, + { + "id": "prose_P28", "studies": ["inverse/joint_gc_l0"], + "description": "Joint toughness/length-scale protocol: four starts, 16 physical increments, observation weights and 200 optimiser updates.", + "status": "mapped_not_recomputed" + }, + { + "id": "prose_P29", "studies": ["inverse/joint_gc_l0"], + "description": "Selected optimiser evaluation 26 and stored terminal outcomes; one of four starts meets the criterion. Final physical increment is not optimiser evaluation 200.", + "status": "mapped_with_unresolved_inputs" + }, + { + "id": "prose_P30", "studies": ["inverse/joint_gc_l0"], + "description": "Selected-state AD/FD discrepancy of 93.005% and primal replay checks; relative 1e-8 projected-KKT convergence remains unverified.", + "status": "mapped_with_unresolved_inputs" + }, + { + "id": "prose_P31", "studies": ["inverse/implicit_memory"], + "description": "Twelve isolated AT2 custom/unrolled CUDA pairs: incremental memory and numerical agreement, not whole-trajectory memory or active-set-transition validation.", + "status": "mapped_not_recomputed" + }, + { + "id": "prose_P32", "studies": ["inverse/alumina_fd"], + "description": "All 15 historical tolerance/perturbation rows retained; five published-tolerance rows plotted. Original mesh and observations remain unresolved.", + "status": "mapped_with_unresolved_inputs" + }, + { + "id": "prose_P33", "studies": ["inverse/alumina_fd"], + "description": "Separate history-derivative and adjoint-tolerance replay; it does not attribute the original 5.1% discrepancy to a specific cause.", + "status": "mapped_with_unresolved_inputs" + }, + { + "id": "prose_P34", "studies": ["forward/B1_dynamic_sent/comparisons/cpu_optimisation", "forward/B1_dynamic_sent/comparisons/cross_code", "forward/B2_kalthoff_winkler/comparisons/cross_code", "forward/B2_kalthoff_winkler/checks/repeatability", "inverse/implicit_memory"], + "description": "Software/hardware statements and runtime configurations, not an additional numerical study.", + "status": "mapped_not_recomputed" + }, + { + "id": "prose_P35", "studies": ["forward/B1_dynamic_sent/checks/amg"], + "description": "AMG eligibility, rebuild/fallback rules and algorithmic constants; no empirical AMG speedup study is claimed.", + "status": "implementation_check_pending" + }, + { + "id": "prose_P36", "studies": ["documentation/algorithmic_explanations"], + "description": "Algorithmic cost illustrations and prospective observation operators, not additional measured results.", + "status": "explanatory_not_measured" + }, + { + "id": "prose_at2_terminal_profile", "studies": ["forward/B1_dynamic_sent/checks/at2_terminal_profile"], + "description": "New source statement: terminal AT2 local and gradient crack-surface-density contributions of 1.53 and 0.26 per unit crack length within 2 mm of the crack plane, compared with 0.5 each for the static profile. No independent analysis is claimed.", + "status": "pending_analysis_inputs", + "source": { + "label": "manuscript.benchmarks.source", + "sha256": "7d27a3c42c6399b67bb555114b6faa49ed12d9f11ebd6f68f27b9030335e912f", + "observed_utc": "2026-09-21T22:03:30.345997+00:00", + "edition_relation": "Source changed after the original grouped audit; not an assertion of coverage by the indexed PDF edition." + } + } + ], + "studies": { + "documentation/algorithmic_explanations": { + "title": "Algorithmic cost and prospective observation illustrations", + "related_example": null, + "archive_paths": [], + "replot_group": null, "replot_status": "not_applicable", "rerun_status": "not_applicable", + "required_inputs": ["Indexed manuscript explanation of differentiation costs and prospective observation operators."], + "unresolved_gaps": ["Final source-to-document mapping needs refresh; these illustrations are not additional measured results."] + }, + "documentation/regularization_schematic": { + "title": "Analytical AT1/AT2 regularization schematic", + "related_example": null, + "archive_paths": ["illustrations/regularization"], + "replot_group": null, "replot_status": "pending_portable_replot", "rerun_status": "not_applicable", + "required_inputs": ["Analytical profile definitions and selected schematic renderer."], + "unresolved_gaps": ["Isolated rendering and layout verification remain pending; this is not a simulation."] + }, + "forward/B1_dynamic_sent": { + "title": "B1 linear-ramp dynamic SENT", + "related_example": "examples/dynamic/B3_dynamic_sent", + "archive_paths": ["forward/B1_dynamic_sent/linear_ramp", "forward/B1_dynamic_sent/shared_inputs", "forward/B1_dynamic_sent/figures"], + "replot_group": null, "replot_status": "pending_portable_replot", "rerun_status": "pending_full_run", + "required_inputs": ["Recorded 1091-node mesh, AT2 spectral plane-strain configuration, trajectory, historical source and environment."], + "unresolved_gaps": ["Portable renderer and full execution remain unverified; execution source and later presentation source have different identities."] + }, + "forward/B1_dynamic_sent/checks/mesh_refinement": { + "title": "Three-level fixed-length-scale SENT refinement", + "related_example": "examples/dynamic/B3_dynamic_sent", + "archive_paths": ["forward/B1_dynamic_sent/checks/mesh_refinement"], + "replot_group": null, "replot_status": "pending_portable_replot", "rerun_status": "pending_full_run", + "required_inputs": ["Exact 3252/10321/37671-node meshes and trajectories, per-level source receipts, separate analysis source and environment."], + "unresolved_gaps": ["Pinned campaign execution and complete trajectory metric recomputation remain pending; regenerated meshes are not substitutions."] + }, + "forward/B1_dynamic_sent/checks/at2_terminal_profile": { + "title": "Terminal AT2 crack-surface-density decomposition", + "related_example": "examples/dynamic/B3_dynamic_sent", + "archive_paths": ["forward/B1_dynamic_sent/checks/mesh_refinement"], + "replot_group": null, "replot_status": "pending_analysis_inputs", "rerun_status": "pending_analysis_inputs", + "required_inputs": ["Exact analysis script, mesh and terminal state, longitudinal integration region, quadrature and per-unit-crack-length normalisation."], + "unresolved_gaps": ["Selected refinement fields alone do not establish the reported 1.53/0.26 decomposition. Identify and archive the exact procedure, then refresh the frozen document mapping."] + }, + "forward/B1_dynamic_sent/checks/damage_bounds": { + "title": "AT2 post-clamp versus projected-CG bounds", + "related_example": "examples/dynamic/B3_dynamic_sent", + "archive_paths": ["forward/B1_dynamic_sent/checks/damage_bounds"], + "replot_group": null, "replot_status": "pending_portable_replot", "rerun_status": "pending_full_run", + "required_inputs": ["Both complete 6116-step runs, fields, configurations, exact execution source and environment."], + "unresolved_gaps": ["Clean portable execution remains unverified; terminal forward agreement is not active-set derivative validation."] + }, + "forward/B1_dynamic_sent/checks/reaction_mass": { + "title": "Consistent versus lumped damage reaction", + "related_example": "examples/dynamic/B3_dynamic_sent", + "archive_paths": ["forward/B1_dynamic_sent/checks/reaction_mass"], + "replot_group": null, "replot_status": "pending_portable_replot", "rerun_status": "pending_full_run", + "required_inputs": ["Current retained fields and metrics, exact execution source, configuration and environment."], + "unresolved_gaps": ["Full extracted execution remains pending; mechanical inertia is lumped in both routes."] + }, + "forward/B1_dynamic_sent/checks/subcycling": { + "title": "SENT damage-update cadence and repeated timing", + "related_example": "examples/dynamic/B3_dynamic_sent", + "archive_paths": ["forward/B1_dynamic_sent/checks/subcycling"], + "replot_group": "forward", "replot_status": "retained_data_checked", "rerun_status": "pending_full_run", + "required_inputs": ["Cadence 1/2/3 trajectories, submitted mesh, terminal metrics, nine timing records, pinned driver and recorded environment."], + "unresolved_gaps": ["Figure, terminal metrics and timing summaries were checked; complete trajectory metric recomputation and new repeated timing runs remain pending."] + }, + "forward/B1_dynamic_sent/checks/energy_c2_displacement": { + "title": "C2 displacement-ramp boundary work balance", + "related_example": "examples/dynamic/B3_dynamic_sent", + "archive_paths": ["forward/B1_dynamic_sent/checks/c2_work"], + "replot_group": null, "replot_status": "pending_portable_replot", "rerun_status": "pending_full_run", + "required_inputs": ["6117 recorded states, full boundary inertia/work data, trusted legacy tensor inputs and exact source/environment."], + "unresolved_gaps": ["Portable execution remains pending; do not substitute the linear-ramp or traction histories."] + }, + "forward/B1_dynamic_sent/checks/energy_linear_traction": { + "title": "Constant-traction SENT energy balance", + "related_example": "examples/dynamic/B3_dynamic_sent", + "archive_paths": ["forward/B1_dynamic_sent/checks/traction_work"], + "replot_group": null, "replot_status": "pending_portable_replot", "rerun_status": "pending_inputs", + "required_inputs": ["762-node constant-traction configuration, 57482-step history, native displacement/damage states and historical source/environment."], + "unresolved_gaps": ["Retained CSVs support curve rendering; all-state native fields for independent energy reintegration are incomplete. The study identifier is historical; loading is constant traction."] + }, + "forward/B1_dynamic_sent/comparisons/cross_code": { + "title": "Matched-code SENT fields and crack histories", + "related_example": "examples/dynamic/B3_dynamic_sent", + "archive_paths": ["forward/B1_dynamic_sent/comparisons/cross_code"], + "replot_group": null, "replot_status": "pending_portable_replot", "rerun_status": "pending_full_run", + "required_inputs": ["Matched 9031-node mesh, baseline and selected extension trajectories, exact backend sources and separate environment locks."], + "unresolved_gaps": ["Portable backend launchers and extracted execution remain unverified. Preserve the precise Akantu build; the field-parity extension does not supply the CPU timing result."] + }, + "forward/B1_dynamic_sent/comparisons/cpu_optimisation": { + "title": "Single-thread CPU operator and execution timing", + "related_example": "examples/dynamic/B3_dynamic_sent", + "archive_paths": ["forward/B1_dynamic_sent/comparisons/cpu_optimisation"], + "replot_group": null, "replot_status": "pending_portable_replot", "rerun_status": "pending_inputs", + "required_inputs": ["Five balanced process records, per-step timing, convergence/iteration records, endpoint parity and exact compiler/library environment."], + "unresolved_gaps": ["Complete runtime environment and repeated extracted execution remain pending. Steady-state timings exclude the first 100 steps; full-run totals retain first-use compilation."] + }, + "forward/B1_dynamic_sent/checks/amg": { + "title": "AMG implementation rules", + "related_example": null, + "archive_paths": [], + "replot_group": null, "replot_status": "not_applicable", "rerun_status": "not_applicable", + "required_inputs": ["Selected implementation source for eligibility, rebuild, fallback and retry constants."], + "unresolved_gaps": ["Source-level constant verification remains pending; no additional empirical timing campaign is required by the reported text."] + }, + "forward/B2_kalthoff_winkler": { + "title": "Five-mesh half-plate Kalthoff-Winkler impact", + "related_example": "examples/dynamic/B2_kalthoff_winkler", + "archive_paths": ["forward/B2_kalthoff_winkler/mesh3_selected_fields"], + "replot_group": null, "replot_status": "rights_hold", "rerun_status": "pending_inputs", + "required_inputs": ["Five exact native meshes, final damage histories, first-extension data and original execution identities; photograph reuse permission for the composite."], + "unresolved_gaps": ["Selected mesh3 frames do not reproduce the five-mesh table. Complete execution inputs and a simulation-only portable renderer remain unresolved; photographs and embedding figures are excluded pending permission."] + }, + "forward/B2_kalthoff_winkler/checks/threshold": { + "title": "Kalthoff final-field threshold sensitivity", + "related_example": "examples/dynamic/B2_kalthoff_winkler", + "archive_paths": ["forward/B2_kalthoff_winkler/checks/threshold"], + "replot_group": null, "replot_status": "pending_portable_replot", "rerun_status": "pending_inputs", + "required_inputs": ["Retained threshold CSV, five native final fields, exact extractor and rendering environment."], + "unresolved_gaps": ["CSV rendering is not independent field recomputation; complete five-mesh field dependencies remain unresolved. These are final-field orientation percentiles, not instantaneous front angles."] + }, + "forward/B2_kalthoff_winkler/checks/repeatability": { + "title": "Five-replica CUDA repeatability", + "related_example": "examples/dynamic/B2_kalthoff_winkler", + "archive_paths": ["forward/B2_kalthoff_winkler/checks/repeatability"], + "replot_group": null, "replot_status": "pending_portable_replot", "rerun_status": "pending_full_run", + "required_inputs": ["Five native replica records, mesh hash, pinned source and public-safe software/hardware environment."], + "unresolved_gaps": ["New CUDA execution remains pending; deterministic algorithms were disabled and cross-hardware bitwise agreement is not claimed."] + }, + "forward/B2_kalthoff_winkler/comparisons/cross_code": { + "title": "Four-route matched-code Kalthoff comparison", + "related_example": "examples/dynamic/B2_kalthoff_winkler", + "archive_paths": ["forward/B2_kalthoff_winkler/comparisons/cross_code"], + "replot_group": null, "replot_status": "pending_portable_replot", "rerun_status": "pending_full_run", + "required_inputs": ["PhAST eager/compiled, legacy FEniCS and DOLFINx 0.11.0 trajectories, frozen source identities and backend environment locks."], + "unresolved_gaps": ["A clean four-route portable launcher and extracted execution remain pending; endpoint images alone cannot recover front/time histories."] + }, + "forward/B3_dynamic_branching": { + "title": "AT1 dynamic branching and post-ramp balance", + "related_example": "examples/dynamic/B7_dynamic_crack_branching_comsol", + "archive_paths": ["forward/B3_dynamic_branching"], + "replot_group": null, "replot_status": "pending_portable_replot", "rerun_status": "pending_inputs", + "required_inputs": ["Selected native fields and post-ramp records, exact execution source, full-run configuration and environment."], + "unresolved_gaps": ["Historical source identities conflict and full trajectory dependencies remain unresolved. Saved post-ramp accounting excludes initial ramp and unsaved states; proprietary model files are not distributed."] + }, + "forward/B4a_30holes": { + "title": "Historical 30-hole perforated plate", + "related_example": "examples/dynamic/B6_perforated_30holes", + "archive_paths": ["forward/B4a_30holes/historical", "forward/B4a_30holes/figures"], + "replot_group": null, "replot_status": "raster_and_csv_only", "rerun_status": "pending_inputs", + "required_inputs": ["Original native mesh/plotted damage states, speed extraction inputs, exact configuration, execution source and environment."], + "unresolved_gaps": ["Original native fields were not recovered; rasters and CSVs do not recreate them. Historical AT2 energy diagnostics must not be relabelled as a corrected AT1 balance."] + }, + "forward/B4b_10holes": { + "title": "Historical 10-hole perforated plate", + "related_example": "examples/dynamic/B6_perforated_10holes", + "archive_paths": ["forward/B4b_10holes"], + "replot_group": null, "replot_status": "raster_and_csv_only", "rerun_status": "pending_inputs", + "required_inputs": ["Original native mesh/plotted damage states, configuration, execution source and environment."], + "unresolved_gaps": ["Original native fields were not recovered; retained rasters are not a field-level reproduction. Historical AT2 energy diagnostics are not corrected AT1 accounting."] + }, + "forward/B4c_single_hole_near": { + "title": "Historical near single-hole plate", + "related_example": "examples/dynamic/B6_perforated_1hole_near", + "archive_paths": ["forward/B4c_single_hole_near"], + "replot_group": null, "replot_status": "raster_and_csv_only", "rerun_status": "pending_inputs", + "required_inputs": ["Original native mesh/plotted damage states, configuration, execution source and environment."], + "unresolved_gaps": ["Original native fields were not recovered; retained rasters are not a field-level reproduction. Historical AT2 energy diagnostics are not corrected AT1 accounting."] + }, + "forward/B4d_single_hole_far": { + "title": "Historical far single-hole plate", + "related_example": "examples/dynamic/B6_perforated_1hole_far", + "archive_paths": ["forward/B4d_single_hole_far"], + "replot_group": null, "replot_status": "raster_and_csv_only", "rerun_status": "pending_inputs", + "required_inputs": ["Original native mesh/plotted damage states, configuration, execution source and environment."], + "unresolved_gaps": ["Original native fields were not recovered; retained rasters are not a field-level reproduction. Historical AT2 energy diagnostics are not corrected AT1 accounting."] + }, + "forward/B4e_offset_loading_matrix": { + "title": "Perforated-plate offset/loading matrix", + "related_example": null, + "archive_paths": ["forward/B4e_offset_loading_matrix"], + "replot_group": null, "replot_status": "pending_portable_replot", "rerun_status": "pending_inputs", + "required_inputs": ["Four native selected-state bundles, exact geometry, extraction hashes and historical simulation inputs/environment."], + "unresolved_gaps": ["Portable native rendering and full-run provenance remain incomplete. Native topology has 14 holes despite legacy 15-hole names; selected snapshots do not reproduce full trajectories."] + }, + "forward/B4a_30holes/checks/at1_energy_restoration": { + "title": "AT1 energy replay and seven controls", + "related_example": "examples/dynamic/B6_perforated_30holes", + "archive_paths": ["forward/B4a_30holes/checks/energy_controls"], + "replot_group": null, "replot_status": "pending_portable_replot", "rerun_status": "pending_inputs", + "required_inputs": ["Frozen parent mesh/source, all 1859 saved states for baseline integration, seven control records and exact environment."], + "unresolved_gaps": ["All-state baseline integration inputs and portable parent-dependent execution remain incomplete. Selected fields and controls do not replace the full baseline; retain deficits and non-improving controls."] + }, + "forward/Q1_quasistatic_sent": { + "title": "Q1 quasi-static SENT", + "related_example": "examples/quasistatic/miehe_tension", + "archive_paths": ["forward/Q1_quasistatic_sent"], + "replot_group": null, "replot_status": "reference_provenance_hold", "rerun_status": "pending_inputs", + "required_inputs": ["350-target records, recovered 2423-node mesh, pinned source/environment and reference-curve provenance/permission."], + "unresolved_gaps": ["Runtime mesh recovery is retrospective. Reference-curve redistribution permission, study environment and isolated execution remain unresolved."] + }, + "forward/Q2_holed_plate": { + "title": "Q2 quasi-static holed plate", + "related_example": "examples/quasistatic/notched_holed_plate", + "archive_paths": ["forward/Q2_holed_plate", "sources/q2"], + "replot_group": "q2", "replot_status": "retained_data_checked", "rerun_status": "pending_full_run", + "required_inputs": ["Completed 200-target run, all retained fields, reaction history, tangent records and selected source/environment."], + "unresolved_gaps": ["Reaction and three damage panels were regenerated and 200 saved fields checked; a complete new simulation from the extracted data remains unverified."] + }, + "verification/standing_wave": { + "title": "Undamaged analytical standing wave", + "related_example": null, + "archive_paths": ["verification/standing_wave"], + "replot_group": "forward", "replot_status": "retained_data_checked", "rerun_status": "pending_full_run", + "required_inputs": ["Four refinement-level error records, summary, exact historical driver and environment."], + "unresolved_gaps": ["Figure and observed orders were recomputed from retained errors; new mechanics simulations remain pending. This does not verify fracture evolution."] + }, + "inverse/scalar_glass_alumina": { + "title": "Scalar glass and alumina toughness recovery", + "related_example": null, + "archive_paths": ["inverse/scalar_gc/glass", "inverse/scalar_gc/alumina", "inverse/scalar_gc/figures"], + "replot_group": "inverse", "replot_status": "retained_data_checked", "rerun_status": "pending_inputs", + "required_inputs": ["Exact retained result JSONs, original meshes/targets, executed source/patches, commands and environments."], + "unresolved_gaps": ["Figure regeneration does not recover original execution inputs. Producer geometry and baseline description differ; glass overrides remain unresolved. Preserve glass pre-update closure alignment and the unresolved alumina baseline discrepancy."] + }, + "inverse/six_start": { + "title": "Six-start noisy scalar recovery", + "related_example": null, + "archive_paths": ["inverse/scalar_gc/six_start", "inverse/scalar_gc/target_dependency", "sources/six_start"], + "replot_group": "inverse", "replot_status": "retained_data_checked", "rerun_status": "pending_inputs", + "required_inputs": ["Six objective histories, fixed-seed target, original mesh or verified regenerated ordering, pinned driver and mesher/runtime environment."], + "unresolved_gaps": ["Mesh ordering must match the retained 2842-entry observations; clean full execution remains pending. Preserve the failed upper-bound start."] + }, + "inverse/cross_mesh": { + "title": "Cross-mesh toughness recovery and observations", + "related_example": null, + "archive_paths": ["inverse/cross_mesh_clean_sent", "sources/cross_mesh_clean_sent"], + "replot_group": "inverse", "replot_status": "retained_data_checked", "rerun_status": "pending_full_run", + "required_inputs": ["Target/inverse meshes, observation operator, six selected fields, evaluation histories, frozen source and study-specific environment."], + "unresolved_gaps": ["Full extracted execution remains unverified. Selected fields and histories support the figures, not field-level recomputation of every intermediate objective; retain failed stopping outcomes."] + }, + "inverse/joint_gc_l0": { + "title": "Joint toughness and length-scale recovery", + "related_example": null, + "archive_paths": ["inverse/joint_gc_l0", "sources/joint_gc_l0"], + "replot_group": "inverse", "replot_status": "retained_data_checked", "rerun_status": "pending_full_run", + "required_inputs": ["Four-start histories, target and evaluation-26 fields, protocol, primal replay records and exact source/environment."], + "unresolved_gaps": ["Full extracted execution remains pending. Displayed evaluation 26 differs from terminal evaluation 200; relative projected-KKT normalisation is absent and the 93.005% AD/FD discrepancy remains unresolved."] + }, + "inverse/implicit_memory": { + "title": "Isolated AT2 implicit-backward memory", + "related_example": null, + "archive_paths": ["verification/at2_memory", "sources/at2_memory"], + "replot_group": "inverse", "replot_status": "retained_data_checked", "rerun_status": "pending_inputs", + "required_inputs": ["Twelve paired records across four meshes, archived worker and compatible recorded CUDA/PyTorch/GPU environment."], + "unresolved_gaps": ["CUDA environment completion and new worker execution remain pending. Measurements concern one interior damage solve, not whole trajectories or active-set transitions."] + }, + "inverse/alumina_fd": { + "title": "Alumina finite-difference sweep and backward replay", + "related_example": null, + "archive_paths": ["verification/alumina_fd", "sources/alumina_fd", "sources/alumina_backward_replay"], + "replot_group": "inverse", "replot_status": "retained_data_checked", "rerun_status": "pending_inputs", + "required_inputs": ["All 15 sweep rows, original mesh/observations, exact source/environment and separately identified later replay inputs."], + "unresolved_gaps": ["Historical sweep mesh and observations remain unresolved; later backward replay is a distinct calculation, not a replacement for the original baseline or sweep."] + } + } +} diff --git a/reproduction/paper1/replot_forward.py b/reproduction/paper1/replot_forward.py new file mode 100644 index 000000000..3b0dd4f45 --- /dev/null +++ b/reproduction/paper1/replot_forward.py @@ -0,0 +1,254 @@ +#!/usr/bin/env python3 +"""Replot retained standing-wave and SENT subcycling data; no simulations. + +Use an existing environment with numpy, matplotlib, h5py, torch, meshio, +PyYAML and Pillow. No original repository, git checkout or network is used. +""" + +from __future__ import annotations + +import argparse +import csv +from datetime import datetime, timezone +import hashlib +import importlib +import importlib.metadata +import json +import os +from pathlib import Path, PurePosixPath, PureWindowsPath +import platform +import shutil +import sys +from typing import Any + + +WAVE = "verification/standing_wave" +CADENCE = "forward/B1_dynamic_sent/checks/subcycling" +CODE = "shared/code/scripts/cmame_revision" +WAVE_FILES = ("summary.json", "standing_wave_verification.csv", "receipt_audit.json") +CADENCE_FILES = ( + "summary.json", "b1_submitted_mesh.msh", "trajectory_metrics.csv", + "timing_measurements.csv", "terminal_metrics.csv", + "trajectory_cadence_1.npz", "trajectory_cadence_2.npz", "trajectory_cadence_3.npz", +) +CODE_FILES = ( + "journal_figure_style.py", "render_known_journal_figures.py", + "subcycling_sent_benchmark_audit.py", +) + + +def digest(path: Path) -> str: + value = hashlib.sha256() + with path.open("rb") as handle: + for block in iter(lambda: handle.read(1024 * 1024), b""): + value.update(block) + return value.hexdigest() + + +def checked_path(root: Path, row: dict[str, Any]) -> Path: + name = row["path"] + if not isinstance(name, str) or not name: + raise ValueError(f"Invalid input path: {name!r}") + relative = PurePosixPath(name) + if (relative.is_absolute() or PureWindowsPath(name).drive or ".." in relative.parts + or "\\" in name or not relative.parts): + raise ValueError(f"Unsafe input path: {name}") + path = root / name + if path.is_symlink() or not path.resolve().is_relative_to(root): + raise ValueError(f"Input escapes archive or is a symlink: {name}") + if not path.is_file() or path.stat().st_size != row["bytes"]: + raise ValueError(f"Input missing or resized: {name}") + if digest(path) != row["sha256"]: + raise ValueError(f"Input hash mismatch: {name}") + return path + + +def read_csv(path: Path, numeric: bool = False) -> list[dict[str, Any]]: + with path.open(newline="") as handle: + rows = list(csv.DictReader(handle)) + return [{key: float(value) for key, value in row.items()} for row in rows] if numeric else rows + + +def write_json(path: Path, data: Any) -> None: + path.write_text(json.dumps(data, indent=2, allow_nan=False) + "\n") + + +def configure_runtime(output: Path) -> None: + sys.dont_write_bytecode = True + os.environ["PYTHONDONTWRITEBYTECODE"] = "1" + os.environ["MPLBACKEND"] = "Agg" + for key in ("OMP_NUM_THREADS", "OPENBLAS_NUM_THREADS", "MKL_NUM_THREADS", "NUMEXPR_NUM_THREADS"): + os.environ[key] = "1" + for key, name in (("MPLCONFIGDIR", "matplotlib"), ("XDG_CACHE_HOME", "cache"), ("TMPDIR", "tmp")): + directory = output / "runtime" / name + directory.mkdir(parents=True) + os.environ[key] = str(directory) + + +def main() -> None: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--archive", type=Path, required=True, help="Root containing manifest.json") + parser.add_argument("--output", type=Path, required=True, help="Fresh directory outside archive") + parser.add_argument("--expected-file-count", type=int) + args = parser.parse_args() + archive, output = args.archive.resolve(), args.output.resolve() + if output.exists() or output.is_relative_to(archive) or archive.is_relative_to(output): + raise ValueError("Use a fresh output directory separate from the archive") + manifest_path = archive / "manifest.json" + manifest_hash = digest(manifest_path) + manifest = json.loads(manifest_path.read_text()) + if args.expected_file_count is not None and len(manifest["files"]) != args.expected_file_count: + raise ValueError("Manifest has not reached the requested materialisation count") + index = {row["path"]: row for row in manifest["files"]} + if len(index) != len(manifest["files"]): + raise ValueError("Duplicate archive paths in manifest") + names = ([f"{WAVE}/{name}" for name in WAVE_FILES] + + [f"{CADENCE}/{name}" for name in CADENCE_FILES] + + [f"{CODE}/{name}" for name in CODE_FILES]) + entries = [index[name] for name in names] + for entry in entries: + checked_path(archive, entry) + output.mkdir(parents=True, exist_ok=False) + configure_runtime(output) + import numpy as np + + sys.path.insert(0, str(archive / CODE)) + style = importlib.import_module("journal_figure_style") + known = importlib.import_module("render_known_journal_figures") + cadence = importlib.import_module("subcycling_sent_benchmark_audit") + for module in (style, known, cadence): + if Path(module.__file__).resolve().parent != (archive / CODE).resolve(): + raise ValueError("Plotting module was not loaded from selected archive") + style.apply_style() + figures = output / "figures" + figures.mkdir() + + wave_rows = read_csv(archive / WAVE / "standing_wave_verification.csv", numeric=True) + wave_summary = json.loads((archive / WAVE / "summary.json").read_text()) + h = np.asarray([row["h"] for row in wave_rows]) + error = np.asarray([row["relative_l2_error"] for row in wave_rows]) + if len(h) != 4 or not np.isfinite([h, error]).all() or not (h > 0).all() or not (error > 0).all(): + raise ValueError("Expected four finite, positive standing-wave records") + orders = np.log(error[:-1] / error[1:]) / np.log(h[:-1] / h[1:]) + np.testing.assert_allclose(orders, wave_summary["observed_orders_relative_l2"], rtol=1e-12, atol=1e-12) + if not (np.diff(error) < 0).all(): + raise ValueError("Standing-wave errors are not monotone") + + # The historical renderer needs its old relative layout, but only these tiny + # staged records are replayed. All renderer destinations are redirected. + replay = output / "replay" + wave_replay = replay / "results/r1_2_standing_wave_20260828_job119146" + wave_replay.mkdir(parents=True) + for name in WAVE_FILES: + shutil.copyfile(archive / WAVE / name, wave_replay / name) + known.ROOT = replay + known.RESULTS = replay / "results" + known.FIGURES_JOURNAL = figures + known.ASSETS_JOURNAL = output / "renderer_assets" + known.render_standing_wave([]) + + data = archive / CADENCE + timing_rows = read_csv(data / "timing_measurements.csv") + timing = cadence.cadence_timing_summary(timing_rows) + stored = json.loads((data / "summary.json").read_text())["timing"] + for interval, record in timing.items(): + for key in ("count", "minimum", "median", "maximum"): + np.testing.assert_allclose(record[key], stored[str(interval)]["explicit_update_wall_s"][key], rtol=1e-12, atol=1e-12) + np.testing.assert_allclose(record["saving_percent"] / 100, + stored[str(interval)]["median_reduction_relative_to_cadence_1"], rtol=1e-12, atol=1e-12) + if {int(row["steps"]) for row in timing_rows} != {6116}: + raise ValueError("Unexpected timing step count") + compacts = {} + for interval in (1, 2, 3): + with np.load(data / f"trajectory_cadence_{interval}.npz", allow_pickle=False) as arrays: + compacts[interval] = {"nodes": arrays["nodes"].copy(), "elements": arrays["elements"].copy(), + "damage": arrays["damage"][-1:].copy()} + state = compacts[interval] + if not np.isfinite(state["damage"]).all() or state["damage"].min() < -1e-12 or state["damage"].max() > 1 + 1e-12: + raise ValueError("Invalid terminal damage field") + for key in ("nodes", "elements"): + np.testing.assert_array_equal(compacts[1][key], state[key]) + terminal = cadence.terminal_comparisons(compacts) + recorded_terminal = read_csv(data / "terminal_metrics.csv", numeric=True) + if len(terminal) != len(recorded_terminal): + raise ValueError("Terminal metric row count differs") + max_difference = 0.0 + for result, recorded in zip(terminal, recorded_terminal): + if result.keys() != recorded.keys(): + raise ValueError("Terminal metric columns differ") + for key, value in result.items(): + np.testing.assert_allclose(value, recorded[key], rtol=1e-10, atol=1e-12) + max_difference = max(max_difference, abs(value - recorded[key])) + trajectory = read_csv(data / "trajectory_metrics.csv", numeric=True) + mesh = data / "b1_submitted_mesh.msh" + notch = style.mesh_initial_crack(mesh, ("notch_upper", "notch_lower"), nodes=compacts[1]["nodes"]) + cadence.write_visual(compacts, trajectory, figures / "damage_cadence.png", journal=True, + compact=True, timing_rows=None, notch=notch, notch_source=mesh) + + from PIL import Image + pixel_checks = [] + for name in ("standing_wave_verification", "damage_cadence"): + with Image.open(figures / f"{name}.png") as image: + pixels = np.asarray(image.convert("RGB")) + nonwhite = float(np.mean(np.any(pixels < 240, axis=2))) + if nonwhite < 0.005 or nonwhite > 0.95: + raise ValueError(f"Blank or invalid raster output: {name}") + pixel_checks.append({"path": f"figures/{name}.png", "width": int(pixels.shape[1]), + "height": int(pixels.shape[0]), "nonwhite_fraction": nonwhite}) + + numeric = {"standing_wave": {"label": "fig:standing_wave_verification", "orders": orders.tolist(), + "orders_match_retained_summary": True, "mechanics_errors_recomputed": False}, + "subcycling": {"labels": ["fig:damage_cadence", "tab:damage_cadence"], "timing": timing, + "timing_matches_retained_summary": True, "terminal_metrics": terminal, + "terminal_metrics_max_abs_difference": max_difference, + "terminal_metrics_match_retained_csv": True, + "trajectory_metrics_recomputed": False}} + write_json(output / "numeric_checks.json", numeric) + for entry in entries: + checked_path(archive, entry) + current = json.loads(manifest_path.read_text()) + final_index = {row["path"]: row for row in current["files"]} + if [final_index[name] for name in names] != entries: + raise ValueError("Selected manifest entries changed during replot") + products = sorted(figures.iterdir()) + [output / "numeric_checks.json"] + receipt = { + "status": "completed_bounded_replot_and_numeric_checks", "created_utc": datetime.now(timezone.utc).isoformat(), + "archive_file_count": len(manifest["files"]), "archive_payload_bytes": manifest["payload_bytes"], + "manifest_sha256_before": manifest_hash, "manifest_sha256_after": digest(manifest_path), + "wrapper_sha256": digest(Path(__file__)), "verified_input_files": len(entries), + "verified_input_bytes": sum(row["bytes"] for row in entries), "inputs": entries, + "selected_input_hashes_unchanged": True, "solver_runs": 0, "remote_transfers": 0, + "original_repository_access_required": False, "pixel_checks": pixel_checks, + "runtime": {"python": platform.python_version(), **{name: importlib.metadata.version(name) + for name in ("numpy", "matplotlib", "h5py", "torch", "meshio", "PyYAML", "Pillow")}}, + "limits": ["Retained-data replot checks, not full simulation reproduction or scientific validation.", + "Standing-wave convergence orders recomputed from retained errors, not displacement fields or mechanics.", + "Nine retained timing measurements summarised; no timings measured afresh.", + "Subcycling terminal metrics recomputed at three thresholds; full trajectory metrics reused, not recomputed.", + "No solver, equilibrium-residual or all-figure verification.", + "Only standing-wave and SENT subcycling inputs are included in these checks."], + "products": [{"path": str(path.relative_to(output)), "bytes": path.stat().st_size, + "sha256": digest(path)} for path in products], + } + write_json(output / "receipt.json", receipt) + report = ["# Bounded Forward Replot", "", f"Status: {receipt['status']}.", "", + f"Archive manifest: {len(manifest['files'])} entries; {manifest['payload_bytes']:,} bytes.", + f"Verified and rechecked: {len(entries)} selected inputs; {receipt['verified_input_bytes']:,} bytes.", + "", "## Results", "", + "S1 convergence orders: " + ", ".join(f"{value:.8f}" for value in orders) + ". Match retained summary.", + "Subcycling terminal metrics match all nine retained threshold/cadence records.", + "", "| Interval | Median update time (s) | Saving (%) |", "| --- | ---: | ---: |"] + report.extend(f"| {key} | {value['median']:.9f} | {value['saving_percent']:.6f} |" for key, value in timing.items()) + report.extend(["", "Timing is accumulated explicit-update time over 6,116 steps, three retained one-thread CPU processes per interval. It excludes energy evaluation, telemetry and file output; these are not fresh timings.", + "", "## Replot", "", "From the repository root with the same plotting dependencies:", "", "```sh", + "python -B reproduction/paper1/replot_forward.py --archive /path/to/phast-paper-reproduction --output /path/to/new-forward-check", + "```", "", "Products: `figures/standing_wave_verification.{png,pdf}`, `figures/damage_cadence.{png,pdf}`, `numeric_checks.json`, `receipt.json`.", + "", "## Limits", ""]) + report.extend("- " + limit for limit in receipt["limits"]) + (output / "README.md").write_text("\n".join(report) + "\n") + print(json.dumps({key: receipt[key] for key in ("status", "archive_file_count", "verified_input_files", + "verified_input_bytes", "selected_input_hashes_unchanged", "solver_runs")}, indent=2)) + + +if __name__ == "__main__": + main() diff --git a/reproduction/paper1/replot_inverse.py b/reproduction/paper1/replot_inverse.py new file mode 100644 index 000000000..7be1dbd38 --- /dev/null +++ b/reproduction/paper1/replot_inverse.py @@ -0,0 +1,517 @@ +#!/usr/bin/env python3 +"""Replot retained inverse data with exact-input checks; no simulation or new gradients.""" + +from __future__ import annotations + +import argparse +import ast +from collections.abc import Callable, Sequence +from datetime import datetime, timezone +import hashlib +import importlib.abc +from importlib.machinery import ModuleSpec +import importlib.metadata +import json +import os +from pathlib import Path, PurePosixPath, PureWindowsPath +import platform +import runpy +import shutil +import sys +import traceback +from types import ModuleType +from typing import Any + + +# Historical relative paths are replay identifiers, not public workflow names. +EVIDENCE = "papers/paper/reviewer_evidence" +STUDIES = EVIDENCE + "/studies" +SCRIPTS = "scripts/cmame_revision" +CASES = ("at2_memory", "alumina_fd", "joint_selected", "scalar", "cross_mesh") +GAPS = { + "at2_memory": "One interior AT2 update only; no trajectory-memory or runtime claim.", + "alumina_fd": "The historical sweep is distinct from both baseline inversion and later " + "backward-rule replay. Raw historical perturbation fields remain absent.", + "joint_selected": "Evaluation 26 is the selected minimum-loss state, not terminal " + "evaluation 200. Full terminal fields remain absent. The selected " + "93.005% AD-FD disagreement and unverified relative projected-KKT " + "criterion are not resolved by replotting.", + "scalar": "Baseline fields, meshes and execution-source/environment locks remain " + "missing. Glass closure losses are pre-update states 0-9; alumina losses " + "are accepted states 1-10. The six-start 4x failure remains included.", + "cross_mesh": "All six outcomes are retained, including coarse AD budget exhaustion " + "and fine AD abnormal line-search termination. Recovery, gradient " + "verification and comparative efficiency remain separate questions.", +} + + +def digest(path: str | Path) -> str: + value = hashlib.sha256() + with Path(path).open("rb") as stream: + for block in iter(lambda: stream.read(1024 * 1024), b""): + value.update(block) + return value.hexdigest() + + +def safe_relative(value: str) -> Path: + if not isinstance(value, str) or not value or "\\" in value: + raise ValueError(f"Invalid relative path: {value!r}") + path = PurePosixPath(value) + if path.is_absolute() or PureWindowsPath(value).drive or ".." in path.parts or not path.parts: + raise ValueError(f"Unsafe relative path: {value!r}") + return Path(*path.parts) + + +def verify(root: Path, name: str, row: dict[str, Any]) -> Path: + path = root / safe_relative(name) + if path.is_symlink() or not path.resolve().is_relative_to(root): + raise ValueError(f"Input escapes root or is a symlink: {name}") + if not path.is_file() or path.stat().st_size != row["bytes"]: + raise ValueError(f"Missing or resized input: {name}") + if digest(path) != row["sha256"]: + raise ValueError(f"SHA-256 mismatch: {name}") + return path + + +class NoSolverImports(importlib.abc.MetaPathFinder): + def find_spec(self, fullname: str, path: Sequence[str] | None = None, + target: ModuleType | None = None) -> ModuleSpec | None: + if fullname.split(".")[0] in {"torch", "phast", "torch_pf_solver", "gmsh"}: + raise ImportError(f"Numerical-solver import prohibited in replot: {fullname}") + return None + + +class Inputs: + def __init__(self, archive: Path, output: Path) -> None: + self.archive = archive + self.output = output + self.layout = output / "legacy" + self.layout.mkdir(parents=True) + self.manifest = archive / "manifest.json" + self.manifest_hash = digest(self.manifest) + self.rows = json.loads(self.manifest.read_text())["files"] + self.by_legacy: dict[str, list[dict[str, Any]]] = {} + names = set() + for row in self.rows: + name = safe_relative(row["path"]).as_posix() + if name in names: + raise ValueError(f"Duplicate archive path: {name}") + names.add(name) + # Pinned source variants must never collide with the plotting layout. + if row.get("kind") != "copy": + continue + legacy = safe_relative(row["legacy_path"]).as_posix() + self.by_legacy.setdefault(legacy, []).append(row) + self.used: dict[str, dict[str, Any]] = {} + self.adaptations: list[dict[str, Any]] = [] + + def row(self, legacy: str) -> dict[str, Any]: + legacy = safe_relative(legacy).as_posix() + matches = self.by_legacy.get(legacy, []) + if not matches or len({(r["bytes"], r["sha256"]) for r in matches}) != 1: + raise ValueError(f"Missing or conflicting copy mapping for {legacy}") + chosen = sorted(matches, key=lambda r: ( + "renderers_and_checks" not in r.get("groups", []), r["path"]))[0] + if len(matches) > 1: + alias = {"kind": "identical_manifest_aliases", "legacy_path": legacy, + "chosen": chosen["path"], "sha256": chosen["sha256"], + "aliases": sorted(r["path"] for r in matches)} + if alias not in self.adaptations: + self.adaptations.append(alias) + return chosen + + def take(self, legacy: str, expected: str | None = None) -> Path: + legacy = safe_relative(legacy).as_posix() + row = self.row(legacy) + if expected is not None and row["sha256"] != expected: + raise ValueError(f"Recorded scientific input hash differs: {legacy}") + target = self.layout / safe_relative(legacy) + if legacy not in self.used: + source = verify(self.archive, row["path"], row) + target.parent.mkdir(parents=True, exist_ok=True) + shutil.copyfile(source, target) + verify(self.layout, legacy, row) + self.used[legacy] = row + return target + + def group(self, name: str) -> None: + matches = [r for r in self.rows if r.get("kind") == "copy" + and name in r.get("groups", [])] + if not matches: + raise ValueError(f"Missing copy group: {name}") + for row in matches: + self.take(row["legacy_path"]) + + def read(self, legacy: str) -> dict[str, Any]: + return json.loads(self.take(legacy).read_text()) + + def old_to_legacy(self, value: str) -> str: + # Historical records may contain native separators; manifests may not. + value = value.replace("\\", "/") + if value in self.by_legacy: + return value + matches = [name for name in self.by_legacy if value.endswith("/" + name)] + if len(matches) != 1: + raise ValueError(f"Old path has no unambiguous manifest mapping: {value}") + return matches[0] + + def locked_record(self, record: dict[str, Any]) -> Path: + return self.take(self.old_to_legacy(record["path"]), record["sha256"]) + + def renderer(self, name: str, transform: Callable[[ast.Module], ast.Module] | None = None, + additions: dict[str, Any] | None = None) -> dict[str, Any]: + source = self.take(SCRIPTS + "/" + name) + self.take(SCRIPTS + "/journal_figure_style.py") + directory = str(source.parent) + if directory not in sys.path: + sys.path.insert(0, directory) + if transform is None: + return runpy.run_path(str(source), run_name="_retained_inverse_plotter") + tree = ast.parse(source.read_text(), filename=str(source)) + tree = ast.fix_missing_locations(transform(tree)) + adapted = self.output / "adapted" / name + adapted.parent.mkdir(parents=True, exist_ok=True) + adapted.write_text(ast.unparse(tree) + "\n") + self.adaptations.append({"source": source.relative_to(self.layout).as_posix(), + "source_sha256": digest(source), + "adapted_source": adapted.relative_to(self.output).as_posix(), + "adapted_sha256": digest(adapted)}) + namespace = {"__file__": str(source), "__name__": "_portable_inverse_plotter", + **(additions or {})} + exec(compile(tree, str(source), "exec"), namespace) + return namespace + + def finish(self) -> dict[str, Any]: + for legacy, row in self.used.items(): + verify(self.archive, row["path"], row) + verify(self.layout, legacy, row) + current = {r["path"]: r for r in json.loads(self.manifest.read_text())["files"]} + for row in self.used.values(): + if current.get(row["path"]) != row: + raise ValueError(f"Used public manifest entry changed: {row['path']}") + mapping = [{k: row[k] for k in ("path", "legacy_path", "bytes", "sha256")} + for row in sorted(self.used.values(), key=lambda x: x["path"])] + (self.output / "verified_inputs.json").write_text(json.dumps(mapping, indent=2) + "\n") + return {"verified_files": len(mapping), "verified_bytes": sum(r["bytes"] for r in mapping), + "archive_and_disposable_inputs_unchanged": True, + "manifest_sha256_at_start": self.manifest_hash, + "manifest_sha256_at_end": digest(self.manifest), + "used_manifest_entries_unchanged": True, + "mapping_file": "verified_inputs.json"} + + +def call_main(namespace: dict[str, Any], arguments: Sequence[str | Path]) -> None: + saved = sys.argv + try: + sys.argv = [namespace["__file__"], *map(str, arguments)] + namespace["main"]() + finally: + sys.argv = saved + + +def memory(inputs: Inputs, figures: Path) -> dict[str, Any]: + inputs.group("implicit_memory") + inputs.take(SCRIPTS + "/at2_backward_memory_aggregate.py") + summary = inputs.take(EVIDENCE + "/results/r2_2_at2_isolated_20260826_job117684/summary.json") + ns = inputs.renderer("render_at2_isolated_memory_audit.py") + call_main(ns, ["--summary", summary, "--output", figures / "at2_memory.png", "--journal", + "--receipt", figures / "at2_memory_receipt.json"]) + rows = json.loads(summary.read_text())["mesh_levels"] + if len(rows) != 4: + raise ValueError("Expected four memory mesh levels") + return {"mesh_levels": 4, "matched_pairs": 12, "renderer_source_unchanged": True} + + +def alumina(inputs: Inputs, figures: Path) -> dict[str, Any]: + inputs.group("alumina_fd_sweep") + csv = inputs.take(EVIDENCE + "/results/alumina_fd_active_set_job111131/alumina_fd_active_set_audit.csv") + ns = inputs.renderer("render_alumina_fd_active_set_audit.py") + selected = ns["select_rows"](csv, 1e-8) + if len(selected) != 5: + raise ValueError("Expected exactly five rows at damage tolerance 1e-8") + call_main(ns, ["--csv", csv, "--damage-tol", "1e-8", "--journal", + "--output", figures / "alumina_fd.png"]) + return {"damage_tolerance": 1e-8, "plotted_rows": 5, + "plotted_relative_steps": [float(r["relative_step"]) for r in selected], + "renderer_source_unchanged": True} + + +def joint(inputs: Inputs, figures: Path) -> dict[str, Any]: + inputs.group("joint_selected_fields") + inputs.group("joint_four_start") + summary = inputs.read(STUDIES + "/r1_5_joint_gc_l0_multistart_20260904/" + "collection_20260905T123100Z/raw/job125734_2/summary.json") + if summary["selected_evaluation"] != 26 or summary["objective_evaluations"] != 201: + raise ValueError("Joint selected-state identity differs") + ns = inputs.renderer("render_v2_joint_figure.py") + fn = ns["render_joint_recovery"] + fn.__globals__["OUTPUT"] = figures / "joint_selected.png" + fn() + return {"selected_evaluation": 26, "terminal_evaluation": 200, + "selected_loss": summary["selected_loss"], + "selected_parameters": summary["selected"], + "selected_gradient_check_pass": summary["selected_gradient_verification_pass"], + "renderer_adaptation": "Output constant redirected only; numerical plotting unchanged."} + + +def scalar(inputs: Inputs, figures: Path) -> dict[str, Any]: + import numpy as np + receipt_name = EVIDENCE + "/figure_improvements_20260906/scalar_recovery_composite/receipt.json" + receipt = inputs.read(receipt_name) + for record in receipt["inputs"] + receipt["sources"] + receipt["preserved_standalone_outputs"]: + inputs.locked_record(record) + baseline_path = inputs.locked_record(receipt["scalar_input_hash_lock"]) + baseline = json.loads(baseline_path.read_text()) + for record in baseline["inputs"]: + inputs.locked_record(record) + paths = [inputs.locked_record(r) for r in receipt["inputs"][:2]] + folder = inputs.layout / EVIDENCE / "results/r1_m4_752_reparam_v4_20260826_job117725" + for name in ("target_damage_clean.npy", "target_damage_observed.npy"): + values = np.load(folder / name, allow_pickle=False) + if not np.isfinite(values).all() or values.shape != (2842,): + raise ValueError(f"Invalid retained scalar target: {name}") + checksums = {} + for line in (folder / "sha256sums_relative.txt").read_text().splitlines(): + sha, name = line.split(maxsplit=1) + checksums[name.removeprefix("./")] = sha + for name in ("optimisation_history.csv", "optimisation_summary.csv", "protocol.json", + "config.json", "target_damage_clean.npy", "target_damage_observed.npy"): + if digest(folder / name) != checksums[name]: + raise ValueError(f"Original scalar checksum fails: {name}") + ns = inputs.renderer("render_inverse_journal.py") + glass, al = [ns["scalar_data"](path) for path in paths] + multi = ns["composite_multistart_data"](folder) + ns["apply_style"]() + plotted = ns["scalar_protocol_composite"](glass, al, multi, figures) + adaptation = {"kind": "retained_pure_plotting_functions", + "original_receipt": receipt_name, + "omitted_preservation_archive": receipt["preservation_archive"], + "replacement": "Verified all original exact inputs, plot sources, scalar " + "input hash lock, original six file checksums, both target " + "arrays and all eight standalone outputs against recorded " + "SHA-256 plus public manifest; only the unrelated broad " + "preservation tarball predicate is omitted.", + "numeric_plotting_functions_unchanged": True} + inputs.adaptations.append(adaptation) + return {"adaptation": adaptation, "plotted_values": plotted} + + +class CrossRendererAdapter(ast.NodeTransformer): + def __init__(self) -> None: + self.loader_count = 0 + self.read_count = 0 + + def visit_Expr(self, node: ast.Expr) -> ast.AST: + if ast.unparse(node) == "spec.loader.exec_module(c)": + self.loader_count += 1 + return ast.copy_location(ast.parse("portable_load_contracts(c, contracts)").body[0], node) + return self.generic_visit(node) + + def visit_Return(self, node: ast.Return) -> ast.AST: + if node.value and ast.unparse(node.value) == "json.loads(register(path, expected).read_text())": + self.read_count += 1 + node.value = ast.Call(func=ast.Name(id="portable_relocate", ctx=ast.Load()), + args=[node.value], keywords=[]) + return self.generic_visit(node) + + +def cross_mesh(inputs: Inputs, figures: Path) -> dict[str, Any]: + receipt_name = EVIDENCE + "/version_2_20260906/inverse_figures_receipt.json" + receipt = inputs.read(receipt_name) + omitted = STUDIES + "/r1_4_clean_sent_20260904/source_125692.tar.gz" + if omitted not in receipt["inputs"] or len(receipt["inputs"]) != 316: + raise ValueError("Unrecognised cross-mesh exact-input contract") + for name, record in receipt["inputs"].items(): + if name != omitted: + inputs.take(name, record["sha256"]) + omitted_hash = receipt["inputs"][omitted]["sha256"] + script = inputs.take(SCRIPTS + "/render_v2_inverse_figures.py") + if digest(script) != receipt["script_sha256"]: + raise ValueError("Cross renderer differs from archived scientific figure receipt") + + relocations = {} + relocation_candidates = set(inputs.used) + relocation_candidates.update(PurePosixPath(name).parent.as_posix() for name in inputs.used) + relocation_candidates.discard(".") + def relocate(value: Any) -> Any: + if isinstance(value, dict): + return {key: relocate(item) for key, item in value.items()} + if isinstance(value, list): + return [relocate(item) for item in value] + if isinstance(value, str) and (value.startswith("/") or PureWindowsPath(value).is_absolute()): + normalized = value.replace("\\", "/") + matches = [name for name in relocation_candidates if normalized.endswith("/" + name)] + if len(matches) == 1: + mapped = str(inputs.layout / matches[0]) + relocations[value] = mapped + return mapped + return value + + def load_contracts(module: ModuleType, source: str | Path) -> None: + tree = ast.parse(Path(source).read_text()) + replaced = 0 + for node in tree.body: + if isinstance(node, ast.Assign) and any(isinstance(t, ast.Name) and t.id == "ANCHORS" + for t in node.targets): + if not isinstance(node.value, ast.Dict): + raise ValueError("Unexpected source anchor declaration") + for index, key in enumerate(node.value.keys): + if isinstance(key, ast.Constant) and key.value == "source_125692.tar.gz": + if ast.literal_eval(node.value.values[index]) != omitted_hash: + raise ValueError("Original source-archive anchor does not match receipt") + del node.value.keys[index] + del node.value.values[index] + replaced += 1 + break + if replaced != 1: + raise ValueError("Expected one excluded broad-source archive anchor") + adapted = inputs.output / "adapted/contracts.py" + adapted.parent.mkdir(parents=True, exist_ok=True) + adapted.write_text(ast.unparse(tree) + "\n") + inputs.adaptations.append({"source": Path(source).relative_to(inputs.layout).as_posix(), + "source_sha256": digest(source), + "adapted_source": adapted.relative_to(inputs.output).as_posix(), + "adapted_sha256": digest(adapted), + "omitted_archive_sha256": omitted_hash, + "equivalent_recorded_input_hashes_checked": 315, + "all_other_source_and_numerical_contract_checks_preserved": True}) + exec(compile(ast.fix_missing_locations(tree), str(source), "exec"), module.__dict__) + + adapter = CrossRendererAdapter() + def transform(tree: ast.Module) -> ast.Module: + result = adapter.visit(tree) + if (adapter.loader_count, adapter.read_count) != (1, 1): + raise ValueError("Cross renderer adaptation did not match exactly twice") + return result + ns = inputs.renderer("render_v2_inverse_figures.py", transform, + {"portable_load_contracts": load_contracts, "portable_relocate": relocate}) + matplotlib, plt = ns["configure_plotting"]() + target, items, registered = ns["load_evidence"]() + outcomes = [] + for method in ("AD", "Brent"): + for mesh in ns["MESHES"]: + row = items[method, mesh]["row"] + outcomes.append({key: row[key] for key in ("method", "mesh", "Gc", "Gc_error_percent", + "holdout_relative_l2", "criteria_pass", + "optimizer_success", "status")}) + exported = {} + for stem, function in (("inverse_observations_meshes", ns["observations_figure"]), + ("cross_mesh_outcomes", ns["outcomes_figure"])): + fig, axes = function(plt, target, items) + exported[stem] = ns["export_and_check"](fig, axes, stem) + plt.close(fig) + for ext in ("pdf", "png"): + shutil.copyfile(inputs.layout / exported[stem][ext], figures / f"{stem}.{ext}") + for name, record in registered.items(): + if digest(inputs.layout / name) != record["sha256"]: + raise ValueError(f"Cross plotting input changed: {name}") + inputs.adaptations.append({"kind": "in_memory_absolute_path_translation", + "mapping": relocations, + "scope": "Verified exact files and their immediate parent directories only", + "original_metadata_bytes_unchanged": True}) + return {"exact_recorded_inputs_verified": 315, "omitted_broad_archive": omitted, + "outcomes": outcomes, "export_checks": exported, + "gradient_verified": False, "numerical_checks": "Original mesh/sampler/field " + "identity, eight-time observation reconstruction, losses, forward receipt, " + "source equality, outcome checks and PDF layout checks all executed."} + + +def visual_checks(figures: Path, output: Path) -> list[dict[str, Any]]: + import fitz + import numpy as np + from PIL import Image, ImageDraw + previews = output / "pdf_previews" + previews.mkdir() + records, tiles = [], [] + for pdf in sorted(figures.glob("*.pdf")): + with fitz.open(pdf) as doc: + if len(doc) != 1: + raise ValueError(f"Unexpected PDF page count: {pdf}") + page = doc[0] + pix = page.get_pixmap(matrix=fitz.Matrix(1.5, 1.5), alpha=False) + pixels = np.frombuffer(pix.samples, dtype=np.uint8).reshape(pix.height, pix.width, pix.n) + if pixels.std() < 10 or not page.get_text().strip(): + raise ValueError(f"Blank PDF or absent labels: {pdf}") + png = previews / (pdf.stem + ".png") + pix.save(png) + records.append({"pdf": pdf.relative_to(output).as_posix(), "pages": 1, + "pixel_standard_deviation": float(pixels.std()), + "width_inches": page.rect.width / 72, + "height_inches": page.rect.height / 72, + "preview": png.relative_to(output).as_posix()}) + with Image.open(png) as original: + tile = Image.new("RGB", (760, 650), "white") + view = original.convert("RGB") + view.thumbnail((740, 610)) + tile.paste(view, ((760-view.width)//2, 30)) + ImageDraw.Draw(tile).text((12, 8), pdf.stem, fill="black") + tiles.append(tile) + if tiles: + sheet = Image.new("RGB", (1520, 650*((len(tiles)+1)//2)), "#dddddd") + for i, tile in enumerate(tiles): + sheet.paste(tile, ((i % 2)*760, (i//2)*650)) + sheet.save(output / "contact_sheet.png") + return records + + +def main() -> None: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--archive", type=Path, required=True) + parser.add_argument("--output", type=Path, required=True) + parser.add_argument("--cases", nargs="+", choices=CASES, default=list(CASES)) + args = parser.parse_args() + archive, output = args.archive.resolve(), args.output.resolve() + if output.exists() or output.is_relative_to(archive) or archive.is_relative_to(output): + raise ValueError("Use a fresh output directory disjoint from the archive") + output.mkdir(parents=True) + sys.dont_write_bytecode = True + os.environ["PYTHONDONTWRITEBYTECODE"] = "1" + os.environ["MPLBACKEND"] = "Agg" + for var in ("OMP_NUM_THREADS", "MKL_NUM_THREADS", "OPENBLAS_NUM_THREADS", "NUMEXPR_NUM_THREADS"): + os.environ[var] = "1" + for var, folder in (("MPLCONFIGDIR", "matplotlib"), ("XDG_CACHE_HOME", "cache"), ("TMPDIR", "tmp")): + target = output / "runtime" / folder + target.mkdir(parents=True) + os.environ[var] = str(target) + sys.meta_path.insert(0, NoSolverImports()) + figures = output / "figures" + figures.mkdir() + inputs = Inputs(archive, output) + reports = {} + functions = dict(zip(CASES, (memory, alumina, joint, scalar, cross_mesh))) + for name in dict.fromkeys(args.cases): + print(f"Replotting {name}", flush=True) + try: + reports[name] = {"status": "passed", **functions[name](inputs, figures), "scientific_gap": GAPS[name]} + except Exception as error: + reports[name] = {"status": "failed", "error": str(error), + "traceback": traceback.format_exc(), "scientific_gap": GAPS[name]} + print(f"FAILED {name}: {error}", flush=True) + integrity = inputs.finish() + visual = visual_checks(figures, output) + forbidden = [name for name in sys.modules if name.split(".")[0] in + {"torch", "phast", "torch_pf_solver", "gmsh"}] + if forbidden: + raise ValueError(f"Unexpected solver modules: {forbidden}") + success = all(row["status"] == "passed" for row in reports.values()) + receipt = {"status": "completed" if success else "partial", "cases": reports, + "created_utc": datetime.now(timezone.utc).isoformat(), + "wrapper_sha256": digest(Path(__file__)), "integrity": integrity, + "adaptations": inputs.adaptations, "pdf_checks": visual, + "visual_review": "PDF previews generated; human-visible inspection required.", + "raw_numerical_data_modified": False, "simulations_run": 0, + "solver_modules_imported": forbidden, + "runtime": {"python": platform.python_version(), **{ + name: importlib.metadata.version(name) for name in + ("numpy", "matplotlib", "h5py", "meshio", "PyMuPDF", "Pillow")}}, + "products": [{"path": path.relative_to(output).as_posix(), "bytes": path.stat().st_size, + "sha256": digest(path)} for path in sorted(figures.iterdir()) if path.is_file()]} + (output / "receipt.json").write_text(json.dumps(receipt, indent=2) + "\n") + print(json.dumps({"status": receipt["status"], "cases": {key: value["status"] for key, value in reports.items()}, + "figures": len(visual), "receipt": str(output / "receipt.json")}), flush=True) + if not success: + raise SystemExit(1) + + +if __name__ == "__main__": + main() diff --git a/reproduction/paper1/replot_q2.py b/reproduction/paper1/replot_q2.py new file mode 100644 index 000000000..a6adc087b --- /dev/null +++ b/reproduction/paper1/replot_q2.py @@ -0,0 +1,183 @@ +#!/usr/bin/env python3 +"""Replot retained Q2 data and audit saved fields; no simulation or residual solve.""" + +from __future__ import annotations + +import argparse +from datetime import datetime, timezone +import hashlib +import importlib.metadata +import json +import os +from pathlib import Path, PurePosixPath, PureWindowsPath +import platform +import runpy +import shutil +import sys +from typing import Any + + +# Historical relative paths are replay identifiers, not public workflow names. +STUDY = Path("papers/paper/reviewer_evidence/studies/q2_completion_20260918") + + +def digest(path: Path) -> str: + value = hashlib.sha256() + with path.open("rb") as handle: + for block in iter(lambda: handle.read(1024 * 1024), b""): + value.update(block) + return value.hexdigest() + + +def relative_path(value: str) -> Path: + if not isinstance(value, str) or not value: + raise ValueError("Manifest paths must be nonempty strings") + path = PurePosixPath(value) + if (path.is_absolute() or PureWindowsPath(value).drive or ".." in path.parts + or "\\" in value or not path.parts): + raise ValueError(f"Unsafe manifest path {value!r}") + return Path(*path.parts) + + +def verified_path(root: Path, relative: Path, entry: dict[str, Any]) -> Path: + relative = relative_path(relative.as_posix()) + path = root / relative + if path.is_symlink() or not path.resolve().is_relative_to(root): + raise ValueError(f"Path escapes its input root or is a symlink: {relative}") + if not path.is_file() or path.stat().st_size != entry["bytes"]: + raise ValueError(f"Missing input or changed byte count: {relative}") + if digest(path) != entry["sha256"]: + raise ValueError(f"Changed SHA-256: {relative}") + return path + + +def selected_entries(manifest: dict[str, Any]) -> list[dict[str, Any]]: + rows = [row for row in manifest["files"] if "q2" in row.get("groups", [])] + if not rows: + raise ValueError("Manifest contains no q2 entries") + archive_paths, legacy_paths = set(), set() + for row in rows: + archive = relative_path(row["path"]) + legacy = relative_path(row["legacy_path"]) + if archive in archive_paths or legacy in legacy_paths: + raise ValueError("Duplicate archive or legacy destination in q2 selection") + archive_paths.add(archive) + legacy_paths.add(legacy) + if type(row["bytes"]) is not int or row["bytes"] < 0: + raise ValueError("Manifest byte counts must be nonnegative integers") + if (not isinstance(row["sha256"], str) or len(row["sha256"]) != 64 + or any(c not in "0123456789abcdef" for c in row["sha256"])): + raise ValueError("Invalid SHA-256 in manifest") + return rows + + +def configure_runtime(output: Path) -> None: + sys.dont_write_bytecode = True + os.environ["PYTHONDONTWRITEBYTECODE"] = "1" + os.environ["MPLBACKEND"] = "Agg" + for variable in ("OMP_NUM_THREADS", "OPENBLAS_NUM_THREADS", "MKL_NUM_THREADS", "NUMEXPR_NUM_THREADS"): + os.environ[variable] = "1" + for variable, name in (("MPLCONFIGDIR", "matplotlib"), ("XDG_CACHE_HOME", "cache"), ("TMPDIR", "tmp")): + directory = output / "runtime" / name + directory.mkdir(parents=True) + os.environ[variable] = str(directory) + + +def main() -> None: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--archive", type=Path, required=True, + help="Extracted archive root containing manifest.json") + parser.add_argument("--output", type=Path, required=True, + help="Fresh directory outside archive") + args = parser.parse_args() + archive = args.archive.resolve() + output = args.output.resolve() + if output.exists() or output.is_relative_to(archive) or archive.is_relative_to(output): + raise ValueError("Use a fresh output directory separate from the archive") + manifest_path = archive / "manifest.json" + manifest_hash = digest(manifest_path) + rows = selected_entries(json.loads(manifest_path.read_text())) + for row in rows: + verified_path(archive, relative_path(row["path"]), row) + + output.mkdir(parents=True, exist_ok=False) + configure_runtime(output) + replay = output / "replay" + replay.mkdir() + for row in rows: + target = replay / relative_path(row["legacy_path"]) + target.parent.mkdir(parents=True, exist_ok=True) + shutil.copyfile(archive / relative_path(row["path"]), target) + verified_path(replay, relative_path(row["legacy_path"]), row) + + study = replay / STUDY + run = study / "hpc_runs/zero_job134377" + accepted = json.loads((run / "accepted.json").read_text()) + status = json.loads((run / "status.json").read_text()) + if (len(accepted) != 200 or status.get("status") != "completed" + or status.get("original_targets_reached") != 200 + or not status.get("full_trajectory_from_zero")): + raise ValueError("Selected data is not the completed fresh 200-target trajectory") + + audit_function = runpy.run_path(str(study / "audit_saved_fields.py"))["audit"] + audit = audit_function(run, output / "field_audit") + if not audit["field_archive_checks_pass"] or audit["states_inspected"] != 200: + raise ValueError(f"Saved-field audit failed: {audit['field_failures']}") + + # Redirect only output destinations, leaving the archived plotting code intact. + render = runpy.run_path(str(study / "render_manuscript.py"))["completed_figure"] + figures = output / "figures" + figures.mkdir() + publication = output / "render_destinations/paper" + (publication / "figures/version_2").mkdir(parents=True) + (publication / "reviewer_evidence/assets/journal").mkdir(parents=True) + render.__globals__["OUT"] = figures + render.__globals__["PAPER"] = publication + render() + + for row in rows: + verified_path(archive, relative_path(row["path"]), row) + verified_path(replay, relative_path(row["legacy_path"]), row) + final_rows = selected_entries(json.loads(manifest_path.read_text())) + ordered = lambda entries: sorted(entries, key=lambda entry: entry["path"]) + if ordered(final_rows) != ordered(rows): + raise ValueError("Q2 manifest entries changed while replotting") + final_manifest_hash = digest(manifest_path) + q2_manifest_hash = hashlib.sha256( + json.dumps(ordered(rows), sort_keys=True, separators=(",", ":")).encode() + ).hexdigest() + + products = [figures / "q2_completed.png", figures / "q2_completed.pdf", + output / "field_audit/field_audit.json", + output / "field_audit/full_fields.png", output / "field_audit/reaction.png"] + receipt = { + "status": "completed_replot_and_saved_field_audit", + "created_utc": datetime.now(timezone.utc).isoformat(), + "manifest_sha256": manifest_hash, + "final_manifest_sha256": final_manifest_hash, + "q2_manifest_entries_sha256": q2_manifest_hash, + "q2_manifest_entries_unchanged": True, + "other_manifest_content_changed": final_manifest_hash != manifest_hash, + "wrapper_sha256": digest(Path(__file__)), + "selected_files": len(rows), + "selected_bytes": sum(row["bytes"] for row in rows), + "states_inspected": audit["states_inspected"], + "field_archive_checks_pass": audit["field_archive_checks_pass"], + "field_failures": audit["field_failures"], + "archive_and_replay_input_hashes_unchanged": True, + "full_simulation_run": False, + "equilibrium_residuals_recomputed": False, + "scope": "All saved states checked for file integrity, finite fields, damage bounds, " + "irreversibility, previous-damage consistency and load-factor consistency. " + "The figure uses archived reactions; equilibrium residuals are not recomputed.", + "runtime": {"python": platform.python_version(), + **{name: importlib.metadata.version(name) for name in ("numpy", "scipy", "matplotlib")}}, + "products": [{"path": str(path.relative_to(output)), "bytes": path.stat().st_size, + "sha256": digest(path)} for path in products], + } + (output / "receipt.json").write_text(json.dumps(receipt, indent=2) + "\n") + print(json.dumps(receipt, indent=2)) + + +if __name__ == "__main__": + main() diff --git a/reproduction/paper1/reproduce.py b/reproduction/paper1/reproduce.py new file mode 100644 index 000000000..7126fdfa1 --- /dev/null +++ b/reproduction/paper1/reproduce.py @@ -0,0 +1,235 @@ +#!/usr/bin/env python3 +"""Inspect the paper catalogue, check retained data and run bounded replots. + +Catalogue and checksum checks use the Python standard library. Replotting +uses the separate numerical environment described in the adjacent README. +""" + +from __future__ import annotations + +import argparse +import hashlib +import json +from pathlib import Path, PurePosixPath +import re +import subprocess +import sys +from typing import Any + + +HERE = Path(__file__).resolve().parent +REPLOT_SCRIPTS = { + "q2": "replot_q2.py", + "inverse": "replot_inverse.py", + "forward": "replot_forward.py", +} +SHA256 = re.compile(r"[0-9a-f]{64}") + + +def read_json(path: Path) -> dict[str, Any]: + """Read an object while rejecting ambiguous duplicate keys.""" + def pairs(values: list[tuple[str, Any]]) -> dict[str, Any]: + result: dict[str, Any] = {} + for key, value in values: + if key in result: + raise ValueError(f"Duplicate JSON key {key!r} in {path.name}") + result[key] = value + return result + + def invalid_constant(value: str) -> None: + raise ValueError(f"Invalid JSON numeric constant {value}") + + result = json.loads(path.read_text(encoding="utf-8"), object_pairs_hook=pairs, + parse_constant=invalid_constant) + if not isinstance(result, dict): + raise ValueError(f"Expected a JSON object in {path.name}") + return result + + +def relative_path(value: str) -> PurePosixPath: + """Require a portable archive-relative file path.""" + if not isinstance(value, str) or not value or "\\" in value or ":" in value: + raise ValueError(f"Invalid archive path {value!r}") + path = PurePosixPath(value) + if (path.is_absolute() or any(part in ("", ".", "..") for part in value.split("/")) + or any(ord(char) < 32 for char in value)): + raise ValueError(f"Unsafe archive path {value!r}") + return path + + +def data_file(root: Path, name: str) -> Path: + path = root + for part in relative_path(name).parts: + path = path / part + if path.is_symlink(): + raise ValueError(f"Archive symlink is unsupported {name!r}") + if not path.is_file(): + raise ValueError(f"Required archive file is missing {name!r}") + return path + + +def digest(path: Path) -> str: + result = hashlib.sha256() + with path.open("rb") as handle: + for block in iter(lambda: handle.read(1024 * 1024), b""): + result.update(block) + return result.hexdigest() + + +def catalogue_check(catalogue: dict[str, Any]) -> dict[str, Any]: + if catalogue.get("schema_version") != 1: + raise ValueError("Unsupported catalogue schema") + identity = catalogue["data_identity"] + if not isinstance(identity, dict): + raise ValueError("Expected a data identity object") + for key in ("manifest_sha256", "document_identity_sha256"): + if not isinstance(identity.get(key), str) or not SHA256.fullmatch(identity[key]): + raise ValueError(f"Catalogue requires a valid {key}") + studies = catalogue["studies"] + if not isinstance(studies, dict) or not studies: + raise ValueError("Expected a non-empty study index") + if any(not isinstance(study, dict) for study in studies.values()): + raise ValueError("Expected a study object for each study identifier") + if any(not isinstance(catalogue.get(key), list) for key in ("items", "results")): + raise ValueError("Expected item and result lists") + seen: set[str] = set() + counts: dict[str, int] = {} + for row in catalogue["items"] + catalogue["results"]: + if not isinstance(row, dict) or not isinstance(row.get("studies"), list): + raise ValueError("Each catalogue entry requires a study list") + identifier = row["id"] + if not isinstance(identifier, str) or not identifier or identifier in seen: + raise ValueError(f"Invalid or repeated catalogue identifier {identifier!r}") + seen.add(identifier) + if not row["studies"]: + raise ValueError(f"Study mapping required for {identifier}") + for study in row["studies"]: + if study not in studies: + raise ValueError(f"Unknown study {study!r} in {identifier}") + for name in row.get("archive_assets", []): + relative_path(name) + if "document" in row: + key = f"{row['document']}_{row['kind']}" + counts[key] = counts.get(key, 0) + 1 + for identifier, study in studies.items(): + if study.get("replot_group") not in (None, *REPLOT_SCRIPTS): + raise ValueError(f"Unknown replot group in {identifier}") + if not study.get("replot_status") or not study.get("rerun_status"): + raise ValueError(f"Separate replot and rerun status required for {identifier}") + for name in study.get("archive_paths", []): + relative_path(name) + related = study.get("related_example") + if related: + relative_path(related) + if not (HERE.parents[1] / related).is_dir(): + raise ValueError(f"Related public example is missing {related!r}") + return {"status": "catalogue_structure_checked", "items": len(catalogue["items"]), + "result_groups": len(catalogue["results"]), "studies": len(studies), + "counts": counts, "simulation_reproduction_verified": False} + + +def verify_data(root: Path, expected_manifest: str | None = None) -> dict[str, Any]: + root = root.resolve() + manifest_path = data_file(root, "manifest.json") + manifest_sha256 = digest(manifest_path) + if expected_manifest is not None: + if not SHA256.fullmatch(expected_manifest): + raise ValueError("Expected manifest hash must be a lowercase SHA-256") + if manifest_sha256 != expected_manifest: + raise ValueError("Archive manifest differs from the expected SHA-256") + manifest = read_json(manifest_path) + entries = manifest["files"] + if not isinstance(entries, list) or not entries: + raise ValueError("Expected a non-empty archive file manifest") + names: set[str] = set() + total = 0 + for row in entries: + name = row["path"] + if name in names: + raise ValueError(f"Duplicate archive entry {name!r}") + names.add(name) + size, expected = row["bytes"], row["sha256"] + if type(size) is not int or size < 0 or not isinstance(expected, str) or not SHA256.fullmatch(expected): + raise ValueError(f"Invalid size or checksum for {name!r}") + path = data_file(root, name) + if path.stat().st_size != size or digest(path) != expected: + raise ValueError(f"Archive size or checksum mismatch for {name!r}") + total += size + if manifest.get("payload_bytes", total) != total: + raise ValueError("Archive payload total differs from its file entries") + if manifest.get("source_file_count", len(entries)) != len(entries): + raise ValueError("Archive file count differs from its file entries") + if digest(manifest_path) != manifest_sha256: + raise ValueError("Archive manifest changed during verification") + return {"status": "input_integrity_checked", "files": len(entries), "bytes": total, + "manifest_sha256": manifest_sha256, "archive_status": manifest.get("status"), + "simulation_reproduction_verified": False} + + +def verify_catalogued_data(catalogue: dict[str, Any], root: Path, + expected_manifest: str | None = None) -> dict[str, Any]: + identity = catalogue["data_identity"] + pinned = identity["manifest_sha256"] + if expected_manifest is not None and expected_manifest != pinned: + raise ValueError("Requested manifest hash differs from this paper catalogue") + document_path = data_file(root.resolve(), "document_identity.json") + if digest(document_path) != identity["document_identity_sha256"]: + raise ValueError("Archive document edition differs from this paper catalogue") + report = verify_data(root, pinned) + manifest = read_json(root / "manifest.json") + names = {row["path"] for row in manifest["files"]} + assets = {name for row in catalogue.get("items", []) + for name in row.get("archive_assets", [])} + if assets - names: + raise ValueError(f"Catalogued assets absent from the manifest: {sorted(assets - names)}") + for study in catalogue.get("studies", {}).values(): + for path in study.get("archive_paths", []): + if path not in names and not any(name.startswith(path + "/") for name in names): + raise ValueError(f"Catalogued study path absent from the manifest: {path}") + report["catalogued_asset_paths_checked"] = len(assets) + return report + + +def main(argv: list[str] | None = None) -> int: + parser = argparse.ArgumentParser(description=__doc__) + commands = parser.add_subparsers(dest="command", required=True) + commands.add_parser("list", help="List manuscript and supplementary items in reading order") + check = commands.add_parser("check", help="Check catalogue and optionally retained-data hashes") + check.add_argument("--data", type=Path) + check.add_argument("--manifest-sha256", help="Independently recorded manifest hash") + replot = commands.add_parser("replot", help="Regenerate selected figures from retained data") + replot.add_argument("group", choices=REPLOT_SCRIPTS) + replot.add_argument("--data", type=Path, required=True) + replot.add_argument("--output", type=Path, required=True) + replot.add_argument("--manifest-sha256") + args = parser.parse_args(argv) + try: + catalogue = read_json(HERE / "catalogue.json") + report = catalogue_check(catalogue) + if args.command == "list": + for row in catalogue["items"]: + print(f"{row['id']} {' '.join(row['studies'])}") + print(f"{report['result_groups']} numerical-result groups are also indexed.") + return 0 + if args.manifest_sha256 and args.data is None: + raise ValueError("--manifest-sha256 requires --data") + if args.command == "replot": + archive, output = args.data.resolve(), args.output.resolve() + if output.exists() or output.is_relative_to(archive) or archive.is_relative_to(output): + raise ValueError("Choose a fresh output directory separate from the archive") + if args.data is not None: + report["data"] = verify_catalogued_data(catalogue, args.data, args.manifest_sha256) + print(json.dumps(report, indent=2), flush=True, + file=sys.stderr if args.command == "replot" else sys.stdout) + if args.command == "replot": + command = [sys.executable, "-B", str(HERE / REPLOT_SCRIPTS[args.group]), + "--archive", str(archive), "--output", str(output)] + return subprocess.run(command, check=False).returncode + return 0 + except (OSError, ValueError, KeyError, TypeError) as exc: + print(f"Reproduction check error: {exc}", file=sys.stderr) + return 2 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/reproduction/paper1/requirements-replot.txt b/reproduction/paper1/requirements-replot.txt new file mode 100644 index 000000000..a8a85b054 --- /dev/null +++ b/reproduction/paper1/requirements-replot.txt @@ -0,0 +1,11 @@ +# Retained-data plotting checks on macOS arm64, Python 3.10.18 +# Separate from historical simulation and timing environments +numpy==2.2.6 +scipy==1.14.1 +matplotlib==3.10.9 +h5py==3.16.0 +meshio==5.3.5 +PyYAML==6.0.3 +torch==2.14.0 +Pillow==12.3.0 +PyMuPDF==1.27.1 diff --git a/tests/test_paper_replot_adapters.py b/tests/test_paper_replot_adapters.py new file mode 100644 index 000000000..8b2c8ba1b --- /dev/null +++ b/tests/test_paper_replot_adapters.py @@ -0,0 +1,378 @@ +"""Bounded adapter contracts using synthetic files, without scientific dependencies.""" + +from __future__ import annotations + +import ast +import hashlib +import importlib.util +import json +from pathlib import Path, PurePosixPath, PureWindowsPath +import subprocess +import sys +from types import ModuleType, SimpleNamespace +from typing import Any + +import pytest + + +ADAPTERS = Path(__file__).resolve().parents[1] / "reproduction" / "paper1" +NAMES = ("q2", "inverse", "forward") + + +def load_adapter(name: str) -> ModuleType: + spec = importlib.util.spec_from_file_location(name, ADAPTERS / f"replot_{name}.py") + assert spec is not None and spec.loader is not None + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module + + +@pytest.fixture(params=NAMES) +def adapter(request: pytest.FixtureRequest) -> ModuleType: + return load_adapter(request.param) + + +def entry(path: str = "data/field.bin", legacy: str = "results/field.bin") -> dict[str, Any]: + return {"path": path, "legacy_path": legacy, "kind": "copy", "groups": ["q2"], + "bytes": 4, "sha256": hashlib.sha256(b"data").hexdigest()} + + +def make_archive(root: Path, rows: list[dict[str, Any]]) -> Path: + root.mkdir() + for row in rows: + path = root / row["path"] + path.parent.mkdir(parents=True, exist_ok=True) + path.write_bytes(b"data") + (root / "manifest.json").write_text(json.dumps({"files": rows, "payload_bytes": 4 * len(rows)})) + return root + + +def check_input(adapter: ModuleType, root: Path, row: dict[str, Any]) -> Path: + if adapter.__name__ == "q2": + return adapter.verified_path(root, adapter.relative_path(row["path"]), row) + if adapter.__name__ == "inverse": + return adapter.verify(root, row["path"], row) + return adapter.checked_path(root, row) + + +def run_adapter(name: str, cwd: Path, *args: str) -> subprocess.CompletedProcess[str]: + return subprocess.run([sys.executable, "-B", str(ADAPTERS / f"replot_{name}.py"), *args], + cwd=cwd, capture_output=True, text=True, check=False, timeout=15) + + +def test_exact_input_hash(adapter: ModuleType, tmp_path: Path) -> None: + row = entry() + root = make_archive(tmp_path / "archive", [row]) + path = check_input(adapter, root, row) + assert path == root / row["path"] + assert adapter.digest(path) == row["sha256"] + + +@pytest.mark.parametrize("name", ["", ".", "..", "../outside", "data/../../outside", + "/absolute", "C:/absolute", "C:relative", "data\\field", None, 3]) +def test_unsafe_input_paths(adapter: ModuleType, tmp_path: Path, name: Any) -> None: + with pytest.raises(ValueError): + check_input(adapter, tmp_path, {**entry(), "path": name}) + + +@pytest.mark.parametrize("change", ["missing", "resized", "hash"]) +def test_changed_input_rejected(adapter: ModuleType, tmp_path: Path, change: str) -> None: + row = entry() + root = make_archive(tmp_path / "archive", [row]) + path = root / row["path"] + if change == "missing": + path.unlink() + else: + path.write_bytes(b"longer" if change == "resized" else b"edit") + with pytest.raises(ValueError): + check_input(adapter, root, row) + + +@pytest.mark.parametrize("kind", ["file", "directory"]) +def test_symlink_escape_rejected(adapter: ModuleType, tmp_path: Path, kind: str) -> None: + outside = tmp_path / "outside" + outside.mkdir() + (outside / "field.bin").write_bytes(b"data") + root = tmp_path / "archive" + root.mkdir() + try: + if kind == "directory": + (root / "data").symlink_to(outside, target_is_directory=True) + else: + (root / "data").mkdir() + (root / "data/field.bin").symlink_to(outside / "field.bin") + except OSError as error: + pytest.skip(f"Symlinks unavailable: {error}") + with pytest.raises(ValueError, match="escapes|symlink"): + check_input(adapter, root, entry()) + + +@pytest.mark.parametrize("name", NAMES) +@pytest.mark.parametrize("relation", ["existing", "inside", "ancestor", "same", "symlink"]) +def test_output_must_be_fresh_and_disjoint(tmp_path: Path, name: str, relation: str) -> None: + archive = tmp_path / "archive" + if relation == "ancestor": + output = tmp_path / "not-created" + archive = output / "archive" + else: + archive.mkdir() + output = tmp_path / "output" + if relation == "existing": + output.mkdir() + (output / "sentinel").write_text("unchanged") + elif relation == "inside": + output = archive / "output" + elif relation == "same": + output = archive + elif relation == "symlink": + link = tmp_path / "archive-link" + try: + link.symlink_to(archive, target_is_directory=True) + except OSError as error: + pytest.skip(f"Symlinks unavailable: {error}") + output = link / "output" + result = run_adapter(name, tmp_path, "--archive", str(archive), "--output", str(output)) + assert result.returncode != 0 + assert "fresh output directory" in result.stderr + if relation == "existing": + assert (output / "sentinel").read_text() == "unchanged" + elif relation != "same": + assert not output.exists() + + +@pytest.mark.parametrize("name", NAMES) +def test_help_without_plotting_stack(tmp_path: Path, name: str) -> None: + result = subprocess.run([sys.executable, "-B", "-S", str(ADAPTERS / f"replot_{name}.py"), + "--help"], cwd=tmp_path, capture_output=True, text=True, timeout=15) + assert result.returncode == 0, result.stderr + assert "--archive" in result.stdout and "--output" in result.stdout + assert "--remote-availability" not in result.stdout + + +def test_forward_private_option_removed(tmp_path: Path) -> None: + result = run_adapter("forward", tmp_path, "--archive", "archive", "--output", "output", + "--remote-availability", "unused.json") + assert result.returncode == 2 + assert "unrecognized arguments" in result.stderr + assert not (tmp_path / "output").exists() + + +@pytest.mark.parametrize("field", ["path", "legacy_path"]) +def test_q2_duplicate_mapping_rejected(field: str) -> None: + module = load_adapter("q2") + first, second = entry(), entry("data/other.bin", "results/other.bin") + second[field] = "./" + first[field] + with pytest.raises(ValueError, match="Duplicate"): + module.selected_entries({"files": [first, second]}) + + +@pytest.mark.parametrize(("field", "value"), [("bytes", -1), ("bytes", True), ("bytes", 4.0), + ("sha256", "a" * 63), ("sha256", "g" * 64), + ("sha256", "A" * 64), ("sha256", None)]) +def test_q2_invalid_integrity_metadata(field: str, value: Any) -> None: + with pytest.raises(ValueError): + load_adapter("q2").selected_entries({"files": [{**entry(), field: value}]}) + + +def test_q2_selection_is_bounded() -> None: + module = load_adapter("q2") + row = entry() + assert module.selected_entries({"files": [row, {"groups": ["other"]}]}) == [row] + with pytest.raises(ValueError, match="no q2"): + module.selected_entries({"files": []}) + + +def test_inverse_exact_lock_and_identical_aliases(tmp_path: Path) -> None: + module = load_adapter("inverse") + first = entry() + preferred = {**entry("code/field.bin"), "groups": ["renderers_and_checks"]} + root = make_archive(tmp_path / "archive", [first, preferred]) + inputs = module.Inputs(root, tmp_path / "output") + selected = inputs.take(first["legacy_path"], first["sha256"]) + assert selected.read_bytes() == b"data" + assert inputs.used[first["legacy_path"]]["path"] == preferred["path"] + assert inputs.adaptations[0]["kind"] == "identical_manifest_aliases" + with pytest.raises(ValueError, match="Recorded scientific input hash"): + inputs.take(first["legacy_path"], "0" * 64) + old_path = str(tmp_path / "historical" / first["legacy_path"]) + assert inputs.locked_record({"path": old_path, "sha256": first["sha256"]}) == selected + assert inputs.finish()["archive_and_disposable_inputs_unchanged"] is True + + +def test_inverse_simulated_windows_mapping_keys(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + module = load_adapter("inverse") + row = entry() + root = make_archive(tmp_path / "archive", [row]) + safe_relative = module.safe_relative + + def windows_relative(value: str) -> PureWindowsPath: + return PureWindowsPath(safe_relative(value).as_posix()) + + # Exercise Windows stringification without changing the host filesystem. + with monkeypatch.context() as patch: + patch.setattr(module, "safe_relative", windows_relative) + inputs = module.Inputs(root, tmp_path / "output") + assert set(inputs.by_legacy) == {row["legacy_path"]} + assert inputs.row(row["legacy_path"]) == row + copied = inputs.take("./" + row["legacy_path"], row["sha256"]) + assert copied.read_bytes() == b"data" + assert set(inputs.used) == {row["legacy_path"]} + assert inputs.finish()["verified_files"] == 1 + mapping = json.loads((inputs.output / "verified_inputs.json").read_text()) + assert mapping == [{key: row[key] for key in ("path", "legacy_path", "bytes", "sha256")}] + + +@pytest.mark.parametrize("old_path", [r"C:\retained\results\field.bin", + "C:/retained/results/field.bin", r"results\field.bin"]) +def test_inverse_windows_record_separators(tmp_path: Path, old_path: str) -> None: + module = load_adapter("inverse") + row = entry() + inputs = module.Inputs(make_archive(tmp_path / "archive", [row]), tmp_path / "output") + assert inputs.old_to_legacy(old_path) == row["legacy_path"] + copied = inputs.locked_record({"path": old_path, "sha256": row["sha256"]}) + assert copied.read_bytes() == b"data" + with pytest.raises(ValueError, match="Recorded scientific input hash"): + inputs.locked_record({"path": old_path, "sha256": "0" * 64}) + with pytest.raises(ValueError, match="Invalid relative path"): + inputs.take(r"results\field.bin") + assert inputs.finish()["archive_and_disposable_inputs_unchanged"] is True + + +def test_inverse_windows_record_ambiguous_suffix_rejected(tmp_path: Path) -> None: + module = load_adapter("inverse") + rows = [entry(), entry("other/field.bin", "nested/results/field.bin")] + inputs = module.Inputs(make_archive(tmp_path / "archive", rows), tmp_path / "output") + with pytest.raises(ValueError, match="unambiguous"): + inputs.old_to_legacy(r"C:\retained\nested\results\field.bin") + + +def test_inverse_renderer_simulated_windows_receipt_paths( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch, +) -> None: + module = load_adapter("inverse") + + class WindowsRelativePath(type(Path())): + def relative_to(self, *other: Any) -> PureWindowsPath: + return PureWindowsPath(super().relative_to(*other).as_posix()) + + root = tmp_path / "archive" + root.mkdir() + rows = [] + for name in ("fixture.py", "journal_figure_style.py"): + row = entry(f"code/{name}", f"{module.SCRIPTS}/{name}") + path = root / row["path"] + path.parent.mkdir(exist_ok=True) + path.write_text("VALUE = 7\n") + row.update(bytes=path.stat().st_size, sha256=module.digest(path)) + rows.append(row) + (root / "manifest.json").write_text(json.dumps({"files": rows})) + inputs = module.Inputs(root, WindowsRelativePath(tmp_path / "output")) + monkeypatch.setattr(sys, "path", sys.path.copy()) + + def unchanged(tree: ast.Module) -> ast.Module: + return tree + + assert inputs.renderer("fixture.py", unchanged)["VALUE"] == 7 + adaptation = inputs.adaptations[0] + assert adaptation["source"] == f"{module.SCRIPTS}/fixture.py" + assert adaptation["adapted_source"] == "adapted/fixture.py" + assert inputs.finish()["verified_files"] == 2 + + +def test_cross_relocation_simulated_windows_keys(tmp_path: Path) -> None: + module = load_adapter("inverse") + tree = ast.parse(Path(module.__file__).read_text()) + function = next(node for node in tree.body if isinstance(node, ast.FunctionDef) and node.name == "cross_mesh") + first = next(i for i, node in enumerate(function.body) + if isinstance(node, ast.Assign) and isinstance(node.targets[0], ast.Name) + and node.targets[0].id == "relocations") + last = next(i for i, node in enumerate(function.body) + if isinstance(node, ast.FunctionDef) and node.name == "relocate") + # Run only the actual path-translation block, not the scientific renderer. + block = ast.Module(body=function.body[first:last + 1], type_ignores=[]) + layout = tmp_path / "legacy" + namespace = {"inputs": SimpleNamespace(used={"runs/results/field.bin": entry()}, layout=layout), + "Path": PureWindowsPath, "PurePosixPath": PurePosixPath, + "PureWindowsPath": PureWindowsPath, "Any": Any} + exec(compile(block, module.__file__, "exec"), namespace) + relocate = namespace["relocate"] + for prefix in ("/retained/", "C:\\retained\\"): + separator = "\\" if prefix.startswith("C:") else "/" + file = prefix + separator.join(("runs", "results", "field.bin")) + directory = prefix + separator.join(("runs", "results")) + assert relocate({"file": file, "directories": [directory]}) == { + "file": str(layout / "runs/results/field.bin"), "directories": [str(layout / "runs/results")], + } + assert namespace["relocations"][file] == str(layout / "runs/results/field.bin") + assert relocate(r"C:\unverified\field.bin") == r"C:\unverified\field.bin" + + +def test_inverse_conflicting_aliases_rejected(tmp_path: Path) -> None: + module = load_adapter("inverse") + first = entry() + conflicting = {**entry("other/field.bin"), "sha256": "0" * 64} + inputs = module.Inputs(make_archive(tmp_path / "archive", [first, conflicting]), tmp_path / "output") + with pytest.raises(ValueError, match="conflicting"): + inputs.take(first["legacy_path"]) + + +@pytest.mark.parametrize("field", ["path", "legacy_path"]) +@pytest.mark.parametrize("value", ["../escape", r"results\field.bin"]) +def test_inverse_unsafe_manifest_mapping_rejected(tmp_path: Path, field: str, value: str) -> None: + module = load_adapter("inverse") + root = make_archive(tmp_path / "archive", []) + (root / "manifest.json").write_text(json.dumps({"files": [{**entry(), field: value}]})) + with pytest.raises(ValueError, match="Unsafe|Invalid"): + module.Inputs(root, tmp_path / "output") + + +def test_inverse_duplicate_archive_path_rejected(tmp_path: Path) -> None: + module = load_adapter("inverse") + root = make_archive(tmp_path / "archive", [entry(), entry()]) + with pytest.raises(ValueError, match="Duplicate archive path"): + module.Inputs(root, tmp_path / "output") + + +def test_inverse_pinned_variant_not_a_copy(tmp_path: Path) -> None: + module = load_adapter("inverse") + row = {**entry(), "kind": "source_variant"} + inputs = module.Inputs(make_archive(tmp_path / "archive", [row]), tmp_path / "output") + with pytest.raises(ValueError, match="Missing or conflicting"): + inputs.take(row["legacy_path"]) + + +@pytest.mark.parametrize("changed", ["archive", "replay", "manifest"]) +def test_inverse_final_recheck(tmp_path: Path, changed: str) -> None: + module = load_adapter("inverse") + row = entry() + root = make_archive(tmp_path / "archive", [row]) + inputs = module.Inputs(root, tmp_path / "output") + copied = inputs.take(row["legacy_path"]) + if changed == "manifest": + (root / "manifest.json").write_text(json.dumps({"files": [{**row, "sha256": "0" * 64}]})) + else: + (root / row["path"] if changed == "archive" else copied).write_bytes(b"edit") + with pytest.raises(ValueError, match="SHA-256|manifest entry changed"): + inputs.finish() + + +@pytest.mark.parametrize("name", ["torch", "torch.linalg", "phast", "torch_pf_solver", "gmsh"]) +def test_inverse_solver_import_guard(name: str) -> None: + with pytest.raises(ImportError, match="prohibited"): + load_adapter("inverse").NoSolverImports().find_spec(name) + + +def test_cross_renderer_adapter_only_wraps_expected_calls() -> None: + module = load_adapter("inverse") + tree = ast.parse("spec.loader.exec_module(c)\n" + "def read(path, expected):\n" + " return json.loads(register(path, expected).read_text())\n" + "def scientific(x):\n" + " return x * x + 1\n") + scientific = ast.dump(tree.body[-1]) + adapter = module.CrossRendererAdapter() + transformed = adapter.visit(tree) + assert (adapter.loader_count, adapter.read_count) == (1, 1) + assert ast.dump(transformed.body[-1]) == scientific + assert "portable_load_contracts(c, contracts)" in ast.unparse(transformed) + assert "portable_relocate(json.loads(register(path, expected).read_text()))" in ast.unparse(transformed) diff --git a/tests/test_paper_reproduction.py b/tests/test_paper_reproduction.py new file mode 100644 index 000000000..3b2eee807 --- /dev/null +++ b/tests/test_paper_reproduction.py @@ -0,0 +1,190 @@ +"""Paper catalogue and retained-data integrity checks use tiny local fixtures.""" + +from __future__ import annotations + +import hashlib +import importlib.util +import json +from pathlib import Path +from typing import Any + +import pytest + + +ROOT = Path(__file__).resolve().parents[1] +SCRIPT = ROOT / "reproduction" / "paper1" / "reproduce.py" +SPEC = importlib.util.spec_from_file_location("paper_reproduce", SCRIPT) +assert SPEC is not None and SPEC.loader is not None +MODULE = importlib.util.module_from_spec(SPEC) +SPEC.loader.exec_module(MODULE) + + +@pytest.fixture +def data(tmp_path: Path) -> Path: + root = tmp_path / "data" + root.mkdir() + value = b"time,energy\n0,0\n1,2\n" + (root / "history.csv").write_bytes(value) + manifest = {"status": "test_fixture", "source_file_count": 1, + "payload_bytes": len(value), "files": [ + {"path": "history.csv", "bytes": len(value), + "sha256": hashlib.sha256(value).hexdigest()}]} + (root / "manifest.json").write_text(json.dumps(manifest), encoding="utf-8") + return root + + +def rewrite_manifest(root: Path, key: str, value: Any) -> None: + path = root / "manifest.json" + manifest = json.loads(path.read_text(encoding="utf-8")) + manifest[key] = value + path.write_text(json.dumps(manifest), encoding="utf-8") + + +def test_catalogue_has_unique_resolved_items_and_separate_statuses() -> None: + catalogue = MODULE.read_json(SCRIPT.with_name("catalogue.json")) + report = MODULE.catalogue_check(catalogue) + assert report["items"] == 38 + assert report["result_groups"] >= 36 + assert report["simulation_reproduction_verified"] is False + + +def test_data_check_preserves_bytes_and_distinguishes_integrity(data: Path) -> None: + before = {file.name: file.read_bytes() for file in data.iterdir()} + report = MODULE.verify_data(data, MODULE.digest(data / "manifest.json")) + assert report["files"] == 1 + assert report["simulation_reproduction_verified"] is False + assert report["archive_status"] == "test_fixture" + assert before == {file.name: file.read_bytes() for file in data.iterdir()} + + +def test_changed_same_size_data_is_rejected(data: Path) -> None: + path = data / "history.csv" + path.write_bytes(path.read_bytes().replace(b"1,2", b"1,3")) + with pytest.raises(ValueError, match="checksum mismatch"): + MODULE.verify_data(data) + + +def test_missing_data_is_rejected(data: Path) -> None: + (data / "history.csv").unlink() + with pytest.raises(ValueError, match="missing"): + MODULE.verify_data(data) + + +@pytest.mark.parametrize("name", ["../history.csv", "/history.csv", "a/../../x", "a\\x", + "C:/x", "a//b", "./a", "a/", "", "a\nb"]) +def test_unsafe_paths_are_rejected(name: str) -> None: + with pytest.raises(ValueError): + MODULE.relative_path(name) + + +def test_symlink_in_input_path_is_rejected(data: Path) -> None: + link = data / "alias" + try: + link.symlink_to(data, target_is_directory=True) + except OSError: + pytest.skip("Symlink creation unavailable on this platform") + with pytest.raises(ValueError, match="symlink"): + MODULE.data_file(data, "alias/history.csv") + + +def test_manifest_may_be_pinned_independently(data: Path) -> None: + with pytest.raises(ValueError, match="expected SHA-256"): + MODULE.verify_data(data, "0" * 64) + + +def test_data_is_bound_to_catalogued_document_edition(data: Path) -> None: + identity_path = data / "document_identity.json" + identity_path.write_text('{"edition": "fixture"}', encoding="utf-8") + catalogue = {"data_identity": { + "manifest_sha256": MODULE.digest(data / "manifest.json"), + "document_identity_sha256": MODULE.digest(identity_path)}} + assert MODULE.verify_catalogued_data(catalogue, data)["files"] == 1 + identity_path.write_text('{"edition": "different"}', encoding="utf-8") + with pytest.raises(ValueError, match="document edition differs"): + MODULE.verify_catalogued_data(catalogue, data) + + +def test_optional_hash_cannot_override_catalogued_data(data: Path) -> None: + catalogue = {"data_identity": {"manifest_sha256": "a" * 64}} + with pytest.raises(ValueError, match="differs from this paper catalogue"): + MODULE.verify_catalogued_data(catalogue, data, "b" * 64) + + +@pytest.mark.parametrize("declaration", [ + {"items": [{"archive_assets": ["missing.pdf"]}]}, + {"studies": {"test": {"archive_paths": ["missing-study"]}}}, +]) +def test_dangling_catalogue_data_references_are_rejected( + data: Path, declaration: dict[str, Any]) -> None: + path = data / "document_identity.json" + path.write_text("{}", encoding="utf-8") + catalogue = {**declaration, "data_identity": { + "manifest_sha256": MODULE.digest(data / "manifest.json"), + "document_identity_sha256": MODULE.digest(path)}} + with pytest.raises(ValueError, match="absent from the manifest"): + MODULE.verify_catalogued_data(catalogue, data) + + +def test_duplicate_paths_are_rejected(data: Path) -> None: + manifest = MODULE.read_json(data / "manifest.json") + rewrite_manifest(data, "files", manifest["files"] * 2) + with pytest.raises(ValueError, match="Duplicate archive entry"): + MODULE.verify_data(data) + + +@pytest.mark.parametrize("key,value", [("payload_bytes", 0), ("source_file_count", 0)]) +def test_incorrect_manifest_totals_are_rejected(data: Path, key: str, value: int) -> None: + rewrite_manifest(data, key, value) + with pytest.raises(ValueError, match="differs"): + MODULE.verify_data(data) + + +def test_duplicate_json_keys_are_rejected(tmp_path: Path) -> None: + path = tmp_path / "bad.json" + path.write_text('{"files": [], "files": []}', encoding="utf-8") + with pytest.raises(ValueError, match="Duplicate JSON key"): + MODULE.read_json(path) + + +def test_nonfinite_json_is_rejected(tmp_path: Path) -> None: + path = tmp_path / "bad.json" + path.write_text('{"bytes": NaN}', encoding="utf-8") + with pytest.raises(ValueError, match="numeric constant"): + MODULE.read_json(path) + + +def test_unknown_study_reference_is_rejected() -> None: + catalogue = MODULE.read_json(SCRIPT.with_name("catalogue.json")) + catalogue["items"][0]["studies"] = ["missing"] + with pytest.raises(ValueError, match="Unknown study"): + MODULE.catalogue_check(catalogue) + + +@pytest.mark.parametrize("field", ["data_identity", "studies", "items", "results"]) +def test_invalid_catalogue_containers_are_rejected(field: str) -> None: + catalogue = MODULE.read_json(SCRIPT.with_name("catalogue.json")) + catalogue[field] = None + with pytest.raises(ValueError): + MODULE.catalogue_check(catalogue) + + +def test_invalid_study_record_is_rejected() -> None: + catalogue = MODULE.read_json(SCRIPT.with_name("catalogue.json")) + catalogue["studies"][next(iter(catalogue["studies"]))] = None + with pytest.raises(ValueError, match="study object"): + MODULE.catalogue_check(catalogue) + + +def test_replot_requires_fresh_output(data: Path) -> None: + assert MODULE.main(["replot", "q2", "--data", str(data), "--output", str(data)]) == 2 + + +def test_manifest_option_requires_data() -> None: + assert MODULE.main(["check", "--manifest-sha256", "0" * 64]) == 2 + + +def test_catalogue_and_list_commands(capsys: pytest.CaptureFixture[str]) -> None: + assert MODULE.main(["check"]) == 0 + assert json.loads(capsys.readouterr().out)["status"] == "catalogue_structure_checked" + assert MODULE.main(["list"]) == 0 + assert "figure.1" in capsys.readouterr().out