eugenmik commited on
Commit
278fee4
·
verified ·
1 Parent(s): 756b1b2

Release v5.1: CastSolid GNN casting solidification surrogate (Apache-2.0)

Browse files
This view is limited to 50 files because it contains too many changes.   See raw diff
.gitattributes CHANGED
@@ -33,3 +33,8 @@ saved_model/**/* filter=lfs diff=lfs merge=lfs -text
33
  *.zip filter=lfs diff=lfs merge=lfs -text
34
  *.zst filter=lfs diff=lfs merge=lfs -text
35
  *tfevents* filter=lfs diff=lfs merge=lfs -text
 
 
 
 
 
 
33
  *.zip filter=lfs diff=lfs merge=lfs -text
34
  *.zst filter=lfs diff=lfs merge=lfs -text
35
  *tfevents* filter=lfs diff=lfs merge=lfs -text
36
+ assets/3dforms.png filter=lfs diff=lfs merge=lfs -text
37
+ assets/mesh.png filter=lfs diff=lfs merge=lfs -text
38
+ assets/solidification.gif filter=lfs diff=lfs merge=lfs -text
39
+ weights/gbm_std_v2.skops filter=lfs diff=lfs merge=lfs -text
40
+ weights/gbm_total_v3.skops filter=lfs diff=lfs merge=lfs -text
.gitignore ADDED
@@ -0,0 +1,15 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Raw and derived data are large. Keep only the small public property tables.
2
+ data/*
3
+ !data/properties/
4
+ !data/properties/*.csv
5
+ # Training artifacts, checkpoints, logs
6
+ runs/
7
+ # Model checkpoints anywhere
8
+ *.pt
9
+ # SDD scratch (progress ledger, briefs, review packages)
10
+ .superpowers/
11
+ # Python
12
+ __pycache__/
13
+ *.pyc
14
+ .ipynb_checkpoints/
15
+ .pytest_cache/
LICENSE ADDED
@@ -0,0 +1,202 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ Apache License
2
+ Version 2.0, January 2004
3
+ http://www.apache.org/licenses/
4
+
5
+ TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
6
+
7
+ 1. Definitions.
8
+
9
+ "License" shall mean the terms and conditions for use, reproduction,
10
+ and distribution as defined by Sections 1 through 9 of this document.
11
+
12
+ "Licensor" shall mean the copyright owner or entity authorized by
13
+ the copyright owner that is granting the License.
14
+
15
+ "Legal Entity" shall mean the union of the acting entity and all
16
+ other entities that control, are controlled by, or are under common
17
+ control with that entity. For the purposes of this definition,
18
+ "control" means (i) the power, direct or indirect, to cause the
19
+ direction or management of such entity, whether by contract or
20
+ otherwise, or (ii) ownership of fifty percent (50%) or more of the
21
+ outstanding shares, or (iii) beneficial ownership of such entity.
22
+
23
+ "You" (or "Your") shall mean an individual or Legal Entity
24
+ exercising permissions granted by this License.
25
+
26
+ "Source" form shall mean the preferred form for making modifications,
27
+ including but not limited to software source code, documentation
28
+ source, and configuration files.
29
+
30
+ "Object" form shall mean any form resulting from mechanical
31
+ transformation or translation of a Source form, including but
32
+ not limited to compiled object code, generated documentation,
33
+ and conversions to other media types.
34
+
35
+ "Work" shall mean the work of authorship, whether in Source or
36
+ Object form, made available under the License, as indicated by a
37
+ copyright notice that is included in or attached to the work
38
+ (an example is provided in the Appendix below).
39
+
40
+ "Derivative Works" shall mean any work, whether in Source or Object
41
+ form, that is based on (or derived from) the Work and for which the
42
+ editorial revisions, annotations, elaborations, or other modifications
43
+ represent, as a whole, an original work of authorship. For the purposes
44
+ of this License, Derivative Works shall not include works that remain
45
+ separable from, or merely link (or bind by name) to the interfaces of,
46
+ the Work and Derivative Works thereof.
47
+
48
+ "Contribution" shall mean any work of authorship, including
49
+ the original version of the Work and any modifications or additions
50
+ to that Work or Derivative Works thereof, that is intentionally
51
+ submitted to Licensor for inclusion in the Work by the copyright owner
52
+ or by an individual or Legal Entity authorized to submit on behalf of
53
+ the copyright owner. For the purposes of this definition, "submitted"
54
+ means any form of electronic, verbal, or written communication sent
55
+ to the Licensor or its representatives, including but not limited to
56
+ communication on electronic mailing lists, source code control systems,
57
+ and issue tracking systems that are managed by, or on behalf of, the
58
+ Licensor for the purpose of discussing and improving the Work, but
59
+ excluding communication that is conspicuously marked or otherwise
60
+ designated in writing by the copyright owner as "Not a Contribution."
61
+
62
+ "Contributor" shall mean Licensor and any individual or Legal Entity
63
+ on behalf of whom a Contribution has been received by Licensor and
64
+ subsequently incorporated within the Work.
65
+
66
+ 2. Grant of Copyright License. Subject to the terms and conditions of
67
+ this License, each Contributor hereby grants to You a perpetual,
68
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
69
+ copyright license to reproduce, prepare Derivative Works of,
70
+ publicly display, publicly perform, sublicense, and distribute the
71
+ Work and such Derivative Works in Source or Object form.
72
+
73
+ 3. Grant of Patent License. Subject to the terms and conditions of
74
+ this License, each Contributor hereby grants to You a perpetual,
75
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
76
+ (except as stated in this section) patent license to make, have made,
77
+ use, offer to sell, sell, import, and otherwise transfer the Work,
78
+ where such license applies only to those patent claims licensable
79
+ by such Contributor that are necessarily infringed by their
80
+ Contribution(s) alone or by combination of their Contribution(s)
81
+ with the Work to which such Contribution(s) was submitted. If You
82
+ institute patent litigation against any entity (including a
83
+ cross-claim or counterclaim in a lawsuit) alleging that the Work
84
+ or a Contribution incorporated within the Work constitutes direct
85
+ or contributory patent infringement, then any patent licenses
86
+ granted to You under this License for that Work shall terminate
87
+ as of the date such litigation is filed.
88
+
89
+ 4. Redistribution. You may reproduce and distribute copies of the
90
+ Work or Derivative Works thereof in any medium, with or without
91
+ modifications, and in Source or Object form, provided that You
92
+ meet the following conditions:
93
+
94
+ (a) You must give any other recipients of the Work or
95
+ Derivative Works a copy of this License; and
96
+
97
+ (b) You must cause any modified files to carry prominent notices
98
+ stating that You changed the files; and
99
+
100
+ (c) You must retain, in the Source form of any Derivative Works
101
+ that You distribute, all copyright, patent, trademark, and
102
+ attribution notices from the Source form of the Work,
103
+ excluding those notices that do not pertain to any part of
104
+ the Derivative Works; and
105
+
106
+ (d) If the Work includes a "NOTICE" text file as part of its
107
+ distribution, then any Derivative Works that You distribute must
108
+ include a readable copy of the attribution notices contained
109
+ within such NOTICE file, excluding those notices that do not
110
+ pertain to any part of the Derivative Works, in at least one
111
+ of the following places: within a NOTICE text file distributed
112
+ as part of the Derivative Works; within the Source form or
113
+ documentation, if provided along with the Derivative Works; or,
114
+ within a display generated by the Derivative Works, if and
115
+ wherever such third-party notices normally appear. The contents
116
+ of the NOTICE file are for informational purposes only and
117
+ do not modify the License. You may add Your own attribution
118
+ notices within Derivative Works that You distribute, alongside
119
+ or as an addendum to the NOTICE text from the Work, provided
120
+ that such additional attribution notices cannot be construed
121
+ as modifying the License.
122
+
123
+ You may add Your own copyright statement to Your modifications and
124
+ may provide additional or different license terms and conditions
125
+ for use, reproduction, or distribution of Your modifications, or
126
+ for any such Derivative Works as a whole, provided Your use,
127
+ reproduction, and distribution of the Work otherwise complies with
128
+ the conditions stated in this License.
129
+
130
+ 5. Submission of Contributions. Unless You explicitly state otherwise,
131
+ any Contribution intentionally submitted for inclusion in the Work
132
+ by You to the Licensor shall be under the terms and conditions of
133
+ this License, without any additional terms or conditions.
134
+ Notwithstanding the above, nothing herein shall supersede or modify
135
+ the terms of any separate license agreement you may have executed
136
+ with Licensor regarding such Contributions.
137
+
138
+ 6. Trademarks. This License does not grant permission to use the trade
139
+ names, trademarks, service marks, or product names of the Licensor,
140
+ except as required for reasonable and customary use in describing the
141
+ origin of the Work and reproducing the content of the NOTICE file.
142
+
143
+ 7. Disclaimer of Warranty. Unless required by applicable law or
144
+ agreed to in writing, Licensor provides the Work (and each
145
+ Contributor provides its Contributions) on an "AS IS" BASIS,
146
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
147
+ implied, including, without limitation, any warranties or conditions
148
+ of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
149
+ PARTICULAR PURPOSE. You are solely responsible for determining the
150
+ appropriateness of using or redistributing the Work and assume any
151
+ risks associated with Your exercise of permissions under this License.
152
+
153
+ 8. Limitation of Liability. In no event and under no legal theory,
154
+ whether in tort (including negligence), contract, or otherwise,
155
+ unless required by applicable law (such as deliberate and grossly
156
+ negligent acts) or agreed to in writing, shall any Contributor be
157
+ liable to You for damages, including any direct, indirect, special,
158
+ incidental, or consequential damages of any character arising as a
159
+ result of this License or out of the use or inability to use the
160
+ Work (including but not limited to damages for loss of goodwill,
161
+ work stoppage, computer failure or malfunction, or any and all
162
+ other commercial damages or losses), even if such Contributor
163
+ has been advised of the possibility of such damages.
164
+
165
+ 9. Accepting Warranty or Additional Liability. While redistributing
166
+ the Work or Derivative Works thereof, You may choose to offer,
167
+ and charge a fee for, acceptance of support, warranty, indemnity,
168
+ or other liability obligations and/or rights consistent with this
169
+ License. However, in accepting such obligations, You may act only
170
+ on Your own behalf and on Your sole responsibility, not on behalf
171
+ of any other Contributor, and only if You agree to indemnify,
172
+ defend, and hold each Contributor harmless for any liability
173
+ incurred by, or claims asserted against, such Contributor by reason
174
+ of your accepting any such warranty or additional liability.
175
+
176
+ END OF TERMS AND CONDITIONS
177
+
178
+ APPENDIX: How to apply the Apache License to your work.
179
+
180
+ To apply the Apache License to your work, attach the following
181
+ boilerplate notice, with the fields enclosed by brackets "{}"
182
+ replaced with your own identifying information. (Don't include
183
+ the brackets!) The text should be enclosed in the appropriate
184
+ comment syntax for the file format. We also recommend that a
185
+ file or class name and description of purpose be included on the
186
+ same "printed page" as the copyright notice for easier
187
+ identification within third-party archives.
188
+
189
+ Copyright {yyyy} {name of copyright owner}
190
+
191
+ Licensed under the Apache License, Version 2.0 (the "License");
192
+ you may not use this file except in compliance with the License.
193
+ You may obtain a copy of the License at
194
+
195
+ http://www.apache.org/licenses/LICENSE-2.0
196
+
197
+ Unless required by applicable law or agreed to in writing, software
198
+ distributed under the License is distributed on an "AS IS" BASIS,
199
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
200
+ See the License for the specific language governing permissions and
201
+ limitations under the License.
202
+
NOTICE ADDED
@@ -0,0 +1,9 @@
 
 
 
 
 
 
 
 
 
 
1
+ CastSolid GNN (casting solidification surrogate)
2
+ Copyright 2026 Eugen Miknevic
3
+
4
+ This product includes software and trained model weights developed by
5
+ Eugen Miknevic, released under the Apache License, Version 2.0.
6
+
7
+ The graph network follows the encode-process-decode design of MeshGraphNets
8
+ (Pfaff et al., "Learning Mesh-Based Simulation with Graph Networks", ICLR 2021).
9
+ Reference solidification fields were generated with FEniCSx (dolfinx).
README.md ADDED
@@ -0,0 +1,464 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ ---
2
+ license: apache-2.0
3
+ library_name: pytorch
4
+ pipeline_tag: graph-ml
5
+ language:
6
+ - en
7
+ tags:
8
+ - graph-neural-network
9
+ - gnn
10
+ - meshgraphnet
11
+ - film
12
+ - surrogate-model
13
+ - scientific-machine-learning
14
+ - physics-ml
15
+ - heat-transfer
16
+ - casting
17
+ - metal-casting
18
+ - foundry
19
+ - solidification
20
+ - shrinkage
21
+ - finite-element
22
+ - fenicsx
23
+ - pytorch-geometric
24
+ - scikit-learn
25
+ ---
26
+
27
+ # CastSolid GNN v5.1: casting solidification surrogate
28
+
29
+ CastSolid GNN is a graph neural network surrogate for screening how metal castings solidify. You give it
30
+ a STEP model, an alloy and a mold condition. The pipeline meshes the part, predicts the
31
+ solidification time at every mesh node and marks the regions that freeze last. Those hot spots
32
+ are where feeding problems and shrinkage defects usually start.
33
+
34
+ A transient finite-element solve of the same field takes minutes to hours. Once the part is
35
+ meshed, this model returns the field in seconds, which makes it useful early in design: you can
36
+ compare concepts, see where the hot regions are, and decide which variants are worth a full
37
+ simulation.
38
+
39
+ It does not replace a calibrated process simulation or physical validation. A hot spot marks
40
+ feeding difficulty and shrinkage risk. It is not a porosity label.
41
+
42
+ <p align="center">
43
+ <img src="assets/solidification.gif" alt="Predicted solidification playback" width="720"><br>
44
+ <em>Predicted solidification playback. Solidified material turns translucent while the
45
+ remaining liquid contracts toward the hot spot.</em>
46
+ </p>
47
+
48
+ | | |
49
+ |---|---|
50
+ | Version | v5.1, frozen on 31 July 2026 |
51
+ | Task | Per-node regression on a tetrahedral mesh (graph ML) |
52
+ | Inputs | STEP geometry in mm, alloy grade, mold system, optional chill and sleeve body roles |
53
+ | Outputs | Solidification time per node, total time, top-10% hot-spot mask, out-of-distribution warnings, shrinkage screening fields |
54
+ | Architecture | 12-block MeshGraphNet-style GNN with FiLM conditioning, plus two gradient-boosted heads |
55
+ | Training data | FEniCSx enthalpy-method conduction simulations: about 9.7k primitive cases, 4.7k multi-alloy and multi-BC cases, 500 real-part cases |
56
+ | Framework | PyTorch 2.3, PyTorch Geometric 2.7, scikit-learn 1.5.2, NumPy 2 |
57
+ | License | Apache-2.0 for code and weights |
58
+ | Code | Included in this repository. Showcase on [GitHub](https://github.com/eugenmik/casting-gnn-solidification) |
59
+ | Author | Eugen Miknevic, independent foundry engineer |
60
+
61
+ ## Contents
62
+
63
+ - [What is in this repository](#what-is-in-this-repository)
64
+ - [Quick start](#quick-start)
65
+ - [How the predictor works](#how-the-predictor-works)
66
+ - [Training data](#training-data)
67
+ - [Training procedure](#training-procedure)
68
+ - [Evaluation](#evaluation)
69
+ - [Intended use and limitations](#intended-use-and-limitations)
70
+ - [The node-order bug](#the-node-order-bug)
71
+ - [Reproducibility](#reproducibility)
72
+ - [License and citation](#license-and-citation)
73
+
74
+ ## What is in this repository
75
+
76
+ The predictor combines three trained models. Use them together; none of them is meant to run
77
+ alone.
78
+
79
+ | File | Size | Role |
80
+ |---|---:|---|
81
+ | `weights/gnn_v5.1.safetensors` | 7.4 MB | GNN weights, feature and target normalization, and the architecture config (stored in the safetensors metadata). Predicts the shape of the field |
82
+ | `weights/gbm_total_v3.skops` | 0.6 MB | `HistGradientBoostingRegressor` on 16 features. Predicts the total solidification time of the part, which sets the absolute scale |
83
+ | `weights/gbm_std_v2.skops` | 0.8 MB | `HistGradientBoostingRegressor` with absolute-error loss on 17 features. Predicts the spread (standard deviation) of the field |
84
+ | `weights/ood_ranges.json` | <1 KB | Training envelope used by the geometry out-of-distribution check |
85
+ | `weights/SHA256SUMS` | <1 KB | Checksums of the files above |
86
+ | `src/` | | The full pipeline: CAD healing and meshing, features, GNN, recomposition, shrinkage screening, VTU export, training scripts and a local web viewer |
87
+ | `tools/` | | Density-targeted STEP meshing, the property loader, the sleeve registry and the scripts that recover solver-to-mesh node order |
88
+ | `data/properties/` | | Material, mold, chill and exothermic-sleeve tables read at inference |
89
+ | `tests/` | | Deterministic unit tests that need neither weights nor simulation data |
90
+
91
+ None of the weight files can execute code when loaded. The GNN is stored as
92
+ [safetensors](https://github.com/huggingface/safetensors) and the gradient-boosted heads as
93
+ [skops](https://skops.readthedocs.io/). Before loading a skops file, `src/release_io.py` checks
94
+ every type it names against an allowlist of scikit-learn and NumPy types. All three files were
95
+ converted from the internal PyTorch and joblib pickles and checked against them (see
96
+ [Reproducibility](#reproducibility)).
97
+
98
+ ## Quick start
99
+
100
+ ### 1. Install
101
+
102
+ Python 3.11 is recommended. Install PyTorch 2.3 for your hardware first (CPU works for
103
+ inference), then the matching `torch-cluster` wheel, then the rest:
104
+
105
+ ```bash
106
+ hf download eugenmik/castsolid-gnn --local-dir castsolid-gnn
107
+ cd castsolid-gnn
108
+
109
+ python -m venv .venv && source .venv/bin/activate
110
+ pip install "torch==2.3.0" --index-url https://download.pytorch.org/whl/cpu
111
+ pip install torch-cluster -f https://data.pyg.org/whl/torch-2.3.0+cpu.html
112
+ pip install -r requirements.txt
113
+ pytest -q # optional, no GPU needed
114
+ ```
115
+
116
+ For a CUDA build, replace `cpu` with your CUDA tag (for example `cu121`) in both URLs.
117
+ The `gmsh` Python wheel handles CAD healing and tetrahedral meshing. `pyvista` and `trame` are
118
+ only needed for the browser viewer.
119
+
120
+ ### 2. Predict from the command line
121
+
122
+ Run this from the repository root. The weight paths resolve on their own.
123
+
124
+ ```bash
125
+ python -m src.predict_casting path/to/part.step --out-dir out \
126
+ --alloy EN-GJS-500-7 --mold green_sand
127
+ ```
128
+
129
+ It writes three files:
130
+
131
+ - `out/<name>.vtu` is the tetrahedral mesh with point fields `solidification_time` (s),
132
+ `hotspot` (the 10% of nodes that solidify last), `niyama`, `porosity_risk` and
133
+ `macro_region_id`. Open it in ParaView.
134
+ - `out/<name>_summary.txt` holds the total time, the coordinates of the five latest nodes, any
135
+ OOD warnings and the shrinkage screening summary.
136
+ - `out/<name>.msh` is the mesh the prediction actually used.
137
+
138
+ Meshing runs through the same pipeline that prepared the training parts: keep the largest
139
+ solid, heal it with OpenCASCADE, then search the element size until the mesh has about 8,500
140
+ nodes. A second run on the same STEP file reuses the cached mesh.
141
+
142
+ ### 3. Predict from Python
143
+
144
+ ```python
145
+ from src.predict_casting import predict_casting
146
+ from src.inference_props import list_alloys, list_molds
147
+
148
+ print(list_alloys()) # 12 named grades: EN-GJL-200/250/300, EN-GJS-400-15/500-7/600-3/700-2,
149
+ # GS-38, GS-45, GS-52, G20Mn5, G17CrMo5-5
150
+ print(list_molds()) # ['green_sand', 'no_bake_furan_HTS']
151
+
152
+ r = predict_casting("part.step", out_dir="out", alloy="EN-GJS-500-7", mold="green_sand")
153
+ r["total_time_s"] # predicted total solidification time, s
154
+ r["hotspot_xyz"] # (5, 3) mm coordinates of the latest-solidifying nodes
155
+ r["ood_warnings"] # an empty list means the part is inside the training envelope
156
+ r["shrinkage"] # Niyama and macro-shrinkage screening, with a confidence label
157
+ ```
158
+
159
+ To use an alloy that is not in the table, pass a JSON file with exactly these keys (SI units,
160
+ temperatures in °C):
161
+
162
+ ```json
163
+ {"k": 41.5, "rho": 7150.0, "cp": 535.0, "L_kJ_kg": 240.0,
164
+ "TL": 1220.0, "TS": 1150.0, "Tpour": 1320.0, "mold_effusivity": 1018.0}
165
+ ```
166
+
167
+ ```bash
168
+ python -m src.predict_casting part.step --props my_alloy.json
169
+ ```
170
+
171
+ ### 4. Assemblies with chills or a feeder sleeve
172
+
173
+ By default the whole outer surface cools into a sand mold. For a multi-body STEP you can say
174
+ which solid is the casting and which ones are chills or a sleeve:
175
+
176
+ ```bash
177
+ python -m src.assembly part.step # lists solids with id, volume, bounding box
178
+ python -m src.predict_casting part.step --bodies bodies.json
179
+ ```
180
+
181
+ ```json
182
+ [{"solid_id": 1, "role": "casting"},
183
+ {"solid_id": 2, "role": "chill", "params": {"material": "cast_iron"}},
184
+ {"solid_id": 3, "role": "chill", "params": {"material": "cast_iron"}}]
185
+ ```
186
+
187
+ The roles are `casting`, `chill`, `insulating_sleeve`, `exo_sleeve` and `ignore`. Every
188
+ training case had chills only, one sleeve only, or neither, so the code rejects any other
189
+ combination instead of guessing. A sleeve is either named from
190
+ `tools/sleeves/sleeve_registry.csv` (`{"sleeve": "FOSECO_P12_15K"}`) or given a manual modulus
191
+ extension factor (`{"MEF": 1.55}`). Only `cast_iron` chills appear in the training data.
192
+
193
+ ### 5. Browser viewer (optional)
194
+
195
+ ```bash
196
+ python -m src.webapp # http://localhost:8080
197
+ ```
198
+
199
+ This is a local trame and PyVista app. You upload a STEP file and run the prediction, then move
200
+ a time slider (solidified material turns translucent), toggle the hot spots, or export a
201
+ solidification GIF from the current camera angle.
202
+
203
+ ### Runtime
204
+
205
+ The GNN itself runs in seconds on a CPU or GPU. CAD healing and meshing take most of the time,
206
+ from seconds to a few minutes for typical parts. Very large or very detailed STEP files, with
207
+ thousands of small faces, can run past the meshing time limit.
208
+
209
+ ## How the predictor works
210
+
211
+ ```mermaid
212
+ flowchart LR
213
+ A[STEP + alloy + mold<br/>+ optional body roles] --> B[CAD healing +<br/>tetrahedral mesh]
214
+ B --> C[15 node features<br/>+ 5-value alloy/BC vector]
215
+ C --> D1[12-block GNN<br/>field shape]
216
+ C --> D2[GBM total head<br/>absolute scale]
217
+ C --> D3[GBM spread head<br/>field contrast]
218
+ D1 --> E[Soft-anchor<br/>recomposition]
219
+ D2 --> E
220
+ D3 --> E
221
+ E --> F[Time field + hot spots<br/>+ OOD + shrinkage screening]
222
+ F --> G[VTU / browser viewer]
223
+ ```
224
+
225
+ ### Why three models
226
+
227
+ The GNN gets the spatial pattern of solidification right more reliably than its absolute size.
228
+ The size depends heavily on alloy, mold and part dimensions, and two small gradient-boosted
229
+ models estimate it better from a handful of global descriptors. On the original benchmark with
230
+ held-out geometry families, the hybrid brought nodal MAE down from 5.77 s (GNN alone) to
231
+ 4.26 s.
232
+
233
+ ### Graph network
234
+
235
+ Nodes are mesh vertices. Edges are the union of the tetrahedral mesh edges and the 16 nearest
236
+ neighbours of each node. Each edge carries its displacement vector, its length and a flag that
237
+ says whether it is a mesh edge, so the model sees geometry in millimetres but never absolute
238
+ coordinates.
239
+
240
+ Each node has 15 features. All of them come from the mesh and its boundary labels; file names
241
+ and part identity are never used.
242
+
243
+ - Geodesic distance through the mesh to the nearest cooling surface and to the nearest
244
+ adiabatic surface, plus straight-line distance to the surface and to adiabatic faces.
245
+ - Cooling, adiabatic and interior flags.
246
+ - Part volume, surface area and the fraction of surface that cools.
247
+ - Local modulus: the volume-to-surface ratio inside geodesic balls of 5, 15 and 40 mm. This is
248
+ a local version of Chvorinov's modulus.
249
+ - Two boundary-condition channels. `boundary_extraction` encodes heat drawn out by chills.
250
+ `exothermic_input` encodes heat from an exothermic sleeve, spread along the mesh with a
251
+ 15 mm decay length.
252
+
253
+ A vector `g` of five dimensionless numbers describes the alloy and the mold: Stefan number,
254
+ normalized freezing range, thermal diffusivity ratio, normalized superheat and normalized mold
255
+ effusivity. It conditions every processor block through FiLM (`h ← h·(1+γ(g)) + β(g)`).
256
+
257
+ The network follows the encode-process-decode design of
258
+ [MeshGraphNets](https://arxiv.org/abs/2010.03409). MLP encoders embed nodes and edges, 12
259
+ message-passing blocks (hidden width 128, residual connections, LayerNorm, mean aggregation)
260
+ process them, and a two-head decoder predicts the per-node deviation and a graph-level scale
261
+ separately. The model has about 1.85 M parameters. Its target is the standardized `log1p` of the
262
+ time (s) at which each node cools through the solidus.
263
+
264
+ ### Gradient-boosted heads
265
+
266
+ Both heads are scikit-learn `HistGradientBoostingRegressor` models. They see one row per part:
267
+ alloy properties (k, ρ, cp, latent heat, liquidus, solidus, pouring temperature), the part's
268
+ volume-to-area ratio, mold effusivity, a one-hot boundary regime (none, chills, insulating
269
+ sleeve or exothermic sleeve), the number of chills, the sleeve MEF and the exothermic heat flux.
270
+ The spread head also gets the fraction of boundary area covered by chills or a sleeve. The total
271
+ head predicts `log1p` of the total solidification time; the spread head predicts `log1p` of the
272
+ standard deviation of the nodal field and is trained with absolute-error loss.
273
+
274
+ ### Soft-anchor recomposition
275
+
276
+ The GNN field is scaled, shifted and clipped at zero:
277
+
278
+ ```
279
+ pred(a, m) = max(0, a · (gnn − mean(gnn)) + m)
280
+ ```
281
+
282
+ The pair `(a, m)` minimizes
283
+
284
+ ```
285
+ J = w_total · ((q99.5(pred) − T_total) / T_total)² + w_std · ((std(pred) − S) / S)²
286
+ ```
287
+
288
+ where `T_total` and `S` come from the two heads, `w_total = 1` and `w_std = 4`. When both
289
+ targets can be met exactly, the result equals the closed-form affine solve. When clipping makes
290
+ that impossible, the total anchor is allowed to move by a few percent so the spread stays close.
291
+ The post-clip spread is not monotone in `a`, so the solver brackets the smallest root
292
+ explicitly. The code is in `src/hybrid.py::hybrid_field_soft`.
293
+
294
+ ### Shrinkage screening
295
+
296
+ `src/shrinkage.py` post-processes the predicted field. It computes a time-based Niyama proxy,
297
+ finds macro-shrinkage regions (hot regions that liquid can no longer reach to feed them), places
298
+ gravity-aware sinks and suggests feeder moduli. It is deterministic and adds about half a second
299
+ per part. On parts with chills the Niyama result carries `niyama_confidence: "low"`, because a
300
+ proxy built only from time cannot see the extra heat a chill pulls out locally. Pass
301
+ `--no-shrinkage` to skip it.
302
+
303
+ ## Training data
304
+
305
+ Every reference field was computed with [FEniCSx](https://fenicsproject.org/) (dolfinx) using an
306
+ enthalpy-method model of transient heat conduction with latent heat. The model has no mold
307
+ filling, flow, gas or stress.
308
+
309
+ <p align="center">
310
+ <img src="assets/3dforms.png" alt="Examples of primitive training geometries" width="640"><br>
311
+ <em>Some of the primitive geometry families from the first training regime.</em>
312
+ </p>
313
+
314
+ | Set | Cases | Content |
315
+ |---|---:|---|
316
+ | Primitives (original regime) | 9,710 | 42 parametric geometry families, a single alloy, sand mold, different assignments of cooling and adiabatic faces |
317
+ | Multi-alloy and multi-BC (v4) | 4,727 | Realistic thick-walled castings in 12 named iron and steel grades plus sampled synthetic alloys. Boundary regimes: sand only, chills, insulating sleeves, exothermic sleeves |
318
+ | Real-part assemblies (E) | 500 | Prepared production geometries with virtual chill and sleeve contact patches |
319
+
320
+ <p align="center">
321
+ <img src="assets/mesh.png" alt="Tetrahedral training mesh with tagged boundary groups" width="640"><br>
322
+ <em>Training meshes carry three physical groups: Volume, Cooling (mold contact) and
323
+ Adiabatic.</em>
324
+ </p>
325
+
326
+ The meshes are gmsh tetrahedral meshes with roughly 7,500 to 10,000 nodes. The target at each
327
+ node is the time its temperature drops through the solidus. Material properties come from
328
+ handbook data, and the subset used at inference is in `data/properties/`. The exothermic sleeve
329
+ heat input is calibrated so that the modulus extension falls in vendor-published ranges. The
330
+ raw simulation corpus (about 100 GB) is not distributed.
331
+
332
+ ## Training procedure
333
+
334
+ v5.1 came out of a chain of fine-tunes, not a single training run.
335
+
336
+ 1. SP1 trained a 12-block two-head GNN from scratch on the primitive set, with the union graph,
337
+ local-modulus features and a loss on both nodal and total time.
338
+ 2. FT2b fine-tuned it on the multi-alloy, multi-BC set. The FiLM layers started at zero, so
339
+ training began from exactly the SP1 function.
340
+ 3. Exo-fix re-encoded the exothermic channel. As a sparse indicator with +19σ spikes it had
341
+ been suppressed by the network; as a geodesic-diffused field it is used. Exothermic cases were
342
+ oversampled three times, and the checkpoint was picked on gate metrics instead of validation
343
+ loss alone.
344
+ 4. FT3 fine-tuned on the real-part assemblies (set E) with the normalization statistics frozen.
345
+ Its epoch-21 checkpoint is the v5.1 GNN.
346
+ 5. The BC-aware total head (v3), the absolute-error spread head (v2) and the soft anchor were
347
+ selected on held-out real-part validation rows.
348
+
349
+ Training ran on one RTX 3060 (12 GB) with batch size 2, at 15 to 20 minutes per epoch. To be
350
+ promoted, v5.1 had to pass four gates on held-out data: two gold-standard real assemblies, 24
351
+ held-out real parts, an extrapolation split and a check against collapsed field variance.
352
+
353
+ ## Evaluation
354
+
355
+ Do not compare numbers across regimes. Total times in the primitive regime are tens of seconds;
356
+ on real geometry they run to hundreds or thousands.
357
+
358
+ | Evaluation | n | Nodal MAE | Hot-spot overlap | What it shows |
359
+ |---|---:|---:|---:|---|
360
+ | Held-out primitive geometry families | family split | 4.26 s | 0.917 | Generalization to unseen synthetic shape families in the original regime |
361
+ | Unseen real single-body castings | 6 | 80.3 s median | 0.738 median | Generalization to new real geometry, scored against a FEniCSx reference on the same mesh |
362
+ | Unseen real sleeve assemblies | 2 | 443.7 s median | 0.932 median | The hot regions rank well, but the field is too smooth (predicted spread is about 0.23 of the reference) and the absolute times are off |
363
+
364
+ Hot-spot overlap compares the 10% of nodes that solidify last in the prediction with the same
365
+ set in the reference. 1.0 means the two sets are identical.
366
+
367
+ The real-geometry study started from 10 production CAD models. Two had self-intersecting
368
+ B-reps and could not be meshed, which left eight scored parts. One single-body part reached a
369
+ hot-spot overlap of only 0.035, so the median says nothing certain about any one part.
370
+
371
+ > **Important.** The training data and the real-geometry references come from the same FEniCSx
372
+ > heat-transfer model, the same material database and the same boundary assumptions. These
373
+ > numbers measure how well the surrogate carries over to new geometry. They are not evidence of
374
+ > physical accuracy against instrumented castings.
375
+
376
+ ## Intended use and limitations
377
+
378
+ Good uses:
379
+
380
+ - comparing early casting concepts and gating or feeding variants;
381
+ - finding candidate last-to-solidify regions;
382
+ - deciding which designs need a full process simulation;
383
+ - early quoting and design discussions, as long as the limitations stay visible.
384
+
385
+ Out of scope:
386
+
387
+ - final process sign-off or contractual acceptance;
388
+ - guaranteed defect or porosity prediction;
389
+ - mold filling, flow, gas, stress or distortion;
390
+ - unattended decisions on geometry outside the training envelope;
391
+ - safety-critical decisions without an independent engineering review.
392
+
393
+ Known limitations:
394
+
395
+ 1. The physics is pure conduction with latent heat, and the reference solver shares the
396
+ surrogate's property and boundary assumptions.
397
+ 2. STEP files must be in millimetres; units are not detected. STL is not supported, because
398
+ healing and face tagging need a B-rep.
399
+ 3. Invalid or very detailed B-reps can fail to mesh or run past the meshing time limit.
400
+ 4. The default density of about 8,500 nodes can under-resolve large thin-walled parts.
401
+ 5. On assemblies the predicted field is over-smoothed, even when the hot regions are ranked
402
+ well.
403
+ 6. The OOD check only covers part size and distance to cooling. It is not a calibrated
404
+ uncertainty estimate.
405
+ 7. The two mold systems currently share the same interim thermal properties, so choosing one
406
+ over the other does not change the prediction.
407
+ 8. The Niyama proxy has low confidence on parts with chills and is flagged that way.
408
+
409
+ Treat each result as a screening hypothesis: where should an engineer look first, and is the
410
+ concept ready for a full simulation? Keep the mesh, the inputs, the model version and the
411
+ warnings together with every result. OOD, low-confidence and high-consequence cases belong in a
412
+ calibrated simulation and a physical review.
413
+
414
+ ## The node-order bug
415
+
416
+ The worst defect in this project was outside the neural network. The solver stored
417
+ temperatures in dolfinx degree-of-freedom order, while coordinates and geometric features were
418
+ in gmsh node order. About 60% of the nodal targets ended up attached to the wrong positions,
419
+ silently, and every early model failure traced back to that.
420
+
421
+ `tools/recovery/` rebuilds the permutation between the two orderings, and
422
+ `src/build_dataset.py` applies it when it builds the graphs. If you work on simulation-to-ML
423
+ pipelines, check node identity across every solver and mesh interface before you start tuning a
424
+ model.
425
+
426
+ ## Reproducibility
427
+
428
+ `tools/export_release.py` converted the internal checkpoints with the functions in
429
+ `src/release_io.py`:
430
+
431
+ | Release file | Internal artifact | Check |
432
+ |---|---|---|
433
+ | `gnn_v5.1.safetensors` | `ckpt_ep021.pt` (FT3, epoch 21) | Every tensor bit-identical; same config and normalization |
434
+ | `gbm_total_v3.skops` | `hgb_v3.joblib` | Identical predictions on 10,002 probe rows spanning each feature's training split range |
435
+ | `gbm_std_v2.skops` | `hgb_std_v2.joblib` | Identical predictions on 10,002 probe rows spanning each feature's training split range |
436
+
437
+ On a real test part, the release files and the internal pickles give the same predicted field.
438
+ The training scripts are in `src/` (`train_gnn.py`, `train_ft2b.py`, `gbm_ft2b.py`), but the
439
+ simulation corpus is not part of this release.
440
+
441
+ Software used: Python 3.11, PyTorch 2.3.0, PyTorch Geometric 2.7.0, scikit-learn 1.5.2, NumPy 2.x,
442
+ gmsh 4.13 or newer and SciPy. The gradient-boosted heads were trained with scikit-learn 1.5.2,
443
+ and other versions may refuse to load them or load them with changed behaviour.
444
+
445
+ ## License and citation
446
+
447
+ Code and weights are released under the [Apache License 2.0](LICENSE). The tables in
448
+ `data/properties/` are modelling inputs, not certified material specifications. Check them
449
+ against your actual alloy and process.
450
+
451
+ ```bibtex
452
+ @software{miknevic_castsolid_gnn_2026,
453
+ author = {Miknevic, Eugen},
454
+ title = {CastSolid GNN: a graph neural network surrogate for
455
+ rapid solidification screening of metal castings},
456
+ version = {5.1},
457
+ year = {2026},
458
+ url = {https://huggingface.co/eugenmik/castsolid-gnn}
459
+ }
460
+ ```
461
+
462
+ Contact: Eugen Miknevic, eugenmiknevic@gmail.com. If you have failure cases, questions or
463
+ measured data from real castings, please post them in the
464
+ [Community tab](https://huggingface.co/eugenmik/castsolid-gnn/discussions).
assets/3dforms.png ADDED

Git LFS Details

  • SHA256: f560e08a9d1d0dd0ee37bebc2398e96078d3eb7d70498ee9de73630c4125f9c6
  • Pointer size: 131 Bytes
  • Size of remote file: 204 kB
assets/mesh.png ADDED

Git LFS Details

  • SHA256: f47539fc931f4f38f417992b323d7271de0ad2ef36dc4e108a0453ffad449cb6
  • Pointer size: 132 Bytes
  • Size of remote file: 1.07 MB
assets/solidification.gif ADDED

Git LFS Details

  • SHA256: 32aa99b45277161680d10e51f62b9a0665b36fc09ab1f20795415baa0a225d80
  • Pointer size: 132 Bytes
  • Size of remote file: 4.58 MB
data/properties/README.md ADDED
@@ -0,0 +1,13 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Public runtime property tables
2
+
3
+ These compact CSV files contain the numeric rows required by the public inference and unit-test
4
+ code. They are a curated runtime subset of the project's larger property record.
5
+
6
+ - `materials.csv`: 12 named iron and steel grades.
7
+ - `molds.csv`: two UI labels currently sharing the same interim thermal properties.
8
+ - `chills.csv`: example chill materials; incomplete rows are skipped by the loader.
9
+ - `exo_calibration.csv`: the MEF-to-heat-flux calibration used by the example sleeve path.
10
+
11
+ The values remain modeling inputs, not certified material specifications. A production user
12
+ should replace or verify them for the actual alloy, mold and process and should retain the exact
13
+ property row with every result.
data/properties/chills.csv ADDED
@@ -0,0 +1,5 @@
 
 
 
 
 
 
1
+ material,k_W_mK,rho_kg_m3,cp_J_kgK,effusivity_W_s05_m2K
2
+ graphite,68.97,1922,1692,14978
3
+ cast_iron,38.9,7150,515,11968
4
+ steel,51.0,7860,486,13958
5
+ copper,OWNER,OWNER,OWNER,OWNER
data/properties/exo_calibration.csv ADDED
@@ -0,0 +1,13 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ MEF,q0_W_m2,duration_s,t_sand_s,t_exo_s,ratio,ratio_target
2
+ 1.4,640000.0,105.68312000817032,352.2770666939011,639.1643789347195,1.814379757766347,1.9599999999999997
3
+ 1.45,960000.0,105.68312000817032,352.2770666939011,754.0987323561276,2.1406410000892153,2.1025
4
+ 1.49,960000.0,105.68312000817032,352.2770666939011,760.0587908504301,2.1575596674047954,2.2201
5
+ 1.54,960000.0,105.68312000817032,352.2770666939011,766.9076476958378,2.1770013441216016,2.3716
6
+ 1.58,1120000.0,105.68312000817032,352.2770666939011,827.4879309999664,2.348969062238114,2.4964000000000004
7
+ 1.63,1280000.0,105.68312000817032,352.2770666939011,890.1572505029782,2.5268668745798584,2.6569
8
+ 1.67,1280000.0,105.68312000817032,352.2770666939011,895.2697715970885,2.541379658912063,2.7889
9
+ 1.72,1920000.0,105.68312000817032,352.2770666939011,1131.8937331840514,3.2130781143569904,2.9583999999999997
10
+ 1.76,1920000.0,105.68312000817032,352.2770666939011,1138.063508699037,3.2305921000753584,3.0976
11
+ 1.81,1920000.0,105.68312000817032,352.2770666939011,1145.2364712168662,3.2509538073677096,3.2761
12
+ 1.85,1920000.0,105.68312000817032,352.2770666939011,1150.5882262573784,3.266145699053246,3.4225000000000003
13
+ 1.9,1920000.0,105.68312000817032,352.2770666939011,1156.8270113469869,3.2838555805058167,3.61
data/properties/materials.csv ADDED
@@ -0,0 +1,13 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ material_id,family,k_W_mK,rho_kg_m3,cp_J_kgK,L_kJ_kg,TL_C,TS_C,Tpour_lo_C,Tpour_hi_C
2
+ EN-GJL-200,grey_iron,51.9,7000,535,240,1175,1150,1265,1285
3
+ EN-GJL-250,grey_iron,41.5,7150,535,240,1220,1150,1310,1330
4
+ EN-GJL-300,grey_iron,36.3,7300,535,240,1245,1150,1335,1355
5
+ EN-GJS-400-15,ductile_iron,32.9,6800,515,220,1160,1120,1250,1270
6
+ EN-GJS-500-7,ductile_iron,36.0,7100,515,220,1160,1120,1250,1270
7
+ EN-GJS-600-3,ductile_iron,27.7,7250,515,220,1160,1120,1250,1270
8
+ EN-GJS-700-2,ductile_iron,26.0,7400,515,220,1160,1120,1250,1270
9
+ GS-38 (GE200),low_alloy_steel,51.0,7800,599,260,1530,1495,1620,1640
10
+ GS-45 (GP240GH),low_alloy_steel,50.8,7800,586,260,1530,1495,1620,1640
11
+ GS-52 (GE300),low_alloy_steel,50.7,7800,586,260,1530,1495,1620,1640
12
+ G20Mn5,low_alloy_steel,48.4,7800,590,260,1530,1495,1620,1640
13
+ G17CrMo5-5,low_alloy_steel,28.1,7800,595,251,1520,1420,1610,1630
data/properties/molds.csv ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ mold_system,k_W_mK,rho_kg_m3,cp_J_kgK,effusivity_W_s05_m2K
2
+ green_sand,0.607,1520,1123,1018
3
+ no_bake_furan_HTS,0.607,1520,1123,1018
requirements.txt ADDED
@@ -0,0 +1,21 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Install PyTorch 2.3 for your platform first (see README), plus the matching
2
+ # torch-cluster wheel, e.g. for CPU:
3
+ # pip install "torch==2.3.0" --index-url https://download.pytorch.org/whl/cpu
4
+ # pip install torch-cluster -f https://data.pyg.org/whl/torch-2.3.0+cpu.html
5
+ numpy>=2.0
6
+ scipy>=1.11
7
+ scikit-learn==1.5.2
8
+ joblib>=1.3
9
+ skops>=0.10
10
+ safetensors>=0.4
11
+ torch-geometric==2.7.0
12
+ gmsh>=4.13
13
+ meshio>=5.3
14
+ pytest>=8.3
15
+ # Optional: local browser viewer (python -m src.webapp)
16
+ pyvista>=0.44
17
+ trame>=3.6
18
+ trame-vtk>=2.8
19
+ trame-vuetify>=2.7
20
+ trame-pyvista>=0.1
21
+ imageio>=2.34
src/assembly.py ADDED
@@ -0,0 +1,904 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Multi-body STEP assembly tooling -- Part B Task 1.
2
+
3
+ Two pieces:
4
+ - `list_solids(step_path)` -- the assembly inspector: fresh gmsh/OCC
5
+ session, imports a (possibly multi-solid) STEP file with entity names
6
+ preserved (`Geometry.OCCImportLabels=1`, set BEFORE `importShapes` --
7
+ setting it after import is a no-op), and reports one row per 3-D solid
8
+ (id/name/volume/bbox/center-of-mass), sorted by volume descending. This
9
+ is the data source for the later "role menu" (casting / chill /
10
+ insulating sleeve / exothermic sleeve) that a human or heuristic
11
+ assigns per solid.
12
+ - `compose_assembly(base_step, attachments, out_step)` -- a test/fixture
13
+ helper (used by this task's own tests and by all later Part B fixtures,
14
+ e.g. proximity classification, BC-patch tagging) that builds a
15
+ synthetic multi-body STEP: import a base part, then place one OCC
16
+ primitive (box/cylinder) per attachment spec at one of the base's
17
+ bounding-box faces. NO boolean operations are performed -- the
18
+ attachment is written as an independent solid, deliberately overlapping
19
+ the base by `overlap_mm` (default 2.0mm) along the face normal. This is
20
+ intentional, not a bug: downstream, these bodies are never meshed
21
+ together (only the casting solid is meshed); the attachments exist
22
+ purely so their bounding boxes/faces can drive proximity/contact
23
+ classification against the casting, and a few mm of geometric overlap
24
+ is harmless for that purpose while guaranteeing the two solids actually
25
+ register as "touching" rather than floating a hair apart.
26
+
27
+ All lengths in mm, all gmsh sessions are fresh (`gmsh.initialize()` /
28
+ `gmsh.finalize()` in a try/finally per call) -- never share state across
29
+ calls, matching the pattern in `tools/doe/prep_parts.py` /
30
+ `src/step_mesh.py`.
31
+ """
32
+ from __future__ import annotations
33
+
34
+ import argparse
35
+ import csv
36
+ from dataclasses import dataclass, field
37
+ from pathlib import Path
38
+ from typing import Sequence
39
+
40
+ import numpy as np
41
+
42
+ _AXIS_INDEX = {"x": 0, "y": 1, "z": 2}
43
+ _VALID_SHAPES = {"box", "cylinder"}
44
+
45
+
46
+ # --------------------------------------------------------------------------
47
+ # Assembly inspection
48
+ # --------------------------------------------------------------------------
49
+ def list_solids(step_path: str) -> list[dict]:
50
+ """Import `step_path` in a fresh gmsh/OCC session and return one dict
51
+ per 3-D solid entity, sorted by `volume_mm3` descending (largest first
52
+ -- in practice the casting itself, since attached chills/sleeves are
53
+ normally much smaller):
54
+
55
+ {"solid_id": <gmsh tag, int>, "name": <STEP entity name or "">,
56
+ "volume_mm3": <float>, "bbox": [xmin, ymin, zmin, xmax, ymax, zmax],
57
+ "com": [cx, cy, cz]}
58
+
59
+ `Geometry.OCCImportLabels` MUST be set to 1 BEFORE `importShapes` --
60
+ that's the only way gmsh preserves STEP entity names (product names
61
+ from the CAD export) for `gmsh.model.getEntityName` to read; setting it
62
+ afterwards has no effect on an already-imported model. Solids with no
63
+ STEP name (or on any importer that doesn't preserve one) get `name=""`
64
+ rather than raising -- callers decide how to label an unnamed solid in
65
+ a role menu.
66
+ """
67
+ import gmsh
68
+
69
+ gmsh.initialize()
70
+ try:
71
+ gmsh.option.setNumber("General.Terminal", 0)
72
+ gmsh.option.setNumber("Geometry.OCCImportLabels", 1)
73
+ gmsh.model.add("assembly_inspect")
74
+ gmsh.model.occ.importShapes(step_path)
75
+ gmsh.model.occ.synchronize()
76
+
77
+ rows: list[dict] = []
78
+ for (_dim, tag) in gmsh.model.getEntities(3):
79
+ name = gmsh.model.getEntityName(3, tag) or ""
80
+ volume_mm3 = float(gmsh.model.occ.getMass(3, tag))
81
+ bbox = list(gmsh.model.getBoundingBox(3, tag))
82
+ com = list(gmsh.model.occ.getCenterOfMass(3, tag))
83
+ rows.append({
84
+ "solid_id": tag,
85
+ "name": name,
86
+ "volume_mm3": volume_mm3,
87
+ "bbox": bbox,
88
+ "com": com,
89
+ })
90
+ rows.sort(key=lambda r: r["volume_mm3"], reverse=True)
91
+ return rows
92
+ finally:
93
+ gmsh.finalize()
94
+
95
+
96
+ # --------------------------------------------------------------------------
97
+ # Assembly composer (test/fixture helper)
98
+ # --------------------------------------------------------------------------
99
+ def _parse_face(at_face: str) -> tuple[int, float]:
100
+ """`"+x"/"-x"/"+y"/"-y"/"+z"/"-z"` -> (axis_index, sign)."""
101
+ if len(at_face) != 2 or at_face[0] not in "+-" or at_face[1] not in _AXIS_INDEX:
102
+ raise ValueError(
103
+ f"invalid at_face {at_face!r}; expected one of +x -x +y -y +z -z")
104
+ return _AXIS_INDEX[at_face[1]], (1.0 if at_face[0] == "+" else -1.0)
105
+
106
+
107
+ def _place_attachment(att: dict, bbox_min: Sequence[float], bbox_max: Sequence[float],
108
+ center: Sequence[float]) -> None:
109
+ """Add ONE primitive (box or cylinder) to the currently-open OCC model,
110
+ positioned at the base's bbox face named by `att["at_face"]`, sunk
111
+ `att.get("overlap_mm", 2.0)` INTO the base along the face normal (see
112
+ module docstring for why a positive overlap is intentional). Does not
113
+ synchronize/write -- the caller does that once after all attachments
114
+ are placed."""
115
+ import gmsh
116
+
117
+ shape = att.get("shape")
118
+ if shape not in _VALID_SHAPES:
119
+ raise ValueError(f"invalid shape {shape!r}; expected one of {sorted(_VALID_SHAPES)}")
120
+ if "size" not in att:
121
+ raise ValueError(f"attachment missing required 'size': {att!r}")
122
+ if "at_face" not in att:
123
+ raise ValueError(f"attachment missing required 'at_face': {att!r}")
124
+
125
+ axis, sign = _parse_face(att["at_face"])
126
+ overlap_mm = float(att.get("overlap_mm", 2.0))
127
+ du, dv = att.get("offset", (0.0, 0.0))
128
+ perp_axes = [i for i in range(3) if i != axis]
129
+ face_coord = bbox_max[axis] if sign > 0 else bbox_min[axis]
130
+
131
+ if shape == "box":
132
+ dx, dy, dz = att["size"]
133
+ size_by_axis = [dx, dy, dz]
134
+ perp_size = size_by_axis[axis]
135
+
136
+ if sign > 0:
137
+ lo_axis = face_coord - overlap_mm
138
+ else:
139
+ lo_axis = face_coord + overlap_mm - perp_size
140
+
141
+ coords = [0.0, 0.0, 0.0]
142
+ extents = [0.0, 0.0, 0.0]
143
+ coords[axis] = lo_axis
144
+ extents[axis] = perp_size
145
+ for i, u in zip(perp_axes, (du, dv)):
146
+ extents[i] = size_by_axis[i]
147
+ coords[i] = center[i] - size_by_axis[i] / 2.0 + u
148
+
149
+ gmsh.model.occ.addBox(coords[0], coords[1], coords[2],
150
+ extents[0], extents[1], extents[2])
151
+ else: # cylinder
152
+ radius, height = att["size"]
153
+ base_point = [0.0, 0.0, 0.0]
154
+ axis_vec = [0.0, 0.0, 0.0]
155
+ base_point[axis] = face_coord - sign * overlap_mm
156
+ axis_vec[axis] = sign * height
157
+ for i, u in zip(perp_axes, (du, dv)):
158
+ base_point[i] = center[i] + u
159
+
160
+ gmsh.model.occ.addCylinder(base_point[0], base_point[1], base_point[2],
161
+ axis_vec[0], axis_vec[1], axis_vec[2], radius)
162
+
163
+
164
+ def compose_assembly(base_step: str, attachments: list[dict], out_step: str) -> int:
165
+ """Build a synthetic multi-body STEP: import `base_step`, then add one
166
+ OCC primitive per `attachments` entry (see module docstring for the
167
+ overlap/no-boolean-ops contract), and write the result (all solids,
168
+ unmerged) to `out_step`. Returns the number of 3-D solids written
169
+ (1 + len(attachments) in the normal case where `base_step` itself is
170
+ single-solid).
171
+
172
+ Each attachment dict:
173
+ {"shape": "box" | "cylinder",
174
+ "size": (dx, dy, dz) for a box, (radius, height) for a cylinder,
175
+ "at_face": "+x" | "-x" | "+y" | "-y" | "+z" | "-z",
176
+ "overlap_mm": float, default 2.0,
177
+ "offset": (du, dv) in the face plane, default (0.0, 0.0) (centered)}
178
+
179
+ For a box, `size[axis]` (the dimension along the face normal) is the
180
+ part that gets sunk `overlap_mm` into the base; the other two `size`
181
+ entries lie in the face plane. For a cylinder, the axis runs along the
182
+ face normal (`height`) and `radius` is in the face plane. `offset`
183
+ shifts the attachment's center within the face plane, in the same
184
+ (u, v) order as the two non-normal world axes (e.g. for "+z"/"-z",
185
+ (u, v) = (x, y); for "+x"/"-x", (u, v) = (y, z); for "+y"/"-y",
186
+ (u, v) = (x, z)).
187
+
188
+ NOTE for `tag_contact_patches` callers: a positive `overlap_mm` is a
189
+ fixed geometric gap that `tol_mm` must exceed just to detect the patch
190
+ at all, and any `tol_mm` beyond that dilates the classified patch past
191
+ the true footprint edge (see `tag_contact_patches`'s CAVEAT). A
192
+ synthetic fixture whose `overlap_mm` is large relative to its footprint
193
+ size will over-classify noticeably; real assemblies (near-zero contact
194
+ or a sub-mm clearance, not several mm sunk in) only need a small
195
+ `tol_mm`, where this dilation is negligible.
196
+ """
197
+ import gmsh
198
+
199
+ gmsh.initialize()
200
+ try:
201
+ gmsh.option.setNumber("General.Terminal", 0)
202
+ gmsh.model.add("assembly_compose")
203
+ gmsh.model.occ.importShapes(base_step)
204
+ gmsh.model.occ.synchronize()
205
+
206
+ xmin, ymin, zmin, xmax, ymax, zmax = gmsh.model.getBoundingBox(-1, -1)
207
+ bbox_min = [xmin, ymin, zmin]
208
+ bbox_max = [xmax, ymax, zmax]
209
+ center = [(bbox_min[i] + bbox_max[i]) / 2.0 for i in range(3)]
210
+
211
+ for att in attachments:
212
+ _place_attachment(att, bbox_min, bbox_max, center)
213
+
214
+ gmsh.model.occ.synchronize()
215
+ gmsh.write(out_step)
216
+ return len(gmsh.model.getEntities(3))
217
+ finally:
218
+ gmsh.finalize()
219
+
220
+
221
+ # --------------------------------------------------------------------------
222
+ # Body spec -- Part B Task 2: user-assigned solid roles -> the exact BC
223
+ # parameters the v4 model was trained on (BcPlan), via the same registries
224
+ # `tools/doe/doe.py` used to build the training manifest.
225
+ # --------------------------------------------------------------------------
226
+ _VALID_ROLES = {"casting", "chill", "insulating_sleeve", "exo_sleeve", "ignore"}
227
+ _SLEEVE_ROLES = {"insulating_sleeve", "exo_sleeve"}
228
+ _DEFAULT_CHILL_MATERIAL = "cast_iron"
229
+
230
+
231
+ @dataclass
232
+ class BcPlan:
233
+ """The resolved boundary-condition plan for one casting body, in the
234
+ exact shape `_node_features(version=4)` and `gbm_v2_total_seconds`
235
+ expect (see `feature_spec`/`gbm_bc_spec`). Produced by `validate_bodies`
236
+ -- never construct this by hand from unvalidated user input."""
237
+ bc_config: str # "none" | "chills" | "insulating_sleeve" | "exo_sleeve"
238
+ casting_solid_id: int
239
+ role_solid_ids: list[int] = field(default_factory=list) # bodies forming the patch
240
+ n_chills: int = 0
241
+ chill_effusivity: float = 0.0
242
+ sleeve_MEF: float = 0.0
243
+ exo_q0: float = 0.0 # W/m^2 (training units)
244
+ # Part-B roles task (2026-08-03): per-BODY role, `{solid_id: role}` for
245
+ # every id in `role_solid_ids` (e.g. all-"chill" for the chills branch,
246
+ # the single sleeve id -> its bc_config for the sleeve branch, empty for
247
+ # "none"). Lets a caller (e.g. `predict_casting`'s feed-node selection)
248
+ # decide per BODY ROLE rather than per global `bc_config` -- necessary
249
+ # groundwork for a future mixed-role regime even though `validate_bodies`
250
+ # itself still only ever produces a single-role mapping today.
251
+ role_by_solid: dict[int, str] = field(default_factory=dict)
252
+ # SP4 ADDENDUM (2026-08-08): several sleeve bodies are the norm on real
253
+ # castings. When they all resolve to one MEF it is used exactly and these
254
+ # stay at their defaults. When they differ, `sleeve_MEF` is the
255
+ # contact-area-weighted mean, `mef_weighted` is True, and `mef_spread`
256
+ # carries max(MEF) - min(MEF) so the report can state the spread the
257
+ # single scalar channel is standing in for rather than hiding it.
258
+ mef_weighted: bool = False
259
+ mef_spread: float = 0.0
260
+
261
+ def feature_spec(self, mold_effusivity: float) -> dict:
262
+ """The `spec` dict `src.features._node_features(version=4, spec=...)`
263
+ reads -- keys match `Global Constraints` in
264
+ `docs/superpowers/plans/2026-07-29-part-b-multibody.md` exactly."""
265
+ return {
266
+ "mold_effusivity": mold_effusivity,
267
+ "bc_config": self.bc_config,
268
+ "chill_effusivity": self.chill_effusivity,
269
+ "sleeve_MEF": self.sleeve_MEF,
270
+ "exo_q0": self.exo_q0,
271
+ }
272
+
273
+ def gbm_bc_spec(self) -> dict:
274
+ """The `bc_spec` dict `src.gbm_ft2b.gbm_v2_total_seconds` reads."""
275
+ return {
276
+ "bc_config": self.bc_config,
277
+ "n_chills": self.n_chills,
278
+ "sleeve_MEF": self.sleeve_MEF,
279
+ "exo_q0_MW": self.exo_q0 / 1e6,
280
+ }
281
+
282
+
283
+ def _load_sleeve_registry(csv_path: str) -> dict[str, dict]:
284
+ """{sleeve name: {"MEF": float, "type": str}} from
285
+ `tools/sleeves/sleeve_registry.csv` -- `type` is "exo" or "insulating"
286
+ and is used to cross-check a registry-resolved sleeve's physics against
287
+ the declared role (see `validate_bodies`)."""
288
+ out: dict[str, dict] = {}
289
+ with open(csv_path, newline="") as f:
290
+ for row in csv.DictReader(f):
291
+ out[row["name"]] = {"MEF": float(row["MEF"]), "type": row["type"]}
292
+ return out
293
+
294
+
295
+ # Declared role -> the sleeve_registry.csv `type` value it requires when the
296
+ # sleeve is resolved BY REGISTRY NAME. Without this check, a registry
297
+ # collision (two products landing on the same MEF, e.g. FOSECO_V88E
298
+ # type=insulating MEF=1.45 vs the exo_calibration.csv row at MEF=1.45) lets
299
+ # a plain insulating sleeve silently resolve a confident nonzero exo_q0 --
300
+ # a silent-wrong-physics path, not merely a cosmetic mismatch.
301
+ _ROLE_TO_SLEEVE_TYPE = {"exo_sleeve": "exo", "insulating_sleeve": "insulating"}
302
+
303
+ # Two sleeves whose MEFs differ by less than this are treated as identical --
304
+ # the registry stores MEF to 2 decimals, so anything below it is float noise,
305
+ # not a real difference in sleeve product.
306
+ _MEF_AGREEMENT_TOL = 1e-6
307
+
308
+
309
+ def _resolve_chill_material(params: dict | None) -> str:
310
+ params = params or {}
311
+ return (params.get("material") or _DEFAULT_CHILL_MATERIAL).strip()
312
+
313
+
314
+ def validate_bodies(bodies: list[dict],
315
+ chills_csv: str = "data/properties/chills.csv",
316
+ sleeve_registry_csv: str = "tools/sleeves/sleeve_registry.csv",
317
+ exo_calibration_csv: str = "data/properties/exo_calibration.csv",
318
+ tol: float = 0.05,
319
+ mef_weights: dict[int, float] | None = None) -> BcPlan:
320
+ """Validate a user-assigned role list and resolve it into a `BcPlan`.
321
+
322
+ `bodies` rows: `{"solid_id": int, "role": <see _VALID_ROLES>,
323
+ "params": dict (optional, role-dependent)}`. `ignore` rows are dropped
324
+ before anything else. Enforces the training-regime restriction (owner,
325
+ 2026-07-29): EXACTLY one casting; then either >=1 `chill` bodies
326
+ (bc_config="chills") OR one or more sleeve bodies that share a single role
327
+ (`insulating_sleeve`/`exo_sleeve`, bc_config = that role; mixing the two
328
+ roles raises) OR neither (bc_config="none").
329
+ Chills+sleeve mixed, two sleeves, or a casting count != 1 all raise a
330
+ loud `ValueError` explaining the restriction -- the trained model has no
331
+ BC channel for anything else, so silently coercing one regime into
332
+ another would produce a confidently wrong prediction.
333
+
334
+ Chill params: `{"material": str}` (default "cast_iron"); an unknown
335
+ material raises `ValueError` listing `load_chill_effusivities()`'s known
336
+ materials. Multiple chills MUST share one material -- the trained model
337
+ has a single scalar `chill_effusivity` channel, not one per chill, so
338
+ differing materials raise `ValueError` (single chill_effusivity per
339
+ training convention).
340
+
341
+ Sleeve params: exactly one of `{"sleeve": <sleeve_registry.csv name>}`
342
+ (MEF looked up in the registry) or `{"MEF": float}` (manual override) --
343
+ both or neither present raises `ValueError`. An unknown registry name
344
+ raises `ValueError` listing the known names. When resolved BY NAME, the
345
+ registry row's `type` column ("exo" or "insulating") MUST match the
346
+ declared role (`exo_sleeve` requires type=="exo", `insulating_sleeve`
347
+ requires type=="insulating") -- a mismatch raises `ValueError` naming
348
+ both, since two products can share the same MEF (a registry collision)
349
+ while being physically different (an insulating sleeve has no
350
+ exothermic heat release), and resolving one under the other's role
351
+ would silently produce a confidently wrong prediction. Manual
352
+ `{"MEF": float}` overrides BYPASS this check entirely (no registry row
353
+ to check against) -- this is expert mode: the caller is asserting the
354
+ physics directly and is trusted to pick the right role. For `bc_config
355
+ == "exo_sleeve"`, the resolved MEF is turned into a calibrated `exo_q0`
356
+ via `tools.doe.load_properties.resolve_exo_q0_by_mef` (same nearest-MEF
357
+ lookup `src.build_dataset._resolve_exo_q0_v4` uses for training data --
358
+ raises `ValueError` if no calibration point is within `tol`).
359
+
360
+ Several sleeves (SP4 ADDENDUM, 2026-08-08): real castings carry 3-8
361
+ sleeves, so N bodies of one role are accepted, mirroring the chills
362
+ branch. When every sleeve resolves to the same MEF it is used exactly
363
+ (`mef_weighted=False`, `mef_spread=0.0`). When they differ -- e.g. an
364
+ assembly with two sleeve sizes -- the model's single scalar channel
365
+ cannot hold both, so `mef_weights` (contact-patch area per solid_id,
366
+ exactly `tag_contact_patches`'s `per_body_patch_area_mm2`) MUST be
367
+ supplied and the effective MEF is their area-weighted mean, with
368
+ `mef_weighted=True` and `mef_spread` recording what was approximated.
369
+ Omitting the weights on a mixed set raises rather than silently picking
370
+ one sleeve's MEF or an unweighted mean.
371
+ """
372
+ from tools.doe.load_properties import (load_chill_effusivities,
373
+ resolve_exo_q0_by_mef)
374
+
375
+ active = [b for b in bodies if (b.get("role") or "") != "ignore"]
376
+
377
+ for b in active:
378
+ role = b.get("role")
379
+ if role not in _VALID_ROLES:
380
+ raise ValueError(
381
+ f"body solid_id={b.get('solid_id')!r}: unknown role {role!r} "
382
+ f"(expected one of {sorted(_VALID_ROLES)})")
383
+
384
+ castings = [b for b in active if b["role"] == "casting"]
385
+ chills = [b for b in active if b["role"] == "chill"]
386
+ sleeves = [b for b in active if b["role"] in _SLEEVE_ROLES]
387
+
388
+ if len(castings) != 1:
389
+ raise ValueError(
390
+ f"training-regime restriction: expected exactly one body with "
391
+ f"role='casting', found {len(castings)}")
392
+ casting_solid_id = castings[0]["solid_id"]
393
+
394
+ if chills and sleeves:
395
+ raise ValueError(
396
+ "training-regime restriction: chills and a sleeve cannot be "
397
+ "combined -- the trained model has no mixed chills+sleeve BC "
398
+ "channel in the current training regimes")
399
+ if sleeves:
400
+ sleeve_roles = {b["role"] for b in sleeves}
401
+ if len(sleeve_roles) > 1:
402
+ raise ValueError(
403
+ f"training-regime restriction: all sleeve bodies must share "
404
+ f"one role, got {sorted(sleeve_roles)} -- the trained model "
405
+ f"has a single sleeve BC channel and cannot represent an "
406
+ f"insulating and an exothermic sleeve at once")
407
+
408
+ if chills:
409
+ materials = {_resolve_chill_material(b.get("params")) for b in chills}
410
+ effs = load_chill_effusivities(chills_csv)
411
+ for material in materials:
412
+ if material not in effs:
413
+ raise ValueError(
414
+ f"unknown chill material {material!r} (known: "
415
+ f"{sorted(effs)})")
416
+ if len(materials) > 1:
417
+ raise ValueError(
418
+ f"training-regime restriction: all chills must share one "
419
+ f"material (single chill_effusivity per training "
420
+ f"convention), got {sorted(materials)}")
421
+ material = next(iter(materials))
422
+ chill_ids = [b["solid_id"] for b in chills]
423
+ return BcPlan(
424
+ bc_config="chills",
425
+ casting_solid_id=casting_solid_id,
426
+ role_solid_ids=chill_ids,
427
+ n_chills=len(chills),
428
+ chill_effusivity=float(effs[material]),
429
+ role_by_solid={sid: "chill" for sid in chill_ids},
430
+ )
431
+
432
+ if sleeves:
433
+ bc_config = sleeves[0]["role"]
434
+ registry = None
435
+ mef_by_solid: dict[int, float] = {}
436
+ for sleeve in sleeves:
437
+ params = sleeve.get("params") or {}
438
+ has_name = "sleeve" in params
439
+ has_mef = "MEF" in params
440
+ if has_name and has_mef:
441
+ raise ValueError(
442
+ f"sleeve solid_id={sleeve['solid_id']!r}: specify either "
443
+ f"'sleeve' (registry name) or 'MEF' (manual override), not "
444
+ f"both")
445
+ if not has_name and not has_mef:
446
+ raise ValueError(
447
+ f"sleeve solid_id={sleeve['solid_id']!r}: params must "
448
+ f"contain either 'sleeve' (registry name) or 'MEF' (manual "
449
+ f"override)")
450
+ if has_name:
451
+ if registry is None:
452
+ registry = _load_sleeve_registry(sleeve_registry_csv)
453
+ name = params["sleeve"]
454
+ if name not in registry:
455
+ raise ValueError(
456
+ f"unknown sleeve name {name!r} (known: "
457
+ f"{sorted(registry)})")
458
+ entry = registry[name]
459
+ expected_type = _ROLE_TO_SLEEVE_TYPE[bc_config]
460
+ if entry["type"] != expected_type:
461
+ raise ValueError(
462
+ f"sleeve {name!r} is type={entry['type']} but was "
463
+ f"assigned role {bc_config} (expected "
464
+ f"type={expected_type!r})")
465
+ mef_by_solid[sleeve["solid_id"]] = float(entry["MEF"])
466
+ else:
467
+ mef_by_solid[sleeve["solid_id"]] = float(params["MEF"])
468
+
469
+ sleeve_ids = [b["solid_id"] for b in sleeves]
470
+ mefs = [mef_by_solid[sid] for sid in sleeve_ids]
471
+ spread = float(max(mefs) - min(mefs))
472
+ if spread <= _MEF_AGREEMENT_TOL:
473
+ sleeve_mef = float(mefs[0])
474
+ weighted = False
475
+ spread = 0.0
476
+ else:
477
+ if mef_weights is None:
478
+ raise ValueError(
479
+ f"sleeve bodies {sleeve_ids} resolve to differing MEF "
480
+ f"{sorted(set(mefs))} (spread {spread:.4f}) -- the model has "
481
+ f"a single scalar sleeve_MEF channel, so a mixed set needs "
482
+ f"explicit `mef_weights` (contact-patch area per solid_id, "
483
+ f"as returned by `tag_contact_patches`'s "
484
+ f"'per_body_patch_area_mm2'). Refusing to guess a "
485
+ f"weighting. Contact-patch areas only exist after meshing "
486
+ f"and tagging, so a caller that only has `bodies` (e.g. "
487
+ f"`predict_casting`'s CLI/webapp entry point) cannot supply "
488
+ f"them here -- mesh and tag the part first (`get_or_mesh` "
489
+ f"+ `tag_contact_patches`), then call `validate_bodies` "
490
+ f"again with `mef_weights=per_body_patch_area_mm2`, as "
491
+ f"`tools/sp4/prep.py` and `tools/sp4/score.py` do for the "
492
+ f"SP4 gold set.")
493
+ missing = [sid for sid in sleeve_ids if sid not in mef_weights]
494
+ if missing:
495
+ raise ValueError(
496
+ f"mef_weights is missing solid_id(s) {missing} -- every "
497
+ f"sleeve body needs a weight, got keys "
498
+ f"{sorted(mef_weights)}")
499
+ weights = [float(mef_weights[sid]) for sid in sleeve_ids]
500
+ if any(w <= 0.0 for w in weights):
501
+ raise ValueError(
502
+ f"mef_weights must all be positive contact areas, got "
503
+ f"{dict(zip(sleeve_ids, weights))}")
504
+ total_w = sum(weights)
505
+ sleeve_mef = float(sum(m * w for m, w in zip(mefs, weights)) / total_w)
506
+ weighted = True
507
+
508
+ exo_q0 = 0.0
509
+ if bc_config == "exo_sleeve":
510
+ exo_q0 = resolve_exo_q0_by_mef(sleeve_mef, exo_calibration_csv, tol)
511
+
512
+ return BcPlan(
513
+ bc_config=bc_config,
514
+ casting_solid_id=casting_solid_id,
515
+ role_solid_ids=sleeve_ids,
516
+ sleeve_MEF=sleeve_mef,
517
+ exo_q0=exo_q0,
518
+ role_by_solid={sid: bc_config for sid in sleeve_ids},
519
+ mef_weighted=weighted,
520
+ mef_spread=spread,
521
+ )
522
+
523
+ return BcPlan(bc_config="none", casting_solid_id=casting_solid_id, role_solid_ids=[])
524
+
525
+
526
+ # --------------------------------------------------------------------------
527
+ # Contact-patch tagging -- Part B Task 3: mesh the user-designated casting
528
+ # solid, classify its surface triangles by proximity to the role bodies'
529
+ # BReps, and rewrite the mesh's facet groups so the contact patch becomes
530
+ # "Adiabatic" (the exact BC-patch convention `_node_features(version=4)` /
531
+ # `fenicsx_solver.solve_case` read -- see the module docstring's B3 design
532
+ # reference).
533
+ # --------------------------------------------------------------------------
534
+ _ADIABATIC_PHYS_TAG = 3 # alongside prep_parts._VOLUME_PHYS_TAG=1 / _COOLING_PHYS_TAG=2
535
+
536
+
537
+ def _triangle_areas(node_xyz: np.ndarray, tris: np.ndarray) -> np.ndarray:
538
+ """(M,) triangle areas (mm^2) via the standard half cross-product
539
+ magnitude -- `tris` is (M, 3) 0-based vertex indices into `node_xyz`."""
540
+ p0 = node_xyz[tris[:, 0]]
541
+ p1 = node_xyz[tris[:, 1]]
542
+ p2 = node_xyz[tris[:, 2]]
543
+ cross = np.cross(p1 - p0, p2 - p0)
544
+ return 0.5 * np.linalg.norm(cross, axis=1)
545
+
546
+
547
+ def _sample_solid_surface_points(solid_id: int) -> np.ndarray:
548
+ """Assumes a fresh gmsh/OCC session already has the model imported,
549
+ synchronized, AND 2-D-meshed (`gmsh.model.mesh.generate(2)` already ran
550
+ -- done ONCE per `tag_contact_patches` call, not per body, since it
551
+ covers every solid's boundary surfaces in one pass). Returns (P, 3) the
552
+ coordinates of every mesh node lying on any boundary surface
553
+ (`gmsh.model.getBoundary`) of 3-D entity `solid_id`."""
554
+ import gmsh
555
+
556
+ boundary = gmsh.model.getBoundary([(3, solid_id)], oriented=False)
557
+ surf_tags = [t for (d, t) in boundary if d == 2]
558
+ if not surf_tags:
559
+ raise ValueError(f"role body {solid_id}: no boundary surfaces found (not a 3-D solid?)")
560
+
561
+ pts = []
562
+ for surf_tag in surf_tags:
563
+ _node_tags, coords, _param = gmsh.model.mesh.getNodes(2, surf_tag, includeBoundary=True)
564
+ pts.append(np.asarray(coords, dtype=np.float64).reshape(-1, 3))
565
+ points = np.concatenate(pts, axis=0)
566
+ if points.shape[0] == 0:
567
+ raise ValueError(f"role body {solid_id}: surface sampling yielded 0 points")
568
+ return points
569
+
570
+
571
+ def _distances_to_solid(points: np.ndarray, centroids: np.ndarray) -> np.ndarray:
572
+ """(M,) nearest-neighbor distance from each row of `centroids` (M, 3, mm)
573
+ to the sampled boundary point cloud `points` (P, 3, mm) of one role
574
+ solid, via `scipy.spatial.cKDTree`.
575
+
576
+ WHY sampled points + KDTree, not `gmsh.model.getClosestPoint` directly
577
+ on the centroids: probed first (as the brief suggested trying), and
578
+ found to be not just slower but WRONG for axis-aligned primitive faces
579
+ (the chill/sleeve boxes and cylinders this module builds and that real
580
+ role bodies resemble) -- `getClosestPoint`'s orthogonal projection lands
581
+ on the face's UNDERLYING (untrimmed) surface, not the actual bounded
582
+ patch (`isInside()` on the returned point came back False in every case
583
+ probed), so a query point far outside a face's true extent can still
584
+ project to a deceptively small distance whenever it happens to share
585
+ that face's plane (e.g. a base box's OWN top face reported ~2mm to a
586
+ chill's bottom-face plane from a corner ~15mm from the chill's actual
587
+ footprint). Meshing the role solids' real (trimmed) boundary at a size
588
+ tied to `tol_mm` and taking nearest-neighbor distance to those sampled
589
+ points respects the true finite geometry and reproduces the expected
590
+ footprint exactly in this task's synthetic fixtures (measured
591
+ corner-case distance corrected from a wrong ~2mm to the correct
592
+ ~11.2mm). Timing (composer fixture, `Mesh.MeshSizeMax = tol_mm/2`,
593
+ 2-D-meshing the WHOLE imported model once): 0.16-0.9s total for
594
+ 408-11019-node castings (656-8328 Cooling triangles) -- see
595
+ task-3-report.md for the full measurement."""
596
+ from scipy.spatial import cKDTree
597
+
598
+ tree = cKDTree(points)
599
+ dist, _ = tree.query(centroids)
600
+ return np.asarray(dist, dtype=np.float64)
601
+
602
+
603
+ def _parse_physical_names_block(lines: list[str], start_idx: int):
604
+ """`lines[start_idx]` must be the bare `"$PhysicalNames"` marker line.
605
+ Returns `(count_idx, entries, end_idx)`: `count_idx` is the index of the
606
+ entry-count line, `entries` is a list of `(dim, tag, name)` (name
607
+ unquoted), and `end_idx` is the index of the `"$EndPhysicalNames"`
608
+ line."""
609
+ count_idx = start_idx + 1
610
+ cnt = int(lines[count_idx].strip())
611
+ entries = []
612
+ for k in range(cnt):
613
+ dim_s, tag_s, name_s = lines[count_idx + 1 + k].split(maxsplit=2)
614
+ entries.append((int(dim_s), int(tag_s), name_s.strip().strip('"')))
615
+ end_idx = count_idx + 1 + cnt
616
+ return count_idx, entries, end_idx
617
+
618
+
619
+ def rewrite_msh_adiabatic(msh_path: str, is_adiabatic: np.ndarray) -> None:
620
+ """Rewrite `msh_path` in place so that the Cooling-tagged surface
621
+ triangles flagged True in `is_adiabatic` (same length AND order as
622
+ `src.msh_parse.parse_msh(msh_path).tris["Cooling"]` -- both this
623
+ function's own `$Elements` scan and `parse_msh` walk the file's
624
+ triangle elements in on-disk order, so the two arrays line up
625
+ positionally) move to a new "Adiabatic" physical group (pinned tag
626
+ `_ADIABATIC_PHYS_TAG`, reusing an existing "Adiabatic" group's tag if
627
+ the mesh already has one).
628
+
629
+ Deliberately edits the raw text rather than reimporting through gmsh:
630
+ `$Nodes` is never touched and every `$Elements` line is copied through
631
+ unchanged except the physical-group tag (first tag) of the flagged
632
+ triangle lines -- this is what guarantees node ids/coords and tet
633
+ connectivity survive EXACTLY (features/geodesics depend on it; verified
634
+ by the caller re-parsing and comparing `node_xyz`/`tets`). Raises
635
+ loudly (`ValueError`/`AssertionError`) on any structural surprise --
636
+ missing sections, a malformed `$PhysicalNames` block, tag-3 already
637
+ bound to a different name, or an `is_adiabatic` length that doesn't
638
+ match the number of Cooling-tagged triangle elements actually found --
639
+ rather than silently writing a mismatched mesh."""
640
+ from src.msh_parse import _TRI_TYPE
641
+
642
+ lines = Path(msh_path).read_text().splitlines()
643
+
644
+ try:
645
+ phn_idx = lines.index("$PhysicalNames")
646
+ except ValueError:
647
+ raise ValueError(f"{msh_path}: missing $PhysicalNames section")
648
+ count_idx, entries, end_idx = _parse_physical_names_block(lines, phn_idx)
649
+ if lines[end_idx].strip() != "$EndPhysicalNames":
650
+ raise ValueError(f"{msh_path}: malformed $PhysicalNames block (no $EndPhysicalNames "
651
+ f"at the expected offset)")
652
+
653
+ cooling_entries = [e for e in entries if e[0] == 2 and e[2] == "Cooling"]
654
+ if not cooling_entries:
655
+ raise ValueError(f"{msh_path}: no dim=2 'Cooling' physical group found")
656
+ cooling_tag = cooling_entries[0][1]
657
+
658
+ existing_adiabatic = [e for e in entries if e[0] == 2 and e[2] == "Adiabatic"]
659
+ if existing_adiabatic:
660
+ adiabatic_tag = existing_adiabatic[0][1]
661
+ new_entries = entries
662
+ else:
663
+ collision = [e for e in entries if e[0] == 2 and e[1] == _ADIABATIC_PHYS_TAG]
664
+ if collision:
665
+ raise ValueError(f"{msh_path}: physical tag {_ADIABATIC_PHYS_TAG} is already "
666
+ f"bound to {collision[0][2]!r}; cannot pin 'Adiabatic' there")
667
+ adiabatic_tag = _ADIABATIC_PHYS_TAG
668
+ new_entries = entries + [(2, adiabatic_tag, "Adiabatic")]
669
+
670
+ new_phys_lines = [str(len(new_entries))] + [
671
+ f'{d} {t} "{nm}"' for (d, t, nm) in new_entries
672
+ ]
673
+
674
+ try:
675
+ elem_idx = lines.index("$Elements")
676
+ except ValueError:
677
+ raise ValueError(f"{msh_path}: missing $Elements section")
678
+ elem_count_idx = elem_idx + 1
679
+ elem_cnt = int(lines[elem_count_idx].strip())
680
+ elem_lines = lines[elem_count_idx + 1: elem_count_idx + 1 + elem_cnt]
681
+
682
+ flags = is_adiabatic.tolist()
683
+ n_flags = len(flags)
684
+ flag_i = 0
685
+ new_elem_lines = []
686
+ for ln in elem_lines:
687
+ parts = ln.split()
688
+ etype = int(parts[1])
689
+ if etype == _TRI_TYPE and int(parts[3]) == cooling_tag:
690
+ if flag_i >= n_flags:
691
+ raise AssertionError(
692
+ f"{msh_path}: is_adiabatic has only {n_flags} flag(s) -- fewer than the "
693
+ f"Cooling-tagged triangle elements found in $Elements -- refusing to write "
694
+ f"a mismatched mesh")
695
+ if flags[flag_i]:
696
+ parts[3] = str(adiabatic_tag)
697
+ ln = " ".join(parts)
698
+ flag_i += 1
699
+ new_elem_lines.append(ln)
700
+ if flag_i != n_flags:
701
+ raise AssertionError(
702
+ f"{msh_path}: is_adiabatic has {n_flags - flag_i} more flag(s) than Cooling-tagged "
703
+ f"triangle elements found in $Elements -- refusing to write a mismatched mesh")
704
+
705
+ new_lines = (
706
+ lines[:count_idx] + new_phys_lines + lines[end_idx:elem_count_idx]
707
+ + [str(elem_cnt)] + new_elem_lines + lines[elem_count_idx + 1 + elem_cnt:]
708
+ )
709
+ Path(msh_path).write_text("\n".join(new_lines) + "\n")
710
+
711
+
712
+ # Back-compat alias -- this function was originally private (`_rewrite_msh_
713
+ # adiabatic`); FT3's `tools/doe/virtual_patches.py` reuses it under the
714
+ # promoted public name `rewrite_msh_adiabatic`, but existing call sites/tests
715
+ # (this module's own `tag_contact_patches`, `tests/test_assembly.py`) still
716
+ # reference the old private name, so both names resolve to the same function.
717
+ _rewrite_msh_adiabatic = rewrite_msh_adiabatic
718
+
719
+
720
+ def tag_contact_patches(msh_path: str, step_path: str, role_solid_ids: Sequence[int],
721
+ tol_mm: float | None = None) -> dict:
722
+ """Classify the casting mesh's (`msh_path`) "Cooling" surface triangles
723
+ by proximity to each of `role_solid_ids`'s BReps in `step_path` (a fresh
724
+ gmsh/OCC session, distinct from the casting mesh's own session --
725
+ `step_path` is the ORIGINAL multi-body assembly STEP the role bodies'
726
+ tags came from, e.g. `list_solids`' `solid_id`, not the reduced
727
+ single-solid STEP the casting was meshed from); triangles within
728
+ `tol_mm` of ANY role body move from "Cooling" to a new "Adiabatic"
729
+ facet group (see `_rewrite_msh_adiabatic`), rewriting `msh_path` IN
730
+ PLACE. Distance-to-body samples each role solid's real (trimmed)
731
+ boundary surfaces into a point cloud (one shared `gmsh.model.mesh.
732
+ generate(2)` pass over the whole imported model, sized off `tol_mm`)
733
+ and takes the nearest-neighbor distance via `scipy.spatial.cKDTree`
734
+ (`_sample_solid_surface_points` + `_distances_to_solid`) -- see
735
+ `_distances_to_solid`'s docstring for why a direct
736
+ `gmsh.model.getClosestPoint` probe was rejected (not just slower, but
737
+ wrong for bounded planar faces).
738
+
739
+ `tol_mm`, if `None` (default), is `0.5 * elem_est` where `elem_est =
740
+ bbox_diag / n_nodes**(1/3)` of the casting mesh (all nodes, matching
741
+ `heal_and_mesh`'s own whole-model bbox convention) -- a rough local
742
+ element-size estimate, so the tolerance scales with mesh density rather
743
+ than being a fixed physical distance that's too tight for a coarse mesh
744
+ or too loose for a fine one.
745
+
746
+ CAVEAT -- tol_mm dilates the detected patch past the true contact
747
+ footprint: since classification is "distance to body <= tol_mm", any
748
+ positive tol_mm grows the patch outward from the geometric contact edge
749
+ by roughly `perimeter * tol_mm + pi * tol_mm**2` (a rectangular-
750
+ footprint Minkowski-sum estimate). This matters most for the SYNTHETIC
751
+ fixtures `compose_assembly` builds with a large POSITIVE `overlap_mm`
752
+ (sunk INTO the base -- see that function's docstring): `tol_mm` must
753
+ exceed `overlap_mm` just to detect the patch at all, and when
754
+ `overlap_mm` is a large fraction of the footprint size, the resulting
755
+ dilation can be a large fraction of the footprint's own area (measured
756
+ up to +140% on a 10x10mm footprint with a 2mm overlap -- see
757
+ task-3-report.md). Real assemblies -- where role bodies sit at
758
+ near-zero contact or a sub-mm clearance rather than several mm sunk in
759
+ -- only need a small `tol_mm` to bridge that gap, so this dilation
760
+ stays a small fraction of a realistic contact-patch area; it is a
761
+ synthetic-fixture-scale concern, not a production one.
762
+
763
+ A role body whose patch comes out EMPTY (no Cooling triangle centroid
764
+ within `tol_mm`) raises `ValueError` naming that body and `tol_mm` --
765
+ never a silent all-sand prediction for a body the caller explicitly
766
+ assigned a role to. Symmetrically, if EVERY Cooling triangle ends up
767
+ classified Adiabatic (role bodies + tol_mm cover the entire casting
768
+ surface), this raises `ValueError` too, rather than silently handing
769
+ downstream feature code (`geodesic_d_cool` et al., see
770
+ `src.features`) a casting mesh with no cooling surface at all to
771
+ measure geodesic distance to -- which would silently degrade to
772
+ bbox_diag-derived constants (confidently wrong physics, not a loud
773
+ failure).
774
+
775
+ Returns `{"per_body_patch_area_mm2": {solid_id: float}, "n_adiab_tris":
776
+ int, "nodes_by_solid": {solid_id: sorted list[int]}}`.
777
+ `per_body_patch_area_mm2` sums each body's own classified
778
+ triangle areas INDEPENDENTLY -- a triangle within `tol_mm` of more than
779
+ one role body (their tolerance zones overlap) is counted once per such
780
+ body, i.e. DOUBLE-COUNTED across `per_body_patch_area_mm2`'s entries,
781
+ even though it is relabeled to "Adiabatic" exactly once; `n_adiab_tris`
782
+ counts each such triangle once (via the union `is_adiabatic` mask), so
783
+ `sum(per_body_patch_area_mm2.values())` can exceed the true total
784
+ Adiabatic area when role bodies' patches overlap.
785
+
786
+ `nodes_by_solid` (Part-B roles task, 2026-08-03): the sorted-unique node
787
+ ids (0-based, into `mesh.node_xyz` -- the SAME indexing as every other
788
+ node id this module and `src.msh_parse` use) of each role body's own
789
+ classified triangles -- computed from the SAME per-body `close` mask as
790
+ `per_body_patch_area_mm2`, so it shares its double-counting property (a
791
+ triangle within `tol_mm` of more than one body contributes its nodes to
792
+ EVERY such body's entry). This is an IN-MEMORY-only piece of metadata:
793
+ it is never written into `msh_path` itself (the `.msh` physical groups,
794
+ node order, and this function's cache-relevant hashes/tags are all
795
+ unchanged by its addition) -- callers needing a role-aware node
796
+ selection (e.g. `predict_casting`'s shrinkage-screening feed-node logic)
797
+ combine this per-SOLID mapping with their own solid_id -> role lookup
798
+ (e.g. `BcPlan.role_by_solid`) themselves; `tag_contact_patches` has no
799
+ opinion on role semantics, only geometry. After rewriting,
800
+ re-parses `msh_path` and asserts `node_xyz` is unchanged (`np.allclose`)
801
+ and `tets` is unchanged (`np.array_equal`) -- the node-order/tet-
802
+ connectivity invariant features/geodesics depend on (see the module
803
+ docstring's caution and `_rewrite_msh_adiabatic`)."""
804
+ from src.msh_parse import parse_msh
805
+ import gmsh
806
+
807
+ mesh = parse_msh(msh_path)
808
+ cooling_tris = mesh.tris.get("Cooling")
809
+ if cooling_tris is None or cooling_tris.shape[0] == 0:
810
+ raise ValueError(f"{msh_path}: no 'Cooling' surface triangles to classify")
811
+
812
+ centroids = mesh.node_xyz[cooling_tris].mean(axis=1)
813
+ tri_areas = _triangle_areas(mesh.node_xyz, cooling_tris)
814
+
815
+ if tol_mm is None:
816
+ bbox_min = mesh.node_xyz.min(axis=0)
817
+ bbox_max = mesh.node_xyz.max(axis=0)
818
+ bbox_diag_mm = float(np.linalg.norm(bbox_max - bbox_min))
819
+ n_nodes = mesh.node_xyz.shape[0]
820
+ elem_est = bbox_diag_mm / (n_nodes ** (1.0 / 3.0))
821
+ tol_mm = 0.5 * elem_est
822
+
823
+ gmsh.initialize()
824
+ try:
825
+ gmsh.option.setNumber("General.Terminal", 0)
826
+ gmsh.model.add("tag_contact_patches")
827
+ gmsh.model.occ.importShapes(step_path)
828
+ gmsh.model.occ.synchronize()
829
+
830
+ # One shared 2-D surface mesh (not per-body) -- covers every role
831
+ # solid's boundary in a single pass. Sized off tol_mm/2 so sampled
832
+ # points are dense enough relative to the tolerance to not miss or
833
+ # blur the true footprint.
834
+ sample_size_mm = max(tol_mm / 2.0, 1e-3)
835
+ gmsh.option.setNumber("Mesh.MeshSizeMin", sample_size_mm)
836
+ gmsh.option.setNumber("Mesh.MeshSizeMax", sample_size_mm)
837
+ gmsh.model.mesh.generate(2)
838
+
839
+ dist_by_body = {}
840
+ for solid_id in role_solid_ids:
841
+ points = _sample_solid_surface_points(solid_id)
842
+ dist_by_body[solid_id] = _distances_to_solid(points, centroids)
843
+ finally:
844
+ gmsh.finalize()
845
+
846
+ is_adiabatic = np.zeros(cooling_tris.shape[0], dtype=bool)
847
+ per_body_patch_area_mm2: dict[int, float] = {}
848
+ nodes_by_solid: dict[int, list[int]] = {}
849
+ for solid_id, dist in dist_by_body.items():
850
+ close = dist <= tol_mm
851
+ area = float(tri_areas[close].sum())
852
+ if area <= 0.0:
853
+ raise ValueError(f"body {solid_id} does not touch the casting within "
854
+ f"tol={tol_mm:.4f}mm")
855
+ per_body_patch_area_mm2[solid_id] = area
856
+ nodes_by_solid[solid_id] = sorted(
857
+ int(n) for n in np.unique(cooling_tris[close].reshape(-1)))
858
+ is_adiabatic |= close
859
+
860
+ if bool(is_adiabatic.all()):
861
+ raise ValueError(
862
+ "role bodies cover the entire casting surface; no cooling surface remains "
863
+ f"-- check roles/tol (tol={tol_mm:.4f}mm)")
864
+
865
+ rewrite_msh_adiabatic(msh_path, is_adiabatic)
866
+
867
+ mesh_after = parse_msh(msh_path)
868
+ if not np.allclose(mesh_after.node_xyz, mesh.node_xyz):
869
+ raise AssertionError(f"{msh_path}: node_xyz changed after Adiabatic rewrite "
870
+ f"(node order/coords must be byte-identical)")
871
+ if mesh_after.tets.shape != mesh.tets.shape or not np.array_equal(mesh_after.tets, mesh.tets):
872
+ raise AssertionError(f"{msh_path}: tets changed after Adiabatic rewrite "
873
+ f"(tet connectivity must be byte-identical)")
874
+
875
+ return {"per_body_patch_area_mm2": per_body_patch_area_mm2,
876
+ "n_adiab_tris": int(is_adiabatic.sum()),
877
+ "nodes_by_solid": nodes_by_solid}
878
+
879
+
880
+ # --------------------------------------------------------------------------
881
+ # CLI printer
882
+ # --------------------------------------------------------------------------
883
+ def main(argv: list[str] | None = None) -> int:
884
+ ap = argparse.ArgumentParser(
885
+ description="List the 3-D solids in a (possibly multi-body) STEP file.")
886
+ ap.add_argument("step_path", help="path to a .step/.stp file")
887
+ args = ap.parse_args(argv)
888
+
889
+ rows = list_solids(args.step_path)
890
+ header = f"{'solid_id':>8} {'name':<24} {'volume_mm3':>14} {'bbox (mm)':<48} {'com (mm)':<30}"
891
+ print(header)
892
+ print("-" * len(header))
893
+ for row in rows:
894
+ bbox_str = "[" + ", ".join(f"{v:.1f}" for v in row["bbox"]) + "]"
895
+ com_str = "[" + ", ".join(f"{v:.1f}" for v in row["com"]) + "]"
896
+ name = row["name"] or "<unnamed>"
897
+ print(f"{row['solid_id']:>8} {name:<24} {row['volume_mm3']:>14.2f} "
898
+ f"{bbox_str:<48} {com_str:<30}")
899
+ print(f"\n{len(rows)} solid(s) total.")
900
+ return 0
901
+
902
+
903
+ if __name__ == "__main__":
904
+ raise SystemExit(main())
src/baseline.py ADDED
@@ -0,0 +1,63 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Per-node regression baselines: gradient boosting (primary) and a small MLP.
2
+
3
+ Both predict standardized log1p(solidification_time) from standardized
4
+ physics features. predict_seconds inverts the transform back to seconds.
5
+ """
6
+ import numpy as np
7
+ import torch
8
+ import torch.nn as nn
9
+ from sklearn.ensemble import HistGradientBoostingRegressor
10
+
11
+
12
+ def train_hgb(X_std, y_log, max_iter: int = 300):
13
+ model = HistGradientBoostingRegressor(
14
+ max_iter=max_iter, learning_rate=0.1, max_leaf_nodes=63,
15
+ l2_regularization=1.0, early_stopping=True, validation_fraction=0.1,
16
+ random_state=0)
17
+ model.fit(X_std, y_log) # trained directly on log1p(y)
18
+ return model
19
+
20
+
21
+ class _MLP(nn.Module):
22
+ def __init__(self, in_dim):
23
+ super().__init__()
24
+ self.net = nn.Sequential(
25
+ nn.Linear(in_dim, 128), nn.ReLU(),
26
+ nn.Linear(128, 64), nn.ReLU(),
27
+ nn.Linear(64, 1))
28
+
29
+ def forward(self, x):
30
+ return self.net(x).squeeze(-1)
31
+
32
+
33
+ def train_mlp(X_std, y_log, epochs: int = 30, device: str = "cpu", batch: int = 8192):
34
+ torch.manual_seed(0)
35
+ X = torch.tensor(np.asarray(X_std), dtype=torch.float32, device=device)
36
+ y = torch.tensor(np.asarray(y_log), dtype=torch.float32, device=device)
37
+ model = _MLP(X.shape[1]).to(device)
38
+ opt = torch.optim.AdamW(model.parameters(), lr=1e-3, weight_decay=1e-5)
39
+ loss_fn = nn.SmoothL1Loss()
40
+ n = X.shape[0]
41
+ for _ in range(epochs):
42
+ perm = torch.randperm(n, device=device)
43
+ for i in range(0, n, batch):
44
+ idx = perm[i:i + batch]
45
+ opt.zero_grad()
46
+ loss = loss_fn(model(X[idx]), y[idx])
47
+ loss.backward()
48
+ opt.step()
49
+ model.eval()
50
+ return model
51
+
52
+
53
+ def predict_seconds(model, X_std, norm) -> np.ndarray:
54
+ if isinstance(model, nn.Module):
55
+ dev = next(model.parameters()).device
56
+ with torch.no_grad():
57
+ pred_log = model(
58
+ torch.tensor(np.asarray(X_std), dtype=torch.float32, device=dev)
59
+ ).cpu().numpy()
60
+ else:
61
+ pred_log = model.predict(X_std)
62
+ # Both models predict raw log1p(seconds); invert uniformly.
63
+ return np.clip(np.expm1(pred_log), 0.0, None)
src/build_dataset.py ADDED
@@ -0,0 +1,1199 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Reprocess raw .pt + paired .msh into cached per-node feature files.
2
+
3
+ Writes one .npz per casting under <data_root>/features/graphs/ plus a global
4
+ meta.npz and norm_stats.npz. Parallelised across CPU cores.
5
+ """
6
+ import os
7
+ # CPU-hygiene: cap every BLAS backend to 1 thread PER WORKER PROCESS, set
8
+ # before numpy (or anything that transitively imports it) is first imported
9
+ # in this process. This must run before `import numpy as np` below to take
10
+ # effect for numpy's own BLAS init; ProcessPoolExecutor workers either
11
+ # re-import this module from scratch ("spawn", re-running this) or fork from
12
+ # a parent that already applied it ("fork", inherited via copy-on-write) --
13
+ # mirrors tools/doe/doe.py's identical top-of-file pattern.
14
+ for _v in ("OMP_NUM_THREADS", "OPENBLAS_NUM_THREADS",
15
+ "MKL_NUM_THREADS", "NUMEXPR_NUM_THREADS"):
16
+ os.environ.setdefault(_v, "1")
17
+
18
+ import csv
19
+ import itertools
20
+ import json
21
+ import os.path as osp
22
+ import re
23
+ import shutil
24
+ import sys
25
+ from glob import glob
26
+
27
+ import meshio
28
+ import numpy as np
29
+ import torch
30
+ from scipy.spatial import cKDTree
31
+
32
+ sys.path.insert(0, osp.dirname(osp.dirname(__file__)))
33
+ from src.msh_parse import parse_msh
34
+ from src.features import (compute_features, FEATURE_NAMES, FEATURE_NAMES_V3,
35
+ FEATURE_NAMES_V4, FEATURE_NAMES_V5, G_VECTOR_NAMES,
36
+ _node_features, alloy_g_vector, chvorinov_t_ref)
37
+ from src.geometry import mesh_volume, total_area
38
+
39
+ _FACE_RE = re.compile(r'_[+-][XYZ]')
40
+ _MSH_SETS = ("msh_none", "msh_half", "msh_all")
41
+
42
+ # Canonical raw temperature backup (dolfinx-DOF-order snapshots_nodal), on the
43
+ # large Media drive. Used when data/pt/*.pt has been deleted to free space.
44
+ # The .pt temps are byte-identical to these npz (verified). Override if moved.
45
+ RAW_NPZ_DIRS = ("data/raw",)
46
+
47
+
48
+ def find_msh(stem: str, data_root: str = "data"):
49
+ for s in _MSH_SETS:
50
+ hits = glob(osp.join(data_root, s, "*", f"{stem}.msh"))
51
+ if hits:
52
+ return hits[0]
53
+ return None
54
+
55
+
56
+ def _load_raw_temps(stem: str, data_root: str = "data"):
57
+ """Return (temps_dof (T,N) float32, times (T,) float32) or None.
58
+
59
+ Prefers data/pt/<stem>.pt; falls back to the canonical raw npz backup so the
60
+ pipeline still rebuilds after data/pt is deleted."""
61
+ ptp = osp.join(data_root, "pt", f"{stem}.pt")
62
+ if osp.exists(ptp):
63
+ raw = torch.load(ptp, weights_only=False)
64
+ return raw["temps"].numpy().astype(np.float32), raw["times"].numpy().astype(np.float32)
65
+ for d in RAW_NPZ_DIRS:
66
+ hits = glob(osp.join(d, "*", f"{stem}.npz"))
67
+ if hits:
68
+ z = np.load(hits[0])
69
+ return z["snapshots_nodal"].astype(np.float32), z["times"].astype(np.float32)
70
+ return None
71
+
72
+
73
+ def process_one(stem: str, data_root: str = "data", feature_version: int = 2):
74
+ mpath = find_msh(stem, data_root)
75
+ if mpath is None:
76
+ print(f" WARN missing msh for {stem}")
77
+ return None
78
+ src = _load_raw_temps(stem, data_root)
79
+ if src is None:
80
+ print(f" WARN no raw temps for {stem} (.pt or npz backup)")
81
+ return None
82
+ temps_dof, times = src
83
+ mesh = parse_msh(mpath)
84
+ n_pt = temps_dof.shape[1]
85
+ if mesh.node_xyz.shape[0] != n_pt:
86
+ print(f" WARN node count mismatch {stem}: msh={mesh.node_xyz.shape[0]} raw={n_pt}")
87
+ return None
88
+
89
+ # Apply the node-ordering recovery permutation: the raw temps are in dolfinx
90
+ # DOF order, which is scrambled relative to the gmsh node order used by pos/
91
+ # mesh labels. perm g maps DOF->gmsh node: temps_gmsh[:, g] = temps_dof.
92
+ # Files without a validated perm are excluded (their per-node target is
93
+ # unrecoverable). See docs spec §1.5.
94
+ perm_path = osp.join(data_root, "features", "perms", f"{stem}.npy")
95
+ if not osp.exists(perm_path):
96
+ print(f" WARN no recovery perm for {stem} (excluded)")
97
+ return None
98
+ g = np.load(perm_path)
99
+ if g.shape[0] != n_pt:
100
+ print(f" WARN perm/temps size mismatch {stem}: perm={g.shape[0]} temps_N={n_pt}")
101
+ return None
102
+ temps = np.empty_like(temps_dof)
103
+ temps[:, g] = temps_dof # reorder into gmsh node order
104
+
105
+ feat = compute_features(mesh, temps, times, version=feature_version)
106
+
107
+ n_adiab = len(_FACE_RE.findall(stem))
108
+ prefix = stem.split("_")[0]
109
+ return {
110
+ "stem": stem,
111
+ "X": feat["X"],
112
+ "y": feat["y"],
113
+ "pos": feat["pos"],
114
+ "mesh_edges": feat["mesh_edges"],
115
+ "y_global": feat["y_global"],
116
+ "prefix": prefix,
117
+ "n_adiab": n_adiab,
118
+ "is_fully_cooled": n_adiab == 0,
119
+ "n_nodes": n_pt,
120
+ }
121
+
122
+
123
+ import argparse
124
+ from concurrent.futures import ProcessPoolExecutor
125
+
126
+
127
+ def _init_worker():
128
+ """ProcessPoolExecutor initializer: lower scheduling priority + force
129
+ every BLAS backend down to 1 thread at RUNTIME via threadpoolctl. The
130
+ module-top env vars already cover a fresh/"spawn" worker's own numpy
131
+ import; this is the belt-and-braces fallback for a "fork" worker that
132
+ inherited an ALREADY-initialized BLAS library from the parent (in which
133
+ case env vars set post-fork would be too late) -- mirrors
134
+ tools/doe/doe.py's `_init_worker`."""
135
+ try:
136
+ os.nice(10)
137
+ except Exception:
138
+ pass
139
+ try:
140
+ import threadpoolctl
141
+ threadpoolctl.threadpool_limits(1)
142
+ except Exception:
143
+ pass
144
+
145
+
146
+ def write_record(rec, out_dir):
147
+ gdir = osp.join(out_dir, "graphs")
148
+ os.makedirs(gdir, exist_ok=True)
149
+ np.savez(osp.join(gdir, f"{rec['stem']}.npz"), X=rec["X"], y=rec["y"], pos=rec["pos"], mesh_edges=rec["mesh_edges"])
150
+
151
+
152
+ def _all_stems(data_root):
153
+ """Enumerate casting stems. Prefer data/pt; after it is deleted, fall back to
154
+ the recovery perms (the exact recoverable set), then the raw npz backup."""
155
+ stems = [osp.splitext(osp.basename(p))[0]
156
+ for p in glob(osp.join(data_root, "pt", "*.pt"))]
157
+ if not stems:
158
+ stems = [osp.splitext(osp.basename(p))[0]
159
+ for p in glob(osp.join(data_root, "features", "perms", "*.npy"))]
160
+ if not stems:
161
+ for d in RAW_NPZ_DIRS:
162
+ stems = [osp.splitext(osp.basename(p))[0]
163
+ for p in glob(osp.join(d, "*", "*.npz"))]
164
+ if stems:
165
+ break
166
+ return sorted(stems)
167
+
168
+
169
+ # ============================================================================
170
+ # feature_version == 4: join raw FEniCSx solves (`data/raw_v4/<run_id>.npz`,
171
+ # {t,gradT,dTdt} already in gmsh node order -- see tools/doe/adapters.py's
172
+ # map_to_gmsh) with the DOE manifest (per-run spec: material, mold/chill/
173
+ # sleeve BC, hold_out). One graph per manifest row; `run_id` is the cache key
174
+ # ("stem").
175
+ #
176
+ # IMPORTANT interface note: `src.features.compute_features(mesh, temps, times,
177
+ # version=4, spec=...)` (Task 3) still expects raw (T,N) temperature snapshots
178
+ # + times, and internally derives y via `solidification_time` -- that is the
179
+ # OLD raw-pipeline contract (v2/v3). The FT2 raw solves are NOT snapshot
180
+ # histories: `fenicsx_solver.py` already outputs the per-node crossing time
181
+ # `t` directly (see its module docstring). So this v4 path does NOT call
182
+ # `compute_features`; it composes the same lower-level building blocks
183
+ # (`_node_features`, `alloy_g_vector`, `chvorinov_t_ref`) directly, using the
184
+ # solver-provided `t` array as `y` with no re-derivation.
185
+ # ============================================================================
186
+ def _read_manifest_rows(manifest_path: str) -> list:
187
+ with open(manifest_path, newline="") as f:
188
+ return list(csv.DictReader(f))
189
+
190
+
191
+ def _archive_mesh_work_dir(data_root: str) -> str:
192
+ d = osp.join(data_root, "mesh_work_v4")
193
+ os.makedirs(d, exist_ok=True)
194
+ return d
195
+
196
+
197
+ def _find_msh_v4(row: dict, data_root: str = "data"):
198
+ """Resolve a manifest row's geometry to a .msh path.
199
+ - "path": geometry_id verbatim (a full .msh path).
200
+ - "primitive"/"scaled": reuse the existing 42-primitive lookup
201
+ (`find_msh`) by geometry_id.
202
+ - "archive": geometry_id is a STEP file path; mesh it via
203
+ `src.step_mesh.mesh_step` (same tool `tools.doe.adapters.mesh()` uses
204
+ for dataset D) into `<data_root>/mesh_work_v4/<run_id>.msh`, cached
205
+ across calls (the pre-pass Chvorinov-C fit and the main pass would
206
+ otherwise mesh the same archive part twice). Returns None (with a
207
+ printed WARN) if the STEP import/meshing itself fails -- a bad archive
208
+ part must WARN+skip its row, not crash the batch.
209
+ `scale_factor` (if != 1.0) is applied to the resolved mesh's node
210
+ coordinates by the caller, not here.
211
+
212
+ ft2a feature-BC fix: a non-empty `mesh_variant` column (written by
213
+ `tools/doe/augment_manifest_bc.py` for chill/sleeve rows) is an explicit
214
+ face-tagged msh_half path that OVERRIDES every other resolution branch
215
+ below -- mirrors `tools.doe.adapters.mesh()`'s identical check, which the
216
+ SOLVER side already uses. msh_none (what `find_msh` would otherwise
217
+ return) has no "Adiabatic" (BC-patch) facet tag, so without this override
218
+ the chill/sleeve/exo BC-dependent feature channels (is_adiabatic,
219
+ boundary_extraction, exothermic_input) would be computed as if the row
220
+ were plain sand, even though the row's SOLVER target used the tagged
221
+ msh_half mesh -- silently dropping the BC input signal from X. msh_none
222
+ and its msh_half variant share the same node order/coordinates (verified
223
+ -- only the facet tagging differs), so this override changes only the
224
+ tag-derived feature channels, not the geometric features or the
225
+ per-node alignment with the (msh_half-based) solver targets.
226
+
227
+ Task 3 (ft3-assembly-finetune): dataset=="E" rows (assembly/integration)
228
+ have mesh_variant as a literal direct path. For consistency with FT2a's
229
+ batch-resilience pattern, missing mesh_variant is WARN+skip (row-level
230
+ failure, not batch-killing), same as non-E rows."""
231
+ mesh_variant = (row.get("mesh_variant") or "").strip()
232
+ if mesh_variant:
233
+ if not osp.exists(mesh_variant):
234
+ print(f" WARN mesh_variant {mesh_variant!r} (run_id="
235
+ f"{row.get('run_id')!r}) does not exist -- stale path?")
236
+ return None
237
+ return mesh_variant
238
+ source = row.get("geometry_source") or "primitive"
239
+ gid = row["geometry_id"]
240
+ if source == "path":
241
+ return gid if osp.exists(gid) else None
242
+ if source == "archive":
243
+ out_msh = osp.join(_archive_mesh_work_dir(data_root),
244
+ f"{row.get('run_id', 'archive')}.msh")
245
+ if osp.exists(out_msh):
246
+ return out_msh
247
+ try:
248
+ from src.step_mesh import mesh_step
249
+ mesh_step(gid, out_msh)
250
+ except Exception as e: # noqa
251
+ print(f" WARN mesh_step failed for run_id={row.get('run_id')} "
252
+ f"(STEP={gid!r}): {e}")
253
+ return None
254
+ return out_msh if osp.exists(out_msh) else None
255
+ return find_msh(gid, data_root)
256
+
257
+
258
+ def _resolve_material_props_v4(material_id: str, manifest_dir: str) -> dict:
259
+ """material_id -> {k, rho, cp, L_kJ_kg, TL, TS, Tpour}, mirroring
260
+ `tools.doe.adapters._resolve_material`'s 3-step fallback: the
261
+ owner-verified CSV, then `materials_synthetic.csv` next to the manifest
262
+ (SYN_* ids), then the 12 named anchor grades."""
263
+ from tools.doe.load_properties import load_materials
264
+ for m in load_materials():
265
+ if m.material_id == material_id:
266
+ return dict(k=m.k, rho=m.rho, cp=m.cp, L_kJ_kg=m.L,
267
+ TL=m.TL, TS=m.TS, Tpour=m.Tpour)
268
+
269
+ synth_csv = osp.join(manifest_dir, "materials_synthetic.csv")
270
+ if osp.exists(synth_csv):
271
+ with open(synth_csv, newline="") as f:
272
+ for row in csv.DictReader(f):
273
+ if row.get("material_id") == material_id:
274
+ return dict(
275
+ k=float(row["k_W_mK"]), rho=float(row["rho_kg_m3"]),
276
+ cp=float(row["cp_J_kgK"]), L_kJ_kg=float(row["L_kJ_kg"]),
277
+ TL=float(row["TL_C"]), TS=float(row["TS_C"]),
278
+ Tpour=float(row["Tpour_C"]))
279
+
280
+ from tools.doe.doe import _anchor_materials
281
+ for m in _anchor_materials():
282
+ if m.material_id == material_id:
283
+ return dict(k=m.k, rho=m.rho, cp=m.cp, L_kJ_kg=m.L,
284
+ TL=m.TL, TS=m.TS, Tpour=m.Tpour)
285
+
286
+ raise KeyError(
287
+ f"material_id {material_id!r} not resolvable via load_properties, "
288
+ f"{synth_csv}, or tools.doe.doe._anchor_materials()")
289
+
290
+
291
+ def _resolve_chill_effusivity_v4(row: dict) -> float:
292
+ """0.0 unless bc_config=='chills', in which case
293
+ `tools.doe.doe.get_chill_effusivities()[chill_material]` -- raises
294
+ ValueError (never a silent 0.0) if chill_material is empty/unknown."""
295
+ if (row.get("bc_config") or "none") != "chills":
296
+ return 0.0
297
+ from tools.doe.doe import get_chill_effusivities
298
+ chill_material = (row.get("chill_material") or "").strip()
299
+ effs = get_chill_effusivities()
300
+ if not chill_material or chill_material not in effs:
301
+ raise ValueError(
302
+ f"run_id={row.get('run_id')!r}: bc_config=='chills' but "
303
+ f"chill_material={chill_material!r} not in get_chill_effusivities() "
304
+ f"(known: {sorted(effs)}) -- refusing to silently use 0.0")
305
+ return float(effs[chill_material])
306
+
307
+
308
+ def _resolve_exo_q0_v4(row: dict,
309
+ exo_calibration_csv: str = "data/properties/exo_calibration.csv",
310
+ tol: float = 0.05) -> float:
311
+ """0.0 unless bc_config=='exo_sleeve', in which case the nearest-MEF
312
+ `q0_W_m2` row of `exo_calibration_csv` (the sweep output of
313
+ `tools/doe/calibrate_exo.py`), resolved via the shared
314
+ `tools.doe.load_properties.resolve_exo_q0_by_mef` lookup (also used by
315
+ `src.assembly.validate_bodies` for Part B) -- raises ValueError (never a
316
+ silent 0.0) if the calibration table is missing or has no row within
317
+ `tol` of the row's sleeve_MEF, mirroring
318
+ `tools.doe.adapters._resolve_exo_params`. The ValueError message is
319
+ re-wrapped here with this row's run_id for context."""
320
+ if (row.get("bc_config") or "none") != "exo_sleeve":
321
+ return 0.0
322
+ from tools.doe.load_properties import resolve_exo_q0_by_mef
323
+ mef = float(row.get("sleeve_MEF") or 0.0)
324
+ try:
325
+ return resolve_exo_q0_by_mef(mef, exo_calibration_csv, tol)
326
+ except ValueError as e:
327
+ raise ValueError(f"run_id={row.get('run_id')!r}: {e}") from e
328
+
329
+
330
+ # --------------------------------------------------------------------- #
331
+ # I1 (ft2a-final-review): archive/DF feature-mesh coordinate correspondence.
332
+ #
333
+ # For archive rows the FEATURE builder (`_find_msh_v4`, above) re-meshes the
334
+ # row's STEP file via `mesh_step` INDEPENDENTLY of the SOLVER side, which
335
+ # also calls `mesh_step` (via `tools.doe.adapters.mesh()`) but into a
336
+ # DIFFERENT work directory (`<doe_dir>/mesh_work/` vs this module's
337
+ # `<data_root>/mesh_work_v4/`) -- two separate meshing invocations of the
338
+ # same STEP file. Until now only the resulting node COUNT was checked to
339
+ # match the raw npz's array length (see the `t.shape[0] != n_pt` check in
340
+ # `_process_row_stage1`); node ORDER/coordinates were never verified. The
341
+ # solver's `t`/`gradT`/`dTdt`/`t_liquidus` raw_v4 arrays are in the node
342
+ # order of THAT solve-time mesh (`fenicsx_solver._dof_to_gmsh_perm`
343
+ # coordinate-matches its dolfinx DOFs onto it, at ~1e-9 m tolerance); if a
344
+ # re-mesh of the identical STEP ever produced the same node count in a
345
+ # DIFFERENT order (a gmsh non-determinism this repo has not observed but
346
+ # cannot rule out -- different gmsh version, different meshing-algorithm
347
+ # code path, etc.), targets and features would silently misalign for that
348
+ # row with no other symptom. This mirrors the strict bijection
349
+ # `tools.doe.adapters.map_to_gmsh` already enforces on the solver side, on
350
+ # the feature side.
351
+ # --------------------------------------------------------------------- #
352
+ _ARCHIVE_COORD_TOL_MM = 1e-6
353
+
354
+
355
+ def _archive_reference_mesh_path(row: dict, manifest_dir: str) -> str:
356
+ """The ORIGINAL solve-time .msh for an archive/DF row, as cached by
357
+ `tools.doe.adapters.mesh()` under `<doe_dir>/mesh_work/<run_id>[_scaled]
358
+ .msh` (`adapters._work_dir()`'s naming convention). `manifest_dir`
359
+ (where manifest.csv lives) IS `<doe_dir>` in the standard layout (both
360
+ default to "doe_out"), so no separate config knob is needed."""
361
+ run_id = row.get("run_id", "<unknown>")
362
+ scale = float(row.get("scale_factor") or 1.0)
363
+ suffix = "_scaled" if scale != 1.0 else ""
364
+ return osp.join(manifest_dir, "mesh_work", f"{run_id}{suffix}.msh")
365
+
366
+
367
+ def _assert_archive_mesh_coord_correspondence(mesh, row: dict, manifest_dir: str) -> None:
368
+ """No-op unless `row["geometry_source"] == "archive"` (msh_none/msh_half
369
+ primitive rows already share node order with the raw npz by construction
370
+ -- verified -- so this cost is deliberately not paid there).
371
+
372
+ For archive rows, raises a loud `ValueError` naming the run_id --
373
+ NEVER silently proceeds -- when:
374
+ - the reference solve-time mesh can't be found (an archive row with a
375
+ raw_v4 solve implies `tools.doe.adapters.mesh()` ran and cached one;
376
+ its absence means this check literally cannot be performed, which is
377
+ treated as a failure, not a silent pass);
378
+ - node counts differ; or
379
+ - node coordinates fail to correspond AT THE SAME INDEX, within
380
+ `_ARCHIVE_COORD_TOL_MM`.
381
+ The last check is deliberately NOT a plain nearest-neighbour SET
382
+ membership test (which a pure reordering of the identical point set
383
+ would pass): it uses a KDTree to find, for every feature-mesh node, its
384
+ nearest reference-mesh node (mirroring `fenicsx_solver.
385
+ _dof_to_gmsh_perm`'s own coordinate-matching approach) but then requires
386
+ BOTH that the match be within tolerance AND that the matched index equal
387
+ the feature node's own index (identity correspondence) -- since the two
388
+ meshes are expected to already share node order (same deterministic
389
+ `mesh_step` call on the same STEP), a same-count/same-position-set but
390
+ DIFFERENT-order mesh (exactly the silent-misalignment risk I1 flags)
391
+ must fail this check, not pass it."""
392
+ if (row.get("geometry_source") or "primitive") != "archive":
393
+ return
394
+ run_id = row.get("run_id", "<unknown>")
395
+ ref_path = _archive_reference_mesh_path(row, manifest_dir)
396
+ if not osp.exists(ref_path):
397
+ raise ValueError(
398
+ f"run_id={run_id!r}: archive-row coordinate-correspondence check "
399
+ f"cannot find the original solve-time mesh at {ref_path!r} (the "
400
+ f"one tools.doe.adapters.mesh() caches for the FEniCSx solve) -- "
401
+ "refusing to trust the feature builder's independently-remeshed "
402
+ "STEP without verifying it against it")
403
+
404
+ ref_pts = meshio.read(ref_path).points[:, :3].astype(np.float64)
405
+ feat_pts = np.asarray(mesh.node_xyz, dtype=np.float64)[:, :3]
406
+ if ref_pts.shape[0] != feat_pts.shape[0]:
407
+ raise ValueError(
408
+ f"run_id={run_id!r}: archive feature-mesh node count "
409
+ f"({feat_pts.shape[0]}) != solve-time mesh node count "
410
+ f"({ref_pts.shape[0]}) at {ref_path!r} -- a gmsh remesh of the "
411
+ "same STEP produced a DIFFERENT mesh; targets<->features cannot "
412
+ "be trusted to align for this row")
413
+
414
+ dist, idx = cKDTree(ref_pts).query(feat_pts)
415
+ max_dist = float(dist.max())
416
+ if max_dist > _ARCHIVE_COORD_TOL_MM:
417
+ raise ValueError(
418
+ f"run_id={run_id!r}: archive feature-mesh node coordinates do "
419
+ f"NOT correspond to the solve-time mesh at {ref_path!r} within "
420
+ f"{_ARCHIVE_COORD_TOL_MM} mm (max nearest-neighbour distance = "
421
+ f"{max_dist:.6g} mm) -- a gmsh remesh of the same STEP produced "
422
+ "a mesh whose node positions don't match; targets<->features "
423
+ "would silently misalign for this row")
424
+ if not np.array_equal(idx, np.arange(feat_pts.shape[0])):
425
+ n_reordered = int((idx != np.arange(feat_pts.shape[0])).sum())
426
+ raise ValueError(
427
+ f"run_id={run_id!r}: archive feature-mesh node ORDER does not "
428
+ f"match the solve-time mesh at {ref_path!r} -- {n_reordered} "
429
+ "node(s) matched a DIFFERENT index by nearest-neighbour "
430
+ "coordinate (same node positions, different order) -- a gmsh "
431
+ "remesh of the same STEP produced the same node count/positions "
432
+ "but a DIFFERENT order; targets<->features would silently "
433
+ "misalign for this row")
434
+
435
+
436
+ def _load_mesh_for_row(row: dict, data_root: str):
437
+ """Resolve + parse a manifest row's mesh, applying scale_factor (if any)
438
+ to node coordinates. Returns None (with a printed warning) if the .msh
439
+ can't be found."""
440
+ mpath = _find_msh_v4(row, data_root)
441
+ if mpath is None:
442
+ print(f" WARN missing msh for run_id={row.get('run_id')} "
443
+ f"(geometry_id={row.get('geometry_id')!r})")
444
+ return None
445
+ mesh = parse_msh(mpath)
446
+ scale = float(row.get("scale_factor") or 1.0)
447
+ if scale != 1.0:
448
+ mesh.node_xyz = mesh.node_xyz * scale
449
+ return mesh
450
+
451
+
452
+ def _fit_chvorinov_c_per_family(rows: list, raw_dir: str, data_root: str) -> dict:
453
+ """Per alloy_family, a fixed-exponent (Chvorinov exponent==2) Chvorinov
454
+ constant C fit from the generated (t_total, V/A) pairs: log(C) =
455
+ mean(log(t_total) - 2*log(M)) -- the geometric mean of the per-row ratio
456
+ t_total/M^2. This is the "underlying fit" `gate_b.fit_chvorinov` performs
457
+ a free two-parameter (slope+intercept) regression of, specialized to a
458
+ FIXED slope of 2 (the assumed t=C*M^2 model): more robust than the free
459
+ regression when a family has few/degenerate (M, t) points (e.g. a single
460
+ geometry solved once -- `gate_b.fit_chvorinov` requires >=3 non-degenerate
461
+ points and would raise), which is expected for early/small manifests."""
462
+ # M-4 (ft2a-branch-review): dataset=="S" (sanity cross-check pairs, always
463
+ # a duplicate of an existing A/B/C row) rows are EXCLUDED here -- they
464
+ # must not double-weight their base row's (M, t_total) point in the
465
+ # per-family geometric-mean fit, mirroring `_build_splits_v4`'s existing
466
+ # S exclusion from train/test.
467
+ fam_points = {}
468
+ for row in rows:
469
+ if row.get("dataset") == "S":
470
+ continue
471
+ run_id = row["run_id"]
472
+ mesh = _load_mesh_for_row(row, data_root)
473
+ if mesh is None:
474
+ continue
475
+ raw_path = osp.join(raw_dir, f"{run_id}.npz")
476
+ if not osp.exists(raw_path):
477
+ continue
478
+ with np.load(raw_path) as z:
479
+ if "t" not in z.files:
480
+ continue
481
+ t_total = float(np.max(z["t"]))
482
+ cool_tri = mesh.tris.get("Cooling", np.empty((0, 3), np.int64))
483
+ adiab_tri = mesh.tris.get("Adiabatic", np.empty((0, 3), np.int64))
484
+ area = total_area(mesh.node_xyz, cool_tri) + total_area(mesh.node_xyz, adiab_tri)
485
+ vol = mesh_volume(mesh.node_xyz, mesh.tets)
486
+ if area <= 0 or vol <= 0 or t_total <= 0:
487
+ continue
488
+ M = vol / area
489
+ fam = row.get("alloy_family") or "unknown"
490
+ fam_points.setdefault(fam, []).append((M, t_total))
491
+
492
+ return _fit_c_from_points(fam_points)
493
+
494
+
495
+ def _fit_c_from_points(fam_points: dict) -> dict:
496
+ """Shared core of the per-family Chvorinov-C geometric-mean fit: given
497
+ {alloy_family: [(M, t_total), ...]} (M = volume/area), returns
498
+ {alloy_family: C}. Factored out so `_fit_chvorinov_c_per_family`
499
+ (mesh-parsing, standalone/tested directly) and `build_v4`'s parallel
500
+ stage-1 pipeline (which already has volume/area/t_total from stage 1 and
501
+ must NOT re-parse the mesh to get them -- see M-3) can never silently
502
+ diverge in the actual fit math."""
503
+ C_by_family = {}
504
+ for fam, pts in fam_points.items():
505
+ M = np.array([p[0] for p in pts], dtype=np.float64)
506
+ T = np.array([p[1] for p in pts], dtype=np.float64)
507
+ C_by_family[fam] = float(np.exp(np.mean(np.log(T) - 2.0 * np.log(M))))
508
+ return C_by_family
509
+
510
+
511
+ # ----------------------------------------------------------------------- #
512
+ # M-3 (ft2a-branch-review): build_v4 used to parse each row's .msh TWICE --
513
+ # once inside `_fit_chvorinov_c_per_family`'s pre-pass (for volume/area, to
514
+ # compute M=V/A) and again inside `process_one_v4`'s main pass (for X/pos/
515
+ # mesh_edges) -- and did both passes SERIALLY, row by row, which is the slow
516
+ # path for the ~4725-row FT2 manifest. Fixed by splitting the per-row work
517
+ # into two stages:
518
+ # stage 1 (`_process_row_stage1`, embarrassingly parallel, one mesh parse
519
+ # per row): resolve+parse the mesh ONCE, load the raw solve, resolve the
520
+ # BC/material spec, and compute X/pos/mesh_edges/volume/area/t_total.
521
+ # None of this needs the cross-row Chvorinov C fit -- `_node_features`
522
+ # never reads `spec["C_material"]` (only `chvorinov_t_ref` does, in stage
523
+ # 2), so X can be computed here without a fitted C yet.
524
+ # (serial aggregate, cheap, no mesh reparse): fit C_by_family straight from
525
+ # stage 1's (volume, area, t_total) triples via `_fit_c_from_points` --
526
+ # the same fit math `_fit_chvorinov_c_per_family` uses, so the two paths
527
+ # can never silently diverge.
528
+ # stage 2 (`_finalize_row_stage2`, pure arithmetic, no mesh/IO): plugs the
529
+ # fitted C into t_ref/y_nondim/g and assembles the final record.
530
+ # `build_v4` calls the two stages directly (stage 1 parallelised across the
531
+ # whole manifest, once) instead of going through `process_one_v4`, which
532
+ # remains as a back-compat single-row wrapper composing both stages in one
533
+ # call for anyone calling it directly.
534
+ # ----------------------------------------------------------------------- #
535
+ def _process_row_stage1(row: dict, manifest_dir: str, raw_dir: str, data_root: str,
536
+ exo_calibration_csv: str = "data/properties/exo_calibration.csv",
537
+ feature_version: int = 4):
538
+ """Parallel-safe, picklable: parses the mesh exactly once. Returns a dict
539
+ of picklable (numpy/scalar) results for stage 2, or None (+ printed WARN)
540
+ if this row must be skipped -- every skip condition here is byte-for-byte
541
+ the same check `process_one_v4` used to make (missing mesh/raw/t, node-
542
+ count mismatch, unknown material_id, unresolvable chill/exo BC props).
543
+
544
+ `feature_version` (Task 2, bc-reach): threaded straight into
545
+ `_node_features` -- 4 (default, v4's 15 cols) or 5 (v4's 15 cols +
546
+ Task 1's 5 bc-reach cols, `build_v5`'s 20-wide X). Nothing else in this
547
+ function depends on the feature version; the BC-spec resolution above
548
+ is shared verbatim by both."""
549
+ run_id = row["run_id"]
550
+ mesh = _load_mesh_for_row(row, data_root)
551
+ if mesh is None:
552
+ return None
553
+
554
+ raw_path = osp.join(raw_dir, f"{run_id}.npz")
555
+ if not osp.exists(raw_path):
556
+ print(f" WARN no raw_v4 npz for run_id={run_id}: {raw_path}")
557
+ return None
558
+ with np.load(raw_path) as z:
559
+ if "t" not in z.files:
560
+ print(f" WARN raw_v4 npz for {run_id} has no 't' field (keys={z.files})")
561
+ return None
562
+ t = z["t"].astype(np.float32)
563
+ gradT = z["gradT"].astype(np.float32) if "gradT" in z.files else np.zeros_like(t)
564
+ dTdt = z["dTdt"].astype(np.float32) if "dTdt" in z.files else np.zeros_like(t)
565
+ # t_liquidus (FT2a: liquidus-crossing time, enables t - t_liquidus =
566
+ # local solidification time without re-simulating) is OPTIONAL --
567
+ # older raw_v4 npz files predate this field. Tolerate its absence
568
+ # (or a malformed length) by simply not carrying it forward; never
569
+ # crash and never fabricate a value (unlike gradT/dTdt, a missing
570
+ # t_liquidus has no safe zero-fallback -- see _finalize_row_stage2).
571
+ t_liquidus = None
572
+ if "t_liquidus" in z.files:
573
+ _tl = z["t_liquidus"].astype(np.float32)
574
+ if _tl.shape[0] == t.shape[0]:
575
+ t_liquidus = _tl
576
+ else:
577
+ print(f" WARN t_liquidus length mismatch run_id={run_id}: "
578
+ f"t={t.shape[0]} t_liquidus={_tl.shape[0]} -- skipping "
579
+ "t_liquidus/local_solidification_time for this row")
580
+
581
+ n_pt = mesh.node_xyz.shape[0]
582
+ if t.shape[0] != n_pt:
583
+ print(f" WARN node count mismatch run_id={run_id}: msh={n_pt} raw={t.shape[0]}")
584
+ return None
585
+
586
+ # I1 (ft2a-final-review): archive/DF rows only -- see the function's
587
+ # docstring above. Deliberately NOT wrapped in try/except: a mismatch (or
588
+ # a missing reference mesh) here is a hard, batch-aborting failure, not a
589
+ # row-level WARN+skip like every other check in this function -- it
590
+ # signals a potential systemic feature<->target misalignment, not a bad
591
+ # individual row.
592
+ _assert_archive_mesh_coord_correspondence(mesh, row, manifest_dir)
593
+
594
+ try:
595
+ spec = _resolve_material_props_v4(row["material_id"], manifest_dir)
596
+ except KeyError as e:
597
+ print(f" WARN {e}")
598
+ return None
599
+
600
+ bc_config = row.get("bc_config") or "none"
601
+ spec["mold_effusivity"] = float(row["mold_effusivity"])
602
+ spec["bc_config"] = bc_config
603
+ spec["sleeve_MEF"] = float(row.get("sleeve_MEF") or 0.0)
604
+ # Row-level BC-property resolution failures (unknown chill_material, an
605
+ # exo_sleeve with no calibrated MEF / no exo_calibration.csv yet) must
606
+ # WARN+skip this one row, exactly like every other row-level failure
607
+ # above (missing mesh/raw/t, node-count mismatch, unknown material_id) --
608
+ # NOT abort the whole build_v4 batch. _resolve_chill_effusivity_v4 /
609
+ # _resolve_exo_q0_v4 raise ValueError by design (never a silent 0.0); this
610
+ # is where that "loud" behavior is turned into a "skip THIS row" outcome
611
+ # instead of letting it propagate out of build_v4 mid-manifest.
612
+ try:
613
+ spec["chill_effusivity"] = _resolve_chill_effusivity_v4(row)
614
+ spec["exo_q0"] = _resolve_exo_q0_v4(row, exo_calibration_csv)
615
+ except ValueError as e:
616
+ print(f" WARN {e}")
617
+ return None
618
+
619
+ # `spec` here deliberately has NO "C_material" key yet -- `_node_features`
620
+ # (version=4/5) never reads it (only mold_effusivity/bc_config/
621
+ # chill_effusivity/sleeve_MEF/exo_q0), so X/pos/mesh_edges are fully
622
+ # computable before the cross-row Chvorinov fit exists.
623
+ X, pos, mesh_edges = _node_features(mesh, version=feature_version, spec=spec)
624
+
625
+ volume = float(X[0, FEATURE_NAMES.index("volume_mm3")])
626
+ area = float(X[0, FEATURE_NAMES.index("surface_area_mm2")])
627
+ fam = row.get("alloy_family") or "unknown"
628
+
629
+ return {
630
+ "run_id": run_id, "dataset": row.get("dataset", ""),
631
+ "alloy_family": fam, "bc_config": bc_config,
632
+ "hold_out": row.get("hold_out", ""),
633
+ "X": X, "pos": pos, "mesh_edges": mesh_edges,
634
+ "spec": spec, "volume": volume, "area": area,
635
+ "t": t, "gradT": gradT, "dTdt": dTdt, "t_liquidus": t_liquidus,
636
+ "t_total": float(np.max(t)), "n_nodes": n_pt,
637
+ }
638
+
639
+
640
+ def _finalize_row_stage2(s1: dict, C_by_family: dict):
641
+ """Pure arithmetic + dict assembly, no mesh/IO: plugs the fitted
642
+ per-family Chvorinov C into stage 1's output. Returns None (+ printed
643
+ WARN) if no C was fitted for this row's family (e.g. every row in that
644
+ family failed stage 1)."""
645
+ fam = s1["alloy_family"]
646
+ run_id = s1["run_id"]
647
+ if fam not in C_by_family:
648
+ print(f" WARN no Chvorinov C fitted for alloy_family={fam!r} "
649
+ f"(run_id={run_id}) -- skipped")
650
+ return None
651
+
652
+ spec = dict(s1["spec"])
653
+ spec["C_material"] = C_by_family[fam]
654
+ t = s1["t"]
655
+ t_ref = chvorinov_t_ref(s1["volume"], s1["area"], spec["C_material"])
656
+ g = alloy_g_vector(spec)
657
+ y_nondim = (t.astype(np.float64) / max(t_ref, 1e-9)).astype(np.float32)
658
+
659
+ rec = {
660
+ "stem": run_id, "run_id": run_id, "dataset": s1["dataset"],
661
+ "alloy_family": fam, "bc_config": s1["bc_config"],
662
+ "hold_out": s1["hold_out"],
663
+ "X": s1["X"], "pos": s1["pos"], "mesh_edges": s1["mesh_edges"],
664
+ "g": g, "y": t, "y_nondim": y_nondim, "t_ref": t_ref,
665
+ "gradT": s1["gradT"], "dTdt": s1["dTdt"],
666
+ "y_global": float(np.max(t)), "n_nodes": s1["n_nodes"],
667
+ }
668
+ t_liquidus = s1["t_liquidus"]
669
+ if t_liquidus is not None:
670
+ # local solidification time = t_solidus - t_liquidus, clipped at 0
671
+ # (the two independent threshold-crossing interpolations can, in
672
+ # principle, disagree by a tiny numerical amount right at a node
673
+ # that crosses both thresholds within the same accepted step).
674
+ rec["t_liquidus"] = t_liquidus
675
+ rec["local_solidification_time"] = np.clip(t - t_liquidus, 0.0, None).astype(np.float32)
676
+ return rec
677
+
678
+
679
+ def process_one_v4(row: dict, manifest_dir: str, raw_dir: str, data_root: str,
680
+ C_by_family: dict,
681
+ exo_calibration_csv: str = "data/properties/exo_calibration.csv"):
682
+ """Back-compat single-row entrypoint (still parses the mesh exactly
683
+ once). `build_v4` does NOT call this -- it calls stage 1 across the
684
+ whole manifest first (parallelised) and stage 2 after the C fit, so a
685
+ row's mesh is parsed once per BUILD, not once per stage."""
686
+ s1 = _process_row_stage1(row, manifest_dir, raw_dir, data_root, exo_calibration_csv)
687
+ if s1 is None:
688
+ return None
689
+ return _finalize_row_stage2(s1, C_by_family)
690
+
691
+
692
+ def write_record_v4(rec, out_dir):
693
+ gdir = osp.join(out_dir, "graphs")
694
+ os.makedirs(gdir, exist_ok=True)
695
+ extra = {}
696
+ if "t_liquidus" in rec:
697
+ extra["t_liquidus"] = rec["t_liquidus"]
698
+ extra["local_solidification_time"] = rec["local_solidification_time"]
699
+ np.savez(osp.join(gdir, f"{rec['stem']}.npz"),
700
+ X=rec["X"], pos=rec["pos"], mesh_edges=rec["mesh_edges"],
701
+ g=rec["g"], y=rec["y"], y_raw=rec["y"], y_nondim=rec["y_nondim"],
702
+ t_ref=np.float64(rec["t_ref"]),
703
+ gradT=rec["gradT"], dTdt=rec["dTdt"], **extra)
704
+
705
+
706
+ def _build_splits_v4(meta: dict) -> dict:
707
+ """{"interp": {"train","test"}, "extrap": {"train","test"}} from the
708
+ manifest's hold_out column. dataset=="S" (sanity cross-check pairs) rows
709
+ are EXCLUDED from every train (and test) list here, regardless of their
710
+ hold_out value -- they are not part of the GNN train/val protocol."""
711
+ stems = list(meta["stem"]); dataset = list(meta["dataset"]); hold_out = list(meta["hold_out"])
712
+ def _stems_where(pred):
713
+ return sorted(s for s, d, h in zip(stems, dataset, hold_out)
714
+ if d != "S" and pred(h))
715
+ train = _stems_where(lambda h: h == "train")
716
+ val_interp = _stems_where(lambda h: h == "val_interp")
717
+ val_extrap = _stems_where(lambda h: h == "val_extrap")
718
+ return {
719
+ "interp": {"train": train, "test": val_interp},
720
+ "extrap": {"train": train, "test": val_extrap},
721
+ }
722
+
723
+
724
+ def _init_norm_accum(feature_version: int = 4, n_feat: int = None):
725
+ """Fresh accumulator state for the norm-stats running sums, shared by
726
+ `build_v4`/`build_v5`'s Phase 2 fold and `recompute_norm_stats_v4` (so
727
+ these can never silently diverge in the accumulation math).
728
+
729
+ `n_feat`, if given, overrides the width implied by `feature_version`
730
+ (used by `recompute_norm_stats_v4` to size the accumulator from an
731
+ on-disk graph's ACTUAL X width -- 15 for a v4 cache, 20 for a v5 one --
732
+ instead of assuming a version from the caller)."""
733
+ if n_feat is None:
734
+ n_feat = len(FEATURE_NAMES_V5) if feature_version >= 5 else len(FEATURE_NAMES_V4)
735
+ n_g = len(G_VECTOR_NAMES)
736
+ return {
737
+ "fsum": np.zeros(n_feat), "fsq": np.zeros(n_feat), "fcnt": 0,
738
+ "gsum": np.zeros(n_g), "gsq": np.zeros(n_g), "gcnt": 0,
739
+ "ylsum": 0.0, "ylsq": 0.0, "ycnt": 0,
740
+ }
741
+
742
+
743
+ def _accumulate_norm_stats(acc: dict, X: np.ndarray, g: np.ndarray, y: np.ndarray) -> None:
744
+ """Fold one row's (X, g, y) into `acc` in place."""
745
+ X = X.astype(np.float64)
746
+ acc["fsum"] += X.sum(0); acc["fsq"] += (X ** 2).sum(0); acc["fcnt"] += X.shape[0]
747
+ g = g.astype(np.float64)
748
+ acc["gsum"] += g; acc["gsq"] += g ** 2; acc["gcnt"] += 1
749
+ yl = np.log1p(y.astype(np.float64))
750
+ acc["ylsum"] += yl.sum(); acc["ylsq"] += (yl ** 2).sum(); acc["ycnt"] += yl.size
751
+
752
+
753
+ def _finalize_norm_stats(acc: dict) -> dict:
754
+ """Turn the running sums into the final {feat,g,y_log}_{mean,std} dict --
755
+ exactly the arithmetic `build_v4` used to do inline."""
756
+ feat_mean = acc["fsum"] / max(acc["fcnt"], 1)
757
+ feat_std = np.sqrt(np.clip(acc["fsq"] / max(acc["fcnt"], 1) - feat_mean ** 2, 1e-12, None))
758
+ g_mean = acc["gsum"] / max(acc["gcnt"], 1)
759
+ g_std = np.sqrt(np.clip(acc["gsq"] / max(acc["gcnt"], 1) - g_mean ** 2, 1e-12, None))
760
+ y_log_mean = acc["ylsum"] / max(acc["ycnt"], 1)
761
+ y_log_std = float(np.sqrt(max(acc["ylsq"] / max(acc["ycnt"], 1) - y_log_mean ** 2, 1e-12)))
762
+ return {
763
+ "feat_mean": feat_mean, "feat_std": feat_std,
764
+ "g_mean": g_mean, "g_std": g_std,
765
+ "y_log_mean": np.float64(y_log_mean), "y_log_std": np.float64(y_log_std),
766
+ }
767
+
768
+
769
+ def recompute_norm_stats_v4(features_dir: str) -> dict:
770
+ """Recompute `norm_stats.npz` from EVERY graph `.npz` currently on disk
771
+ under `<features_dir>/graphs/`, using exactly the same accumulation
772
+ conventions as `build_v4`'s Phase 2 (`_accumulate_norm_stats` /
773
+ `_finalize_norm_stats` are the SAME helpers build_v4 folds each row
774
+ through -- this cannot silently diverge from a full build's math).
775
+
776
+ dataset=="S" rows are excluded, mirroring build_v4's own exclusion --
777
+ looked up via `<features_dir>/meta.npz`'s stem/dataset columns (still
778
+ the full, untouched manifest-wide meta written by the last full build;
779
+ a selective `only_stems` rebuild deliberately never rewrites it).
780
+
781
+ Overwrites `<features_dir>/norm_stats.npz` in place and returns the new
782
+ stats dict. Intended for use AFTER a selective (`only_stems`) rebuild,
783
+ to fold the rebuilt rows' (possibly changed) features back into the
784
+ global normalization without re-running the whole manifest.
785
+
786
+ Version-agnostic (v4 15-wide or v5 20-wide cache): the accumulator's
787
+ feature width is read off the FIRST graph `.npz` actually found on disk
788
+ (`X.shape[1]`), not assumed from a hardcoded FEATURE_NAMES_V4 -- so this
789
+ one function serves both a v4 and a v5 `features_dir` without a
790
+ parameter, and can never disagree with what's actually cached there."""
791
+ meta_path = osp.join(features_dir, "meta.npz")
792
+ dataset_by_stem = {}
793
+ if osp.exists(meta_path):
794
+ with np.load(meta_path, allow_pickle=True) as mz:
795
+ for stem, ds in zip(mz["stem"], mz["dataset"]):
796
+ dataset_by_stem[str(stem)] = str(ds)
797
+
798
+ gpaths = sorted(glob(osp.join(features_dir, "graphs", "*.npz")))
799
+ n_feat = None
800
+ if gpaths:
801
+ with np.load(gpaths[0]) as z0:
802
+ n_feat = int(z0["X"].shape[1])
803
+ acc = _init_norm_accum(n_feat=n_feat)
804
+ for gp in gpaths:
805
+ stem = osp.splitext(osp.basename(gp))[0]
806
+ if dataset_by_stem.get(stem) == "S":
807
+ continue
808
+ with np.load(gp) as z:
809
+ _accumulate_norm_stats(acc, z["X"], z["g"], z["y"])
810
+
811
+ stats = _finalize_norm_stats(acc)
812
+ np.savez(osp.join(features_dir, "norm_stats.npz"), **stats)
813
+ return stats
814
+
815
+
816
+ def _recover_c_by_family_from_cache(rows: list, out_dir: str) -> dict:
817
+ """For a SELECTIVE (`only_stems`) rebuild: recover each needed
818
+ alloy_family's Chvorinov C by inverting `t_ref = C * (V/A)**2` from the
819
+ row's OWN currently-cached graph `.npz` (read BEFORE this rebuild
820
+ overwrites it). Volume/area are geometry-only and unchanged by Task 1's
821
+ exothermic_input re-encoding, so this exactly reproduces the ORIGINAL
822
+ full-manifest fit's C for that family -- to floating-point precision,
823
+ not a re-fit from the (possibly much smaller/skewed) `only_stems`
824
+ subset, which would silently shift t_ref/y_nondim for every row in that
825
+ family (see the Task 2 brief's Chvorinov-C correctness point).
826
+
827
+ One point per family suffices (algebraic inversion of a constant, not a
828
+ regression); if more than one row of a family is present among `rows`,
829
+ their recovered C values are averaged purely as a sanity cross-check
830
+ (they should already agree to float precision)."""
831
+ v_idx = FEATURE_NAMES.index("volume_mm3")
832
+ a_idx = FEATURE_NAMES.index("surface_area_mm2")
833
+ fam_c_vals = {}
834
+ for row in rows:
835
+ run_id = row["run_id"]
836
+ fam = row.get("alloy_family") or "unknown"
837
+ gpath = osp.join(out_dir, "graphs", f"{run_id}.npz")
838
+ if not osp.exists(gpath):
839
+ continue
840
+ with np.load(gpath) as z:
841
+ if "t_ref" not in z.files or "X" not in z.files:
842
+ continue
843
+ X = z["X"]
844
+ t_ref_old = float(z["t_ref"])
845
+ V = float(X[0, v_idx]); A = float(X[0, a_idx])
846
+ if V <= 0 or A <= 0 or t_ref_old <= 0:
847
+ continue
848
+ M = V / A
849
+ fam_c_vals.setdefault(fam, []).append(t_ref_old / (M ** 2))
850
+ return {fam: float(np.mean(vals)) for fam, vals in fam_c_vals.items()}
851
+
852
+
853
+ def _run_stage1_v4(rows: list, manifest_dir: str, raw_dir: str, data_root: str,
854
+ exo_calibration_csv: str, n_workers, feature_version: int = 4) -> list:
855
+ """Stage 1 across the whole manifest. Returns a list of `dict | None`,
856
+ ONE PER ROW, in the SAME ORDER as `rows` (required for the serial
857
+ aggregation phase below to be byte-identical to the pre-parallel serial
858
+ build, which folded rows into norm_stats/meta in manifest order).
859
+ `n_workers in (None, 1)` runs serially in-process (no pool overhead,
860
+ easiest to debug); otherwise a bounded `ProcessPoolExecutor` -- BLAS
861
+ capped (module-top env vars + `_init_worker`'s threadpoolctl fallback)
862
+ and niced, mirroring tools/doe/doe.py's execute() pool. `ex.map` (not
863
+ submit+as_completed) is used specifically because it yields results in
864
+ submission order regardless of completion order.
865
+
866
+ `feature_version` (Task 2, bc-reach): forwarded to every
867
+ `_process_row_stage1` call (serial or pooled) -- `build_v4` passes 4
868
+ (the default), `build_v5` passes 5."""
869
+ if n_workers is None or n_workers <= 1:
870
+ return [_process_row_stage1(r, manifest_dir, raw_dir, data_root,
871
+ exo_calibration_csv, feature_version)
872
+ for r in rows]
873
+ with ProcessPoolExecutor(max_workers=n_workers, initializer=_init_worker) as ex:
874
+ return list(ex.map(
875
+ _process_row_stage1, rows,
876
+ itertools.repeat(manifest_dir), itertools.repeat(raw_dir),
877
+ itertools.repeat(data_root), itertools.repeat(exo_calibration_csv),
878
+ itertools.repeat(feature_version)))
879
+
880
+
881
+ def _build_v4_family(manifest_path: str, feature_version: int, raw_dir: str,
882
+ data_root: str, out_dir: str, limit: int,
883
+ exo_calibration_csv: str, n_workers, only_stems,
884
+ write_splits) -> None:
885
+ """Shared implementation behind `build_v4` (feature_version=4, 15-wide
886
+ X) and `build_v5` (feature_version=5, 20-wide X, Task 2/bc-reach).
887
+ Everything here is feature-version-agnostic: X's width is entirely
888
+ determined by `_process_row_stage1`/`_node_features(version=
889
+ feature_version)` in stage 1 -- the per-family Chvorinov-C fit (or, for
890
+ a selective `only_stems` rebuild, its cache-recovery + same-family
891
+ fallback), stage-2 finalize + write, and norm-stats/meta accumulation
892
+ never read a hardcoded column count.
893
+
894
+ `write_splits(meta, out_dir)` is the ONE place the two callers diverge:
895
+ called only for a FULL (non-selective) build, after meta.npz/
896
+ norm_stats.npz are already written, it decides how splits.json is
897
+ produced -- `build_v4` fits it from this build's own meta
898
+ (`_build_splits_v4`); `build_v5` instead copies an existing v4 cache's
899
+ splits.json VERBATIM (same manifest/rows, so the split membership is
900
+ identical by construction, never independently re-derived and risking
901
+ divergence). A selective (`only_stems`) rebuild never calls
902
+ `write_splits` at all -- splits.json is left untouched, same as
903
+ meta.npz/norm_stats.npz.
904
+
905
+ `only_stems` (optional set/iterable of `run_id`): a SELECTIVE rebuild --
906
+ process only the manifest rows whose `run_id` is in `only_stems`, write
907
+ (only) their graph `.npz`, and skip `meta.npz`/`norm_stats.npz`/
908
+ `splits.json` entirely (those describe the WHOLE manifest and a partial
909
+ rebuild must never truncate/corrupt them). The per-family Chvorinov C
910
+ each selected row needs is NOT re-fit from the (possibly tiny/skewed)
911
+ subset -- it is recovered from the already-cached graph .npz on disk
912
+ (`_recover_c_by_family_from_cache`), so t_ref/y_nondim for unaffected
913
+ rows never drifts. Use `recompute_norm_stats_v4` afterwards to fold the
914
+ rebuilt rows back into `norm_stats.npz`."""
915
+ rows = _read_manifest_rows(manifest_path)
916
+ if limit:
917
+ rows = rows[:limit]
918
+ rows_full = rows
919
+ if only_stems is not None:
920
+ only_stems = set(only_stems)
921
+ rows = [r for r in rows if r["run_id"] in only_stems]
922
+ manifest_dir = osp.dirname(osp.abspath(manifest_path))
923
+ os.makedirs(out_dir, exist_ok=True)
924
+
925
+ # Phase 1 (parallel, embarrassingly-parallel per row -- M-3): resolve +
926
+ # parse each row's mesh EXACTLY ONCE (previously parsed twice: once in
927
+ # `_fit_chvorinov_c_per_family`'s pre-pass, once in `process_one_v4`'s
928
+ # main pass), load its raw solve, resolve its BC/material spec, and
929
+ # compute X/pos/mesh_edges/volume/area/t_total -- everything that does
930
+ # NOT depend on the cross-row Chvorinov C fit.
931
+ stage1 = _run_stage1_v4(rows, manifest_dir, raw_dir, data_root,
932
+ exo_calibration_csv, n_workers,
933
+ feature_version=feature_version)
934
+
935
+ # Phase 1.5 (serial aggregate, cheap -- no mesh reparse, just a fold over
936
+ # already-computed scalars): fit the per-family Chvorinov C, EXCLUDING
937
+ # dataset=="S" rows (M-4, mirrored from `_fit_chvorinov_c_per_family`)
938
+ # from the fit, using the exact same fit math (`_fit_c_from_points`) so
939
+ # this can never silently diverge from the standalone/tested function.
940
+ # Selective (only_stems) rebuild: NEVER re-fit C from this row subset --
941
+ # recover the ORIGINAL full-manifest C per family from the already-cached
942
+ # graph .npz on disk instead (see build_v4's docstring + the Task 2
943
+ # brief's Chvorinov-C correctness point).
944
+ if only_stems is not None:
945
+ C_by_family = _recover_c_by_family_from_cache(rows, out_dir)
946
+ # NEW stems (no graph .npz of their own cached yet) contribute no
947
+ # point to the lookup above, so a family made up ENTIRELY of new
948
+ # stems comes back with no C at all -- fall back to recovering that
949
+ # family's C from any OTHER (already-cached, from a prior full or
950
+ # selective build) row of the same family across the FULL manifest,
951
+ # excluding dataset=="S" rows (mirrors the fit's own exclusion, see
952
+ # `_fit_chvorinov_c_per_family`). This is still the same algebraic
953
+ # inversion of a per-family constant, just sourced from a wider row
954
+ # set -- never a re-fit from the (possibly tiny/skewed) only_stems
955
+ # subset.
956
+ needed = {r.get("alloy_family") or "unknown" for r in rows}
957
+ missing = needed - set(C_by_family)
958
+ if missing:
959
+ fallback = [r for r in rows_full
960
+ if (r.get("alloy_family") or "unknown") in missing
961
+ and r.get("dataset") != "S"]
962
+ C_by_family.update(_recover_c_by_family_from_cache(fallback, out_dir))
963
+ # If a needed family STILL has no C after the full-manifest fallback,
964
+ # every one of its rows would otherwise be silently skipped by
965
+ # `_finalize_row_stage2`'s warn-and-skip -- a selective rebuild must
966
+ # fail loudly instead of quietly writing zero graphs.
967
+ still_missing = needed - set(C_by_family)
968
+ if still_missing:
969
+ raise RuntimeError(
970
+ f"build_v{feature_version}(only_stems=...): no cached graph "
971
+ f"found anywhere in {out_dir!r} to recover Chvorinov C for "
972
+ f"alloy_family(s) {sorted(still_missing)!r} -- run a full "
973
+ f"(non-selective) build_v{feature_version} first so at least "
974
+ f"one graph of each family is cached.")
975
+ else:
976
+ fam_points = {}
977
+ for s1 in stage1:
978
+ if s1 is None or s1["dataset"] == "S":
979
+ continue
980
+ fam_points.setdefault(s1["alloy_family"], []).append(
981
+ (s1["volume"] / s1["area"], s1["t_total"]))
982
+ C_by_family = _fit_c_from_points(fam_points)
983
+
984
+ # Phase 2 (serial -- pure arithmetic + file writes, no mesh parsing or
985
+ # other heavy work, so parallelising it would only add IPC/pickling
986
+ # overhead): finalize each row now that C_by_family is known, write its
987
+ # graph .npz, and fold it into the running norm-stats/meta accumulators
988
+ # in the SAME `rows` order the old serial build used -- byte-identical
989
+ # accumulation order (floating-point sums are order-sensitive in
990
+ # principle, though not in practice at this row count/precision).
991
+ names = FEATURE_NAMES_V5 if feature_version >= 5 else FEATURE_NAMES_V4
992
+ acc = _init_norm_accum(feature_version=feature_version)
993
+ meta = {k: [] for k in ("stem", "run_id", "dataset", "alloy_family",
994
+ "bc_config", "hold_out", "y_global", "n_nodes")}
995
+ n_written = 0
996
+
997
+ for s1 in stage1:
998
+ if s1 is None:
999
+ continue
1000
+ rec = _finalize_row_stage2(s1, C_by_family)
1001
+ if rec is None:
1002
+ continue
1003
+ write_record_v4(rec, out_dir)
1004
+ n_written += 1
1005
+ if only_stems is not None:
1006
+ # Selective rebuild: meta/norm_stats/splits describe the WHOLE
1007
+ # manifest and are deliberately left untouched (see
1008
+ # `_build_v4_family`'s docstring) -- use `recompute_norm_stats_v4`
1009
+ # afterwards instead.
1010
+ continue
1011
+ # M-4 (ft2a-branch-review): dataset=="S" rows still get a cached
1012
+ # graph .npz (and a meta.npz row, so tooling that looks them up by
1013
+ # run_id keeps working) but must NOT fold into norm_stats -- they
1014
+ # duplicate an existing A/B/C geometry+material and would otherwise
1015
+ # mildly double-weight that point in feature/g/y normalization,
1016
+ # mirroring their existing exclusion from the Chvorinov-C fit above
1017
+ # and from train/test splits (`_build_splits_v4`).
1018
+ if rec["dataset"] != "S":
1019
+ _accumulate_norm_stats(acc, rec["X"], rec["g"], rec["y"])
1020
+ for k in meta:
1021
+ meta[k].append(rec[k])
1022
+
1023
+ if only_stems is not None:
1024
+ print(f"Rebuilt {n_written} v{feature_version} graphs (of {len(rows)} "
1025
+ f"selected stems) -> {out_dir}/graphs (meta.npz/norm_stats.npz/"
1026
+ "splits.json untouched -- run recompute_norm_stats_v4 separately)")
1027
+ return
1028
+
1029
+ stats = _finalize_norm_stats(acc)
1030
+
1031
+ np.savez(osp.join(out_dir, "meta.npz"),
1032
+ stem=np.array(meta["stem"]), run_id=np.array(meta["run_id"]),
1033
+ dataset=np.array(meta["dataset"]),
1034
+ alloy_family=np.array(meta["alloy_family"]),
1035
+ bc_config=np.array(meta["bc_config"]),
1036
+ hold_out=np.array(meta["hold_out"]),
1037
+ y_global=np.array(meta["y_global"], dtype=np.float32),
1038
+ n_nodes=np.array(meta["n_nodes"]),
1039
+ feature_names=np.array(names))
1040
+ np.savez(osp.join(out_dir, "norm_stats.npz"), **stats)
1041
+
1042
+ write_splits(meta, out_dir)
1043
+
1044
+ print(f"Built {len(meta['stem'])} v{feature_version} graphs "
1045
+ f"(of {len(rows)} manifest rows) -> {out_dir}")
1046
+
1047
+
1048
+ def build_v4(manifest_path: str, raw_dir: str = "data/raw_v4", data_root: str = "data",
1049
+ out_dir: str = "data/features_v4", limit: int = None,
1050
+ exo_calibration_csv: str = "data/properties/exo_calibration.csv",
1051
+ n_workers=1, only_stems=None):
1052
+ """v4 (15-wide X) feature-cache build. See `_build_v4_family`'s
1053
+ docstring for the full contract (stage 1/2 split, only_stems selective
1054
+ rebuild + Chvorinov-C cache-recovery). splits.json is fit from this
1055
+ build's own meta via `_build_splits_v4` -- the v4-specific case of
1056
+ `_build_v4_family`'s `write_splits` hook."""
1057
+ def _write_splits_v4(meta: dict, out_dir: str) -> None:
1058
+ splits = _build_splits_v4(meta)
1059
+ with open(osp.join(out_dir, "splits.json"), "w") as f:
1060
+ json.dump(splits, f, indent=2)
1061
+
1062
+ return _build_v4_family(manifest_path, 4, raw_dir, data_root, out_dir,
1063
+ limit, exo_calibration_csv, n_workers, only_stems,
1064
+ _write_splits_v4)
1065
+
1066
+
1067
+ def build_v5(manifest_path: str, raw_dir: str = "data/raw_v4", data_root: str = "data",
1068
+ out_dir: str = "data/features_v5", limit: int = None,
1069
+ exo_calibration_csv: str = "data/properties/exo_calibration.csv",
1070
+ n_workers=1, only_stems=None,
1071
+ v4_splits_path: str = "data/features_v4/splits.json"):
1072
+ """Task 2 (bc-reach): the 20-channel feature-cache build -- `build_v4`
1073
+ parameterized to feature_version=5 via the shared `_build_v4_family`
1074
+ implementation (see its docstring for the full stage 1/2 + only_stems
1075
+ contract, unchanged here). X is 20-wide: v4's 15 columns UNCHANGED
1076
+ (byte-identical to a v4 build of the same row -- Task 1's
1077
+ `compute_bc_reach_channels`/`FEATURE_NAMES_V5` only APPEND 5 new
1078
+ columns) + the 5 bc-reach columns from Task 1.
1079
+
1080
+ Two deliberate differences from `build_v4`:
1081
+ - norm_stats.npz is FRESH over the full 20-wide X -- NOT v4's stats
1082
+ reused or zero-padded. `_init_norm_accum(feature_version=5)` sizes
1083
+ the accumulator to `len(FEATURE_NAMES_V5) == 20` and every row's
1084
+ real 20-column X is folded into it (`_accumulate_norm_stats`,
1085
+ unchanged -- it never assumes a column count).
1086
+ - splits.json is NOT computed from this build's own meta -- v5 uses
1087
+ the IDENTICAL manifest/rows/hold_out protocol as the v4 cache it's
1088
+ built alongside, so `splits.json` is copied VERBATIM from
1089
+ `v4_splits_path` (default `data/features_v4/splits.json`, the
1090
+ production v4 cache) instead of re-deriving it a second time --
1091
+ guaranteeing byte-for-byte the same split membership (incl. e_val)
1092
+ with zero risk of the two derivations silently diverging. Raises
1093
+ `FileNotFoundError` if `v4_splits_path` doesn't exist -- a v5 build
1094
+ requires the v4 cache to already exist; this never silently skips
1095
+ or fabricates a split. Selective (`only_stems`) rebuilds never call
1096
+ this at all (same as `build_v4`), so `v4_splits_path` need not even
1097
+ be reachable for those."""
1098
+ def _copy_splits_from_v4(meta: dict, out_dir: str) -> None:
1099
+ if not osp.exists(v4_splits_path):
1100
+ raise FileNotFoundError(
1101
+ f"build_v5: v4_splits_path {v4_splits_path!r} does not exist "
1102
+ "-- build_v5 copies splits.json VERBATIM from an existing v4 "
1103
+ "cache (same manifest/rows) instead of re-deriving it; run "
1104
+ "build_v4 on this manifest first, or pass the correct "
1105
+ "v4_splits_path")
1106
+ shutil.copyfile(v4_splits_path, osp.join(out_dir, "splits.json"))
1107
+
1108
+ return _build_v4_family(manifest_path, 5, raw_dir, data_root, out_dir,
1109
+ limit, exo_calibration_csv, n_workers, only_stems,
1110
+ _copy_splits_from_v4)
1111
+
1112
+
1113
+ def build(data_root="data", out_dir="data/features", n_workers=None, limit=None,
1114
+ feature_version=2, manifest_path=None, raw_dir="data/raw_v4",
1115
+ exo_calibration_csv="data/properties/exo_calibration.csv"):
1116
+ if feature_version >= 4:
1117
+ if manifest_path is None:
1118
+ raise ValueError("build(feature_version=4) requires manifest_path")
1119
+ return build_v4(manifest_path=manifest_path, raw_dir=raw_dir,
1120
+ data_root=data_root, out_dir=out_dir, limit=limit,
1121
+ exo_calibration_csv=exo_calibration_csv,
1122
+ n_workers=(n_workers if n_workers is not None else 1))
1123
+ stems = _all_stems(data_root)
1124
+ if limit:
1125
+ stems = stems[:limit]
1126
+ os.makedirs(out_dir, exist_ok=True)
1127
+
1128
+ # Determine feature names based on version
1129
+ names = FEATURE_NAMES_V3 if feature_version >= 3 else FEATURE_NAMES
1130
+
1131
+ # accumulators for global normalization (Welford-free: sum, sumsq, count)
1132
+ fsum = np.zeros(len(names)); fsq = np.zeros(len(names)); fcnt = 0
1133
+ ylsum = ylsq = ycnt = 0.0
1134
+ meta = {k: [] for k in ("stem", "prefix", "n_adiab", "is_fully_cooled",
1135
+ "y_global", "n_nodes")}
1136
+
1137
+ def _accumulate(rec):
1138
+ nonlocal fsum, fsq, fcnt, ylsum, ylsq, ycnt
1139
+ write_record(rec, out_dir)
1140
+ X = rec["X"].astype(np.float64)
1141
+ fsum += X.sum(0); fsq += (X ** 2).sum(0); fcnt += X.shape[0]
1142
+ yl = np.log1p(rec["y"].astype(np.float64))
1143
+ ylsum += yl.sum(); ylsq += (yl ** 2).sum(); ycnt += yl.size
1144
+ for k in meta:
1145
+ meta[k].append(rec[k])
1146
+
1147
+ worker = process_one
1148
+ if n_workers == 1:
1149
+ for i, s in enumerate(stems):
1150
+ rec = worker(s, data_root, feature_version)
1151
+ if rec is not None:
1152
+ _accumulate(rec)
1153
+ if (i + 1) % 500 == 0:
1154
+ print(f" {i+1}/{len(stems)}")
1155
+ else:
1156
+ with ProcessPoolExecutor(max_workers=n_workers) as ex:
1157
+ futs = {ex.submit(worker, s, data_root, feature_version): s for s in stems}
1158
+ done = 0
1159
+ for fut in futs:
1160
+ rec = fut.result()
1161
+ done += 1
1162
+ if rec is not None:
1163
+ _accumulate(rec)
1164
+ if done % 500 == 0:
1165
+ print(f" {done}/{len(stems)}")
1166
+
1167
+ feat_mean = fsum / max(fcnt, 1)
1168
+ feat_std = np.sqrt(np.clip(fsq / max(fcnt, 1) - feat_mean ** 2, 1e-12, None))
1169
+ y_log_mean = ylsum / max(ycnt, 1)
1170
+ y_log_std = float(np.sqrt(max(ylsq / max(ycnt, 1) - y_log_mean ** 2, 1e-12)))
1171
+
1172
+ np.savez(osp.join(out_dir, "meta.npz"),
1173
+ stem=np.array(meta["stem"]),
1174
+ prefix=np.array(meta["prefix"]),
1175
+ n_adiab=np.array(meta["n_adiab"]),
1176
+ is_fully_cooled=np.array(meta["is_fully_cooled"]),
1177
+ y_global=np.array(meta["y_global"], dtype=np.float32),
1178
+ n_nodes=np.array(meta["n_nodes"]),
1179
+ feature_names=np.array(names))
1180
+ np.savez(osp.join(out_dir, "norm_stats.npz"),
1181
+ feat_mean=feat_mean, feat_std=feat_std,
1182
+ y_log_mean=np.float64(y_log_mean), y_log_std=np.float64(y_log_std))
1183
+ print(f"Built {len(meta['stem'])} graphs → {out_dir}")
1184
+
1185
+
1186
+ if __name__ == "__main__":
1187
+ ap = argparse.ArgumentParser()
1188
+ ap.add_argument("--data-root", default="data")
1189
+ ap.add_argument("--out-dir", default="data/features")
1190
+ ap.add_argument("--workers", type=int, default=max(1, (os.cpu_count() or 2) - 2))
1191
+ ap.add_argument("--limit", type=int, default=None)
1192
+ ap.add_argument("--feature-version", type=int, default=2)
1193
+ ap.add_argument("--manifest", default=None,
1194
+ help="DOE manifest.csv (required for --feature-version 4)")
1195
+ ap.add_argument("--raw-dir", default="data/raw_v4",
1196
+ help="raw FEniCSx solve npz dir ({run_id}.npz -> t/gradT/dTdt), v4 only")
1197
+ a = ap.parse_args()
1198
+ build(a.data_root, a.out_dir, a.workers, a.limit, a.feature_version,
1199
+ manifest_path=a.manifest, raw_dir=a.raw_dir)
src/compare_d_parts.py ADDED
@@ -0,0 +1,214 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """FT2b v4-inference Task 5: D-parts v4-vs-SP1 comparison.
2
+
3
+ Before promoting the v4 hybrid (FiLM GNN + multi-alloy GBM, `src.evaluate_ft2b`)
4
+ over the SP1 production hybrid (`runs/sp1_run3_final/best_gnn.pt` +
5
+ `runs/baseline_v2/hgb_A.joblib`), both stacks are compared head-to-head on
6
+ the real-part "dataset D" geometries (`tools/doe/prep_parts.py`'s cleaned
7
+ STEP parts) that have a FEniCSx-ground-truth v4 feature cache. This is the
8
+ *same* geometry fed through *both* stacks -- exactly the `_no_regression_check`
9
+ technique in `src.evaluate_ft2b` (v4_X[:, :13]/[:9] through SP1's own norm
10
+ stats), just scoped to dataset=="D" instead of dataset=="A", and reported
11
+ per-stem as well as aggregated.
12
+
13
+ Reused, not reimplemented: `load_ft2b_gnn`/`predict_graph_seconds` (GNN
14
+ loading+inference, film-aware), `_evaluate_stems_v4` (v4 quantile-anchor hybrid
15
+ per stem), `_sp1_hybrid_predict_stem` (SP1 mean-anchor hybrid on the v4
16
+ cache's geometry), `hotspot_overlap` -- all from `src.evaluate_ft2b`, the
17
+ single source of truth for both compositions (see that module's docstring
18
+ for the quantile-anchor-vs-mean-anchor rationale).
19
+ """
20
+ from __future__ import annotations
21
+
22
+ import json
23
+ import os
24
+ import os.path as osp
25
+
26
+ import joblib
27
+ import numpy as np
28
+ import torch
29
+
30
+ from src import evaluate_ft2b as ft2b
31
+ from src import metrics
32
+ from src.dataload import load_norm
33
+ from src.gbm_ft2b import _load_manifest_rows
34
+
35
+
36
+ def _d_stems(manifest_rows: dict, features_dir: str) -> tuple[list[str], int]:
37
+ """Sorted `run_id`s with `dataset=="D"` AND a v4 graph cache on disk, plus
38
+ the raw count of `dataset=="D"` manifest rows (cache present or not) --
39
+ the latter lets callers tell "no D rows in the manifest at all" apart
40
+ from "D rows exist but none have been built into the v4 cache yet"."""
41
+ d_rows = [stem for stem, row in manifest_rows.items() if row.get("dataset") == "D"]
42
+ out = [s for s in d_rows if osp.exists(osp.join(features_dir, "graphs", f"{s}.npz"))]
43
+ return sorted(out), len(d_rows)
44
+
45
+
46
+ def _stem_metrics(y_true: np.ndarray, y_pred: np.ndarray, xyz: np.ndarray) -> dict:
47
+ gid = np.zeros(y_true.shape[0], dtype=np.int64)
48
+ return {
49
+ "mae": metrics.mae(y_true, y_pred),
50
+ "hotspot": ft2b.hotspot_overlap(y_true, y_pred, xyz, gid),
51
+ "std_ratio": metrics.pred_std_ratio(y_true, y_pred, gid),
52
+ }
53
+
54
+
55
+ def _aggregate_metrics(records: list[dict]) -> dict:
56
+ """Node-weighted aggregate over `{"y_true", "y_hybrid", "xyz"}` records.
57
+
58
+ Delegates to `ft2b._overall_metrics` (single source of truth for the
59
+ node-weighted concat/gid glue -- don't re-derive it here and risk it
60
+ drifting out of sync), just renaming `hotspot_overlap` -> `hotspot` to
61
+ match this module's own key convention."""
62
+ agg = ft2b._overall_metrics(records)
63
+ return {"mae": agg["mae"], "hotspot": agg["hotspot_overlap"],
64
+ "std_ratio": agg["std_ratio"]}
65
+
66
+
67
+ def compare_d_parts(v4_ckpt, v4_gbm,
68
+ sp1_ckpt=ft2b.DEFAULT_SP1_CKPT, sp1_hgb=ft2b.DEFAULT_SP1_HGB,
69
+ features_dir="data/features_v4", manifest="doe_out/manifest.csv",
70
+ out_json="runs/v4_inference/d_parts_comparison.json",
71
+ baseline_features_dir=ft2b.DEFAULT_BASELINE_FEATURES_DIR,
72
+ hgb_std=None,
73
+ lambda_shrink: float = 1.0,
74
+ w_total: float = 1.0,
75
+ w_std: float = 1.0,
76
+ device=None) -> dict:
77
+ """Head-to-head v4-hybrid vs SP1-hybrid comparison on dataset D stems.
78
+
79
+ Args:
80
+ v4_ckpt: path to the v4 GNN checkpoint, or an already-loaded
81
+ checkpoint dict (`ft2b.load_ft2b_gnn`'s own convention).
82
+ v4_gbm: path to the refit multi-alloy GBM (`src.gbm_ft2b.refit_gbm`),
83
+ or an already-loaded estimator.
84
+ sp1_ckpt / sp1_hgb: the SP1 production hybrid stack.
85
+ features_dir: v4 feature cache directory (`graphs/<stem>.npz`).
86
+ manifest: DOE manifest CSV (`run_id`==stem; needs `dataset`/
87
+ `geometry_id`/`alloy_family`/`bc_config` columns).
88
+ out_json: where to write the result as JSON (falsy skips the write;
89
+ the parent directory is created when writing).
90
+ baseline_features_dir: SP1 HGB norm stats (`norm_stats.npz`, 9 v2
91
+ features) -- same default as `evaluate_ft2b`.
92
+ hgb_std (variance-recomposition Task 3): an optional path to (or
93
+ already-loaded) GBM std-head model
94
+ (`src.gbm_ft2b.refit_gbm_std`'s output), forwarded ONLY to the
95
+ v4 side's `ft2b._evaluate_stems_v4` (the SP1 side has no
96
+ std-head analog and is unaffected). `None` (the default) is
97
+ byte-identical to before this argument existed (the v4 side
98
+ stays on the quantile-anchor). The returned dict gains a
99
+ top-level `"recomposition": "soft"|"anchor"` field (soft-anchor
100
+ Task 3: was `"affine"|"anchor"` -- `ft2b._evaluate_stems_v4` now
101
+ recomposes via `src.hybrid.hybrid_field_soft` instead of
102
+ `hybrid_field_affine` whenever `hgb_std` is given).
103
+ lambda_shrink (variance-recomposition amendment 2): forwarded
104
+ straight to the v4 side's `ft2b._evaluate_stems_v4` (ignored
105
+ when `hgb_std` is `None`). `1.0` (the default) is byte-identical
106
+ to before this argument existed.
107
+ w_total / w_std (soft-anchor Task 3): forwarded straight to the v4
108
+ side's `ft2b._evaluate_stems_v4`/`hybrid_field_soft`'s own
109
+ weights (ignored when `hgb_std` is `None`). `1.0`/`1.0` (the
110
+ defaults) reproduce `hybrid_field_soft`'s own default weighting.
111
+ device: "cuda"/"cpu"; default picks cuda if available.
112
+
113
+ Returns: `{"rows": [{"stem", "geometry_id", "alloy_family",
114
+ "v4": {"mae", "hotspot", "std_ratio"}, "sp1": {...}}, ...],
115
+ "v4": {...aggregate...}, "sp1": {...aggregate...}, "n_stems",
116
+ "n_d_stems_in_manifest", "verdict": {"mae_ok", "hotspot_ok"[, "note"]},
117
+ "recomposition"}`
118
+ where `mae_ok = v4.mae <= sp1.mae` and `hotspot_ok = v4.hotspot >=
119
+ sp1.hotspot` (both computed on the node-weighted aggregates,
120
+ mirroring `evaluate_ft2b`'s `_overall_metrics`) -- EXCEPT when
121
+ `n_stems == 0`, which forces BOTH verdict fields to `False` (an
122
+ empty comparison is not evidence of no-regression: `0.0 <= 0.0` and
123
+ `0.0 >= 0.0` would otherwise vacuously pass) and adds a `note`
124
+ explaining why, mentioning `n_stems`/`n_d_stems_in_manifest`.
125
+ `n_d_stems_in_manifest` is the raw count of `dataset=="D"` manifest
126
+ rows (cache present or not), for skip-visibility -- `n_stems` (<=
127
+ it) is how many of those actually had a `features_dir` graph cache
128
+ and were compared.
129
+ """
130
+ device = device or ("cuda" if torch.cuda.is_available() else "cpu")
131
+ manifest_dir = osp.dirname(osp.abspath(manifest)) or "."
132
+ manifest_rows = _load_manifest_rows(manifest)
133
+ stems, n_d_in_manifest = _d_stems(manifest_rows, features_dir)
134
+
135
+ # -- v4 hybrid (GNN shape, quantile-anchor GBM scale -- or, when `hgb_std`
136
+ # is given, the soft-anchor recomposition) -- exactly `evaluate_ft2b`'s
137
+ # own per-stem composition, reused verbatim.
138
+ v4_model, v4_cfg, v4_norm = ft2b.load_ft2b_gnn(v4_ckpt, device)
139
+ v4_gbm_model = joblib.load(v4_gbm) if isinstance(v4_gbm, (str, os.PathLike)) else v4_gbm
140
+ hgb_std_model = joblib.load(hgb_std) if isinstance(hgb_std, (str, os.PathLike)) else hgb_std
141
+ v4_records = ft2b._evaluate_stems_v4(v4_model, v4_cfg, v4_norm, v4_gbm_model, stems,
142
+ manifest_rows, manifest_dir, features_dir, device,
143
+ hgb_std=hgb_std_model, lambda_shrink=lambda_shrink,
144
+ w_total=w_total, w_std=w_std)
145
+ v4_by_stem = {r["stem"]: r for r in v4_records}
146
+
147
+ # -- SP1 hybrid (GNN shape, mean-anchor HGB scale) on the SAME v4-cache
148
+ # geometry, fed through SP1's own norm stats -- `_sp1_hybrid_predict_stem`'s
149
+ # own contract (see `evaluate_ft2b`'s `_no_regression_check` docstring for
150
+ # the full rationale/caveats of this "plug the v4 cache into the SP1
151
+ # stack" technique).
152
+ sp1_model, sp1_cfg, sp1_norm = ft2b.load_ft2b_gnn(sp1_ckpt, device)
153
+ hgb = joblib.load(sp1_hgb) if isinstance(sp1_hgb, (str, os.PathLike)) else sp1_hgb
154
+ hgb_norm = load_norm(baseline_features_dir)
155
+ sp1_by_stem = {}
156
+ for stem in stems:
157
+ y_true, hyb, pos_np = ft2b._sp1_hybrid_predict_stem(
158
+ stem, sp1_model, sp1_cfg, sp1_norm, hgb, hgb_norm, features_dir, device)
159
+ sp1_by_stem[stem] = {"y_true": y_true, "y_hybrid": hyb, "xyz": pos_np}
160
+
161
+ rows = []
162
+ for stem in stems:
163
+ v4r, sp1r = v4_by_stem[stem], sp1_by_stem[stem]
164
+ row = manifest_rows.get(stem, {})
165
+ rows.append({
166
+ "stem": stem,
167
+ "geometry_id": row.get("geometry_id") or "",
168
+ "alloy_family": v4r.get("family") or "unknown",
169
+ "v4": _stem_metrics(v4r["y_true"], v4r["y_hybrid"], v4r["xyz"]),
170
+ "sp1": _stem_metrics(sp1r["y_true"], sp1r["y_hybrid"], sp1r["xyz"]),
171
+ })
172
+
173
+ v4_agg = _aggregate_metrics([v4_by_stem[s] for s in stems])
174
+ sp1_agg = _aggregate_metrics([sp1_by_stem[s] for s in stems])
175
+
176
+ if stems:
177
+ verdict = {
178
+ "mae_ok": bool(v4_agg["mae"] <= sp1_agg["mae"]),
179
+ "hotspot_ok": bool(v4_agg["hotspot"] >= sp1_agg["hotspot"]),
180
+ }
181
+ else:
182
+ # An empty comparison must never read as a pass: with no records,
183
+ # both aggregates are all-zero, so a naive `0.0 <= 0.0` /
184
+ # `0.0 >= 0.0` would vacuously satisfy both gates. Force an
185
+ # explicit fail instead, with a note pointing at WHY (0 of
186
+ # n_d_in_manifest dataset=="D" manifest rows had a cached graph).
187
+ verdict = {
188
+ "mae_ok": False,
189
+ "hotspot_ok": False,
190
+ "note": (f"no D stems compared (n_stems=0 of "
191
+ f"n_d_stems_in_manifest={n_d_in_manifest} dataset==\"D\" "
192
+ "manifest rows have a features_dir graph cache) -- "
193
+ "vacuous run, verdict forced to fail rather than "
194
+ "trivially passing on empty aggregates"),
195
+ }
196
+
197
+ result = {
198
+ "rows": rows,
199
+ "v4": v4_agg,
200
+ "sp1": sp1_agg,
201
+ "n_stems": len(stems),
202
+ "n_d_stems_in_manifest": n_d_in_manifest,
203
+ "verdict": verdict,
204
+ "recomposition": "soft" if hgb_std_model is not None else "anchor",
205
+ }
206
+
207
+ if out_json:
208
+ out_dir = osp.dirname(out_json)
209
+ if out_dir:
210
+ os.makedirs(out_dir, exist_ok=True)
211
+ with open(out_json, "w") as f:
212
+ json.dump(result, f, indent=2)
213
+
214
+ return result
src/dataload.py ADDED
@@ -0,0 +1,28 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Load cached per-node features for a set of stems into flat matrices."""
2
+ import os.path as osp
3
+ import numpy as np
4
+
5
+
6
+ def load_norm(features_dir: str) -> dict:
7
+ d = np.load(osp.join(features_dir, "norm_stats.npz"))
8
+ return {"feat_mean": d["feat_mean"], "feat_std": d["feat_std"],
9
+ "y_log_mean": float(d["y_log_mean"]), "y_log_std": float(d["y_log_std"])}
10
+
11
+
12
+ def load_matrix(features_dir: str, stems, max_nodes_per_graph=None, seed=0):
13
+ rng = np.random.default_rng(seed)
14
+ Xs, ys, gids = [], [], []
15
+ for gi, stem in enumerate(stems):
16
+ d = np.load(osp.join(features_dir, "graphs", f"{stem}.npz"))
17
+ X, y = d["X"], d["y"]
18
+ if max_nodes_per_graph is not None and X.shape[0] > max_nodes_per_graph:
19
+ idx = rng.choice(X.shape[0], max_nodes_per_graph, replace=False)
20
+ X, y = X[idx], y[idx]
21
+ Xs.append(X)
22
+ ys.append(y)
23
+ gids.append(np.full(X.shape[0], gi, dtype=np.int64))
24
+ return (np.concatenate(Xs), np.concatenate(ys), np.concatenate(gids))
25
+
26
+
27
+ def standardize(X, norm) -> np.ndarray:
28
+ return (X - norm["feat_mean"]) / norm["feat_std"]
src/evaluate_ft2b.py ADDED
@@ -0,0 +1,630 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """FT2b Task 8: evaluation of the multi-alloy/multi-BC hybrid predictor.
2
+
3
+ Consumes a trained v4 checkpoint (FiLM, 15 node features, `g`-conditioned,
4
+ `node_in=15`) + the refit multi-alloy GBM scale-head (`src.gbm_ft2b`) and
5
+ reports, on `interp.test` AND `extrap.test`: per-`alloy_family` and per-
6
+ `bc_config` hybrid MAE (physical seconds), hot-spot top-10% overlap, and
7
+ `std_ratio = std(pred)/std(true)`; a no-regression comparison against the
8
+ SP1 hybrid on the all-sand subset; a Chvorinov-scaling sanity check; and a
9
+ variance-collapse flag.
10
+
11
+ === y / y_raw / t_ref inversion chain (read before touching this file) ===
12
+ `src/build_dataset.py` (`_finalize_row_stage2`, `write_record_v4`) writes,
13
+ per v4 graph cache:
14
+ y = t (PHYSICAL seconds, per-node solidification time)
15
+ y_raw = t (same array, duplicate key for API clarity)
16
+ y_nondim = t / t_ref (t_ref = C_family * (V/A)^2, Chvorinov reference)
17
+ t_ref = C_family * (V/A)^2 (scalar, physical seconds)
18
+ `build_v4`'s norm_stats.npz computes `y_log_mean`/`y_log_std` from
19
+ `log1p(rec["y"])` i.e. `log1p(t)` -- log1p of the PHYSICAL target, exactly
20
+ the same convention `features_v2`/`features_v3` use. `SolidGraphDataset`
21
+ (`src/gnn_dataset.py`) trains on `standardize(log1p(d["y"]))` i.e.
22
+ standardized log1p(PHYSICAL seconds) -- NOT the non-dim `y_nondim` field.
23
+ Therefore the GNN's raw output, once unstandardized and expm1'd, is ALREADY
24
+ physical seconds -- the exact same inversion `src.evaluate_gnn.
25
+ predict_seconds_gnn` performs for SP1. No `t_ref` multiplication is needed
26
+ to get physical seconds out of the v4 GNN. `y_nondim`/`t_ref`/`g` are real,
27
+ separately-stored cache fields used elsewhere (the Chvorinov sanity check
28
+ below reads `t_ref`; `g` feeds FiLM) -- they are NOT an alternate target
29
+ space the model was trained on.
30
+
31
+ === v4 hybrid recomposition: QUANTILE-anchor (q=0.995), not MEAN-anchor ===
32
+ `src.hybrid.hybrid_field(gnn, baseline)` (SP1's convention) re-anchors to the
33
+ baseline's MEAN: `pred = gnn - mean(gnn) + mean(baseline)`. That is correct
34
+ for SP1 because SP1's HGB baseline predicts a genuine PER-NODE value, so
35
+ `mean(baseline)` is a real per-graph mean estimate. The FT2b GBM
36
+ (`src.gbm_ft2b`) is different by design (Task 6): it predicts a single
37
+ per-graph SCALAR, `log1p(total/max solidification time)` -- see its module
38
+ docstring, "Target: per-graph `log1p(y_raw.max())`". Broadcasting that
39
+ scalar to every node and then MEAN-anchoring would force
40
+ `mean(pred) == gbm_total`, inflating every node by
41
+ `(true_max - true_mean)` -- confirmed in review to blow up MAE by ~60x on an
42
+ otherwise near-perfect GNN. `_max_anchor_hybrid` (an alias of
43
+ `src.hybrid.hybrid_field_max_anchor`, the single source -- see that
44
+ function's docstring) anchors to a high QUANTILE of the GNN field instead
45
+ (default `q=0.995`; `q=1.0` reproduces the original exact-MAX behavior
46
+ bit-for-bit): `pred_i = gnn_i - anchor + gbm_total` where
47
+ `anchor = np.quantile(gnn, q)`, which makes `max(pred) ~= gbm_total` (the
48
+ quantity the GBM actually predicts) while still preserving the GNN's field
49
+ shape (its deviations from that anchor). The anchor moved off the exact
50
+ max (Task 7) because it is fragile to a handful of outlier nodes -- a
51
+ promotion-gate smoke test found a few inflated GNN log-space predictions
52
+ on a real casting dragging ~90% of the field below the zero clip, since the
53
+ single max node alone set the whole graph's additive offset; anchoring to
54
+ the 99.5th percentile instead is robust to that. This function is used for
55
+ EVERY v4 hybrid composition in this module (`_evaluate_stems_v4`, and
56
+ therefore every per-family/per-bc metric, the v4 side of `no_regression`,
57
+ and `chvorinov`'s `pred_total`). The SP1 side of `no_regression`
58
+ (`_sp1_hybrid_predict_stem`) deliberately keeps `src.hybrid.hybrid_field`'s
59
+ MEAN-anchor unchanged -- it is SP1's own genuine per-node HGB baseline, not
60
+ a broadcast scalar, so the MEAN-anchor is correct there.
61
+ """
62
+ from __future__ import annotations
63
+
64
+ import json
65
+ import os
66
+ import os.path as osp
67
+ from collections import defaultdict
68
+
69
+ import joblib
70
+ import numpy as np
71
+ import torch
72
+
73
+ from src import metrics
74
+ from src.baseline import predict_seconds
75
+ from src.dataload import load_norm, standardize
76
+ from src.evaluate_gnn import predict_seconds_gnn
77
+ from src.gbm_ft2b import (_load_manifest_rows, _row_features, predict_std_seconds,
78
+ std_feature_row)
79
+ from src.graph_build import (edge_attr, knn_edge_index, union_edge_attr,
80
+ union_edge_index)
81
+ from src.hybrid import hybrid_field, hybrid_field_soft
82
+ from src.hybrid import hybrid_field_max_anchor as _max_anchor_hybrid
83
+ from src.model_gnn import SolidGNN
84
+
85
+ # The HGB v2/SP1 baseline was trained on the 9 v2 features = first 9 columns
86
+ # of any newer feature version (ordering contract, SP1 spec §3). The SP1
87
+ # production GNN was trained on the 13 v3 features = first 13 columns of the
88
+ # v4 cache's X (`src/features.py`: "v4: v3's 13 columns UNCHANGED").
89
+ N_V2_FEATURES = 9
90
+ N_V3_FEATURES = 13
91
+
92
+ DEFAULT_OUT_PATH = "runs/ft2b_stage3/eval.json"
93
+ DEFAULT_SP1_CKPT = "runs/sp1_run3_final/best_gnn.pt"
94
+ DEFAULT_SP1_HGB = "runs/baseline_v2/hgb_A.joblib"
95
+ DEFAULT_BASELINE_FEATURES_DIR = "data/features_v2"
96
+
97
+
98
+ def hotspot_overlap(y_true, y_pred, xyz, graph_id, top_frac: float = 0.10,
99
+ tol_mm: float = 5.0) -> float:
100
+ """Top-`top_frac` latest-solidifying-node overlap, within `tol_mm`.
101
+
102
+ Thin, deliberately-not-reimplemented wrapper over
103
+ `src.metrics.hotspot_accuracy` -- the README-documented hot-spot metric
104
+ ("overlap of predicted vs. true top-10% latest-solidifying nodes, within
105
+ a tolerance"). Exposed under this module's own name so callers of
106
+ `evaluate_ft2b` don't need to reach into `src.metrics` directly.
107
+ """
108
+ return metrics.hotspot_accuracy(y_true, y_pred, xyz, graph_id,
109
+ top_frac=top_frac, tol_mm=tol_mm)
110
+
111
+
112
+ # --------------------------------------------------------------------------- #
113
+ # GNN loading / prediction (v4-aware: film + g)
114
+ # --------------------------------------------------------------------------- #
115
+ def load_ft2b_gnn(ckpt, device):
116
+ """Load a `{model, cfg, norm}` checkpoint into a `SolidGNN` (film-aware).
117
+
118
+ `ckpt` may be a path (loaded via `torch.load`) or an already-loaded
119
+ checkpoint dict -- convenient for tests that build a ckpt in-process.
120
+
121
+ Learned-heads Task 3: `cfg.get("deriv_heads")` truthy builds the model
122
+ with `deriv_heads=True` (the `SolidGNN` gains `grad_head`/`dtdt_head`,
123
+ see `src/model_gnn.py`) before the strict `load_state_dict` below -- a
124
+ heads checkpoint's saved `state["model"]` already carries the two heads'
125
+ weights (`src/train_heads.py` saves the full `model.state_dict()` of a
126
+ `deriv_heads=True` model), so this is still a plain strict load, not
127
+ `attach_deriv_heads` (that helper is only for loading a HEADLESS
128
+ state_dict into a heads-shaped model, e.g. inside the trainer). A
129
+ headless checkpoint (`cfg.get("deriv_heads")` absent/falsy, the default)
130
+ builds `deriv_heads=False` exactly as before -- byte-identical path.
131
+ """
132
+ state = torch.load(ckpt, map_location=device, weights_only=False) \
133
+ if isinstance(ckpt, (str, os.PathLike)) else ckpt
134
+ cfg, norm = state["cfg"], state["norm"]
135
+ model = SolidGNN(node_in=cfg.get("node_in", 15), edge_in=cfg.get("edge_in", 4),
136
+ hidden=cfg["hidden"], n_blocks=cfg["n_blocks"],
137
+ two_head=cfg.get("two_head", False),
138
+ film=cfg.get("film", False), g_dim=cfg.get("g_dim", 5),
139
+ deriv_heads=cfg.get("deriv_heads", False)).to(device)
140
+ model.load_state_dict(state["model"])
141
+ model.eval()
142
+ return model, cfg, norm
143
+
144
+
145
+ def _build_edges(pos, cfg, mesh_edges=None):
146
+ if cfg.get("graph", "knn") == "union":
147
+ if mesh_edges is None:
148
+ raise ValueError("checkpoint expects a union graph but mesh_edges is missing")
149
+ ei, is_mesh = union_edge_index(pos, mesh_edges, k=cfg["k"])
150
+ return ei, union_edge_attr(pos, ei, is_mesh)
151
+ ei = knn_edge_index(pos, k=cfg["k"])
152
+ return ei, edge_attr(pos, ei)
153
+
154
+
155
+ def _run_model(model, x, ei, ea, zeros, g, device):
156
+ """Shared forward-pass plumbing for `predict_graph_seconds`/
157
+ `predict_graph_derivs` (learned-heads Task 3): moves the already-built
158
+ tensors to `device` and calls the model once. Returns whatever tuple the
159
+ model itself returns -- 2-tuple `(y_nodal, y_global)` for a headless
160
+ model, 3-tuple `(y_nodal, y_global, deriv)` for a `deriv_heads=True` one
161
+ -- so callers must index (`out[0]`), never unpack-by-position, to stay
162
+ correct for both."""
163
+ with torch.no_grad():
164
+ return model(x.to(device), ei.to(device), ea.to(device), zeros, g=g)
165
+
166
+
167
+ def _prepare_graph_inputs(cfg, norm, X, pos_np, mesh_edges, g_vec, device):
168
+ """Shared tensor-building for `predict_graph_seconds`/`predict_graph_derivs`
169
+ (learned-heads Task 3): standardizes `X`, builds the edge set per `cfg`,
170
+ and the FiLM `g` vector (if applicable). Returns `(x, ei, ea, zeros, g)`,
171
+ all ready to feed straight into `_run_model`."""
172
+ fmean = torch.tensor(norm["feat_mean"], dtype=torch.float32)
173
+ fstd = torch.tensor(norm["feat_std"], dtype=torch.float32)
174
+ x = (torch.tensor(X, dtype=torch.float32) - fmean) / fstd
175
+ pos = torch.tensor(pos_np, dtype=torch.float32)
176
+ me = torch.tensor(mesh_edges, dtype=torch.long) if mesh_edges is not None else None
177
+ ei, ea = _build_edges(pos, cfg, me)
178
+ zeros = torch.zeros(x.shape[0], dtype=torch.long, device=device)
179
+ g = None
180
+ if cfg.get("film", False) and g_vec is not None:
181
+ g = torch.tensor(np.asarray(g_vec), dtype=torch.float32).reshape(1, -1).to(device)
182
+ return x, ei, ea, zeros, g
183
+
184
+
185
+ def predict_graph_seconds(model, cfg, norm, X, pos_np, mesh_edges=None, g_vec=None,
186
+ device="cpu") -> np.ndarray:
187
+ """One graph's per-node hybrid-shape input -> physical-seconds GNN prediction.
188
+
189
+ `g_vec`: the cache's raw (5,) alloy/mould vector, forwarded un-standardized
190
+ (mirrors `SolidGraphDataset`, which does not standardize `g`) -- only used
191
+ when `cfg["film"]` is set.
192
+
193
+ Learned-heads Task 3: `model` may be a `deriv_heads=True` `SolidGNN`
194
+ (forward returns a 3-tuple `(y_nodal, y_global, deriv)` instead of the
195
+ headless 2-tuple) -- this function's OWN return contract is unchanged
196
+ either way (always just the `(N,)` physical-seconds array from
197
+ `y_nodal`); the extra `deriv` output (if any) is simply ignored here.
198
+ Use the sibling `predict_graph_derivs` to get the derivative heads'
199
+ predictions.
200
+ """
201
+ x, ei, ea, zeros, g = _prepare_graph_inputs(cfg, norm, X, pos_np, mesh_edges, g_vec, device)
202
+ out = _run_model(model, x, ei, ea, zeros, g, device)
203
+ yn = out[0]
204
+ return predict_seconds_gnn(yn.cpu().numpy(), norm)
205
+
206
+
207
+ def predict_graph_derivs(model, cfg, norm, X, pos_np, mesh_edges=None, g_vec=None,
208
+ device="cpu"):
209
+ """One graph's per-node hybrid-shape input -> physical-units learned
210
+ gradT (K/m) / dTdt (K/s), the two `deriv_heads` outputs (learned-heads
211
+ Task 3). Sibling of `predict_graph_seconds`, sharing its exact tensor
212
+ construction (standardized `X`, `cfg`-selected edge set, FiLM `g`) --
213
+ only the output side differs.
214
+
215
+ `model` MUST be a `deriv_heads=True` SolidGNN (forward returns a 3-tuple
216
+ `(y_nodal, y_global, deriv)`, `deriv` shape `(N, 2)` = standardized
217
+ `[log1p(gradT), log1p(|dTdt|)]`) -- a headless (2-tuple) model raises a
218
+ loud `ValueError` naming the mismatch rather than crashing on a tuple-
219
+ index error deeper in the call, or silently returning garbage.
220
+
221
+ `norm` must carry `deriv_log_mean`/`deriv_log_std` (each shape `(2,)`,
222
+ written by `src.train_heads.train_deriv_heads` into the checkpoint's
223
+ `norm` dict) -- the per-head log-space standardization stats the model
224
+ was trained against.
225
+
226
+ Inverse transform (pinned exactly, mirrors `predict_seconds_gnn`'s own
227
+ `pred_log = pred_std_log * std + mean; expm1`, applied per-head):
228
+ `deriv_std * z + deriv_mean -> expm1 -> clip(min=0)`. Both outputs are
229
+ non-negative by construction: `gradT` is a gradient MAGNITUDE (never
230
+ signed), and `dTdt` is returned as a positive magnitude in K/s -- this
231
+ matches the cache's own `dTdt` convention (`tools/doe/crossing_capture.py`
232
+ stores `dTdt_at = (T_prev - T_now) / dt`, i.e. a positive cooling rate by
233
+ construction on a monotonically-cooling casting; `src.train_heads`'s
234
+ `DerivGraphDataset` additionally takes `.abs()` defensively before
235
+ log-standardizing) -- and `src.shrinkage.niyama_exact` itself also takes
236
+ `np.abs(dTdt_K_per_s)` internally, so this function's non-negative
237
+ `dTdt` is compatible either way.
238
+
239
+ Returns `(gradT_K_per_m, dTdt_K_per_s)`, each an `(N,)` float64 array.
240
+ """
241
+ x, ei, ea, zeros, g = _prepare_graph_inputs(cfg, norm, X, pos_np, mesh_edges, g_vec, device)
242
+ out = _run_model(model, x, ei, ea, zeros, g, device)
243
+ if len(out) != 3:
244
+ raise ValueError(
245
+ "predict_graph_derivs: model did not return a deriv-heads output "
246
+ f"(expected a 3-tuple (y_nodal, y_global, deriv), got a "
247
+ f"{len(out)}-tuple) -- pass a deriv_heads=True SolidGNN / a heads "
248
+ "checkpoint (cfg['deriv_heads'] truthy, loaded via load_ft2b_gnn)")
249
+ deriv = out[2].cpu().numpy().astype(np.float64)
250
+ mean = np.asarray(norm["deriv_log_mean"], dtype=np.float64)
251
+ std = np.asarray(norm["deriv_log_std"], dtype=np.float64)
252
+ log_vals = deriv * std + mean
253
+ vals = np.clip(np.expm1(log_vals), 0.0, None)
254
+ return vals[:, 0], vals[:, 1]
255
+
256
+
257
+ # --------------------------------------------------------------------------- #
258
+ # Per-stem v4 hybrid prediction (GNN shape x GBM total-time scale)
259
+ # --------------------------------------------------------------------------- #
260
+ def _gbm_seconds_for_stem(gbm, stem, manifest_rows, manifest_dir, features_dir) -> float:
261
+ x_row, _y_log_true = _row_features(stem, manifest_rows, manifest_dir, features_dir)
262
+ log_pred = float(gbm.predict(x_row.reshape(1, -1))[0])
263
+ return float(np.clip(np.expm1(log_pred), 0.0, None))
264
+
265
+
266
+ def _evaluate_stems_v4(model, cfg, norm, gbm, stems, manifest_rows, manifest_dir,
267
+ features_dir, device, hgb_std=None, lambda_shrink: float = 1.0,
268
+ w_total: float = 1.0, w_std: float = 1.0
269
+ ) -> list[dict]:
270
+ """One record per stem: per-node y_true/y_hybrid/xyz + per-stem metadata
271
+ (alloy_family, bc_config, t_ref, predicted total time).
272
+
273
+ `hgb_std` (variance-recomposition Task 3): an optional loaded GBM
274
+ std-head model (`src.gbm_ft2b.refit_gbm_std`'s output). `None` (the
275
+ default) is byte-identical to before this argument existed --
276
+ `_max_anchor_hybrid` (quantile-anchor). When given, each stem's row
277
+ features are rebuilt via `src.gbm_ft2b.std_feature_row` (soft-anchor
278
+ Task 3: width-dispatched off `hgb_std`'s own `n_features_in_`, 16 or 17
279
+ -- a SEPARATE call from the one `_gbm_seconds_for_stem` makes internally
280
+ for the total, kept separate, rather than refactored to share one call,
281
+ so existing tests that monkeypatch `_gbm_seconds_for_stem` wholesale
282
+ keep working unmodified) and recomposition uses
283
+ `src.hybrid.hybrid_field_soft` (soft-anchor Task 3; was
284
+ `hybrid_field_affine`) instead; each such record also carries
285
+ `recomposition_info` (that function's `info` dict, now carrying
286
+ `"m"`/`"target_unreachable"`/... in place of affine's `"a_clamped"`).
287
+
288
+ `lambda_shrink` (variance-recomposition amendment 2): forwarded straight
289
+ to `hybrid_field_soft` (ignored when `hgb_std` is `None`, same as every
290
+ other soft-only knob here). `1.0` (the default) is byte-identical to
291
+ before this argument existed.
292
+
293
+ `w_total`/`w_std` (soft-anchor Task 3): forwarded straight to
294
+ `hybrid_field_soft`'s own weights (ignored when `hgb_std` is `None`).
295
+ `1.0`/`1.0` (the defaults) reproduce `hybrid_field_soft`'s own default
296
+ weighting."""
297
+ records = []
298
+ for stem in stems:
299
+ d = np.load(osp.join(features_dir, "graphs", f"{stem}.npz"))
300
+ X, pos_np = d["X"], d["pos"]
301
+ y_true = d["y_raw"] if "y_raw" in d.files else d["y"]
302
+ mesh_edges = d["mesh_edges"] if "mesh_edges" in d.files else None
303
+ g_vec = d["g"] if "g" in d.files else None
304
+ gnn_seconds = predict_graph_seconds(model, cfg, norm, X, pos_np, mesh_edges,
305
+ g_vec, device=device)
306
+ gbm_seconds = _gbm_seconds_for_stem(gbm, stem, manifest_rows, manifest_dir,
307
+ features_dir)
308
+ recomp_info = None
309
+ if hgb_std is not None:
310
+ n_std_features = int(getattr(hgb_std, "n_features_in_", 16))
311
+ x_row = std_feature_row(stem, manifest_rows, manifest_dir, features_dir,
312
+ n_std_features)
313
+ std_seconds = predict_std_seconds(hgb_std, x_row)
314
+ hyb, recomp_info = hybrid_field_soft(gnn_seconds, gbm_seconds, std_seconds,
315
+ lambda_shrink=lambda_shrink,
316
+ w_total=w_total, w_std=w_std)
317
+ else:
318
+ hyb = _max_anchor_hybrid(gnn_seconds, gbm_seconds)
319
+ row = manifest_rows.get(stem, {})
320
+ t_ref = float(d["t_ref"]) if "t_ref" in d.files else None
321
+ rec = {
322
+ "stem": stem,
323
+ "family": row.get("alloy_family") or "unknown",
324
+ "bc": row.get("bc_config") or "none",
325
+ "dataset": row.get("dataset") or "",
326
+ "y_true": y_true, "y_hybrid": hyb, "xyz": pos_np,
327
+ "gbm_seconds": gbm_seconds, "t_ref": t_ref,
328
+ "pred_total": float(np.max(hyb)),
329
+ }
330
+ if recomp_info is not None:
331
+ rec["recomposition_info"] = recomp_info
332
+ records.append(rec)
333
+ return records
334
+
335
+
336
+ def _group_metrics(records, key: str) -> dict:
337
+ groups = defaultdict(list)
338
+ for r in records:
339
+ groups[r[key]].append(r)
340
+ out = {}
341
+ for gval, recs in groups.items():
342
+ y = np.concatenate([r["y_true"] for r in recs])
343
+ p = np.concatenate([r["y_hybrid"] for r in recs])
344
+ xyz = np.concatenate([r["xyz"] for r in recs])
345
+ gid = np.concatenate([np.full(r["y_true"].shape[0], i, dtype=np.int64)
346
+ for i, r in enumerate(recs)])
347
+ out[str(gval)] = {
348
+ "n_stems": len(recs),
349
+ "n_nodes": int(y.shape[0]),
350
+ "mae": metrics.mae(y, p),
351
+ "hotspot_overlap": hotspot_overlap(y, p, xyz, gid),
352
+ "std_ratio": metrics.pred_std_ratio(y, p, gid),
353
+ }
354
+ return out
355
+
356
+
357
+ def _overall_metrics(records) -> dict:
358
+ if not records:
359
+ return {"n_stems": 0, "n_nodes": 0, "mae": 0.0, "hotspot_overlap": 0.0,
360
+ "std_ratio": 0.0}
361
+ y = np.concatenate([r["y_true"] for r in records])
362
+ p = np.concatenate([r["y_hybrid"] for r in records])
363
+ xyz = np.concatenate([r["xyz"] for r in records])
364
+ gid = np.concatenate([np.full(r["y_true"].shape[0], i, dtype=np.int64)
365
+ for i, r in enumerate(records)])
366
+ return {
367
+ "n_stems": len(records), "n_nodes": int(y.shape[0]),
368
+ "mae": metrics.mae(y, p),
369
+ "hotspot_overlap": hotspot_overlap(y, p, xyz, gid),
370
+ "std_ratio": metrics.pred_std_ratio(y, p, gid),
371
+ }
372
+
373
+
374
+ # --------------------------------------------------------------------------- #
375
+ # No-regression check vs the SP1 hybrid
376
+ # --------------------------------------------------------------------------- #
377
+ # DESIGN CHOICE (documented per Task 8 brief): the no-regression subset is
378
+ # manifest dataset=="A" (all-sand, no BC variation -- the closest analog to
379
+ # the OLD single-alloy regime SP1 was trained/validated on) intersected with
380
+ # the union of interp.test/extrap.test. SP1 itself cannot be run on this
381
+ # subset "as originally trained" (SP1 has no v4 cache -- its own
382
+ # `data/features_v3` cache is a DIFFERENT, single-alloy dataset with
383
+ # different geometries/splits). Instead we run the SP1 GNN + SP1 HGB THE SAME
384
+ # WAY they are normally run (`src.evaluate_gnn.evaluate`): standardize with
385
+ # SP1's OWN norm stats and feed it whatever 13/9-column feature vector it's
386
+ # given. Since `FEATURE_NAMES_V4 == FEATURE_NAMES_V3 + [2 BC cols]` and
387
+ # `FEATURE_NAMES_V3 == FEATURE_NAMES + [4 local-modulus/geodesic cols]`
388
+ # (`src/features.py`), `v4_X[:, :13]` IS the v3 feature vector and
389
+ # `v4_X[:, :9]` IS the v2 feature vector for the SAME geometry -- so this is
390
+ # a legitimate "plug the v4 cache's geometry into the SP1 stack" comparison,
391
+ # not an apples-to-oranges one. CAVEATS: (1) the v4 A-subset's alloy
392
+ # properties vary (synthetic LHS-sampled alloys, not the single fixed alloy
393
+ # SP1's target distribution assumes) -- SP1 has no way to know this, so its
394
+ # scale prediction (HGB) is evaluated fully out-of-its-training-distribution
395
+ # here; a "regression" in this comparison could reflect the alloy variety
396
+ # alone, not the FT2b GNN's shape quality. (2) mesh/graph identity is
397
+ # guaranteed (same X/pos/mesh_edges array), so any MAE delta is NOT a
398
+ # geometry-mismatch artifact. (3) small subset sizes (a handful of A-rows in
399
+ # the actual test splits) make this a noisy signal, not a hard gate by
400
+ # itself -- read alongside per-family/per-bc numbers above.
401
+ def _no_regression_subset(splits_d: dict, manifest_rows: dict) -> list[str]:
402
+ test_stems = set(splits_d["interp"]["test"]) | set(splits_d["extrap"]["test"])
403
+ return sorted(s for s in test_stems if manifest_rows.get(s, {}).get("dataset") == "A")
404
+
405
+
406
+ def _sp1_hybrid_predict_stem(stem, sp1_model, sp1_cfg, sp1_norm, hgb, hgb_norm,
407
+ features_dir, device):
408
+ d = np.load(osp.join(features_dir, "graphs", f"{stem}.npz"))
409
+ X, pos_np = d["X"], d["pos"]
410
+ y_true = d["y_raw"] if "y_raw" in d.files else d["y"]
411
+ mesh_edges = d["mesh_edges"] if "mesh_edges" in d.files else None
412
+ X13 = X[:, :N_V3_FEATURES]
413
+ gnn_seconds = predict_graph_seconds(sp1_model, sp1_cfg, sp1_norm, X13, pos_np,
414
+ mesh_edges, g_vec=None, device=device)
415
+ Xb = standardize(X[:, :N_V2_FEATURES], hgb_norm)
416
+ baseline_seconds = predict_seconds(hgb, Xb, hgb_norm)
417
+ hyb = hybrid_field(gnn_seconds, baseline_seconds)
418
+ return y_true, hyb, pos_np
419
+
420
+
421
+ def _no_regression_check(splits_d, manifest_rows, features_dir, v4_records_by_stem,
422
+ sp1_ckpt, sp1_hgb, baseline_features_dir, device,
423
+ tolerance: float = 1.15) -> dict:
424
+ subset = _no_regression_subset(splits_d, manifest_rows)
425
+ note = ("subset = manifest dataset=='A' (all-sand) stems present in "
426
+ "interp.test | extrap.test; SP1 hybrid run on the SAME v4-cache "
427
+ "geometry via v4_X[:, :13] (v3 features) / v4_X[:, :9] (v2 "
428
+ "features) fed through SP1's own norm stats -- see this module's "
429
+ "`_no_regression_subset`/`_no_regression_check` docstring for the "
430
+ "full design rationale + caveats (alloy variety is OOD for SP1's "
431
+ "HGB; small-subset noise).")
432
+ if not subset:
433
+ zero = {"n_stems": 0, "mae": 0.0, "hotspot_overlap": 0.0, "std_ratio": 0.0}
434
+ return {"n_stems": 0, "v4_hybrid": zero, "sp1_hybrid": dict(zero),
435
+ "mae_ratio": 0.0, "regression_flag": False,
436
+ "note": note + " EMPTY SUBSET: no dataset=='A' test stems found."}
437
+
438
+ v4_recs = [v4_records_by_stem[s] for s in subset if s in v4_records_by_stem]
439
+ v4_metrics = _overall_metrics(v4_recs)
440
+
441
+ sp1_model, sp1_cfg, sp1_norm = load_ft2b_gnn(sp1_ckpt, device)
442
+ hgb = joblib.load(sp1_hgb) if isinstance(sp1_hgb, (str, os.PathLike)) else sp1_hgb
443
+ hgb_norm = load_norm(baseline_features_dir)
444
+
445
+ ys, ps, xyzs = [], [], []
446
+ for stem in subset:
447
+ y_true, hyb, pos_np = _sp1_hybrid_predict_stem(
448
+ stem, sp1_model, sp1_cfg, sp1_norm, hgb, hgb_norm, features_dir, device)
449
+ ys.append(y_true); ps.append(hyb); xyzs.append(pos_np)
450
+ y = np.concatenate(ys); p = np.concatenate(ps); xyz = np.concatenate(xyzs)
451
+ gid = np.concatenate([np.full(ys[i].shape[0], i, dtype=np.int64) for i in range(len(ys))])
452
+ sp1_metrics = {
453
+ "n_stems": len(subset), "n_nodes": int(y.shape[0]),
454
+ "mae": metrics.mae(y, p),
455
+ "hotspot_overlap": hotspot_overlap(y, p, xyz, gid),
456
+ "std_ratio": metrics.pred_std_ratio(y, p, gid),
457
+ }
458
+
459
+ mae_ratio = (v4_metrics["mae"] / sp1_metrics["mae"]) if sp1_metrics["mae"] > 1e-9 else 0.0
460
+ return {
461
+ "n_stems": len(subset),
462
+ "v4_hybrid": v4_metrics,
463
+ "sp1_hybrid": sp1_metrics,
464
+ "mae_ratio": float(mae_ratio),
465
+ "regression_flag": bool(v4_metrics["mae"] > sp1_metrics["mae"] * tolerance),
466
+ "note": note,
467
+ }
468
+
469
+
470
+ # --------------------------------------------------------------------------- #
471
+ # Chvorinov-scaling sanity check
472
+ # --------------------------------------------------------------------------- #
473
+ def _linfit(x: np.ndarray, y: np.ndarray) -> dict:
474
+ """Least-squares y = slope*x + intercept + R^2. Always finite: falls
475
+ back to a degenerate-but-finite {0,0,0} fit when there are fewer than 2
476
+ points or `x` is (numerically) constant, rather than raising or NaN'ing."""
477
+ n = len(x)
478
+ if n < 2 or np.ptp(x) < 1e-9:
479
+ return {"n": int(n), "slope": 0.0, "intercept": 0.0, "r2": 0.0}
480
+ slope, intercept = np.polyfit(x, y, 1)
481
+ y_hat = slope * x + intercept
482
+ ss_res = float(np.sum((y - y_hat) ** 2))
483
+ ss_tot = float(np.sum((y - np.mean(y)) ** 2))
484
+ r2 = 1.0 - ss_res / ss_tot if ss_tot > 1e-12 else 0.0
485
+ return {"n": int(n), "slope": float(slope), "intercept": float(intercept), "r2": float(r2)}
486
+
487
+
488
+ def _chvorinov_check(records: list[dict]) -> dict:
489
+ """Regress the model's predicted total solidification time (`pred_total`
490
+ = max of the per-node hybrid field, per stem) against each stem's cached
491
+ analytic Chvorinov reference `t_ref = C_family * (V/A)^2`
492
+ (`src/build_dataset.py::_finalize_row_stage2`). A clean per-family fit
493
+ (slope near 1, high R^2) means the scale head still tracks Chvorinov
494
+ scaling after fine-tuning; reported per family AND overall."""
495
+ usable = [r for r in records if r["t_ref"] is not None and np.isfinite(r["t_ref"])]
496
+ groups = defaultdict(list)
497
+ for r in usable:
498
+ groups[r["family"]].append(r)
499
+ out = {}
500
+ for fam, recs in groups.items():
501
+ x = np.array([r["t_ref"] for r in recs], dtype=np.float64)
502
+ y = np.array([r["pred_total"] for r in recs], dtype=np.float64)
503
+ out[str(fam)] = _linfit(x, y)
504
+ x_all = np.array([r["t_ref"] for r in usable], dtype=np.float64)
505
+ y_all = np.array([r["pred_total"] for r in usable], dtype=np.float64)
506
+ out["overall"] = _linfit(x_all, y_all)
507
+ return out
508
+
509
+
510
+ # --------------------------------------------------------------------------- #
511
+ # Top-level entry point
512
+ # --------------------------------------------------------------------------- #
513
+ def _load_splits(splits) -> dict:
514
+ if isinstance(splits, (str, os.PathLike)):
515
+ with open(splits) as f:
516
+ return json.load(f)
517
+ return splits
518
+
519
+
520
+ def evaluate_ft2b(ckpt, gbm, features_dir, manifest, splits,
521
+ sp1_ckpt=DEFAULT_SP1_CKPT, sp1_hgb=DEFAULT_SP1_HGB,
522
+ baseline_features_dir=DEFAULT_BASELINE_FEATURES_DIR,
523
+ out_path=DEFAULT_OUT_PATH,
524
+ variance_collapse_threshold: float = 0.9,
525
+ no_regression_tolerance: float = 1.15,
526
+ hgb_std=None,
527
+ lambda_shrink: float = 1.0,
528
+ w_total: float = 1.0,
529
+ w_std: float = 1.0,
530
+ device=None) -> dict:
531
+ """Evaluate a trained FT2b v4 checkpoint + refit GBM hybrid predictor.
532
+
533
+ Args:
534
+ ckpt: path to the v4 GNN checkpoint (`{model, cfg, norm}`), or an
535
+ already-loaded checkpoint dict.
536
+ gbm: path to the refit multi-alloy GBM (`src.gbm_ft2b.refit_gbm`'s
537
+ `out_path`), or an already-loaded estimator.
538
+ features_dir: v4 feature cache directory (`graphs/<stem>.npz`).
539
+ manifest: path to the DOE manifest CSV (`run_id`==stem; needs
540
+ `dataset`/`alloy_family`/`bc_config`/`material_id`/
541
+ `mold_effusivity` columns).
542
+ splits: path to `features_dir/splits.json`-shaped JSON, or an
543
+ already-loaded `{"interp": {"train","test"}, "extrap":
544
+ {"train","test"}}` dict.
545
+ sp1_ckpt / sp1_hgb / baseline_features_dir: the SP1 production
546
+ hybrid stack, for the no-regression comparison.
547
+ out_path: where to write the result as JSON (set `None`/falsy to
548
+ skip writing).
549
+ variance_collapse_threshold: any per-family/per-bc `std_ratio` below
550
+ this trips `variance_collapse_flag`.
551
+ no_regression_tolerance: the v4 hybrid may be at most this factor
552
+ worse (MAE) than the SP1 hybrid on the all-sand subset before
553
+ `no_regression.regression_flag` trips.
554
+ hgb_std (variance-recomposition Task 3): an optional path to (or
555
+ already-loaded) GBM std-head model
556
+ (`src.gbm_ft2b.refit_gbm_std`'s output). `None` (the default)
557
+ keeps every v4 hybrid composition in this module on the
558
+ quantile-anchor (`src.hybrid.hybrid_field_max_anchor`) --
559
+ byte-identical to before this argument existed. When given,
560
+ every v4 composition (`_evaluate_stems_v4`, and therefore every
561
+ per-family/per-bc metric, the v4 side of `no_regression`, and
562
+ `chvorinov`'s `pred_total`) uses the soft-anchor (soft-anchor
563
+ Task 3; was affine) recomposition instead
564
+ (`src.hybrid.hybrid_field_soft`); the SP1 side of
565
+ `no_regression` is unaffected (SP1 has no std-head analog).
566
+ lambda_shrink (variance-recomposition amendment 2): forwarded
567
+ straight to `_evaluate_stems_v4`/`hybrid_field_soft` (ignored
568
+ when `hgb_std` is `None`). `1.0` (the default) is byte-identical
569
+ to before this argument existed.
570
+ w_total / w_std (soft-anchor Task 3): forwarded straight to
571
+ `_evaluate_stems_v4`/`hybrid_field_soft`'s own weights (ignored
572
+ when `hgb_std` is `None`). `1.0`/`1.0` (the defaults) reproduce
573
+ `hybrid_field_soft`'s own default weighting.
574
+ device: "cuda"/"cpu"; default picks cuda if available.
575
+
576
+ Returns: nested dict with `interp`/`extrap` (each `per_alloy_family`,
577
+ `per_bc_config`, `overall`), `no_regression`, `chvorinov`,
578
+ `variance_collapse_flag`, `recomposition` ("soft" iff `hgb_std`
579
+ was given, else "anchor").
580
+ """
581
+ device = device or ("cuda" if torch.cuda.is_available() else "cpu")
582
+ model, cfg, norm = load_ft2b_gnn(ckpt, device)
583
+ gbm_model = joblib.load(gbm) if isinstance(gbm, (str, os.PathLike)) else gbm
584
+ hgb_std_model = joblib.load(hgb_std) if isinstance(hgb_std, (str, os.PathLike)) else hgb_std
585
+ manifest_dir = osp.dirname(osp.abspath(manifest)) or "."
586
+ manifest_rows = _load_manifest_rows(manifest)
587
+ splits_d = _load_splits(splits)
588
+
589
+ result: dict = {}
590
+ records_by_stem: dict = {}
591
+ for split_name in ("interp", "extrap"):
592
+ test_stems = splits_d[split_name]["test"]
593
+ records = _evaluate_stems_v4(model, cfg, norm, gbm_model, test_stems,
594
+ manifest_rows, manifest_dir, features_dir, device,
595
+ hgb_std=hgb_std_model, lambda_shrink=lambda_shrink,
596
+ w_total=w_total, w_std=w_std)
597
+ for r in records:
598
+ records_by_stem[r["stem"]] = r
599
+ result[split_name] = {
600
+ "n_stems": len(records),
601
+ "per_alloy_family": _group_metrics(records, "family"),
602
+ "per_bc_config": _group_metrics(records, "bc"),
603
+ "overall": _overall_metrics(records),
604
+ }
605
+
606
+ variance_collapse = False
607
+ for split_res in result.values():
608
+ for grouping in ("per_alloy_family", "per_bc_config"):
609
+ for m in split_res[grouping].values():
610
+ if m["std_ratio"] < variance_collapse_threshold:
611
+ variance_collapse = True
612
+ result["variance_collapse_flag"] = variance_collapse
613
+
614
+ result["no_regression"] = _no_regression_check(
615
+ splits_d, manifest_rows, features_dir, records_by_stem,
616
+ sp1_ckpt, sp1_hgb, baseline_features_dir, device,
617
+ tolerance=no_regression_tolerance)
618
+
619
+ result["chvorinov"] = _chvorinov_check(list(records_by_stem.values()))
620
+
621
+ result["recomposition"] = "soft" if hgb_std_model is not None else "anchor"
622
+
623
+ if out_path:
624
+ out_dir = osp.dirname(out_path)
625
+ if out_dir:
626
+ os.makedirs(out_dir, exist_ok=True)
627
+ with open(out_path, "w") as f:
628
+ json.dump(result, f, indent=2)
629
+
630
+ return result
src/evaluate_gnn.py ADDED
@@ -0,0 +1,150 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Evaluate a trained GNN on cache stems: GNN-only and (optionally) hybrid,
2
+ optional rotation TTA, per-split comparison table. No augmentation leakage:
3
+ TTA rotations are seeded and averaged in seconds-space."""
4
+ import argparse
5
+ import json
6
+ import os.path as osp
7
+
8
+ import numpy as np
9
+ import torch
10
+
11
+ from src.graph_build import (knn_edge_index, edge_attr, random_rotation_matrix, rotate,
12
+ union_edge_index, union_edge_attr)
13
+ from src.model_gnn import SolidGNN
14
+ from src.dataload import load_norm, standardize
15
+ from src.splits import load_splits
16
+ from src.baseline import predict_seconds
17
+ from src.hybrid import hybrid_field
18
+ from src import metrics
19
+
20
+ # The HGB baseline was trained on the 9 v2 features = first 9 columns of any
21
+ # newer feature version (ordering contract, SP1 spec §3).
22
+ N_BASELINE_FEATURES = 9
23
+ SPLIT_NAMES = ("interpolation", "family", "fully_cooled")
24
+
25
+
26
+ def predict_seconds_gnn(pred_std_log, norm) -> np.ndarray:
27
+ pred_log = pred_std_log * norm["y_log_std"] + norm["y_log_mean"]
28
+ return np.clip(np.expm1(pred_log), 0.0, None)
29
+
30
+
31
+ def load_gnn(ckpt, device):
32
+ state = torch.load(ckpt, map_location=device, weights_only=False)
33
+ cfg, norm = state["cfg"], state["norm"]
34
+ model = SolidGNN(node_in=cfg.get("node_in", 9), edge_in=cfg.get("edge_in", 4),
35
+ hidden=cfg["hidden"], n_blocks=cfg["n_blocks"],
36
+ two_head=cfg.get("two_head", False)).to(device)
37
+ model.load_state_dict(state["model"])
38
+ model.eval()
39
+ return model, cfg, norm
40
+
41
+
42
+ def _build_edges(pos, cfg, mesh_edges=None):
43
+ if cfg.get("graph", "knn") == "union":
44
+ if mesh_edges is None:
45
+ raise ValueError("checkpoint expects a union graph but mesh_edges is missing")
46
+ ei, is_mesh = union_edge_index(pos, mesh_edges, k=cfg["k"])
47
+ return ei, union_edge_attr(pos, ei, is_mesh)
48
+ ei = knn_edge_index(pos, k=cfg["k"])
49
+ return ei, edge_attr(pos, ei)
50
+
51
+
52
+ def predict_graph_seconds(model, cfg, norm, x, pos, mesh_edges=None, device="cpu", tta=0):
53
+ """Mean GNN prediction (seconds) over identity + `tta` seeded random rotations."""
54
+ outs = []
55
+ rots = [None] + [random_rotation_matrix(seed=s) for s in range(tta)]
56
+ zeros = torch.zeros(x.shape[0], dtype=torch.long, device=device)
57
+ for R in rots:
58
+ p = pos if R is None else rotate(pos, R)
59
+ ei, ea = _build_edges(p, cfg, mesh_edges)
60
+ with torch.no_grad():
61
+ yn, _ = model(x.to(device), ei.to(device), ea.to(device), zeros)
62
+ outs.append(predict_seconds_gnn(yn.cpu().numpy(), norm))
63
+ return np.mean(outs, axis=0)
64
+
65
+
66
+ def _metric_dict(y, p, xyz, gid) -> dict:
67
+ return {
68
+ "mae": metrics.mae(y, p),
69
+ "rmse": metrics.rmse(y, p),
70
+ "median_rel_error": metrics.median_rel_error(y, p),
71
+ "pred_std_ratio": metrics.pred_std_ratio(y, p, gid),
72
+ "hotspot_accuracy": metrics.hotspot_accuracy(y, p, xyz, gid),
73
+ }
74
+
75
+
76
+ def evaluate(ckpt, features_dir, test_stems, device=None, tta=0,
77
+ hgb_path=None, baseline_features_dir="data/features_v2") -> dict:
78
+ device = device or ("cuda" if torch.cuda.is_available() else "cpu")
79
+ model, cfg, norm = load_gnn(ckpt, device)
80
+ fmean = torch.tensor(norm["feat_mean"], dtype=torch.float32)
81
+ fstd = torch.tensor(norm["feat_std"], dtype=torch.float32)
82
+ hgb = hgb_norm = None
83
+ if hgb_path is not None:
84
+ import joblib
85
+ hgb = joblib.load(hgb_path)
86
+ hgb_norm = load_norm(baseline_features_dir)
87
+
88
+ ys, preds, hyb, xyz, gid = [], [], [], [], []
89
+ for i, stem in enumerate(test_stems):
90
+ d = np.load(osp.join(features_dir, "graphs", f"{stem}.npz"))
91
+ X, y, pos_np = d["X"], d["y"], d["pos"]
92
+ x = (torch.tensor(X, dtype=torch.float32) - fmean) / fstd
93
+ pos = torch.tensor(pos_np, dtype=torch.float32)
94
+ me = (torch.tensor(d["mesh_edges"], dtype=torch.long)
95
+ if "mesh_edges" in d.files else None)
96
+ p = predict_graph_seconds(model, cfg, norm, x, pos, me, device=device, tta=tta)
97
+ preds.append(p); ys.append(y); xyz.append(pos_np)
98
+ gid.append(np.full(y.shape[0], i, dtype=np.int64))
99
+ if hgb is not None:
100
+ Xb = standardize(X[:, :N_BASELINE_FEATURES], hgb_norm)
101
+ hyb.append(hybrid_field(p, predict_seconds(hgb, Xb, hgb_norm)))
102
+
103
+ y = np.concatenate(ys); p = np.concatenate(preds)
104
+ xyz = np.concatenate(xyz); gid = np.concatenate(gid)
105
+ out = _metric_dict(y, p, xyz, gid)
106
+ if hgb is not None:
107
+ out["hybrid"] = _metric_dict(y, np.concatenate(hyb), xyz, gid)
108
+ return out
109
+
110
+
111
+ def format_table(results) -> str:
112
+ lines = [f"{'split':<15}{'model':<8}{'MAE':>8}{'RMSE':>8}{'hot-spot':>10}{'std-ratio':>11}"]
113
+ for split, res in results.items():
114
+ rows = [("gnn", res)] + ([("hybrid", res["hybrid"])] if "hybrid" in res else [])
115
+ for label, m in rows:
116
+ lines.append(f"{split:<15}{label:<8}{m['mae']:>8.2f}{m['rmse']:>8.2f}"
117
+ f"{m['hotspot_accuracy']:>10.3f}{m['pred_std_ratio']:>11.3f}")
118
+ return "\n".join(lines)
119
+
120
+
121
+ def evaluate_all_splits(ckpt, features_dir, splits_path, hgb_path=None, tta=0,
122
+ device=None, out_json=None,
123
+ baseline_features_dir="data/features_v2") -> dict:
124
+ splits = load_splits(splits_path)
125
+ results = {}
126
+ for name in SPLIT_NAMES:
127
+ results[name] = evaluate(ckpt, features_dir, splits[name]["test"], device=device,
128
+ tta=tta, hgb_path=hgb_path,
129
+ baseline_features_dir=baseline_features_dir)
130
+ if out_json:
131
+ with open(out_json, "w") as f:
132
+ json.dump(results, f, indent=2)
133
+ print(format_table(results))
134
+ return results
135
+
136
+
137
+ if __name__ == "__main__":
138
+ ap = argparse.ArgumentParser()
139
+ ap.add_argument("--ckpt", required=True)
140
+ ap.add_argument("--features-dir", default="data/features_v2")
141
+ ap.add_argument("--splits", default=None, help="default: <features-dir>/splits.json")
142
+ ap.add_argument("--hgb", default=None)
143
+ ap.add_argument("--baseline-features-dir", default="data/features_v2")
144
+ ap.add_argument("--tta", type=int, default=0)
145
+ ap.add_argument("--out", default=None)
146
+ a = ap.parse_args()
147
+ evaluate_all_splits(a.ckpt, a.features_dir,
148
+ a.splits or osp.join(a.features_dir, "splits.json"),
149
+ hgb_path=a.hgb, tta=a.tta, out_json=a.out,
150
+ baseline_features_dir=a.baseline_features_dir)
src/features.py ADDED
@@ -0,0 +1,334 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Assemble per-node physics features (v2) + target for one casting.
2
+
3
+ v2 vs v1: distance-to-cooling is GEODESIC (through the material); the degenerate
4
+ engineered ratios (d_cool_sq, d_cool_over_dsurf) are dropped; node positions are
5
+ returned for downstream graph construction. All features mm, invariant, from mesh
6
+ + facet labels only.
7
+ """
8
+ import logging
9
+
10
+ import numpy as np
11
+
12
+ from src.geometry import (triangle_areas, total_area, mesh_volume,
13
+ nearest_distance, boundary_node_mask,
14
+ edges_from_tets, geodesic_distance, local_modulus,
15
+ geodesic_diameter_two_sweep)
16
+ from src.targets import solidification_time, global_time
17
+
18
+ logger = logging.getLogger(__name__)
19
+
20
+ FEATURE_NAMES = [
21
+ "geodesic_d_cool", "d_surf", "d_adiab",
22
+ "is_cooling", "is_adiabatic", "is_interior",
23
+ "volume_mm3", "surface_area_mm2", "cooling_area_frac",
24
+ ]
25
+
26
+ FEATURE_NAMES_V3 = FEATURE_NAMES + [
27
+ "geodesic_d_adiab",
28
+ "local_modulus_r5", "local_modulus_r15", "local_modulus_r40",
29
+ ]
30
+ LOCAL_MODULUS_RADII = (5.0, 15.0, 40.0)
31
+
32
+ # v4: v3's 13 columns UNCHANGED (X[:, :9] == v2, X[:, :13] == v3 -- the HGB
33
+ # baseline + backward-compat contract), with two continuous BC channels
34
+ # APPENDED at the end (replacing is_cooling/is_adiabatic/is_interior as MODEL
35
+ # input signal, though those binary tags stay in the first 9 columns as-is).
36
+ FEATURE_NAMES_V4 = FEATURE_NAMES_V3 + ["boundary_extraction", "exothermic_input"]
37
+
38
+ # v5: v4's 15 columns UNCHANGED, with five multi-scale part-normalized BC
39
+ # influence channels APPENDED -- exo_mid/exo_long extend the v4
40
+ # exothermic_input ("exo_short") reach to 0.15*D_geo/0.40*D_geo, and
41
+ # chill_short/chill_mid/chill_long give chills the same three-scale
42
+ # treatment (chills previously only had the surface-local
43
+ # boundary_extraction). See BC_REACH_MID_FRAC/BC_REACH_LONG_FRAC above.
44
+ FEATURE_NAMES_V5 = FEATURE_NAMES_V4 + [
45
+ "exo_mid", "exo_long", "chill_short", "chill_mid", "chill_long",
46
+ ]
47
+
48
+ # Geodesic-diffusion length scale (mm) for the exothermic_input channel: the
49
+ # sleeve's calibrated heat flux q0 decays as exp(-d_geo/lambda) away from the
50
+ # adiab_only (BC-patch) source nodes, instead of being painted only on those
51
+ # 1-2% of nodes as a sparse indicator (see docs/superpowers exo-fix design).
52
+ # This is also reused, unchanged, as the "short" reach scale for the v5
53
+ # chill_short channel below (bc-reach spec: same short-range mesh-scale
54
+ # decay length, not part-size-normalized).
55
+ EXO_DIFFUSION_LAMBDA_MM = 15.0
56
+
57
+ # v5 "mid"/"long" BC-influence reach fractions -- PART-NORMALIZED (of
58
+ # D_geo = the part's GEODESIC DIAMETER: a two-sweep-Dijkstra estimate over
59
+ # the mesh-edge graph -- `geodesic_diameter_two_sweep` -- patch-INDEPENDENT
60
+ # and through-metal), unlike the fixed EXO_DIFFUSION_LAMBDA_MM above.
61
+ # CORRECTION (2026-08-02, Task-1 review round 1, F1/F3): D_geo was
62
+ # originally (wrongly) `max(geodesic_d_cool)` -- that is max WALL DEPTH
63
+ # (~35-94mm on real parts, and BC-PATCH-DEPENDENT since it depends on which
64
+ # facets are tagged Cooling), not part size; it made L_long 6-17x too
65
+ # short (shorter even than the v4 fixed 15mm scale on some rows), inverting
66
+ # the intended short<mid<long ladder. The geodesic diameter is a single
67
+ # per-part constant, identical across every BC row of the same geometry.
68
+ # Motivation (bc-reach investigation): the fixed 15mm short scale decays to
69
+ # ~0 by ~140mm, but a real exo sleeve's influence on a 600mm-class casting
70
+ # extends past 400mm -- normalizing by the part's own geodesic diameter
71
+ # lets the encoding transfer across part sizes instead of a single global
72
+ # mm constant under/over-reaching.
73
+ BC_REACH_MID_FRAC = 0.15
74
+ BC_REACH_LONG_FRAC = 0.40
75
+
76
+ # Global dimensionless alloy+mould vector (broadcast to every node by the
77
+ # cache builder / FT2b; computed here RAW -- not standardized).
78
+ G_VECTOR_NAMES = ["Ste", "dTf_norm", "alpha_ratio", "superheat_norm", "mold_eff_norm"]
79
+ T_REF = 1000.0 # deg C, normalizes temperature-difference channels
80
+ ALPHA_REF = 1e-5 # m^2/s, ballpark alloy thermal diffusivity
81
+ MOLD_EFF_REF = 1250.0 # W*s^0.5/(m^2*K), green-sand mold effusivity baseline
82
+
83
+
84
+ def alloy_g_vector(props: dict) -> np.ndarray:
85
+ """Global dimensionless alloy+mould vector `g` (raw, not standardized).
86
+
87
+ `props` keys: k, rho, cp, L_kJ_kg, TL, TS, Tpour, mold_effusivity."""
88
+ k = float(props["k"]); rho = float(props["rho"]); cp = float(props["cp"])
89
+ L = float(props["L_kJ_kg"]); TL = float(props["TL"]); TS = float(props["TS"])
90
+ Tpour = float(props["Tpour"]); b_mold = float(props["mold_effusivity"])
91
+
92
+ Ste = cp * (Tpour - TS) / (L * 1000.0)
93
+ dTf_norm = (TL - TS) / T_REF
94
+ alpha_ratio = (k / (rho * cp)) / ALPHA_REF
95
+ superheat_norm = (Tpour - TL) / T_REF
96
+ mold_eff_norm = b_mold / MOLD_EFF_REF
97
+ return np.array([Ste, dTf_norm, alpha_ratio, superheat_norm, mold_eff_norm],
98
+ dtype=np.float64)
99
+
100
+
101
+ def chvorinov_t_ref(volume_mm3, area_mm2, C_material) -> float:
102
+ """Chvorinov total-solidification-time estimate: C * (V/A)^2."""
103
+ return float(C_material) * (float(volume_mm3) / float(area_mm2)) ** 2
104
+
105
+
106
+ def geodesic_diffuse(source_values: np.ndarray, length_mm: float, N: int,
107
+ edges: np.ndarray, edge_len: np.ndarray) -> np.ndarray:
108
+ """Multi-source geodesic exponential-decay diffusion, factored out of
109
+ the original inline `exothermic_input` construction.
110
+
111
+ For every node: the max over distinct nonzero source magnitudes of
112
+ (magnitude * exp(-geodesic distance to the nearest node carrying that
113
+ magnitude / length_mm)). Distinct magnitudes are grouped and Dijkstra'd
114
+ independently, then combined by elementwise max -- for the single
115
+ uniform-magnitude source group this codebase actually uses (every BC
116
+ source node carries the same scalar peak), this reduces EXACTLY to
117
+ `peak * exp(-d_geo/length_mm)` via one ordinary multi-source Dijkstra
118
+ call, byte-identical to the original `exothermic_input` formula --
119
+ PROVIDED that peak is non-negative. The `out = np.maximum(out, ...)`
120
+ combine is an implicit >= 0 clip: a genuinely negative source magnitude
121
+ would get clamped toward 0 instead of decaying to a negative value (a
122
+ node's own zero-init `out` beats any negative contribution). No caller
123
+ in this codebase produces a negative peak today (`exo_q0/1e6` and
124
+ `(chill_eff-mold_eff)/mold_eff` are both >= 0 in every resolved spec),
125
+ so this is currently unreachable, not a live bug -- documented so a
126
+ future negative-magnitude source doesn't rediscover it the hard way.
127
+
128
+ Returns all-zeros WITHOUT invoking the Dijkstra solver when there are
129
+ no nonzero sources or `length_mm <= 0` (cheap all-sand / degenerate
130
+ path -- `np.exp(-inf) == 0.0` would otherwise handle disconnected mesh
131
+ components fine, but a zero/negative length is not physically
132
+ meaningful and must not divide-by-zero).
133
+ """
134
+ source_values = np.asarray(source_values, dtype=np.float64)
135
+ src_idx = np.flatnonzero(source_values != 0.0)
136
+ if src_idx.size == 0 or length_mm <= 0:
137
+ return np.zeros(N, dtype=np.float32)
138
+ out = np.zeros(N, dtype=np.float64)
139
+ for val in np.unique(source_values[src_idx]):
140
+ grp = src_idx[source_values[src_idx] == val]
141
+ d = geodesic_distance(N, edges, edge_len, grp)
142
+ # np.exp(-inf) == 0.0 handles disconnected components
143
+ out = np.maximum(out, val * np.exp(-d / length_mm))
144
+ return out.astype(np.float32)
145
+
146
+
147
+ def compute_bc_reach_channels(N: int, edges: np.ndarray, edge_len: np.ndarray,
148
+ adiab_only: np.ndarray, bc_config: str,
149
+ mold_eff: float, chill_eff: float,
150
+ exo_q0: float) -> np.ndarray:
151
+ """The five v5 multi-scale part-normalized BC-influence channels:
152
+ `[exo_mid, exo_long, chill_short, chill_mid, chill_long]`, shape (N, 5)
153
+ float32.
154
+
155
+ `D_geo` (the part-normalizing length) is the part's GEODESIC DIAMETER --
156
+ `geodesic_diameter_two_sweep(N, edges, edge_len, start=0)`, a two-sweep
157
+ Dijkstra estimate over the SAME mesh-edge graph the other geodesic
158
+ features use. This is patch-INDEPENDENT (unlike `geodesic_d_cool`,
159
+ whose max is wall DEPTH, not part size -- see the CORRECTION note above
160
+ `BC_REACH_MID_FRAC`) and identical across every BC row of the same
161
+ geometry. exo_mid/exo_long reuse the exo_sleeve's `adiab_only` source
162
+ patch and peak (`exo_q0/1e6`, matching `exothermic_input`'s "exo_short"
163
+ scale) at `BC_REACH_MID_FRAC`/`BC_REACH_LONG_FRAC` * D_geo.
164
+ chill_short/chill_mid/chill_long give the chills bc_config the same
165
+ three-scale treatment, sourced at the chill contact nodes (`adiab_only`)
166
+ with peak magnitude `(chill_eff - mold_eff) / mold_eff` -- chill_short
167
+ reuses the fixed `EXO_DIFFUSION_LAMBDA_MM` short scale, chill_mid/
168
+ chill_long use the same part-normalized fractions as the exo channels.
169
+
170
+ D_geo (and therefore the two extra Dijkstra sweeps) is computed ONLY
171
+ when `bc_config` is `"exo_sleeve"` or `"chills"` -- an all-sand
172
+ (`"none"`) or `"insulating_sleeve"` part returns all-zeros without any
173
+ solver call, same contract as `exothermic_input`/`geodesic_diffuse`. A
174
+ degenerate/disconnected mesh (D_geo <= 0) logs a warning and leaves the
175
+ mid/long channels at zero (short-scale channels are unaffected, since
176
+ they don't depend on D_geo).
177
+ """
178
+ out = np.zeros((N, 5), dtype=np.float32)
179
+ if bc_config not in ("exo_sleeve", "chills"):
180
+ return out
181
+
182
+ d_geo = geodesic_diameter_two_sweep(N, edges, edge_len, start=0)
183
+ if d_geo <= 0:
184
+ logger.warning(
185
+ "compute_bc_reach_channels: D_geo (geodesic diameter) <= 0 for "
186
+ "bc_config=%r -- degenerate/disconnected mesh, zeroing the "
187
+ "part-normalized mid/long BC-reach channels", bc_config)
188
+ lambda_mid = BC_REACH_MID_FRAC * d_geo
189
+ lambda_long = BC_REACH_LONG_FRAC * d_geo
190
+
191
+ if bc_config == "exo_sleeve":
192
+ src = np.zeros(N, dtype=np.float64)
193
+ src[adiab_only] = exo_q0 / 1e6
194
+ out[:, 0] = geodesic_diffuse(src, lambda_mid, N, edges, edge_len)
195
+ out[:, 1] = geodesic_diffuse(src, lambda_long, N, edges, edge_len)
196
+
197
+ if bc_config == "chills":
198
+ chill_val = (chill_eff - mold_eff) / mold_eff if mold_eff != 0 else 0.0
199
+ src = np.zeros(N, dtype=np.float64)
200
+ src[adiab_only] = chill_val
201
+ out[:, 2] = geodesic_diffuse(src, EXO_DIFFUSION_LAMBDA_MM, N, edges, edge_len)
202
+ out[:, 3] = geodesic_diffuse(src, lambda_mid, N, edges, edge_len)
203
+ out[:, 4] = geodesic_diffuse(src, lambda_long, N, edges, edge_len)
204
+
205
+ return out
206
+
207
+
208
+ def _node_features(mesh, version=2, spec=None):
209
+ """Compute the (N,9/13/15/20) feature matrix (v2/v3/v4/v5) + pos (mm) +
210
+ mesh edges for a mesh. No target."""
211
+ xyz = mesh.node_xyz
212
+ N = xyz.shape[0]
213
+ cool_tri = mesh.tris.get("Cooling", np.empty((0, 3), np.int64))
214
+ adiab_tri = mesh.tris.get("Adiabatic", np.empty((0, 3), np.int64))
215
+
216
+ cool_mask = boundary_node_mask(N, cool_tri)
217
+ adiab_mask = boundary_node_mask(N, adiab_tri)
218
+ adiab_only = adiab_mask & ~cool_mask
219
+ surf_mask = cool_mask | adiab_mask
220
+ interior_mask = ~surf_mask
221
+
222
+ bbox = xyz.max(0) - xyz.min(0)
223
+ bbox_diag = float(np.linalg.norm(bbox))
224
+
225
+ edges = edges_from_tets(mesh.tets)
226
+ edge_len = np.linalg.norm(xyz[edges[:, 0]] - xyz[edges[:, 1]], axis=1) if edges.shape[0] else np.empty(0)
227
+ src = np.flatnonzero(cool_mask)
228
+ g_cool = geodesic_distance(N, edges, edge_len, src)
229
+ eucl_cool = nearest_distance(xyz, xyz[cool_mask])
230
+ eucl_cool = np.where(np.isinf(eucl_cool), bbox_diag, eucl_cool)
231
+ g_cool = np.where(np.isinf(g_cool), eucl_cool, g_cool)
232
+
233
+ d_surf = nearest_distance(xyz, xyz[surf_mask])
234
+ d_adiab = nearest_distance(xyz, xyz[adiab_mask])
235
+ d_surf = np.where(np.isinf(d_surf), bbox_diag, d_surf)
236
+ d_adiab = np.where(np.isinf(d_adiab), bbox_diag, d_adiab)
237
+
238
+ area_cool = total_area(xyz, cool_tri)
239
+ area_total = area_cool + total_area(xyz, adiab_tri)
240
+ cool_frac = area_cool / area_total if area_total > 0 else 0.0
241
+ volume = mesh_volume(xyz, mesh.tets)
242
+
243
+ cols = {
244
+ "geodesic_d_cool": g_cool, "d_surf": d_surf, "d_adiab": d_adiab,
245
+ "is_cooling": cool_mask.astype(np.float32),
246
+ "is_adiabatic": adiab_only.astype(np.float32),
247
+ "is_interior": interior_mask.astype(np.float32),
248
+ "volume_mm3": np.full(N, volume),
249
+ "surface_area_mm2": np.full(N, area_total),
250
+ "cooling_area_frac": np.full(N, cool_frac),
251
+ }
252
+ if version >= 3:
253
+ g_adiab = geodesic_distance(N, edges, edge_len, np.flatnonzero(adiab_mask))
254
+ g_adiab = np.where(np.isinf(g_adiab), d_adiab, g_adiab)
255
+ lm = local_modulus(xyz, mesh.tets, cool_tri, edges, edge_len, LOCAL_MODULUS_RADII)
256
+ cols.update({
257
+ "geodesic_d_adiab": g_adiab,
258
+ "local_modulus_r5": lm[:, 0],
259
+ "local_modulus_r15": lm[:, 1],
260
+ "local_modulus_r40": lm[:, 2],
261
+ })
262
+ if version >= 4:
263
+ if spec is None:
264
+ raise ValueError("_node_features(version=4) requires a `spec` dict "
265
+ "(BC info: mold_effusivity, bc_config, "
266
+ "chill_effusivity, sleeve_MEF, exo_q0)")
267
+ mold_eff = float(spec["mold_effusivity"])
268
+ bc_config = spec["bc_config"]
269
+ chill_eff = float(spec.get("chill_effusivity", 0.0))
270
+ sleeve_mef = float(spec.get("sleeve_MEF", 0.0))
271
+ exo_q0 = float(spec.get("exo_q0", 0.0))
272
+
273
+ # Mirrors tools/doe/fenicsx_solver.py's tag convention: tag "Cooling"
274
+ # is always the ordinary sand-mold Robin term; tag "Adiabatic" is the
275
+ # BC-patch footprint, reinterpreted per bc_config. Nodes shared
276
+ # between the two triangle groups follow the same cooling-takes-
277
+ # priority convention as `is_adiabatic` (adiab_only).
278
+ boundary_extraction = np.zeros(N, dtype=np.float32)
279
+ boundary_extraction[cool_mask] = mold_eff
280
+ if bc_config == "chills":
281
+ adiab_val = chill_eff
282
+ elif bc_config in ("insulating_sleeve", "exo_sleeve"):
283
+ adiab_val = mold_eff / max(sleeve_mef ** 2, 1.0)
284
+ else: # "none" -- BC patch treated as ordinary mold (matches solver)
285
+ adiab_val = mold_eff
286
+ boundary_extraction[adiab_only] = adiab_val
287
+
288
+ exothermic_input = np.zeros(N, dtype=np.float32)
289
+ if bc_config == "exo_sleeve":
290
+ exo_src_vals = np.zeros(N, dtype=np.float64)
291
+ exo_src_vals[adiab_only] = exo_q0 / 1e6
292
+ exothermic_input = geodesic_diffuse(exo_src_vals, EXO_DIFFUSION_LAMBDA_MM,
293
+ N, edges, edge_len)
294
+
295
+ cols.update({
296
+ "boundary_extraction": boundary_extraction,
297
+ "exothermic_input": exothermic_input,
298
+ })
299
+
300
+ if version >= 5:
301
+ bc_reach = compute_bc_reach_channels(
302
+ N, edges, edge_len, adiab_only, bc_config,
303
+ mold_eff, chill_eff, exo_q0)
304
+ cols.update({
305
+ "exo_mid": bc_reach[:, 0], "exo_long": bc_reach[:, 1],
306
+ "chill_short": bc_reach[:, 2], "chill_mid": bc_reach[:, 3],
307
+ "chill_long": bc_reach[:, 4],
308
+ })
309
+
310
+ names = (FEATURE_NAMES_V5 if version >= 5 else
311
+ FEATURE_NAMES_V4 if version >= 4 else
312
+ FEATURE_NAMES_V3 if version >= 3 else FEATURE_NAMES)
313
+ X = np.stack([cols[n] for n in names], axis=1).astype(np.float32)
314
+ return X, xyz.astype(np.float32), edges.astype(np.int64)
315
+
316
+
317
+ def compute_features_inference(mesh, version=2, spec=None) -> dict:
318
+ X, pos, edges = _node_features(mesh, version, spec=spec)
319
+ return {"X": X, "pos": pos, "mesh_edges": edges}
320
+
321
+
322
+ def compute_features(mesh, temps, times, version=2, spec=None) -> dict:
323
+ X, pos, edges = _node_features(mesh, version, spec=spec)
324
+ y = solidification_time(temps, times)
325
+ out = {"X": X, "y": y.astype(np.float32), "y_global": global_time(y),
326
+ "pos": pos, "mesh_edges": edges}
327
+ if version >= 4:
328
+ g = alloy_g_vector(spec)
329
+ volume = float(X[0, FEATURE_NAMES.index("volume_mm3")])
330
+ area = float(X[0, FEATURE_NAMES.index("surface_area_mm2")])
331
+ t_ref = chvorinov_t_ref(volume, area, spec["C_material"])
332
+ y_nondim = y.astype(np.float64) / max(t_ref, 1e-9)
333
+ out.update({"g": g, "t_ref": t_ref, "y_nondim": y_nondim.astype(np.float32)})
334
+ return out
src/gbm_ft2b.py ADDED
@@ -0,0 +1,598 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """FT2b Task 6: multi-alloy GBM scale-head refit.
2
+
3
+ The production predictor is a HYBRID: GNN field *shape* x a per-graph
4
+ *scale* from a HistGradientBoostingRegressor (see `src/hybrid.py`,
5
+ `runs/baseline_v2/hgb_A.joblib`). That GBM was trained on `src/features.py`
6
+ v2 physics features from the SINGLE-alloy dataset and knows nothing about
7
+ material properties, so it cannot generalize its scale prediction across
8
+ alloys/mold systems.
9
+
10
+ This module refits a NEW scale-head GBM directly on raw material properties
11
+ + geometry scale + mold-system effusivity -- exactly the quantities that
12
+ determine Chvorinov-style total solidification time -- resolved per training
13
+ run_id from the FT2a/FT2b DOE manifest via the SAME resolution path used to
14
+ build the v4 feature cache (`src.build_dataset._resolve_material_props_v4`).
15
+
16
+ Feature row layout (see FEATURE_NAMES): [k, rho, cp, L_kJ_kg, TL, TS, Tpour,
17
+ V_over_A, mold_effusivity] (9 material/geometry columns) + [bc_none, bc_chills,
18
+ bc_insulating_sleeve, bc_exo_sleeve] (4 BC one-hot from manifest.bc_config) +
19
+ [n_chills, sleeve_MEF, exo_q0_MW] (3 BC scalar fields) = 16 columns. `Tpour` IS a
20
+ per-material property in this project's DB (`tools.doe.load_properties.load_materials`
21
+ midpoints Tpour_lo_C/Tpour_hi_C for the 12 named anchor grades; the LHS-sampled
22
+ synthetic alloys carry their own `Tpour_C` in `materials_synthetic.csv`) -- so it is
23
+ resolved per material_id, not hardcoded as a constant.
24
+
25
+ Target: per-graph `log1p(y_raw.max())` -- the total solidification time
26
+ (scale), read straight from the v4 graph cache (`y_raw`, physical seconds).
27
+ """
28
+ from __future__ import annotations
29
+
30
+ import csv
31
+ import os
32
+ import os.path as osp
33
+
34
+ import joblib
35
+ import numpy as np
36
+ from sklearn.ensemble import HistGradientBoostingRegressor
37
+ from sklearn.model_selection import train_test_split
38
+
39
+ from src.build_dataset import (FEATURE_NAMES_V4, _resolve_exo_q0_v4,
40
+ _resolve_material_props_v4)
41
+ from src.features import FEATURE_NAMES as _NODE_FEATURE_NAMES
42
+
43
+ # volume_mm3/surface_area_mm2 are constant-per-node columns of the v2/v3/v4
44
+ # node-feature matrix X (see src/features.py FEATURE_NAMES and the v4
45
+ # comment there: "v3's 13 columns UNCHANGED (X[:, :9] == v2, X[:, :13] ==
46
+ # v3)" -- so these indices are valid for the v4 cache too). Verified live
47
+ # against src/build_dataset.py:622-623, which reads the SAME two indices
48
+ # out of the SAME FEATURE_NAMES list to get volume/area for the Chvorinov
49
+ # t_ref fit:
50
+ # volume = float(X[0, FEATURE_NAMES.index("volume_mm3")])
51
+ # area = float(X[0, FEATURE_NAMES.index("surface_area_mm2")])
52
+ _VOL_IDX = _NODE_FEATURE_NAMES.index("volume_mm3")
53
+ _AREA_IDX = _NODE_FEATURE_NAMES.index("surface_area_mm2")
54
+
55
+ # bc_area_frac (soft-anchor Task 2, std head v2 17th feature) column indices --
56
+ # resolved off `src.build_dataset.FEATURE_NAMES_V4` (never hardcoded silently)
57
+ # with a cross-check assertion against the brief's stated indices
58
+ # (is_cooling=3, is_adiabatic=4): if FEATURE_NAMES_V4 ever changes upstream,
59
+ # this fails loudly instead of silently reading the wrong columns.
60
+ _IS_COOLING_IDX = FEATURE_NAMES_V4.index("is_cooling")
61
+ _IS_ADIABATIC_IDX = FEATURE_NAMES_V4.index("is_adiabatic")
62
+ assert (_IS_COOLING_IDX, _IS_ADIABATIC_IDX) == (3, 4), (
63
+ f"bc_area_frac column indices drifted from the Global Constraints formula's "
64
+ f"is_cooling=3/is_adiabatic=4 (FEATURE_NAMES_V4 now gives "
65
+ f"is_cooling={_IS_COOLING_IDX}, is_adiabatic={_IS_ADIABATIC_IDX}) -- "
66
+ f"FEATURE_NAMES_V4 changed upstream; update this module before trusting "
67
+ f"bc_area_frac_from_graph")
68
+
69
+ # GBM row feature layout -- exposed for the Task 8 evaluator so it can
70
+ # rebuild rows the exact same way at inference time.
71
+ #
72
+ # BC-aware extension (exo-fix Task 3): the original 9 material/geometry
73
+ # columns are blind to boundary conditions (chills/sleeves change total
74
+ # solidification time independently of material/geometry), so the scale
75
+ # head could not tell a chilled casting from a plain sand one. The 7 new
76
+ # columns make it BC-aware: a one-hot over the manifest's `bc_config`
77
+ # values (`none`/`chills`/`insulating_sleeve`/`exo_sleeve` -- the only
78
+ # values `tools/doe/doe.py` ever writes), plus the raw `n_chills` and
79
+ # `sleeve_MEF` manifest fields, plus the calibrated exothermic-sleeve heat
80
+ # release `exo_q0_MW` (0.0 for non-exo rows, by `_resolve_exo_q0_v4`'s own
81
+ # contract).
82
+ FEATURE_NAMES = [
83
+ "k", "rho", "cp", "L_kJ_kg", "TL", "TS", "Tpour",
84
+ "V_over_A", "mold_effusivity",
85
+ "bc_none", "bc_chills", "bc_insulating_sleeve", "bc_exo_sleeve",
86
+ "n_chills", "sleeve_MEF", "exo_q0_MW",
87
+ ]
88
+
89
+ # The only bc_config values `tools/doe/doe.py` ever writes to the manifest
90
+ # (verified live against doe_out/manifest.csv: Counter gives exactly these
91
+ # 4 keys). An empty string means "no BC row was ever assigned" and is
92
+ # treated as "none" (mirrors `_resolve_exo_q0_v4`'s own
93
+ # `row.get("bc_config") or "none"` convention). Anything else is an
94
+ # unrecognized/typo'd value -- raise loudly rather than silently ignoring it
95
+ # in the one-hot.
96
+ _BC_VALUES = ("none", "chills", "insulating_sleeve", "exo_sleeve")
97
+
98
+ _HGB_DEFAULTS = dict(learning_rate=0.1, max_leaf_nodes=63,
99
+ l2_regularization=1.0, random_state=0)
100
+
101
+ # HGB's own early_stopping carves off validation_fraction (default 0.1) of
102
+ # whatever it's given -- on tiny row counts (e.g. a ~20-stem unit test) that
103
+ # leaves 1-2 validation rows, which stops training almost immediately and
104
+ # badly underfits. Only turn early stopping on once there's enough data for
105
+ # it to be a meaningful signal; production refits (thousands of stems) still
106
+ # get it.
107
+ _MIN_ROWS_FOR_EARLY_STOPPING = 50
108
+
109
+
110
+ def _load_manifest_rows(manifest_path: str) -> dict:
111
+ rows = {}
112
+ with open(manifest_path, newline="") as f:
113
+ for row in csv.DictReader(f):
114
+ rows[row["run_id"]] = row
115
+ return rows
116
+
117
+
118
+ def _num_or_zero(raw, field: str, stem: str) -> float:
119
+ """float(raw), except '' / None -> 0.0 (the brief's explicit rule for
120
+ n_chills/sleeve_MEF, mirroring how the manifest encodes N/A for rows
121
+ with no chill/sleeve BC). A present-but-non-numeric value still raises
122
+ loudly (e.g. a typo) -- only blank is treated as "not applicable"."""
123
+ if raw is None or str(raw).strip() == "":
124
+ return 0.0
125
+ try:
126
+ return float(raw)
127
+ except (TypeError, ValueError) as e:
128
+ raise ValueError(f"run_id={stem!r}: {field}={raw!r} is not a number") from e
129
+
130
+
131
+ def _row_features_and_graph(stem: str, manifest_rows: dict, manifest_dir: str,
132
+ features_dir: str) -> tuple[np.ndarray, "np.lib.npyio.NpzFile"]:
133
+ """Shared row-assembly: one BC-aware feature row (see `FEATURE_NAMES`) +
134
+ the loaded graph-cache npz. Factored out of `_row_features` so the
135
+ std-target head (`_row_features_std`) reuses the IDENTICAL X (same
136
+ material/BC resolution, same I/O) and swaps only the target it derives
137
+ from the already-open graph -- never re-deriving or duplicating the
138
+ resolution logic.
139
+
140
+ Loud-fails (raises) on anything unresolvable -- a missing manifest row,
141
+ a missing/unresolvable material_id, a missing mold_effusivity, an
142
+ unrecognized bc_config value, an uncalibrated exo_sleeve row, or a
143
+ missing graph-cache file -- NEVER silently drops or fabricates a value.
144
+ The one EXCEPTION (per the brief) is n_chills/sleeve_MEF: '' -> 0.0,
145
+ mirroring how the manifest encodes "no chill/sleeve BC" for this row.
146
+ """
147
+ row = manifest_rows.get(stem)
148
+ if row is None:
149
+ raise KeyError(f"stem {stem!r} not found in manifest ({manifest_dir!r})")
150
+
151
+ material_id = (row.get("material_id") or "").strip()
152
+ if not material_id:
153
+ raise KeyError(f"run_id={stem!r}: manifest row has no material_id")
154
+ # Raises KeyError if unresolvable via any of the 3 fallback sources
155
+ # (owner-verified CSV / materials_synthetic.csv / named anchor grades) --
156
+ # see src.build_dataset._resolve_material_props_v4's own docstring.
157
+ props = _resolve_material_props_v4(material_id, manifest_dir)
158
+
159
+ mold_eff_raw = row.get("mold_effusivity")
160
+ if mold_eff_raw is None or str(mold_eff_raw).strip() == "":
161
+ raise KeyError(f"run_id={stem!r}: manifest row has no mold_effusivity")
162
+ mold_effusivity = float(mold_eff_raw)
163
+
164
+ bc_config = (row.get("bc_config") or "").strip() or "none"
165
+ if bc_config not in _BC_VALUES:
166
+ raise ValueError(
167
+ f"run_id={stem!r}: unrecognized bc_config={bc_config!r} "
168
+ f"(expected one of {_BC_VALUES})")
169
+ one_hot = [1.0 if bc_config == v else 0.0 for v in _BC_VALUES]
170
+
171
+ n_chills = _num_or_zero(row.get("n_chills"), "n_chills", stem)
172
+ sleeve_MEF = _num_or_zero(row.get("sleeve_MEF"), "sleeve_MEF", stem)
173
+
174
+ # 0.0 for non-exo rows by its own contract; raises loudly for
175
+ # bc_config=="exo_sleeve" rows with no calibrated MEF match -- see
176
+ # src.build_dataset._resolve_exo_q0_v4's docstring.
177
+ exo_q0_MW = _resolve_exo_q0_v4(row) / 1e6
178
+
179
+ gpath = osp.join(features_dir, "graphs", f"{stem}.npz")
180
+ if not osp.exists(gpath):
181
+ raise FileNotFoundError(f"graph cache missing for stem={stem!r}: {gpath}")
182
+ d = np.load(gpath)
183
+ volume = float(d["X"][0, _VOL_IDX])
184
+ area = float(d["X"][0, _AREA_IDX])
185
+ if area <= 0:
186
+ raise ValueError(f"run_id={stem!r}: non-positive surface_area_mm2={area}")
187
+ v_over_a = volume / area
188
+
189
+ x = np.array([
190
+ props["k"], props["rho"], props["cp"], props["L_kJ_kg"],
191
+ props["TL"], props["TS"], props["Tpour"],
192
+ v_over_a, mold_effusivity,
193
+ *one_hot,
194
+ n_chills, sleeve_MEF, exo_q0_MW,
195
+ ], dtype=np.float64)
196
+ return x, d
197
+
198
+
199
+ def _row_features(stem: str, manifest_rows: dict, manifest_dir: str,
200
+ features_dir: str) -> tuple[np.ndarray, float]:
201
+ """One BC-aware feature row (see `FEATURE_NAMES`) + log1p(y_global).
202
+
203
+ See `_row_features_and_graph` for the loud-fail contract this shares.
204
+ """
205
+ x, d = _row_features_and_graph(stem, manifest_rows, manifest_dir, features_dir)
206
+ y_raw = d["y_raw"] if "y_raw" in d.files else d["y"]
207
+ y_log = float(np.log1p(float(np.max(y_raw))))
208
+ return x, y_log
209
+
210
+
211
+ def _row_features_std(stem: str, manifest_rows: dict, manifest_dir: str,
212
+ features_dir: str) -> tuple[np.ndarray, float]:
213
+ """The SAME BC-aware feature row as `_row_features` (see
214
+ `_row_features_and_graph`) + log1p(std(y_nodal)) -- the per-graph
215
+ field-spread target for the GBM std head (variance-recomposition Task 2).
216
+
217
+ `y_nodal` is the graph npz's `y` field (per-node solidification time,
218
+ seconds) -- raises loudly (KeyError) if a requested stem's graph cache
219
+ has no `y`, never silently skips it.
220
+ """
221
+ x, d = _row_features_and_graph(stem, manifest_rows, manifest_dir, features_dir)
222
+ if "y" not in d.files:
223
+ raise KeyError(f"run_id={stem!r}: graph cache in {features_dir!r} has no 'y' field")
224
+ y_std_log = float(np.log1p(float(np.std(d["y"]))))
225
+ return x, y_std_log
226
+
227
+
228
+ # std head v2 (soft-anchor Task 2): the v1 row (`FEATURE_NAMES`, 16 columns)
229
+ # + one graph-derived 17th column, `bc_area_frac`. Kept as its own module
230
+ # constant (rather than mutating `FEATURE_NAMES`) so the 16-feature total
231
+ # head stays UNTOUCHED -- see `std_feature_row`'s width-dispatch contract.
232
+ FEATURE_NAMES_STD_V2 = FEATURE_NAMES + ["bc_area_frac"]
233
+
234
+
235
+ def bc_area_frac_from_graph(graph) -> float:
236
+ """The std-head-v2 17th feature (Global Constraints formula, soft-anchor
237
+ spec Sec 2): the fraction of BC-tagged surface nodes that are adiabatic
238
+ (vs. cooling) -- `sum(is_adiabatic) / max(sum(is_adiabatic) +
239
+ sum(is_cooling), 1)`, read straight off the graph's node feature matrix
240
+ `graph["X"]` at `_IS_ADIABATIC_IDX`/`_IS_COOLING_IDX` (columns 4/3 of
241
+ `FEATURE_NAMES_V4`, cross-checked at import time above -- never
242
+ hardcoded silently a second time here).
243
+
244
+ `graph` is whatever `_row_features_and_graph` returns as its second
245
+ element (an open graph-cache npz) -- or, for tests, any object
246
+ supporting `graph["X"]` indexing (e.g. a plain dict).
247
+
248
+ A node-count proxy for the true tri-area fraction (no per-node area
249
+ weight is available in `X`), acceptable per the spec. All-sand rows
250
+ (no adiabatic surface at all, e.g. the fully-cooled regime) -> exactly
251
+ 0.0, since the numerator is 0 regardless of the denominator; a graph
252
+ with neither cooling nor adiabatic nodes tagged also -> 0.0 (the `max(
253
+ ..., 1)` floor avoids a 0/0 division).
254
+ """
255
+ X = graph["X"]
256
+ n_adiabatic = float(np.sum(X[:, _IS_ADIABATIC_IDX]))
257
+ n_cooling = float(np.sum(X[:, _IS_COOLING_IDX]))
258
+ return n_adiabatic / max(n_adiabatic + n_cooling, 1.0)
259
+
260
+
261
+ def std_feature_row(stem: str, manifest_rows: dict, manifest_dir: str,
262
+ features_dir: str, n_features: int) -> np.ndarray:
263
+ """Width-dispatching feature-row builder for the std head (soft-anchor
264
+ Task 2) -- lets a caller (Task 3's `predict_casting`/`evaluate_ft2b`)
265
+ build the right-width row for whichever std-head model it loaded,
266
+ keyed off that model's own `n_features_in_`, without duplicating
267
+ `_row_features_and_graph`'s resolution logic.
268
+
269
+ - `n_features == 16` -> the v1 row (`_row_features_and_graph`'s X,
270
+ unchanged -- byte-identical to `_row_features`'s own row).
271
+ - `n_features == 17` -> the v1 row + `bc_area_frac_from_graph` of the
272
+ SAME already-open graph appended (never a second graph load).
273
+ - anything else -> a loud `ValueError` naming the two supported widths
274
+ (never a silent truncation/pad).
275
+ """
276
+ if n_features not in (16, 17):
277
+ raise ValueError(
278
+ f"std_feature_row: unsupported n_features={n_features!r} for "
279
+ f"stem={stem!r} -- the std head only supports 16 (v1) or 17 "
280
+ f"(v2, v1 + bc_area_frac) feature columns")
281
+
282
+ x, d = _row_features_and_graph(stem, manifest_rows, manifest_dir, features_dir)
283
+ if n_features == 16:
284
+ return x
285
+ area_frac = bc_area_frac_from_graph(d)
286
+ return np.concatenate([x, [area_frac]])
287
+
288
+
289
+ def build_v2_feature_row(props: dict, v_over_a: float,
290
+ bc_spec: dict | None = None) -> np.ndarray:
291
+ """One BC-aware v2-total-GBM feature row (`FEATURE_NAMES` order) -- the
292
+ bare-mesh-inference counterpart to `_row_features_and_graph`'s
293
+ manifest/graph-cache-driven row (that one resolves `props`/`v_over_a`
294
+ from a manifest row + graph cache; this one takes them straight from
295
+ the caller, for real-part inference where there is no manifest row).
296
+
297
+ Public (no leading underscore) and factored out of
298
+ `gbm_v2_total_seconds` -- rather than duplicated wherever a caller needs
299
+ the total head's row (`src.predict_casting.predict_field`'s optional std
300
+ head, variance-recomposition Task 3) -- so the total head
301
+ (`gbm_v2_total_seconds`) and any std head scored from the SAME row
302
+ (`predict_std_seconds`) can never silently desync on a `FEATURE_NAMES`
303
+ reorder. Mirrors `_row_features_and_graph`'s own "so the two heads can
304
+ never drift apart" rationale (Task 2).
305
+
306
+ Builds the row from `props` (Task 1's `src.inference_props.
307
+ resolve_props()` 8-key dict: k/rho/cp/L_kJ_kg/TL/TS/Tpour/
308
+ mold_effusivity), `v_over_a` (volume/surface-area, mm), and `bc_spec`:
309
+ - `None` (the default, "plain sand mold" case) -> `bc_config`
310
+ "none" one-hot, `n_chills`/`sleeve_MEF`/`exo_q0_MW` all 0.0.
311
+ - a dict with keys `bc_config`/`n_chills`/`sleeve_MEF`/`exo_q0_MW`
312
+ (Part B's BC-aware inference entry point) -> that `bc_config`'s
313
+ one-hot + its 3 raw scalar fields, verbatim.
314
+
315
+ An unrecognized `bc_config` raises `ValueError` (mirrors `_row_features`'
316
+ own convention for a manifest row with a typo'd `bc_config`).
317
+ """
318
+ if bc_spec is None:
319
+ bc_config = "none"
320
+ n_chills = 0.0
321
+ sleeve_MEF = 0.0
322
+ exo_q0_MW = 0.0
323
+ else:
324
+ bc_config = bc_spec["bc_config"]
325
+ n_chills = float(bc_spec["n_chills"])
326
+ sleeve_MEF = float(bc_spec["sleeve_MEF"])
327
+ exo_q0_MW = float(bc_spec["exo_q0_MW"])
328
+
329
+ if bc_config not in _BC_VALUES:
330
+ raise ValueError(
331
+ f"unrecognized bc_config={bc_config!r} (expected one of {_BC_VALUES})")
332
+ one_hot = [1.0 if bc_config == v else 0.0 for v in _BC_VALUES]
333
+
334
+ return np.array([
335
+ props["k"], props["rho"], props["cp"], props["L_kJ_kg"],
336
+ props["TL"], props["TS"], props["Tpour"],
337
+ float(v_over_a), props["mold_effusivity"],
338
+ *one_hot,
339
+ n_chills, sleeve_MEF, exo_q0_MW,
340
+ ], dtype=np.float64)
341
+
342
+
343
+ def gbm_v2_total_seconds(hgb, props: dict, v_over_a: float,
344
+ bc_spec: dict | None = None) -> float:
345
+ """Bare-mesh inference entry point: one feature row -> total seconds.
346
+
347
+ Builds the row via `build_v2_feature_row` (see that function for the
348
+ full `props`/`v_over_a`/`bc_spec` contract) and returns
349
+ `float(np.expm1(hgb.predict(row)[0]))` -- physical seconds, inverting
350
+ the same `log1p(y_raw.max())` target `_row_features`/`refit_gbm` train
351
+ on.
352
+ """
353
+ x = build_v2_feature_row(props, v_over_a, bc_spec)
354
+ log_pred = float(hgb.predict(x.reshape(1, -1))[0])
355
+ return float(np.expm1(log_pred))
356
+
357
+
358
+ def _assemble_matrix(stems, manifest_path: str, features_dir: str):
359
+ """[len(stems), len(FEATURE_NAMES)] feature matrix + log1p(y_global) target vector.
360
+
361
+ Raises (does not skip) on any row that cannot be resolved -- see
362
+ `_row_features`.
363
+ """
364
+ manifest_dir = osp.dirname(osp.abspath(manifest_path)) or "."
365
+ manifest_rows = _load_manifest_rows(manifest_path)
366
+
367
+ X_rows, y_rows, used = [], [], []
368
+ for stem in stems:
369
+ x, y_log = _row_features(stem, manifest_rows, manifest_dir, features_dir)
370
+ X_rows.append(x)
371
+ y_rows.append(y_log)
372
+ used.append(stem)
373
+
374
+ X = np.stack(X_rows) if X_rows else np.empty((0, len(FEATURE_NAMES)))
375
+ y = np.array(y_rows, dtype=np.float64)
376
+ return X, y, used
377
+
378
+
379
+ def _assemble_matrix_std(stems, manifest_path: str, features_dir: str):
380
+ """[len(stems), len(FEATURE_NAMES)] feature matrix + log1p(std(y_nodal))
381
+ target vector -- the std-head counterpart to `_assemble_matrix`. Same X
382
+ (via `_row_features_std` -> `_row_features_and_graph`), different target.
383
+
384
+ Raises (does not skip) on any row that cannot be resolved -- see
385
+ `_row_features_std`.
386
+ """
387
+ manifest_dir = osp.dirname(osp.abspath(manifest_path)) or "."
388
+ manifest_rows = _load_manifest_rows(manifest_path)
389
+
390
+ X_rows, y_rows, used = [], [], []
391
+ for stem in stems:
392
+ x, y_log = _row_features_std(stem, manifest_rows, manifest_dir, features_dir)
393
+ X_rows.append(x)
394
+ y_rows.append(y_log)
395
+ used.append(stem)
396
+
397
+ X = np.stack(X_rows) if X_rows else np.empty((0, len(FEATURE_NAMES)))
398
+ y = np.array(y_rows, dtype=np.float64)
399
+ return X, y, used
400
+
401
+
402
+ def _fit_hgb_with_metrics(X: np.ndarray, y: np.ndarray, out_path: str,
403
+ max_iter: int, holdout_frac: float,
404
+ min_samples_leaf: int, random_state: int,
405
+ caller: str, loss_kwargs: dict | None = None) -> dict:
406
+ """Shared HGB-fit/holdout/save logic behind `refit_gbm`, `refit_gbm_std`,
407
+ and `refit_gbm_std_v2` -- identical modeling code, only the assembled
408
+ (X, y) (and, for std v2, the loss) differ between heads. Factored out so
409
+ the heads can never drift apart in their fitting/metrics logic;
410
+ `refit_gbm`'s and `refit_gbm_std`'s own public behavior is unchanged by
411
+ this factoring (see the guard tests in `tests/test_gbm_ft2b.py`).
412
+
413
+ `min_samples_leaf` (sklearn's HGB default is 20) must be turned down for
414
+ very small row counts (e.g. a ~20-row unit test) -- HGB cannot split a
415
+ node at all once a candidate split would leave either side with fewer
416
+ than `min_samples_leaf` samples, so with the sklearn default and only
417
+ ~20 training rows every tree degenerates to a single leaf (the
418
+ unconditional mean). Production refits (thousands of stems) should keep
419
+ the default.
420
+
421
+ `loss_kwargs` (soft-anchor Task 2): optional extra
422
+ `HistGradientBoostingRegressor` constructor kwargs (e.g.
423
+ `{"loss": "quantile", "quantile": 0.5}` for the std-head-v2 median
424
+ objective), merged into BOTH the holdout probe and the final model so a
425
+ caller never has to duplicate this fit/holdout/save logic just to swap
426
+ the loss. `None` (the default) reproduces the exact pre-existing
427
+ behavior (sklearn's default squared-error loss) -- `refit_gbm`'s and
428
+ `refit_gbm_std`'s own output is therefore byte-identical to before this
429
+ parameter existed.
430
+
431
+ Returns a dict of finite metrics: `n_train`, `train_mae_log1p`,
432
+ `train_mae_seconds`, and (when there are enough rows to spare a
433
+ holdout split) `holdout_mae_log1p`.
434
+ """
435
+ if X.shape[0] == 0:
436
+ raise ValueError(f"{caller}: no training stems resolved -- nothing to fit")
437
+
438
+ metrics: dict = {"n_train": X.shape[0]}
439
+
440
+ # Bonus: a quick internal holdout split for a generalization signal.
441
+ # Skipped for tiny row counts (both HGB's own internal validation split
442
+ # and this outer split would starve each other of data).
443
+ if X.shape[0] >= 10 and holdout_frac > 0:
444
+ X_tr, X_ho, y_tr, y_ho = train_test_split(
445
+ X, y, test_size=holdout_frac, random_state=random_state)
446
+ probe_params = dict(_HGB_DEFAULTS)
447
+ probe_params["random_state"] = random_state
448
+ if loss_kwargs:
449
+ probe_params.update(loss_kwargs)
450
+ probe = HistGradientBoostingRegressor(
451
+ max_iter=max_iter,
452
+ early_stopping=(len(X_tr) >= _MIN_ROWS_FOR_EARLY_STOPPING),
453
+ validation_fraction=0.1, min_samples_leaf=min_samples_leaf,
454
+ **probe_params)
455
+ probe.fit(X_tr, y_tr)
456
+ ho_mae = float(np.mean(np.abs(probe.predict(X_ho) - y_ho)))
457
+ if np.isfinite(ho_mae):
458
+ metrics["holdout_mae_log1p"] = ho_mae
459
+
460
+ # Final production model: fit on ALL resolved training rows.
461
+ model_params = dict(_HGB_DEFAULTS)
462
+ model_params["random_state"] = random_state
463
+ if loss_kwargs:
464
+ model_params.update(loss_kwargs)
465
+ model = HistGradientBoostingRegressor(
466
+ max_iter=max_iter,
467
+ early_stopping=(X.shape[0] >= _MIN_ROWS_FOR_EARLY_STOPPING),
468
+ validation_fraction=0.1, min_samples_leaf=min_samples_leaf,
469
+ **model_params)
470
+ model.fit(X, y)
471
+
472
+ train_pred = model.predict(X)
473
+ metrics["train_mae_log1p"] = float(np.mean(np.abs(train_pred - y)))
474
+ metrics["train_mae_seconds"] = float(
475
+ np.mean(np.abs(np.expm1(train_pred) - np.expm1(y))))
476
+
477
+ out_dir = osp.dirname(out_path)
478
+ if out_dir:
479
+ os.makedirs(out_dir, exist_ok=True)
480
+ joblib.dump(model, out_path)
481
+ metrics["out_path"] = out_path
482
+
483
+ return metrics
484
+
485
+
486
+ def refit_gbm(features_dir: str, train_stems, manifest_path: str, out_path: str,
487
+ max_iter: int = 300, holdout_frac: float = 0.2,
488
+ min_samples_leaf: int = 20, random_state: int = 0) -> dict:
489
+ """Refit the multi-alloy GBM scale-head (total solidification time target,
490
+ `log1p(y_global)`) and save it to `out_path`. See `_fit_hgb_with_metrics`
491
+ for the shared fitting/metrics logic.
492
+
493
+ Returns a dict of finite metrics: `n_train`, `train_mae_log1p`,
494
+ `train_mae_seconds`, and (when there are enough rows to spare a
495
+ holdout split) `holdout_mae_log1p`.
496
+ """
497
+ X, y, used = _assemble_matrix(train_stems, manifest_path, features_dir)
498
+ return _fit_hgb_with_metrics(
499
+ X, y, out_path, max_iter=max_iter, holdout_frac=holdout_frac,
500
+ min_samples_leaf=min_samples_leaf, random_state=random_state,
501
+ caller="refit_gbm")
502
+
503
+
504
+ def refit_gbm_std(features_dir: str, train_stems, manifest_path: str, out_path: str,
505
+ max_iter: int = 300, holdout_frac: float = 0.2,
506
+ min_samples_leaf: int = 20, random_state: int = 0) -> dict:
507
+ """Refit the GBM std-head (per-row field-spread target,
508
+ `log1p(std(y_nodal))`) and save it to `out_path` -- the counterpart to
509
+ `refit_gbm`'s total-time head, for variance-preserving hybrid
510
+ recomposition (see `src.hybrid.hybrid_field_affine`, Task 1).
511
+
512
+ Consumes the SAME feature row layout (`FEATURE_NAMES`) as `refit_gbm`,
513
+ built via `_assemble_matrix_std` (which reuses `_row_features_and_graph`
514
+ -- identical material/BC resolution, only the target differs). See
515
+ `_fit_hgb_with_metrics` for the shared fitting/metrics logic.
516
+
517
+ Returns a dict of finite metrics: `n_train`, `train_mae_log1p`,
518
+ `train_mae_seconds`, and (when there are enough rows to spare a
519
+ holdout split) `holdout_mae_log1p`.
520
+ """
521
+ X, y, used = _assemble_matrix_std(train_stems, manifest_path, features_dir)
522
+ return _fit_hgb_with_metrics(
523
+ X, y, out_path, max_iter=max_iter, holdout_frac=holdout_frac,
524
+ min_samples_leaf=min_samples_leaf, random_state=random_state,
525
+ caller="refit_gbm_std")
526
+
527
+
528
+ def _assemble_matrix_std_v2(stems, manifest_path: str, features_dir: str):
529
+ """[len(stems), 17] feature matrix (v1 row + `bc_area_frac`) +
530
+ `log1p(std(y_nodal))` target vector -- the std-head-v2 counterpart to
531
+ `_assemble_matrix_std`. Reuses `_row_features_and_graph` for the shared
532
+ v1 row AND derives `bc_area_frac_from_graph` off that SAME already-open
533
+ graph (never a second graph load per row, mirroring
534
+ `_row_features_and_graph`'s own "so the two heads can never drift
535
+ apart" rationale).
536
+
537
+ Raises (does not skip) on any row that cannot be resolved -- see
538
+ `_row_features_and_graph`; also raises `KeyError` if a stem's graph
539
+ cache has no `y` field (mirrors `_row_features_std`).
540
+ """
541
+ manifest_dir = osp.dirname(osp.abspath(manifest_path)) or "."
542
+ manifest_rows = _load_manifest_rows(manifest_path)
543
+
544
+ X_rows, y_rows, used = [], [], []
545
+ for stem in stems:
546
+ x, d = _row_features_and_graph(stem, manifest_rows, manifest_dir, features_dir)
547
+ if "y" not in d.files:
548
+ raise KeyError(f"run_id={stem!r}: graph cache in {features_dir!r} has no 'y' field")
549
+ area_frac = bc_area_frac_from_graph(d)
550
+ x17 = np.concatenate([x, [area_frac]])
551
+ y_log = float(np.log1p(float(np.std(d["y"]))))
552
+ X_rows.append(x17)
553
+ y_rows.append(y_log)
554
+ used.append(stem)
555
+
556
+ X = np.stack(X_rows) if X_rows else np.empty((0, len(FEATURE_NAMES_STD_V2)))
557
+ y = np.array(y_rows, dtype=np.float64)
558
+ return X, y, used
559
+
560
+
561
+ def refit_gbm_std_v2(features_dir: str, train_stems, manifest_path: str, out_path: str,
562
+ max_iter: int = 300, holdout_frac: float = 0.2,
563
+ min_samples_leaf: int = 20, random_state: int = 0) -> dict:
564
+ """Refit the std-head v2 (soft-anchor Task 2): the SAME per-row
565
+ field-spread target as `refit_gbm_std` (`log1p(std(y_nodal))`), but on
566
+ 17 features (`FEATURE_NAMES_STD_V2` = the v1 16 + graph-derived
567
+ `bc_area_frac`) and with a median (quantile=0.5) loss instead of squared
568
+ error -- tail-robust against the per-stem noise that squared-error
569
+ over-weights (see the soft-anchor design spec Sec 2).
570
+
571
+ Built via `_assemble_matrix_std_v2` (identical material/BC/graph
572
+ resolution to `_assemble_matrix_std`, plus the extra column). See
573
+ `_fit_hgb_with_metrics` for the shared fitting/metrics logic; this is
574
+ the one caller that passes `loss_kwargs`.
575
+
576
+ Returns a dict of finite metrics: `n_train`, `train_mae_log1p`,
577
+ `train_mae_seconds`, and (when there are enough rows to spare a
578
+ holdout split) `holdout_mae_log1p`.
579
+ """
580
+ X, y, used = _assemble_matrix_std_v2(train_stems, manifest_path, features_dir)
581
+ return _fit_hgb_with_metrics(
582
+ X, y, out_path, max_iter=max_iter, holdout_frac=holdout_frac,
583
+ min_samples_leaf=min_samples_leaf, random_state=random_state,
584
+ caller="refit_gbm_std_v2", loss_kwargs={"loss": "quantile", "quantile": 0.5})
585
+
586
+
587
+ def predict_std_seconds(model, X_row) -> float:
588
+ """One feature row (`FEATURE_NAMES` order, shape `(len(FEATURE_NAMES),)`
589
+ or `(1, len(FEATURE_NAMES))`) -> predicted std(y_nodal) in seconds.
590
+
591
+ Inverts the `log1p(std(y_nodal))` target `refit_gbm_std` trains on via
592
+ `expm1`, floored at 0.0 (a predicted std can never be physically
593
+ negative; `expm1` of a very negative log-prediction could underflow to
594
+ a tiny negative float).
595
+ """
596
+ x = np.asarray(X_row, dtype=np.float64).reshape(1, -1)
597
+ log_pred = float(model.predict(x)[0])
598
+ return max(0.0, float(np.expm1(log_pred)))
src/geometry.py ADDED
@@ -0,0 +1,252 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Pure-geometry helpers: triangle areas, tet volumes, nearest-surface
2
+ distances (via KD-tree), and boundary-node masks. All lengths in mm."""
3
+ import numpy as np
4
+ from scipy.spatial import cKDTree
5
+ from scipy.sparse import coo_matrix
6
+ from scipy.sparse.csgraph import dijkstra
7
+
8
+
9
+ def triangle_areas(node_xyz: np.ndarray, tri: np.ndarray) -> np.ndarray:
10
+ if tri.shape[0] == 0:
11
+ return np.zeros(0)
12
+ a = node_xyz[tri[:, 0]]
13
+ b = node_xyz[tri[:, 1]]
14
+ c = node_xyz[tri[:, 2]]
15
+ cross = np.cross(b - a, c - a)
16
+ return 0.5 * np.linalg.norm(cross, axis=1)
17
+
18
+
19
+ def total_area(node_xyz: np.ndarray, tri: np.ndarray) -> float:
20
+ return float(triangle_areas(node_xyz, tri).sum())
21
+
22
+
23
+ def tet_volumes(node_xyz: np.ndarray, tets: np.ndarray) -> np.ndarray:
24
+ if tets.shape[0] == 0:
25
+ return np.zeros(0)
26
+ a = node_xyz[tets[:, 0]]
27
+ b = node_xyz[tets[:, 1]]
28
+ c = node_xyz[tets[:, 2]]
29
+ d = node_xyz[tets[:, 3]]
30
+ return np.abs(np.einsum('ij,ij->i', b - a, np.cross(c - a, d - a))) / 6.0
31
+
32
+
33
+ def mesh_volume(node_xyz: np.ndarray, tets: np.ndarray) -> float:
34
+ if tets.shape[0] == 0:
35
+ return 0.0
36
+ return float(tet_volumes(node_xyz, tets).sum())
37
+
38
+
39
+ def nearest_distance(points: np.ndarray, targets: np.ndarray) -> np.ndarray:
40
+ """Euclidean distance from each point to the nearest target point.
41
+ Returns +inf for every point when there are no targets."""
42
+ if targets.shape[0] == 0:
43
+ return np.full(points.shape[0], np.inf)
44
+ tree = cKDTree(targets)
45
+ d, _ = tree.query(points, k=1)
46
+ return d
47
+
48
+
49
+ def boundary_node_mask(n_nodes: int, tri: np.ndarray) -> np.ndarray:
50
+ mask = np.zeros(n_nodes, dtype=bool)
51
+ if tri.shape[0]:
52
+ mask[np.unique(tri.reshape(-1))] = True
53
+ return mask
54
+
55
+
56
+ _TET_EDGE_PAIRS = ((0, 1), (0, 2), (0, 3), (1, 2), (1, 3), (2, 3))
57
+
58
+
59
+ def edges_from_tets(tets: np.ndarray) -> np.ndarray:
60
+ if tets.shape[0] == 0:
61
+ return np.empty((0, 2), dtype=np.int64)
62
+ e = np.concatenate([tets[:, [a, b]] for a, b in _TET_EDGE_PAIRS], axis=0)
63
+ e = np.sort(e, axis=1)
64
+ return np.unique(e, axis=0).astype(np.int64)
65
+
66
+
67
+ def _edge_csr(n_nodes: int, edges: np.ndarray, edge_len: np.ndarray):
68
+ r = np.concatenate([edges[:, 0], edges[:, 1]])
69
+ c = np.concatenate([edges[:, 1], edges[:, 0]])
70
+ w = np.concatenate([edge_len, edge_len])
71
+ return coo_matrix((w, (r, c)), shape=(n_nodes, n_nodes)).tocsr()
72
+
73
+
74
+ def geodesic_distance(n_nodes: int, edges: np.ndarray, edge_len: np.ndarray,
75
+ source_idx: np.ndarray) -> np.ndarray:
76
+ if source_idx.shape[0] == 0 or edges.shape[0] == 0:
77
+ return np.full(n_nodes, np.inf)
78
+ g = _edge_csr(n_nodes, edges, edge_len)
79
+ d = dijkstra(g, directed=False, indices=source_idx, min_only=True)
80
+ return d
81
+
82
+
83
+ def geodesic_diameter_two_sweep(n_nodes: int, edges: np.ndarray,
84
+ edge_len: np.ndarray, start: int = 0) -> float:
85
+ """Two-sweep (double-Dijkstra) approximation of the mesh's geodesic
86
+ diameter, over the SAME mesh-edge graph `geodesic_distance` already uses
87
+ (not a per-BC-patch quantity -- patch-independent, through-metal, a
88
+ single per-part constant): sweep 1 finds the farthest node from `start`;
89
+ sweep 2 finds the farthest node from THAT node; the sweep-2 distance is
90
+ returned as D_geo. This is the standard double-sweep heuristic for graph
91
+ diameter (exact on trees, a lower bound in general, but a good practical
92
+ estimate for a roughly part-shaped mesh graph).
93
+
94
+ `start` is a fixed node INDEX (default 0), not searched or randomized --
95
+ deterministic and reproducible for the same mesh (same node ordering).
96
+
97
+ Returns 0.0 (never NaN/inf) for an edgeless mesh, or if either sweep's
98
+ farthest reachable node has zero finite distance (degenerate/single-node
99
+ or fully-disconnected mesh) -- callers must treat 0.0 as "no valid
100
+ part-normalized length available" rather than a real zero-size part.
101
+
102
+ CAVEAT (bc-reach re-review N2): the two-sweep heuristic is a LOWER
103
+ BOUND on the true graph diameter in general (exact only on trees), and
104
+ is start-sensitive -- on a BUKSA-like real casting mesh, re-running
105
+ with different random start nodes measured ~15% spread in the reported
106
+ D_geo across starts. Fixing `start=0` makes the result perfectly
107
+ DETERMINISTIC for a given mesh (bit-identical across repeated calls,
108
+ verified in tests/test_geometry.py), which is what the cache-build and
109
+ inference paths both rely on (the same mesh must always yield the same
110
+ D_geo) -- but it does NOT make the estimate stable under a RE-MESH of
111
+ the same part (a different tetrahedralization can shift which node
112
+ lands at index 0, and therefore the estimate, by roughly that same
113
+ ~15%). Closing that gap would need iterating sweeps to convergence
114
+ (repeat from the new farthest node until the distance stops growing)
115
+ or a multi-start-and-take-max scheme -- deliberately NOT done here:
116
+ the part-normalized reach channels only need a stable-per-mesh
117
+ O(part-size) scale, not a tight diameter bound, and re-mesh stability
118
+ was judged out of scope for this task."""
119
+ if edges.shape[0] == 0 or n_nodes == 0:
120
+ return 0.0
121
+ start = int(min(max(start, 0), n_nodes - 1))
122
+ d1 = geodesic_distance(n_nodes, edges, edge_len, np.array([start], dtype=np.int64))
123
+ finite1 = np.isfinite(d1)
124
+ if not finite1.any():
125
+ return 0.0
126
+ far1 = int(np.flatnonzero(finite1)[np.argmax(d1[finite1])])
127
+ d2 = geodesic_distance(n_nodes, edges, edge_len, np.array([far1], dtype=np.int64))
128
+ finite2 = d2[np.isfinite(d2)]
129
+ return float(finite2.max()) if finite2.size else 0.0
130
+
131
+
132
+ def _local_modulus_for_query(tets: np.ndarray, cool_tri: np.ndarray, radii: np.ndarray,
133
+ chunk: int, query_idx: np.ndarray, g, tvol: np.ndarray,
134
+ tarea: np.ndarray, bbox_diag: float) -> np.ndarray:
135
+ """Exact V/A geodesic-ball modulus, computed only for the nodes in
136
+ `query_idx` (as Dijkstra sources). Returns (len(query_idx), len(radii))."""
137
+ rmax = float(radii.max())
138
+ vals = np.full((query_idx.shape[0], len(radii)), bbox_diag, dtype=np.float64)
139
+ for start in range(0, query_idx.shape[0], chunk):
140
+ sel = query_idx[start:start + chunk]
141
+ dmat = dijkstra(g, directed=False, indices=sel, limit=rmax) # (c, N)
142
+ for j, r in enumerate(radii):
143
+ in_ball = dmat <= r # (c, N)
144
+ vol = in_ball[:, tets].all(axis=2) @ tvol # (c,)
145
+ area = (in_ball[:, cool_tri].all(axis=2) @ tarea
146
+ if cool_tri.shape[0] else np.zeros(len(sel)))
147
+ vals[start:start + len(sel), j] = np.where(
148
+ area > 0, vol / np.maximum(area, 1e-12), bbox_diag)
149
+ return vals
150
+
151
+
152
+ _IDW_K = 4 # neighbors for inverse-distance-weighted subsample fill
153
+ _IDW_EPS = 1e-9 # distance floor to avoid divide-by-zero
154
+
155
+
156
+ def _idw_geodesic_fill(vals: np.ndarray, dist_from_computed: np.ndarray,
157
+ missing: np.ndarray, bbox_diag: float,
158
+ k: int = _IDW_K) -> np.ndarray:
159
+ """Inverse-distance-weighted fill for `missing` nodes from the values
160
+ (`vals`, one row per computed node) at the computed/query nodes, using
161
+ geodesic (mesh-edge) distances (`dist_from_computed`, shape
162
+ (n_computed, N)) to the k nearest computed nodes. Smoother than a
163
+ single-nearest-node copy for a continuously-varying field. Nodes
164
+ unreachable from every computed node (inf distance -- disconnected mesh
165
+ component) fall back to the bbox-diagonal sentinel."""
166
+ n_computed = vals.shape[0]
167
+ k_eff = max(1, min(k, n_computed))
168
+ sub = dist_from_computed[:, missing] # (n_computed, n_missing)
169
+ part = np.argpartition(sub, k_eff - 1, axis=0)[:k_eff] # (k_eff, n_missing)
170
+ d = np.take_along_axis(sub, part, axis=0) # (k_eff, n_missing)
171
+ finite = np.isfinite(d)
172
+ w = np.where(finite, 1.0 / np.maximum(d, _IDW_EPS), 0.0) # (k_eff, n_missing)
173
+ wsum = w.sum(axis=0) # (n_missing,)
174
+ reachable = wsum > 0
175
+ w_norm = w / np.where(reachable, wsum, 1.0) # (k_eff, n_missing)
176
+ gathered = vals[part] # (k_eff, n_missing, R)
177
+ result = np.einsum('kn,knr->nr', w_norm, gathered) # (n_missing, R)
178
+ result[~reachable] = bbox_diag
179
+ return result
180
+
181
+
182
+ def local_modulus(node_xyz: np.ndarray, tets: np.ndarray, cool_tri: np.ndarray,
183
+ edges: np.ndarray, edge_len: np.ndarray, radii,
184
+ chunk: int = 128, exact: bool = True, subsample=None) -> np.ndarray:
185
+ """Local cooling modulus per node: V/A over geodesic balls (mm).
186
+
187
+ For each node and radius r: (sum of volumes of tets whose 4 nodes all lie
188
+ within geodesic distance r of the node) / (area of cooling triangles whose
189
+ 3 nodes all lie within r). Balls with no cooling area get the bbox-diagonal
190
+ sentinel (consistent with the other distance sentinels).
191
+
192
+ `exact=True` (default): the reference per-node computation -- byte-identical
193
+ to the original (pre-subsample) implementation regardless of `subsample`.
194
+
195
+ `exact=False` with `subsample=k` (int): compute the exact ball modulus only
196
+ on every k-th node ("computed" nodes), then fill every other node by
197
+ inverse-distance-weighted (IDW) interpolation over its `_IDW_K` (=4)
198
+ nearest computed nodes, measured by mesh-edge geodesic distance
199
+ (multi-source Dijkstra over `edges`/`edge_len`, reusing `_edge_csr`; see
200
+ `_idw_geodesic_fill`). This trades a small amount of accuracy for roughly
201
+ a `k`-fold cut in the number of expensive geodesic-ball evaluations (the
202
+ dense-mesh bottleneck). If `subsample >= N` (or is None/<=1) this
203
+ degenerates to the exact path.
204
+
205
+ HONEST ACCURACY NOTE (see tests/test_local_modulus.py::
206
+ test_subsample_per_radius_column_error and task-2-report.md for the full
207
+ sweep): at the production radii (5/15/40 mm) and subsample=4, per-radius-
208
+ column median relative error on a real ~4k-node training mesh (casting-
209
+ scale bbox) is roughly 0% at r=5mm but ~25-30% at r=15mm and ~6% at
210
+ r=40mm -- i.e. the mid radius does NOT meet a 5% target. This is because
211
+ local_modulus is a ball-membership STEP FUNCTION of geodesic position
212
+ (a tet/triangle is either fully inside a ball or not), not a smoothly-
213
+ varying field -- averaging several dissimilar neighbors (larger IDW k)
214
+ measurably makes the mid-radius error WORSE, not better (single-nearest,
215
+ k=1, beats IDW k=4-8 there); k=4 is used as the best compromise within
216
+ the requested range, not because it minimizes error in isolation.
217
+ CONSEQUENCE: this subsample path is intended for DENSE-mesh inference
218
+ (FT3), where it is a useful order-of-magnitude speed/accuracy trade
219
+ against an otherwise-prohibitive `exact` cost -- it is NOT a validated
220
+ drop-in replacement for `exact=True` in the training-density feature
221
+ cache (FT2a), where `exact` is already fast enough (seconds per mesh)
222
+ and should stay the default. Do not switch `build_dataset`'s v4 feature
223
+ cache to `exact=False` without re-validating per-radius accuracy on the
224
+ actual dense meshes it will be used on."""
225
+ N = node_xyz.shape[0]
226
+ radii = np.asarray(radii, dtype=float)
227
+ bbox_diag = float(np.linalg.norm(node_xyz.max(0) - node_xyz.min(0)))
228
+ out = np.full((N, len(radii)), bbox_diag, dtype=np.float64)
229
+ if edges.shape[0] == 0 or tets.shape[0] == 0:
230
+ return out.astype(np.float32)
231
+ tvol = tet_volumes(node_xyz, tets)
232
+ tarea = triangle_areas(node_xyz, cool_tri)
233
+ g = _edge_csr(N, edges, edge_len)
234
+
235
+ use_subsample = (not exact) and subsample is not None and 1 < subsample < N
236
+ query_idx = (np.arange(0, N, subsample, dtype=np.int64) if use_subsample
237
+ else np.arange(N, dtype=np.int64))
238
+
239
+ vals = _local_modulus_for_query(tets, cool_tri, radii, chunk, query_idx, g,
240
+ tvol, tarea, bbox_diag)
241
+ out[query_idx] = vals
242
+
243
+ if use_subsample and query_idx.shape[0] < N:
244
+ missing = np.setdiff1d(np.arange(N), query_idx, assume_unique=True)
245
+ if missing.shape[0]:
246
+ # inverse-distance-weighted fill from the k nearest computed
247
+ # nodes, along mesh-edge geodesics (smoother than single-nearest)
248
+ dist_from_computed = dijkstra(g, directed=False, indices=query_idx,
249
+ min_only=False) # (M, N)
250
+ out[missing] = _idw_geodesic_fill(vals, dist_from_computed, missing, bbox_diag)
251
+
252
+ return out.astype(np.float32)
src/gnn_dataset.py ADDED
@@ -0,0 +1,51 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """PyG dataset over the v2/v3 feature cache. Builds either a kNN graph or the
2
+ SP1 union graph (mesh edges union kNN, `graph="union"`, read from the cache's
3
+ `mesh_edges`) with mm edge attributes on load; optional random-rotation
4
+ augmentation. Features and the log1p target are standardized with the cache's
5
+ global stats."""
6
+ import os.path as osp
7
+ import numpy as np
8
+ import torch
9
+ from torch.utils.data import Dataset
10
+ from torch_geometric.data import Data
11
+
12
+ from src.graph_build import (knn_edge_index, edge_attr, random_rotation_matrix, rotate,
13
+ union_edge_index, union_edge_attr)
14
+ from src.dataload import load_norm
15
+
16
+
17
+ class SolidGraphDataset(Dataset):
18
+ def __init__(self, features_dir, stems, k=16, augment=False, norm=None, graph="knn"):
19
+ self.dir = features_dir
20
+ self.stems = list(stems)
21
+ self.k = k
22
+ self.augment = augment
23
+ self.graph = graph
24
+ self.norm = norm if norm is not None else load_norm(features_dir)
25
+ self.fmean = torch.tensor(self.norm["feat_mean"], dtype=torch.float32)
26
+ self.fstd = torch.tensor(self.norm["feat_std"], dtype=torch.float32)
27
+ self.ym = float(self.norm["y_log_mean"]); self.ys = float(self.norm["y_log_std"])
28
+
29
+ def __len__(self):
30
+ return len(self.stems)
31
+
32
+ def __getitem__(self, i):
33
+ d = np.load(osp.join(self.dir, "graphs", f"{self.stems[i]}.npz"))
34
+ x = (torch.tensor(d["X"], dtype=torch.float32) - self.fmean) / self.fstd
35
+ pos = torch.tensor(d["pos"], dtype=torch.float32)
36
+ y_log = torch.log1p(torch.tensor(d["y"], dtype=torch.float32))
37
+ y = (y_log - self.ym) / self.ys
38
+ g = torch.tensor(d["g"], dtype=torch.float).reshape(1, -1) if "g" in d.files else None
39
+ if self.augment:
40
+ pos = rotate(pos, random_rotation_matrix())
41
+ if self.graph == "union":
42
+ if "mesh_edges" not in d.files:
43
+ raise KeyError(f"{self.stems[i]}: cache lacks mesh_edges — "
44
+ f"rebuild with build_dataset --feature-version 3")
45
+ me = torch.tensor(d["mesh_edges"], dtype=torch.long)
46
+ ei, is_mesh = union_edge_index(pos, me, k=self.k)
47
+ ea = union_edge_attr(pos, ei, is_mesh)
48
+ else:
49
+ ei = knn_edge_index(pos, k=self.k)
50
+ ea = edge_attr(pos, ei)
51
+ return Data(x=x, edge_index=ei, edge_attr=ea, y=y, pos=pos, g=g)
src/graph_build.py ADDED
@@ -0,0 +1,52 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Mesh-agnostic graph construction: kNN edges with millimetre edge attributes,
2
+ and random 3D-rotation augmentation (physics is rotation-invariant)."""
3
+ import torch
4
+ from torch_geometric.nn import knn_graph
5
+ from torch_geometric.utils import to_undirected
6
+
7
+
8
+ def knn_edge_index(pos: torch.Tensor, k: int = 16) -> torch.Tensor:
9
+ ei = knn_graph(pos, k=k, loop=False) # directed k-nearest
10
+ return to_undirected(ei) # symmetric
11
+
12
+
13
+ def edge_attr(pos: torch.Tensor, edge_index: torch.Tensor) -> torch.Tensor:
14
+ src, dst = edge_index
15
+ d = pos[dst] - pos[src] # (E,3) mm
16
+ return torch.cat([d, d.norm(dim=1, keepdim=True)], dim=1) # (E,4)
17
+
18
+
19
+ def random_rotation_matrix(seed: int | None = None) -> torch.Tensor:
20
+ g = torch.Generator().manual_seed(seed) if seed is not None else None
21
+ # QR of a random Gaussian matrix gives a Haar-random orthogonal matrix
22
+ a = torch.randn(3, 3, generator=g)
23
+ q, r = torch.linalg.qr(a)
24
+ q = q * torch.sign(torch.diagonal(r)) # fix signs
25
+ if torch.det(q) < 0: # ensure proper rotation (det +1)
26
+ q[:, 0] = -q[:, 0]
27
+ return q
28
+
29
+
30
+ def rotate(pos: torch.Tensor, R: torch.Tensor) -> torch.Tensor:
31
+ return pos @ R.to(pos.device, pos.dtype).t()
32
+
33
+
34
+ def union_edge_index(pos: torch.Tensor, mesh_edges: torch.Tensor, k: int = 16):
35
+ """Undirected union of tet-mesh edges and kNN edges, deduplicated.
36
+
37
+ mesh_edges: (M,2) long tensor of unique node pairs (any orientation).
38
+ Returns (edge_index (2,E) long, is_mesh (E,) float32 flag)."""
39
+ n = pos.shape[0]
40
+ knn = knn_edge_index(pos, k=k).to(torch.long)
41
+ me = mesh_edges.t().to(torch.long)
42
+ me = torch.cat([me, me.flip(0)], dim=1) # both directions
43
+ mesh_key = me[0] * n + me[1]
44
+ keys = torch.unique(torch.cat([mesh_key, knn[0] * n + knn[1]]))
45
+ ei = torch.stack([keys // n, keys % n])
46
+ is_mesh = torch.isin(keys, mesh_key).to(torch.float32)
47
+ return ei, is_mesh
48
+
49
+
50
+ def union_edge_attr(pos: torch.Tensor, edge_index: torch.Tensor,
51
+ is_mesh: torch.Tensor) -> torch.Tensor:
52
+ return torch.cat([edge_attr(pos, edge_index), is_mesh.unsqueeze(1)], dim=1) # (E,5)
src/hybrid.py ADDED
@@ -0,0 +1,639 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Hybrid predictor: GNN field *shape* + baseline absolute *scale*.
2
+
3
+ The GNN captures the within-graph solidification field shape almost perfectly
4
+ (per-graph corr ~0.98, hot-spot ~0.88) but has a per-graph absolute-scale bias.
5
+ The physics-feature baseline (HGB) predicts the per-graph magnitude well. The
6
+ hybrid keeps the GNN's deviations from its own per-graph mean and re-anchors the
7
+ per-graph level to the baseline's per-graph mean:
8
+
9
+ pred_i = gnn_i - mean_g(gnn) + mean_g(baseline)
10
+
11
+ This preserves the GNN's hot-spot/variance advantage while fixing the offset,
12
+ and beats both parents on MAE and hot-spot across all splits.
13
+ """
14
+ import logging
15
+
16
+ import numpy as np
17
+
18
+ logger = logging.getLogger(__name__)
19
+
20
+
21
+ def hybrid_field(gnn_seconds: np.ndarray, baseline_seconds: np.ndarray) -> np.ndarray:
22
+ """Combine one casting's per-node predictions (both in seconds, same node order).
23
+
24
+ Shape from `gnn_seconds`, absolute level from `baseline_seconds`. Clipped at 0.
25
+ """
26
+ g = np.asarray(gnn_seconds, dtype=np.float64)
27
+ b = np.asarray(baseline_seconds, dtype=np.float64)
28
+ out = g - g.mean() + b.mean()
29
+ return np.clip(out, 0.0, None)
30
+
31
+
32
+ def hybrid_field_max_anchor(gnn_seconds: np.ndarray, total_seconds: float,
33
+ q: float = 0.995) -> np.ndarray:
34
+ """v4 hybrid recomposition: GNN field *shape* + a per-graph quantile-anchor scale.
35
+
36
+ `pred_i = gnn_i - anchor + total`, clipped at 0, where
37
+ `anchor = np.quantile(gnn_seconds, q)`. Unlike `hybrid_field`'s
38
+ MEAN-anchor (correct for a genuine per-node baseline), the FT2b GBM scale
39
+ head (`src.gbm_ft2b`) predicts a single per-graph SCALAR -- the total
40
+ solidification time (the field's true max), not a per-node mean.
41
+ Broadcasting that scalar and MEAN-anchoring would force
42
+ `mean(pred) == total`, inflating every node by `(true_max - true_mean)`.
43
+ Anchoring to a high quantile of the GNN field instead makes
44
+ `max(pred) ~= total` (the quantity actually predicted) while preserving
45
+ the GNN's field shape (its deviations from that anchor).
46
+
47
+ `q=1.0` reproduces the previous exact-max behavior bit-for-bit
48
+ (`np.quantile(x, 1.0) == x.max()`): `max(pred) == total` exactly. The
49
+ DEFAULT `q=0.995` exists because the exact-max anchor is fragile to a
50
+ handful of outlier nodes -- a promotion-gate smoke test found a few
51
+ inflated GNN log-space predictions on a real casting dragging ~90% of
52
+ the field below the zero clip, because the single max node set the
53
+ entire graph's offset. Anchoring to the 99.5th percentile instead makes
54
+ the offset robust to a small number of outliers, at the cost that nodes
55
+ ABOVE the anchor (up to ~0.5% of nodes, by construction) may now exceed
56
+ `total` slightly. This does not affect hot-spot ranking or field shape:
57
+ the anchor choice is still a per-graph ADDITIVE CONSTANT shift, so
58
+ within-graph node ordering (`argsort`) is unchanged, and only the
59
+ reported `float(pred.max())` total-time summary can differ slightly
60
+ from `total`. See `src/evaluate_ft2b.py`'s module docstring ("v4 hybrid
61
+ recomposition: QUANTILE-anchor (q=0.995), not MEAN-anchor") for the full anchor-choice
62
+ rationale -- this is the single source; `evaluate_ft2b` imports this
63
+ function under its former private name, and the same default `q` applies
64
+ everywhere (gate evaluation, checkpoint selection, and production
65
+ inference all share this one function).
66
+ """
67
+ g = np.asarray(gnn_seconds, dtype=np.float64)
68
+ anchor = np.quantile(g, q)
69
+ out = g - anchor + float(total_seconds)
70
+ return np.clip(out, 0.0, None)
71
+
72
+
73
+ def hybrid_field_affine(gnn_seconds: np.ndarray, total_seconds: float,
74
+ std_seconds: float, q: float = 0.995,
75
+ a_clamp: tuple = (0.3, 5.0),
76
+ lambda_shrink: float = 1.0) -> tuple:
77
+ """Affine hybrid recomposition: GNN field *shape* rescaled to match TWO scalar
78
+ targets from the GBM scale head -- a per-graph total AND a per-graph spread --
79
+ instead of `hybrid_field_max_anchor`'s single total-only anchor.
80
+
81
+ `hybrid_field_max_anchor` only re-anchors the GNN field's level (an additive
82
+ shift); it inherits the GNN's own spread (std) verbatim. FT3 gate 4
83
+ (`runs/ft3_stage1/FT3_GATE_REPORT.md`) found that spread can be badly wrong
84
+ even when the level and hot-spot ranking are fixed: the GNN's std can
85
+ collapse relative to ground truth on regimes it under-fits, and a pure
86
+ level-shift cannot repair that -- e.g. the report's BUKSA case, where a
87
+ refit GBM anchor overshot the true total by ~+190 s because the GNN field's
88
+ shape carried a whole-field bias that a level-only anchor could not absorb.
89
+ This function additionally SCALES the field's deviations from its own mean
90
+ by a factor `a`, then re-anchors so `np.quantile(pred, q) == total_seconds`
91
+ (same quantile-anchor rationale as `hybrid_field_max_anchor` -- robust to a
92
+ handful of outlier nodes, at the cost that nodes above the anchor may
93
+ slightly exceed `total_seconds`). Scaling deviations by a positive constant
94
+ and then shifting by an additive constant is a strictly increasing affine
95
+ map, so within-graph node ordering (`argsort`, hot-spot ranking) is
96
+ unchanged from the raw GNN field -- exactly as for `hybrid_field_max_anchor`.
97
+
98
+ AMENDMENT (owner-approved 2026-07-31, see the "AMENDMENT" block in
99
+ `docs/superpowers/specs/2026-07-31-variance-recomposition-design.md`): the
100
+ field is 0-bounded (solidification time can't be negative) but this affine
101
+ map is symmetric around the field's own mean, so on real (skewed, clip-prone)
102
+ parts a naive `a = std_seconds / std(gnn_seconds)` collapses under its own
103
+ clip. The e_val eval matrix found 21-34% of nodes clipping and 33-35% of
104
+ chills/exo stems clamp-saturated at the old `a<=3.0` ceiling, leaving group
105
+ std_ratio at ~0.83 against the 0.9 floor. `a` is therefore solved so the
106
+ POST-CLIP field matches `std_seconds`, not the naive pre-clip ratio:
107
+
108
+ c = gnn_seconds - mean(gnn_seconds)
109
+ d = c - quantile(c, q) # constant vector; independent of a
110
+ raw(a) = a * d + total_seconds # quantile(raw(a), q) == total_seconds for any a>0
111
+ f(a) = std(clip(raw(a), 0, None))
112
+ a = solve f(a) == std_seconds over a in a_clamp
113
+
114
+ (`raw(a) = a*d + total_seconds` follows from the quantile-anchor property:
115
+ anchoring `a*c` at `total_seconds` gives `m(a) = total_seconds - a*quantile(c, q)`,
116
+ so `a*c + m(a) = a*(c - quantile(c, q)) + total_seconds = a*d + total_seconds`.)
117
+
118
+ CORRECTION (2026-07-31 review): `f` is **NOT monotone** on realistic
119
+ fields. Below `a_clipstart = total_seconds / max(-d.min(), eps)` nothing
120
+ clips and `f(a) = a * std(gnn_seconds)` exactly (the pre-amendment closed
121
+ form). PAST `a_clipstart`, clipping starts eating the field's low tail;
122
+ on real (23/25 sampled `data/features_v4` graphs) `y` fields this makes
123
+ `f` RISE to an interior peak just past `a_clipstart` and then FALL as
124
+ growing clip mass collapses variance faster than the surviving nodes'
125
+ growing spread can compensate. A naive `f(hi) < std_target => clamp to
126
+ hi` pre-check is therefore wrong: it can fire even when a perfectly good
127
+ (much-less-clipped) root exists on the RISING part of the curve, close to
128
+ `a_clipstart` -- on real data this false-clamp inflated MAE from 85.1 (at
129
+ the true root) to 359.5 (at the falsely-clamped `a=5.0`), because clamping
130
+ to `hi` clips 61-90% of the field instead of the true root's ~5-30%.
131
+
132
+ Solve semantics, in order:
133
+ 1. If `a_raw = std_seconds / std(gnn_seconds) <= a_clipstart`: nothing
134
+ clips at `a_raw`, so use the closed form directly (clamped to
135
+ `a_clamp` -- fast path, identical to the pre-amendment closed form
136
+ to floating-point precision).
137
+ 2. Otherwise, coarse-scan `a` over `[max(a_clipstart, a_clamp[0]),
138
+ a_clamp[1]]` (64 points) for the FIRST sign change of
139
+ `f(a) - std_seconds`. Because `f` starts below `std_seconds` at
140
+ `a_clipstart` (by construction of this branch) and is scanned
141
+ low-to-high, the first crossing is the SMALLEST root -- i.e. the
142
+ LEAST-clipped `a` that hits the target, always preferred over any
143
+ larger root further out on the peak's falling side. Bisect inside
144
+ that bracket to refine it; `info["a_clamped"]` is `False`.
145
+ 3. If no crossing exists anywhere on the scan (`std_seconds` exceeds
146
+ even the interior peak, or the scan range is empty), the target is
147
+ genuinely unreachable: pick the SCANNED POINT whose `f` is closest
148
+ to `std_seconds` (not blindly `a_clamp[1]` -- the peak is often a
149
+ much better fit than the ceiling, exactly the failure mode above)
150
+ and set `info["a_clamped"] = True`.
151
+
152
+ `info["clip_frac"]` reports how much of the FINAL field hit the 0 floor,
153
+ so callers can flag graphs where the GBM's total/std pair is inconsistent
154
+ with the GNN's shape; if `clip_frac > 0.5` (a majority of the field
155
+ clipped -- a strong scale/anchor-inconsistency signal) this also logs a
156
+ warning.
157
+
158
+ Degenerate fallback: if the GNN field is (numerically) flat, `a` is
159
+ undefined (division by ~0), so this falls back to
160
+ `hybrid_field_max_anchor(gnn_seconds, total_seconds, q=q)` and logs a
161
+ warning; `info["degenerate_fallback"]` is `True` in that case and `a` is
162
+ reported as `1.0` (a no-op scale, since there is no shape to rescale).
163
+ This guard is unaffected by the amendment above -- it is checked before
164
+ any `a`-solve is attempted.
165
+
166
+ AMENDMENT 2 (owner-approved 2026-07-31, see the "AMENDMENT 2" block in
167
+ `docs/superpowers/specs/2026-07-31-variance-recomposition-design.md`):
168
+ the corrected solver exposed the next layer of error -- the GBM std
169
+ head's own per-part noise (p10-p90 ~ 0.66-1.36x; BUKSA gold
170
+ over-predicted at ~1.3x) feeds straight into MAE through the spread it
171
+ now honestly hits. `lambda_shrink` (default `1.0`, fully trusting
172
+ `std_seconds`) lets a caller partially discount that noisy target via
173
+ GEOMETRIC interpolation between the GNN's OWN spread and the GBM's
174
+ predicted spread, applied BEFORE the (unchanged) clip-aware solve above:
175
+
176
+ std_eff = std(gnn_seconds)**(1 - lambda_shrink) * std_seconds**lambda_shrink
177
+
178
+ `lambda_shrink=1.0` reproduces the full-trust behavior exactly (`std_eff
179
+ == std_seconds`) -- this is the ONLY code path change, gated by `if
180
+ lambda_shrink != 1.0:` so the default is untouched byte-for-bit.
181
+ `lambda_shrink=0.0` degenerates to `std_eff == std(gnn_seconds)`, i.e.
182
+ the solve targets the GNN's own (unscaled) spread -- in a no-clip case
183
+ this makes `a == 1.0` and the result IS `hybrid_field_max_anchor`'s
184
+ output exactly (no spread rescaling at all, quantile-anchor-only level
185
+ shift). Intermediate `lambda_shrink` partially shrinks the GBM's spread
186
+ prediction toward the GNN's own, geometric-mean-style. Selection
187
+ protocol (owner): `lambda_shrink` is tuned on `e_val` only (per-BC
188
+ `std_ratio` vs. the 0.9 floor + MAE); the GOLD assemblies stay a pure
189
+ held-out test, evaluated once at the chosen value.
190
+
191
+ `std_seconds <= 0` is a degenerate SCALE target (the geometric
192
+ interpolation is undefined/nonsensical for a non-positive base) --
193
+ ONLY WHEN `lambda_shrink != 1.0` (the default path never inspects
194
+ `std_seconds`'s sign, matching pre-amendment-2 behavior exactly), this
195
+ falls back to `hybrid_field_max_anchor` with the same loud-warning
196
+ pattern as the degenerate-GNN-field guard above.
197
+
198
+ Returns `(pred, info)` where `info = {"a": float, "a_clamped": bool,
199
+ "degenerate_fallback": bool, "clip_frac": float, "lambda_shrink": float,
200
+ "std_target_effective": float}`. NOTE `a_clamped` semantics since the
201
+ smallest-root correction: True means "the requested std target was
202
+ UNREACHABLE" (closest-fit `a` returned, which may be an interior scan
203
+ point) -- it no longer implies `a` sits exactly on an `a_clamp`
204
+ boundary. `std_target_effective` is `std_eff` above (`== std_seconds`
205
+ when `lambda_shrink == 1.0`).
206
+ """
207
+ g = np.asarray(gnn_seconds, dtype=np.float64)
208
+ s = float(np.std(g))
209
+ lambda_shrink = float(lambda_shrink)
210
+ if s < 1e-9:
211
+ logger.warning("hybrid_field_affine: degenerate flat GNN field (std=%.3g) -- "
212
+ "falling back to quantile-anchor recomposition", s)
213
+ pred = hybrid_field_max_anchor(g, total_seconds, q=q)
214
+ return pred, {"a": 1.0, "a_clamped": False, "degenerate_fallback": True,
215
+ "clip_frac": float((pred == 0.0).mean()),
216
+ "lambda_shrink": lambda_shrink,
217
+ "std_target_effective": float(std_seconds)}
218
+
219
+ std_target = float(std_seconds)
220
+ if lambda_shrink != 1.0:
221
+ if std_target <= 0.0:
222
+ logger.warning("hybrid_field_affine: degenerate std target "
223
+ "(std_seconds=%.3g, lambda_shrink=%.3g) -- falling back to "
224
+ "quantile-anchor recomposition", std_target, lambda_shrink)
225
+ pred = hybrid_field_max_anchor(g, total_seconds, q=q)
226
+ return pred, {"a": 1.0, "a_clamped": False, "degenerate_fallback": True,
227
+ "clip_frac": float((pred == 0.0).mean()),
228
+ "lambda_shrink": lambda_shrink,
229
+ "std_target_effective": std_target}
230
+ std_target = (s ** (1.0 - lambda_shrink)) * (std_target ** lambda_shrink)
231
+ std_target_effective = std_target
232
+
233
+ lo, hi = float(a_clamp[0]), float(a_clamp[1])
234
+ total = float(total_seconds)
235
+
236
+ # `d` and `a_clipstart` depend only on the (fixed) GNN field, the quantile
237
+ # `q`, and `total` -- computed ONCE, not inside any search loop.
238
+ c = g - g.mean()
239
+ shift = float(np.quantile(c, q))
240
+ d = c - shift
241
+ eps = 1e-9
242
+ a_clipstart = total / max(-float(d.min()), eps)
243
+
244
+ def _raw_for_a(a):
245
+ return a * d + total
246
+
247
+ def _post_clip_std(a):
248
+ return float(np.std(np.clip(_raw_for_a(a), 0.0, None)))
249
+
250
+ a_raw = std_target / s
251
+
252
+ if a_raw <= a_clipstart:
253
+ # At a_raw <= a_clipstart the SOLUTION a_raw clips nothing, so
254
+ # f(a_raw) = a_raw*s exactly (closed form; identical to the
255
+ # pre-amendment behavior to floating-point precision). NOTE: if
256
+ # a_raw < lo the clamp to lo can still clip (total << spread).
257
+ a = float(np.clip(a_raw, lo, hi))
258
+ a_clamped = bool(a != a_raw)
259
+ raw = _raw_for_a(a)
260
+ else:
261
+ # a_raw would clip. f is not monotone past a_clipstart -- it rises to
262
+ # an interior peak, then falls. Coarse-scan for the smallest (first,
263
+ # least-clipping) root and bisect to refine it.
264
+ lo_search = max(a_clipstart, lo)
265
+ if lo_search >= hi:
266
+ a, a_clamped = hi, True
267
+ raw = _raw_for_a(a)
268
+ else:
269
+ n_scan = 64
270
+ scan_a = np.linspace(lo_search, hi, n_scan)
271
+ scan_f = np.array([_post_clip_std(x) for x in scan_a])
272
+ diffs = scan_f - std_target
273
+ crossing = None
274
+ for i in range(1, n_scan):
275
+ if diffs[i - 1] == 0.0 or diffs[i - 1] * diffs[i] <= 0.0:
276
+ crossing = i
277
+ break
278
+ if crossing is not None:
279
+ blo, bhi = float(scan_a[crossing - 1]), float(scan_a[crossing])
280
+ flo = float(diffs[crossing - 1])
281
+ if flo == 0.0:
282
+ a = blo
283
+ else:
284
+ for _ in range(50):
285
+ mid = 0.5 * (blo + bhi)
286
+ fm = _post_clip_std(mid) - std_target
287
+ if (fm > 0.0) == (flo > 0.0):
288
+ blo, flo = mid, fm
289
+ else:
290
+ bhi = mid
291
+ if (bhi - blo) <= 1e-9 * max(abs(bhi), 1e-12):
292
+ break
293
+ a = 0.5 * (blo + bhi)
294
+ a_clamped = False
295
+ raw = _raw_for_a(a)
296
+ else:
297
+ # No crossing anywhere on the scan: std_target is unreachable
298
+ # (e.g. it exceeds the interior peak). Pick the scanned point
299
+ # CLOSEST to the target rather than blindly a_clamp[1].
300
+ best_idx = int(np.argmin(np.abs(diffs)))
301
+ a = float(scan_a[best_idx])
302
+ a_clamped = True
303
+ raw = _raw_for_a(a)
304
+
305
+ clip_frac = float((raw < 0.0).mean())
306
+ if clip_frac > 0.5:
307
+ logger.warning("hybrid_field_affine: majority of field clipped "
308
+ "(clip_frac=%.3f) -- GBM total/std may be inconsistent "
309
+ "with the GNN shape", clip_frac)
310
+ return np.clip(raw, 0.0, None), {"a": a, "a_clamped": a_clamped,
311
+ "degenerate_fallback": False, "clip_frac": clip_frac,
312
+ "lambda_shrink": lambda_shrink,
313
+ "std_target_effective": std_target_effective}
314
+
315
+
316
+ def hybrid_field_soft(gnn_seconds: np.ndarray, total_seconds: float, std_seconds: float,
317
+ q: float = 0.995, a_clamp: tuple = (0.3, 5.0),
318
+ w_total: float = 1.0, w_std: float = 1.0,
319
+ lambda_shrink: float = 1.0) -> tuple:
320
+ """Soft-anchor recomposition: `hybrid_field_affine` generalized to a WEIGHTED
321
+ two-target objective, so the total anchor may itself deviate a few percent
322
+ when that trade buys a much closer spread match.
323
+
324
+ WHY (see `docs/superpowers/specs/2026-07-31-soft-anchor-design.md` §1):
325
+ `hybrid_field_affine`'s quantile-anchor is a HARD constraint
326
+ (`quantile(pred, q) == total_seconds` exactly, by construction of its
327
+ `m(a)`), and the field is 0-clipped from below. Those two constraints
328
+ together form a ceiling: on some real parts (e_val exo stems, std_ratio
329
+ 0.874 < the 0.9 floor) there is NO `a` in `a_clamp` for which the
330
+ post-clip field can reach `std_seconds` while ALSO satisfying the hard
331
+ top-anchor exactly -- the affine solve already returns the closest-fit
332
+ root, and that is provably the best available under the hard constraint.
333
+ Relaxing the top anchor into a soft (penalized) term recovers a strictly
334
+ larger solution space: allowing `quantile(pred, q)` to sit a bit off
335
+ `total_seconds` can unlock a much less-clipped field whose spread lands
336
+ far closer to `std_seconds`, at a small, bounded cost to the total.
337
+
338
+ Objective (both residuals normalized by their own target, so the weights
339
+ are scale-free across parts of wildly different absolute magnitude):
340
+
341
+ pred(a, m) = clip(a * (gnn - mean(gnn)) + m, 0, None)
342
+ r_total = (quantile(pred, q) - total_seconds) / total_seconds
343
+ r_std = (std(pred) - std_target_eff) / std_target_eff
344
+ J(a, m) = w_total * r_total**2 + w_std * r_std**2
345
+
346
+ where `std_target_eff` is `std_seconds` shrunk toward the GNN's own
347
+ spread by `lambda_shrink` (identical convention to `hybrid_field_affine`;
348
+ delegated to it -- see its AMENDMENT 2 docstring). `a` ranges over
349
+ `a_clamp`; `m` is a free additive constant (no longer tied to the hard
350
+ anchor's `m(a) = total - a*quantile(centered_gnn, q)`, though that
351
+ formula is exactly where the SEED point comes from, below).
352
+
353
+ Solver: `hybrid_field_affine`'s own clip-aware smallest-root solve
354
+ supplies the SEED `(a_seed, m_seed)` -- `a_seed` is its returned `a`;
355
+ `m_seed` is recovered from the identity `m(a) = total_seconds -
356
+ a*quantile(gnn - mean(gnn), q)` that its solve always satisfies (true in
357
+ every branch, including both degenerate fallbacks: `hybrid_field_max_anchor`
358
+ is itself `a=1, m = total - quantile(centered_gnn, q)` after the same
359
+ quantile-shift-equivariance identity). A coarse grid -- 64 log-spaced `a`
360
+ over `a_clamp` x 33 linear `m` CENTERED ON THE SEED, `m_seed +
361
+ total_seconds*[-0.3, +0.3]` -- is evaluated (vectorized per row: one
362
+ clip+quantile+std pass per `a`, across all `m` at once), PLUS the single
363
+ exact seed point `(a_seed, m_seed)` folded into the running best (not
364
+ cross-producted against either axis -- that would silently widen the
365
+ search past the intended band). REVIEW FIX (2026-07-31, round 1,
366
+ CRITICAL-1): the window was originally the ABSOLUTE
367
+ `total_seconds*[0.7,1.3]` (centered on `total_seconds`, not on
368
+ `m_seed`) -- measured on 58 real `features_v4` fields, `m_seed/total in
369
+ [0.03, 0.61]`, i.e. `m_seed` was NEVER inside that window, so the grid
370
+ could only ever explore points strictly worse than the seed and the
371
+ solver never engaged on the exo regime it exists for. Centering the
372
+ window on `m_seed` is what actually lets the search reach nearby, less-
373
+ clipped `(a, m)` combinations. ONE 9x9 local refinement grid (log-spaced
374
+ `a` / linear `m`, windowed to one coarse grid cell around whichever point
375
+ -- grid or seed -- is best so far, bounded within the same centered
376
+ window) is then evaluated the same way.
377
+
378
+ DEGENERATION GUARANTEE: `J_seed` is computed EXACTLY at `(a_seed, m_seed)`
379
+ before any grid search runs (not interpolated from a grid cell -- it is a
380
+ direct, separate evaluation). A grid or refinement candidate is accepted
381
+ ONLY if it beats the seed by a strict margin (`J_candidate < J_seed -
382
+ 1e-12`); otherwise this returns the SEED array unchanged (same object,
383
+ not a recomputation) with the seed's own residuals. This is the guard
384
+ against the v5 track's earlier critical finding that `f` (the affine
385
+ solve's post-clip std, as a function of `a`) is NOT monotone/convex on
386
+ real fields -- the same non-convexity applies to `J(a, m)` here, so the
387
+ grid+refinement scheme never ASSUMES a single interior optimum; it only
388
+ ever accepts a point that is verifiably better than the seed by direct
389
+ evaluation. On any field where the affine seed already satisfies both
390
+ targets (no-clip case), this makes `hybrid_field_soft` numerically
391
+ identical to `hybrid_field_affine` -- and, as a PERFORMANCE consequence
392
+ (REVIEW FIX, CRITICAL-2a), when `J_seed < 1e-12` this returns immediately
393
+ WITHOUT building or evaluating any grid at all (healthy/no-clip parts pay
394
+ only the affine solve's own cost). When the grid does run (REVIEW FIX,
395
+ CRITICAL-2b), its per-`a` quantile term is computed via the closed-form
396
+ `a*shift + m` (exact whenever `clip_frac < 1 - q`, which always holds in
397
+ practice -- see the in-code comment above `_grid_eval`) instead of a per-
398
+ point `np.quantile`/`np.partition` call, and the grid's winning candidate
399
+ is re-evaluated with the exact `_residuals()` helper before the
400
+ accept/reject decision, so no approximation reaches the returned
401
+ `info`/prediction -- this keeps a 10k-node solve inside the
402
+ ~200ms budget even when the search engages.
403
+
404
+ `lambda_shrink` and the degenerate-field/degenerate-std-target fallbacks
405
+ are delegated verbatim to `hybrid_field_affine` (same warnings, same
406
+ `hybrid_field_max_anchor` escape hatch) -- when `info["degenerate_fallback"]`
407
+ comes back `True` there is no field shape left to trade off, so this
408
+ returns that fallback's prediction unchanged (no grid search).
409
+
410
+ Returns `(pred, info)` where `info = {"a": float, "m": float,
411
+ "target_unreachable": bool, "degenerate_fallback": bool,
412
+ "clip_frac": float, "total_deviation_frac": float, "j_total_resid": float,
413
+ "j_std_resid": float, "lambda_shrink": float,
414
+ "std_target_effective": float}`. `target_unreachable` repurposes
415
+ `hybrid_field_affine`'s `a_clamped` (`True` means the HARD two-target
416
+ problem the affine seed solved was itself unreachable at some `a` -- the
417
+ signal that motivated relaxing to a soft objective in the first place).
418
+ `clip_frac` is measured on the FINAL (returned) field. `total_deviation_frac
419
+ = r_total` of the final field (positive = the top anchor rose above
420
+ `total_seconds`). `j_total_resid`/`j_std_resid` are `J`'s two weighted,
421
+ squared terms at the final `(a, m)` (`J == j_total_resid + j_std_resid`).
422
+
423
+ REVIEW FIX (round 1, LOW): `w_total`/`w_std` must both be `>= 0` (a
424
+ negative weight REWARDS moving away from that target instead of
425
+ penalizing deviation from it -- a reviewer-demonstrated `w_total=-1.0`
426
+ silently drove `a` to the `a_clamp` ceiling with a +273% total
427
+ deviation, since minimizing `J` then means MAXIMIZING `r_total**2`) and
428
+ may not both be exactly `0` (the objective `J(a, m)` would be
429
+ identically zero everywhere, leaving the grid/refinement search no
430
+ signal to optimize against at all) -- either violation raises a loud
431
+ `ValueError` before any solve is attempted.
432
+ """
433
+ g = np.asarray(gnn_seconds, dtype=np.float64)
434
+ total = float(total_seconds)
435
+ q = float(q)
436
+ w_total = float(w_total)
437
+ w_std = float(w_std)
438
+
439
+ # REVIEW FIX (round 1, LOW): a negative weight would reward the search
440
+ # for moving AWAY from that target rather than penalizing deviation from
441
+ # it (minimizing J then means MAXIMIZING that residual's square), and
442
+ # both-zero weights make J identically zero everywhere -- fail loudly
443
+ # rather than silently returning a meaningless/adversarial optimum.
444
+ if w_total < 0.0 or w_std < 0.0:
445
+ raise ValueError(
446
+ f"hybrid_field_soft: w_total/w_std must be >= 0 (got "
447
+ f"w_total={w_total!r}, w_std={w_std!r}) -- a negative weight would "
448
+ f"reward moving AWAY from that target instead of penalizing "
449
+ f"deviation from it")
450
+ if w_total == 0.0 and w_std == 0.0:
451
+ raise ValueError(
452
+ "hybrid_field_soft: w_total and w_std cannot both be 0 -- the "
453
+ "objective J(a, m) would be identically zero everywhere, leaving "
454
+ "the search no signal to optimize against")
455
+
456
+ # REVIEW FIX (LOW-6): np.logspace(log10(a_clamp[0]), ...) below silently
457
+ # produces NaN/-inf for a non-positive lower clamp; fail loudly instead,
458
+ # regardless of whether this call ends up taking the no-grid fast path.
459
+ if float(a_clamp[0]) <= 0.0:
460
+ raise ValueError(f"hybrid_field_soft: a_clamp[0] must be > 0 (got {a_clamp[0]!r}) "
461
+ "-- the a-grid is log-spaced and cannot include a non-positive "
462
+ "lower bound")
463
+
464
+ pred_seed, info_affine = hybrid_field_affine(g, total, std_seconds, q=q,
465
+ a_clamp=a_clamp,
466
+ lambda_shrink=lambda_shrink)
467
+
468
+ # `c`/`shift` recover m(a) = total - a*shift for ANY branch of the affine
469
+ # solve, including both its degenerate fallbacks (each is exactly a=1,
470
+ # m = total - shift, by the quantile-shift-equivariance identity used in
471
+ # `hybrid_field_affine`'s own AMENDMENT derivation).
472
+ c = g - g.mean()
473
+ shift = float(np.quantile(c, q))
474
+ a_seed = float(info_affine["a"])
475
+ m_seed = total - a_seed * shift
476
+ std_target_eff = float(info_affine["std_target_effective"])
477
+
478
+ denom_total = total if abs(total) > 1e-9 else (1e-9 if total >= 0.0 else -1e-9)
479
+ denom_std = std_target_eff if abs(std_target_eff) > 1e-9 else 1e-9
480
+
481
+ def _residuals(pred):
482
+ r_total = (float(np.quantile(pred, q)) - total) / denom_total
483
+ r_std = (float(np.std(pred)) - std_target_eff) / denom_std
484
+ return r_total, r_std
485
+
486
+ def _build_info(final_a, final_m, final_rt, final_rs, clip_frac, target_unreachable):
487
+ if clip_frac > 0.5:
488
+ logger.warning("hybrid_field_soft: majority of the accepted soft-anchor "
489
+ "field clipped (clip_frac=%.3f) -- GBM total/std may be "
490
+ "inconsistent with the GNN shape", clip_frac)
491
+ return {
492
+ "a": final_a, "m": final_m, "target_unreachable": target_unreachable,
493
+ "degenerate_fallback": False, "clip_frac": clip_frac,
494
+ "total_deviation_frac": final_rt,
495
+ "j_total_resid": float(w_total * final_rt ** 2),
496
+ "j_std_resid": float(w_std * final_rs ** 2),
497
+ "lambda_shrink": info_affine["lambda_shrink"],
498
+ "std_target_effective": std_target_eff,
499
+ }
500
+
501
+ r_total_seed, r_std_seed = _residuals(pred_seed)
502
+ j_seed = w_total * r_total_seed ** 2 + w_std * r_std_seed ** 2
503
+
504
+ if info_affine["degenerate_fallback"]:
505
+ return pred_seed, {
506
+ "a": a_seed, "m": m_seed, "target_unreachable": False,
507
+ "degenerate_fallback": True, "clip_frac": info_affine["clip_frac"],
508
+ "total_deviation_frac": r_total_seed,
509
+ "j_total_resid": float(w_total * r_total_seed ** 2),
510
+ "j_std_resid": float(w_std * r_std_seed ** 2),
511
+ "lambda_shrink": info_affine["lambda_shrink"],
512
+ "std_target_effective": std_target_eff,
513
+ }
514
+
515
+ target_unreachable = bool(info_affine["a_clamped"])
516
+ seed_clip_frac = float((c * a_seed + m_seed < 0.0).mean())
517
+
518
+ # REVIEW FIX (CRITICAL-2a): when the seed already satisfies both targets
519
+ # (J_seed ~ 0), skip the grid/refinement search entirely -- healthy,
520
+ # no-clip parts then pay only the affine solve's own cost (~ms), not the
521
+ # grid's. This is the common case in production (most parts do not need
522
+ # the soft trade-off at all).
523
+ if j_seed < 1e-12:
524
+ return pred_seed, _build_info(a_seed, m_seed, r_total_seed, r_std_seed,
525
+ seed_clip_frac, target_unreachable)
526
+
527
+ lo, hi = float(a_clamp[0]), float(a_clamp[1])
528
+ n_a, n_m = 64, 33
529
+ a_grid = np.logspace(np.log10(lo), np.log10(hi), n_a)
530
+ # REVIEW FIX (CRITICAL-1): the m-window is CENTERED ON THE SEED
531
+ # (`m_seed + total*[-0.3, +0.3]`), not on the absolute `total*[0.7, 1.3]`
532
+ # -- see the docstring's "REVIEW FIX (2026-07-31, round 1, CRITICAL-1)"
533
+ # note for why the absolute window never contained `m_seed` on real
534
+ # fields and so never let the solver engage.
535
+ m_lo, m_hi = sorted((m_seed - 0.3 * total, m_seed + 0.3 * total))
536
+ m_grid = np.linspace(m_lo, m_hi, n_m)
537
+
538
+ # REVIEW FIX (CRITICAL-2b): the grid's hot loop no longer calls
539
+ # `np.quantile`/`np.partition` PER GRID POINT for the top-quantile term.
540
+ # `raw(a, m) = a*c + m` is a POSITIVE-scale-then-shift map, which is
541
+ # order-preserving; clipping at 0 only ever affects the LOW tail. The
542
+ # q-quantile of `clip(raw, 0, None)` therefore equals the q-quantile of
543
+ # the UNCLIPPED `raw` -- which is just `a*shift + m` (`shift =
544
+ # quantile(c, q)`, computed once above) -- exactly, as long as the number
545
+ # of clipped nodes is <= floor((n-1)*q), i.e. clip_frac up to ~q = 0.995
546
+ # (review-verified exact at clip_frac 0.00 / 0.43 / 0.83 on real fields;
547
+ # rel err <= 1.7e-16). The identity only breaks at clip_frac == 1.0, and
548
+ # there it is ONE-SIDED SAFE: the closed form returns the negative raw
549
+ # quantile vs the true 0, so r_total < -1 and J is OVER-estimated --
550
+ # such a point can never be falsely selected for any total > 0. This
551
+ # turns an O(n_nodes log n_nodes)-per-point cost into an O(1)-per-point
552
+ # closed form; the winning candidate is re-scored through the exact
553
+ # `_residuals()` before acceptance regardless. `np.std` still needs the actual clipped per-node values (clip
554
+ # changes variance nontrivially), so it remains the only per-node
555
+ # computation in the search; the search's own (a, m) argmin is therefore
556
+ # APPROXIMATE only in the sense that its objective is now built from an
557
+ # exact-closed-form quantile + an exact std -- i.e. not approximate at
558
+ # all for the quantile term, and exact for std. Regardless, the FINAL
559
+ # accept/reject decision below re-evaluates the winning candidate's
560
+ # residuals via the exact `_residuals()` helper (a single `np.quantile`
561
+ # call, not a grid-wide one) before comparing to `j_seed`, so no
562
+ # approximation ever reaches the returned `info`/decision.
563
+ def _grid_eval(a_vals, m_vals):
564
+ n_av, n_mv = len(a_vals), len(m_vals)
565
+ J = np.empty((n_av, n_mv))
566
+ Rt = np.empty((n_av, n_mv))
567
+ Rs = np.empty((n_av, n_mv))
568
+ for i, av in enumerate(a_vals):
569
+ raw = m_vals[:, None] + av * c[None, :] # (n_mv, n_nodes)
570
+ clipped = np.clip(raw, 0.0, None)
571
+ qv = av * shift + m_vals
572
+ sv = np.std(clipped, axis=-1)
573
+ rt = (qv - total) / denom_total
574
+ rs = (sv - std_target_eff) / denom_std
575
+ J[i] = w_total * rt ** 2 + w_std * rs ** 2
576
+ Rt[i] = rt
577
+ Rs[i] = rs
578
+ return J, Rt, Rs
579
+
580
+ # Coarse 64x33 (a, m) grid, PLUS the single explicit seed point (NOT a
581
+ # cross-product extension of either axis -- crossing m_seed against every
582
+ # `a` would silently widen the m-search far outside the intended window).
583
+ # The seed's own (J_seed, r_total_seed, r_std_seed) were already computed
584
+ # exactly above, so it is folded into the "best so far" comparison
585
+ # directly rather than re-evaluated here.
586
+ J_coarse, Rt_coarse, Rs_coarse = _grid_eval(a_grid, m_grid)
587
+ idx = np.unravel_index(int(np.argmin(J_coarse)), J_coarse.shape)
588
+ best_a, best_m = float(a_grid[idx[0]]), float(m_grid[idx[1]])
589
+ best_J = float(J_coarse[idx])
590
+ best_rt, best_rs = float(Rt_coarse[idx]), float(Rs_coarse[idx])
591
+
592
+ if j_seed < best_J:
593
+ best_a, best_m, best_J = a_seed, m_seed, j_seed
594
+ best_rt, best_rs = r_total_seed, r_std_seed
595
+
596
+ # One local refinement pass: a finer 9x9 sub-grid windowed to roughly one
597
+ # coarse grid cell around whichever point (grid or seed) is best so far
598
+ # (log-spaced in a, linear in m), bounded within the same centered window.
599
+ log_lo, log_hi = np.log10(lo), np.log10(hi)
600
+ a_step = (log_hi - log_lo) / max(n_a - 1, 1)
601
+ log_best_a = np.log10(best_a)
602
+ ref_log_a = np.clip(np.linspace(log_best_a - a_step, log_best_a + a_step, 9),
603
+ log_lo, log_hi)
604
+ ref_a = 10.0 ** ref_log_a
605
+
606
+ m_step = (m_hi - m_lo) / max(n_m - 1, 1)
607
+ ref_m = np.clip(np.linspace(best_m - m_step, best_m + m_step, 9), m_lo, m_hi)
608
+
609
+ J_ref, Rt_ref, Rs_ref = _grid_eval(ref_a, ref_m)
610
+ idx_r = np.unravel_index(int(np.argmin(J_ref)), J_ref.shape)
611
+ if float(J_ref[idx_r]) < best_J:
612
+ best_a, best_m = float(ref_a[idx_r[0]]), float(ref_m[idx_r[1]])
613
+ best_J = float(J_ref[idx_r])
614
+ best_rt, best_rs = float(Rt_ref[idx_r]), float(Rs_ref[idx_r])
615
+
616
+ # Re-evaluate the winning candidate's residuals EXACTLY (a single
617
+ # `np.quantile` + `np.std` call, not a grid-wide pass) before the
618
+ # accept/reject decision -- the search above is already exact for
619
+ # everything but relies on the closed-form quantile identity holding, so
620
+ # this is a cheap final confirmation, not a correction of a known bias.
621
+ if (best_a, best_m) != (a_seed, m_seed):
622
+ best_pred = np.clip(best_a * c + best_m, 0.0, None)
623
+ best_rt, best_rs = _residuals(best_pred)
624
+ best_J = w_total * best_rt ** 2 + w_std * best_rs ** 2
625
+
626
+ if best_J < j_seed - 1e-12:
627
+ final_a, final_m = best_a, best_m
628
+ final_raw = final_a * c + final_m
629
+ final_pred = np.clip(final_raw, 0.0, None)
630
+ final_rt, final_rs = best_rt, best_rs
631
+ else:
632
+ final_a, final_m = a_seed, m_seed
633
+ final_raw = final_a * c + final_m
634
+ final_pred = pred_seed
635
+ final_rt, final_rs = r_total_seed, r_std_seed
636
+
637
+ clip_frac = float((final_raw < 0.0).mean())
638
+ return final_pred, _build_info(final_a, final_m, final_rt, final_rs, clip_frac,
639
+ target_unreachable)
src/inference_props.py ADDED
@@ -0,0 +1,202 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Conditioning resolver for v4 multi-alloy inference.
2
+
3
+ Turns a named alloy grade + mold system (or a raw-props JSON override) into
4
+ the 8-key property dict consumed by `src.features.alloy_g_vector` and the
5
+ FT2b-conditioned model/GBM:
6
+
7
+ {k, rho, cp, L_kJ_kg, TL, TS, Tpour, mold_effusivity}
8
+
9
+ Named alloys come from `tools.doe.load_properties.load_materials()` (reads
10
+ `data/properties/materials.csv`); mold effusivities come from
11
+ `data/properties/molds.csv`. A `props_json` path bypasses both tables
12
+ entirely and must already contain all 8 keys.
13
+ """
14
+ from __future__ import annotations
15
+
16
+ import csv
17
+ import json
18
+ from pathlib import Path
19
+
20
+ from tools.doe.load_properties import load_materials
21
+
22
+ DEFAULT_ALLOY = "EN-GJL-250"
23
+
24
+ _REQUIRED_KEYS = ("k", "rho", "cp", "L_kJ_kg", "TL", "TS", "Tpour", "mold_effusivity")
25
+
26
+ _MATERIALS_PATH = "data/properties/materials.csv"
27
+ _MOLDS_PATH = "data/properties/molds.csv"
28
+
29
+ _materials_cache: dict[str, object] | None = None
30
+ _molds_cache: dict[str, float] | None = None
31
+
32
+
33
+ def _materials() -> dict[str, object]:
34
+ """Module-level cached {material_id: Material}."""
35
+ global _materials_cache
36
+ if _materials_cache is None:
37
+ _materials_cache = {m.material_id: m for m in load_materials(_MATERIALS_PATH)}
38
+ return _materials_cache
39
+
40
+
41
+ def _molds() -> dict[str, float]:
42
+ """Module-level cached {mold_system: effusivity_W_s05_m2K}."""
43
+ global _molds_cache
44
+ if _molds_cache is None:
45
+ molds: dict[str, float] = {}
46
+ p = Path(_MOLDS_PATH)
47
+ if p.exists():
48
+ with p.open(newline="") as f:
49
+ for row in csv.DictReader(f):
50
+ system = row.get("mold_system")
51
+ raw = row.get("effusivity_W_s05_m2K")
52
+ if system and raw not in (None, ""):
53
+ molds[system] = float(raw)
54
+ _molds_cache = molds
55
+ return _molds_cache
56
+
57
+
58
+ def list_alloys() -> list[str]:
59
+ """Named material_ids available (from `data/properties/materials.csv`)."""
60
+ return list(_materials().keys())
61
+
62
+
63
+ def list_molds() -> list[str]:
64
+ """Named mold systems available (from `data/properties/molds.csv`)."""
65
+ return list(_molds().keys())
66
+
67
+
68
+ def _validate_tl_ts(props: dict, source: str) -> None:
69
+ """Review finding I2 (final whole-branch review, 2026-08-01): `TL <= TS`
70
+ is physically inverted (liquidus at or below solidus) and was previously
71
+ UNvalidated for a `--props` custom-JSON override -- `resolve_props` only
72
+ checked key *presence*, never ordering. `src.shrinkage.niyama_proxy`
73
+ clamps its `tdot_proxy` term to `>= 0.0`, so an inverted pair silently
74
+ yields `Ny == 0` for every node, and `porosity_risk(mode="percentile")`
75
+ then flags 100% of nodes as at-risk -- no warning, no exception, just a
76
+ self-contradictory "100% flagged (bottom decile by construction)"
77
+ summary line. Named grades are protected by `tools.doe.load_properties.
78
+ load_materials`'s own `TS < TL < Tpour` check at CSV-load time, but that
79
+ guard never runs for a `--props` override (it bypasses the materials
80
+ table entirely) -- and SP2 makes shrinkage screening default-ON, so this
81
+ path is live in production, not dead code. Raises loudly, naming both
82
+ values and which of `props_json`/`alloy` triggered it.
83
+ """
84
+ TL, TS = props["TL"], props["TS"]
85
+ if TL <= TS:
86
+ raise ValueError(
87
+ f"resolve_props ({source}): TL ({TL!r}) must be strictly greater than "
88
+ f"TS ({TS!r}) -- an inverted or degenerate liquidus/solidus pair makes "
89
+ f"the Niyama proxy (src.shrinkage.niyama_proxy) collapse to 0 "
90
+ f"everywhere, silently flagging 100% of nodes as at-risk")
91
+
92
+
93
+ def resolve_props(alloy: str | None = None, mold: str = "green_sand",
94
+ props_json: str | None = None) -> dict:
95
+ """Resolve the 8-key conditioning-property dict.
96
+
97
+ `props_json`, if given, overrides `alloy`/`mold` entirely: the file must
98
+ be a JSON object containing all 8 required keys (missing keys raise
99
+ ValueError naming them).
100
+
101
+ Otherwise resolves `alloy` (default `DEFAULT_ALLOY` if None) against the
102
+ named-material table and `mold` against the mold-effusivity table;
103
+ unknown names raise ValueError listing the available options.
104
+
105
+ Both branches validate `TL > TS` (review finding I2) before returning --
106
+ a `--props` override with an inverted or degenerate pair raises a loud
107
+ `ValueError` naming both values, rather than silently degrading the
108
+ shrinkage screening's Niyama proxy to "0 everywhere / 100% flagged".
109
+ """
110
+ if props_json is not None:
111
+ with open(props_json) as f:
112
+ data = json.load(f)
113
+ missing = [k for k in _REQUIRED_KEYS if k not in data]
114
+ if missing:
115
+ raise ValueError(
116
+ f"props_json '{props_json}' is missing required key(s): {missing} "
117
+ f"(required: {list(_REQUIRED_KEYS)})")
118
+ props = {k: float(data[k]) for k in _REQUIRED_KEYS}
119
+ _validate_tl_ts(props, f"props_json={props_json!r}")
120
+ return props
121
+
122
+ alloy_id = alloy if alloy is not None else DEFAULT_ALLOY
123
+ materials = _materials()
124
+ if alloy_id not in materials:
125
+ raise ValueError(
126
+ f"unknown alloy '{alloy_id}'; available alloys: {sorted(materials.keys())}")
127
+ material = materials[alloy_id]
128
+
129
+ molds = _molds()
130
+ if mold not in molds:
131
+ raise ValueError(
132
+ f"unknown mold system '{mold}'; available mold systems: {sorted(molds.keys())}")
133
+ mold_effusivity = molds[mold]
134
+
135
+ props = {
136
+ "k": float(material.k),
137
+ "rho": float(material.rho),
138
+ "cp": float(material.cp),
139
+ "L_kJ_kg": float(material.L),
140
+ "TL": float(material.TL),
141
+ "TS": float(material.TS),
142
+ "Tpour": float(material.Tpour),
143
+ "mold_effusivity": float(mold_effusivity),
144
+ }
145
+ _validate_tl_ts(props, f"alloy={alloy_id!r}")
146
+ return props
147
+
148
+
149
+ # --------------------------------------------------------------------------- #
150
+ # resolve_alloy_family (SP2 shrinkage Task 4, controller directive 2):
151
+ # `resolve_props`'s returned 8-key dict has no alloy-family field -- it is
152
+ # BUILT from `data/properties/materials.csv`'s own `family` column
153
+ # (`tools.doe.load_properties.load_materials` stores it on `Material.
154
+ # alloy_family`), but that value never makes it into `resolve_props`'s
155
+ # return dict, and `src.shrinkage.screen_casting` needs a family key from
156
+ # `src.shrinkage.SHRINKAGE_PCT` to look up the alloy's volumetric shrinkage
157
+ # percentage. Rather than reaching back into the materials table (which
158
+ # doesn't exist at all for a `--props` custom-JSON override), this maps the
159
+ # alloy GRADE NAME directly by prefix -- every named grade in
160
+ # `materials.csv` today falls into one of `SHRINKAGE_PCT`'s 4 keys via this
161
+ # same prefix convention (EN-GJL-* -> grey_iron, EN-GJS-* -> ductile_iron,
162
+ # GS-*/G17*/G20* -> low_alloy_steel), so this is not a guess for any grade
163
+ # actually in the property DB -- only for a name this helper has never seen
164
+ # (a future grade, a typo, or a `--props` override with no named grade at
165
+ # all) does it fall back, loudly (the caller records `alloy_family_assumed`
166
+ # rather than silently proceeding as if the mapping were exact).
167
+ # --------------------------------------------------------------------------- #
168
+ _ALLOY_FAMILY_PREFIXES = (
169
+ ("EN-GJS", "ductile_iron"),
170
+ ("EN-GJL", "grey_iron"),
171
+ ("GS-", "low_alloy_steel"),
172
+ ("G17", "low_alloy_steel"),
173
+ ("G20", "low_alloy_steel"),
174
+ )
175
+
176
+ # The conservative (highest-shrinkage-percentage) fallback family for a grade
177
+ # name this helper doesn't recognize, or for a `--props` custom-JSON override
178
+ # (no grade name to map from at all) -- see the module comment above.
179
+ _UNKNOWN_ALLOY_FAMILY_FALLBACK = "steel"
180
+
181
+
182
+ def resolve_alloy_family(alloy: str | None, props_json: str | None = None) -> tuple[str, bool]:
183
+ """Map an alloy grade name to one of `src.shrinkage.SHRINKAGE_PCT`'s
184
+ family keys, for the shrinkage-screening integration.
185
+
186
+ Returns `(family, assumed)`: `assumed` is `True` when the mapping is a
187
+ documented fallback rather than an exact name-prefix match -- either
188
+ because `props_json` was given (a raw-properties override has no named
189
+ grade to map from) or because `alloy` (or `DEFAULT_ALLOY`, if `alloy`
190
+ is `None`) doesn't start with any of the known prefixes. The caller is
191
+ expected to surface `assumed` (e.g. as `alloy_family_assumed` in the
192
+ shrinkage result) rather than silently treat a defaulted family as if it
193
+ were an exact match.
194
+ """
195
+ if props_json is not None:
196
+ return _UNKNOWN_ALLOY_FAMILY_FALLBACK, True
197
+
198
+ label = alloy if alloy is not None else DEFAULT_ALLOY
199
+ for prefix, family in _ALLOY_FAMILY_PREFIXES:
200
+ if label.startswith(prefix):
201
+ return family, False
202
+ return _UNKNOWN_ALLOY_FAMILY_FALLBACK, True
src/mesh_cache.py ADDED
@@ -0,0 +1,236 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Content-keyed mesh cache for the STEP -> tet-mesh pipeline.
2
+
3
+ Inference meshing (`tools.doe.prep_parts.heal_and_mesh`) takes minutes and is
4
+ routinely repeated for byte-identical STEP inputs (re-runs, batch backends,
5
+ concurrent workers). This module memoizes that call on disk, keyed on the
6
+ SHA-256 of the STEP file's bytes + `target_nodes` + `MESH_PIPELINE_VERSION`,
7
+ so any change to the meshing pipeline invalidates the cache automatically
8
+ (bump `MESH_PIPELINE_VERSION` in `tools/doe/prep_parts.py` whenever the mesh
9
+ output could change).
10
+
11
+ `heal_and_mesh` and `MESH_PIPELINE_VERSION` are imported at module level and
12
+ referenced dynamically (via `globals()`/module attribute lookup) so tests can
13
+ monkeypatch `mesh_cache.heal_and_mesh` / `mesh_cache.MESH_PIPELINE_VERSION`
14
+ without needing a real gmsh install.
15
+
16
+ Cache layout (under `cache_dir`, default `runs/mesh_cache/`):
17
+ <key>.msh -- the cached gmsh 2.2 mesh (only written when ok=True)
18
+ <key>.json -- meta dict (ok, n_nodes, ..., mesh_wall_s, pipeline_version,
19
+ source_path, created_at)
20
+
21
+ Writes are atomic and per-writer-unique (`<key>.msh.<pid>.<uuid>.tmp` /
22
+ `<key>.json.<pid>.<uuid>.tmp`, then `os.replace` onto the shared final path)
23
+ so concurrent workers racing on the same cache miss never share a tmp inode
24
+ (which would let their writes interleave and corrupt it) and never observe a
25
+ partially written file -- whichever replace lands last simply wins, and both
26
+ writers produced an equally valid, uncorrupted mesh for the same key.
27
+
28
+ A failed `heal_and_mesh` call (`ok=False`) is never cached: a fixed STEP file
29
+ (or a differently-tuned pipeline, which would change the key anyway) must be
30
+ retried, not permanently remembered as unmeshable.
31
+
32
+ Defeaturing auto-trigger (mesh-accel Task 5, 2026-07-29): `get_or_mesh`
33
+ decides whether to pass `defeature=True` to `heal_and_mesh` via a
34
+ deterministic, input-only policy, symmetric in both directions -- a STEP
35
+ file whose on-disk byte size exceeds `_BIG_FILE_THRESHOLD_BYTES` (8 MiB)
36
+ goes straight to a `defeature=True` attempt first (large real parts are,
37
+ empirically, the ones that need it -- see
38
+ `tools/doe/prep_parts.py::_apply_defeature`), and
39
+ only if that attempt fails outright (`ok=False`) retries ONCE more with
40
+ `defeature=False`; a smaller file does the mirror image -- plain
41
+ (`defeature=False`) first, retrying ONCE with `defeature=True` only on
42
+ outright failure. Either way there is at most one retry, ever. Because the
43
+ policy is a pure function of the input (file size, then a same-input
44
+ retry-on-failure), a given STEP file always resolves to the same
45
+ `defeature` choice across runs/workers -- so `defeature` does NOT need to
46
+ be (and is not) part of `mesh_cache_key`; the cache key stays exactly as
47
+ before this task. The returned meta dict always carries `defeature` (bool,
48
+ whichever value actually produced the returned result) and `retry` (bool,
49
+ True only when a second attempt actually ran, i.e. the first attempt --
50
+ big-file `defeature=True` or small-file `defeature=False` -- failed
51
+ outright; False whenever the first attempt already succeeded or the input
52
+ size never triggers the alternate branch).
53
+ """
54
+
55
+ from __future__ import annotations
56
+
57
+ import hashlib
58
+ import json
59
+ import os
60
+ import shutil
61
+ import time
62
+ import uuid
63
+
64
+ from tools.doe.prep_parts import DEFAULT_TARGET_NODES, MESH_PIPELINE_VERSION, heal_and_mesh
65
+
66
+ _HASH_CHUNK_SIZE = 1 << 20 # 1 MiB
67
+ # Participates in mesh-output selection (which `defeature` attempt is tried
68
+ # first -- see the "Defeaturing auto-trigger" section above). It is NOT part
69
+ # of `mesh_cache_key`, so changing this value changes what mesh a given STEP
70
+ # file resolves to WITHOUT changing its cache key -- any change here requires
71
+ # bumping `MESH_PIPELINE_VERSION` (tools/doe/prep_parts.py) to invalidate
72
+ # stale cache entries. Cross-file contract; keep in sync with prep_parts.py.
73
+ _BIG_FILE_THRESHOLD_BYTES = 8 * 1024 * 1024 # 8 MiB
74
+
75
+
76
+ def _sha256_file(path: str) -> str:
77
+ h = hashlib.sha256()
78
+ with open(path, "rb") as f:
79
+ while True:
80
+ chunk = f.read(_HASH_CHUNK_SIZE)
81
+ if not chunk:
82
+ break
83
+ h.update(chunk)
84
+ return h.hexdigest()
85
+
86
+
87
+ def mesh_cache_key(step_path: str, target_nodes: int = DEFAULT_TARGET_NODES,
88
+ bodies_key: str = "none") -> str:
89
+ """Content-addressed cache key: sha256(file bytes) + target_nodes +
90
+ MESH_PIPELINE_VERSION + bodies_key, so the key changes if any of those
91
+ change. Reads `MESH_PIPELINE_VERSION` dynamically from module globals so
92
+ tests can monkeypatch it.
93
+
94
+ `bodies_key` (Part B Task 4): a caller-supplied string folding in the
95
+ Part-B multi-body role assignment (see
96
+ `src.predict_casting._bodies_cache_key`) -- default `"none"` for the
97
+ ordinary single-body inference path, so an ordinary call's key is
98
+ unaffected by this parameter's mere existence (a bodies-aware and a
99
+ bodies-less call for the SAME step_path/target_nodes only collide if
100
+ the bodies-aware caller explicitly passes `bodies_key="none"`, which it
101
+ never does -- `_bodies_cache_key` always produces a real hash).
102
+ """
103
+ digest = _sha256_file(step_path)
104
+ pipeline_version = globals()["MESH_PIPELINE_VERSION"]
105
+ key_input = f"{digest}:{int(target_nodes)}:{int(pipeline_version)}:{bodies_key}".encode("utf-8")
106
+ return hashlib.sha256(key_input).hexdigest()
107
+
108
+
109
+ def _unique_tmp_path(final_path: str) -> str:
110
+ """A per-writer-unique tmp sibling of `final_path`. Two workers racing on
111
+ the same cache miss must never write the same tmp inode (that would let
112
+ their writes interleave and corrupt it before either `os.replace` runs) --
113
+ each caller gets its own pid+uuid-suffixed tmp file, then atomically
114
+ replaces the SAME shared final path (last replace wins, and both writers
115
+ produced a fully-formed, uncorrupted file)."""
116
+ return f"{final_path}.{os.getpid()}.{uuid.uuid4().hex}.tmp"
117
+
118
+
119
+ def _paths(cache_dir: str, key: str) -> tuple[str, str]:
120
+ return (
121
+ os.path.join(cache_dir, f"{key}.msh"),
122
+ os.path.join(cache_dir, f"{key}.json"),
123
+ )
124
+
125
+
126
+ def get_or_mesh(step_path: str, out_msh: str, target_nodes: int = DEFAULT_TARGET_NODES,
127
+ cache_dir: str = "runs/mesh_cache", keep_solid_id: int | None = None,
128
+ bodies_key: str = "none", post_mesh=None) -> dict:
129
+ """Return a `heal_and_mesh`-shaped meta dict for `step_path`, using the
130
+ on-disk cache when possible.
131
+
132
+ - Hit: copy the cached `.msh` to `out_msh`, return the stored meta dict
133
+ plus `{"cache": "hit"}` (no `heal_and_mesh` call, no `post_mesh`
134
+ call -- the cached mesh is already whatever `post_mesh` produced on
135
+ the miss that created it, see below).
136
+ - Miss + `ok=True`: call `heal_and_mesh` (forwarding `keep_solid_id`),
137
+ then -- if `post_mesh` is given -- call `post_mesh(out_msh) -> dict`
138
+ and merge its return into the meta, THEN atomically store both the
139
+ (now possibly `post_mesh`-modified) mesh and its meta json under the
140
+ cache key, and return the meta dict plus `{"cache": "miss"}`.
141
+ - Miss + `ok=False`: store nothing, return `heal_and_mesh`'s dict
142
+ unchanged plus `{"cache": "miss"}` -- failures are never cached, and
143
+ `post_mesh` is never called on a failed mesh.
144
+
145
+ `keep_solid_id` (Part B Task 4): the user-designated casting solid, when
146
+ a multi-body STEP's role assignment has already picked which solid IS
147
+ the casting -- forwarded verbatim to `heal_and_mesh`, in place of the
148
+ largest-solid default (`None`, unchanged).
149
+
150
+ `bodies_key` (Part B Task 4): folded into `mesh_cache_key` (see there)
151
+ so a role-assignment change (a different `bodies.json`, a registry edit
152
+ that changes a resolved BC scalar, etc.) is a genuinely different cache
153
+ entry, never a stale hit.
154
+
155
+ `post_mesh` (Part B Task 4): an optional `(out_msh) -> dict` hook run
156
+ on a cache MISS, after `heal_and_mesh` succeeds and BEFORE the mesh is
157
+ stored -- e.g. `src.assembly.tag_contact_patches`, which rewrites
158
+ `out_msh` in place (Cooling -> Adiabatic contact patch) and returns
159
+ `{"per_body_patch_area_mm2": ..., "n_adiab_tris": ...}`. Tagging cost is
160
+ not trivial on big meshes, so the TAGGED mesh is what gets cached (the
161
+ bodies hash is already in the key) -- a later hit for the same
162
+ `bodies_key` returns the tagged mesh directly, with no re-tagging.
163
+ """
164
+ os.makedirs(cache_dir, exist_ok=True)
165
+ key = mesh_cache_key(step_path, target_nodes, bodies_key)
166
+ msh_path, json_path = _paths(cache_dir, key)
167
+
168
+ if os.path.exists(msh_path) and os.path.exists(json_path):
169
+ with open(json_path) as f:
170
+ meta = json.load(f)
171
+ shutil.copyfile(msh_path, out_msh)
172
+ result = dict(meta)
173
+ result["cache"] = "hit"
174
+ return result
175
+
176
+ heal_and_mesh_fn = globals()["heal_and_mesh"]
177
+ t0 = time.time()
178
+ big_file = os.path.getsize(step_path) > _BIG_FILE_THRESHOLD_BYTES
179
+ if big_file:
180
+ # Large STEP files go straight to the defeatured attempt -- no
181
+ # point burning a near-certain-to-fail plain pass first. But if
182
+ # even that fails outright, retry ONCE with defeature=False --
183
+ # symmetric with the small-file policy below, so a big file is
184
+ # never left with only one attempt while a small file gets two.
185
+ meta = heal_and_mesh_fn(step_path, out_msh, target_nodes=target_nodes, defeature=True,
186
+ keep_solid_id=keep_solid_id)
187
+ defeature_used, retry = True, False
188
+ if not meta.get("ok"):
189
+ meta = heal_and_mesh_fn(step_path, out_msh, target_nodes=target_nodes, defeature=False,
190
+ keep_solid_id=keep_solid_id)
191
+ defeature_used, retry = False, True
192
+ else:
193
+ meta = heal_and_mesh_fn(step_path, out_msh, target_nodes=target_nodes, defeature=False,
194
+ keep_solid_id=keep_solid_id)
195
+ defeature_used, retry = False, False
196
+ if not meta.get("ok"):
197
+ # Exactly one retry, ever -- a defeature=True failure is final.
198
+ meta = heal_and_mesh_fn(step_path, out_msh, target_nodes=target_nodes, defeature=True,
199
+ keep_solid_id=keep_solid_id)
200
+ defeature_used, retry = True, True
201
+ wall_s = time.time() - t0
202
+
203
+ if not meta.get("ok"):
204
+ result = dict(meta)
205
+ result["cache"] = "miss"
206
+ result["defeature"] = defeature_used
207
+ result["retry"] = retry
208
+ return result
209
+
210
+ post_meta = post_mesh(out_msh) if post_mesh is not None else {}
211
+
212
+ stored_meta = dict(meta)
213
+ stored_meta.update(post_meta)
214
+ stored_meta["mesh_wall_s"] = wall_s
215
+ stored_meta["pipeline_version"] = globals()["MESH_PIPELINE_VERSION"]
216
+ stored_meta["source_path"] = os.path.abspath(step_path)
217
+ stored_meta["created_at"] = time.time()
218
+ stored_meta["defeature"] = defeature_used
219
+ stored_meta["retry"] = retry
220
+
221
+ msh_tmp = _unique_tmp_path(msh_path)
222
+ json_tmp = _unique_tmp_path(json_path)
223
+ shutil.copyfile(out_msh, msh_tmp) # copies the (possibly post_mesh-tagged) mesh
224
+ os.replace(msh_tmp, msh_path)
225
+ with open(json_tmp, "w") as f:
226
+ json.dump(stored_meta, f)
227
+ os.replace(json_tmp, json_path)
228
+
229
+ result = dict(stored_meta)
230
+ result["cache"] = "miss"
231
+ return result
232
+
233
+
234
+ def clear_mesh_cache(cache_dir: str = "runs/mesh_cache") -> None:
235
+ """Remove the entire cache directory, if present."""
236
+ shutil.rmtree(cache_dir, ignore_errors=True)
src/metrics.py ADDED
@@ -0,0 +1,50 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Evaluation metrics for the solidification-time field (all in seconds)."""
2
+ import numpy as np
3
+
4
+
5
+ def mae(y_true, y_pred) -> float:
6
+ return float(np.mean(np.abs(y_true - y_pred)))
7
+
8
+
9
+ def rmse(y_true, y_pred) -> float:
10
+ return float(np.sqrt(np.mean((y_true - y_pred) ** 2)))
11
+
12
+
13
+ def median_rel_error(y_true, y_pred, eps: float = 1.0) -> float:
14
+ return float(np.median(np.abs(y_true - y_pred) / (y_true + eps)))
15
+
16
+
17
+ def _per_graph(graph_id):
18
+ order = np.argsort(graph_id, kind="stable")
19
+ gid = graph_id[order]
20
+ bounds = np.flatnonzero(np.diff(gid)) + 1
21
+ return order, np.split(order, bounds)
22
+
23
+
24
+ def pred_std_ratio(y_true, y_pred, graph_id) -> float:
25
+ _, groups = _per_graph(np.asarray(graph_id))
26
+ ratios = []
27
+ for idx in groups:
28
+ st = np.std(y_true[idx])
29
+ if st > 1e-6:
30
+ ratios.append(np.std(y_pred[idx]) / st)
31
+ return float(np.mean(ratios)) if ratios else 0.0
32
+
33
+
34
+ def hotspot_accuracy(y_true, y_pred, xyz, graph_id,
35
+ top_frac: float = 0.10, tol_mm: float = 5.0) -> float:
36
+ _, groups = _per_graph(np.asarray(graph_id))
37
+ accs = []
38
+ for idx in groups:
39
+ n = len(idx)
40
+ k = max(1, int(round(n * top_frac)))
41
+ true_top = idx[np.argsort(y_true[idx])[-k:]]
42
+ pred_top = idx[np.argsort(y_pred[idx])[-k:]]
43
+ true_xyz = xyz[true_top]
44
+ hits = 0
45
+ for p in pred_top:
46
+ d = np.linalg.norm(true_xyz - xyz[p], axis=1)
47
+ if d.min() <= tol_mm:
48
+ hits += 1
49
+ accs.append(hits / k)
50
+ return float(np.mean(accs)) if accs else 0.0
src/model_gnn.py ADDED
@@ -0,0 +1,136 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """MeshGraphNet-style encode-process-decode GNN predicting standardized
2
+ log1p(solidification_time) per node; global scalar = max over nodes."""
3
+ import torch
4
+ import torch.nn as nn
5
+ from torch_geometric.utils import scatter
6
+
7
+
8
+ def _mlp(i, h, o):
9
+ return nn.Sequential(nn.Linear(i, h), nn.ReLU(), nn.Linear(h, o))
10
+
11
+
12
+ def zero_init_input_columns(model, n_new):
13
+ """Zero the trailing n_new weight columns of the first Linear layer in node_encoder.
14
+
15
+ This is used when extending the input dim (e.g., 13 → 15 for new BC channels)
16
+ to ensure new columns start at zero and contribute nothing at initialization.
17
+
18
+ Args:
19
+ model: SolidGNN instance
20
+ n_new: Number of trailing columns to zero in the first Linear weight matrix
21
+ """
22
+ with torch.no_grad():
23
+ model.node_encoder[0][0].weight[:, -n_new:] = 0
24
+
25
+
26
+ class ProcessorBlock(nn.Module):
27
+ def __init__(self, h):
28
+ super().__init__()
29
+ self.edge_mlp = _mlp(3 * h, h, h)
30
+ self.edge_norm = nn.LayerNorm(h)
31
+ self.node_mlp = _mlp(2 * h, h, h)
32
+ self.node_norm = nn.LayerNorm(h)
33
+
34
+ def forward(self, hnode, edge_index, e):
35
+ src, dst = edge_index
36
+ e_new = self.edge_norm(self.edge_mlp(torch.cat([hnode[src], hnode[dst], e], dim=-1)) + e)
37
+ agg = scatter(e_new, dst, dim=0, dim_size=hnode.shape[0], reduce="mean")
38
+ h_new = self.node_norm(self.node_mlp(torch.cat([hnode, agg], dim=-1)) + hnode)
39
+ return h_new, e_new
40
+
41
+
42
+ class FiLM(nn.Module):
43
+ """g -> (gamma, beta) per hidden channel; zero-init => gamma=0,beta=0 => identity."""
44
+ def __init__(self, g_dim, hidden):
45
+ super().__init__()
46
+ self.net = nn.Sequential(nn.Linear(g_dim, hidden), nn.SiLU(),
47
+ nn.Linear(hidden, 2 * hidden))
48
+ nn.init.zeros_(self.net[-1].weight); nn.init.zeros_(self.net[-1].bias)
49
+
50
+ def forward(self, h, g_nodes):
51
+ gamma, beta = self.net(g_nodes).chunk(2, dim=-1)
52
+ return h * (1.0 + gamma) + beta
53
+
54
+
55
+ class SolidGNN(nn.Module):
56
+ def __init__(self, node_in=9, edge_in=4, hidden=128, n_blocks=12, two_head=False,
57
+ film=False, g_dim=5, deriv_heads=False):
58
+ super().__init__()
59
+ self.node_encoder = nn.Sequential(_mlp(node_in, hidden, hidden), nn.LayerNorm(hidden))
60
+ self.edge_encoder = nn.Sequential(_mlp(edge_in, hidden, hidden), nn.LayerNorm(hidden))
61
+ self.blocks = nn.ModuleList([ProcessorBlock(hidden) for _ in range(n_blocks)])
62
+ self.decoder = _mlp(hidden, hidden // 2, 1)
63
+ self.two_head = two_head
64
+ if two_head:
65
+ self.scale_head = _mlp(hidden, hidden // 2, 1)
66
+ self.film = film
67
+ self.films = nn.ModuleList([FiLM(g_dim, hidden) for _ in range(n_blocks)]) if film else None
68
+ self.deriv_heads = deriv_heads
69
+ if deriv_heads:
70
+ # Optional gradT/dTdt output heads. They hang off the SAME final
71
+ # hidden state h that the decoder consumes (after the FiLM/blocks
72
+ # loop, before the two_head mean/scale recomposition) -- they read
73
+ # h but never modify it, so the frozen y_nodal/y_global path below
74
+ # is unaffected by their presence.
75
+ self.grad_head = _mlp(hidden, hidden // 2, 1)
76
+ self.dtdt_head = _mlp(hidden, hidden // 2, 1)
77
+
78
+ def forward(self, x, edge_index, edge_attr, batch, g=None):
79
+ h = self.node_encoder(x)
80
+ e = self.edge_encoder(edge_attr)
81
+ g_nodes = g[batch] if (self.film and g is not None) else None
82
+ for i, blk in enumerate(self.blocks):
83
+ h, e = blk(h, edge_index, e)
84
+ if g_nodes is not None:
85
+ h = self.films[i](h, g_nodes)
86
+ if self.deriv_heads:
87
+ deriv = torch.cat([self.grad_head(h), self.dtdt_head(h)], dim=-1)
88
+ y_nodal = self.decoder(h).squeeze(-1)
89
+ if self.two_head:
90
+ dev = y_nodal - scatter(y_nodal, batch, dim=0, reduce="mean")[batch]
91
+ scale = self.scale_head(scatter(h, batch, dim=0, reduce="mean")).squeeze(-1)
92
+ y_nodal = dev + scale[batch]
93
+ y_global = scatter(y_nodal, batch, dim=0, reduce="max")
94
+ if self.deriv_heads:
95
+ return y_nodal, y_global, deriv
96
+ return y_nodal, y_global
97
+
98
+
99
+ def attach_deriv_heads(model, state_dict):
100
+ """Load a (possibly headless) state_dict into a `deriv_heads=True` model
101
+ via `load_state_dict(strict=False)`, asserting the ONLY missing keys are
102
+ the two new heads' parameters (`grad_head.*` / `dtdt_head.*`).
103
+
104
+ This is how a production checkpoint trained before the deriv-heads
105
+ feature existed gets loaded into a `deriv_heads=True` model: everything
106
+ else must match exactly, or this raises loudly instead of silently
107
+ leaving some backbone tensor at its random init.
108
+
109
+ Args:
110
+ model: a `SolidGNN(deriv_heads=True)` instance.
111
+ state_dict: source state_dict (e.g. from an old checkpoint's `model`
112
+ entry). May or may not already contain grad_head/dtdt_head keys.
113
+
114
+ Returns:
115
+ list of missing key names (all `grad_head.*`/`dtdt_head.*`).
116
+
117
+ Raises:
118
+ ValueError: if any non-head (backbone) key is missing after the load,
119
+ or if the state_dict contains keys the model doesn't recognize.
120
+ """
121
+ result = model.load_state_dict(state_dict, strict=False)
122
+ if result.unexpected_keys:
123
+ raise ValueError(
124
+ f"attach_deriv_heads: unexpected extra keys in state_dict "
125
+ f"(not present in model): {list(result.unexpected_keys)}"
126
+ )
127
+ non_head_missing = [
128
+ k for k in result.missing_keys
129
+ if not k.startswith("grad_head.") and not k.startswith("dtdt_head.")
130
+ ]
131
+ if non_head_missing:
132
+ raise ValueError(
133
+ f"attach_deriv_heads: missing backbone keys after load (expected "
134
+ f"only grad_head.*/dtdt_head.* to be missing): {non_head_missing}"
135
+ )
136
+ return list(result.missing_keys)
src/msh_parse.py ADDED
@@ -0,0 +1,67 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Parser for gmsh 2.2 ASCII meshes used by the Foundry dataset.
2
+
3
+ Extracts node coordinates (mm, in node-id order — matches the paired .pt),
4
+ surface triangles grouped by physical name (Cooling/Adiabatic), and volume
5
+ tetrahedra. All connectivity is remapped to 0-based node indices.
6
+ """
7
+ from dataclasses import dataclass
8
+ import numpy as np
9
+
10
+ _TRI_TYPE = 2 # 3-node triangle
11
+ _TET_TYPE = 4 # 4-node tetrahedron
12
+
13
+
14
+ @dataclass
15
+ class Mesh:
16
+ node_xyz: np.ndarray # (N, 3) float64, mm
17
+ tris: dict # name -> (M, 3) int64, 0-based
18
+ tets: np.ndarray # (K, 4) int64, 0-based
19
+
20
+
21
+ def parse_msh(path: str) -> Mesh:
22
+ lines = open(path).read().splitlines()
23
+ i, n = 0, len(lines)
24
+ phys = {} # physical tag -> name
25
+ node_ids = [] # id order
26
+ coords = [] # xyz in that order
27
+ tri_by_name = {} # name -> list of [i,j,k]
28
+ tets = []
29
+
30
+ while i < n:
31
+ s = lines[i].strip()
32
+ if s == "$PhysicalNames":
33
+ cnt = int(lines[i + 1])
34
+ for k in range(cnt):
35
+ dim, tag, name = lines[i + 2 + k].split(maxsplit=2)
36
+ phys[int(tag)] = name.strip().strip('"')
37
+ i += 2 + cnt
38
+ continue
39
+ if s == "$Nodes":
40
+ cnt = int(lines[i + 1])
41
+ for k in range(cnt):
42
+ p = lines[i + 2 + k].split()
43
+ node_ids.append(int(p[0]))
44
+ coords.append((float(p[1]), float(p[2]), float(p[3])))
45
+ i += 2 + cnt
46
+ continue
47
+ if s == "$Elements":
48
+ cnt = int(lines[i + 1])
49
+ id2idx = {nid: idx for idx, nid in enumerate(node_ids)}
50
+ for k in range(cnt):
51
+ p = [int(x) for x in lines[i + 2 + k].split()]
52
+ etype, ntags = p[1], p[2]
53
+ tags = p[3:3 + ntags]
54
+ conn = [id2idx[v] for v in p[3 + ntags:]]
55
+ name = phys.get(tags[0]) if ntags else None
56
+ if etype == _TRI_TYPE and name is not None:
57
+ tri_by_name.setdefault(name, []).append(conn)
58
+ elif etype == _TET_TYPE:
59
+ tets.append(conn)
60
+ i += 2 + cnt
61
+ continue
62
+ i += 1
63
+
64
+ node_xyz = np.asarray(coords, dtype=np.float64)
65
+ tris = {name: np.asarray(v, dtype=np.int64) for name, v in tri_by_name.items()}
66
+ tets_arr = np.asarray(tets, dtype=np.int64) if tets else np.empty((0, 4), np.int64)
67
+ return Mesh(node_xyz=node_xyz, tris=tris, tets=tets_arr)
src/ood.py ADDED
@@ -0,0 +1,49 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Out-of-distribution size check: compare a casting's scale quantities against
2
+ the training-set maxima so predictions beyond the training envelope are flagged."""
3
+ import json
4
+ import os.path as osp
5
+ from glob import glob
6
+
7
+ import numpy as np
8
+
9
+ from src.features import FEATURE_NAMES
10
+
11
+ _VOL = FEATURE_NAMES.index("volume_mm3")
12
+ _GC = FEATURE_NAMES.index("geodesic_d_cool")
13
+
14
+
15
+ def build_ood_ranges(features_dir: str) -> dict:
16
+ vol_max = bbox_max = gc_max = 0.0
17
+ for p in glob(osp.join(features_dir, "graphs", "*.npz")):
18
+ d = np.load(p)
19
+ X, pos = d["X"], d["pos"]
20
+ vol_max = max(vol_max, float(X[0, _VOL]))
21
+ gc_max = max(gc_max, float(X[:, _GC].max()))
22
+ diag = float(np.linalg.norm(pos.max(0) - pos.min(0)))
23
+ bbox_max = max(bbox_max, diag)
24
+ return {"volume_mm3_max": vol_max, "bbox_diag_max": bbox_max,
25
+ "geodesic_d_cool_max": gc_max}
26
+
27
+
28
+ def save_ranges(ranges: dict, path: str):
29
+ with open(path, "w") as f:
30
+ json.dump(ranges, f, indent=2)
31
+
32
+
33
+ def load_ranges(path: str) -> dict:
34
+ with open(path) as f:
35
+ return json.load(f)
36
+
37
+
38
+ def ood_flags(volume_mm3, bbox_diag, geodesic_d_cool_max, ranges, tol=1.05) -> list:
39
+ w = []
40
+ if volume_mm3 > ranges["volume_mm3_max"] * tol:
41
+ w.append(f"casting volume {volume_mm3:.0f} mm^3 exceeds training max "
42
+ f"{ranges['volume_mm3_max']:.0f} mm^3 — prediction unreliable")
43
+ if bbox_diag > ranges["bbox_diag_max"] * tol:
44
+ w.append(f"bounding-box diagonal {bbox_diag:.0f} mm exceeds training max "
45
+ f"{ranges['bbox_diag_max']:.0f} mm — prediction unreliable")
46
+ if geodesic_d_cool_max > ranges["geodesic_d_cool_max"] * tol:
47
+ w.append(f"max distance-to-cooling {geodesic_d_cool_max:.1f} mm exceeds training max "
48
+ f"{ranges['geodesic_d_cool_max']:.1f} mm — prediction unreliable")
49
+ return w
src/predict_casting.py ADDED
@@ -0,0 +1,1409 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Real-casting inference: hybrid field prediction + VTU output.
2
+
3
+ predict_field combines the GNN field shape with the baseline per-graph scale
4
+ (src/hybrid.py). write_vtu exports a ParaView file. load_hybrid loads the
5
+ Phase-1 checkpoints. The predict_casting orchestrator (Task 5) ties in meshing.
6
+
7
+ v4 (film) path (Task 3): `cfg.get("film")` truthy routes `predict_field`
8
+ through the multi-alloy/multi-BC stack instead -- v4 node features (BC
9
+ spec fixed to a plain sand mold, per this module's bare-mesh-inference
10
+ contract: no chills/sleeves info comes from a STEP file), the alloy/mould
11
+ `g` vector, the v4 GNN (`src.evaluate_ft2b`), and the v2-total GBM
12
+ (`src.gbm_ft2b`) recomposed via the quantile-anchor (`hybrid_field_max_anchor`,
13
+ default `q=0.995` -- outlier-robust; `q=1.0` is the original exact-MAX
14
+ anchor) -- NOT the SP1 MEAN-anchor, which assumes a genuine per-node
15
+ baseline (see `src/evaluate_ft2b.py`'s module docstring for the full
16
+ rationale). The non-film path below is functionally unchanged (plus an explicit HGB-dimension guard).
17
+
18
+ variance-recomposition Task 3: an optional GBM std-head (`src.gbm_ft2b.
19
+ refit_gbm_std`'s output, loaded via `load_hybrid`'s `hgb_std_path` /
20
+ `predict_casting`'s `--hgb-std`) can be threaded through the film path so
21
+ `predict_field` recomposes via `src.hybrid.hybrid_field_affine` (rescales
22
+ the GNN field's deviations to match a per-graph SCALE target too, not just
23
+ a per-graph LEVEL) instead of `hybrid_field_max_anchor`. `None` (the
24
+ default, everywhere) is byte-identical to the pre-Task-3 code.
25
+
26
+ variance-recomposition amendment 2: `predict_casting`'s `--affine-lambda` /
27
+ `predict_field`'s `lambda_shrink` (default `1.0`, forwarded straight to
28
+ `hybrid_field_affine`) geometrically shrinks the affine spread target
29
+ toward the GNN's own spread -- see `src.hybrid.hybrid_field_affine`'s
30
+ docstring for the math/rationale. `1.0` everywhere is byte-identical to
31
+ before this argument existed.
32
+
33
+ soft-anchor Task 3: `hybrid_field_affine` at the std-head call site above
34
+ is REPLACED by `src.hybrid.hybrid_field_soft` (a weighted two-target
35
+ objective that lets the total anchor itself deviate a bit when that trade
36
+ buys a much closer spread match -- see that function's docstring). The
37
+ degeneration guarantee built into `hybrid_field_soft` means any case where
38
+ the hard affine solve already satisfied both targets recomposes
39
+ IDENTICALLY (same array object, same `a`) -- only genuinely stuck cases
40
+ (the ones that motivated this track) actually change. `feat["recomposition"]`
41
+ is now `"soft"` (was `"affine"`) whenever `hgb_std` is active; the info dict
42
+ gains `"m"`/`"target_unreachable"`/`"total_deviation_frac"`/`"j_total_resid"`/
43
+ `"j_std_resid"` (see `hybrid_field_soft`'s own docstring for the full key
44
+ list) in place of affine's `"a_clamped"`. New `w_total`/`w_std` params
45
+ (`predict_field`, `predict_casting`, CLI `--w-total`/`--w-std`; production
46
+ defaults `DEFAULT_W_TOTAL=1.0` / `DEFAULT_W_STD=4.0` since the v5.1
47
+ promotion) forward straight to `hybrid_field_soft`'s own weights, letting a
48
+ caller trade total-anchor fidelity against spread fidelity. The GBM std
49
+ head's own row width is now dispatched off the loaded model's
50
+ `n_features_in_` (16 -> the v1 row unchanged, 17 -> the v1 row + a
51
+ graph-derived `bc_area_frac` 17th column computed from the SAME in-memory
52
+ per-node feature array `predict_field` already has -- see
53
+ `_std_row_for_predict_field`); anything else raises a loud `ValueError`.
54
+ The no-std-head (quantile-anchor) path is completely untouched by any of
55
+ this.
56
+
57
+ variance-recomposition Task 6 (the production flip, 2026-07-31):
58
+ `DEFAULT_GNN_CKPT`/`DEFAULT_HGB` now point at the ft3 stack
59
+ (`runs/ft3_stage1/ckpt_ep021.pt` x `runs/ft3_gbm/hgb_v3.joblib`) and a new
60
+ `DEFAULT_HGB_STD` (now `runs/ft3_gbm/hgb_std_v2.joblib` since the v5.1
61
+ promotion) exists. `hgb_std_path`
62
+ no longer defaults to plain `None` on `predict_casting`/CLI `--hgb-std`:
63
+ both default to the module-level `_USE_DEFAULT` sentinel, which
64
+ `load_hybrid` resolves to `DEFAULT_HGB_STD` for a film-capable checkpoint
65
+ or to `None` for a non-film one (see `load_hybrid`'s docstring) -- so the
66
+ production call, with zero extra flags, now recomposes via
67
+ `hybrid_field_affine` (`lambda_shrink=1.0`) instead of the quantile-anchor
68
+ (superseded by soft-anchor Task 3 below -- the zero-flag production call
69
+ recomposes via `hybrid_field_soft` today; this paragraph is kept as the
70
+ Task 6 historical record of when the std head first became the default),
71
+ while the SP1 rollback (`--gnn`/`--hgb` pointed at a non-film checkpoint)
72
+ is untouched: the sentinel silently resolves to `None` there, exactly as
73
+ if `hgb_std_path` had never been passed. The case-insensitive string
74
+ `"none"` always disables the std head regardless of checkpoint kind (the
75
+ explicit rollback switch to the quantile-anchor, usable on any film
76
+ checkpoint, including the new default one) -- passing an explicit path or
77
+ loaded model object still loads/uses it unconditionally, exactly as
78
+ before Task 6.
79
+
80
+ learned-heads Task 3 (`--gnn` pointed at a `deriv_heads`-capable checkpoint):
81
+ `predict_field`'s film branch also runs `src.evaluate_ft2b.predict_graph_derivs`
82
+ and stashes the result in `feat["gradT"]`/`feat["dTdt"]` (K/m / K/s); `predict_casting`
83
+ then feeds those into `src.shrinkage.screen_casting`'s exact Niyama branch
84
+ instead of the time-only proxy, and relabels the result `niyama_source=
85
+ "learned"` (screen_casting's own "exact" label is reserved for a real cached
86
+ solver field). Absent entirely for a headless checkpoint -- byte-identical
87
+ to the pre-Task-3 proxy-only behavior. The chill low-confidence
88
+ `niyama_confidence` marking (spec ADDENDUM 2) is UNCHANGED by this -- still
89
+ keyed only on `plan.bc_config == "chills"`, regardless of `niyama_source`;
90
+ retiring that scoping is a later (promotion-step) task.
91
+
92
+ bc-reach Task 4 (cfg-driven v5 feature building at inference): the film
93
+ branch's `compute_features_inference` call now reads
94
+ `version=cfg.get("feature_version", 4)` instead of hardcoding `version=4`
95
+ -- the SAME `cfg.get("feature_version", ...)` pattern the non-film branch
96
+ already used (this module, the SP1/non-film path). A v5 (bc-reach)
97
+ checkpoint's saved cfg carries `feature_version=5` (`src.train_gnn`'s
98
+ `_build_checkpoint_dict`, echoed verbatim from `train(feature_version=...)`,
99
+ independent of but in practice equal to `node_in`), so loading one through
100
+ `load_hybrid`/`load_ft2b_gnn` and calling `predict_field` now builds the
101
+ 20-column v5 `X` (`src.features.FEATURE_NAMES_V5`: v4's 15 columns +
102
+ `exo_mid`/`exo_long`/`chill_short`/`chill_mid`/`chill_long`) with NO other
103
+ change to this module -- `compute_bc_reach_channels` (features v5) already
104
+ resolves its chill/exo source patch off the SAME `spec`/`bc_spec` dicts
105
+ this module already builds from `bc_plan.feature_spec(...)`/the all-sand
106
+ literal, so the reach channels see EXACTLY the same source magnitudes the
107
+ training builder (`src.build_dataset.build_v5`) does. A v4 (or absent)
108
+ `cfg["feature_version"]` is byte-identical to the pre-Task-4 code (defaults
109
+ to `4`, same as before this dispatch existed)."""
110
+ import hashlib
111
+ import json
112
+ import logging
113
+ import os.path as osp
114
+ import numpy as np
115
+ import torch
116
+ import joblib
117
+
118
+ logger = logging.getLogger(__name__)
119
+
120
+ from src.features import (compute_features_inference, FEATURE_NAMES, FEATURE_NAMES_V4,
121
+ alloy_g_vector)
122
+ from src.dataload import standardize
123
+ from src.baseline import predict_seconds
124
+ from src.evaluate_gnn import predict_graph_seconds, load_gnn, N_BASELINE_FEATURES
125
+ from src.evaluate_ft2b import predict_graph_seconds as predict_graph_seconds_v4, load_ft2b_gnn
126
+ from src.evaluate_ft2b import predict_graph_derivs as predict_graph_derivs_v4
127
+ from src.gbm_ft2b import (gbm_v2_total_seconds, FEATURE_NAMES as GBM_FEATURE_NAMES,
128
+ predict_std_seconds, build_v2_feature_row,
129
+ bc_area_frac_from_graph)
130
+ from src.hybrid import hybrid_field, hybrid_field_max_anchor, hybrid_field_soft
131
+ from src.inference_props import resolve_props, DEFAULT_ALLOY, resolve_alloy_family
132
+ from src.shrinkage import screen_casting
133
+ from src.release_io import resolve_artifact, load_gbm, load_gnn_any
134
+
135
+ N_GBM_V2_FEATURES = len(GBM_FEATURE_NAMES)
136
+ _V4_VOL_IDX = FEATURE_NAMES_V4.index("volume_mm3")
137
+ _V4_AREA_IDX = FEATURE_NAMES_V4.index("surface_area_mm2")
138
+
139
+ # Production v5.1 hybrid stack (promoted 2026-07-31, soft-anchor Task 6 -- see
140
+ # docs/superpowers/specs/2026-07-31-soft-anchor-design.md's ADDENDUM) -- module
141
+ # constants so CLI/kwarg defaults live in one place instead of being duplicated
142
+ # string literals. GNN field *shape* x GBM *scale* (total) x GBM *spread* (std,
143
+ # std_v2 -- median-loss HGB, 17 features incl. bc_area_frac), recomposed via the
144
+ # soft-anchor map (src.hybrid.hybrid_field_soft) at w_total=1.0, w_std=4.0
145
+ # (the e_val sweep winner: v2+soft takes gold decisively -- BUKSA 90.2 s/0.700,
146
+ # H17 120.1 s/0.895, best-ever MAE on both -- missing only the (owner-revised,
147
+ # see tools/ft3_gates.py's DEFAULT_E_VAL_STD_RATIO_FLOOR) e_val exo std_ratio floor).
148
+ #
149
+ # Rollback #1 (v5, 2026-07-31 same day, pre-soft-anchor-sweep): ckpt_ep021 x
150
+ # hgb_v3 x hgb_std_v1 (squared-error, 16 features), w_total=1.0, w_std=1.0 --
151
+ # reachable via `--hgb-std runs/ft3_gbm/hgb_std_v1.joblib --w-std 1.0`
152
+ # (hgb_std_v1.joblib is left untouched on disk by this promotion).
153
+ # Rollback #2 (previous production, 2026-07-28..2026-07-31): ckpt_ep054 x hgb_v2,
154
+ # quantile-anchor recomposition (no std head) -- reachable via
155
+ # `--gnn runs/ft2b_stage2c/ckpt_ep054.pt --hgb runs/ft2b_gbm/hgb_v2.joblib --hgb-std none`.
156
+ # Rollback #3 (SP1, still on disk): runs/sp1_run3_final/best_gnn.pt +
157
+ # runs/baseline_v2/hgb_A.joblib, non-film -- reachable via --gnn/--hgb; --hgb-std needs
158
+ # no extra flag here, `load_hybrid` auto-resolves the defaulted std head to None for a
159
+ # non-film checkpoint (see `load_hybrid`'s docstring / the `_USE_DEFAULT` sentinel below).
160
+ DEFAULT_GNN_CKPT = "runs/ft3_stage1/ckpt_ep021.pt"
161
+ DEFAULT_HGB = "runs/ft3_gbm/hgb_v3.joblib"
162
+ DEFAULT_HGB_STD = "runs/ft3_gbm/hgb_std_v2.joblib"
163
+ DEFAULT_W_TOTAL = 1.0
164
+ DEFAULT_W_STD = 4.0
165
+
166
+
167
+ class _UseDefaultHgbStd:
168
+ """Sentinel for `predict_casting`'s `hgb_std_path` param / CLI `--hgb-std`:
169
+ means "the caller did not specify" -- `load_hybrid` resolves this to
170
+ `DEFAULT_HGB_STD` when the selected checkpoint is film-capable, or to
171
+ `None` when it isn't (so the SP1 rollback via `--gnn`/`--hgb` needs no
172
+ extra flag). Distinct from an explicit `None` or the string `"none"`
173
+ (case-insensitive), both of which ALWAYS disable the std head -- the
174
+ explicit rollback switch to the quantile-anchor recomposition, usable on
175
+ any film checkpoint including the new default one."""
176
+
177
+ def __repr__(self):
178
+ return "<DEFAULT_HGB_STD>"
179
+
180
+
181
+ _USE_DEFAULT = _UseDefaultHgbStd()
182
+
183
+
184
+ def _check_hgb_dim(hgb_model, expected: int, ckpt_kind: str) -> None:
185
+ """Loud, clear failure on a mismatched (film vs. SP1) GNN/HGB pairing.
186
+
187
+ `hgb.n_features_in_` is set by sklearn on `.fit()`; a stub without it
188
+ (shouldn't happen for a real fitted estimator) is treated as "unknown"
189
+ and NOT checked, rather than crashing the attribute lookup itself.
190
+ """
191
+ n_have = getattr(hgb_model, "n_features_in_", None)
192
+ if n_have is None or n_have == expected:
193
+ return
194
+ raise ValueError(
195
+ f"predict_field: {ckpt_kind} checkpoint expects an HGB baseline with "
196
+ f"{expected} input features, but hgb_model.n_features_in_={n_have!r} -- "
197
+ f"this looks like the wrong baseline for this checkpoint (a v2-total "
198
+ f"GBM has {N_GBM_V2_FEATURES} features, an SP1 per-node HGB has "
199
+ f"{N_BASELINE_FEATURES}); pass the matching --hgb for this --gnn checkpoint.")
200
+
201
+
202
+ def _std_row_for_predict_field(row_16: np.ndarray, X: np.ndarray, n_features: int
203
+ ) -> np.ndarray:
204
+ """`predict_field`'s in-memory counterpart to `src.gbm_ft2b.std_feature_row`
205
+ (soft-anchor Task 3): width-dispatches the std-head row off the loaded
206
+ std model's own `n_features_in_`, without a manifest stem or graph-cache
207
+ file to read from -- `predict_field` already has everything both widths
208
+ need in memory.
209
+
210
+ - `n_features == 16` -> `row_16` unchanged (the SAME 16-column row
211
+ `predict_field`'s total head already scores -- byte-identical to the
212
+ pre-soft-anchor code).
213
+ - `n_features == 17` -> `row_16` + `src.gbm_ft2b.bc_area_frac_from_graph`'s
214
+ 17th column, computed from the SAME in-memory per-node v4 feature
215
+ array `X` (`bc_area_frac_from_graph` only ever indexes `graph["X"]`, so
216
+ a plain `{"X": X}` dict satisfies its contract without a real graph
217
+ npz).
218
+ - anything else -> a loud `ValueError` naming the two supported widths
219
+ (mirrors `std_feature_row`'s own contract).
220
+ """
221
+ if n_features not in (16, 17):
222
+ raise ValueError(
223
+ f"predict_field: unsupported std-head n_features_in_={n_features!r} -- "
224
+ f"the std head only supports 16 (v1) or 17 (v2, v1 + bc_area_frac) "
225
+ f"feature columns")
226
+ if n_features == 16:
227
+ return row_16
228
+ area_frac = bc_area_frac_from_graph({"X": X})
229
+ return np.concatenate([row_16, [area_frac]])
230
+
231
+
232
+ def predict_field(mesh, gnn_model, hgb_model, norm, cfg=None, device=None, tta=0,
233
+ k=16, baseline_norm=None, props=None, bc_plan=None, hgb_std=None,
234
+ lambda_shrink: float = 1.0, w_total: float = DEFAULT_W_TOTAL,
235
+ w_std: float = DEFAULT_W_STD) -> tuple:
236
+ """`bc_plan` (Part B Task 4): an optional `src.assembly.BcPlan`, resolved
237
+ by `validate_bodies` from a multi-body role assignment. `None` (the
238
+ default) is byte-identical to the pre-Part-B behavior -- the film
239
+ branch's `spec` stays the all-sand literal and `gbm_v2_total_seconds` is
240
+ called with `bc_spec=None` (its own "plain sand mold" default). When
241
+ given, the film branch's `spec` comes from `bc_plan.feature_spec(...)`
242
+ (carries `chill_effusivity`/`sleeve_MEF`/`exo_q0`/`bc_config`) and
243
+ `gbm_v2_total_seconds` gets `bc_spec=bc_plan.gbm_bc_spec()`. Ignored
244
+ entirely by the non-film (SP1) path -- SP1 has no BC channel at all.
245
+
246
+ `hgb_std` (variance-recomposition Task 3, recomposition swapped to
247
+ `hybrid_field_soft` by soft-anchor Task 3): an optional loaded GBM
248
+ std-head model (`src.gbm_ft2b.refit_gbm_std`/`refit_gbm_std_v2`'s
249
+ output -- e.g. loaded via `load_hybrid`'s `hgb_std_path`). Only
250
+ consulted on the film (v4) path, and only once the total head has
251
+ produced a valid `total`: the 16-column total-head row is built ONCE
252
+ (`src.gbm_ft2b.build_v2_feature_row`, the SAME public row-builder
253
+ `gbm_v2_total_seconds` itself uses, so a `FEATURE_NAMES` reorder can
254
+ never silently desync the two heads) and fed to the total head
255
+ (`hgb_model.predict` directly, mathematically identical to
256
+ `gbm_v2_total_seconds`); the std head is scored on a SEPARATE row,
257
+ width-dispatched off `hgb_std.n_features_in_` (16 -> that same row
258
+ unchanged, 17 -> that row + a graph-derived `bc_area_frac` 17th column,
259
+ via `_std_row_for_predict_field` -- else a loud `ValueError`), then
260
+ recomposed via `src.hybrid.hybrid_field_soft` instead of
261
+ `hybrid_field_max_anchor`.
262
+ `None` (the default) is BYTE-IDENTICAL to the pre-Task-3 code (the
263
+ `total = gbm_v2_total_seconds(...)` / `hybrid_field_max_anchor` path
264
+ below is untouched). When active, the returned `feat` dict gains
265
+ `feat["recomposition"] = "soft"` and `feat["recomposition_info"]`
266
+ (`hybrid_field_soft`'s `info`: `a`/`m`/`target_unreachable`/
267
+ `degenerate_fallback`/`clip_frac`/`total_deviation_frac`/
268
+ `j_total_resid`/`j_std_resid`/`lambda_shrink`/`std_target_effective`)
269
+ -- absent entirely when `hgb_std` is `None`. Ignored entirely by the
270
+ non-film (SP1) path -- SP1 has no scale-head std target.
271
+
272
+ `lambda_shrink` (variance-recomposition amendment 2): forwarded straight
273
+ to `hybrid_field_soft` (ignored when `hgb_std` is `None`, same as every
274
+ other soft-anchor-only knob here). `1.0` (the default) is byte-identical
275
+ to before this argument existed.
276
+
277
+ `w_total`/`w_std` (soft-anchor Task 3; defaults changed to `DEFAULT_W_TOTAL`/
278
+ `DEFAULT_W_STD` by the soft-anchor Task 6 promotion, 2026-07-31): forwarded
279
+ straight to `src.hybrid.hybrid_field_soft`'s own weights (ignored when
280
+ `hgb_std` is `None`). The defaults here (`1.0`/`4.0`) are the e_val-sweep
281
+ winner recorded in the spec addendum -- NOT `hybrid_field_soft`'s own
282
+ function-level default of `1.0`/`1.0` (that default is unchanged; a
283
+ caller invoking `hybrid_field_soft` directly, bypassing `predict_field`,
284
+ still gets equal weighting unless it passes these explicitly).
285
+
286
+ Learned-heads Task 3: when `cfg["deriv_heads"]` is truthy (a heads
287
+ checkpoint, film path only -- SP1 has no deriv heads at all), `feat`
288
+ additionally carries `feat["gradT"]`/`feat["dTdt"]` -- the checkpoint's
289
+ OWN learned per-node gradT (K/m) / dTdt (K/s) predictions
290
+ (`src.evaluate_ft2b.predict_graph_derivs`), on the SAME features the
291
+ shape head just scored. This is a `feat`-dict addition only -- the
292
+ `(pred, feat)` 2-tuple return contract is unchanged, so every existing
293
+ `out, feat = predict_field(...)` call site keeps working unmodified.
294
+ Absent entirely for a headless checkpoint (the default): `"gradT"`/
295
+ `"dTdt"` simply aren't keys of `feat` then, exactly as before this
296
+ feature existed. `predict_casting` reads these (if present) and feeds
297
+ them to `screen_casting`'s exact Niyama branch, labeling the result
298
+ `niyama_source="learned"` (see that function's own docstring)."""
299
+ device = device or ("cuda" if torch.cuda.is_available() else "cpu")
300
+ cfg = cfg or {"k": k, "graph": "knn", "feature_version": 2}
301
+ gnn_model = gnn_model.to(device).eval()
302
+
303
+ if cfg.get("film", False):
304
+ if props is None:
305
+ raise ValueError(
306
+ "predict_field: cfg indicates a film (v4) checkpoint but no "
307
+ "`props` (alloy/mould conditioning dict) were given")
308
+ if hgb_model is None:
309
+ raise ValueError(
310
+ "predict_field: a film (v4) checkpoint requires an hgb_model "
311
+ "(the v2-total GBM scale head) -- got None")
312
+ _check_hgb_dim(hgb_model, N_GBM_V2_FEATURES, "film (v4)")
313
+
314
+ if bc_plan is not None:
315
+ spec = bc_plan.feature_spec(props["mold_effusivity"])
316
+ bc_spec = bc_plan.gbm_bc_spec()
317
+ else:
318
+ spec = {"mold_effusivity": props["mold_effusivity"], "bc_config": "none",
319
+ "chill_effusivity": 0.0, "sleeve_MEF": 0.0, "exo_q0": 0.0}
320
+ bc_spec = None
321
+ feat = compute_features_inference(mesh, version=cfg.get("feature_version", 4), spec=spec)
322
+ X, pos = feat["X"], feat["pos"]
323
+ g_vec = alloy_g_vector(props)
324
+ gnn_seconds = predict_graph_seconds_v4(gnn_model, cfg, norm, X, pos,
325
+ feat["mesh_edges"], g_vec=g_vec,
326
+ device=device)
327
+
328
+ # Learned-heads Task 3: a deriv_heads-capable checkpoint additionally
329
+ # predicts per-node gradT (K/m) / dTdt (K/s) off the SAME features --
330
+ # stashed into `feat` (not the return tuple's arity, keeping every
331
+ # existing `out, feat = predict_field(...)` call site unchanged) so
332
+ # `predict_casting` can feed them to `screen_casting`'s exact branch
333
+ # instead of the time-only proxy. Absent entirely (no "gradT"/"dTdt"
334
+ # keys in `feat`) for a headless checkpoint -- byte-identical to the
335
+ # pre-Task-3 code for every existing (non-heads) checkpoint.
336
+ if cfg.get("deriv_heads", False):
337
+ gradT_pred, dTdt_pred = predict_graph_derivs_v4(
338
+ gnn_model, cfg, norm, X, pos, feat["mesh_edges"], g_vec=g_vec,
339
+ device=device)
340
+ feat["gradT"] = gradT_pred
341
+ feat["dTdt"] = dTdt_pred
342
+
343
+ v_over_a = float(X[0, _V4_VOL_IDX]) / float(X[0, _V4_AREA_IDX])
344
+
345
+ if hgb_std is not None:
346
+ row = build_v2_feature_row(props, v_over_a, bc_spec)
347
+ total = float(np.expm1(float(hgb_model.predict(row.reshape(1, -1))[0])))
348
+ if total <= 0:
349
+ raise ValueError(
350
+ f"predict_field: the v2-total GBM head returned a non-positive "
351
+ f"total ({total!r}) for props={props!r} -- the v2-total GBM is "
352
+ f"broken for this input")
353
+ n_std_features = int(getattr(hgb_std, "n_features_in_", 16))
354
+ std_row = _std_row_for_predict_field(row, X, n_std_features)
355
+ std_seconds = predict_std_seconds(hgb_std, std_row)
356
+ pred, info = hybrid_field_soft(gnn_seconds, total, std_seconds,
357
+ lambda_shrink=lambda_shrink,
358
+ w_total=w_total, w_std=w_std)
359
+ feat["recomposition"] = "soft"
360
+ feat["recomposition_info"] = info
361
+ return pred, feat
362
+
363
+ total = gbm_v2_total_seconds(hgb_model, props, v_over_a, bc_spec=bc_spec)
364
+ if total <= 0:
365
+ raise ValueError(
366
+ f"predict_field: gbm_v2_total_seconds returned a non-positive "
367
+ f"total ({total!r}) for props={props!r} -- the v2-total GBM is "
368
+ f"broken for this input")
369
+ return hybrid_field_max_anchor(gnn_seconds, total), feat
370
+
371
+ # --- non-film (SP1) path: functionally unchanged (plus explicit HGB-dimension guard) ---
372
+ if hgb_model is not None:
373
+ _check_hgb_dim(hgb_model, N_BASELINE_FEATURES, "non-film (SP1)")
374
+ feat = compute_features_inference(mesh, version=cfg.get("feature_version", 2))
375
+ X, pos = feat["X"], feat["pos"]
376
+ Xs = standardize(X, norm)
377
+ x = torch.tensor(Xs, dtype=torch.float32)
378
+ pos_t = torch.tensor(pos, dtype=torch.float32)
379
+ me = (torch.tensor(feat["mesh_edges"], dtype=torch.long)
380
+ if cfg.get("graph", "knn") == "union" else None)
381
+ g = predict_graph_seconds(gnn_model, cfg, norm, x, pos_t, me, device=device, tta=tta)
382
+ if hgb_model is None:
383
+ return g, feat
384
+ bn = baseline_norm if baseline_norm is not None else norm
385
+ b = predict_seconds(hgb_model, standardize(X[:, :N_BASELINE_FEATURES], bn), bn)
386
+ return hybrid_field(g, b), feat
387
+
388
+
389
+ def write_vtu(out_path, node_xyz, tets, seconds, top_frac=0.1, extra_point_data=None):
390
+ """`extra_point_data` (SP2 shrinkage Task 4): an optional
391
+ `{field_name: (N,) array}` dict merged into the written `point_data`
392
+ dict alongside the original `solidification_time`/`hotspot` fields --
393
+ e.g. `{"niyama": ..., "porosity_risk": ..., "macro_region_id": ...}`
394
+ from `src.shrinkage.screen_casting`. `None` (the default) is
395
+ byte-identical to the pre-Task-4 contract (only the original two
396
+ fields are written) -- this is a generic extension point, not a
397
+ hardcoded shrinkage-only special case, so any future per-node field
398
+ can reuse it the same way."""
399
+ import meshio
400
+ seconds = np.asarray(seconds, dtype=np.float32)
401
+ n = seconds.shape[0]
402
+ k = max(1, int(round(n * top_frac)))
403
+ thresh = np.sort(seconds)[-k]
404
+ hotspot = (seconds >= thresh).astype(np.int32)
405
+ point_data = {"solidification_time": seconds, "hotspot": hotspot}
406
+ if extra_point_data:
407
+ point_data.update(extra_point_data)
408
+ m = meshio.Mesh(points=np.asarray(node_xyz, dtype=np.float64),
409
+ cells=[("tetra", np.asarray(tets))],
410
+ point_data=point_data)
411
+ m.write(out_path)
412
+
413
+
414
+ # --------------------------------------------------------------------------- #
415
+ # SP2 shrinkage Task 4: `predict_casting` integration helpers -- deriving
416
+ # screen_casting's mesh-topology inputs from the parsed `.msh`, trimming its
417
+ # result to a return-dict-friendly shape, and formatting the summary's risk
418
+ # block. Kept as free functions (rather than inlined in `predict_casting`)
419
+ # so each piece is independently testable and the main function stays
420
+ # readable.
421
+ # --------------------------------------------------------------------------- #
422
+ def _shrinkage_surface_and_feed_nodes(mesh):
423
+ """Controller directive 1 (owner-adjudicated 2026-08-01): `surface_nodes`
424
+ = unique nodes of ALL surface triangles (every physical-group tag the
425
+ mesh carries -- "Cooling" union "Adiabatic" -- matching `screen_casting`'s
426
+ own contract of "the whole free surface", used for sink/feeder
427
+ placement). `feed_nodes` = unique nodes of "Adiabatic"-tagged triangles
428
+ ONLY (riser/sleeve faces, per `src.shrinkage.is_fed`'s documented
429
+ convention) -- empty when the group is absent or empty (a bare sand
430
+ casting with no risers, conservatively "everything unfed")."""
431
+ tri_arrays = [tri for tri in mesh.tris.values() if tri.size]
432
+ surface_nodes = (np.unique(np.concatenate(tri_arrays, axis=0).reshape(-1))
433
+ if tri_arrays else np.zeros(0, dtype=np.int64))
434
+ adiab_tri = mesh.tris.get("Adiabatic")
435
+ feed_nodes = (np.unique(adiab_tri.reshape(-1))
436
+ if adiab_tri is not None and adiab_tri.size else np.zeros(0, dtype=np.int64))
437
+ return surface_nodes.astype(np.int64), feed_nodes.astype(np.int64)
438
+
439
+
440
+ # --------------------------------------------------------------------------- #
441
+ # Part-B roles task (2026-08-03): role-aware feed-node selection, replacing
442
+ # review finding I1's coarse `plan.bc_config == "chills"` special case (which
443
+ # forced feed_nodes empty for the WHOLE input whenever any chill role was
444
+ # present, rather than excluding just the chill bodies' own patch nodes).
445
+ # `src.assembly.tag_contact_patches` relabels EVERY role body's contact patch
446
+ # "Adiabatic" regardless of role (chills included) -- these two pure helpers
447
+ # recover the per-BODY-role distinction from `tag_contact_patches`'s
448
+ # `nodes_by_solid` output (per-solid, geometry-only) + a solid_id -> role
449
+ # lookup (`BcPlan.role_by_solid`), so the exclusion decision is made per
450
+ # body, never per global `bc_config`.
451
+ #
452
+ # Investigation note (binding on the design below): in bodies mode the
453
+ # casting mesh handed to `tag_contact_patches` never carries a PRE-EXISTING
454
+ # "Adiabatic" group of its own. `bodies` requires `mesh_mode="prep"` (see the
455
+ # guard above `predict_casting` raises), and `tools.doe.prep_parts.
456
+ # heal_and_mesh` -- the only meshing path "prep" mode uses -- tags every
457
+ # casting surface "Cooling" uniformly with no Adiabatic seed at all (verified
458
+ # in `tools/doe/prep_parts.py` and `src/step_mesh.py`: neither ever calls
459
+ # `setPhysicalName(2, ..., "Adiabatic")`; the ONLY producer of an "Adiabatic"
460
+ # physical group anywhere in this codebase is `src.assembly.
461
+ # rewrite_msh_adiabatic`, i.e. THIS tagging pass itself, or FT3's training-
462
+ # data patch simulator `tools/doe/virtual_patches.py`, which never runs at
463
+ # inference time). So every "Adiabatic" triangle on a bodies-mode inference
464
+ # mesh is a role-body contact patch -- there is no separate "genuine riser"
465
+ # contribution that could coexist with role patches and would need unioning
466
+ # in on top of the per-role mapping below. The bare/all-sand path (no
467
+ # `bodies`) is therefore UNCHANGED by this task: `_shrinkage_surface_and_
468
+ # feed_nodes`'s "Adiabatic" union (if the mesh has one at all -- only
469
+ # possible via `mesh_mode="raw"` or a training `.msh` with real user-tagged
470
+ # riser faces) keeps meaning exactly what it always has.
471
+ # --------------------------------------------------------------------------- #
472
+ def _nodes_by_role_from_tagging(nodes_by_solid: dict, role_by_solid: dict) -> dict:
473
+ """Group `tag_contact_patches`'s per-solid contact-patch node ids
474
+ (`nodes_by_solid`, `{solid_id: node ids}` -- solid_id keys may arrive as
475
+ `str` after a mesh-cache JSON round-trip on a cache HIT, handled here via
476
+ `int()`) by each solid's declared role (`role_by_solid`, `{solid_id:
477
+ role}`, e.g. `BcPlan.role_by_solid`).
478
+
479
+ A solid_id present in `nodes_by_solid` with no entry in `role_by_solid`
480
+ is dropped rather than raising, but LOUDLY (`logger.warning`, naming the
481
+ solid_id) -- review round 1, M1: silently dropping it would silently
482
+ mis-attribute (or simply lose) that body's contact-patch nodes. In the
483
+ normal `predict_casting` call path this warning never actually fires:
484
+ `_role_aware_feed_nodes` (review round 1, H1) only calls this function
485
+ after confirming `nodes_by_solid` covers every id in `plan.
486
+ role_solid_ids`, and `role_by_solid` covers exactly those same ids by
487
+ construction in `validate_bodies` -- this is a defense-in-depth net for
488
+ any future/direct caller that skips that completeness check, not a code
489
+ path this module exercises today.
490
+
491
+ Returns `{role: sorted node ids}`.
492
+
493
+ NOTE on shared-node precedence (L3, review round 1): if a node's
494
+ contact-patch membership spans more than one role (two role bodies'
495
+ `tol_mm` zones overlap on the casting surface -- see `tag_contact_
496
+ patches`'s documented double-counting of `per_body_patch_area_mm2`),
497
+ this function keeps it under EVERY role it was classified into, and
498
+ `_feed_nodes_from_role_patches` below then unions in whichever of those
499
+ roles is a sleeve role -- i.e. a node shared between a chill patch and a
500
+ sleeve patch counts as fed ("sleeve wins"), matching the spec's literal
501
+ per-role union rule. `validate_bodies` forbids a chills+sleeve mix today
502
+ so this can never actually occur via the validated path (only via a
503
+ directly-constructed mapping that bypasses it, e.g. this module's own
504
+ mixed-mapping unit test). A FUTURE mixed-role regime might instead want
505
+ "chill wins" for a shared node (the more physically conservative choice
506
+ -- proximity to a chill accelerates freezing there regardless of a
507
+ nearby sleeve) -- flagged here for that future design, not implemented
508
+ now."""
509
+ grouped: dict[str, set] = {}
510
+ for sid_key, nodes in nodes_by_solid.items():
511
+ sid = int(sid_key)
512
+ role = role_by_solid.get(sid)
513
+ if role is None:
514
+ logger.warning(
515
+ f"_nodes_by_role_from_tagging: solid_id={sid} has classified "
516
+ f"contact-patch nodes but no entry in role_by_solid -- "
517
+ f"dropping it from the per-role feed-node mapping rather than "
518
+ f"silently mis-attributing it to some other role")
519
+ continue
520
+ grouped.setdefault(role, set()).update(int(n) for n in nodes)
521
+ return {role: sorted(ids) for role, ids in grouped.items()}
522
+
523
+
524
+ def _feed_nodes_from_role_patches(nodes_by_role: dict) -> np.ndarray:
525
+ """The sorted-unique union of every SLEEVE role's nodes
526
+ (`src.assembly._SLEEVE_ROLES` = `insulating_sleeve`/`exo_sleeve`) in
527
+ `nodes_by_role` (`{role: iterable of node ids}`) -- a chill patch, or any
528
+ other non-sleeve role, NEVER contributes a feed node. Pure function of
529
+ its argument, with NO `BcPlan`/`bc_config` involved at all: it stays
530
+ correct even fed a directly-constructed mapping mixing roles that
531
+ `validate_bodies` itself would reject (chills + a sleeve combined) --
532
+ the decision is per body role, not per global config. See
533
+ `_nodes_by_role_from_tagging`'s docstring (L3, review round 1) for the
534
+ shared-node ("sleeve wins") precedence this union implies when a node is
535
+ classified into more than one role."""
536
+ feed: set = set()
537
+ for role, nodes in nodes_by_role.items():
538
+ if role in _SLEEVE_ROLES:
539
+ feed.update(int(n) for n in nodes)
540
+ return np.asarray(sorted(feed), dtype=np.int64)
541
+
542
+
543
+ def _role_aware_feed_nodes(step_path: str, nodes_by_solid: dict, plan,
544
+ fallback_feed_nodes: np.ndarray) -> np.ndarray:
545
+ """Review round 1, H1: `nodes_by_solid` is only present in `prep_info`
546
+ when `tag_contact_patches` actually ran on THIS call. A mesh-cache HIT
547
+ served from a side-car JSON written BEFORE the Part-B roles task never
548
+ had `nodes_by_solid` written into it at all (the key didn't exist yet),
549
+ and even a post-task entry could in principle cover only some of
550
+ `plan.role_solid_ids`. `MESH_PIPELINE_VERSION` is deliberately NOT
551
+ bumped for this task (cache keys are binding, per the task brief), so
552
+ such LEGACY entries are genuinely reachable today -- `runs/mesh_cache/`
553
+ already holds pre-task assembly entries, including both gold parts.
554
+ Naively grouping incomplete/absent `nodes_by_solid` data would silently
555
+ degrade `feed_nodes` to empty for EVERY `bc_config` (not just chills),
556
+ a silent regression on exactly the sleeve inputs this task's own byte-
557
+ identity test binds. `plan.role_solid_ids` MUST all be present as keys
558
+ of `nodes_by_solid` (after normalizing str keys) for the new per-role
559
+ path to run at all; otherwise this loudly (`logger.warning`, naming
560
+ `step_path`) falls back to the PRE-CHANGE rule: empty for `bc_config ==
561
+ "chills"`, the passed-through `fallback_feed_nodes` (the plain
562
+ "Adiabatic"-tag node union `_shrinkage_surface_and_feed_nodes` already
563
+ computed) for every other config -- i.e. exactly review finding I1's
564
+ original fix, byte-identical to `predict_casting`'s behavior before this
565
+ task. Recommends refreshing/clearing the mesh-cache entry for
566
+ `step_path` (see `src.mesh_cache.clear_mesh_cache`) so a later run gets
567
+ full per-role accuracy."""
568
+ required_ids = set(plan.role_solid_ids)
569
+ covered_ids = {int(k) for k in nodes_by_solid}
570
+ if not required_ids.issubset(covered_ids):
571
+ logger.warning(
572
+ f"predict_casting: role-aware contact-patch node metadata is "
573
+ f"missing or incomplete for step_path={step_path!r} "
574
+ f"(nodes_by_solid covers solid_id(s) {sorted(covered_ids)}, but "
575
+ f"plan.role_solid_ids requires {sorted(required_ids)}) -- this is "
576
+ f"almost certainly a LEGACY runs/mesh_cache/ entry written before "
577
+ f"the Part-B roles task (nodes_by_solid didn't exist yet). "
578
+ f"Falling back to the pre-change feed-node rule (empty for "
579
+ f"bc_config=='chills', the Adiabatic-tag union otherwise) rather "
580
+ f"than silently zeroing every config's feed_nodes. Recommend "
581
+ f"clearing/refreshing the mesh-cache entry for this STEP file "
582
+ f"(see src.mesh_cache.clear_mesh_cache) to restore full per-role "
583
+ f"feed-node accuracy on the next run.")
584
+ return (np.zeros(0, dtype=np.int64) if plan.bc_config == "chills"
585
+ else fallback_feed_nodes)
586
+ nodes_by_role = _nodes_by_role_from_tagging(nodes_by_solid, plan.role_by_solid)
587
+ return _feed_nodes_from_role_patches(nodes_by_role)
588
+
589
+
590
+ def _shrinkage_result_for_return(screen: dict) -> dict:
591
+ """Directive 6: the `screen_casting` result MINUS its bulky per-node
592
+ arrays (`niyama`/`porosity_risk` dropped), plus summary counts +
593
+ `flagged_frac` -- everything else (`macro_shrinkage`/`top_sinks`/
594
+ `feeders`/`material`/`niyama_source`/`per_orientation`) is kept as-is."""
595
+ result = {k: v for k, v in screen.items() if k not in ("niyama", "porosity_risk")}
596
+ risk = screen["porosity_risk"]
597
+ result["flagged_frac"] = float(np.mean(risk)) if risk.size else 0.0
598
+ result["n_macro_regions"] = len(screen["macro_shrinkage"])
599
+ result["n_sinks"] = len(screen["top_sinks"])
600
+ result["n_feeders"] = len(screen["feeders"])
601
+ return result
602
+
603
+
604
+ def _shrinkage_extra_point_data(mesh, screen: dict) -> dict:
605
+ """The 3 VTU point fields directive 4 asks for: `niyama` (float),
606
+ `porosity_risk` (uint8 0/1), and `macro_region_id` (-1 = no macro
607
+ region, else that region's own `region_id`)."""
608
+ n_nodes = mesh.node_xyz.shape[0]
609
+ macro_region_id = np.full(n_nodes, -1, dtype=np.int64)
610
+ for region in screen["macro_shrinkage"]:
611
+ macro_region_id[np.asarray(region["nodes"], dtype=np.int64)] = region["region_id"]
612
+ return {
613
+ "niyama": np.asarray(screen["niyama"], dtype=np.float32),
614
+ "porosity_risk": np.asarray(screen["porosity_risk"], dtype=np.uint8),
615
+ "macro_region_id": macro_region_id,
616
+ }
617
+
618
+
619
+ def _write_shrinkage_summary_block(f, shrinkage) -> None:
620
+ """Directive 5's exact skeleton: a `shrinkage screening:` section with
621
+ niyama_source, flagged-node %, N macro regions (worst severity +
622
+ centroid), N sinks, and feeder recommendations (location + min
623
+ modulus) -- or a single `shrinkage screening: disabled` line.
624
+
625
+ Only called when screening was ATTEMPTED (`screen_shrinkage=True`) but
626
+ either produced a result (`shrinkage` is the trimmed dict) or was
627
+ internally skipped (`shrinkage` is `None`, e.g. no surface nodes to
628
+ place a feeder against) -- `screen_shrinkage=False` (the explicit
629
+ opt-out) never calls this at all, so that path stays byte-identical to
630
+ the pre-Task-4 summary (see `predict_casting`'s own docstring)."""
631
+ if shrinkage is None:
632
+ f.write("shrinkage screening: disabled\n")
633
+ return
634
+ f.write("shrinkage screening:\n")
635
+ f.write(f" niyama_source: {shrinkage['niyama_source']}\n")
636
+ if shrinkage.get("niyama_confidence") == "low":
637
+ # Spec ADDENDUM 2 (2026-08-01, owner-approved scoped ship): Gate C
638
+ # passed both floors on the all-sand slice (sleeves owner-accepted as
639
+ # intermediate: rho 0.768/0.691, below the 0.8 floor) but failed on the
640
+ # chill-bearing slice (Spearman rho 0.313) -- the proxy Niyama
641
+ # identity `Tdot_proxy = (TL-TS)/t_s` has no way to see a chill's
642
+ # locally-enhanced cooling rate, so its Niyama ranking on a
643
+ # chill-bearing input is not trustworthy the way it is on a bare or
644
+ # sleeve-only casting. Macro-shrinkage/sinks/feeders don't depend on
645
+ # Niyama at all and keep full confidence regardless.
646
+ #
647
+ # Learned-heads Task 3 review round 1, F3: the marking itself
648
+ # (`niyama_confidence == "low"` on any chill-bearing input) STAYS as
649
+ # is until the promote step (per the spec's own retirement plan) --
650
+ # only the CAVEAT TEXT is now conditional on `niyama_source`. The
651
+ # proxy's specific failure mode (and its Gate C rho number) is a
652
+ # claim about the PROXY identity, not about a learned checkpoint's
653
+ # heads -- citing "rho 0.313" next to a learned-source result would
654
+ # misattribute the proxy's own measured shortfall to a different
655
+ # (not yet slice-validated) source. A learned checkpoint gets a
656
+ # neutral caveat instead, pending its own Gate C learned-mode slice
657
+ # numbers (`tools/gate_c.py field_source="learned"`).
658
+ if shrinkage["niyama_source"] == "proxy":
659
+ f.write(" niyama confidence: LOW (chill-bearing input; proxy Niyama cannot see "
660
+ "chill-local cooling rates — Gate C chills slice rho 0.313)\n")
661
+ elif shrinkage["niyama_source"] == "learned":
662
+ f.write(" niyama confidence: LOW (chill-bearing input; learned-head Niyama "
663
+ "pending slice validation)\n")
664
+ else:
665
+ f.write(f" niyama confidence: LOW (chill-bearing input; "
666
+ f"niyama_source={shrinkage['niyama_source']!r})\n")
667
+ flagged_line = f" flagged nodes: {shrinkage['flagged_frac'] * 100.0:.1f}%"
668
+ if shrinkage["niyama_source"] == "proxy":
669
+ # Review round 1, finding 4: the proxy path's risk mask is
670
+ # `porosity_risk(mode="percentile")` -- ALWAYS ~the bottom decile by
671
+ # construction (src.shrinkage's default `pct=10.0`), a within-part
672
+ # relative ranking, not an absolute risk level. Without this note a
673
+ # reader could mistake "10.0%" for a measured severity rather than
674
+ # the fixed cutoff it structurally always is on this path.
675
+ flagged_line += " (bottom decile by construction)"
676
+ f.write(flagged_line + "\n")
677
+ macro = shrinkage["macro_shrinkage"]
678
+ if macro:
679
+ # Review finding C1: regions with fewer than 4 nodes
680
+ # (`region["sub_resolution"]`, set by `src.shrinkage.hot_regions`)
681
+ # are still listed here (their volume/severity stay meaningful) but
682
+ # reported SEPARATELY from the well-resolved count -- they never
683
+ # get a feeder recommendation below (their V/A modulus diverges
684
+ # without bound at this resolution; see `feeder_recommendations`'s
685
+ # docstring), so folding them into one undifferentiated N would
686
+ # overstate how many of the N regions actually have a usable
687
+ # feeder placement.
688
+ n_sub_resolution = sum(1 for r in macro if r.get("sub_resolution"))
689
+ region_count = str(len(macro))
690
+ if n_sub_resolution:
691
+ region_count += f" (+{n_sub_resolution} sub-resolution)"
692
+ worst = max(macro, key=lambda r: r["severity"])
693
+ c = worst["centroid"]
694
+ f.write(f" macro shrinkage regions: {region_count} "
695
+ f"(worst severity={worst['severity']:.4g} mm^3 @ "
696
+ f"({c[0]:.1f},{c[1]:.1f},{c[2]:.1f}))\n")
697
+ else:
698
+ f.write(" macro shrinkage regions: 0\n")
699
+ f.write(f" sinks: {len(shrinkage['top_sinks'])}\n")
700
+ feeders = shrinkage["feeders"]
701
+ if feeders:
702
+ f.write(" feeder recommendations:\n")
703
+ for feeder in feeders:
704
+ loc = feeder["location"]
705
+ f.write(f" - location=({loc[0]:.1f},{loc[1]:.1f},{loc[2]:.1f}) mm "
706
+ f"min_modulus_mm={feeder['min_modulus_mm']:.4f}\n")
707
+ else:
708
+ f.write(" feeder recommendations: none\n")
709
+ if shrinkage.get("alloy_family_assumed"):
710
+ f.write(f" alloy_family_assumed: {shrinkage['material']['alloy_family']} "
711
+ f"(alloy grade name not recognized -- conservative fallback)\n")
712
+
713
+
714
+ def _parse_gravity(s: str) -> tuple:
715
+ """CLI `--gravity` parser: `"x,y,z"` -> `(float(x), float(y), float(z))`.
716
+ A small free function so it's unit-testable without going through
717
+ `argparse`/`sys.argv` -- and (review round 1, finding 5) passed directly
718
+ as `argparse`'s own `type=` callable, so a malformed `--gravity` value
719
+ surfaces as argparse's normal `ValueError`-driven usage error (exit
720
+ code 2, "argument --gravity: invalid _parse_gravity value: ...") rather
721
+ than an uncaught traceback deep inside `predict_casting`.
722
+
723
+ Rejects the zero vector loudly (`ValueError`): `screen_casting`'s
724
+ `top_sinks`/`feeder_recommendations` normalize `gravity_vec` by its own
725
+ norm and would divide by zero silently otherwise (`up = up / norm`
726
+ guarded only by `norm > 0`, which degrades to a NEVER-normalized,
727
+ physically meaningless direction rather than raising) -- a caller
728
+ typo'ing `0,0,0` deserves a clear error, not a silently-wrong feeder
729
+ placement."""
730
+ parts = [p.strip() for p in s.split(",")]
731
+ if len(parts) != 3:
732
+ raise ValueError(f"--gravity must be 'x,y,z' (3 comma-separated numbers), got {s!r}")
733
+ vec = tuple(float(p) for p in parts)
734
+ if all(v == 0.0 for v in vec):
735
+ raise ValueError(
736
+ f"--gravity must not be the zero vector (got {s!r}) -- gravity_vec is "
737
+ f"normalized by its own norm for top-sink/feeder placement, and the zero "
738
+ f"vector has no direction")
739
+ return vec
740
+
741
+
742
+ def load_hybrid(gnn_ckpt, hgb_path, device=None,
743
+ baseline_features_dir="data/features_v2", hgb_std_path=None):
744
+ """Load the GNN + (optional) HGB baseline (+ optional GBM std-head) for
745
+ `predict_field`.
746
+
747
+ Film (v4) checkpoints load via `src.evaluate_ft2b.load_ft2b_gnn` and skip
748
+ `baseline_norm` entirely (v4's HGB is the v2-total scale GBM, standardized
749
+ by `src.gbm_ft2b` itself -- there is no separate per-node baseline-feature
750
+ norm to load). Non-film checkpoints are loaded exactly as before
751
+ (`src.evaluate_gnn.load_gnn`) -- byte-identical to the pre-v4 code.
752
+
753
+ `hgb_std_path` (variance-recomposition Task 3): an optional path to a
754
+ `joblib`-dumped GBM std-head model (`src.gbm_ft2b.refit_gbm_std`'s
755
+ output), loaded exactly like `hgb_path` (`joblib.load` if given, else
756
+ `None`). `None` (the default -- unchanged by Task 6, see below) means
757
+ the returned `hgb_std` is `None` and every other return value / code
758
+ path is byte-identical to before this argument existed.
759
+
760
+ `hgb_std_path` may also be (variance-recomposition Task 6):
761
+ - the module-level `_USE_DEFAULT` sentinel: resolved HERE, using the
762
+ GNN checkpoint's own `cfg["film"]` flag (already being determined
763
+ below to pick the loader) -- `DEFAULT_HGB_STD` for a film-capable
764
+ checkpoint, `None` for a non-film one. This is what lets
765
+ `predict_casting`'s own sentinel default reach the new production
766
+ std head with zero flags, while leaving a non-film (SP1) `--gnn`/
767
+ `--hgb` rollback untouched: no attempt is made to load
768
+ `DEFAULT_HGB_STD` at all in that case, so a missing/irrelevant v4-only
769
+ artifact never breaks a non-film call.
770
+ - the case-insensitive string `"none"`: always resolved to `None`,
771
+ regardless of film-ness -- the explicit rollback switch to the
772
+ quantile-anchor recomposition (usable on any film checkpoint,
773
+ including the new default one).
774
+ An explicit path (or an already-loaded model object, via `hgb_path`'s
775
+ own convention) is still loaded/used unconditionally either way, exactly
776
+ as before Task 6 -- `load_hybrid` never judges whether a checkpoint/
777
+ std-head pairing makes sense; that is `predict_casting`'s job (it raises
778
+ if the resulting `hgb_std` is non-`None` for a non-film checkpoint).
779
+
780
+ Returns a 6-tuple (was 5, before this argument existed): `(gnn, hgb,
781
+ norm, cfg, baseline_norm, hgb_std)` -- every existing call site was
782
+ updated to the new arity when this argument was added; any NEW call site
783
+ must unpack all 6.
784
+ """
785
+ device = device or ("cuda" if torch.cuda.is_available() else "cpu")
786
+ gnn_ckpt, hgb_path = resolve_artifact(gnn_ckpt), resolve_artifact(hgb_path)
787
+ hgb = load_gbm(hgb_path)
788
+ state = load_gnn_any(gnn_ckpt, device)
789
+ is_film = state.get("cfg", {}).get("film", False)
790
+
791
+ if hgb_std_path is _USE_DEFAULT:
792
+ hgb_std_path = DEFAULT_HGB_STD if is_film else None
793
+ elif isinstance(hgb_std_path, str) and hgb_std_path.strip().lower() == "none":
794
+ hgb_std_path = None
795
+ hgb_std_path = resolve_artifact(hgb_std_path)
796
+ try:
797
+ hgb_std = load_gbm(hgb_std_path)
798
+ except FileNotFoundError as e:
799
+ raise FileNotFoundError(
800
+ f"GBM std head not found: {hgb_std_path!r}. Since the 2026-07-31 v5 "
801
+ f"promotion the std artifact ({DEFAULT_HGB_STD}) is a PRODUCTION "
802
+ f"requirement of the default affine recomposition -- provision it "
803
+ f"alongside the GNN/GBM artifacts, or pass --hgb-std none (API: "
804
+ f"hgb_std_path=None) to fall back to the anchor recomposition.") from e
805
+
806
+ if is_film:
807
+ gnn, cfg, norm = load_ft2b_gnn(state, device)
808
+ return gnn, hgb, norm, cfg, None, hgb_std
809
+ gnn, cfg, norm = load_gnn(gnn_ckpt, device)
810
+ from src.dataload import load_norm
811
+ baseline_norm = load_norm(baseline_features_dir) if hgb is not None else None
812
+ return gnn, hgb, norm, cfg, baseline_norm, hgb_std
813
+
814
+
815
+ import os
816
+ import argparse
817
+
818
+ from src.step_mesh import mesh_step
819
+ from src.msh_parse import parse_msh
820
+ from src import ood as _ood
821
+ from src.mesh_cache import get_or_mesh
822
+ from src.assembly import validate_bodies, tag_contact_patches, _SLEEVE_ROLES
823
+ from tools.doe.prep_parts import DEFAULT_TARGET_NODES
824
+
825
+
826
+ def _bodies_cache_key(bodies: list, plan) -> str:
827
+ """Part B Task 4: a normalized-content hash of `bodies`, folding in the
828
+ resolved `BcPlan` scalars (`chill_effusivity`/`sleeve_MEF`/`exo_q0`) for
829
+ whichever body ids are `plan.role_solid_ids` -- NOT the raw role/params
830
+ text the caller typed -- so a `sleeve_registry.csv`/`chills.csv` edit
831
+ changes the mesh-cache key even for a byte-identical `bodies.json` (the
832
+ key means "this exact resolved BC plan", not just "this exact JSON
833
+ text"). Order-invariant (sorted by `solid_id`) and role-sensitive (a
834
+ role change always changes that row, even for a body with no numeric
835
+ params of its own, e.g. `"casting"` or `"ignore"`)."""
836
+ role_ids = set(plan.role_solid_ids)
837
+ rows = []
838
+ for i, b in enumerate(bodies):
839
+ if "solid_id" not in b:
840
+ raise ValueError(
841
+ f"_bodies_cache_key: bodies[{i}] (role={b.get('role')!r}) is "
842
+ f"missing 'solid_id' -- every row, including 'ignore' rows, "
843
+ f"must carry a solid_id")
844
+ row = {"solid_id": b["solid_id"], "role": b.get("role")}
845
+ if b["solid_id"] in role_ids:
846
+ if plan.bc_config == "chills":
847
+ row["chill_effusivity"] = plan.chill_effusivity
848
+ elif plan.bc_config in ("insulating_sleeve", "exo_sleeve"):
849
+ row["sleeve_MEF"] = plan.sleeve_MEF
850
+ row["exo_q0"] = plan.exo_q0
851
+ rows.append(row)
852
+ rows.sort(key=lambda r: r["solid_id"])
853
+ return hashlib.sha256(json.dumps(rows, sort_keys=True).encode("utf-8")).hexdigest()
854
+
855
+
856
+ def predict_casting(step_path, out_dir="runs/castings", elem_size=None,
857
+ mesh_mode="prep",
858
+ gnn_ckpt=DEFAULT_GNN_CKPT,
859
+ hgb_path=DEFAULT_HGB,
860
+ ood_json="data/features_v2/ood_ranges.json", device=None,
861
+ tta=0, alloy=None, mold="green_sand", props_json=None,
862
+ bodies=None, target_nodes: int = DEFAULT_TARGET_NODES,
863
+ hgb_std_path=_USE_DEFAULT,
864
+ lambda_shrink: float = 1.0, w_total: float = DEFAULT_W_TOTAL,
865
+ w_std: float = DEFAULT_W_STD, screen_shrinkage: bool = True,
866
+ gravity: tuple = (0, 0, -1)) -> dict:
867
+ """`mesh_mode`: "prep" (default) routes meshing through
868
+ `tools.doe.prep_parts.heal_and_mesh` -- the SAME largest-solid + OCC
869
+ heal + density-searched pipeline used to prep the training/dataset-D
870
+ parts, so real-part inference and training use one honest meshing
871
+ stack. "raw" keeps the old `src.step_mesh.mesh_step` single-pass
872
+ uniform-density path (escape hatch). An explicit `elem_size` always
873
+ implies "raw" regardless of `mesh_mode` -- density-searching towards a
874
+ node-count target and pinning an exact element size are mutually
875
+ exclusive requests, and the explicit size wins. "prep" mode meshes via
876
+ `src.mesh_cache.get_or_mesh`, so a byte-identical STEP file (same
877
+ `target_nodes`) is served from `runs/mesh_cache/` instead of re-meshed.
878
+
879
+ `target_nodes` (app Phase A, 2026-08-01): the node-count target the
880
+ "prep" density search aims at, forwarded to `src.mesh_cache.get_or_mesh`
881
+ (and thus part of the mesh-cache key, so two densities of the same STEP
882
+ cache separately). Defaults to `DEFAULT_TARGET_NODES` (8500), which is
883
+ byte-identical to the previous hardcoded behaviour. The trained density
884
+ band is ~7.5-10k nodes (DECISIONS-2026-07-22 §9); values outside it put
885
+ the mesh outside what the model saw, and callers exposing this to a user
886
+ should bound it there. Passing a non-default value together with an
887
+ explicit `elem_size` raises `ValueError` rather than being ignored --
888
+ `elem_size` forces "raw", which never runs the density search at all.
889
+
890
+ `bodies` (Part B Task 4): a multi-body STEP's role assignment -- either
891
+ a list of `{"solid_id": int, "role": ..., "params": {...}}` dicts (see
892
+ `src.assembly.validate_bodies`) or a `str`/path-like path to a JSON file
893
+ holding that list (loaded via `json.load`). `None` (the default) is
894
+ byte-identical to the pre-Part-B behavior: no `validate_bodies` call,
895
+ `keep_solid_id=None`/`bodies_key="none"` into `get_or_mesh`, no
896
+ `tag_contact_patches` call, and the film spec/gbm bc_spec stay at their
897
+ all-sand-mold defaults (see `predict_field`). When given, the flow is
898
+ `validate_bodies` -> `get_or_mesh(..., keep_solid_id=plan.casting_solid_id,
899
+ bodies_key=<normalized hash>, post_mesh=<tag_contact_patches closure>)`
900
+ -> `predict_field(..., bc_plan=plan)`; the resolved `bc_config` and any
901
+ contact-patch areas are added to the result dict and the summary file.
902
+ Requires `mesh_mode="prep"` (the effective mode after any `elem_size`
903
+ override) -- role-based casting-solid selection and contact tagging
904
+ only run through that pipeline; combining `bodies` with the "raw" mesh
905
+ path raises `ValueError` rather than silently ignoring the role
906
+ assignment.
907
+
908
+ `hgb_std_path` (variance-recomposition Task 3; default changed in Task 6;
909
+ recomposition swapped to `hybrid_field_soft` by soft-anchor Task 3): an
910
+ optional path to a `joblib`-dumped GBM std-head model
911
+ (`src.gbm_ft2b.refit_gbm_std`/`refit_gbm_std_v2`'s output, 16 or 17
912
+ features -- `predict_field` width-dispatches off the loaded model's own
913
+ `n_features_in_`), forwarded to `load_hybrid`/`predict_field`. Defaults
914
+ to the module-level `_USE_DEFAULT` sentinel (not `None`, since Task 6):
915
+ `load_hybrid` resolves this to `DEFAULT_HGB_STD` for a film-capable
916
+ checkpoint (the production call, zero extra flags, now recomposes via
917
+ `hybrid_field_soft`) or to `None` for a non-film one (the SP1 rollback
918
+ via `--gnn`/`--hgb` is unaffected -- no attempt is made to load
919
+ `DEFAULT_HGB_STD` for a non-film checkpoint at all). Pass `None`
920
+ explicitly, or the case-insensitive string `"none"` (the CLI-friendly
921
+ form), to force the quantile-anchor recomposition on ANY checkpoint,
922
+ including the new default film one -- this is byte-identical to the
923
+ pre-Task-3 code. Passing an explicit path (or an already-loaded model
924
+ object) still requires a film-capable checkpoint (same reasoning as
925
+ `bodies`: SP1 has no scale-head std target) -- raises `ValueError`
926
+ otherwise, BEFORE meshing. When the soft-anchor recomposition is active,
927
+ the summary file gains a `soft recomposition: a=..., m=...,
928
+ target_unreachable=..., clip_frac=...` line (`target_unreachable`
929
+ surfaces whether the underlying hard (affine) two-target problem the
930
+ soft solve seeds from was itself unreachable at some `a` -- see
931
+ `src.hybrid.hybrid_field_soft`), further appending a trailing
932
+ `total_dev=+x.x%` field (final review I-1, 2026-08-01) whenever
933
+ `abs(info["total_deviation_frac"]) > 0.001` -- signed, one decimal
934
+ (e.g. `total_dev=+4.7%`) -- surfacing the soft anchor's defining
935
+ trade-off (letting the total deviate from the GBM total to buy a closer
936
+ spread match) in the human-readable summary, not just the return dict's
937
+ `recomposition_info`; omitted entirely below that threshold so a healthy
938
+ part's summary stays clean. Otherwise (no active std head) it gains a
939
+ `recomposition: anchor ...` line (film checkpoints only).
940
+
941
+ `lambda_shrink` (variance-recomposition amendment 2, CLI `--affine-
942
+ lambda`): forwarded straight to `predict_field`/`hybrid_field_soft`
943
+ (ignored unless `hgb_std_path` is also given). `1.0` (the default) is
944
+ byte-identical to before this argument existed. When active AND
945
+ `lambda_shrink != 1.0`, the summary's recomposition line gains a
946
+ trailing `lambda_shrink=...` field.
947
+
948
+ `w_total`/`w_std` (soft-anchor Task 3, CLI `--w-total`/`--w-std`; defaults
949
+ changed to `DEFAULT_W_TOTAL`/`DEFAULT_W_STD` (`1.0`/`4.0`) by the
950
+ soft-anchor Task 6 promotion, 2026-07-31 -- the e_val-sweep winner
951
+ recorded in the spec addendum): forwarded straight to
952
+ `predict_field`/`hybrid_field_soft`'s own weights (ignored unless
953
+ `hgb_std_path` is also given). When either differs from `1.0`/`1.0` (NOT
954
+ from the new defaults -- this compares against `hybrid_field_soft`'s own
955
+ equal-weighting baseline, so the new production default line still
956
+ prints its weights), the summary's recomposition line gains a trailing
957
+ `w_total=..., w_std=...` field.
958
+
959
+ `screen_shrinkage` (SP2 shrinkage Task 4, CLI `--no-shrinkage`; learned
960
+ source added by learned-heads Task 3): runs `src.shrinkage.screen_casting`
961
+ on the recomposed hybrid field (`t_s`) AFTER recomposition, BEFORE the
962
+ VTU write -- Niyama (the time-only proxy, UNLESS `gnn_ckpt` is a
963
+ deriv_heads-capable checkpoint, in which case `predict_field` has already
964
+ populated `feat["gradT"]`/`feat["dTdt"]` with the checkpoint's own learned
965
+ per-node predictions, routing `screen_casting` through its exact branch
966
+ instead; the returned `shrinkage["niyama_source"]` is then relabeled
967
+ `"learned"` here -- NOT `screen_casting`'s own `"exact"`, which is
968
+ reserved for a real cached solver field, e.g. Gate C's per-row
969
+ comparison), macro-shrinkage region detection, top-surface sinks, and
970
+ feeder recommendations. `surface_nodes`/`feed_nodes` come straight off
971
+ the parsed mesh's physical-group triangles (`_shrinkage_surface_and_
972
+ feed_nodes` -- ALL surface tris union for `surface_nodes`, "Adiabatic"
973
+ tris only for `feed_nodes`, empty for a bare sand casting with no
974
+ risers); `TL`/`TS` come from the already-resolved `props`; `alloy_family`
975
+ comes from `src.inference_props.resolve_alloy_family` (a name-prefix map
976
+ onto `src.shrinkage.SHRINKAGE_PCT`'s family keys -- an unrecognized grade
977
+ name, or any `--props` custom-JSON override, falls back loudly to
978
+ `"steel"` and is recorded as `alloy_family_assumed` in the result rather
979
+ than crashing or silently mis-scaling severities). `True` (the default)
980
+ adds `niyama`/`porosity_risk`/`macro_region_id` VTU point fields, a
981
+ `shrinkage screening:` block in the summary, and a `"shrinkage"` key in
982
+ the return dict (the `screen_casting` result minus its bulky per-node
983
+ `niyama`/`porosity_risk` arrays, plus `flagged_frac`/`n_macro_regions`/
984
+ `n_sinks`/`n_feeders` counts).
985
+
986
+ Shrinkage-screening spec ADDENDUM 2 (2026-08-01, owner-approved SCOPED
987
+ SHIP): the shrinkage dict always carries `"niyama_confidence"` (`"full"`
988
+ or `"low"`). Gate C (the proxy-Niyama validation) passed both floors on
989
+ all-sand only (sleeve slices are an owner-accepted intermediate: rho
990
+ 0.768/0.691, below the 0.8 floor) and FAILED on the chill-bearing slice (Spearman rho
991
+ 0.313 -- the proxy `Tdot ~ (TL-TS)/t_s` identity has no way to see a
992
+ chill's local cooling-rate enhancement). Whenever `bodies` resolves to a
993
+ `BcPlan` with `bc_config == "chills"`, `"niyama_confidence"` is `"low"`
994
+ and the summary gains a caveat line naming the Gate C number; bare/
995
+ all-sand and sleeve-only inputs (`bc_config` `"none"`/
996
+ `"insulating_sleeve"`/`"exo_sleeve"`) stay `"full"`. The `niyama`/
997
+ `porosity_risk` VTU fields and the return dict still populate either way
998
+ (screening stays informative, just honestly labeled) -- macro-shrinkage/
999
+ top-sink/feeder outputs never depended on Niyama and are unaffected by
1000
+ this label.
1001
+ `False` skips all of that entirely --
1002
+ `"shrinkage"` is `None` and the VTU/summary are byte-identical to the
1003
+ pre-Task-4 contract (no extra fields, no extra summary lines at all --
1004
+ the `shrinkage screening: disabled` line only appears when screening
1005
+ was attempted but internally skipped, e.g. a mesh with no surface nodes
1006
+ to place a feeder against, never for this explicit opt-out).
1007
+
1008
+ `gravity` (CLI `--gravity x,y,z`, parsed by `_parse_gravity`): forwarded
1009
+ straight to `screen_casting`'s `gravity_vec` (top-sink/feeder placement
1010
+ direction; conduction/Niyama/macro-region *detection* don't depend on
1011
+ it). `(0, 0, -1)` (the default) is the same "gravity along -z" convention
1012
+ used elsewhere in this module. Ignored entirely when `screen_shrinkage`
1013
+ is `False`.
1014
+
1015
+ Returns a dict that (soft-anchor Task 4, v5 review M-1) always carries
1016
+ `"recomposition"` and `"recomposition_info"` alongside the existing keys:
1017
+ `"recomposition"` is `"soft"` (the affine two-target solve via
1018
+ `hybrid_field_soft` is active -- `hgb_std_path` resolved to a real std
1019
+ head), `"anchor"` (a film/v4 checkpoint recomposed via the plain
1020
+ quantile-anchor -- `hgb_std_path` resolved to `None`, e.g. via `"none"`),
1021
+ or `"sp1"` (a non-film checkpoint -- no recomposition-mode concept
1022
+ applies at all). `"recomposition_info"` is `hybrid_field_soft`'s own
1023
+ info dict (`a`/`m`/`target_unreachable`/`clip_frac`/
1024
+ `total_deviation_frac`/... -- see `predict_field`'s docstring) when
1025
+ `"recomposition"` is `"soft"`, else `None` -- there is no equivalent info
1026
+ dict on the anchor or SP1 paths.
1027
+ """
1028
+ os.makedirs(out_dir, exist_ok=True)
1029
+ name = osp.splitext(osp.basename(step_path))[0]
1030
+ out_msh = osp.join(out_dir, f"{name}.msh")
1031
+
1032
+ # Resolve properties early (cheap, no mesh dependency) so typos/bad alloys
1033
+ # fail fast, before potentially lengthy meshing.
1034
+ props = resolve_props(alloy=alloy, mold=mold, props_json=props_json)
1035
+
1036
+ plan = None
1037
+ bodies_key = "none"
1038
+ if bodies is not None:
1039
+ if isinstance(bodies, (str, os.PathLike)):
1040
+ with open(osp.expanduser(bodies)) as f:
1041
+ bodies = json.load(f)
1042
+ # No `mef_weights` here (SP4 review I3, 2026-08-08): a mixed-MEF
1043
+ # sleeve set needs contact-patch areas per solid_id, but those only
1044
+ # exist after meshing + `tag_contact_patches`, which have not run
1045
+ # yet at this point -- `validate_bodies` is called here specifically
1046
+ # to build the mesh-cache key (`_bodies_cache_key` below) before any
1047
+ # mesh exists. This is not a regression (every mixed-MEF assembly
1048
+ # was rejected outright before the multi-sleeve ADDENDUM), but it
1049
+ # does mean this CLI/webapp entry point cannot resolve a mixed-MEF
1050
+ # assembly -- it raises naming `mef_weights`, a parameter neither
1051
+ # surface accepts. A caller that needs a mixed-MEF assembly must
1052
+ # mesh and tag first, then call `validate_bodies` again with
1053
+ # `mef_weights=per_body_patch_area_mm2`, exactly as `tools/sp4/
1054
+ # prep.py` + `tools/sp4/score.py` do for the SP4 gold set. Known
1055
+ # gap, not fixed here -- restructuring this function's meshing flow
1056
+ # to do that two-pass resolution is separate work; see the spec's
1057
+ # multi-sleeve ADDENDUM.
1058
+ plan = validate_bodies(bodies)
1059
+ bodies_key = _bodies_cache_key(bodies, plan)
1060
+
1061
+ effective_mode = "raw" if elem_size is not None else mesh_mode
1062
+ if target_nodes != DEFAULT_TARGET_NODES and effective_mode != "prep":
1063
+ raise ValueError(
1064
+ f"predict_casting: `target_nodes` ({target_nodes}) applies to the "
1065
+ f"prep/get_or_mesh density search only, but an explicit "
1066
+ f"elem_size={elem_size!r} forces mesh_mode='raw' -- pinning an "
1067
+ f"element size and searching towards a node count are mutually "
1068
+ f"exclusive requests. Drop one.")
1069
+ if bodies is not None and effective_mode != "prep":
1070
+ raise ValueError(
1071
+ f"predict_casting: `bodies` requires mesh_mode='prep' (got "
1072
+ f"mesh_mode={mesh_mode!r}, effective_mode={effective_mode!r} -- "
1073
+ f"an explicit elem_size forces 'raw') -- role-based casting-solid "
1074
+ f"selection and contact-patch tagging only run through the "
1075
+ f"prep/get_or_mesh pipeline")
1076
+
1077
+ # Loaded BEFORE meshing so a bodies/non-film-checkpoint mismatch (below)
1078
+ # is caught before tag_contact_patches ever runs -- not merely before
1079
+ # its result is used. A resolved BcPlan (chills/sleeve roles) means
1080
+ # tag_contact_patches would relabel the contact patch "Adiabatic", which
1081
+ # a non-film (SP1) checkpoint's geometry features read AS PHYSICAL
1082
+ # INSULATION. For a chill, that's backwards physics (a chill COOLS,
1083
+ # Adiabatic INSULATES); for a sleeve it's merely uncalibrated. Either
1084
+ # way, SP1 has no BC channel to honor the role assignment at all, so
1085
+ # silently proceeding would both report bc_config as if honored AND
1086
+ # leave a wrong-physics tagged mesh sitting in the mesh cache.
1087
+ gnn_ckpt, hgb_path = resolve_artifact(gnn_ckpt), resolve_artifact(hgb_path)
1088
+ gnn, hgb, norm, cfg, baseline_norm, hgb_std = load_hybrid(
1089
+ gnn_ckpt, hgb_path, device, hgb_std_path=hgb_std_path)
1090
+ # Provenance label for the summary txt: the RESOLVED std artifact (the
1091
+ # v5.1 stack is defined by it + the weights), mirroring load_hybrid's own
1092
+ # sentinel/"none" resolution without re-running it.
1093
+ if hgb_std is None:
1094
+ hgb_std_path_label = "none"
1095
+ elif hgb_std_path is _USE_DEFAULT:
1096
+ hgb_std_path_label = resolve_artifact(DEFAULT_HGB_STD)
1097
+ else:
1098
+ hgb_std_path_label = str(hgb_std_path)
1099
+ if plan is not None and not cfg.get("film", False):
1100
+ raise ValueError(
1101
+ "predict_casting: bodies/BC roles require the film-conditioned "
1102
+ "v4 stack; the selected checkpoint is not film-capable -- rerun "
1103
+ "without --bodies or with the v4 checkpoints")
1104
+ if hgb_std is not None and not cfg.get("film", False):
1105
+ raise ValueError(
1106
+ "predict_casting: --hgb-std requires the film-conditioned v4 "
1107
+ "stack; the selected checkpoint is not film-capable -- rerun "
1108
+ "without --hgb-std or with the v4 checkpoints")
1109
+
1110
+ if effective_mode == "raw":
1111
+ raw_info = mesh_step(step_path, out_msh, elem_size)
1112
+ mesh = parse_msh(out_msh)
1113
+ minfo = {"n_nodes": raw_info["n_nodes"], "n_tets": raw_info["n_tets"],
1114
+ "elem_size": raw_info["elem_size"], "bbox_mm": raw_info["bbox_mm"]}
1115
+ elif effective_mode == "prep":
1116
+ keep_solid_id = plan.casting_solid_id if plan is not None else None
1117
+ post_mesh = None
1118
+ if plan is not None and plan.role_solid_ids:
1119
+ def post_mesh(msh_path, _step_path=step_path, _role_ids=plan.role_solid_ids):
1120
+ return tag_contact_patches(msh_path, _step_path, _role_ids)
1121
+
1122
+ prep_info = get_or_mesh(step_path, out_msh, target_nodes=target_nodes,
1123
+ keep_solid_id=keep_solid_id, bodies_key=bodies_key,
1124
+ post_mesh=post_mesh)
1125
+ if not prep_info.get("ok", False):
1126
+ raise RuntimeError(
1127
+ f"predict_casting: prep meshing pipeline (get_or_mesh) failed for "
1128
+ f"{step_path!r}: {prep_info.get('notes', '(no notes)')}")
1129
+ mesh = parse_msh(out_msh)
1130
+ bbox_mm = tuple((mesh.node_xyz.max(0) - mesh.node_xyz.min(0)).tolist())
1131
+ cache = prep_info.get("cache", "unknown")
1132
+ minfo = {"n_nodes": prep_info["n_nodes"], "n_tets": int(mesh.tets.shape[0]),
1133
+ "elem_size": None, "bbox_mm": bbox_mm, "cache": cache}
1134
+ if "per_body_patch_area_mm2" in prep_info:
1135
+ # A cache HIT round-trips through JSON, turning int dict keys
1136
+ # into str -- normalize back to int so callers (and the summary
1137
+ # below) see one consistent shape regardless of hit vs. miss.
1138
+ minfo["per_body_patch_area_mm2"] = {
1139
+ int(k): v for k, v in prep_info["per_body_patch_area_mm2"].items()}
1140
+ else:
1141
+ raise ValueError(f"predict_casting: mesh_mode must be 'prep' or 'raw', got {mesh_mode!r}")
1142
+
1143
+ seconds, feat = predict_field(mesh, gnn, hgb, norm, cfg=cfg, device=device, tta=tta,
1144
+ baseline_norm=baseline_norm, props=props, bc_plan=plan,
1145
+ hgb_std=hgb_std, lambda_shrink=lambda_shrink,
1146
+ w_total=w_total, w_std=w_std)
1147
+
1148
+ warnings = []
1149
+ ood_json = resolve_artifact(ood_json)
1150
+ if osp.exists(ood_json):
1151
+ ranges = _ood.load_ranges(ood_json)
1152
+ X = feat["X"][:, :N_BASELINE_FEATURES]
1153
+ vol = float(X[0, FEATURE_NAMES.index("volume_mm3")])
1154
+ gcmax = float(X[:, FEATURE_NAMES.index("geodesic_d_cool")].max())
1155
+ diag = float(np.linalg.norm(mesh.node_xyz.max(0) - mesh.node_xyz.min(0)))
1156
+ warnings = _ood.ood_flags(vol, diag, gcmax, ranges)
1157
+
1158
+ # SP2 shrinkage Task 4: screen the recomposed hybrid field for shrinkage-
1159
+ # porosity risk, AFTER recomposition, BEFORE the VTU write -- see
1160
+ # predict_casting's own docstring for the full contract.
1161
+ shrinkage = None
1162
+ shrinkage_extra_point_data = None
1163
+ if screen_shrinkage:
1164
+ surface_nodes, feed_nodes = _shrinkage_surface_and_feed_nodes(mesh)
1165
+ if plan is not None and plan.role_solid_ids:
1166
+ # Part-B roles task (2026-08-03, replaces review finding I1's
1167
+ # coarse `bc_config == "chills"` special case): recompute
1168
+ # feed_nodes PER BODY ROLE rather than zeroing it out for the
1169
+ # whole input -- only the SLEEVE roles' nodes are ever a feed
1170
+ # source, a chill patch never is, regardless of `bc_config`. See
1171
+ # `_feed_nodes_from_role_patches`'s docstring and this module's
1172
+ # own investigation note (above `_nodes_by_role_from_tagging`)
1173
+ # for why no separate "pre-existing Adiabatic" contribution
1174
+ # needs unioning in here. `_role_aware_feed_nodes` (review round
1175
+ # 1, H1) additionally guards against a LEGACY mesh-cache HIT
1176
+ # whose side-car predates this task (no `nodes_by_solid` at
1177
+ # all) -- see its own docstring.
1178
+ nodes_by_solid = prep_info.get("nodes_by_solid", {})
1179
+ feed_nodes = _role_aware_feed_nodes(step_path, nodes_by_solid, plan, feed_nodes)
1180
+ if surface_nodes.size == 0:
1181
+ logger.warning(
1182
+ "predict_casting: no surface nodes found on the meshed part -- "
1183
+ "shrinkage screening skipped (a feeder location requires a free "
1184
+ "surface)")
1185
+ else:
1186
+ alloy_family, alloy_family_assumed = resolve_alloy_family(alloy, props_json)
1187
+ # Learned-heads Task 3: `feat["gradT"]`/`feat["dTdt"]` are only
1188
+ # present when `predict_field` ran a deriv_heads-capable (film)
1189
+ # checkpoint (see that function's docstring) -- `None`/`None` for
1190
+ # every other checkpoint, which routes `screen_casting` through
1191
+ # its own time-only proxy branch exactly as before this feature
1192
+ # existed.
1193
+ learned_gradT = feat.get("gradT")
1194
+ learned_dTdt = feat.get("dTdt")
1195
+ shrinkage_full = screen_casting(
1196
+ pos=mesh.node_xyz, tets=mesh.tets, surface_nodes=surface_nodes,
1197
+ t_s=seconds, TL=props["TL"], TS=props["TS"], alloy_family=alloy_family,
1198
+ gravity_vec=gravity, feed_nodes=feed_nodes,
1199
+ gradT=learned_gradT, dTdt=learned_dTdt)
1200
+ shrinkage = _shrinkage_result_for_return(shrinkage_full)
1201
+ if learned_gradT is not None and learned_dTdt is not None:
1202
+ # `screen_casting` itself only distinguishes "exact" (real
1203
+ # cached solver fields, e.g. Gate C's per-row comparison)
1204
+ # from "proxy" -- it has no notion of "predicted by a
1205
+ # checkpoint at inference time". Relabel HERE, at the
1206
+ # inference call site, rather than teaching `screen_casting`
1207
+ # a third source string: that keeps `src.shrinkage`'s own
1208
+ # tests (which pass real cached gradT/dTdt and expect
1209
+ # "exact") untouched, while giving inference-time callers an
1210
+ # honest label distinguishing a MODEL PREDICTION from a true
1211
+ # solver field.
1212
+ shrinkage["niyama_source"] = "learned"
1213
+ if alloy_family_assumed:
1214
+ shrinkage["alloy_family_assumed"] = True
1215
+ # Spec ADDENDUM 2 (2026-08-01, owner-approved scoped ship): Gate C
1216
+ # failed on the chill-bearing slice (rho 0.313) -- proxy Niyama
1217
+ # cannot see a chill's local cooling-rate enhancement, so mark it
1218
+ # low-confidence whenever the input's BC plan has a CHILL role
1219
+ # (`plan.bc_config == "chills"`; bare/all-sand and sleeve-only
1220
+ # inputs, whose bc_config is "none"/"insulating_sleeve"/
1221
+ # "exo_sleeve", are unaffected and stay "full"). Macro-shrinkage/
1222
+ # sinks/feeders don't depend on Niyama at all and keep full scope
1223
+ # regardless -- only this confidence LABEL changes, no output is
1224
+ # withheld ("screening stays informative").
1225
+ shrinkage["niyama_confidence"] = (
1226
+ "low" if (plan is not None and plan.bc_config == "chills") else "full")
1227
+ shrinkage_extra_point_data = _shrinkage_extra_point_data(mesh, shrinkage_full)
1228
+
1229
+ vtu = osp.join(out_dir, f"{name}.vtu")
1230
+ write_vtu(vtu, mesh.node_xyz, mesh.tets, seconds, extra_point_data=shrinkage_extra_point_data)
1231
+
1232
+ # hot-spot summary: top-5 latest-solidifying nodes
1233
+ order = np.argsort(seconds)[::-1][:5]
1234
+ hot_xyz = mesh.node_xyz[order]
1235
+ total = float(seconds.max())
1236
+ summary = osp.join(out_dir, f"{name}_summary.txt")
1237
+ alloy_label = alloy if alloy is not None else DEFAULT_ALLOY
1238
+ if props_json is not None:
1239
+ alloy_label = f"custom ({props_json})"
1240
+ mold_label = mold if props_json is None else "n/a (props_json)"
1241
+ elem_size_label = (f"{minfo['elem_size']:.2f} mm" if minfo["elem_size"] is not None
1242
+ else "n/a (density-searched)")
1243
+ cache_label = minfo.get("cache") if effective_mode == "prep" else "n/a (raw mode)"
1244
+ bc_config = plan.bc_config if plan is not None else "none"
1245
+ patch_areas = minfo.get("per_body_patch_area_mm2", {})
1246
+ with open(summary, "w") as f:
1247
+ f.write(f"casting: {name}\n")
1248
+ f.write(f"total solidification time: {total:.1f} s\n")
1249
+ f.write(f"alloy: {alloy_label} mold: {mold_label} gnn: {gnn_ckpt} hgb: {hgb_path} "
1250
+ f"hgb_std: {hgb_std_path_label}\n")
1251
+ f.write(f"mesh_mode: {effective_mode} cache: {cache_label} nodes: {minfo['n_nodes']} "
1252
+ f"tets: {minfo['n_tets']} elem_size: {elem_size_label} bbox_mm: "
1253
+ f"{tuple(round(v,1) for v in minfo['bbox_mm'])}\n")
1254
+ f.write(f"bc_config: {bc_config}\n")
1255
+ if plan is not None:
1256
+ for b in bodies:
1257
+ sid = b.get("solid_id")
1258
+ line = f" body solid_id={sid} role={b.get('role')}"
1259
+ if b.get("params"):
1260
+ line += f" params={b['params']}"
1261
+ if sid in patch_areas:
1262
+ line += f" patch_area_mm2={patch_areas[sid]:.1f}"
1263
+ f.write(line + "\n")
1264
+ if feat.get("recomposition") == "soft":
1265
+ info = feat["recomposition_info"]
1266
+ line = (f"soft recomposition: a={info['a']:.4f}, m={info['m']:.4f}, "
1267
+ f"target_unreachable={info['target_unreachable']}, "
1268
+ f"clip_frac={info['clip_frac']:.4f}")
1269
+ if info.get("lambda_shrink", 1.0) != 1.0:
1270
+ line += f", lambda_shrink={info['lambda_shrink']:.4f}"
1271
+ if w_total != 1.0 or w_std != 1.0:
1272
+ line += f", w_total={w_total:.4f}, w_std={w_std:.4f}"
1273
+ total_dev = info.get("total_deviation_frac")
1274
+ if total_dev is not None and abs(total_dev) > 0.001:
1275
+ line += f", total_dev={total_dev * 100.0:+.1f}%"
1276
+ f.write(line + "\n")
1277
+ elif cfg.get("film", False):
1278
+ # variance-recomposition Task 6: the film path without an active
1279
+ # std head (hgb_std_path="none", or a film checkpoint used
1280
+ # without one) recomposes via the quantile-anchor -- state that
1281
+ # explicitly, mirroring the affine line above, rather than
1282
+ # leaving the recomposition mode unstated.
1283
+ f.write("recomposition: anchor (quantile, q=0.995)\n")
1284
+ if screen_shrinkage:
1285
+ _write_shrinkage_summary_block(f, shrinkage)
1286
+ f.write("top hot spots (x,y,z mm : t_solidus s):\n")
1287
+ for xyz_i, i in zip(hot_xyz, order):
1288
+ f.write(f" ({xyz_i[0]:.1f},{xyz_i[1]:.1f},{xyz_i[2]:.1f}) : {seconds[i]:.1f}\n")
1289
+ if warnings:
1290
+ f.write("WARNINGS:\n")
1291
+ for w in warnings:
1292
+ f.write(f" - {w}\n")
1293
+
1294
+ # soft-anchor Task 4 (v5 review M-1): surface the recomposition mode +
1295
+ # its info dict as structured data, mirroring the summary text above --
1296
+ # "soft" (hybrid_field_soft active, feat carries its info dict), "anchor"
1297
+ # (film checkpoint, quantile-anchor recomposition -- no info dict exists
1298
+ # on this path), or "sp1" (non-film checkpoint -- no recomposition
1299
+ # concept at all).
1300
+ if feat.get("recomposition") == "soft":
1301
+ recomposition = "soft"
1302
+ recomposition_info = feat["recomposition_info"]
1303
+ elif cfg.get("film", False):
1304
+ recomposition = "anchor"
1305
+ recomposition_info = None
1306
+ else:
1307
+ recomposition = "sp1"
1308
+ recomposition_info = None
1309
+
1310
+ result = {"name": name, "vtu": vtu, "summary": summary, "total_time_s": total,
1311
+ "mesh_mode": effective_mode, "n_nodes": minfo["n_nodes"],
1312
+ "elem_size": minfo["elem_size"], "ood_warnings": warnings,
1313
+ "hotspot_xyz": hot_xyz, "bc_config": bc_config,
1314
+ "per_body_patch_area_mm2": patch_areas,
1315
+ "recomposition": recomposition,
1316
+ "recomposition_info": recomposition_info,
1317
+ "shrinkage": shrinkage}
1318
+ if "cache" in minfo:
1319
+ result["cache"] = minfo["cache"]
1320
+ return result
1321
+
1322
+
1323
+ if __name__ == "__main__":
1324
+ ap = argparse.ArgumentParser()
1325
+ ap.add_argument("step_path")
1326
+ ap.add_argument("--out-dir", default="runs/castings")
1327
+ ap.add_argument("--elem-size", type=float, default=None,
1328
+ help="explicit uniform element size (mm) -- implies --mesh-mode raw, "
1329
+ "regardless of --mesh-mode")
1330
+ ap.add_argument("--mesh-mode", choices=["prep", "raw"], default="prep",
1331
+ help="'prep' (default): route meshing through the same largest-solid + "
1332
+ "OCC heal + density-searched pipeline used to prep training/dataset-D "
1333
+ "parts (tools.doe.prep_parts.heal_and_mesh), served through the "
1334
+ "on-disk mesh cache (runs/mesh_cache/) so a repeated byte-identical "
1335
+ "STEP file is not re-meshed. 'raw': the old single-pass "
1336
+ "uniform-density src.step_mesh.mesh_step path (escape hatch). Ignored "
1337
+ "(forced to 'raw') when --elem-size is given.")
1338
+ ap.add_argument("--gnn", dest="gnn_ckpt", default=DEFAULT_GNN_CKPT,
1339
+ help="GNN checkpoint path (SP1 or a film/v4 checkpoint)")
1340
+ ap.add_argument("--hgb", dest="hgb_path", default=DEFAULT_HGB,
1341
+ help="HGB baseline path (SP1 per-node baseline or the v4 v2-total GBM)")
1342
+ ap.add_argument("--hgb-std", dest="hgb_std_path", default=_USE_DEFAULT,
1343
+ help="path to an optional GBM std-head model "
1344
+ "(src.gbm_ft2b.refit_gbm_std's output); when active, recomposition "
1345
+ "uses the affine (variance-preserving) map instead of the "
1346
+ "quantile-anchor -- only consulted for film/v4 checkpoints. Default: "
1347
+ f"{DEFAULT_HGB_STD} for a film/v4 checkpoint (auto-disabled, no extra "
1348
+ "flag needed, for a non-film/SP1 checkpoint e.g. via --gnn/--hgb "
1349
+ "rollback); pass 'none' (case-insensitive) to force the old "
1350
+ "quantile-anchor recomposition on a film/v4 stack too")
1351
+ ap.add_argument("--affine-lambda", dest="lambda_shrink", type=float, default=1.0,
1352
+ help="geometric-interpolation shrinkage of the affine spread target "
1353
+ "(variance-recomposition amendment 2): 1.0 (default) fully trusts "
1354
+ "--hgb-std's predicted spread; 0.0 targets the GNN's own spread "
1355
+ "(no rescaling); only used together with --hgb-std on film/v4 "
1356
+ "checkpoints")
1357
+ ap.add_argument("--w-total", dest="w_total", type=float, default=DEFAULT_W_TOTAL,
1358
+ help="soft-anchor weight on the total-time residual in "
1359
+ "src.hybrid.hybrid_field_soft's objective; default "
1360
+ f"{DEFAULT_W_TOTAL} (the e_val-sweep winner, soft-anchor Task 6 "
1361
+ "promotion, 2026-07-31) -- only used together with --hgb-std")
1362
+ ap.add_argument("--w-std", dest="w_std", type=float, default=DEFAULT_W_STD,
1363
+ help="soft-anchor weight on the spread residual in "
1364
+ "src.hybrid.hybrid_field_soft's objective; default "
1365
+ f"{DEFAULT_W_STD} (the e_val-sweep winner, soft-anchor Task 6 "
1366
+ "promotion, 2026-07-31) -- only used together with --hgb-std")
1367
+ ap.add_argument("--alloy", default=None,
1368
+ help=f"named alloy grade (default: {DEFAULT_ALLOY}); only used by film/v4 checkpoints")
1369
+ ap.add_argument("--mold", default="green_sand",
1370
+ help="named mold system; only used by film/v4 checkpoints")
1371
+ ap.add_argument("--props", dest="props_json", default=None,
1372
+ help="path to a JSON file overriding alloy/mold with raw material properties")
1373
+ ap.add_argument("--bodies", dest="bodies_json", default=None,
1374
+ help="path to a JSON file describing a multi-body STEP's role "
1375
+ "assignment (Part B): a list of {\"solid_id\": int, \"role\": "
1376
+ "\"casting\"|\"chill\"|\"insulating_sleeve\"|\"exo_sleeve\"|"
1377
+ "\"ignore\", \"params\": {...} (optional, role-dependent)}. "
1378
+ "Example: [{\"solid_id\": 1, \"role\": \"casting\"}, "
1379
+ "{\"solid_id\": 2, \"role\": \"chill\", \"params\": "
1380
+ "{\"material\": \"cast_iron\"}}]. Requires --mesh-mode prep "
1381
+ "(the default) -- role-based casting-solid selection and "
1382
+ "contact-patch tagging only run through that pipeline.")
1383
+ ap.add_argument("--no-shrinkage", dest="screen_shrinkage", action="store_false",
1384
+ help="disable shrinkage-porosity screening (SP2): by default, "
1385
+ "predict_casting runs src.shrinkage.screen_casting on the "
1386
+ "recomposed field and adds niyama/porosity_risk/macro_region_id "
1387
+ "VTU point fields, a 'shrinkage screening:' summary block, and "
1388
+ "a 'shrinkage' return-dict key. This flag skips all of that, "
1389
+ "byte-identical to the pre-shrinkage-screening contract.")
1390
+ ap.add_argument("--gravity", dest="gravity", type=_parse_gravity, default=(0, 0, -1),
1391
+ help="gravity direction as 'x,y,z' (default '0,0,-1'), forwarded to "
1392
+ "screen_casting's top-sink/feeder placement; ignored with "
1393
+ "--no-shrinkage. Parsed by _parse_gravity as argparse's own "
1394
+ "type= callable (review round 1, finding 5), so a malformed "
1395
+ "value (wrong arity, non-numeric, or the zero vector) is a "
1396
+ "normal argparse usage error, not a traceback.")
1397
+ a = ap.parse_args()
1398
+ r = predict_casting(a.step_path, out_dir=a.out_dir, elem_size=a.elem_size,
1399
+ mesh_mode=a.mesh_mode,
1400
+ gnn_ckpt=a.gnn_ckpt, hgb_path=a.hgb_path,
1401
+ alloy=a.alloy, mold=a.mold, props_json=a.props_json,
1402
+ bodies=a.bodies_json, hgb_std_path=a.hgb_std_path,
1403
+ lambda_shrink=a.lambda_shrink, w_total=a.w_total, w_std=a.w_std,
1404
+ screen_shrinkage=a.screen_shrinkage,
1405
+ gravity=a.gravity)
1406
+ print(f"{r['name']}: total {r['total_time_s']:.1f}s mesh_mode {r['mesh_mode']} "
1407
+ f"nodes {r['n_nodes']} bc_config {r['bc_config']} -> {r['vtu']}")
1408
+ for w in r["ood_warnings"]:
1409
+ print("WARNING:", w)
src/release_io.py ADDED
@@ -0,0 +1,131 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Public-release artifact formats (Hugging Face model repo).
2
+
3
+ The internal artifacts are pickles: the GNN checkpoint is a `torch.save`d
4
+ `{model, norm, cfg}` dict (loaded with `weights_only=False`) and the GBM heads
5
+ are `joblib` dumps. The public release ships them in non-executable formats:
6
+
7
+ - GNN -> one `.safetensors` file: tensors `model.<state_dict key>` +
8
+ `norm.<key>`, and a JSON `cfg` plus the norm entries' original container
9
+ type in the safetensors metadata header. `load_gnn_state` rebuilds the
10
+ exact `{model, norm, cfg}` dict `torch.load` would have returned, so every
11
+ downstream loader (`src.evaluate_ft2b.load_ft2b_gnn`, `load_hybrid`) takes
12
+ it unchanged.
13
+ - GBM -> `.skops` (sklearn's safe serialization). Loading checks the file's
14
+ untrusted types against a fixed sklearn/numpy allowlist instead of
15
+ trusting whatever the file names.
16
+
17
+ `resolve_artifact` maps the internal default paths (`runs/...`) onto the
18
+ release layout (`weights/...`) when only the latter exists, so the public
19
+ snapshot runs `python -m src.predict_casting part.step` with zero flags
20
+ while the internal checkout keeps using its original files.
21
+ """
22
+ from __future__ import annotations
23
+
24
+ import json
25
+ import os
26
+
27
+ import numpy as np
28
+ import torch
29
+
30
+ GNN_FORMAT = "foundry-gnn-release/1"
31
+
32
+ # Internal default path -> public release path (repo-root relative).
33
+ RELEASE_PATHS = {
34
+ "runs/ft3_stage1/ckpt_ep021.pt": "weights/gnn_v5.1.safetensors",
35
+ "runs/ft3_gbm/hgb_v3.joblib": "weights/gbm_total_v3.skops",
36
+ "runs/ft3_gbm/hgb_std_v2.joblib": "weights/gbm_std_v2.skops",
37
+ "data/features_v2/ood_ranges.json": "weights/ood_ranges.json",
38
+ }
39
+
40
+ # Types a release `.skops` GBM file may name. Anything else fails the load.
41
+ _SKOPS_ALLOWED_PREFIXES = ("sklearn.", "numpy.")
42
+
43
+
44
+ def resolve_artifact(path):
45
+ """`path` unchanged unless it is a known internal default that is absent
46
+ on disk while its release counterpart exists."""
47
+ if isinstance(path, (str, os.PathLike)):
48
+ p = os.fspath(path)
49
+ alt = RELEASE_PATHS.get(p)
50
+ if alt is not None and not os.path.exists(p) and os.path.exists(alt):
51
+ return alt
52
+ return path
53
+
54
+
55
+ def _norm_to_tensor(value):
56
+ if isinstance(value, torch.Tensor):
57
+ return value.detach().cpu().contiguous(), "torch"
58
+ if isinstance(value, np.ndarray):
59
+ return torch.from_numpy(np.ascontiguousarray(value)), "numpy"
60
+ return None, None
61
+
62
+
63
+ def save_gnn_release(state: dict, out_path: str, extra_metadata: dict | None = None) -> None:
64
+ """Write a `{model, norm, cfg}` checkpoint dict as one safetensors file."""
65
+ from safetensors.torch import save_file
66
+ tensors, norm_kinds, norm_scalars = {}, {}, {}
67
+ for k, v in state["model"].items():
68
+ tensors[f"model.{k}"] = v.detach().cpu().contiguous()
69
+ for k, v in state["norm"].items():
70
+ t, kind = _norm_to_tensor(v)
71
+ if t is None:
72
+ norm_scalars[k] = v
73
+ else:
74
+ tensors[f"norm.{k}"] = t
75
+ norm_kinds[k] = kind
76
+ meta = {"format": GNN_FORMAT,
77
+ "cfg": json.dumps(state["cfg"], sort_keys=True),
78
+ "norm_kinds": json.dumps(norm_kinds, sort_keys=True),
79
+ "norm_scalars": json.dumps(norm_scalars, sort_keys=True)}
80
+ for k, v in (extra_metadata or {}).items():
81
+ meta[k] = v if isinstance(v, str) else json.dumps(v)
82
+ save_file(tensors, out_path, metadata=meta)
83
+
84
+
85
+ def load_gnn_state(path: str, device="cpu") -> dict:
86
+ """Inverse of `save_gnn_release`: the `{model, norm, cfg}` dict."""
87
+ from safetensors import safe_open
88
+ with safe_open(path, framework="pt", device="cpu") as f:
89
+ meta = f.metadata() or {}
90
+ if meta.get("format") != GNN_FORMAT:
91
+ raise ValueError(f"{path}: not a {GNN_FORMAT} file "
92
+ f"(format={meta.get('format')!r})")
93
+ tensors = {k: f.get_tensor(k) for k in f.keys()}
94
+ norm_kinds = json.loads(meta["norm_kinds"])
95
+ model = {k[len("model."):]: v.to(device) for k, v in tensors.items()
96
+ if k.startswith("model.")}
97
+ norm = dict(json.loads(meta["norm_scalars"]))
98
+ for k, kind in norm_kinds.items():
99
+ t = tensors[f"norm.{k}"]
100
+ norm[k] = t.numpy() if kind == "numpy" else t
101
+ return {"model": model, "norm": norm, "cfg": json.loads(meta["cfg"])}
102
+
103
+
104
+ def save_gbm_release(model, out_path: str) -> None:
105
+ import skops.io as sio
106
+ sio.dump(model, out_path)
107
+
108
+
109
+ def load_gbm(path):
110
+ """Load a GBM head: `.skops` through the type allowlist, else joblib."""
111
+ if path is None or not isinstance(path, (str, os.PathLike)):
112
+ return path
113
+ if os.fspath(path).endswith(".skops"):
114
+ import skops.io as sio
115
+ untrusted = sio.get_untrusted_types(file=path)
116
+ bad = [t for t in untrusted if not t.startswith(_SKOPS_ALLOWED_PREFIXES)]
117
+ if bad:
118
+ raise ValueError(f"{path}: refusing to load unexpected types {bad}")
119
+ return sio.load(path, trusted=untrusted)
120
+ import joblib
121
+ return joblib.load(path)
122
+
123
+
124
+ def load_gnn_any(path, device):
125
+ """A GNN checkpoint path (`.safetensors` release or internal `.pt`) as
126
+ the `{model, norm, cfg}` dict; an already-loaded dict passes through."""
127
+ if not isinstance(path, (str, os.PathLike)):
128
+ return path
129
+ if os.fspath(path).endswith(".safetensors"):
130
+ return load_gnn_state(path, device)
131
+ return torch.load(path, map_location=device, weights_only=False)
src/run_baseline.py ADDED
@@ -0,0 +1,150 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """End-to-end Phase-0 baseline: build splits, train HGB (+ ablation) and MLP,
2
+ evaluate across splits, write a Markdown report and per-prefix MAE plot."""
3
+ import os
4
+ import os.path as osp
5
+
6
+ import numpy as np
7
+ import torch
8
+ import matplotlib
9
+ matplotlib.use("Agg")
10
+ import matplotlib.pyplot as plt
11
+
12
+ from src.dataload import load_matrix, load_norm, standardize
13
+ from src.baseline import train_hgb, train_mlp, predict_seconds
14
+ from src.splits import build_splits, save_splits, load_splits
15
+ from src import metrics
16
+
17
+
18
+ def _xyz_mm(features_dir, data_root, stem, n):
19
+ """Per-node positions in mm. Prefer the feature cache (v2 stores `pos` in mm);
20
+ fall back to the raw .pt (metres) when the cache lacks positions."""
21
+ gpath = osp.join(features_dir, "graphs", f"{stem}.npz")
22
+ d = np.load(gpath)
23
+ if "pos" in d.files:
24
+ return d["pos"][:n] # already mm
25
+ raw = torch.load(osp.join(data_root, "pt", f"{stem}.pt"), weights_only=False)
26
+ return raw["pos"].numpy()[:n] * 1000.0
27
+
28
+
29
+ def evaluate_model(model, features_dir, test_stems, norm, data_root="data"):
30
+ X, y, gid = load_matrix(features_dir, test_stems)
31
+ # Both model types are trained on standardized features (see run()), so
32
+ # standardize uniformly here too.
33
+ Xs = standardize(X, norm)
34
+ pred = predict_seconds(model, Xs, norm)
35
+ xyz = np.concatenate([_xyz_mm(features_dir, data_root, s, (gid == i).sum())
36
+ for i, s in enumerate(test_stems)])
37
+ return {
38
+ "mae": metrics.mae(y, pred),
39
+ "rmse": metrics.rmse(y, pred),
40
+ "median_rel_error": metrics.median_rel_error(y, pred),
41
+ "pred_std_ratio": metrics.pred_std_ratio(y, pred, gid),
42
+ "hotspot_accuracy": metrics.hotspot_accuracy(y, pred, xyz, gid),
43
+ }
44
+
45
+
46
+ def per_prefix_mae(model, features_dir, test_stems, norm):
47
+ by = {}
48
+ for s in test_stems:
49
+ X, y, _ = load_matrix(features_dir, [s])
50
+ pred = predict_seconds(model, standardize(X, norm), norm)
51
+ by.setdefault(s.split("_")[0], []).append(metrics.mae(y, pred))
52
+ return {k: float(np.mean(v)) for k, v in sorted(by.items())}
53
+
54
+
55
+ def _balanced(train_stems, meta_lookup):
56
+ """Config C: downsample so each n_adiab regime has ~equal stem count."""
57
+ from collections import defaultdict
58
+ import random
59
+ groups = defaultdict(list)
60
+ for s in train_stems:
61
+ groups[meta_lookup[s]].append(s)
62
+ if not groups:
63
+ return train_stems
64
+ k = min(len(v) for v in groups.values())
65
+ rng = random.Random(0)
66
+ out = []
67
+ for v in groups.values():
68
+ out.extend(rng.sample(v, k))
69
+ return out
70
+
71
+
72
+ def run(features_dir="data/features", splits_path=None, out_dir="runs/baseline",
73
+ ablation=True, max_nodes_per_graph=2000, data_root="data"):
74
+ os.makedirs(out_dir, exist_ok=True)
75
+ splits_path = splits_path or osp.join(features_dir, "splits.json")
76
+ meta = np.load(osp.join(features_dir, "meta.npz"), allow_pickle=True)
77
+ if not osp.exists(splits_path):
78
+ save_splits(build_splits(meta), splits_path)
79
+ sp = load_splits(splits_path)
80
+ norm = load_norm(features_dir)
81
+ meta_nad = {str(s): int(n) for s, n in zip(meta["stem"], meta["n_adiab"])}
82
+ fully = {str(s) for s, f in zip(meta["stem"], meta["is_fully_cooled"]) if f}
83
+
84
+ configs = {"A_all": sp["interpolation"]["train"]}
85
+ if ablation:
86
+ configs["B_cooled_only"] = [s for s in sp["interpolation"]["train"] if s in fully]
87
+ configs["C_balanced"] = _balanced(sp["interpolation"]["train"], meta_nad)
88
+
89
+ eval_sets = {"interpolation": sp["interpolation"]["test"],
90
+ "family": sp["family"]["test"],
91
+ "fully_cooled": sp["fully_cooled"]["test"]}
92
+
93
+ lines = ["# Phase 0 Baseline Report\n"]
94
+ results = {}
95
+ for cfg, train_stems in configs.items():
96
+ if not train_stems:
97
+ print(f" skipping config {cfg}: no training stems")
98
+ continue
99
+ Xtr, ytr, _ = load_matrix(features_dir, train_stems,
100
+ max_nodes_per_graph=max_nodes_per_graph)
101
+ model = train_hgb(standardize(Xtr, norm), np.log1p(ytr))
102
+ lines.append(f"\n## HGB — config {cfg} (train graphs: {len(train_stems)})\n")
103
+ lines.append("| split | MAE(s) | RMSE(s) | med.rel | pred/GT std | hotspot |")
104
+ lines.append("|---|---|---|---|---|---|")
105
+ for name, stems in eval_sets.items():
106
+ if not stems:
107
+ continue
108
+ r = evaluate_model(model, features_dir, stems, norm, data_root)
109
+ results[(cfg, name)] = r
110
+ lines.append(f"| {name} | {r['mae']:.2f} | {r['rmse']:.2f} | "
111
+ f"{r['median_rel_error']:.3f} | {r['pred_std_ratio']:.3f} | "
112
+ f"{r['hotspot_accuracy']:.3f} |")
113
+ if cfg == "A_all":
114
+ import joblib
115
+ joblib.dump(model, osp.join(out_dir, "hgb_A.joblib"))
116
+ # per-prefix plot on interpolation test
117
+ pmae = per_prefix_mae(model, features_dir, eval_sets["interpolation"], norm)
118
+ plt.figure(figsize=(10, 4))
119
+ plt.bar(list(pmae.keys()), list(pmae.values()))
120
+ plt.ylabel("MAE (s)"); plt.xticks(rotation=90); plt.tight_layout()
121
+ plt.savefig(osp.join(out_dir, "per_prefix_mae.png"), dpi=120)
122
+ plt.close()
123
+
124
+ # MLP on config A
125
+ if not configs["A_all"]:
126
+ print(f" skipping MLP training: config A_all has no training stems")
127
+ else:
128
+ Xtr, ytr, _ = load_matrix(features_dir, configs["A_all"],
129
+ max_nodes_per_graph=max_nodes_per_graph)
130
+ device = "cuda" if torch.cuda.is_available() else "cpu"
131
+ mlp = train_mlp(standardize(Xtr, norm), np.log1p(ytr), device=device)
132
+ lines.append("\n## MLP — config A_all\n")
133
+ lines.append("| split | MAE(s) | RMSE(s) | med.rel | pred/GT std | hotspot |")
134
+ lines.append("|---|---|---|---|---|---|")
135
+ for name, stems in eval_sets.items():
136
+ if not stems:
137
+ continue
138
+ r = evaluate_model(mlp, features_dir, stems, norm, data_root)
139
+ lines.append(f"| {name} | {r['mae']:.2f} | {r['rmse']:.2f} | "
140
+ f"{r['median_rel_error']:.3f} | {r['pred_std_ratio']:.3f} | "
141
+ f"{r['hotspot_accuracy']:.3f} |")
142
+
143
+ with open(osp.join(out_dir, "report.md"), "w") as f:
144
+ f.write("\n".join(lines) + "\n")
145
+ print(f"Report → {osp.join(out_dir, 'report.md')}")
146
+ return results
147
+
148
+
149
+ if __name__ == "__main__":
150
+ run()
src/select_ckpt_ft2b.py ADDED
@@ -0,0 +1,331 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """FT2b Task 7: gate-metric checkpoint selector.
2
+
3
+ `src.train_gnn.train(..., save_every_epoch=True)` (Task 6) writes one
4
+ checkpoint per epoch (`<out_dir>/ckpt_ep{ep:03d}.pt`, payload
5
+ `{model, norm, cfg}`). Picking among them by validation loss alone can pick a
6
+ variance-collapsed checkpoint (near-constant predictions can still have low
7
+ smooth-L1 loss on a small/skewed val set) -- exactly the failure mode the
8
+ FT2b gate metrics (`src.evaluate_ft2b`'s `std_ratio`/`hotspot_overlap`) exist
9
+ to catch. This module re-ranks the per-epoch checkpoints by those SAME gate
10
+ metrics instead.
11
+
12
+ === Why no GBM is needed here (key insight from the Task 7 brief) ===
13
+ `src.evaluate_ft2b._max_anchor_hybrid` recomposes the hybrid prediction as
14
+ `pred_i = gnn_i - anchor + gbm_total`, i.e. a per-graph ADDITIVE SHIFT of
15
+ the GNN field by a constant (`gbm_total - anchor`, the same shift for every
16
+ node in that graph), where `anchor = np.quantile(gnn, q)` (default
17
+ `q=0.995`, a high quantile rather than the exact max -- see
18
+ `src.hybrid.hybrid_field_max_anchor`'s docstring for why: the exact-max
19
+ anchor is fragile to a handful of outlier nodes). Both metrics this
20
+ selector ranks by are shift-invariant per graph regardless of which
21
+ quantile the anchor uses:
22
+ - `std_ratio = std(pred)/std(true)` (`src.metrics.pred_std_ratio`): std of a
23
+ per-graph-shifted array is unchanged by the shift.
24
+ - `hotspot_overlap` (`src.metrics.hotspot_accuracy`): picks the top-`k`
25
+ LATEST-solidifying nodes by rank; a per-graph constant shift does not
26
+ reorder nodes within a graph, so the top-k set (and thus the overlap) is
27
+ unchanged.
28
+ So GNN-only seconds and the eventual hybrid produce IDENTICAL std_ratio/
29
+ hotspot_overlap numbers -- this selector never needs the GBM (which also
30
+ means it never needs the DOE manifest's alloy/BC feature-row resolution that
31
+ `src.gbm_ft2b._row_features` performs; only `alloy_family`/`bc_config` for
32
+ grouping are read from the manifest).
33
+
34
+ === Reuse, not reimplementation ===
35
+ Everything gate-metric-shaped is imported from `src.evaluate_ft2b`, which is
36
+ already exercised by `tests/test_evaluate_ft2b.py`:
37
+ - `load_ft2b_gnn` -- checkpoint -> `(model, cfg, norm)`, film-aware.
38
+ - `predict_graph_seconds` -- one graph's cached `X`/`pos`/`mesh_edges`/`g`
39
+ -> physical-seconds GNN prediction (the ckpt's own norm-inversion chain).
40
+ - `_group_metrics` / `_overall_metrics` -- the EXACT per-group (and
41
+ overall) `{mae, hotspot_overlap, std_ratio}` computation `evaluate_ft2b`
42
+ uses for its own `eval.json` (pool all nodes of every stem in the group,
43
+ build a per-stem `graph_id` array, hand the pooled arrays to
44
+ `src.metrics.pred_std_ratio`/`hotspot_overlap`). This selector builds the
45
+ same `{"family", "bc", "y_true", "y_hybrid", "xyz"}`-shaped records (with
46
+ `y_hybrid` set to the GNN-only seconds, per the shift-invariance above)
47
+ and calls those two functions UNCHANGED, so its numbers are directly
48
+ comparable to `evaluate_ft2b`'s `eval.json` for the same stems/ckpt.
49
+ - `src.gbm_ft2b._load_manifest_rows` -- manifest CSV -> `{run_id: row}`.
50
+
51
+ === Ranking (fix pass -- two-sided calibration, not one-sided "biggest std") ===
52
+ For each checkpoint, pool `per_alloy_family` and `per_bc_config` group
53
+ metrics (`bc_config UNION alloy_family`, i.e. every group from BOTH
54
+ partitions, not their cross product) into one flat collection, then rank
55
+ checkpoints by, in order:
56
+ 1. FEWEST of those groups with `std_ratio < std_ratio_threshold` (default
57
+ 0.9, matching `evaluate_ft2b`'s own `variance_collapse_threshold`
58
+ default) -- fewer variance-collapsed groups is better.
59
+ 2. SMALLEST `calib = max over groups of abs(log(std_ratio))` -- a
60
+ TWO-SIDED mis-calibration score. `std_ratio` measures the same
61
+ miscalibration whether it collapses (std_ratio << 1) or over-disperses
62
+ (std_ratio >> 1); `log` makes the two directions symmetric
63
+ (`log(0.5) == -log(2)`) so neither a collapsed nor an inflated group can
64
+ win by having a numerically larger raw ratio.
65
+ ORIGINAL (WRONG) CRITERION, replaced here: "largest minimum group
66
+ std_ratio" -- that criterion is UNBOUNDED ABOVE and actively rewards
67
+ over-dispersion (a checkpoint where every group is 2.3x over-dispersed
68
+ beats one where every group is a well-calibrated ~0.95, because 2.3 >
69
+ 0.95 as a raw ratio). Confirmed on a real 64-checkpoint run: the old
70
+ criterion picked ep019 (min_group_std_ratio=2.34, EVERY group
71
+ over-dispersed) over ep063 (all groups >= 0.9, calibrated). `calib`
72
+ fixes this: ep019's `calib = abs(log(2.34)) ~= 0.85`; a calibrated
73
+ ep063 with all ratios near 1 has `calib` near 0 and wins.
74
+ 3. HIGHEST overall `hotspot_overlap` (pooled over every given stem).
75
+ 4. Deterministic tie-break: LOWEST epoch number (prefers the earlier,
76
+ cheaper-to-have-trained checkpoint when the gate metrics are tied).
77
+
78
+ `min_group_std_ratio`/`max_group_std_ratio` are still computed and stored on
79
+ every ranking row (for human inspection / plotting the calibration spread)
80
+ -- they are just no longer part of the sort key beyond criterion (1)'s
81
+ threshold count.
82
+
83
+ === `select_ckpt` vs `rerank_selection` ===
84
+ `select_ckpt` runs the (slow -- GPU forward pass per checkpoint) evaluation
85
+ and produces `selection.json`. `rerank_selection` re-applies the ranking
86
+ rule above to an ALREADY-WRITTEN `selection.json` (the per-group numbers for
87
+ every checkpoint are already in that file) with NO forward passes --
88
+ needed because a real run over many checkpoints takes hours, and a ranking
89
+ bugfix must not force re-running it. Both route through the same
90
+ `_rank_entries` so the two can never silently diverge.
91
+ """
92
+ from __future__ import annotations
93
+
94
+ import glob
95
+ import json
96
+ import math
97
+ import os
98
+ import os.path as osp
99
+ import re
100
+
101
+ import numpy as np
102
+ import torch
103
+
104
+ from src.evaluate_ft2b import (_group_metrics, _overall_metrics,
105
+ load_ft2b_gnn, predict_graph_seconds)
106
+ from src.gbm_ft2b import _load_manifest_rows
107
+
108
+ DEFAULT_STD_RATIO_THRESHOLD = 0.9
109
+
110
+ # Floor applied to std_ratio before taking log(), so a fully-collapsed group
111
+ # (std_ratio == 0.0 exactly) yields a large-but-finite `calib` instead of
112
+ # `inf` (which `json.dump` would emit as the non-standard token `Infinity`).
113
+ _CALIB_STD_RATIO_FLOOR = 1e-6
114
+
115
+ _CKPT_EPOCH_RE = re.compile(r"ckpt_ep(\d+)\.pt$")
116
+
117
+
118
+ def _epoch_of(ckpt_path: str) -> int:
119
+ m = _CKPT_EPOCH_RE.search(osp.basename(ckpt_path))
120
+ if not m:
121
+ raise ValueError(f"not a ckpt_ep###.pt path: {ckpt_path!r}")
122
+ return int(m.group(1))
123
+
124
+
125
+ def _list_ckpts(ckpt_dir: str) -> list[str]:
126
+ paths = glob.glob(osp.join(ckpt_dir, "ckpt_ep*.pt"))
127
+ return sorted(paths, key=_epoch_of)
128
+
129
+
130
+ def _default_stems(features_dir: str) -> list[str]:
131
+ with open(osp.join(features_dir, "splits.json")) as f:
132
+ splits_d = json.load(f)
133
+ return list(splits_d["extrap"]["test"])
134
+
135
+
136
+ def _gnn_only_records(model, cfg, norm, stems, manifest_rows, features_dir,
137
+ device) -> list[dict]:
138
+ """One record per stem: `{family, bc, y_true, y_hybrid, xyz}`, shaped
139
+ exactly for `src.evaluate_ft2b._group_metrics`/`_overall_metrics` --
140
+ `y_hybrid` here holds the GNN-ONLY seconds prediction (see this module's
141
+ docstring for why that's metric-equivalent to the eventual hybrid)."""
142
+ records = []
143
+ for stem in stems:
144
+ d = np.load(osp.join(features_dir, "graphs", f"{stem}.npz"))
145
+ X, pos_np = d["X"], d["pos"]
146
+ y_true = d["y_raw"] if "y_raw" in d.files else d["y"]
147
+ mesh_edges = d["mesh_edges"] if "mesh_edges" in d.files else None
148
+ g_vec = d["g"] if "g" in d.files else None
149
+ gnn_seconds = predict_graph_seconds(model, cfg, norm, X, pos_np, mesh_edges,
150
+ g_vec, device=device)
151
+ row = manifest_rows.get(stem, {})
152
+ records.append({
153
+ "stem": stem,
154
+ "family": row.get("alloy_family") or "unknown",
155
+ "bc": row.get("bc_config") or "none",
156
+ "y_true": y_true, "y_hybrid": gnn_seconds, "xyz": pos_np,
157
+ })
158
+ return records
159
+
160
+
161
+ def _combined_groups(per_family: dict, per_bc: dict) -> dict:
162
+ """Union (not cross product) of the two partitions' groups, disambiguated
163
+ by a `"family:"`/`"bc:"` prefix so same-named groups in each partition
164
+ (e.g. a `bc_config` and an `alloy_family` both happening to be `"none"`)
165
+ don't collide."""
166
+ out = {}
167
+ for k, v in per_family.items():
168
+ out[f"family:{k}"] = v
169
+ for k, v in per_bc.items():
170
+ out[f"bc:{k}"] = v
171
+ return out
172
+
173
+
174
+ def _calib_score(std_ratio: float) -> float:
175
+ """Two-sided mis-calibration score for one group's `std_ratio`.
176
+
177
+ `abs(log(std_ratio))`, floored at `_CALIB_STD_RATIO_FLOOR` before the log
178
+ so an exact-zero (fully collapsed) ratio gives a large finite number
179
+ instead of `inf`. 0 at perfect calibration (`std_ratio == 1`), symmetric
180
+ between collapse and over-dispersion (`calib(0.5) == calib(2.0)`)."""
181
+ r = max(float(std_ratio), _CALIB_STD_RATIO_FLOOR)
182
+ return abs(math.log(r))
183
+
184
+
185
+ def _rank_entries(entries: list[dict], std_ratio_threshold: float) -> list[dict]:
186
+ """Shared scoring + sort for both `select_ckpt` and `rerank_selection`.
187
+
188
+ `entries`: one dict per checkpoint, each with (at least)
189
+ `{"ckpt", "epoch", "overall", "per_alloy_family", "per_bc_config"}` --
190
+ exactly what both callers already have (freshly computed, or loaded back
191
+ out of a previously-written `selection.json`). Recomputes the derived
192
+ scoring fields (`n_fail_groups`/`min_group_std_ratio`/
193
+ `max_group_std_ratio`/`calib`/`overall_hotspot`) from the stored
194
+ per-group numbers -- no forward pass, no re-evaluation -- and returns a
195
+ NEW list of entries (original dicts are not mutated), sorted best-first
196
+ per this module's ranking rule (see module docstring)."""
197
+ scored = []
198
+ for e in entries:
199
+ combined = _combined_groups(e["per_alloy_family"], e["per_bc_config"])
200
+ std_ratios = [m["std_ratio"] for m in combined.values()]
201
+ n_fail_groups = sum(1 for r in std_ratios if r < std_ratio_threshold)
202
+ min_group_std_ratio = float(min(std_ratios)) if std_ratios else 0.0
203
+ max_group_std_ratio = float(max(std_ratios)) if std_ratios else 0.0
204
+ calib = max((_calib_score(r) for r in std_ratios), default=0.0)
205
+ overall_hotspot = float(e["overall"]["hotspot_overlap"])
206
+
207
+ new_entry = dict(e)
208
+ new_entry.update({
209
+ "n_fail_groups": int(n_fail_groups),
210
+ "min_group_std_ratio": min_group_std_ratio,
211
+ "max_group_std_ratio": max_group_std_ratio,
212
+ "calib": float(calib),
213
+ "overall_hotspot": overall_hotspot,
214
+ })
215
+ scored.append(new_entry)
216
+
217
+ scored.sort(key=lambda r: (r["n_fail_groups"], r["calib"],
218
+ -r["overall_hotspot"], r["epoch"]))
219
+ return scored
220
+
221
+
222
+ def rerank_selection(json_path, out_json=None,
223
+ std_ratio_threshold: float = DEFAULT_STD_RATIO_THRESHOLD) -> dict:
224
+ """Re-rank an ALREADY-WRITTEN `select_ckpt` output (`json_path`) with the
225
+ current ranking rule, WITHOUT re-running any GNN forward pass -- every
226
+ checkpoint's per-group numbers are already stored in `json_path`'s
227
+ `"ranking"` entries (`per_alloy_family`/`per_bc_config`/`overall`), so
228
+ this only recomputes the derived scoring fields and re-sorts. Exists so a
229
+ ranking-rule bugfix never forces re-running a real (hours-long,
230
+ many-checkpoint) `select_ckpt` call.
231
+
232
+ Args:
233
+ json_path: path to a JSON file shaped like `select_ckpt`'s return
234
+ value (`{"best", "ranking": [...]}`) -- typically its own
235
+ previous `out_json` output.
236
+ out_json: path to write the re-ranked result (falsy skips writing).
237
+ std_ratio_threshold: same meaning as `select_ckpt`'s.
238
+
239
+ Returns: `{"best": <ckpt path>, "ranking": [...]}`, re-sorted.
240
+ """
241
+ with open(json_path) as f:
242
+ data = json.load(f)
243
+ ranking = _rank_entries(data["ranking"], std_ratio_threshold)
244
+ result = {"best": ranking[0]["ckpt"], "ranking": ranking}
245
+
246
+ if out_json:
247
+ out_dir = osp.dirname(out_json)
248
+ if out_dir:
249
+ os.makedirs(out_dir, exist_ok=True)
250
+ with open(out_json, "w") as f:
251
+ json.dump(result, f, indent=2)
252
+
253
+ return result
254
+
255
+
256
+ def select_ckpt(ckpt_dir, features_dir, manifest, stems=None, out_json=None,
257
+ device=None, std_ratio_threshold: float = DEFAULT_STD_RATIO_THRESHOLD) -> dict:
258
+ """Rank every `<ckpt_dir>/ckpt_ep*.pt` checkpoint by the gate metrics
259
+ (per-group `std_ratio` calibration + `hotspot_overlap`), NOT val loss.
260
+
261
+ Args:
262
+ ckpt_dir: directory containing `ckpt_ep{ep:03d}.pt` files (Task 6's
263
+ `train(..., save_every_epoch=True)` output).
264
+ features_dir: v4 feature cache directory (`graphs/<stem>.npz` +
265
+ `splits.json`, used only when `stems` is None).
266
+ manifest: path to the DOE manifest CSV (`run_id`==stem; needs
267
+ `alloy_family`/`bc_config` columns).
268
+ stems: stems to evaluate on. Default (None): `splits.json`'s
269
+ `extrap.test` list (the OOD-generalization split -- the one the
270
+ gate metrics matter most on).
271
+ out_json: path to write the ranked result as JSON (falsy skips
272
+ writing).
273
+ device: "cuda"/"cpu"; default picks cuda if available.
274
+ std_ratio_threshold: a group's `std_ratio` below this counts as
275
+ "failing" for ranking criterion (1). Default 0.9, matching
276
+ `evaluate_ft2b`'s own `variance_collapse_threshold` default.
277
+
278
+ Returns: `{"best": <ckpt path>, "ranking": [<per-ckpt dict>, ...]}`,
279
+ `ranking` sorted best-first per this module's docstring's 4-step
280
+ ranking rule. Each ranking entry also carries `per_alloy_family`/
281
+ `per_bc_config`/`overall` (same shape as `evaluate_ft2b`'s `eval.json`
282
+ groups) so the numbers are directly inspectable/comparable.
283
+ """
284
+ device = device or ("cuda" if torch.cuda.is_available() else "cpu")
285
+ ckpts = _list_ckpts(ckpt_dir)
286
+ if not ckpts:
287
+ raise FileNotFoundError(f"no ckpt_ep*.pt files found in {ckpt_dir!r}")
288
+
289
+ if stems is None:
290
+ stems = _default_stems(features_dir)
291
+ manifest_rows = _load_manifest_rows(manifest)
292
+
293
+ raw_entries = []
294
+ n = len(ckpts)
295
+ for i, ckpt_path in enumerate(ckpts):
296
+ epoch = _epoch_of(ckpt_path)
297
+ print(f"[select_ckpt] ({i + 1}/{n}) evaluating {osp.basename(ckpt_path)} "
298
+ f"on {len(stems)} stems ...", flush=True)
299
+ model, cfg, norm = load_ft2b_gnn(ckpt_path, device)
300
+ records = _gnn_only_records(model, cfg, norm, stems, manifest_rows,
301
+ features_dir, device)
302
+ entry = {
303
+ "ckpt": ckpt_path,
304
+ "epoch": epoch,
305
+ "overall": _overall_metrics(records),
306
+ "per_alloy_family": _group_metrics(records, "family"),
307
+ "per_bc_config": _group_metrics(records, "bc"),
308
+ }
309
+ # Per-entry pass through the shared scorer just for the progress
310
+ # print -- `select_ckpt`'s own final ranking below re-derives this
311
+ # (identically) via `_rank_entries` over the whole batch, so the two
312
+ # can never disagree.
313
+ scored = _rank_entries([entry], std_ratio_threshold)[0]
314
+ print(f"[select_ckpt] epoch {epoch:3d}: n_fail_groups={scored['n_fail_groups']} "
315
+ f"calib={scored['calib']:.4f} "
316
+ f"min_group_std_ratio={scored['min_group_std_ratio']:.4f} "
317
+ f"max_group_std_ratio={scored['max_group_std_ratio']:.4f} "
318
+ f"overall_hotspot={scored['overall_hotspot']:.4f}", flush=True)
319
+ raw_entries.append(entry)
320
+
321
+ ranking = _rank_entries(raw_entries, std_ratio_threshold)
322
+ result = {"best": ranking[0]["ckpt"], "ranking": ranking}
323
+
324
+ if out_json:
325
+ out_dir = osp.dirname(out_json)
326
+ if out_dir:
327
+ os.makedirs(out_dir, exist_ok=True)
328
+ with open(out_json, "w") as f:
329
+ json.dump(result, f, indent=2)
330
+
331
+ return result
src/shrinkage.py ADDED
@@ -0,0 +1,901 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Field derivatives and the Niyama shrinkage-porosity criterion on tet meshes.
2
+
3
+ Pure numpy/scipy: no project imports. Mesh arrays (`pos` in mm, `tets` as
4
+ int node-index quadruples) are passed in by the caller -- this module knows
5
+ nothing about `.msh` parsing, features, or the GNN.
6
+
7
+ Unit conventions (mirroring the cached raw-solver fields, see
8
+ `docs/superpowers/specs/2026-07-24-shrinkage-porosity-screening-design.md`,
9
+ ADDENDUM section "GNN-path derived fields"):
10
+ - `pos` -- node coordinates in **mm**.
11
+ - `gradT` -- thermal-gradient magnitude in **K/m** (as cached by the FEniCSx
12
+ solver, see `tools/doe/adapters.py`).
13
+ - `dTdt` -- cooling-rate in **K/s** (as cached).
14
+ - `t_s` / solidification time -- **s**.
15
+ - Niyama number `Ny` -- reported in the literature unit
16
+ **(degC*s)^0.5 / mm** (`G[degC/mm] / sqrt(|Tdot|[degC/s])`); note 1 K
17
+ of *difference* equals 1 degC of difference, so no additive offset is
18
+ needed when converting a gradient/rate, only the K/m -> degC/mm scale
19
+ (divide by 1000).
20
+
21
+ Two ways to obtain Ny, per the ADDENDUM:
22
+ - **Exact path** (training/validation rows with cached `gradT`/`dTdt`):
23
+ `niyama_exact` computes Ny directly from the solver's own gradient and
24
+ cooling-rate fields, and is compared against literature absolute
25
+ thresholds (`NY_CRIT`, `porosity_risk(mode="absolute")`).
26
+ - **Proxy path** (GNN-inference rows, where only the predicted
27
+ solidification-time field `t_s` exists): the exact identity
28
+ `grad(T) = -Tdot * grad(t_s)` (differentiate `T(x, t_s(x)) = T_S`) gives
29
+ `G = |Tdot| * |grad(t_s)|` and `Ny = G / sqrt(|Tdot|) = sqrt(|Tdot|) *
30
+ |grad(t_s)|`. `Tdot` itself is not available on this path and is proxied
31
+ by the characteristic freezing-interval rate `(TL - TS) / max(t_s, eps)`.
32
+ `niyama_proxy` implements this. Because it is a proxy, it is only a
33
+ *relative* risk ranking within one part -- use
34
+ `porosity_risk(mode="percentile")`, never the absolute thresholds.
35
+ """
36
+ import logging
37
+
38
+ import numpy as np
39
+ from scipy.sparse import csr_matrix
40
+ from scipy.sparse.csgraph import connected_components
41
+
42
+ logger = logging.getLogger(__name__)
43
+
44
+ # Shrinkage percentage per alloy family (volumetric solidification shrinkage,
45
+ # used as severity = shrinkage_pct/100 * region volume). Grey iron's graphite
46
+ # expansion during eutectic solidification largely offsets liquid/solid
47
+ # shrinkage, hence 0.0 -- macro-shrinkage regions are still detected/reported
48
+ # for grey iron (the geometry-only isolation criterion doesn't depend on
49
+ # alloy), just with zero severity.
50
+ SHRINKAGE_PCT = {
51
+ "steel": 2.5,
52
+ "low_alloy_steel": 2.5,
53
+ "ductile_iron": 0.75,
54
+ "grey_iron": 0.0,
55
+ }
56
+
57
+ # Absolute Niyama-criterion thresholds (exact path only), (degC*s)^0.5/mm.
58
+ # Below this value -> flagged as at-risk of micro-shrinkage porosity.
59
+ NY_CRIT = {
60
+ "steel": 1.0,
61
+ "low_alloy_steel": 1.0,
62
+ "grey_iron": 0.75,
63
+ "ductile_iron": 0.75,
64
+ }
65
+
66
+ # Small epsilon guards against division-by-zero / sqrt(0) without perturbing
67
+ # any physically meaningful value (cooling rates and solidification times of
68
+ # interest are always many orders of magnitude larger than this).
69
+ _EPS = 1e-9
70
+
71
+
72
+ def node_gradient(pos: np.ndarray, tets: np.ndarray, field: np.ndarray) -> np.ndarray:
73
+ """(N,3) volume-weighted average of per-tet linear-shape-function gradients.
74
+
75
+ For a linear (P1) tetrahedral element the interpolated field is affine
76
+ over the tet, so its gradient is constant per tet. For tet vertices
77
+ x0..x3 with field values f0..f3, the gradient g solves the 3x3 system
78
+ J @ g = rhs, J[i] = x_{i+1} - x0, rhs[i] = f_{i+1} - f0 (i=0,1,2)
79
+ i.e. J's rows are the tet's three edge vectors from vertex 0, and rhs is
80
+ the corresponding field differences -- the standard closed form for a
81
+ linear tet element's gradient (no shape-function basis needed beyond
82
+ this, since a P1 gradient is uniquely determined by 3 independent edge
83
+ derivatives).
84
+
85
+ The constant per-tet gradient is then averaged onto each of its 4 nodes,
86
+ weighted by tet volume (a volume-weighted average over all tets incident
87
+ to a node), which is the standard nodal-recovery scheme for
88
+ element-constant gradients on unstructured tet meshes.
89
+
90
+ Units: `pos` in mm; the returned gradient is in [field units] / mm.
91
+
92
+ Degenerate (zero-volume) tets contribute zero weight and zero gradient
93
+ (guarded, never divide by zero); a node touched by no tets (or an empty
94
+ `tets` array) gets a zero gradient.
95
+ """
96
+ pos = np.asarray(pos, dtype=np.float64)
97
+ tets = np.asarray(tets, dtype=np.int64)
98
+ field = np.asarray(field, dtype=np.float64)
99
+ n_nodes = pos.shape[0]
100
+
101
+ if tets.shape[0] == 0:
102
+ return np.zeros((n_nodes, 3))
103
+
104
+ p0 = pos[tets[:, 0]]
105
+ p1 = pos[tets[:, 1]]
106
+ p2 = pos[tets[:, 2]]
107
+ p3 = pos[tets[:, 3]]
108
+ f0 = field[tets[:, 0]]
109
+ f1 = field[tets[:, 1]]
110
+ f2 = field[tets[:, 2]]
111
+ f3 = field[tets[:, 3]]
112
+
113
+ e1 = p1 - p0
114
+ e2 = p2 - p0
115
+ e3 = p3 - p0
116
+
117
+ jac = np.stack([e1, e2, e3], axis=1) # (M,3,3), rows = edge vectors
118
+ rhs = np.stack([f1 - f0, f2 - f0, f3 - f0], axis=1) # (M,3)
119
+
120
+ vol = np.abs(np.einsum('ij,ij->i', e1, np.cross(e2, e3))) / 6.0 # (M,)
121
+
122
+ grad_tet = np.zeros_like(rhs)
123
+ nondeg = vol > 0
124
+ if nondeg.any():
125
+ grad_tet[nondeg] = np.linalg.solve(jac[nondeg], rhs[nondeg, :, None])[..., 0]
126
+
127
+ node_grad_sum = np.zeros((n_nodes, 3))
128
+ node_vol_sum = np.zeros(n_nodes)
129
+ weighted = grad_tet * vol[:, None]
130
+ for k in range(4):
131
+ idx = tets[:, k]
132
+ np.add.at(node_grad_sum, idx, weighted)
133
+ np.add.at(node_vol_sum, idx, vol)
134
+
135
+ return node_grad_sum / np.maximum(node_vol_sum, _EPS)[:, None]
136
+
137
+
138
+ def niyama_exact(gradT_K_per_m: np.ndarray, dTdt_K_per_s: np.ndarray) -> np.ndarray:
139
+ """Ny in (degC*s)^0.5/mm: G[degC/mm] / sqrt(|Tdot|[degC/s]), eps-guarded.
140
+
141
+ Exact path: uses the solver's own cached `gradT` (K/m) and `dTdt` (K/s)
142
+ fields directly (see module docstring). 1 K/m = 1e-3 degC/mm (a
143
+ difference/gradient, not an absolute temperature, so no offset).
144
+ """
145
+ gradT_K_per_m = np.asarray(gradT_K_per_m, dtype=np.float64)
146
+ dTdt_K_per_s = np.asarray(dTdt_K_per_s, dtype=np.float64)
147
+
148
+ G_degC_per_mm = gradT_K_per_m / 1000.0
149
+ tdot_abs = np.abs(dTdt_K_per_s)
150
+ return G_degC_per_mm / np.sqrt(np.maximum(tdot_abs, _EPS))
151
+
152
+
153
+ def niyama_proxy(pos: np.ndarray, tets: np.ndarray, t_s: np.ndarray,
154
+ TL: float, TS: float) -> np.ndarray:
155
+ """sqrt((TL-TS)/max(t_s,eps)) * |node_gradient(t_s)| -- the ADDENDUM contract.
156
+
157
+ Proxy path: only the (predicted) solidification-time field `t_s` (s) is
158
+ available. Using the identity grad(T) = -Tdot * grad(t_s), a proxy
159
+ cooling rate `Tdot_proxy = (TL - TS) / max(t_s, eps)` (the characteristic
160
+ freezing-interval rate) stands in for the true `Tdot`, giving
161
+ `Ny_proxy = sqrt(Tdot_proxy) * |grad(t_s)|`. `TL`, `TS` in degC; `t_s` in
162
+ s; `pos` in mm (so `grad(t_s)` is in s/mm, matching the exact path's
163
+ (degC*s)^0.5/mm unit once multiplied by sqrt(degC/s)).
164
+
165
+ This is a *relative* ranking only, not comparable to the literature
166
+ `NY_CRIT` absolute thresholds -- flag risk within one part via
167
+ `porosity_risk(mode="percentile")`.
168
+ """
169
+ t_s = np.asarray(t_s, dtype=np.float64)
170
+ grad = node_gradient(pos, tets, t_s)
171
+ grad_mag = np.linalg.norm(grad, axis=1)
172
+
173
+ t_safe = np.maximum(t_s, _EPS)
174
+ tdot_proxy = (TL - TS) / t_safe
175
+ return np.sqrt(np.maximum(tdot_proxy, 0.0)) * grad_mag
176
+
177
+
178
+ def porosity_risk(ny: np.ndarray, mode: str, alloy_family: str = None,
179
+ pct: float = 10.0) -> np.ndarray:
180
+ """Boolean at-risk mask from a Niyama field.
181
+
182
+ mode="absolute" (exact path): `ny < NY_CRIT[alloy_family]`. `alloy_family`
183
+ must be a key of `NY_CRIT`; an unknown or missing (`None`) family raises
184
+ `KeyError` -- there is no silent default, because guessing an alloy
185
+ family would silently mis-threshold the risk call.
186
+
187
+ mode="percentile" (proxy path): flags the within-part bottom-`pct`%
188
+ lowest Niyama values, i.e. `ny <= numpy.percentile(ny, pct)` (numpy's
189
+ default linear-interpolation percentile). Tie handling is deterministic
190
+ and order-independent: every value tied at (or below) the threshold is
191
+ flagged, so if duplicate values straddle the threshold the flagged
192
+ fraction can be slightly *more* than `pct`% (never less, and never
193
+ dependent on array order).
194
+ """
195
+ ny = np.asarray(ny, dtype=np.float64)
196
+
197
+ if mode == "absolute":
198
+ crit = NY_CRIT[alloy_family]
199
+ return ny < crit
200
+ elif mode == "percentile":
201
+ threshold = np.percentile(ny, pct)
202
+ return ny <= threshold
203
+ else:
204
+ raise ValueError(f"unknown porosity_risk mode: {mode!r}")
205
+
206
+
207
+ # ---------------------------------------------------------------------------
208
+ # Macro-shrinkage regions, top-surface sinks, feeder recommendations.
209
+ #
210
+ # See docs/superpowers/specs/2026-07-24-shrinkage-porosity-screening-design.md
211
+ # section 2-4 and the OWNER-ADJUDICATED correction in
212
+ # docs/superpowers/plans/2026-08-01-sp2-shrinkage.md (2026-08-01, review round
213
+ # 1, finding 1): a hot spot is a local maximum of solidification time `t_s`
214
+ # (last metal to freeze). The ORIGINAL "monotonically non-decreasing t_s path
215
+ # to any surface node" feeding-path definition was proved degenerate (a
216
+ # region can never escape its own hot-node superlevel-set component under
217
+ # that rule, making it tautologically equivalent to "region touches the
218
+ # domain surface" -- replacing the whole search with `return False` left
219
+ # every test green). The adjudicated definition instead: a region is fed iff
220
+ # a node path over tet adjacency connects it to a caller-supplied FEED node
221
+ # (e.g. nodes on Adiabatic-tagged riser/sleeve faces -- NOT the generic mold
222
+ # surface; a bare sand casting with no risers passes an empty `feed_nodes`,
223
+ # so every region is conservatively unfed) with EVERY node on that path
224
+ # (endpoints included) at or above `corridor_frac * region["t_max"]` -- a
225
+ # warm corridor that stays liquid until the region itself finishes freezing,
226
+ # with a 5% relative tolerance. This breaks the old degeneracy because the
227
+ # corridor threshold is relative to the REGION's own peak, not the mesh-wide
228
+ # hot cut, so a feed node need not itself be part of the region's hot-node
229
+ # superlevel set.
230
+ #
231
+ # Severity scales with the alloy's volumetric shrinkage percentage
232
+ # (`SHRINKAGE_PCT`) times the region's volume. A region whose highest point
233
+ # against gravity is itself a free surface sinks that surface instead of
234
+ # voiding internally. A feeder placed above the region must have modulus
235
+ # (V/A, Chvorinov's rule) >= 1.2x the region's own modulus to freeze after it.
236
+ # ---------------------------------------------------------------------------
237
+
238
+ # Local vertex-index pairs/triples of a tetrahedron (0,1,2,3), used to walk a
239
+ # tet's 6 edges / 4 faces without re-deriving the combinatorics inline.
240
+ _TET_EDGE_PAIRS = [(0, 1), (0, 2), (0, 3), (1, 2), (1, 3), (2, 3)]
241
+ _TET_FACES = [(0, 1, 2), (0, 1, 3), (0, 2, 3), (1, 2, 3)]
242
+
243
+
244
+ def _tet_volumes(pos: np.ndarray, tets: np.ndarray) -> np.ndarray:
245
+ """(M,) unsigned tet volumes in mm^3 (independent copy of the closed form
246
+ used inside `node_gradient`, kept private and separate so Task 1's
247
+ function is never touched)."""
248
+ if tets.shape[0] == 0:
249
+ return np.zeros(0)
250
+ p0, p1, p2, p3 = pos[tets[:, 0]], pos[tets[:, 1]], pos[tets[:, 2]], pos[tets[:, 3]]
251
+ e1, e2, e3 = p1 - p0, p2 - p0, p3 - p0
252
+ return np.abs(np.einsum('ij,ij->i', e1, np.cross(e2, e3))) / 6.0
253
+
254
+
255
+ def _induced_components(n_nodes: int, tets: np.ndarray, mask: np.ndarray):
256
+ """Connected components of the tet-adjacency graph induced on `mask`
257
+ (i.e. vertices = nodes with `mask[node]` True, edges = tet-adjacent pairs
258
+ both satisfying `mask`). Returns `(n_comp, labels)` where `labels` has
259
+ shape `(n_nodes,)`: the component id for masked nodes, `-1` elsewhere.
260
+ Shared by `hot_regions` (mask = the global top-`top_frac` hot cut) and
261
+ `is_fed` (mask = the per-region warm corridor, `t_s >= corridor_frac *
262
+ region_t_max`) -- same graph-labeling primitive, different masks.
263
+ """
264
+ idx = np.flatnonzero(mask)
265
+ labels_full = np.full(n_nodes, -1, dtype=np.int64)
266
+ if idx.size == 0:
267
+ return 0, labels_full
268
+
269
+ local_of = -np.ones(n_nodes, dtype=np.int64)
270
+ local_of[idx] = np.arange(idx.size)
271
+
272
+ rows_list, cols_list = [], []
273
+ for a, b in _TET_EDGE_PAIRS:
274
+ va, vb = tets[:, a], tets[:, b]
275
+ both = mask[va] & mask[vb]
276
+ if both.any():
277
+ rows_list.append(local_of[va[both]])
278
+ cols_list.append(local_of[vb[both]])
279
+ n_local = idx.size
280
+ if rows_list:
281
+ rows = np.concatenate(rows_list)
282
+ cols = np.concatenate(cols_list)
283
+ data = np.ones(rows.shape[0])
284
+ graph = csr_matrix((data, (rows, cols)), shape=(n_local, n_local))
285
+ else:
286
+ graph = csr_matrix((n_local, n_local))
287
+ n_comp, labels_local = connected_components(graph, directed=False)
288
+ labels_full[idx] = labels_local
289
+ return n_comp, labels_full
290
+
291
+
292
+ def _boundary_faces(tets: np.ndarray) -> np.ndarray:
293
+ """(F, 3) int64 array of the mesh's own boundary-face vertex triples
294
+ (ORIGINAL per-tet vertex order, not a sorted dedup key) -- faces touched
295
+ by exactly one tet, i.e. with nothing "on the other side".
296
+
297
+ Review finding I4: `_region_surface_area`'s domain-boundary term used to
298
+ rebuild this WHOLE-MESH structure via a pure-Python double loop over
299
+ every tet's 4 faces, on EVERY call -- but it depends only on `tets`, not
300
+ on any particular region's `nodes`/`threshold`, so `feeder_recommendations`
301
+ (this module's only caller of `_region_surface_area`) was redoing the
302
+ exact same O(n_tets) work once per region. Measured on BUKSA (32138
303
+ tets, 16 regions): 4.74 s total screening time, ~8x the documented
304
+ ~0.5-0.6 s (a figure that had only ever been measured on a single-region
305
+ part). Hoisted here so it runs ONCE per `feeder_recommendations` call,
306
+ vectorized via `np.unique(..., axis=0, return_counts=True)` (both the
307
+ dict-free dedup AND -- inside `_region_surface_area` -- the boundary-term
308
+ summation itself are now array ops, not a Python loop).
309
+
310
+ Every tet contributes 4 candidate faces; a face shared by exactly 2 tets
311
+ (an interior face) is discarded, one shared by exactly 1 (nothing on the
312
+ other side) is kept -- a face can never be shared by >2 tets in a
313
+ conforming tet mesh, so `counts == 1` is the correct (and only
314
+ necessary) boundary test."""
315
+ tets = np.asarray(tets, dtype=np.int64)
316
+ if tets.shape[0] == 0:
317
+ return np.zeros((0, 3), dtype=np.int64)
318
+ face_idx = np.asarray(_TET_FACES, dtype=np.int64) # (4, 3)
319
+ all_faces = tets[:, face_idx].reshape(-1, 3) # (4*M, 3), original order
320
+ sorted_faces = np.sort(all_faces, axis=1)
321
+ _, first_idx, counts = np.unique(sorted_faces, axis=0, return_index=True,
322
+ return_counts=True)
323
+ boundary = counts == 1
324
+ return all_faces[first_idx[boundary]]
325
+
326
+
327
+ def _region_surface_area(pos: np.ndarray, tets: np.ndarray, nodes: np.ndarray,
328
+ t_s: np.ndarray, threshold: float,
329
+ boundary_faces: np.ndarray = None) -> float:
330
+ """Boundary-surface area (mm^2) of the SAME body `hot_regions`' fraction-
331
+ weighted `volume_mm3` integrates over (review round 2, finding NEW-1: the
332
+ original all-4-vertices-hot rule measured a strictly SMALLER body than
333
+ the fraction-weighted volume, giving an inflated, inconsistent modulus).
334
+
335
+ Round-2 history / why this changed again (review round 3): the first fix
336
+ ran the co-area formula on a BINARIZED 0/1 indicator (`nodes` membership
337
+ only). That is an exact calculus identity for a piecewise-linear field,
338
+ but the field it measures is the indicator's own artificial "staircase"
339
+ step (always crossing at each tet edge's midpoint, regardless of where
340
+ the REAL field actually crosses its selection threshold) -- an
341
+ ANISOTROPIC discrete-perimeter measure that plateaus ~24-29% high on
342
+ curved/diagonal boundaries (axis-aligned cuts happened to look fine only
343
+ because a midpoint crossing is coincidentally correct when the true
344
+ level surface IS flat and axis-aligned).
345
+
346
+ This version runs marching tetrahedra directly on the CONTINUOUS `t_s`
347
+ field, cut at `threshold` -- literally "the same cut that defines the
348
+ region" (the percentile cutoff `hot_regions` used, stored on each region
349
+ dict as `hot_threshold`), so the interpolated crossing point on each tet
350
+ edge sits at its true fractional position (`(threshold-f_a)/(f_b-f_a)`),
351
+ not an assumed 0.5. Per tet, classified by how many of its 4 vertices
352
+ have `t_s >= threshold` (`k`):
353
+ - k in {0, 4}: no crossing, no contribution.
354
+ - k in {1, 3}: the crossing is a single triangle through the 3 edges
355
+ connecting the "odd one out" vertex to the other 3 (interpolated
356
+ edge points) -- the standard 1-3 marching-tetrahedra case.
357
+ - k == 2: the crossing is a planar quadrilateral through the 4 edges
358
+ connecting each of the 2 "above" vertices to each of the 2 "below"
359
+ vertices (`t_s` is affine per tet, so ANY level set within a tet is
360
+ a plane intersected with the tet -- always exactly planar,
361
+ regardless of the specific values -- computed via the standard
362
+ diagonal cross-product quadrilateral-area formula).
363
+ A tet is attributed to THIS region iff any of its vertices is in
364
+ `nodes` (well-defined: two vertices with `t_s >= threshold` sharing a
365
+ tet are adjacent by `hot_regions`' own definition, so a mixed tet's
366
+ "above" vertices, if any, all belong to exactly one region already).
367
+
368
+ Domain-boundary term (KEPT, unchanged, review-verified exact for a
369
+ region whose boundary coincides with the mesh's own outer surface): a
370
+ tet face touching no other tet (nothing "on the other side" for the
371
+ interior marching-tets term to see) is credited separately, weighted by
372
+ the (binary, `nodes`-membership) hot fraction of its own 3 vertices.
373
+
374
+ See tests/test_shrinkage.py for the acceptance suite this targets:
375
+ sphere-field area converging toward the analytic 4*pi*R^2 with
376
+ refinement (not plateauing), a diagonal-plane cut and an orientation
377
+ sweep both landing far below the ~29-36% anisotropy the binarized
378
+ co-area formula showed, and the axis-aligned hand-check (now computed
379
+ against a genuine continuous field, not an arbitrarily-thresholded
380
+ binary block) landing near the Chvorinov M=side/6 identity for a cube.
381
+
382
+ `boundary_faces` (review finding I4): the mesh's own domain-boundary
383
+ faces (`_boundary_faces(tets)`'s output), region-INDEPENDENT -- a single
384
+ call's worth of work shared by every region in one
385
+ `feeder_recommendations` invocation instead of being rebuilt (a
386
+ pure-Python double loop over ALL tets) on every one of this function's
387
+ calls. `None` (the default) computes it internally -- exactly the old
388
+ behavior, for any direct caller that doesn't have one precomputed.
389
+ """
390
+ n_nodes = pos.shape[0]
391
+ is_member = np.zeros(n_nodes, dtype=bool)
392
+ is_member[np.asarray(nodes, dtype=np.int64)] = True
393
+ t_s = np.asarray(t_s, dtype=np.float64)
394
+ threshold = float(threshold)
395
+
396
+ area = 0.0
397
+ if tets.shape[0] > 0:
398
+ hot_mask = t_s >= threshold
399
+ member_per_tet = hot_mask[tets]
400
+ n_above = member_per_tet.sum(axis=1)
401
+ mixed = np.flatnonzero((n_above >= 1) & (n_above <= 3))
402
+ for ti in mixed:
403
+ tet = tets[ti]
404
+ if not is_member[tet].any():
405
+ continue # this crossing belongs to a different region
406
+ verts = pos[tet]
407
+ vals = t_s[tet]
408
+ above_local = member_per_tet[ti]
409
+ k = int(n_above[ti])
410
+ if k in (1, 3):
411
+ odd = int(np.flatnonzero(above_local)[0]) if k == 1 else \
412
+ int(np.flatnonzero(~above_local)[0])
413
+ others = [i for i in range(4) if i != odd]
414
+ pts = []
415
+ for o in others:
416
+ frac = (threshold - vals[odd]) / (vals[o] - vals[odd])
417
+ pts.append(verts[odd] + frac * (verts[o] - verts[odd]))
418
+ area += 0.5 * float(np.linalg.norm(np.cross(pts[1] - pts[0], pts[2] - pts[0])))
419
+ else: # k == 2
420
+ hi = np.flatnonzero(above_local)
421
+ lo = np.flatnonzero(~above_local)
422
+
423
+ def _cross_point(h, c, verts=verts, vals=vals):
424
+ frac = (threshold - vals[h]) / (vals[c] - vals[h])
425
+ return verts[h] + frac * (verts[c] - verts[h])
426
+
427
+ a_pt = _cross_point(hi[0], lo[0])
428
+ b_pt = _cross_point(hi[0], lo[1])
429
+ c_pt = _cross_point(hi[1], lo[1])
430
+ d_pt = _cross_point(hi[1], lo[0])
431
+ area += 0.5 * float(np.linalg.norm(np.cross(c_pt - a_pt, d_pt - b_pt)))
432
+
433
+ if boundary_faces is None:
434
+ boundary_faces = _boundary_faces(tets)
435
+ if boundary_faces.shape[0] > 0:
436
+ # Vectorized version of the old per-face Python loop: each boundary
437
+ # face's hot fraction is (# of its 3 vertices in `nodes`) / 3, and
438
+ # only faces with a nonzero fraction contribute a (fraction-
439
+ # weighted) triangle area.
440
+ member_counts = is_member[boundary_faces].sum(axis=1).astype(np.float64) # (F,)
441
+ frac = member_counts / 3.0
442
+ nonzero = frac > 0
443
+ if nonzero.any():
444
+ v = boundary_faces[nonzero]
445
+ p0f, p1f, p2f = pos[v[:, 0]], pos[v[:, 1]], pos[v[:, 2]]
446
+ tri_areas = 0.5 * np.linalg.norm(np.cross(p1f - p0f, p2f - p0f), axis=1)
447
+ area += float(np.sum(frac[nonzero] * tri_areas))
448
+ return area
449
+
450
+
451
+ def hot_regions(pos: np.ndarray, tets: np.ndarray, t_s: np.ndarray,
452
+ top_frac: float = 0.10) -> list:
453
+ """Connected components (via tet adjacency) of the top-`top_frac`
454
+ latest-solidifying nodes.
455
+
456
+ A node is "hot" if `t_s >= percentile(t_s, 100*(1-top_frac))` (inclusive,
457
+ same tie convention as `porosity_risk`'s percentile mode -- may include
458
+ slightly more than `top_frac` when values tie at the threshold).
459
+ Components are the connected components of the graph whose vertices are
460
+ the hot nodes and whose edges are tet-adjacency pairs restricted to hot
461
+ endpoints (two hot nodes sharing a tet).
462
+
463
+ Returns a list of dicts sorted by `t_max` descending, each carrying a
464
+ stable `region_id` = its index in THIS sorted list (0-based) -- the id
465
+ downstream functions (`macro_shrinkage`, `top_sinks`,
466
+ `feeder_recommendations`) read back off the dict rather than
467
+ re-deriving positionally, so it stays coherent across filtering:
468
+ `{"nodes": sorted int index array, "centroid": (3,) mm, "volume_mm3":
469
+ float, "t_max": float, "region_id": int, "hot_threshold": float,
470
+ "sub_resolution": bool}`. `sub_resolution` (review finding C1) is `True`
471
+ iff the region has fewer than 4 nodes -- below this floor
472
+ `_region_surface_area`'s iso-surface area and the lumped-nodal
473
+ `volume_mm3` computed below diverge without bound as `t_max` approaches
474
+ `hot_threshold`; `feeder_recommendations` reads this flag back to skip
475
+ such regions rather than report a physically meaningless modulus (see
476
+ its own docstring), while `macro_shrinkage`/`top_sinks` still list them
477
+ unfiltered (their volume/severity/centroid stay meaningful).
478
+ `hot_threshold` is the same percentile cutoff (`t_s >= hot_threshold`)
479
+ used to select every region in THIS call -- `feeder_recommendations`
480
+ (review round 3) reads it back to run marching tetrahedra on the
481
+ continuous `t_s` field at exactly "the cut that defines the region",
482
+ rather than re-deriving or guessing it downstream.
483
+
484
+ `volume_mm3` is a fraction-weighted sum over ALL tets touching the
485
+ region: each tet contributes `tet_volume * n_hot_vertices/4` to the
486
+ component of its hot vertices (well-defined -- any two hot vertices
487
+ sharing a tet are adjacent by this function's own definition, so a
488
+ tet's hot vertices, if any, all belong to the same component). This is
489
+ mesh-convergent (equivalent to summing each hot node's lumped nodal
490
+ volume, `sum(tet_volume/4)` over its incident tets) and replaces an
491
+ earlier all-4-vertices-hot rule that measurably undercounted volume
492
+ (35-65% on smooth fields, review round 1 finding 2) by discarding every
493
+ tet straddling the region boundary instead of weighting it.
494
+ """
495
+ top_frac = float(top_frac)
496
+ if not (0.0 < top_frac <= 1.0):
497
+ raise ValueError(f"hot_regions: top_frac must be in (0, 1], got {top_frac!r}")
498
+
499
+ pos = np.asarray(pos, dtype=np.float64)
500
+ tets = np.asarray(tets, dtype=np.int64)
501
+ t_s = np.asarray(t_s, dtype=np.float64)
502
+ n_nodes = pos.shape[0]
503
+
504
+ if n_nodes == 0 or tets.shape[0] == 0:
505
+ return []
506
+
507
+ threshold = np.percentile(t_s, 100.0 * (1.0 - top_frac))
508
+ hot_mask = t_s >= threshold
509
+ if not hot_mask.any():
510
+ return []
511
+
512
+ n_comp, labels = _induced_components(n_nodes, tets, hot_mask)
513
+
514
+ vol = _tet_volumes(pos, tets)
515
+ n_hot_per_tet = hot_mask[tets].sum(axis=1)
516
+ has_hot = n_hot_per_tet > 0
517
+ region_vol = np.zeros(n_comp)
518
+ if has_hot.any():
519
+ # any one hot vertex identifies the tet's (unique) component.
520
+ first_hot_col = np.argmax(hot_mask[tets], axis=1)
521
+ first_hot_node = tets[np.arange(tets.shape[0]), first_hot_col]
522
+ comp_of_tet = labels[first_hot_node]
523
+ weight = vol * (n_hot_per_tet / 4.0)
524
+ np.add.at(region_vol, comp_of_tet[has_hot], weight[has_hot])
525
+
526
+ regions = []
527
+ for c in range(n_comp):
528
+ nodes = np.sort(np.flatnonzero(labels == c))
529
+ regions.append({
530
+ "nodes": nodes,
531
+ "centroid": pos[nodes].mean(axis=0),
532
+ "volume_mm3": float(region_vol[c]),
533
+ "t_max": float(t_s[nodes].max()),
534
+ })
535
+ regions.sort(key=lambda r: r["t_max"], reverse=True)
536
+ for i, r in enumerate(regions):
537
+ r["region_id"] = i
538
+ r["hot_threshold"] = float(threshold)
539
+ # Review finding C1: a region with fewer than 4 nodes is below the
540
+ # resolution floor at which `_region_surface_area`'s iso-surface
541
+ # area and the lumped-nodal `volume_mm3` above agree even
542
+ # approximately -- as `t_max` approaches `hot_threshold` the true
543
+ # iso-surface area shrinks toward zero while the lumped volume stays
544
+ # O(h^3), so V/A (the feeder modulus) diverges without bound.
545
+ # `feeder_recommendations` reads this flag back to skip such regions
546
+ # rather than emit a physically-impossible modulus; `volume_mm3`/
547
+ # `severity` remain meaningful (only the surface-area-derived
548
+ # modulus is the part that diverges), so `macro_shrinkage` still
549
+ # lists these regions unfiltered.
550
+ r["sub_resolution"] = len(r["nodes"]) < 4
551
+ return regions
552
+
553
+
554
+ def is_fed(region: dict, t_s: np.ndarray, tets: np.ndarray,
555
+ feed_nodes: np.ndarray, corridor_frac: float = 0.95) -> bool:
556
+ """OWNER-ADJUDICATED SEMANTICS (2026-08-01, review round 1 finding 1 --
557
+ see the module-level comment above for why the original monotone-escape
558
+ definition was degenerate). A region is fed iff some node of `region`
559
+ and some node of `feed_nodes` (e.g. Adiabatic-tagged riser/sleeve nodes;
560
+ an empty `feed_nodes` -- a bare sand casting with no risers -- always
561
+ gives False, the conservative screen) lie in the same connected
562
+ component of the tet-adjacency graph induced on the "warm corridor":
563
+ nodes with `t_s >= corridor_frac * region["t_max"]`.
564
+
565
+ The corridor threshold is relative to the REGION's own peak, not the
566
+ mesh-wide hot cut -- a feed node several hops away through nodes just
567
+ below the region's own hot-selection threshold (but still within
568
+ `corridor_frac` of its peak) now correctly counts as fed, which the old
569
+ "escape the hot superlevel set" definition could never recognize.
570
+
571
+ Guard (review round 2, finding NEW-2): if `region["t_max"] <= 0`, the
572
+ corridor threshold `corridor_frac * t_max` would be `<= 0`, which is
573
+ vacuously satisfied by every non-negative `t_s` value (solidification
574
+ times are never negative) -- i.e. the "warm corridor" would silently
575
+ become the WHOLE mesh, making every region trivially "fed" through any
576
+ non-empty `feed_nodes` regardless of actual connectivity. This isn't a
577
+ physically meaningful region in the first place (its peak already
578
+ solidified at t=0), so it's reported unfed (`False`) rather than let the
579
+ threshold degenerate.
580
+ """
581
+ t_s = np.asarray(t_s, dtype=np.float64)
582
+ tets = np.asarray(tets, dtype=np.int64)
583
+ feed_nodes = np.asarray(feed_nodes, dtype=np.int64)
584
+ region_nodes = np.asarray(region["nodes"], dtype=np.int64)
585
+
586
+ if feed_nodes.size == 0:
587
+ return False
588
+ if tets.shape[0] == 0:
589
+ return False
590
+ region_t_max = float(region["t_max"])
591
+ if region_t_max <= 0:
592
+ logger.debug(
593
+ "is_fed: region t_max=%.6g <= 0 -- corridor threshold would be "
594
+ "vacuous (every non-negative t_s qualifies); reporting unfed.",
595
+ region_t_max)
596
+ return False
597
+
598
+ n_nodes = t_s.shape[0]
599
+ threshold = corridor_frac * region_t_max
600
+ warm_mask = t_s >= threshold
601
+
602
+ _, labels = _induced_components(n_nodes, tets, warm_mask)
603
+
604
+ region_labels = set(int(l) for l in labels[region_nodes] if l >= 0)
605
+ if not region_labels:
606
+ return False
607
+ feed_labels = set(int(l) for l in labels[feed_nodes] if l >= 0)
608
+ return len(region_labels & feed_labels) > 0
609
+
610
+
611
+ def macro_shrinkage(pos: np.ndarray, tets: np.ndarray, t_s: np.ndarray,
612
+ feed_nodes: np.ndarray, shrinkage_pct: float) -> list:
613
+ """Unfed hot regions (`hot_regions` with its default `top_frac`, filtered
614
+ by `is_fed` against `feed_nodes` -- nodes on Adiabatic-tagged riser/sleeve
615
+ faces, NOT the generic mold/cooling surface), each with `severity =
616
+ shrinkage_pct/100 * volume_mm3` added. `region_id` is propagated
617
+ unchanged from `hot_regions` (its index in the FULL sorted list, not
618
+ renumbered by this function's filtering) so ids stay coherent if this
619
+ output is chained into `top_sinks`/`feeder_recommendations`. A bare sand
620
+ casting with no risers passes an empty `feed_nodes` -- every hot region
621
+ is then unfed, the conservative screen (see `is_fed`).
622
+ """
623
+ regions = hot_regions(pos, tets, t_s)
624
+ out = []
625
+ for r in regions:
626
+ if is_fed(r, t_s, tets, feed_nodes):
627
+ continue
628
+ severity = shrinkage_pct / 100.0 * r["volume_mm3"]
629
+ out.append({**r, "severity": severity})
630
+ return out
631
+
632
+
633
+ def top_sinks(regions: list, pos: np.ndarray, surface_nodes: np.ndarray,
634
+ gravity_vec) -> list:
635
+ """Regions whose highest point along `-gravity_vec` is itself a surface
636
+ node sink that free surface (rather than voiding internally) -- returns
637
+ `{"region_id": the region dict's own "region_id" (propagated, NOT a
638
+ positional re-index -- see `hot_regions`/`macro_shrinkage`), "location":
639
+ (3,) mm of that surface node, "volume_mm3": the region's own volume}` for
640
+ each qualifying region.
641
+ """
642
+ pos = np.asarray(pos, dtype=np.float64)
643
+ gravity_vec = np.asarray(gravity_vec, dtype=np.float64)
644
+ up = -gravity_vec
645
+ norm = np.linalg.norm(up)
646
+ if norm > 0:
647
+ up = up / norm
648
+ surface_set = set(np.asarray(surface_nodes, dtype=np.int64).tolist())
649
+
650
+ sinks = []
651
+ for r in regions:
652
+ nodes = np.asarray(r["nodes"], dtype=np.int64)
653
+ proj = pos[nodes] @ up
654
+ top_node = int(nodes[np.argmax(proj)])
655
+ if top_node in surface_set:
656
+ sinks.append({
657
+ "region_id": r["region_id"],
658
+ "location": pos[top_node].copy(),
659
+ "volume_mm3": r["volume_mm3"],
660
+ })
661
+ return sinks
662
+
663
+
664
+ def feeder_recommendations(regions: list, pos: np.ndarray, tets: np.ndarray,
665
+ surface_nodes: np.ndarray, gravity_vec, t_s: np.ndarray) -> list:
666
+ """Per region: `{"location": surface point above the region's centroid
667
+ along -gravity_vec, "min_modulus_mm": 1.2 * region_modulus, "region_id":
668
+ the region dict's own "region_id" (propagated, NOT a positional
669
+ re-index)}`, where `region_modulus = V/A` (Chvorinov's rule), `A` =
670
+ `_region_surface_area` of the region's own tets, cut at the region's own
671
+ `hot_threshold` (review round 3 -- see `_region_surface_area`'s
672
+ docstring; `t_s` is a NEW required parameter added in that round since
673
+ the marching-tetrahedra area computation needs the continuous field, not
674
+ just the region's own node membership).
675
+
676
+ `location` is the surface node minimizing perpendicular distance to the
677
+ vertical line (direction `-gravity_vec`) through the centroid, restricted
678
+ to surface nodes at or above the centroid along that direction (falling
679
+ back to all surface nodes if none qualify, e.g. a degenerate/zero-height
680
+ domain) -- a nearest-existing-mesh-node stand-in for "directly above".
681
+
682
+ Raises `ValueError` if `surface_nodes` is empty (no candidate feeder
683
+ location exists -- a mesh with no free surface is not a valid casting).
684
+ A region with zero volume or zero boundary area (a degenerate/thin
685
+ region under the fraction-weighted volume rule) gets a logged warning
686
+ and `min_modulus_mm = 0.0` rather than a silently meaningless value.
687
+
688
+ Review finding C1 (sub-resolution regions): a region with fewer than 4
689
+ nodes (`region.get("sub_resolution", False)`, set by `hot_regions`) is
690
+ SKIPPED entirely -- no feeder recommendation is produced for it. For such
691
+ a region the iso-surface area shrinks toward zero as `t_max` approaches
692
+ the region's own selection threshold (an unbounded-in-the-limit
693
+ `M = V/A`, since the lumped-nodal `volume_mm3` stays O(h^3) while the
694
+ true iso-surface area vanishes), so `min_modulus_mm` is not a meaningful
695
+ number at this resolution -- reproduced on the branch's own BUKSA smoke
696
+ artifact (`runs/gate_c/smoke/BUKSA_cleaned_summary.txt`): a 1-node
697
+ region's modulus of 185 mm printed a `min_modulus_mm=222` feeder
698
+ recommendation on a 382x317x254 mm part. `macro_shrinkage` still lists
699
+ these regions (their `volume_mm3`/`severity` are fine -- only the
700
+ surface-area-derived modulus is the part that diverges), and the region
701
+ still contributes to `top_sinks`. The number of regions skipped this way
702
+ is logged once (`logger.info`) rather than per-region, so a part with
703
+ many marginal regions doesn't flood the log.
704
+
705
+ Review finding I4 (performance): the mesh's boundary-face structure
706
+ (`_boundary_faces(tets)`) is region-INDEPENDENT and is now computed ONCE
707
+ here and passed to every `_region_surface_area` call, instead of being
708
+ rebuilt (a pure-Python double loop over every tet) once per region --
709
+ see `_region_surface_area`'s own docstring for the before/after cost.
710
+ """
711
+ pos = np.asarray(pos, dtype=np.float64)
712
+ tets = np.asarray(tets, dtype=np.int64)
713
+ t_s = np.asarray(t_s, dtype=np.float64)
714
+ gravity_vec = np.asarray(gravity_vec, dtype=np.float64)
715
+ up = -gravity_vec
716
+ norm = np.linalg.norm(up)
717
+ if norm > 0:
718
+ up = up / norm
719
+ surface_nodes = np.asarray(surface_nodes, dtype=np.int64)
720
+ if surface_nodes.size == 0:
721
+ raise ValueError(
722
+ "feeder_recommendations: surface_nodes is empty -- no candidate "
723
+ "feeder location exists (a mesh with no free surface is not a "
724
+ "valid casting)")
725
+ surface_pos = pos[surface_nodes]
726
+ boundary_faces = _boundary_faces(tets) # I4: computed once, shared by every region
727
+
728
+ recs = []
729
+ n_skipped_sub_resolution = 0
730
+ for r in regions:
731
+ if r.get("sub_resolution", False):
732
+ n_skipped_sub_resolution += 1
733
+ continue
734
+ nodes = np.asarray(r["nodes"], dtype=np.int64)
735
+ volume = r["volume_mm3"]
736
+ threshold = r["hot_threshold"]
737
+ area = _region_surface_area(pos, tets, nodes, t_s, threshold,
738
+ boundary_faces=boundary_faces)
739
+ if area > 0 and volume > 0:
740
+ region_modulus = volume / area
741
+ else:
742
+ logger.warning(
743
+ "feeder_recommendations: region_id=%s has degenerate "
744
+ "volume/area (V=%.6g mm^3, A=%.6g mm^2) -- min_modulus_mm is "
745
+ "not meaningful and is reported as 0.0",
746
+ r.get("region_id"), volume, area)
747
+ region_modulus = 0.0
748
+
749
+ centroid = np.asarray(r["centroid"], dtype=np.float64)
750
+ rel = surface_pos - centroid
751
+ axis_comp = rel @ up
752
+ perp = rel - np.outer(axis_comp, up)
753
+ perp_dist = np.linalg.norm(perp, axis=1)
754
+
755
+ above = np.flatnonzero(axis_comp >= 0)
756
+ candidates = above if above.size > 0 else np.arange(surface_pos.shape[0])
757
+ best = candidates[np.argmin(perp_dist[candidates])]
758
+
759
+ recs.append({
760
+ "location": surface_pos[best].copy(),
761
+ "min_modulus_mm": 1.2 * region_modulus,
762
+ "region_id": r["region_id"],
763
+ })
764
+ if n_skipped_sub_resolution:
765
+ logger.info(
766
+ "feeder_recommendations: skipped %d sub-resolution region(s) (<4 "
767
+ "nodes) -- V/A modulus is not meaningful at this resolution",
768
+ n_skipped_sub_resolution)
769
+ return recs
770
+
771
+
772
+ # ---------------------------------------------------------------------------
773
+ # screen_casting -- one-call orchestrator (Task 3). See
774
+ # docs/superpowers/specs/2026-07-24-shrinkage-porosity-screening-design.md
775
+ # section "Multi-orientation augmentation": the conduction thermal field (and
776
+ # hence Niyama, output 1, and macro-shrinkage region *detection*) does not
777
+ # depend on gravity -- only the gravity-conditioned outputs (top sinks and
778
+ # feeder placement, outputs 2-4 in the spec's numbering) do. `orientations=
779
+ # "axes"` sweeps those over the 6 signed axis directions at zero extra
780
+ # thermal-solve cost, per the spec.
781
+ # ---------------------------------------------------------------------------
782
+
783
+ # Signed unit axis directions for the `orientations="axes"` sweep, keyed by
784
+ # the label used in `per_orientation`.
785
+ _AXIS_SWEEP = {
786
+ "+x": (1.0, 0.0, 0.0), "-x": (-1.0, 0.0, 0.0),
787
+ "+y": (0.0, 1.0, 0.0), "-y": (0.0, -1.0, 0.0),
788
+ "+z": (0.0, 0.0, 1.0), "-z": (0.0, 0.0, -1.0),
789
+ }
790
+
791
+
792
+ def screen_casting(pos: np.ndarray, tets: np.ndarray, surface_nodes: np.ndarray,
793
+ t_s: np.ndarray, TL: float, TS: float, alloy_family: str,
794
+ gravity_vec=(0, 0, -1), gradT: np.ndarray = None, dTdt: np.ndarray = None,
795
+ orientations: str = None, feed_nodes: np.ndarray = None) -> dict:
796
+ """One-call shrinkage-porosity screening: Niyama (exact when `gradT`+`dTdt`
797
+ are both given, else the time-only proxy), macro-shrinkage region
798
+ detection, top-surface sinks, and feeder recommendations -- the single
799
+ entry point Task 4's `predict_casting` integration is meant to call.
800
+
801
+ `feed_nodes` (BRIEF DEVIATION, owner-adjudicated 2026-08-01): the plan's
802
+ signature (`docs/superpowers/plans/2026-08-01-sp2-shrinkage.md` Task 3)
803
+ does not list this parameter, but `macro_shrinkage`/`is_fed` (Task 2,
804
+ review round 1) require an explicit array of ADIABATIC-tagged riser/
805
+ sleeve nodes to know what "feeds" a region -- this is NOT derivable from
806
+ `surface_nodes` (the whole free surface, used only for sink/feeder
807
+ placement -- a different, larger set that includes ordinary mold-cooling
808
+ surface). Omitting it here would either silently misuse `surface_nodes`
809
+ as feed nodes (every region trivially "self-fed" wherever it touches any
810
+ surface, defeating the whole unfed/fed distinction) or hardcode an empty
811
+ array with no way for a caller to ever supply real risers. `feed_nodes=
812
+ None` defaults to an empty array -- the same "bare sand casting, no
813
+ risers, everything conservatively unfed" convention `macro_shrinkage`/
814
+ `is_fed` already document.
815
+
816
+ Niyama: uses `niyama_exact` + `porosity_risk(mode="absolute")` whenever
817
+ both `gradT` and `dTdt` are given (`niyama_source="exact"`); otherwise
818
+ `niyama_proxy` + `porosity_risk(mode="percentile")`
819
+ (`niyama_source="proxy"`) -- see the module docstring for why the two
820
+ paths use different risk-thresholding modes (the proxy is only a
821
+ within-part relative ranking, never comparable to the literature
822
+ absolute thresholds).
823
+
824
+ Material: `alloy_family` must be a key of `SHRINKAGE_PCT`. An unknown (or
825
+ missing) family raises a loud `KeyError` naming the known families --
826
+ mirroring `porosity_risk`'s own loud-failure style for the identical
827
+ reason: silently defaulting would silently mis-scale every downstream
828
+ severity/threshold number.
829
+
830
+ Orientations: `orientations=None` (default) computes `macro_shrinkage`,
831
+ `top_sinks`, and `feeders` once, using `gravity_vec` as given, and
832
+ `per_orientation` is `None`. `orientations="axes"` additionally sweeps
833
+ `top_sinks`/`feeders` (against the SAME `macro_shrinkage` regions --
834
+ region *detection* is gravity-independent, see the module-level comment
835
+ above) over the 6 signed axis directions, populating `per_orientation`
836
+ with one `{"macro_shrinkage", "top_sinks", "feeders"}` entry per axis
837
+ label (`"+x"`, `"-x"`, `"+y"`, `"-y"`, `"+z"`, `"-z"`); the top-level
838
+ `macro_shrinkage`/`top_sinks`/`feeders` keys are still populated from the
839
+ single `gravity_vec` argument regardless of `orientations`, so the
840
+ `"-z"` entry is numerically identical to calling `screen_casting` again
841
+ with `orientations=None, gravity_vec=(0, 0, -1)` (the default) --
842
+ verified in `tests/test_shrinkage.py`.
843
+
844
+ Returns `{"niyama": (N,), "porosity_risk": (N,) bool, "niyama_source":
845
+ str, "macro_shrinkage": [...], "top_sinks": [...], "feeders": [...],
846
+ "per_orientation": {axis: {...}} | None, "material": {"alloy_family",
847
+ "shrinkage_pct"}}`.
848
+ """
849
+ if alloy_family not in SHRINKAGE_PCT:
850
+ raise KeyError(
851
+ f"screen_casting: unknown alloy_family {alloy_family!r} -- known "
852
+ f"families: {sorted(SHRINKAGE_PCT)}"
853
+ )
854
+
855
+ pos = np.asarray(pos, dtype=np.float64)
856
+ tets = np.asarray(tets, dtype=np.int64)
857
+ surface_nodes = np.asarray(surface_nodes, dtype=np.int64)
858
+ t_s = np.asarray(t_s, dtype=np.float64)
859
+ feed_nodes_arr = (np.zeros(0, dtype=np.int64) if feed_nodes is None
860
+ else np.asarray(feed_nodes, dtype=np.int64))
861
+
862
+ if gradT is not None and dTdt is not None:
863
+ ny = niyama_exact(gradT, dTdt)
864
+ risk = porosity_risk(ny, mode="absolute", alloy_family=alloy_family)
865
+ niyama_source = "exact"
866
+ else:
867
+ ny = niyama_proxy(pos, tets, t_s, TL, TS)
868
+ risk = porosity_risk(ny, mode="percentile")
869
+ niyama_source = "proxy"
870
+
871
+ shrinkage_pct = SHRINKAGE_PCT[alloy_family]
872
+ macro = macro_shrinkage(pos, tets, t_s, feed_nodes_arr, shrinkage_pct)
873
+ sinks = top_sinks(macro, pos, surface_nodes, gravity_vec)
874
+ feeders = feeder_recommendations(macro, pos, tets, surface_nodes, gravity_vec, t_s)
875
+
876
+ per_orientation = None
877
+ if orientations == "axes":
878
+ per_orientation = {}
879
+ for label, axis_vec in _AXIS_SWEEP.items():
880
+ per_orientation[label] = {
881
+ "macro_shrinkage": macro,
882
+ "top_sinks": top_sinks(macro, pos, surface_nodes, axis_vec),
883
+ "feeders": feeder_recommendations(macro, pos, tets, surface_nodes,
884
+ axis_vec, t_s),
885
+ }
886
+ elif orientations is not None:
887
+ raise ValueError(
888
+ f"screen_casting: unknown orientations mode {orientations!r} "
889
+ f"(expected None or 'axes')"
890
+ )
891
+
892
+ return {
893
+ "niyama": ny,
894
+ "porosity_risk": risk,
895
+ "niyama_source": niyama_source,
896
+ "macro_shrinkage": macro,
897
+ "top_sinks": sinks,
898
+ "feeders": feeders,
899
+ "per_orientation": per_orientation,
900
+ "material": {"alloy_family": alloy_family, "shrinkage_pct": shrinkage_pct},
901
+ }
src/splits.py ADDED
@@ -0,0 +1,61 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Reproducible train/test splits over the feature cache.
2
+
3
+ Three splits, each with an explicit train/test stem list:
4
+ interpolation — hold out (prefix,dims) groups (interpolate within families)
5
+ family — hold out whole geometry prefixes (generalize to new shapes)
6
+ fully_cooled — evaluate only on 0-adiabatic castings (real-inference regime)
7
+ """
8
+ import json
9
+ import re
10
+
11
+ import numpy as np
12
+
13
+ HELDOUT_FAMILIES = ["BO", "T45", "WH", "RR"]
14
+ _NUM_RE = re.compile(r"^\d+$")
15
+
16
+
17
+ def dims_of(stem: str) -> tuple:
18
+ return tuple(t for t in stem.split("_")[1:] if _NUM_RE.match(t))
19
+
20
+
21
+ def build_splits(meta, seed: int = 0, test_frac: float = 0.1) -> dict:
22
+ stems = list(map(str, meta["stem"]))
23
+ prefix = {s: p for s, p in zip(stems, map(str, meta["prefix"]))}
24
+ fully = {s: bool(v) for s, v in zip(stems, meta["is_fully_cooled"])}
25
+
26
+ non_family = [s for s in stems if prefix[s] not in HELDOUT_FAMILIES]
27
+ family_test = [s for s in stems if prefix[s] in HELDOUT_FAMILIES]
28
+
29
+ # interpolation: hold out groups keyed by (prefix, dims)
30
+ groups = {}
31
+ for s in non_family:
32
+ groups.setdefault((prefix[s], dims_of(s)), []).append(s)
33
+ keys = sorted(groups.keys())
34
+ rng = np.random.default_rng(seed)
35
+ rng.shuffle(keys)
36
+ n_test = max(1, int(round(len(keys) * test_frac)))
37
+ test_keys = set(keys[:n_test])
38
+
39
+ interp_train, interp_test = [], []
40
+ for k, members in groups.items():
41
+ (interp_test if k in test_keys else interp_train).extend(members)
42
+
43
+ # fully_cooled: restrict to 0-adiabatic; test = fully-cooled in the held-out groups
44
+ fc_train = [s for s in interp_train if fully[s]]
45
+ fc_test = [s for s in interp_test if fully[s]]
46
+
47
+ return {
48
+ "interpolation": {"train": sorted(interp_train), "test": sorted(interp_test)},
49
+ "family": {"train": sorted(non_family), "test": sorted(family_test)},
50
+ "fully_cooled": {"train": sorted(fc_train), "test": sorted(fc_test)},
51
+ }
52
+
53
+
54
+ def save_splits(splits: dict, path: str):
55
+ with open(path, "w") as f:
56
+ json.dump(splits, f, indent=2)
57
+
58
+
59
+ def load_splits(path: str) -> dict:
60
+ with open(path) as f:
61
+ return json.load(f)
src/step_mesh.py ADDED
@@ -0,0 +1,45 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """STEP -> tetrahedral gmsh 2.2 mesh with all external surface tagged 'Cooling'.
2
+
3
+ Uses the gmsh Python API (bundled OpenCASCADE) to import a STEP solid, tag the
4
+ volume and its full boundary, mesh uniformly, and export in the gmsh 2.2 format
5
+ consumed by src/msh_parse.py. All lengths in mm."""
6
+ import numpy as np
7
+ import gmsh
8
+
9
+
10
+ def auto_elem_size(bbox_diag: float) -> float:
11
+ """Target element size (mm): bbox diagonal / 60, the validated Phase 2A
12
+ density chosen to give training-comparable node density on real castings."""
13
+ return bbox_diag / 60.0
14
+
15
+
16
+ def mesh_step(step_path: str, out_msh: str, elem_size: float | None = None) -> dict:
17
+ gmsh.initialize()
18
+ try:
19
+ gmsh.option.setNumber("General.Terminal", 0)
20
+ gmsh.model.add("casting")
21
+ gmsh.model.occ.importShapes(step_path)
22
+ gmsh.model.occ.synchronize()
23
+
24
+ xmin, ymin, zmin, xmax, ymax, zmax = gmsh.model.getBoundingBox(-1, -1)
25
+ bbox = (xmax - xmin, ymax - ymin, zmax - zmin)
26
+ diag = float(np.linalg.norm(bbox))
27
+ size = float(elem_size) if elem_size is not None else auto_elem_size(diag)
28
+ gmsh.option.setNumber("Mesh.MeshSizeMin", size)
29
+ gmsh.option.setNumber("Mesh.MeshSizeMax", size)
30
+
31
+ vols = [t for (d, t) in gmsh.model.getEntities(3)]
32
+ surfs = [t for (d, t) in gmsh.model.getEntities(2)]
33
+ pv = gmsh.model.addPhysicalGroup(3, vols); gmsh.model.setPhysicalName(3, pv, "Volume")
34
+ pc = gmsh.model.addPhysicalGroup(2, surfs); gmsh.model.setPhysicalName(2, pc, "Cooling")
35
+
36
+ gmsh.model.mesh.generate(3)
37
+ gmsh.option.setNumber("Mesh.MshFileVersion", 2.2)
38
+ gmsh.write(out_msh)
39
+
40
+ node_tags, _, _ = gmsh.model.mesh.getNodes()
41
+ tet_tags = gmsh.model.mesh.getElementsByType(4)[0]
42
+ return {"n_nodes": len(node_tags), "n_tets": len(tet_tags),
43
+ "bbox_mm": bbox, "elem_size": size}
44
+ finally:
45
+ gmsh.finalize()
src/targets.py ADDED
@@ -0,0 +1,39 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Per-node solidification time from temperature snapshots.
2
+
3
+ For each node, find the first time its temperature crosses T_SOLIDUS
4
+ (cooling, high->low) and linearly interpolate the crossing time. Nodes that
5
+ never cross use the last simulation time. Ported from the verified logic in
6
+ the original dataset.py::_compute_y_nodal, vectorised in NumPy."""
7
+ import numpy as np
8
+
9
+ T_SOLIDUS = 1120.0
10
+
11
+
12
+ def solidification_time(temps: np.ndarray, times: np.ndarray,
13
+ t_solidus: float = T_SOLIDUS) -> np.ndarray:
14
+ temps = np.asarray(temps, dtype=np.float64) # (T, N)
15
+ times = np.asarray(times, dtype=np.float64) # (T,)
16
+ T, N = temps.shape
17
+
18
+ below = temps <= t_solidus # (T, N)
19
+ any_below = below.any(axis=0) # (N,)
20
+ first_s = below.argmax(axis=0) # (N,) first True row, 0 if none
21
+ s = np.clip(first_s, 1, None) # avoid index -1
22
+ ni = np.arange(N)
23
+
24
+ t_hi = temps[s, ni] # temp at crossing snapshot
25
+ t_lo = temps[s - 1, ni] # temp at previous snapshot
26
+ dt = t_hi - t_lo
27
+ dt = np.where(np.abs(dt) < 1e-10, -1.0, dt)
28
+ alpha = (t_solidus - t_lo) / dt
29
+ y = times[s - 1] + alpha * (times[s] - times[s - 1])
30
+
31
+ # already below at first snapshot -> time[0]
32
+ y = np.where((first_s == 0) & below[0], times[0], y)
33
+ # never crosses -> last time
34
+ y = np.where(~any_below, times[-1], y)
35
+ return y.astype(np.float32)
36
+
37
+
38
+ def global_time(y_nodal: np.ndarray) -> float:
39
+ return float(np.asarray(y_nodal).max())
src/train_ft2b.py ADDED
@@ -0,0 +1,627 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """FT2b Task 7: 3-stage staged-training driver (scale -> BC -> calibrate).
2
+
3
+ Orchestrates the already-built FT2b pieces on top of the SP1 production GNN:
4
+ - Task 4 (`src.warm_start`): zero-init FiLM/BC-column warm-start (+ the
5
+ stage-chaining `build_or_load_v4` entry point, used implicitly here since
6
+ `src.train_gnn.train`'s `init_ckpt` calls it).
7
+ - Task 5 (`src.train_gnn`): `train(...)`'s g/FiLM/freeze hooks.
8
+ - Task 6 (`src.gbm_ft2b`): `refit_gbm(...)`, the multi-alloy scale-head GBM.
9
+
10
+ Three stages, each warm-starting from the previous stage's own checkpoint
11
+ (NOT always from the original SP1 checkpoint -- see `src.warm_start.
12
+ build_or_load_v4` for how `train()`'s `init_ckpt` handles both a genuine SP1
13
+ source and an already-v4 FT2b source):
14
+
15
+ 1. **scale/alloy adaptation** -- train stems = the v4 train pool restricted
16
+ to `dataset == "A"` (all-sand, no BC variation). FiLM-only trainable
17
+ (`freeze_backbone=True`), warm-started from `sp1_ckpt`, `lr=3e-4`. Also
18
+ refits the scale-head GBM on the same stems -> `runs/ft2b_gbm/hgb.joblib`
19
+ (the artifact Task 8/9 evaluate against by default).
20
+ 2. **BC generalization** -- train stems = train pool with
21
+ `dataset in {A, B, C}` (chills / insulating & exothermic sleeves added).
22
+ Full backbone unfrozen, chained from stage 1's checkpoint, `lr=1e-4`.
23
+ 3. **calibration** -- train stems = train pool rows that are either
24
+ named-grade (`alloy_family != "synthetic"`) or `dataset == "D"` (real
25
+ parts). Only the last 2 processor blocks + `scale_head` trainable
26
+ (`freeze_mode="all_but_last2"`), chained from stage 2's checkpoint,
27
+ `lr=1e-4`. Refits the GBM again on the calibration stems into a
28
+ SEPARATE artifact, `runs/ft2b_gbm/hgb_stage3.joblib` -- `hgb.joblib`
29
+ from stage 1 is intentionally left untouched; Task 8/9's evaluation
30
+ decides which of the two GBMs the final hybrid predictor should use.
31
+
32
+ Reality check this driver must survive: in the actual FT2a/FT2b DOE
33
+ manifest + `data/features_v4/splits.json`, named-grade and dataset-D rows
34
+ are ENTIRELY reserved for validation (`val_interp`/`val_extrap`) -- none are
35
+ in the train pool. Stage 3 can therefore legitimately have zero train stems.
36
+ When that happens this driver logs loudly and SKIPS stage 3 (no crash, no
37
+ silent no-op train() call, no stage-3 GBM refit): `paths["final_ckpt"]` falls
38
+ back to stage 2's checkpoint.
39
+
40
+ All early-stopping/checkpoint-selection across all 3 stages uses the same
41
+ `extrap.test` split (`early_stop_val_stems`), per the FINETUNING plan.
42
+
43
+ Artifact isolation: this module writes ONLY under
44
+ `runs/ft2b_stage{1,2,3}/` and `runs/ft2b_gbm/` (all nested under `out_root`,
45
+ default `"runs"`).
46
+ """
47
+ from __future__ import annotations
48
+
49
+ import csv
50
+ import json
51
+ import logging
52
+ import os
53
+ import os.path as osp
54
+
55
+ import torch
56
+
57
+ from src.gbm_ft2b import refit_gbm
58
+ from src.train_gnn import train
59
+
60
+ logger = logging.getLogger(__name__)
61
+
62
+ DEFAULT_OUT_ROOT = "runs"
63
+
64
+ # Loss config the FT2b plan mandates for every stage (train()'s own defaults
65
+ # are graph="knn", global_weight=0.0 -- both wrong for FT2b, so pinned here
66
+ # rather than relying on train()'s defaults ever staying in sync).
67
+ _LOSS_DEFAULTS = {"graph": "union", "global_weight": 0.3, "feature_version": 4}
68
+
69
+ _STAGE1_DATASETS = {"A"}
70
+ _STAGE2_DATASETS = {"A", "B", "C"}
71
+
72
+
73
+ def _load_manifest_rows(manifest_path: str) -> dict:
74
+ with open(manifest_path, newline="") as f:
75
+ return {row["run_id"]: row for row in csv.DictReader(f)}
76
+
77
+
78
+ def _load_splits(features_dir: str) -> dict:
79
+ with open(osp.join(features_dir, "splits.json")) as f:
80
+ return json.load(f)
81
+
82
+
83
+ def _stage1_stems(train_pool, manifest_rows, manifest_path: str = None) -> list:
84
+ """dataset == A (all-sand, no BC/sleeve/chill variation).
85
+
86
+ Raises KeyError if any stem in train_pool is missing from manifest_rows.
87
+ """
88
+ missing = [s for s in train_pool if s not in manifest_rows]
89
+ if missing:
90
+ msg = f"Missing from manifest"
91
+ if manifest_path:
92
+ msg += f" {manifest_path}"
93
+ msg += f": {missing}"
94
+ raise KeyError(msg)
95
+ return [s for s in train_pool
96
+ if manifest_rows[s].get("dataset") in _STAGE1_DATASETS]
97
+
98
+
99
+ def _stage2_stems(train_pool, manifest_rows, manifest_path: str = None) -> list:
100
+ """dataset in {A, B, C} (adds chills + sleeve BC variation).
101
+
102
+ Raises KeyError if any stem in train_pool is missing from manifest_rows.
103
+ """
104
+ missing = [s for s in train_pool if s not in manifest_rows]
105
+ if missing:
106
+ msg = f"Missing from manifest"
107
+ if manifest_path:
108
+ msg += f" {manifest_path}"
109
+ msg += f": {missing}"
110
+ raise KeyError(msg)
111
+ return [s for s in train_pool
112
+ if manifest_rows[s].get("dataset") in _STAGE2_DATASETS]
113
+
114
+
115
+ def _stage3_stems(train_pool, manifest_rows, manifest_path: str = None) -> list:
116
+ """Named-grade rows (any dataset) or dataset == D (real parts).
117
+
118
+ Raises KeyError if any stem in train_pool is missing from manifest_rows.
119
+ """
120
+ missing = [s for s in train_pool if s not in manifest_rows]
121
+ if missing:
122
+ msg = f"Missing from manifest"
123
+ if manifest_path:
124
+ msg += f" {manifest_path}"
125
+ msg += f": {missing}"
126
+ raise KeyError(msg)
127
+ stems = []
128
+ for s in train_pool:
129
+ row = manifest_rows[s]
130
+ if row.get("alloy_family", "synthetic") != "synthetic" or row.get("dataset") == "D":
131
+ stems.append(s)
132
+ return stems
133
+
134
+
135
+ def run_ft2b(features_dir, manifest, sp1_ckpt, out_root: str = DEFAULT_OUT_ROOT,
136
+ epochs: int = 200, lr_stage1: float = 3e-4, lr_stage2: float = 1e-4,
137
+ lr_stage3: float = 1e-4, train_kwargs: dict | None = None,
138
+ gbm_kwargs: dict | None = None) -> dict:
139
+ """Run the 3-stage FT2b staged training, returning a dict of output paths.
140
+
141
+ Args:
142
+ features_dir: v4 feature cache directory (must contain `splits.json`
143
+ with `{"interp": {"train": [...], "test": [...]},
144
+ "extrap": {"train": [...], "test": [...]}}`; `interp.train` ==
145
+ `extrap.train`, the single training pool).
146
+ manifest: path to the DOE manifest CSV (`run_id` == stem; needs
147
+ `dataset` and `alloy_family` columns to partition stems by stage).
148
+ sp1_ckpt: path to the production SP1 checkpoint (node_in=13, no
149
+ FiLM) that stage 1 warm-starts from.
150
+ out_root: base directory; stage dirs and the GBM dir are created
151
+ under it (`<out_root>/ft2b_stage{1,2,3}/`, `<out_root>/ft2b_gbm/`).
152
+ epochs: shared epoch budget passed to every `train()` call (early
153
+ stopping/patience are `train()`'s own defaults unless overridden
154
+ via `train_kwargs`).
155
+ lr_stage{1,2,3}: per-stage learning rate.
156
+ train_kwargs: extra kwargs forwarded to every `train()` call (e.g.
157
+ `n_blocks`, `hidden`, `batch_size`, `patience`, `num_workers`,
158
+ `device`) -- must NOT include `lr` (set via `lr_stage*` instead)
159
+ or the stage-specific wiring args (`init_ckpt`, `freeze_backbone`,
160
+ `freeze_mode`, `out_dir`, `early_stop_val_stems`, `film`), which
161
+ this function always sets itself. `graph`/`global_weight`/
162
+ `feature_version` default to the FT2b plan's values but may be
163
+ overridden here.
164
+ gbm_kwargs: extra kwargs forwarded to every `refit_gbm()` call (e.g.
165
+ `max_iter`, `min_samples_leaf`, `holdout_frac`).
166
+
167
+ Returns:
168
+ dict with `stage{1,2,3}_dir`, `stage{1,2,3}_ckpt` (stage 3's may be
169
+ `None` if it was skipped -- see module docstring), `final_ckpt` (the
170
+ last stage actually run), `gbm_stage1_path`/`gbm_stage1_metrics`,
171
+ and `gbm_stage3_path`/`gbm_stage3_metrics` (also `None` if stage 3
172
+ was skipped).
173
+ """
174
+ train_kwargs = dict(train_kwargs or {})
175
+ for k, v in _LOSS_DEFAULTS.items():
176
+ train_kwargs.setdefault(k, v)
177
+ train_kwargs.setdefault("epochs", epochs)
178
+ gbm_kwargs = dict(gbm_kwargs or {})
179
+
180
+ manifest_rows = _load_manifest_rows(manifest)
181
+ splits = _load_splits(features_dir)
182
+ train_pool_interp = splits["interp"]["train"]
183
+ train_pool_extrap = splits["extrap"]["train"]
184
+
185
+ # Guard: interp and extrap train pools must be identical
186
+ if train_pool_interp != train_pool_extrap:
187
+ raise ValueError(
188
+ f"interp['train'] and extrap['train'] must be identical "
189
+ f"(single training pool assumption). Found {len(train_pool_interp)} "
190
+ f"interp and {len(train_pool_extrap)} extrap stems with "
191
+ f"{len(set(train_pool_interp) - set(train_pool_extrap))} only-in-interp "
192
+ f"and {len(set(train_pool_extrap) - set(train_pool_interp))} only-in-extrap.")
193
+
194
+ train_pool = train_pool_interp
195
+ val_stems = splits["interp"]["test"]
196
+ early_stop_val_stems = splits["extrap"]["test"]
197
+
198
+ gbm_dir = osp.join(out_root, "ft2b_gbm")
199
+ os.makedirs(gbm_dir, exist_ok=True)
200
+
201
+ paths: dict = {}
202
+
203
+ # ---- Stage 1: scale/alloy adaptation (FiLM only, warm-start from SP1) ----
204
+ stage1_dir = osp.join(out_root, "ft2b_stage1")
205
+ stage1_stems = _stage1_stems(train_pool, manifest_rows, manifest_path=manifest)
206
+ logger.info("FT2b stage 1 (scale/alloy, dataset=A): %d train stems", len(stage1_stems))
207
+ if not stage1_stems:
208
+ raise RuntimeError(
209
+ "FT2b stage 1: 0 train stems resolved for dataset=A in the train "
210
+ "pool -- cannot proceed (check the manifest/splits inputs)")
211
+ stage1_result = train(
212
+ features_dir, stage1_stems, val_stems, out_dir=stage1_dir, lr=lr_stage1,
213
+ film=True, init_ckpt=sp1_ckpt, freeze_backbone=True,
214
+ early_stop_val_stems=early_stop_val_stems, **train_kwargs,
215
+ )
216
+ paths["stage1_dir"] = stage1_dir
217
+ paths["stage1_ckpt"] = stage1_result["ckpt"]
218
+ paths["final_ckpt"] = paths["stage1_ckpt"]
219
+
220
+ hgb_path = osp.join(gbm_dir, "hgb.joblib")
221
+ paths["gbm_stage1_metrics"] = refit_gbm(
222
+ features_dir=features_dir, train_stems=stage1_stems,
223
+ manifest_path=manifest, out_path=hgb_path, **gbm_kwargs)
224
+ paths["gbm_stage1_path"] = hgb_path
225
+
226
+ # ---- Stage 2: BC generalization (full backbone, chained from stage 1) ----
227
+ stage2_dir = osp.join(out_root, "ft2b_stage2")
228
+ stage2_stems = _stage2_stems(train_pool, manifest_rows, manifest_path=manifest)
229
+ logger.info("FT2b stage 2 (BC, dataset in A/B/C): %d train stems", len(stage2_stems))
230
+ if not stage2_stems:
231
+ raise RuntimeError(
232
+ "FT2b stage 2: 0 train stems resolved for dataset in {A,B,C} in "
233
+ "the train pool -- cannot proceed (check the manifest/splits inputs)")
234
+ stage2_result = train(
235
+ features_dir, stage2_stems, val_stems, out_dir=stage2_dir, lr=lr_stage2,
236
+ film=True, init_ckpt=paths["stage1_ckpt"], freeze_backbone=False,
237
+ early_stop_val_stems=early_stop_val_stems, **train_kwargs,
238
+ )
239
+ paths["stage2_dir"] = stage2_dir
240
+ paths["stage2_ckpt"] = stage2_result["ckpt"]
241
+ paths["final_ckpt"] = paths["stage2_ckpt"]
242
+
243
+ # ---- Stage 3: calibration (named-grade + real parts) -- may be empty ----
244
+ stage3_stems = _stage3_stems(train_pool, manifest_rows, manifest_path=manifest)
245
+ logger.info("FT2b stage 3 (calibrate, named-grade + dataset=D): %d train stems",
246
+ len(stage3_stems))
247
+ if not stage3_stems:
248
+ logger.warning(
249
+ "FT2b stage 3 SKIPPED: 0 train stems resolved for named-grade/"
250
+ "dataset-D rows in the train pool (they may be entirely held out "
251
+ "for validation in this manifest/splits, as in the production "
252
+ "FT2a v4 cache). Final checkpoint stays stage 2's: %s",
253
+ paths["stage2_ckpt"])
254
+ paths["stage3_dir"] = None
255
+ paths["stage3_ckpt"] = None
256
+ paths["gbm_stage3_path"] = None
257
+ paths["gbm_stage3_metrics"] = None
258
+ return paths
259
+
260
+ stage3_dir = osp.join(out_root, "ft2b_stage3")
261
+ stage3_result = train(
262
+ features_dir, stage3_stems, val_stems, out_dir=stage3_dir, lr=lr_stage3,
263
+ film=True, init_ckpt=paths["stage2_ckpt"], freeze_mode="all_but_last2",
264
+ early_stop_val_stems=early_stop_val_stems, **train_kwargs,
265
+ )
266
+ paths["stage3_dir"] = stage3_dir
267
+ paths["stage3_ckpt"] = stage3_result["ckpt"]
268
+ paths["final_ckpt"] = paths["stage3_ckpt"]
269
+
270
+ hgb_stage3_path = osp.join(gbm_dir, "hgb_stage3.joblib")
271
+ paths["gbm_stage3_metrics"] = refit_gbm(
272
+ features_dir=features_dir, train_stems=stage3_stems,
273
+ manifest_path=manifest, out_path=hgb_stage3_path, **gbm_kwargs)
274
+ paths["gbm_stage3_path"] = hgb_stage3_path
275
+
276
+ return paths
277
+
278
+
279
+ def run_stage2b(features_dir, manifest, stage1_ckpt, out_dir: str = "runs/ft2b_stage2b",
280
+ oversample_exo: int = 3, lr: float = 1e-4, epochs: int = 200,
281
+ train_kwargs: dict | None = None) -> dict:
282
+ """Exo-channel remediation: repeat FT2b stage 2 (BC generalization) on the
283
+ fixed feature cache, oversampling exo-sleeve stems so the (previously
284
+ corrupted) exothermic-sleeve BC channel gets proportionally more gradient
285
+ signal than its raw row count in the train pool would give it.
286
+
287
+ Same stem selection as `run_ft2b`'s stage 2 (`_stage2_stems`: dataset in
288
+ {A, B, C}), then the train list actually passed to `train()` is that
289
+ stem list PLUS `(oversample_exo - 1)` extra copies of every stem whose
290
+ manifest row has `bc_config == "exo_sleeve"` (duplicates appended after
291
+ the base list, so ordering is deterministic).
292
+
293
+ Chains from `stage1_ckpt` (a stage-1 FT2b checkpoint, NOT the original
294
+ SP1 checkpoint), full backbone unfrozen (`freeze_backbone=False`), same
295
+ as `run_ft2b`'s stage 2. Early-stop/checkpoint-selection uses
296
+ `extrap.test`, same as every other FT2b stage.
297
+
298
+ Args:
299
+ features_dir: v4 feature cache directory (must contain `splits.json`
300
+ with `interp.train == extrap.train`, the single training pool).
301
+ manifest: path to the DOE manifest CSV (`run_id` == stem; needs
302
+ `dataset` and `bc_config` columns).
303
+ stage1_ckpt: path to the checkpoint this run warm-starts/chains from.
304
+ out_dir: directory `train()` writes its checkpoint into.
305
+ oversample_exo: total multiplicity for each exo-sleeve stem in the
306
+ train list (1 = no oversampling; the base stem is always
307
+ present once, plus `oversample_exo - 1` duplicates).
308
+ lr: learning rate passed to `train()`.
309
+ epochs: epoch budget passed to `train()` (overridden by
310
+ `train_kwargs["epochs"]` if given there).
311
+ train_kwargs: extra kwargs forwarded to `train()` -- must NOT
312
+ include `lr` or the stage-wiring args (`init_ckpt`,
313
+ `freeze_backbone`, `out_dir`, `early_stop_val_stems`, `film`,
314
+ `val_stems`), which this function always sets itself. `epochs` IS
315
+ overridable via train_kwargs["epochs"] (setdefault).
316
+ `graph`/`global_weight`/`feature_version` default to the FT2b
317
+ plan's values (`_LOSS_DEFAULTS`) but may be overridden here.
318
+
319
+ Returns:
320
+ dict with `ckpt` (the trained checkpoint path), `best_val` (from
321
+ `train()`'s return), `n_train` (length of the train list, including
322
+ oversampled duplicates), and `n_exo` (count of distinct exo-sleeve
323
+ stems that were oversampled).
324
+ """
325
+ train_kwargs = dict(train_kwargs or {})
326
+ for k, v in _LOSS_DEFAULTS.items():
327
+ train_kwargs.setdefault(k, v)
328
+ train_kwargs.setdefault("epochs", epochs)
329
+
330
+ manifest_rows = _load_manifest_rows(manifest)
331
+ splits = _load_splits(features_dir)
332
+ train_pool_interp = splits["interp"]["train"]
333
+ train_pool_extrap = splits["extrap"]["train"]
334
+
335
+ # Guard: interp and extrap train pools must be identical
336
+ if train_pool_interp != train_pool_extrap:
337
+ raise ValueError(
338
+ f"interp['train'] and extrap['train'] must be identical "
339
+ f"(single training pool assumption). Found {len(train_pool_interp)} "
340
+ f"interp and {len(train_pool_extrap)} extrap stems with "
341
+ f"{len(set(train_pool_interp) - set(train_pool_extrap))} only-in-interp "
342
+ f"and {len(set(train_pool_extrap) - set(train_pool_interp))} only-in-extrap.")
343
+
344
+ train_pool = train_pool_interp
345
+ val_stems = splits["interp"]["test"]
346
+ early_stop_val_stems = splits["extrap"]["test"]
347
+
348
+ stage2_stems = _stage2_stems(train_pool, manifest_rows, manifest_path=manifest)
349
+ exo_stems = [s for s in stage2_stems if manifest_rows[s].get("bc_config") == "exo_sleeve"]
350
+ train_list = list(stage2_stems) + exo_stems * (oversample_exo - 1)
351
+ logger.info(
352
+ "FT2b stage 2b (exo-oversample, dataset in A/B/C): %d train stems "
353
+ "(%d distinct exo-sleeve, oversampled x%d)",
354
+ len(train_list), len(exo_stems), oversample_exo)
355
+
356
+ result = train(
357
+ features_dir, train_list, val_stems=val_stems, out_dir=out_dir, lr=lr,
358
+ film=True, init_ckpt=stage1_ckpt, freeze_backbone=False,
359
+ early_stop_val_stems=early_stop_val_stems, **train_kwargs,
360
+ )
361
+
362
+ return {
363
+ "ckpt": result["ckpt"],
364
+ "best_val": result["best_val"],
365
+ "n_train": len(train_list),
366
+ "n_exo": len(exo_stems),
367
+ }
368
+
369
+
370
+ def run_ft3(features_dir, manifest, base_ckpt: str = "runs/ft2b_stage2c/ckpt_ep054.pt",
371
+ out_dir: str = "runs/ft3_stage1", oversample_e: int = 3, lr: float = 5e-5,
372
+ epochs: int = 200, train_kwargs: dict | None = None) -> dict:
373
+ """FT3: frozen-norm fine-tune of the production v4 checkpoint onto the new
374
+ dataset-E (multi-body assembly) rows, mixed into the existing FT2b stage-2
375
+ train pool (dataset in {A, B, C}) with dataset-E stems oversampled.
376
+
377
+ Loads `base_ckpt`'s own `norm` (the production checkpoint's frozen
378
+ feature/target normalization -- recomputing norm_stats from the mixed
379
+ A+B+C+E pool would shift every standardization the checkpoint was
380
+ trained with) and forwards it to `train()` as `norm_override`, so every
381
+ SolidGraphDataset construction inside `train()` (train/val/early-stop-val)
382
+ standardizes against the SAME stats the checkpoint already expects.
383
+
384
+ Train stems = `_stage2_stems` (A+B+C ∩ train pool, same selection as
385
+ FT2b stage 2/2b) PLUS dataset-E stems in the train pool, each repeated
386
+ `oversample_e` times. Unlike `run_stage2b`'s exo-sleeve subset (already
387
+ present once inside its A/B/C stage-2 selection, so it tops up with
388
+ `oversample_exo - 1` extra copies), dataset "E" is NOT one of
389
+ `_stage2_stems`'s selected datasets ({A, B, C}) -- so E stems are
390
+ entirely new content here, added as a straight `x oversample_e` (not
391
+ `x (oversample_e - 1)`).
392
+
393
+ Chains from `base_ckpt` (`init_ckpt=base_ckpt`), full backbone unfrozen
394
+ (`freeze_backbone=False` -- FT3 fine-tunes the whole model, not just
395
+ FiLM), `film=True`, `save_every_epoch=True` (per-epoch checkpoints for
396
+ gate-metric checkpoint selection, mirroring the exo-fix stage-2c
397
+ precedent). Early-stop/checkpoint-selection uses `extrap.test`, same as
398
+ every other FT2b/FT3 stage; the `run_ft2b`/`run_stage2b` interp==extrap
399
+ train-pool guard applies here too (Task 4's `extend_splits_with_e` keeps
400
+ it true by extending both pools equally with E-train stems).
401
+
402
+ Args:
403
+ features_dir: v4 feature cache directory (`splits.json` with
404
+ `interp.train == extrap.train`, the single training pool,
405
+ extended with dataset-E train stems by Task 4's
406
+ `extend_splits_with_e`).
407
+ manifest: path to the DOE manifest CSV (`run_id` == stem; needs a
408
+ `dataset` column, with `"E"` rows for the new assembly castings).
409
+ base_ckpt: the production checkpoint this run warm-starts/chains
410
+ from AND whose `norm` is frozen for training (default: the FT2b
411
+ exo-fix production checkpoint, `runs/ft2b_stage2c/ckpt_ep054.pt`).
412
+ out_dir: directory `train()` writes its checkpoints into.
413
+ oversample_e: total multiplicity for each dataset-E train stem in the
414
+ train list (1 = no oversampling).
415
+ lr: learning rate passed to `train()`.
416
+ epochs: epoch budget passed to `train()` (overridden by
417
+ `train_kwargs["epochs"]` if given there).
418
+ train_kwargs: extra kwargs forwarded to `train()` -- must NOT include
419
+ `lr`, `norm_override`, or the stage-wiring args (`init_ckpt`,
420
+ `freeze_backbone`, `out_dir`, `early_stop_val_stems`, `film`,
421
+ `val_stems`, `save_every_epoch`), which this function always sets
422
+ itself. `epochs` IS overridable via `train_kwargs["epochs"]`
423
+ (setdefault); same for `batch_size` (default 2 to prevent OOM: train()'s
424
+ own default batch_size=8 OOMs the RTX 3060 12GB, incident 2026-07-31 FT3
425
+ execution) and `num_workers` (default 4). Explicit caller values in
426
+ `train_kwargs` override all defaults. `graph`/`global_weight`/
427
+ `feature_version` default to the FT2b plan's values (`_LOSS_DEFAULTS`)
428
+ but may be overridden here.
429
+
430
+ Returns:
431
+ dict with `ckpt` (the trained checkpoint path), `best_val` (from
432
+ `train()`'s return), `n_train` (length of the train list, including
433
+ oversampled E duplicates), and `n_e` (count of distinct dataset-E
434
+ stems that were oversampled).
435
+ """
436
+ train_kwargs = dict(train_kwargs or {})
437
+ train_kwargs.setdefault("batch_size", 2)
438
+ train_kwargs.setdefault("num_workers", 4)
439
+ for k, v in _LOSS_DEFAULTS.items():
440
+ train_kwargs.setdefault(k, v)
441
+ train_kwargs.setdefault("epochs", epochs)
442
+
443
+ base_norm = torch.load(base_ckpt, map_location="cpu")["norm"]
444
+
445
+ manifest_rows = _load_manifest_rows(manifest)
446
+ splits = _load_splits(features_dir)
447
+ train_pool_interp = splits["interp"]["train"]
448
+ train_pool_extrap = splits["extrap"]["train"]
449
+
450
+ # Guard: interp and extrap train pools must be identical
451
+ if train_pool_interp != train_pool_extrap:
452
+ raise ValueError(
453
+ f"interp['train'] and extrap['train'] must be identical "
454
+ f"(single training pool assumption). Found {len(train_pool_interp)} "
455
+ f"interp and {len(train_pool_extrap)} extrap stems with "
456
+ f"{len(set(train_pool_interp) - set(train_pool_extrap))} only-in-interp "
457
+ f"and {len(set(train_pool_extrap) - set(train_pool_interp))} only-in-extrap.")
458
+
459
+ train_pool = train_pool_interp
460
+ val_stems = splits["interp"]["test"]
461
+ early_stop_val_stems = splits["extrap"]["test"]
462
+
463
+ stage2_stems = _stage2_stems(train_pool, manifest_rows, manifest_path=manifest)
464
+ e_train_stems = [s for s in train_pool if manifest_rows[s].get("dataset") == "E"]
465
+ train_list = list(stage2_stems) + e_train_stems * oversample_e
466
+ logger.info(
467
+ "FT3 (assembly fine-tune, dataset A/B/C + E oversampled x%d): %d train "
468
+ "stems (%d distinct dataset-E)", oversample_e, len(train_list), len(e_train_stems))
469
+
470
+ result = train(
471
+ features_dir, train_list, val_stems=val_stems, out_dir=out_dir, lr=lr,
472
+ film=True, init_ckpt=base_ckpt, freeze_backbone=False,
473
+ early_stop_val_stems=early_stop_val_stems, save_every_epoch=True,
474
+ norm_override=base_norm, **train_kwargs,
475
+ )
476
+
477
+ return {
478
+ "ckpt": result["ckpt"],
479
+ "best_val": result["best_val"],
480
+ "n_train": len(train_list),
481
+ "n_e": len(e_train_stems),
482
+ }
483
+
484
+
485
+ # Loss config for bc-reach (Task 3): same graph/global_weight convention as
486
+ # FT2b/FT3, but `feature_version=5` (the 20-column BC-reach cache, Task 1/2)
487
+ # instead of FT2b/FT3's 4.
488
+ _BCR_LOSS_DEFAULTS = {"graph": "union", "global_weight": 0.3, "feature_version": 5}
489
+
490
+
491
+ def run_bcr(features_dir, manifest, base_ckpt: str = "runs/ft3_stage1/ckpt_ep021.pt",
492
+ out_dir: str = "runs/bcr_stage1", oversample_e: int = 3, lr: float = 5e-5,
493
+ epochs: int = 200, train_kwargs: dict | None = None) -> dict:
494
+ """bc-reach: warm-started fine-tune of the production v4 checkpoint onto
495
+ the v5 (20-column, BC-reach) feature cache -- node_in 15 -> 20.
496
+
497
+ Same stem assembly as `run_ft3` (train stems = `_stage2_stems`, A+B+C ∩
498
+ train pool, PLUS dataset-E train-pool stems each repeated `oversample_e`
499
+ times), the same `film=True`/`freeze_backbone=False`/`save_every_epoch=True`
500
+ wiring, and the same `extrap.test` early-stop/checkpoint-selection
501
+ convention as every other FT2b/FT3/bc-reach stage.
502
+
503
+ UNLIKE `run_ft3`, this does NOT pass `norm_override`: `run_ft3` freezes
504
+ the fine-tune onto its `base_ckpt`'s own (15-wide) norm because it keeps
505
+ `node_in` unchanged (15 -> 15). Here `node_in` genuinely changes (15 ->
506
+ 20, Task 1/2's 5 new BC-reach columns), so `base_ckpt`'s 15-wide norm
507
+ stats cannot standardize the v5 cache's 20-wide `X` at all -- `train()`
508
+ is left to load `features_dir`'s OWN fresh `norm_stats.npz` (Task 2's
509
+ `build_v5` output), the default `norm_override=None` behavior.
510
+
511
+ `train()`'s own `init_ckpt` dispatch (see its docstring) routes this
512
+ warm-start through `src.warm_start.build_or_load_v5` instead of
513
+ `build_or_load_v4`, because the actual v5 cache data has `node_in=20`:
514
+ `base_ckpt` (`node_in=15`, film=True -- e.g. the production
515
+ `ckpt_ep021.pt`) has its node encoder's first Linear rebuilt for
516
+ `in_features=20` (existing 15 columns' weights copied, the 5 new
517
+ trailing columns zero-init, bias copied verbatim); every other weight
518
+ (edge_encoder, processor blocks, FiLM, decoder, scale_head) is
519
+ strict-copied. The resulting checkpoint's `cfg` therefore carries
520
+ `node_in=20` (computed by `train()` from the actual data, same as every
521
+ other stage) and `feature_version=5` (via `_BCR_LOSS_DEFAULTS` below).
522
+
523
+ Args:
524
+ features_dir: v5 feature cache directory (`splits.json` with
525
+ `interp.train == extrap.train`, the single training pool --
526
+ Task 2's `build_v5` copies this verbatim from the v4 cache, so
527
+ it already carries the same E-train stems FT3's
528
+ `extend_splits_with_e` added).
529
+ manifest: path to the DOE manifest CSV (`run_id` == stem; needs a
530
+ `dataset` column, with `"E"` rows for the assembly castings).
531
+ base_ckpt: the v4 (node_in=15, film=True) checkpoint this run warm-
532
+ starts from (default: the production FT3 checkpoint,
533
+ `runs/ft3_stage1/ckpt_ep021.pt`).
534
+ out_dir: directory `train()` writes its checkpoints into.
535
+ oversample_e: total multiplicity for each dataset-E train stem in the
536
+ train list (1 = no oversampling), same convention as `run_ft3`.
537
+ lr: learning rate passed to `train()` (default `5e-5`, per the
538
+ bc-reach plan).
539
+ epochs: epoch budget passed to `train()` (overridden by
540
+ `train_kwargs["epochs"]` if given there); `train()`'s own
541
+ `patience=25` default (checked against `extrap.test`, via
542
+ `early_stop_val_stems`) is left untouched, per the bc-reach plan
543
+ ("epochs<=200 patience 25 on extrap.test").
544
+ train_kwargs: extra kwargs forwarded to `train()` -- must NOT
545
+ include `lr`, `norm_override`, or the stage-wiring args
546
+ (`init_ckpt`, `freeze_backbone`, `out_dir`,
547
+ `early_stop_val_stems`, `film`, `val_stems`, `save_every_epoch`),
548
+ which this function always sets itself. `epochs` IS overridable
549
+ via `train_kwargs["epochs"]` (setdefault); same for `batch_size`
550
+ (default 2, mirroring FT3's OOM-safe pin) and `num_workers`
551
+ (default 4). Explicit caller values in `train_kwargs` override
552
+ all defaults. `graph`/`global_weight`/`feature_version` default
553
+ to `_BCR_LOSS_DEFAULTS` but may be overridden here. Every other
554
+ disallowed key above fails loudly on its own (a plain `TypeError`
555
+ from `train()`'s call below, "got multiple values for keyword
556
+ argument") since this function also passes it explicitly;
557
+ `norm_override` has no such corresponding explicit call kwarg to
558
+ collide with (this function never passes it at all), so it is
559
+ guarded by an explicit `ValueError` instead (review round 1, F1).
560
+
561
+ Returns:
562
+ dict with `ckpt` (the trained checkpoint path), `best_val` (from
563
+ `train()`'s return), `n_train` (length of the train list, including
564
+ oversampled E duplicates), and `n_e` (count of distinct dataset-E
565
+ stems that were oversampled).
566
+ """
567
+ train_kwargs = dict(train_kwargs or {})
568
+ train_kwargs.setdefault("batch_size", 2)
569
+ train_kwargs.setdefault("num_workers", 4)
570
+ for k, v in _BCR_LOSS_DEFAULTS.items():
571
+ train_kwargs.setdefault(k, v)
572
+ train_kwargs.setdefault("epochs", epochs)
573
+ # F1 (review round 1): every OTHER pinned kwarg (init_ckpt, freeze_backbone,
574
+ # out_dir, early_stop_val_stems, film, val_stems, save_every_epoch, lr) already
575
+ # fails loudly if train_kwargs duplicates it -- train()'s call below raises a
576
+ # plain TypeError ("got multiple values for keyword argument"). norm_override
577
+ # is the ONE pinned divergence from run_ft3 (this driver deliberately never
578
+ # passes it -- v5's fresh norm_stats.npz, not the 15-wide base_ckpt's, must be
579
+ # what train() standardizes against) that has NO corresponding call kwarg to
580
+ # collide with, so it would otherwise slip through train_kwargs silently and
581
+ # freeze the wrong (15-wide, base_ckpt) normalization onto the v5 run. Guard
582
+ # it explicitly, matching the loud-fail convention every other pinned kwarg
583
+ # already gets for free.
584
+ if "norm_override" in train_kwargs:
585
+ raise ValueError(
586
+ "run_bcr: train_kwargs must not include 'norm_override' -- v5 uses "
587
+ "its own fresh norm_stats.npz (Task 2's build_v5), never a frozen "
588
+ "norm from base_ckpt (unlike run_ft3's frozen-norm fine-tune).")
589
+
590
+ manifest_rows = _load_manifest_rows(manifest)
591
+ splits = _load_splits(features_dir)
592
+ train_pool_interp = splits["interp"]["train"]
593
+ train_pool_extrap = splits["extrap"]["train"]
594
+
595
+ # Guard: interp and extrap train pools must be identical
596
+ if train_pool_interp != train_pool_extrap:
597
+ raise ValueError(
598
+ f"interp['train'] and extrap['train'] must be identical "
599
+ f"(single training pool assumption). Found {len(train_pool_interp)} "
600
+ f"interp and {len(train_pool_extrap)} extrap stems with "
601
+ f"{len(set(train_pool_interp) - set(train_pool_extrap))} only-in-interp "
602
+ f"and {len(set(train_pool_extrap) - set(train_pool_interp))} only-in-extrap.")
603
+
604
+ train_pool = train_pool_interp
605
+ val_stems = splits["interp"]["test"]
606
+ early_stop_val_stems = splits["extrap"]["test"]
607
+
608
+ stage2_stems = _stage2_stems(train_pool, manifest_rows, manifest_path=manifest)
609
+ e_train_stems = [s for s in train_pool if manifest_rows[s].get("dataset") == "E"]
610
+ train_list = list(stage2_stems) + e_train_stems * oversample_e
611
+ logger.info(
612
+ "bc-reach (node_in 15->20, dataset A/B/C + E oversampled x%d): %d train "
613
+ "stems (%d distinct dataset-E)", oversample_e, len(train_list), len(e_train_stems))
614
+
615
+ result = train(
616
+ features_dir, train_list, val_stems=val_stems, out_dir=out_dir, lr=lr,
617
+ film=True, init_ckpt=base_ckpt, freeze_backbone=False,
618
+ early_stop_val_stems=early_stop_val_stems, save_every_epoch=True,
619
+ **train_kwargs,
620
+ )
621
+
622
+ return {
623
+ "ckpt": result["ckpt"],
624
+ "best_val": result["best_val"],
625
+ "n_train": len(train_list),
626
+ "n_e": len(e_train_stems),
627
+ }
src/train_gnn.py ADDED
@@ -0,0 +1,300 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """GNN training: loss, training loop, checkpointing. Trains on standardized
2
+ log1p targets with rotation augmentation; validates on a held-out fraction."""
3
+ import torch
4
+ import torch.nn.functional as F
5
+ from torch_geometric.utils import scatter
6
+
7
+
8
+ def gnn_loss(pred, target, batch, spatial_weight: float = 0.3, global_weight: float = 0.0):
9
+ base = F.smooth_l1_loss(pred, target)
10
+ pm = scatter(pred, batch, dim=0, reduce="mean")
11
+ tm = scatter(target, batch, dim=0, reduce="mean")
12
+ spatial = F.mse_loss(pred - pm[batch], target - tm[batch])
13
+ glob = F.smooth_l1_loss(pm, tm)
14
+ return base + spatial_weight * spatial + global_weight * glob
15
+
16
+
17
+ import os
18
+ import os.path as osp
19
+ from torch.optim import AdamW
20
+ from torch.optim.lr_scheduler import ReduceLROnPlateau
21
+ from torch_geometric.loader import DataLoader
22
+
23
+ from src.gnn_dataset import SolidGraphDataset
24
+ from src.model_gnn import SolidGNN
25
+ from src.warm_start import build_or_load_v4, build_or_load_v5
26
+
27
+ # bc-reach (Task 1/2): the v5 BC-reach feature cache's per-node width --
28
+ # FEATURE_NAMES_V4 (15) + the 5 new multi-scale BC-reach channels
29
+ # (exo_mid/exo_long/chill_short/chill_mid/chill_long, `src.features.
30
+ # FEATURE_NAMES_V5`). `train()`'s `init_ckpt` dispatch below compares the
31
+ # ACTUAL training data's own feature width (`node_in`, computed from the
32
+ # built `tr` dataset -- never the checkpoint's) against this constant to
33
+ # decide whether to warm-start through `build_or_load_v5` (rebuild the node
34
+ # encoder for 20 columns) instead of `build_or_load_v4` (the existing
35
+ # 15-column FT2b/FT3 path, unaffected). Named here (review round 1, F3)
36
+ # instead of an inline literal `20`, so the pin's provenance is legible at
37
+ # the call site.
38
+ _V5_NODE_IN = 20
39
+
40
+
41
+ def _freeze_backbone_params(model):
42
+ """Freeze everything except FiLM (used for FT2b stage-1 training: only the
43
+ per-block gamma/beta conditioning is trainable, so warm-started SP1 shape
44
+ knowledge is preserved while the model learns to respond to the material
45
+ g-vector). The new BC input columns live inside node_encoder's first
46
+ Linear, so they are frozen too in stage 1 -- they get unfrozen (along with
47
+ the rest of the backbone) in stage 2 per the FINETUNING plan's stage table.
48
+ """
49
+ for name, p in model.named_parameters():
50
+ p.requires_grad = name.startswith("films.")
51
+
52
+
53
+ # Public name for tests/other callers. `train()` below takes a same-named
54
+ # `freeze_backbone` bool parameter, which would shadow this at module scope
55
+ # inside that function's body -- so `train()` calls `_freeze_backbone_params`
56
+ # directly instead of this alias.
57
+ freeze_backbone = _freeze_backbone_params
58
+
59
+
60
+ def _freeze_all_but_last2_and_scale_head(model):
61
+ """FT2b stage-3 (calibrate) freeze scope: everything is frozen except the
62
+ LAST 2 processor blocks and the scale head -- the small, late-stage
63
+ capacity that plausibly still needs to adapt to a handful of named-grade
64
+ (steel/ductile-iron/grey-iron) + real-part calibration rows, without
65
+ letting so few rows overwrite the shape knowledge learned in stages 1-2.
66
+ FiLM is deliberately NOT included here (unlike stage 1's `freeze_backbone`,
67
+ which trains FiLM only) -- stage 3 is calibrating the backbone's late
68
+ representation + scale, not re-learning material conditioning.
69
+ """
70
+ n_blocks = len(model.blocks)
71
+ keep_prefixes = tuple(f"blocks.{i}." for i in range(max(n_blocks - 2, 0), n_blocks))
72
+ keep_prefixes = keep_prefixes + ("scale_head.",)
73
+ for name, p in model.named_parameters():
74
+ p.requires_grad = name.startswith(keep_prefixes)
75
+
76
+
77
+ # Public name, mirroring `freeze_backbone` above.
78
+ freeze_all_but_last2_and_scale_head = _freeze_all_but_last2_and_scale_head
79
+
80
+
81
+ def _run_epoch(model, loader, device, opt=None, scaler=None, spatial_weight=0.3, global_weight=0.0):
82
+ train_mode = opt is not None
83
+ model.train(train_mode)
84
+ total, n = 0.0, 0
85
+ for batch in loader:
86
+ batch = batch.to(device)
87
+ g = getattr(batch, "g", None)
88
+ if train_mode:
89
+ opt.zero_grad()
90
+ with torch.autocast(device_type=device.split(":")[0], enabled=scaler is not None):
91
+ yn, _ = model(batch.x, batch.edge_index, batch.edge_attr, batch.batch, g=g)
92
+ loss = gnn_loss(yn, batch.y, batch.batch, spatial_weight, global_weight)
93
+ if scaler is not None:
94
+ scaler.scale(loss).backward(); scaler.step(opt); scaler.update()
95
+ else:
96
+ loss.backward(); opt.step()
97
+ else:
98
+ with torch.no_grad():
99
+ yn, _ = model(batch.x, batch.edge_index, batch.edge_attr, batch.batch, g=g)
100
+ loss = gnn_loss(yn, batch.y, batch.batch, spatial_weight, global_weight)
101
+ total += float(loss) * batch.num_graphs; n += batch.num_graphs
102
+ return total / max(n, 1)
103
+
104
+
105
+ def _build_checkpoint_dict(model, norm, hidden, n_blocks, k, node_in, edge_in,
106
+ spatial_weight, global_weight, graph, feature_version,
107
+ two_head, film, g_dim):
108
+ """Build the checkpoint dict payload (same format as best_gnn.pt).
109
+
110
+ Args:
111
+ model: the SolidGNN model.
112
+ norm: normalization stats (SolidGraphDataset.norm).
113
+ All other args: hyperparameters/config to store.
114
+
115
+ Returns:
116
+ dict with keys {model, norm, cfg}.
117
+ """
118
+ return {
119
+ "model": model.state_dict(),
120
+ "norm": norm,
121
+ "cfg": {
122
+ "hidden": hidden,
123
+ "n_blocks": n_blocks,
124
+ "k": k,
125
+ "node_in": node_in,
126
+ "edge_in": edge_in,
127
+ "spatial_weight": spatial_weight,
128
+ "global_weight": global_weight,
129
+ "graph": graph,
130
+ "feature_version": feature_version,
131
+ "two_head": two_head,
132
+ "film": film,
133
+ "g_dim": g_dim,
134
+ },
135
+ }
136
+
137
+
138
+ def train(features_dir, train_stems, val_stems, epochs=200, k=16, hidden=128,
139
+ n_blocks=12, batch_size=8, lr=1e-3, device=None, out_dir="runs/gnn",
140
+ augment=True, patience=25, spatial_weight=0.3, global_weight=0.0,
141
+ graph="knn", feature_version=2, num_workers=0, two_head=False,
142
+ film=False, g_dim=5, freeze_backbone: bool = False, init_ckpt=None,
143
+ early_stop_val_stems=None, freeze_mode=None, save_every_epoch=False,
144
+ norm_override=None):
145
+ """
146
+ film: build the model with FiLM (per-block gamma/beta from data.g); forced
147
+ True when init_ckpt is given (warm-start is always onto a FiLM model).
148
+ g_dim: FiLM conditioning-vector width (ignored unless film/init_ckpt).
149
+ freeze_backbone: freeze node_encoder/edge_encoder/blocks/decoder/scale_head,
150
+ leaving only FiLM params trainable (FT2b stage-1: shape/scale knowledge
151
+ from warm-start is preserved while FiLM learns to respond to `g`).
152
+ Back-compat boolean from Task 5; equivalent to `freeze_mode="backbone"`.
153
+ freeze_mode: FT2b's more general freeze-scope switch, superset of
154
+ `freeze_backbone`. One of:
155
+ - None (default): use `freeze_backbone`'s boolean behavior (freeze
156
+ everything but FiLM if True, nothing if False) -- unchanged from
157
+ the pre-FT2b/Task-5 code path.
158
+ - "backbone": same as `freeze_backbone=True` (only FiLM trainable).
159
+ - "all_but_last2": only the last 2 processor blocks + scale_head are
160
+ trainable (FT2b stage 3 / calibrate -- see
161
+ `_freeze_all_but_last2_and_scale_head`'s docstring).
162
+ `freeze_mode` takes precedence over `freeze_backbone` when both are given.
163
+ init_ckpt: path to an SP1 checkpoint (node_in=13, no FiLM) OR an earlier
164
+ FT2b-stage checkpoint (node_in=15, FiLM already present -- stage 2
165
+ chains from stage 1's `best_gnn.pt`, stage 3 from stage 2's) to warm-
166
+ start from via `src.warm_start.build_or_load_v4`, instead of a
167
+ randomly initialized model. `build_or_load_v4` dispatches on the
168
+ checkpoint's own `cfg` (splice-from-SP1 vs. direct-load-an-already-v4-
169
+ checkpoint) -- see its docstring. Implies film=True. bc-reach: when
170
+ the ACTUAL training data (`node_in`, computed below from the built
171
+ `tr` dataset's own feature width) equals `_V5_NODE_IN` (20) -- i.e.
172
+ `features_dir` is a v5 (BC-reach) cache -- this dispatches to
173
+ `src.warm_start.build_or_load_v5` instead, which rebuilds the node
174
+ encoder's first Linear for the 5 new trailing columns (node_in 15 ->
175
+ 20) from a v4 `init_ckpt` (e.g. the production `ckpt_ep021.pt`), or
176
+ direct-loads an already-v5 checkpoint. Every other `node_in` (in
177
+ particular the existing 15-wide FT2b/FT3 path) is unaffected -- still
178
+ routed through `build_or_load_v4`, byte-identical to before this
179
+ dispatch existed.
180
+ NOTE on normalization: the checkpoint's own `norm` (the SOURCE
181
+ checkpoint's feature/target stats -- SP1's v3 stats for the original
182
+ 13->15 warm-start, or a v4 checkpoint's 15-wide stats for the
183
+ 15->20 bc-reach one) is NOT reused here -- the training data in
184
+ `features_dir` (whichever cache version it actually is: v3/v4/v5)
185
+ is normalized (by `SolidGraphDataset`) with *its own*
186
+ `norm_stats.npz` (`tr.norm`), since the model always consumes
187
+ THAT cache's own normalized features, not the source checkpoint's.
188
+ The saved checkpoint's `norm` field must match what the *data* was
189
+ actually standardized with, so we always save `tr.norm`, discarding
190
+ the `_sp1_norm` that `build_or_load_v4`/`build_or_load_v5` return
191
+ (that value exists for callers who need to reconcile the two spaces
192
+ explicitly, e.g. a sanity-gate check at warm-start time -- not for
193
+ the trained ckpt).
194
+ early_stop_val_stems: if given, checkpoint-selection/early-stopping use a
195
+ loss computed on this stem list instead of `val_stems` (e.g. a
196
+ val_extrap split held out for OOD generalization signal). When None,
197
+ behavior is identical to the pre-FT2b code path (single val set/loader
198
+ serving both roles).
199
+ save_every_epoch: if True, save a checkpoint to `<out_dir>/ckpt_ep{ep:03d}.pt`
200
+ after EVERY epoch (in the same format as best_gnn.pt: {model, norm, cfg}),
201
+ in addition to the unchanged best_gnn.pt logic. Default False preserves
202
+ byte-identical behavior (no ckpt_ep* files).
203
+ norm_override: if given (a norm dict, e.g. a production checkpoint's own
204
+ `ckpt["norm"]"), freeze normalization to it instead of computing/loading
205
+ the feature cache's own `norm_stats.npz` (FT3: fine-tuning must not
206
+ shift any standardization the production checkpoint was trained
207
+ with). Passed as `norm=norm_override` into `tr`'s SolidGraphDataset
208
+ construction; `va`/`va_es` already reuse `tr.norm` downstream, so all
209
+ three dataset constructions end up standardized with `norm_override`,
210
+ and the saved checkpoint's `norm` field (`tr.norm`) is `norm_override`
211
+ too. Default None preserves byte-identical behavior (tr loads the
212
+ cache's own norm_stats.npz, as before).
213
+ """
214
+ device = device or ("cuda" if torch.cuda.is_available() else "cpu")
215
+ os.makedirs(out_dir, exist_ok=True)
216
+ tr = SolidGraphDataset(features_dir, train_stems, k=k, augment=augment, graph=graph,
217
+ norm=norm_override)
218
+ va = SolidGraphDataset(features_dir, val_stems, k=k, augment=False, norm=tr.norm, graph=graph)
219
+ trl = DataLoader(tr, batch_size=batch_size, shuffle=True, num_workers=num_workers, persistent_workers=num_workers > 0)
220
+ val = DataLoader(va, batch_size=batch_size, num_workers=num_workers, persistent_workers=num_workers > 0)
221
+
222
+ if early_stop_val_stems is not None:
223
+ va_es = SolidGraphDataset(features_dir, early_stop_val_stems, k=k, augment=False,
224
+ norm=tr.norm, graph=graph)
225
+ val_es = DataLoader(va_es, batch_size=batch_size, num_workers=num_workers,
226
+ persistent_workers=num_workers > 0)
227
+ else:
228
+ val_es = None
229
+
230
+ sample = tr[0]
231
+ node_in, edge_in = sample.x.shape[1], sample.edge_attr.shape[1]
232
+
233
+ if init_ckpt is not None:
234
+ film = True
235
+ if node_in == _V5_NODE_IN:
236
+ model, _sp1_norm = build_or_load_v5(init_ckpt, hidden=hidden, n_blocks=n_blocks, g_dim=g_dim)
237
+ else:
238
+ model, _sp1_norm = build_or_load_v4(init_ckpt, hidden=hidden, n_blocks=n_blocks, g_dim=g_dim)
239
+ model = model.to(device)
240
+ # build_or_load_v4 (like build_warm_started_v4 before it) prefers the
241
+ # *source* checkpoint's own cfg (hidden/n_blocks/two_head) over the
242
+ # args passed here (two_head isn't even forwarded to it), so
243
+ # re-derive them from the actual constructed model -- the saved cfg
244
+ # below must describe the model we trained, not just echo back the
245
+ # caller's arguments.
246
+ hidden = model.decoder[0].in_features
247
+ n_blocks = len(model.blocks)
248
+ two_head = model.two_head
249
+ else:
250
+ model = SolidGNN(node_in=node_in, edge_in=edge_in, hidden=hidden, n_blocks=n_blocks,
251
+ two_head=two_head, film=film, g_dim=g_dim).to(device)
252
+
253
+ if freeze_mode == "all_but_last2":
254
+ _freeze_all_but_last2_and_scale_head(model)
255
+ elif freeze_mode == "backbone" or (freeze_mode is None and freeze_backbone):
256
+ _freeze_backbone_params(model)
257
+ elif freeze_mode not in (None,):
258
+ raise ValueError(f"train(): unknown freeze_mode={freeze_mode!r}")
259
+
260
+ trainable_params = [p for p in model.parameters() if p.requires_grad]
261
+ opt = AdamW(trainable_params, lr=lr, weight_decay=1e-5)
262
+ sched = ReduceLROnPlateau(opt, mode="min", factor=0.5, patience=10)
263
+ scaler = torch.cuda.amp.GradScaler() if device.startswith("cuda") else None
264
+
265
+ best, best_ep, since = float("inf"), 0, 0
266
+ ckpt = osp.join(out_dir, "best_gnn.pt")
267
+ ep = -1
268
+ for ep in range(epochs):
269
+ tr_loss = _run_epoch(model, trl, device, opt=opt, scaler=scaler, spatial_weight=spatial_weight, global_weight=global_weight)
270
+ # Checkpoint selection/early-stop use shape-only val loss: the global term is
271
+ # noisy at batch=2 and masks shape improvements (Run-1 lesson).
272
+ if val_es is not None:
273
+ vloss = _run_epoch(model, val_es, device, spatial_weight=spatial_weight, global_weight=0.0)
274
+ else:
275
+ vloss = _run_epoch(model, val, device, spatial_weight=spatial_weight, global_weight=0.0)
276
+ sched.step(vloss)
277
+ lr = opt.param_groups[0]["lr"]
278
+ print(f" epoch {ep:3d} train {tr_loss:.4f} val {vloss:.4f} lr {lr:.2e}", flush=True)
279
+
280
+ # Build checkpoint dict (used for both best_gnn.pt and per-epoch saves)
281
+ ckpt_dict = _build_checkpoint_dict(
282
+ model, tr.norm, hidden, n_blocks, k, node_in, edge_in,
283
+ spatial_weight, global_weight, graph, feature_version,
284
+ two_head, film, g_dim
285
+ )
286
+
287
+ # Save per-epoch checkpoint if requested
288
+ if save_every_epoch:
289
+ ep_ckpt = osp.join(out_dir, f"ckpt_ep{ep:03d}.pt")
290
+ torch.save(ckpt_dict, ep_ckpt)
291
+
292
+ # Save best checkpoint if this is a new best
293
+ if vloss < best - 1e-5:
294
+ best, best_ep, since = vloss, ep, 0
295
+ torch.save(ckpt_dict, ckpt)
296
+ else:
297
+ since += 1
298
+ if since >= patience:
299
+ break
300
+ return {"best_val": best, "epochs_run": ep + 1, "ckpt": ckpt}
src/train_heads.py ADDED
@@ -0,0 +1,278 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Learned-heads Task 2: frozen-backbone trainer for the gradT/dTdt deriv
2
+ heads (Task 1's `SolidGNN(deriv_heads=True)` / `attach_deriv_heads`).
3
+
4
+ Loads a headless production checkpoint (`{model, norm, cfg}`), attaches the
5
+ two new heads (`grad_head`/`dtdt_head`), freezes EVERY other parameter, and
6
+ trains only the ~8 head tensors to regress standardized log1p(gradT) /
7
+ log1p(|dTdt|) targets read straight from the v4 feature cache (each
8
+ `data/features_v4/graphs/*.npz` already carries raw per-node `gradT` (K/m)
9
+ and `dTdt` (K/s) arrays, see `src/build_dataset.py`). Base weights are never
10
+ touched (verified by an assertion here, and by the caller's own
11
+ byte-identity check).
12
+ """
13
+ import json
14
+ import logging
15
+ import os
16
+ import os.path as osp
17
+
18
+ import numpy as np
19
+ import torch
20
+ import torch.nn.functional as F
21
+ from torch.optim import AdamW
22
+ from torch_geometric.loader import DataLoader
23
+
24
+ from src.gnn_dataset import SolidGraphDataset
25
+ from src.model_gnn import SolidGNN, attach_deriv_heads
26
+
27
+ logger = logging.getLogger(__name__)
28
+
29
+
30
+ class DerivGraphDataset(SolidGraphDataset):
31
+ """`SolidGraphDataset` plus a standardized `y_deriv` (N, 2) node target:
32
+ `[(log1p(gradT) - mean) / std, (log1p(|dTdt|) - mean) / std]`, read from
33
+ the same graph cache. Callers must pre-filter `stems` to ones known to
34
+ carry valid `gradT`/`dTdt` (see `_filter_stems_with_deriv`) -- this class
35
+ does not re-validate.
36
+ """
37
+
38
+ def __init__(self, features_dir, stems, deriv_mean, deriv_std, k=16,
39
+ graph="union", norm=None):
40
+ super().__init__(features_dir, stems, k=k, augment=False, norm=norm, graph=graph)
41
+ self.deriv_mean = torch.tensor(np.asarray(deriv_mean), dtype=torch.float32)
42
+ self.deriv_std = torch.tensor(np.asarray(deriv_std), dtype=torch.float32)
43
+
44
+ def __getitem__(self, i):
45
+ data = super().__getitem__(i)
46
+ raw = np.load(osp.join(self.dir, "graphs", f"{self.stems[i]}.npz"))
47
+ gradT = torch.tensor(raw["gradT"], dtype=torch.float32).clamp(min=0.0)
48
+ dTdt = torch.tensor(raw["dTdt"], dtype=torch.float32).abs()
49
+ deriv_log = torch.stack([torch.log1p(gradT), torch.log1p(dTdt)], dim=-1)
50
+ data.y_deriv = (deriv_log - self.deriv_mean) / self.deriv_std
51
+ return data
52
+
53
+
54
+ def _stem_has_valid_deriv(features_dir, stem):
55
+ """A stem is usable if its cached graph has finite gradT/dTdt arrays,
56
+ each with exactly one value per node (matching X's row count -- a
57
+ length-mismatched stem would otherwise broadcast silently into the
58
+ loss). A missing `.npz` file is also treated as an exclusion (counted
59
+ and logged by the caller), not a raw crash."""
60
+ path = osp.join(features_dir, "graphs", f"{stem}.npz")
61
+ try:
62
+ d = np.load(path)
63
+ except FileNotFoundError:
64
+ return False
65
+ if "gradT" not in d.files or "dTdt" not in d.files or "X" not in d.files:
66
+ return False
67
+ gradT, dTdt, X = d["gradT"], d["dTdt"], d["X"]
68
+ if gradT.shape != dTdt.shape or gradT.shape[0] != X.shape[0]:
69
+ return False
70
+ return bool(np.all(np.isfinite(gradT)) and np.all(np.isfinite(dTdt)))
71
+
72
+
73
+ def _filter_stems_with_deriv(features_dir, stems):
74
+ """Split `stems` into (ok, n_excluded), logging loudly (never silently
75
+ dropping) every stem missing/invalid gradT/dTdt."""
76
+ ok, excluded = [], []
77
+ for stem in stems:
78
+ if _stem_has_valid_deriv(features_dir, stem):
79
+ ok.append(stem)
80
+ else:
81
+ excluded.append(stem)
82
+ if excluded:
83
+ logger.warning(
84
+ "train_heads: excluding %d/%d stem(s) missing/invalid gradT/dTdt: %s",
85
+ len(excluded), len(stems), excluded)
86
+ return ok, len(excluded)
87
+
88
+
89
+ def _compute_deriv_stats(features_dir, stems):
90
+ """Streamed (single accumulation pass, one stem's arrays in memory at a
91
+ time -- never the whole train-pool's per-node values at once) mean/std
92
+ of `[log1p(gradT), log1p(|dTdt|)]` over every node in `stems`."""
93
+ n = 0
94
+ s1 = np.zeros(2, dtype=np.float64)
95
+ s2 = np.zeros(2, dtype=np.float64)
96
+ for stem in stems:
97
+ d = np.load(osp.join(features_dir, "graphs", f"{stem}.npz"))
98
+ gradT = np.clip(d["gradT"].astype(np.float64), 0.0, None)
99
+ dTdt = np.abs(d["dTdt"].astype(np.float64))
100
+ vals = np.stack([np.log1p(gradT), np.log1p(dTdt)], axis=-1)
101
+ n += vals.shape[0]
102
+ s1 += vals.sum(axis=0)
103
+ s2 += (vals ** 2).sum(axis=0)
104
+ if n == 0:
105
+ raise ValueError("train_heads: no valid stems to compute deriv_log_mean/std from")
106
+ mean = s1 / n
107
+ var = np.maximum(s2 / n - mean ** 2, 1e-12)
108
+ std = np.sqrt(var)
109
+ return mean.astype(np.float32), std.astype(np.float32)
110
+
111
+
112
+ def _freeze_all_but_deriv_heads(model):
113
+ """Freeze every parameter except `grad_head.*`/`dtdt_head.*`. Exposed
114
+ standalone (mirrors `train_gnn._freeze_all_but_last2_and_scale_head`) so
115
+ the freeze scope can be unit-tested directly."""
116
+ for name, p in model.named_parameters():
117
+ p.requires_grad = name.startswith("grad_head.") or name.startswith("dtdt_head.")
118
+
119
+
120
+ def _run_epoch_heads(model, loader, device, opt=None):
121
+ train_mode = opt is not None
122
+ model.train(train_mode)
123
+ total, n = 0.0, 0
124
+ for batch in loader:
125
+ batch = batch.to(device)
126
+ g = getattr(batch, "g", None)
127
+ if train_mode:
128
+ opt.zero_grad()
129
+ _, _, deriv = model(batch.x, batch.edge_index, batch.edge_attr, batch.batch, g=g)
130
+ loss = F.mse_loss(deriv, batch.y_deriv)
131
+ loss.backward()
132
+ opt.step()
133
+ else:
134
+ with torch.no_grad():
135
+ _, _, deriv = model(batch.x, batch.edge_index, batch.edge_attr, batch.batch, g=g)
136
+ loss = F.mse_loss(deriv, batch.y_deriv)
137
+ total += float(loss) * batch.num_graphs
138
+ n += batch.num_graphs
139
+ return total / max(n, 1)
140
+
141
+
142
+ def train_deriv_heads(features_dir: str, base_ckpt: str, out_dir: str = "runs/lh_stage1",
143
+ train_stems=None, val_stems=None, epochs: int = 60, lr: float = 1e-3,
144
+ batch_size: int = 2, num_workers: int = 4, patience: int = 10,
145
+ device=None) -> dict:
146
+ """Freeze the entire base model and train only the deriv (gradT/dTdt) heads.
147
+
148
+ Args:
149
+ features_dir: v4 feature cache dir (`graphs/*.npz` carry X/pos/y/
150
+ mesh_edges/g plus raw `gradT`/`dTdt`).
151
+ base_ckpt: path to a headless `{model, norm, cfg}` production
152
+ checkpoint (Task 1's `attach_deriv_heads` contract: only
153
+ grad_head/dtdt_head keys may be missing from it).
154
+ out_dir: where `heads_gnn.pt` (best-val checkpoint) and per-epoch
155
+ `ckpt_ep{ep:03d}.pt` snapshots are written.
156
+ train_stems, val_stems: stem lists; default to `splits.json`'s
157
+ `interp.train` / **`interp.test`** (NOT `extrap.test`: per the
158
+ spec's 2026-08-01 Task-2-review correction, `extrap.test` is
159
+ 72% of the Gate C evaluation set, so early-stopping/checkpoint
160
+ selection on it would leak into that pre-authorized gate;
161
+ `interp.test` is disjoint from both Gate C evaluation lists
162
+ (`extrap.test` + `e_val`)).
163
+ epochs, lr, batch_size, num_workers, patience: as in `src.train_gnn.train`.
164
+ device: torch device string; default cuda if available else cpu.
165
+
166
+ Freezes ALL base parameters (`requires_grad=False`), asserting the
167
+ optimizer's trainable-parameter set is EXACTLY the head tensors
168
+ (`grad_head.*`/`dtdt_head.*`). Computes `deriv_log_mean`/`deriv_log_std`
169
+ over the (filtered) train pool ONCE, streamed, and stores them in the
170
+ saved checkpoint's `norm` dict (base norm + these two (2,) arrays). Loss
171
+ is MSE over the two standardized heads. Stems whose cached graph is
172
+ missing/length-mismatched gradT/dTdt (or has non-finite values), or
173
+ whose `.npz` file is simply absent, are excluded loudly and counted in
174
+ `n_excluded`, rather than silently dropped or crashing the run. The
175
+ graph mode/kNN-k are read verbatim from the base checkpoint's own cfg
176
+ (`cfg["graph"]`/`cfg["k"]`, no divergent defaults -- a cfg missing
177
+ either raises `KeyError` loudly rather than silently training/serving
178
+ with a mismatched graph construction). Saves a checkpoint every epoch
179
+ (`<out_dir>/ckpt_ep{ep:03d}.pt`, gate-metric checkpoint-selection
180
+ precedent from the exo-fix retrain) IN ADDITION to early-stopping on val
181
+ loss and keeping the MSE-best as `<out_dir>/heads_gnn.pt`, with `cfg` =
182
+ base cfg + `{"deriv_heads": True}`.
183
+
184
+ Returns:
185
+ {"ckpt": str, "best_val": float, "n_train": int, "n_excluded": int}
186
+ """
187
+ device = device or ("cuda" if torch.cuda.is_available() else "cpu")
188
+ os.makedirs(out_dir, exist_ok=True)
189
+
190
+ if train_stems is None or val_stems is None:
191
+ with open(osp.join(features_dir, "splits.json")) as f:
192
+ splits = json.load(f)
193
+ if train_stems is None:
194
+ train_stems = splits["interp"]["train"]
195
+ if val_stems is None:
196
+ val_stems = splits["interp"]["test"]
197
+
198
+ train_ok, n_exc_train = _filter_stems_with_deriv(features_dir, train_stems)
199
+ val_ok, n_exc_val = _filter_stems_with_deriv(features_dir, val_stems)
200
+ n_excluded = n_exc_train + n_exc_val
201
+ if not train_ok:
202
+ raise ValueError("train_deriv_heads: no train stems with valid gradT/dTdt")
203
+ if not val_ok:
204
+ raise ValueError("train_deriv_heads: no val stems with valid gradT/dTdt")
205
+
206
+ deriv_mean, deriv_std = _compute_deriv_stats(features_dir, train_ok)
207
+
208
+ base = torch.load(base_ckpt, map_location="cpu", weights_only=False)
209
+ cfg = dict(base["cfg"])
210
+ base_norm = base["norm"]
211
+
212
+ model = SolidGNN(node_in=cfg.get("node_in", 15), edge_in=cfg.get("edge_in", 4),
213
+ hidden=cfg["hidden"], n_blocks=cfg["n_blocks"],
214
+ two_head=cfg.get("two_head", False), film=cfg.get("film", False),
215
+ g_dim=cfg.get("g_dim", 5), deriv_heads=True)
216
+ attach_deriv_heads(model, base["model"])
217
+ model = model.to(device)
218
+
219
+ _freeze_all_but_deriv_heads(model)
220
+
221
+ trainable_names = {n for n, p in model.named_parameters() if p.requires_grad}
222
+ expected_names = {n for n, _ in model.named_parameters()
223
+ if n.startswith("grad_head.") or n.startswith("dtdt_head.")}
224
+ assert trainable_names == expected_names, (
225
+ f"train_deriv_heads: optimizer param set must be exactly the deriv-head "
226
+ f"tensors -- unexpected trainable: {sorted(trainable_names - expected_names)}, "
227
+ f"missing: {sorted(expected_names - trainable_names)}")
228
+ trainable_params = [p for n, p in model.named_parameters() if n in trainable_names]
229
+
230
+ opt = AdamW(trainable_params, lr=lr, weight_decay=1e-5)
231
+
232
+ # No divergent .get(default) here: the graph mode/k must match whatever
233
+ # the base checkpoint was actually trained/normalized with, or the deriv
234
+ # heads would be trained (and later served) on a mismatched graph
235
+ # construction. A base cfg missing either key is a loud KeyError.
236
+ graph = cfg["graph"]
237
+ k = cfg["k"]
238
+
239
+ tr_ds = DerivGraphDataset(features_dir, train_ok, deriv_mean, deriv_std,
240
+ k=k, graph=graph, norm=base_norm)
241
+ va_ds = DerivGraphDataset(features_dir, val_ok, deriv_mean, deriv_std,
242
+ k=k, graph=graph, norm=base_norm)
243
+ trl = DataLoader(tr_ds, batch_size=batch_size, shuffle=True,
244
+ num_workers=num_workers, persistent_workers=num_workers > 0)
245
+ val = DataLoader(va_ds, batch_size=batch_size, num_workers=num_workers,
246
+ persistent_workers=num_workers > 0)
247
+
248
+ saved_norm = dict(base_norm)
249
+ saved_norm["deriv_log_mean"] = deriv_mean
250
+ saved_norm["deriv_log_std"] = deriv_std
251
+ saved_cfg = dict(cfg)
252
+ saved_cfg["deriv_heads"] = True
253
+
254
+ ckpt_path = osp.join(out_dir, "heads_gnn.pt")
255
+ best, since = float("inf"), 0
256
+ ep = -1
257
+ for ep in range(epochs):
258
+ tr_loss = _run_epoch_heads(model, trl, device, opt=opt)
259
+ vloss = _run_epoch_heads(model, val, device, opt=None)
260
+ print(f" epoch {ep:3d} train {tr_loss:.4f} val {vloss:.4f}", flush=True)
261
+
262
+ ckpt_dict = {"model": model.state_dict(), "norm": saved_norm, "cfg": saved_cfg}
263
+
264
+ # Per-epoch checkpoint (gate-metric checkpoint-selection precedent:
265
+ # the exo-fix retrain needed to compare 8 candidates), independent
266
+ # of the best-val bookkeeping below.
267
+ torch.save(ckpt_dict, osp.join(out_dir, f"ckpt_ep{ep:03d}.pt"))
268
+
269
+ if vloss < best - 1e-6:
270
+ best, since = vloss, 0
271
+ torch.save(ckpt_dict, ckpt_path)
272
+ else:
273
+ since += 1
274
+ if since >= patience:
275
+ break
276
+
277
+ return {"ckpt": ckpt_path, "best_val": best, "n_train": len(train_ok),
278
+ "n_excluded": n_excluded}
src/utils.py ADDED
@@ -0,0 +1,115 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import re
2
+ from pathlib import Path
3
+
4
+ ALL_FACES = ['+X', '-X', '+Y', '-Y', '+Z', '-Z']
5
+ _FACE_RE = re.compile(r'^[+\-][XYZ]$')
6
+
7
+ # Integer encoding for geometry type (used as global feature)
8
+ GEOMETRY_ENCODING = {
9
+ 'BC': 0, 'RIB': 1, 'L': 2, 'T': 3, 'X': 4,
10
+ 'WL': 5, 'UL': 6, 'UM': 7, 'US': 8, 'BH': 9,
11
+ 'BO': 10, 'PB': 11, 'PF': 12, 'PH': 13,
12
+ 'RPB':14, 'RPF':15, 'RPH':16,
13
+ # everything else → 17 (other)
14
+ }
15
+ GEOMETRY_OTHER = 17
16
+
17
+
18
+ def parse_filename(path: str) -> dict:
19
+ """Parse any <PREFIX>_<tokens...>.pt filename used in the Foundry dataset.
20
+
21
+ Token classification (applied to tokens after the prefix):
22
+ - numeric (all digits) → appended to dims list
23
+ - matches [+-][XYZ] → adiabatic face
24
+ - anything else → ignored (angle suffixes like 30, 45, 135
25
+ are already absorbed into the prefix token)
26
+
27
+ Returns:
28
+ prefix — geometry type string (e.g. 'BC', 'RIB', 'L')
29
+ dims — list of numeric dim values in mm (may be 0–3 ints)
30
+ adiabatic_faces — list of face labels that are adiabatic
31
+ cooling_faces — complement of adiabatic_faces over ALL_FACES
32
+ volume_mm3 — product of dims (mm³); 0.0 if no dims
33
+ geometry_type — integer encoding (GEOMETRY_ENCODING, 17 = other)
34
+ """
35
+ stem = Path(path).stem
36
+ tokens = stem.split('_')
37
+ prefix = tokens[0]
38
+
39
+ dims = []
40
+ adiabatic = []
41
+ for tok in tokens[1:]:
42
+ if tok.isdigit():
43
+ dims.append(int(tok))
44
+ elif _FACE_RE.match(tok):
45
+ adiabatic.append(tok)
46
+ # else: skip (e.g. stray letters, already part of prefix)
47
+
48
+ cooling = [f for f in ALL_FACES if f not in set(adiabatic)]
49
+ volume = float(eval('*'.join(str(d) for d in dims))) if dims else 0.0
50
+ geo_type = GEOMETRY_ENCODING.get(prefix, GEOMETRY_OTHER)
51
+
52
+ return {
53
+ 'prefix': prefix,
54
+ 'dims': dims,
55
+ 'adiabatic_faces': adiabatic,
56
+ 'cooling_faces': cooling,
57
+ 'volume_mm3': volume,
58
+ 'geometry_type': geo_type,
59
+ }
60
+
61
+
62
+ def global_feature_vec(info: dict) -> list:
63
+ """6-element global feature vector from a parse_filename result.
64
+
65
+ Layout: [dim1, dim2, dim3, volume_mm3, n_cooling_faces, geometry_type]
66
+ dims are zero-padded to length 3.
67
+ """
68
+ d = (info['dims'] + [0, 0, 0])[:3]
69
+ return [
70
+ float(d[0]),
71
+ float(d[1]),
72
+ float(d[2]),
73
+ info['volume_mm3'],
74
+ float(len(info['cooling_faces'])),
75
+ float(info['geometry_type']),
76
+ ]
77
+
78
+
79
+ if __name__ == '__main__':
80
+ test_cases = [
81
+ # BC — 3 numeric dims
82
+ 'BC_06_08_14_+X_+Z.pt',
83
+ 'BC_06_08_14.pt',
84
+ # RIB — 3 dims
85
+ 'RIB_06_06_06.pt',
86
+ # L — 2 dims
87
+ 'L_06_06.pt',
88
+ # T — 2 dims with faces
89
+ 'T_06_08_+X_+Y.pt',
90
+ # X — 2 dims
91
+ 'X_06_06_+Y.pt',
92
+ # WL — 2 dims
93
+ 'WL_06_06.pt',
94
+ # BH — 2 dims
95
+ 'BH_06_06_+X.pt',
96
+ # CYL — 1 dim
97
+ 'CYL_06.pt',
98
+ # KON — 1 dim
99
+ 'KON_25.pt',
100
+ # angle-suffix prefix
101
+ 'L135_06_06.pt',
102
+ 'TR30_06_06.pt',
103
+ ]
104
+
105
+ header = f"{'File':<35s} {'prefix':<8} {'dims':<14} {'adiab':<18} {'vol':>8} {'geo':>4}"
106
+ print(header)
107
+ print('-' * len(header))
108
+ for p in test_cases:
109
+ r = parse_filename(p)
110
+ gf = global_feature_vec(r)
111
+ print(
112
+ f"{Path(p).name:<35s} {r['prefix']:<8} {str(r['dims']):<14} "
113
+ f"{str(r['adiabatic_faces']):<18} {r['volume_mm3']:>8.0f} {r['geometry_type']:>4d}"
114
+ )
115
+ print(f" global_features: {gf}")
src/viz.py ADDED
@@ -0,0 +1,90 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """PyVista helpers for the solidification viewer. Pure/testable: load a
2
+ prediction VTU and derive the ghost mesh, the still-liquid region above a time
3
+ threshold, and the hot-spot points."""
4
+ import os
5
+
6
+ import numpy as np
7
+ import pyvista as pv
8
+
9
+
10
+ def load_prediction(vtu_path: str) -> pv.UnstructuredGrid:
11
+ grid = pv.read(vtu_path)
12
+ for name in ("solidification_time", "hotspot"):
13
+ if name not in grid.point_data:
14
+ raise ValueError(f"VTU missing point array '{name}'")
15
+ return grid
16
+
17
+
18
+ def time_range(grid):
19
+ t = np.asarray(grid.point_data["solidification_time"])
20
+ return float(t.min()), float(t.max())
21
+
22
+
23
+ def liquid_region(grid, tau: float):
24
+ # cells still liquid at time tau (solidification_time >= tau)
25
+ return grid.threshold(tau, scalars="solidification_time")
26
+
27
+
28
+ def hotspot_points(grid):
29
+ mask = np.asarray(grid.point_data["hotspot"]) > 0.5
30
+ return pv.PolyData(np.asarray(grid.points)[mask])
31
+
32
+
33
+ def points_above_time(grid, t: float) -> pv.PolyData:
34
+ """Points whose solidification_time is >= t. Used to thin out the hot-spot
35
+ cloud with a user-controlled cutoff/threshold slider."""
36
+ times = np.asarray(grid.point_data["solidification_time"])
37
+ mask = times >= t
38
+ pts = pv.PolyData(np.asarray(grid.points)[mask])
39
+ pts.point_data["solidification_time"] = times[mask]
40
+ return pts
41
+
42
+
43
+ def solidification_animation(grid, out_path, *, n_frames=40, fps=10,
44
+ cmap="turbo_r", ghost_opacity=0.15,
45
+ scalar_bar_args=None, bg="#f7f7f5",
46
+ window_size=(1280, 720), camera_position=None):
47
+ """Render an animated GIF of progressive solidification: tau sweeps from
48
+ tmin to tmax; already-solidified material (t < tau) shows as a low-opacity
49
+ ghost of the full body, still-liquid material (t >= tau) is opaque and
50
+ colored by solidification time. Each frame shows the color legend and the
51
+ current time. Returns out_path. Off-screen; independent of any live view,
52
+ except that when `camera_position` is given (e.g. captured from a live
53
+ plotter) it is used verbatim so the GIF renders from that exact viewpoint;
54
+ otherwise the camera is auto-framed via reset_camera()."""
55
+ import pyvista as pv
56
+ tmin, tmax = time_range(grid)
57
+ taus = np.linspace(tmin, tmax, int(n_frames))
58
+ p = pv.Plotter(off_screen=True, window_size=list(window_size))
59
+ p.set_background(bg)
60
+ try:
61
+ # permanent ghost of the full body (owns the one legend)
62
+ p.add_mesh(grid, scalars="solidification_time", opacity=ghost_opacity,
63
+ cmap=cmap, clim=[tmin, tmax], smooth_shading=True,
64
+ show_scalar_bar=True, scalar_bar_args=dict(scalar_bar_args or {}))
65
+ p.add_mesh(liquid_region(grid, tmin), scalars="solidification_time",
66
+ cmap=cmap, clim=[tmin, tmax], smooth_shading=True,
67
+ name="liquid", show_scalar_bar=False)
68
+ if camera_position is not None:
69
+ p.camera_position = camera_position
70
+ else:
71
+ p.reset_camera()
72
+ p.open_gif(out_path, fps=int(fps))
73
+ for tau in taus:
74
+ liq = liquid_region(grid, float(tau))
75
+ if liq.n_cells > 0:
76
+ p.add_mesh(liq, scalars="solidification_time", cmap=cmap,
77
+ clim=[tmin, tmax], smooth_shading=True,
78
+ name="liquid", show_scalar_bar=False)
79
+ else:
80
+ p.remove_actor("liquid") # fully solidified -> only the ghost
81
+ p.add_text(f"t = {float(tau):6.1f} s", name="tlabel",
82
+ position="upper_left", font_size=14, color="black")
83
+ p.write_frame()
84
+ except Exception:
85
+ if os.path.exists(out_path):
86
+ os.unlink(out_path) # don't leave a truncated/corrupt gif behind
87
+ raise
88
+ finally:
89
+ p.close()
90
+ return out_path
src/warm_start.py ADDED
@@ -0,0 +1,239 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Warm-start loader: build a v4 (node_in=15, FiLM) SolidGNN from an SP1
2
+ (node_in=13) checkpoint with zero learned knowledge lost.
3
+
4
+ The v4 model adds two trailing per-node input columns (boundary_extraction,
5
+ exothermic_input) and FiLM conditioning (per-block gamma/beta from a material
6
+ g-vector). Both extensions are zero-init: the new input columns are zeroed in
7
+ the node-encoder's first Linear, and FiLM's last layer is zero-init (see
8
+ `src.model_gnn.FiLM`) so gamma=0, beta=0 => identity. Together this means the
9
+ freshly warm-started v4 model reproduces the SP1 model's output exactly (see
10
+ `sanity_gate` / tests/test_warm_start.py), before any FT2b training happens.
11
+
12
+ torch 2.3 note: `load_state_dict(..., strict=False)` does NOT silently skip
13
+ shape-mismatched tensors -- it raises `RuntimeError: size mismatch` even with
14
+ strict=False (verified empirically). So we pre-filter the SP1 state_dict to
15
+ keys whose shape matches the v4 model before calling load_state_dict, and
16
+ splice the one shape-mismatched tensor (the node_encoder's first Linear
17
+ weight) by hand.
18
+ """
19
+ import logging
20
+
21
+ import torch
22
+
23
+ from src.model_gnn import SolidGNN
24
+
25
+ logger = logging.getLogger(__name__)
26
+
27
+ _ENC_W = "node_encoder.0.0.weight"
28
+ _ENC_B = "node_encoder.0.0.bias"
29
+
30
+
31
+ def splice_node_encoder(v4, sp1_sd, n_old=13):
32
+ """Copy SP1's node-encoder first-Linear weight into v4's leading n_old
33
+ input columns; zero the trailing (new) columns. Bias copied verbatim.
34
+
35
+ Args:
36
+ v4: SolidGNN instance (node_in > n_old).
37
+ sp1_sd: SP1 state_dict (or any mapping with the same keys).
38
+ n_old: number of leading input columns coming from SP1 (13).
39
+ """
40
+ with torch.no_grad():
41
+ w_new = v4.node_encoder[0][0].weight
42
+ w_new[:, :n_old] = sp1_sd[_ENC_W]
43
+ w_new[:, n_old:] = 0.0
44
+ v4.node_encoder[0][0].bias.copy_(sp1_sd[_ENC_B])
45
+
46
+
47
+ def build_warm_started_v4(sp1_ckpt_path, hidden=128, n_blocks=12, g_dim=5,
48
+ node_in_new=15, n_old=13):
49
+ """Build a film=True, two_head=True SolidGNN(node_in=node_in_new) warm-
50
+ started from an SP1 checkpoint (node_in=n_old).
51
+
52
+ All shape-matching parameters (edge_encoder, processor blocks, decoder,
53
+ scale_head) are loaded verbatim from SP1. The node_encoder's first Linear
54
+ is spliced (SP1 columns 0:n_old, zeros n_old:). FiLM stays at its
55
+ zero-init (identity) construction.
56
+
57
+ Args:
58
+ sp1_ckpt_path: path to the SP1 `{model, norm, cfg}` checkpoint.
59
+ hidden, n_blocks, g_dim: v4 model hyperparameters. If the checkpoint's
60
+ `cfg` carries `hidden`/`n_blocks`/`edge_in`/`two_head`, those take
61
+ precedence (the warm-started model must match SP1's architecture
62
+ to load its weights) -- the arguments here are fallback defaults
63
+ for checkpoints without a `cfg`.
64
+ node_in_new: v4 input feature count (13 v3 features + 2 new BC cols).
65
+ n_old: SP1 input feature count.
66
+
67
+ Returns:
68
+ (model, sp1_norm): the warm-started v4 SolidGNN (train mode, as
69
+ constructed) and the SP1 checkpoint's `norm` dict (feature/target
70
+ normalization stats), so callers can decide how to reconcile it with
71
+ v4's own (different) normalization -- not this function's concern.
72
+ """
73
+ ckpt = torch.load(sp1_ckpt_path, map_location="cpu", weights_only=False)
74
+ sp1_sd = ckpt["model"]
75
+ cfg = ckpt.get("cfg", {})
76
+ edge_in = cfg.get("edge_in", 4)
77
+ two_head = cfg.get("two_head", True)
78
+ hidden = cfg.get("hidden", hidden)
79
+ n_blocks = cfg.get("n_blocks", n_blocks)
80
+
81
+ v4 = SolidGNN(node_in=node_in_new, edge_in=edge_in, hidden=hidden,
82
+ n_blocks=n_blocks, two_head=two_head, film=True, g_dim=g_dim)
83
+
84
+ v4_sd = v4.state_dict()
85
+ matching = {k: v for k, v in sp1_sd.items()
86
+ if k in v4_sd and v4_sd[k].shape == v.shape}
87
+ load_result = v4.load_state_dict(matching, strict=False)
88
+
89
+ # Everything skipped by the shape filter above must be exactly the
90
+ # spliced input-encoder tensors (handled below by splice_node_encoder).
91
+ skipped_sp1_keys = [k for k in sp1_sd if k not in matching]
92
+ unexpected_skips = set(skipped_sp1_keys) - {_ENC_W, _ENC_B}
93
+ if unexpected_skips:
94
+ raise RuntimeError(
95
+ f"warm_start: unexpected SP1 keys skipped by shape filter "
96
+ f"(expected only the node_encoder input layer): {sorted(unexpected_skips)}"
97
+ )
98
+
99
+ # Everything load_state_dict reports "missing" in v4 must be exactly the
100
+ # spliced input-encoder tensors plus the new FiLM params (no SP1 counterpart).
101
+ unexpected_missing = [
102
+ k for k in load_result.missing_keys
103
+ if k not in (_ENC_W, _ENC_B) and not k.startswith("films.")
104
+ ]
105
+ if unexpected_missing:
106
+ raise RuntimeError(
107
+ f"warm_start: unexpected missing keys after load "
108
+ f"(expected only node_encoder input layer + films.*): {unexpected_missing}"
109
+ )
110
+ if load_result.unexpected_keys:
111
+ raise RuntimeError(
112
+ f"warm_start: unexpected extra keys in SP1 checkpoint: "
113
+ f"{load_result.unexpected_keys}"
114
+ )
115
+
116
+ logger.info("warm_start: SP1 keys spliced by hand (input encoder): %s", skipped_sp1_keys)
117
+ logger.info("warm_start: new v4-only keys left at zero-init (FiLM): %s",
118
+ [k for k in load_result.missing_keys if k.startswith("films.")])
119
+
120
+ splice_node_encoder(v4, sp1_sd, n_old=n_old)
121
+
122
+ return v4, ckpt["norm"]
123
+
124
+
125
+ def build_or_load_v4(ckpt_path, hidden=128, n_blocks=12, g_dim=5,
126
+ node_in_new=15, n_old=13):
127
+ """Warm-start entry point used by FT2b's staged-training driver, which
128
+ chains checkpoints across stages (stage 2 warm-starts from stage 1's own
129
+ output, stage 3 from stage 2's) -- NOT always from the original SP1
130
+ checkpoint.
131
+
132
+ Dispatches on the checkpoint's own `cfg`:
133
+ - `cfg["node_in"] == node_in_new and cfg["film"]` => this is ALREADY a
134
+ v4 FT2b checkpoint (e.g. a previous stage's `best_gnn.pt`): same
135
+ architecture end to end, so it is loaded directly with a plain
136
+ `strict=True` `load_state_dict` -- no splicing, since there is no
137
+ column-count mismatch to bridge.
138
+ - otherwise => this is an SP1-shaped checkpoint (`node_in=n_old`, no
139
+ FiLM), so it is warm-started via `build_warm_started_v4` exactly as
140
+ before (the node-encoder splice + zero-init FiLM path).
141
+
142
+ `build_warm_started_v4` itself is intentionally left untouched -- it always
143
+ assumes an SP1-shaped source and unconditionally splices, which would
144
+ corrupt (or crash on, due to the column-count mismatch) an
145
+ already-15-column source encoder. This function is the one place that
146
+ decides which path applies.
147
+
148
+ Returns:
149
+ (model, norm): same contract as `build_warm_started_v4` -- the
150
+ `norm` is the SOURCE checkpoint's own `norm` dict, for callers to
151
+ reconcile against the current training data's normalization (as
152
+ `train()` already does for the build_warm_started_v4 path).
153
+ """
154
+ ckpt = torch.load(ckpt_path, map_location="cpu", weights_only=False)
155
+ cfg = ckpt.get("cfg", {})
156
+ if cfg.get("node_in") == node_in_new and cfg.get("film"):
157
+ edge_in = cfg.get("edge_in", 4)
158
+ two_head = cfg.get("two_head", True)
159
+ hidden = cfg.get("hidden", hidden)
160
+ n_blocks = cfg.get("n_blocks", n_blocks)
161
+ model = SolidGNN(node_in=node_in_new, edge_in=edge_in, hidden=hidden,
162
+ n_blocks=n_blocks, two_head=two_head, film=True, g_dim=g_dim)
163
+ model.load_state_dict(ckpt["model"], strict=True)
164
+ logger.info("build_or_load_v4: %s is already a v4 FT2b checkpoint "
165
+ "(node_in=%d, film=True) -- loaded directly, no splice.",
166
+ ckpt_path, node_in_new)
167
+ return model, ckpt["norm"]
168
+ return build_warm_started_v4(ckpt_path, hidden=hidden, n_blocks=n_blocks,
169
+ g_dim=g_dim, node_in_new=node_in_new, n_old=n_old)
170
+
171
+
172
+ def build_or_load_v5(ckpt_path, hidden=128, n_blocks=12, g_dim=5,
173
+ node_in_new=20, n_old=15):
174
+ """bc-reach warm-start entry point: rebuild the node encoder for the 5 new
175
+ BC-reach columns (node_in 15 -> 20), or load an already-v5 checkpoint
176
+ directly.
177
+
178
+ `build_or_load_v4`'s dispatch logic (splice-from-a-smaller-node_in-source
179
+ vs. direct-load-an-already-this-shape checkpoint, `torch 2.3` shape-filter
180
+ pre-check, `load_state_dict(..., strict=False)` caveat) is already fully
181
+ generic in its `node_in_new`/`n_old` arguments -- it makes no assumption
182
+ that the source is specifically SP1-shaped, only that its node_encoder's
183
+ first-Linear weight is the sole shape mismatch. This function is a thin,
184
+ v5-flavored entry point over it: given the production `ckpt_ep021.pt`
185
+ (node_in=15, film=True -- itself already a fine-tuned FT2b/FT3 checkpoint,
186
+ NOT a fresh warm-start, so its FiLM/blocks/decoder/scale_head weights are
187
+ real learned values, not zero-init), it:
188
+ - copies `weight[:, :15]` (the node encoder's first Linear) verbatim,
189
+ - zero-inits the 5 new trailing columns (`weight[:, 15:20]`),
190
+ - copies the bias verbatim (unaffected by the input-width change --
191
+ Linear bias only depends on `hidden`, never on `node_in`),
192
+ - strict-copies everything else (edge_encoder, processor blocks, FiLM,
193
+ decoder, scale_head) verbatim, since `hidden`/`n_blocks`/`g_dim` are
194
+ unchanged from v4 to v5.
195
+ Dispatches to a direct `strict=True` load instead (no splice) when
196
+ `ckpt_path`'s own `cfg` already says `node_in==node_in_new and film` --
197
+ e.g. a later bc-reach stage chaining off an earlier bc-reach checkpoint.
198
+
199
+ Returns:
200
+ (model, norm): same contract as `build_or_load_v4` -- `norm` is the
201
+ SOURCE checkpoint's own `norm` dict (15-wide for a genuine v4 source),
202
+ for callers to reconcile against the CURRENT training data's own
203
+ (fresh, 20-wide) normalization -- `src.train_ft2b.run_bcr` does NOT
204
+ forward this as a `norm_override` (unlike FT3's frozen-norm
205
+ fine-tune): bc-reach's v5 feature cache gets its own fresh norm_stats
206
+ (Task 2, `build_v5`), so `train()` standardizes against that cache's
207
+ own stats, not `ckpt_ep021`'s 15-wide ones.
208
+ """
209
+ return build_or_load_v4(ckpt_path, hidden=hidden, n_blocks=n_blocks, g_dim=g_dim,
210
+ node_in_new=node_in_new, n_old=n_old)
211
+
212
+
213
+ def sanity_gate(model_v4, sp1_model, sample):
214
+ """Run both models (eval, no_grad) on the same reference sample; return
215
+ the max abs diff between their outputs (y_nodal and y_global combined).
216
+
217
+ `sample` is a dict with keys:
218
+ x_v4: (N, node_in_v4) input features for the v4 model.
219
+ x_sp1: (N, node_in_sp1) input features for the SP1 model.
220
+ edge_index: (2, E) shared graph connectivity.
221
+ edge_attr: (E, edge_in) shared edge features.
222
+ batch: (N,) graph-membership vector, shared.
223
+ g: (B, g_dim) material g-vector for v4's FiLM, optional.
224
+
225
+ This function makes no assumption about how x_v4/x_sp1 relate (e.g.
226
+ whether x_v4 is x_sp1 zero-padded, or real v4-normalized features) --
227
+ that reconciliation is the caller's job.
228
+ """
229
+ model_v4.eval()
230
+ sp1_model.eval()
231
+ with torch.no_grad():
232
+ yn_sp1, yg_sp1 = sp1_model(sample["x_sp1"], sample["edge_index"],
233
+ sample["edge_attr"], sample["batch"])
234
+ yn_v4, yg_v4 = model_v4(sample["x_v4"], sample["edge_index"],
235
+ sample["edge_attr"], sample["batch"],
236
+ g=sample.get("g"))
237
+ node_diff = (yn_v4 - yn_sp1).abs().max().item()
238
+ global_diff = (yg_v4 - yg_sp1).abs().max().item()
239
+ return max(node_diff, global_diff)
src/webapp.py ADDED
@@ -0,0 +1,927 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Local trame + PyVista web app: upload a STEP casting, run the Phase 2A hybrid
2
+ prediction, and view the solidification field with a time slider (solidified =
3
+ ghost, liquid = opaque) and a hot-spot toggle. Run: python -m src.webapp
4
+
5
+ Part B Task 5 adds an assembly mode: a multi-solid STEP upload shows a body
6
+ table (role + params per solid) and a role-colored 3-D preview instead of
7
+ auto-running the prediction; a single-solid upload keeps the original flow
8
+ untouched."""
9
+ import csv
10
+ import os
11
+ import os.path as osp
12
+
13
+ import numpy as np
14
+ import pyvista as pv
15
+ from trame.app import get_server
16
+ from trame.ui.vuetify3 import SinglePageWithDrawerLayout
17
+ from trame.widgets import html, vuetify3
18
+ from pyvista.trame.ui import plotter_ui
19
+
20
+ from src import viz
21
+ from src.assembly import list_solids, validate_bodies
22
+ from src.inference_props import DEFAULT_ALLOY, list_alloys, list_molds
23
+ from src.predict_casting import predict_casting
24
+ from tools.doe.load_properties import load_chill_effusivities
25
+
26
+ DEFAULT_MOLD = "green_sand"
27
+ SLEEVE_REGISTRY_CSV = "tools/sleeves/sleeve_registry.csv"
28
+
29
+ pv.OFF_SCREEN = True
30
+
31
+ GHOST_OPACITY = 0.15
32
+ DEFAULT_HOTSPOT_SIZE = 12.0
33
+ HOTSPOT_CUTOFF_PERCENTILE = 90.0
34
+
35
+ # --- visual identity -------------------------------------------------------
36
+ # "Neuralcast" = a neural-network surrogate for a foundry casting process, so
37
+ # the palette stays in the same molten-metal family as before but is rebuilt
38
+ # for a light, legible workbench: warm parchment chrome, white control
39
+ # surfaces, and a near-white VTK stage so the inferno field is what draws the
40
+ # eye. The one recurring accent is a deeper burnt-orange (readable at text
41
+ # contrast on white, unlike the original pale version). Wired into vuetify's
42
+ # theme system so every stock control (slider, checkbox, buttons) inherits it.
43
+ # The app bar is a deliberate exception: a dark charcoal strip (independent of
44
+ # the light theme) so the header reads as instrument-panel chrome holding just
45
+ # the title and a status line, not another content surface.
46
+ BG_APP = "#f4f2ee" # app chrome background (warm parchment)
47
+ SURFACE = "#ffffff" # drawer / card surfaces
48
+ BORDER = "#ddd8cf" # hairline dividers
49
+ VTK_BG = "#f7f7f5" # 3D viewport background -- near-white so the
50
+ # colored field is the highest-contrast thing on screen
51
+ ACCENT_MOLTEN = "#c1440e" # burnt-orange -- the one recurring accent (readable on white)
52
+ TEXT_PRIMARY = "#23262b"
53
+ TEXT_MUTED = "#6b7078"
54
+ HEADER_BG = "#1c1a17" # dark charcoal app-bar -- deliberately dark in the light theme
55
+ HEADER_TEXT = "#f4f2ee"
56
+ HEADER_TEXT_MUTED = "#b6afa1"
57
+ FIELD_CMAP = "turbo_r" # reversed turbo (red -> blue): wide, high-contrast gradient
58
+ # with far more distinguishable steps than a single-hue map,
59
+ # so small time differences across the field stay legible
60
+ HOTSPOT_COLOR = "#0092b8" # deep cyan/teal marker: falls in the middle of turbo's
61
+ # range but is rendered as small spheres (not a field
62
+ # color), so it still reads as an overlay/alert
63
+
64
+ # --- assembly mode (Part B Task 5) -----------------------------------------
65
+ # One row per solid in a multi-body STEP upload: role dropdown (defaults
66
+ # largest solid = casting, everything else = ignore) + role-dependent params
67
+ # (chill material / sleeve name) feeding `src.assembly.validate_bodies` and
68
+ # `predict_casting(..., bodies=...)`. The 3-D preview colors each solid's
69
+ # coarse tessellation by its currently-selected role; "ignore" bodies stay
70
+ # the casting's neutral grey but at the same faint GHOST_OPACITY used for the
71
+ # solidified-region ghost actor elsewhere in this file.
72
+ ROLE_OPTIONS = ["casting", "chill", "insulating_sleeve", "exo_sleeve", "ignore"]
73
+ ROLE_COLORS = {
74
+ "casting": "#9e9e9e",
75
+ "chill": "#2196f3",
76
+ "insulating_sleeve": "#ffc107",
77
+ "exo_sleeve": "#f44336",
78
+ "ignore": "#9e9e9e",
79
+ }
80
+
81
+ _MONO_STACK = ('"JetBrains Mono", ui-monospace, "SFMono-Regular", '
82
+ "Menlo, Consolas, monospace")
83
+ _COND_STACK = '"Roboto Condensed", "Arial Narrow", sans-serif'
84
+
85
+ # Scalar bar (legend) for the solidification-time color field: a vertical bar
86
+ # pinned to the right edge of the viewport, with generous title/label padding
87
+ # so the title never collides with the tick numbers, and integer tick labels
88
+ # (fractional seconds add no information at casting scale).
89
+ SCALAR_BAR_ARGS = dict(
90
+ title="Solidification Time, s",
91
+ fmt="%.0f",
92
+ color="black",
93
+ bold=True,
94
+ font_family="arial",
95
+ vertical=True,
96
+ position_x=0.91,
97
+ position_y=0.06,
98
+ width=0.055,
99
+ height=0.80,
100
+ title_font_size=20,
101
+ label_font_size=16,
102
+ n_labels=6,
103
+ )
104
+
105
+ _CUSTOM_CSS = f"""
106
+ .neuralcast-title {{
107
+ display: flex;
108
+ align-items: center;
109
+ gap: 10px;
110
+ }}
111
+ .neuralcast-title-text {{
112
+ font-family: {_COND_STACK};
113
+ font-weight: 700;
114
+ letter-spacing: .04em;
115
+ text-transform: uppercase;
116
+ font-size: 1.05rem;
117
+ color: {HEADER_TEXT};
118
+ }}
119
+ .neuralcast-status {{
120
+ font-family: {_COND_STACK};
121
+ letter-spacing: .08em;
122
+ text-transform: uppercase;
123
+ font-size: .7rem;
124
+ color: {HEADER_TEXT_MUTED};
125
+ white-space: nowrap;
126
+ }}
127
+ .neuralcast-drawer-content {{
128
+ overflow-y: auto;
129
+ }}
130
+ .neuralcast-section-label {{
131
+ font-family: {_COND_STACK};
132
+ letter-spacing: .12em;
133
+ text-transform: uppercase;
134
+ font-size: .7rem;
135
+ color: {TEXT_MUTED};
136
+ margin-bottom: 10px;
137
+ flex: none;
138
+ }}
139
+ .neuralcast-section {{
140
+ margin-bottom: 4px;
141
+ }}
142
+ .neuralcast-section + .neuralcast-section {{
143
+ border-top: 1px solid {BORDER};
144
+ padding-top: 20px;
145
+ margin-top: 20px;
146
+ }}
147
+ .neuralcast-tau-badge {{
148
+ display: inline-block;
149
+ font-family: {_MONO_STACK};
150
+ font-size: .75rem;
151
+ color: {ACCENT_MOLTEN};
152
+ background: {BG_APP};
153
+ border: 1px solid {BORDER};
154
+ border-radius: 4px;
155
+ padding: 3px 10px;
156
+ white-space: nowrap;
157
+ }}
158
+ .neuralcast-summary-box {{
159
+ min-height: 0;
160
+ }}
161
+ .neuralcast-summary-box .v-input {{
162
+ flex: 1 1 auto;
163
+ min-height: 0;
164
+ }}
165
+ .neuralcast-summary-box .v-field__input,
166
+ .neuralcast-summary-box textarea {{
167
+ font-family: {_MONO_STACK} !important;
168
+ font-size: .78rem;
169
+ line-height: 1.5;
170
+ color: {TEXT_PRIMARY};
171
+ }}
172
+ .neuralcast-footer-text {{
173
+ width: 100%;
174
+ text-align: center;
175
+ font-family: {_COND_STACK};
176
+ letter-spacing: .06em;
177
+ font-size: .72rem;
178
+ color: {TEXT_MUTED};
179
+ }}
180
+ """
181
+
182
+
183
+ def summary_text(result: dict, alloy: str = None, mold: str = None) -> str:
184
+ lines = [f"casting: {result['name']}",
185
+ f"total solidification time: {result['total_time_s']:.1f} s",
186
+ f"nodes: {result['n_nodes']}"]
187
+ if alloy is not None or mold is not None:
188
+ cond_parts = []
189
+ if alloy is not None:
190
+ cond_parts.append(f"alloy: {alloy}")
191
+ if mold is not None:
192
+ cond_parts.append(f"mold: {mold}")
193
+ lines.append(" " + ", ".join(cond_parts))
194
+ lines.append("top hot spots (x,y,z mm):")
195
+ for xyz in result["hotspot_xyz"]:
196
+ lines.append(f" ({xyz[0]:.1f}, {xyz[1]:.1f}, {xyz[2]:.1f})")
197
+ if result.get("ood_warnings"):
198
+ lines.append("WARNINGS:")
199
+ lines += [f" - {w}" for w in result["ood_warnings"]]
200
+ return "\n".join(lines)
201
+
202
+
203
+ INITIAL_SUMMARY = "(upload a STEP file)"
204
+
205
+
206
+ def _run_prediction(step_path, out_dir, alloy, mold, bodies=None):
207
+ """Thin wrapper around `predict_casting` forwarding alloy/mold conditioning
208
+ and (Part B Task 5) an optional multi-body role assignment.
209
+
210
+ Extracted so the upload handler's predict call is unit-testable without
211
+ driving the full trame upload flow (module-level lookup of
212
+ `predict_casting` so tests can monkeypatch `src.webapp.predict_casting`).
213
+ `bodies` is only forwarded as a keyword when given (not merely `None`),
214
+ so the pre-Part-B single-solid call shape (no `bodies` kwarg at all)
215
+ stays byte-identical -- some callers (this module's own existing tests)
216
+ monkeypatch `predict_casting` with a fake that doesn't accept `bodies`.
217
+ """
218
+ if bodies is not None:
219
+ return predict_casting(step_path, out_dir=out_dir, alloy=alloy, mold=mold, bodies=bodies)
220
+ return predict_casting(step_path, out_dir=out_dir, alloy=alloy, mold=mold)
221
+
222
+
223
+ def _load_sleeve_names_by_type(csv_path=SLEEVE_REGISTRY_CSV):
224
+ """{"exo": [name, ...], "insulating": [name, ...]} from
225
+ `tools/sleeves/sleeve_registry.csv`'s `name`/`type` columns, sorted --
226
+ the source of the sleeve-name dropdown options for the assembly panel,
227
+ pre-filtered by role so the UI never offers a sleeve `validate_bodies`
228
+ would reject for a type mismatch (see `src.assembly.validate_bodies`'s
229
+ `_ROLE_TO_SLEEVE_TYPE` cross-check)."""
230
+ out: dict[str, list[str]] = {"exo": [], "insulating": []}
231
+ try:
232
+ with open(csv_path, newline="") as f:
233
+ for row in csv.DictReader(f):
234
+ names = out.setdefault(row["type"], [])
235
+ names.append(row["name"])
236
+ except FileNotFoundError:
237
+ pass
238
+ for names in out.values():
239
+ names.sort()
240
+ return out
241
+
242
+
243
+ def _assemble_bodies_from_state(state_like) -> list:
244
+ """Pure builder: the assembly panel's per-row state (`state_like.
245
+ assembly_bodies`, one dict per solid with at least `solid_id` and
246
+ `role`, plus an optional `params`) -> the plain `bodies` list
247
+ `src.assembly.validate_bodies`/`predict_casting(..., bodies=...)`
248
+ expect (`{"solid_id", "role", "params"}`, UI-only fields like `name`/
249
+ `volume_mm3` dropped). Takes anything with attribute access
250
+ (`state_like.assembly_bodies`) so it is testable with a plain
251
+ `SimpleNamespace`/fake as well as the real trame state -- no gmsh, no
252
+ validation (that's `validate_bodies`'s job, called separately as a
253
+ pre-flight check by the Run-assembly handler)."""
254
+ rows = state_like.assembly_bodies or []
255
+ bodies = []
256
+ for row in rows:
257
+ body = {"solid_id": row["solid_id"], "role": row.get("role", "ignore")}
258
+ params = row.get("params")
259
+ if params:
260
+ body["params"] = params
261
+ bodies.append(body)
262
+ return bodies
263
+
264
+
265
+ def _solid_preview_meshes(step_path):
266
+ """Coarse per-solid surface tessellation for the assembly 3-D preview:
267
+ one fresh gmsh/OCC session imports `step_path` and generates a SINGLE
268
+ 2-D surface mesh over the whole model, but sized PER SOLID (each
269
+ solid's own boundary points get their own target size, its OWN bbox
270
+ diagonal / 15, via `gmsh.model.mesh.setSize` + gmsh's default
271
+ `Mesh.MeshSizeExtendFromBoundary` sizing field) -- a large casting
272
+ tessellates at its own coarse scale while a small chill/sleeve still
273
+ gets enough triangles to read as a shape, instead of one uniform size
274
+ forcing every solid into whichever end of the size range is wrong for
275
+ it (a small uniform size on a big casting is a triangle-count blowup;
276
+ a uniform size sized for the casting leaves a small role body a
277
+ handful of degenerate facets). Returns one `(solid_id,
278
+ pyvista.PolyData)` per 3-D solid (its own boundary surfaces' triangles
279
+ + nodes). `solid_id` matches what `list_solids` reports for the SAME
280
+ STEP file, since both walk a fresh `gmsh.model.occ.importShapes` +
281
+ `gmsh.model.getEntities(3)` and gmsh assigns solid tags
282
+ deterministically from import order.
283
+
284
+ Real-gmsh, no mocking -- callers should expect this to be slow-ish (a
285
+ full 2-D mesh pass) but bounded for the coarse sizes used here."""
286
+ import gmsh
287
+
288
+ gmsh.initialize()
289
+ try:
290
+ gmsh.option.setNumber("General.Terminal", 0)
291
+ gmsh.model.add("assembly_preview")
292
+ gmsh.model.occ.importShapes(step_path)
293
+ gmsh.model.occ.synchronize()
294
+
295
+ solids = gmsh.model.getEntities(3)
296
+ if not solids:
297
+ return []
298
+
299
+ size_by_tag = {}
300
+ for (_dim, tag) in solids:
301
+ xmin, ymin, zmin, xmax, ymax, zmax = gmsh.model.getBoundingBox(3, tag)
302
+ diag = float(np.linalg.norm([xmax - xmin, ymax - ymin, zmax - zmin]))
303
+ size_by_tag[tag] = max(diag / 15.0, 1e-3)
304
+
305
+ # Global bounds only fence in gmsh's implicit sizing AWAY from any
306
+ # explicitly-sized point (e.g. deep in a solid's interior); the
307
+ # per-solid setSize calls below are what actually drive each
308
+ # solid's own coarse scale.
309
+ gmsh.option.setNumber("Mesh.MeshSizeMin", min(size_by_tag.values()))
310
+ gmsh.option.setNumber("Mesh.MeshSizeMax", max(size_by_tag.values()))
311
+ gmsh.option.setNumber("Mesh.MeshSizeExtendFromBoundary", 1)
312
+ for (_dim, tag) in solids:
313
+ boundary_pts = gmsh.model.getBoundary([(3, tag)], recursive=True)
314
+ point_dimtags = [(d, t) for (d, t) in boundary_pts if d == 0]
315
+ if point_dimtags:
316
+ gmsh.model.mesh.setSize(point_dimtags, size_by_tag[tag])
317
+ gmsh.model.mesh.generate(2)
318
+
319
+ out = []
320
+ for (_dim, tag) in solids:
321
+ boundary = gmsh.model.getBoundary([(3, tag)], oriented=False)
322
+ surf_tags = [t for (d, t) in boundary if d == 2]
323
+ node_xyz_by_id = {}
324
+ tri_blocks = []
325
+ for surf_tag in surf_tags:
326
+ node_tags, coords, _param = gmsh.model.mesh.getNodes(
327
+ 2, surf_tag, includeBoundary=True)
328
+ coords = np.asarray(coords, dtype=np.float64).reshape(-1, 3)
329
+ for nid, xyz in zip(node_tags, coords):
330
+ node_xyz_by_id.setdefault(int(nid), xyz)
331
+ elem_types, _elem_tags, elem_node_tags = gmsh.model.mesh.getElements(2, surf_tag)
332
+ for etype, enodes in zip(elem_types, elem_node_tags):
333
+ if etype != 2: # gmsh type 2 = 3-node triangle
334
+ continue
335
+ tri_blocks.append(np.asarray(enodes, dtype=np.int64).reshape(-1, 3))
336
+ if not tri_blocks or not node_xyz_by_id:
337
+ continue
338
+ tris = np.concatenate(tri_blocks, axis=0)
339
+ node_ids = sorted(node_xyz_by_id)
340
+ local_index = {nid: i for i, nid in enumerate(node_ids)}
341
+ points = np.array([node_xyz_by_id[nid] for nid in node_ids], dtype=np.float64)
342
+ local_tris = np.vectorize(local_index.get)(tris)
343
+ faces = np.hstack(
344
+ [np.full((local_tris.shape[0], 1), 3, dtype=np.int64), local_tris])
345
+ out.append((tag, pv.PolyData(points, faces)))
346
+ return out
347
+ finally:
348
+ gmsh.finalize()
349
+
350
+
351
+ def _rebuild_hotspots(app):
352
+ """(Re)build the hot-spot point actor from the current cutoff/size state.
353
+ Called on load and whenever the size or cutoff slider moves."""
354
+ plotter, server = app["plotter"], app["server"]
355
+ state, ctrl = server.state, server.controller
356
+ if app["grid"] is None:
357
+ return
358
+ pts = viz.points_above_time(app["grid"], float(state.hotspot_cutoff))
359
+ actor = plotter.add_mesh(pts, color=HOTSPOT_COLOR, point_size=float(state.hotspot_size),
360
+ name="hotspots", render_points_as_spheres=True)
361
+ actor.SetVisibility(bool(state.show_hotspots))
362
+ app["hotspot_actor"] = actor
363
+ ctrl.view_update()
364
+
365
+
366
+ def _remove_scalar_bar(plotter):
367
+ """Drop any existing scalar bar(s), regardless of how many are present.
368
+ Guarded because pyvista raises if none exist (or if a title is required
369
+ with more than one) -- callers just want a clean slate either way."""
370
+ try:
371
+ plotter.remove_scalar_bar()
372
+ except Exception:
373
+ pass
374
+ # Belt-and-suspenders: clear() takes the whole registry in one shot, in
375
+ # case remove_scalar_bar() above only got one of several.
376
+ try:
377
+ plotter.scalar_bars.clear()
378
+ except Exception:
379
+ pass
380
+
381
+
382
+ def _render_assembly_preview(app):
383
+ """(Re)draw every cached assembly-preview mesh (`app["assembly_meshes"]`,
384
+ built once by `_enter_assembly_mode`), colored/opacity'd by each body's
385
+ CURRENT role in `state.assembly_bodies` (see `ROLE_COLORS`/
386
+ `GHOST_OPACITY`). Called on entering assembly mode and again whenever
387
+ `assembly_bodies` changes (role edits) -- cheap, since it only touches
388
+ actors, never re-tessellates."""
389
+ plotter, server = app["plotter"], app["server"]
390
+ state, ctrl = server.state, server.controller
391
+ plotter.clear_actors()
392
+ _remove_scalar_bar(plotter)
393
+ roles = {b["solid_id"]: b.get("role", "ignore") for b in (state.assembly_bodies or [])}
394
+ meshes = app.get("assembly_meshes") or []
395
+ for solid_id, mesh in meshes:
396
+ role = roles.get(solid_id, "ignore")
397
+ color = ROLE_COLORS.get(role, ROLE_COLORS["ignore"])
398
+ opacity = GHOST_OPACITY if role == "ignore" else 1.0
399
+ plotter.add_mesh(mesh, color=color, opacity=opacity,
400
+ name=f"assembly_solid_{solid_id}", smooth_shading=True)
401
+ if meshes:
402
+ plotter.reset_camera()
403
+ ctrl.view_update()
404
+
405
+
406
+ def _enter_assembly_mode(app, step_path, rows):
407
+ """Switch the viewer into assembly mode for a multi-solid STEP upload:
408
+ builds the role-colored 3-D preview meshes FIRST (`_solid_preview_meshes`
409
+ into `app["assembly_meshes"]`), THEN seeds `state.assembly_bodies` from
410
+ `list_solids`' rows (default roles -- largest solid = casting, everything
411
+ else = ignore, per the brief) and renders (`_render_assembly_preview`).
412
+ This order matters: `state.assembly_bodies` is watched
413
+ (`_on_assembly_bodies_change` -> `_render_assembly_preview`), so setting
414
+ it BEFORE `app["assembly_meshes"]` is refreshed would briefly recolor
415
+ whatever meshes happened to be cached from a PRIOR upload (e.g. a
416
+ multi -> single -> multi upload sequence) against the new roles -- a
417
+ stale-preview flash. A preview failure (e.g. a STEP gmsh can list solids
418
+ for but not re-tessellate) is surfaced in `state.assembly_error` rather
419
+ than raised -- the body table/role panel still works without a 3-D
420
+ preview."""
421
+ server = app["server"]
422
+ state = server.state
423
+ app["grid"] = None
424
+ app["hotspot_actor"] = None
425
+ state.loaded = False
426
+ state.summary = INITIAL_SUMMARY
427
+ state.assembly_step_path = step_path
428
+ state.assembly_error = ""
429
+
430
+ try:
431
+ app["assembly_meshes"] = _solid_preview_meshes(step_path)
432
+ except Exception as e:
433
+ app["assembly_meshes"] = []
434
+ state.assembly_error = f"3D preview failed: {e}"
435
+
436
+ bodies = []
437
+ for i, row in enumerate(rows):
438
+ bodies.append({
439
+ "solid_id": row["solid_id"],
440
+ "name": row["name"] or f"solid {row['solid_id']}",
441
+ "volume_mm3": row["volume_mm3"],
442
+ "role": "casting" if i == 0 else "ignore",
443
+ "params": {},
444
+ })
445
+ state.assembly_mode = True
446
+ state.assembly_bodies = bodies
447
+ _render_assembly_preview(app)
448
+
449
+
450
+ def _process_upload(app, step_path, alloy, mold):
451
+ """Core upload logic, extracted from the `uploaded_file` state-change
452
+ handler so it is testable without a browser/file-upload round trip:
453
+ inspects `step_path` via `list_solids`; >1 solid enters assembly mode
454
+ (`_enter_assembly_mode` -- role panel + 3-D preview, prediction deferred
455
+ to the Run-assembly button); exactly 1 solid (or a STEP `list_solids`
456
+ itself can't parse -- fails soft here so it falls through to the SAME
457
+ meshing/healing pipeline `predict_casting` always used) runs the
458
+ original single-solid predict-and-render flow, byte-identical to
459
+ pre-Part-B behavior."""
460
+ server = app["server"]
461
+ state = server.state
462
+ try:
463
+ rows = list_solids(step_path)
464
+ except Exception:
465
+ rows = [] # fall back to the pre-Part-B single-body flow untouched
466
+
467
+ if len(rows) > 1:
468
+ _enter_assembly_mode(app, step_path, rows)
469
+ return
470
+
471
+ # Single-solid (or unlistable) upload: leave no assembly-mode residue
472
+ # behind -- mirrors clear_casting()'s reset of the same four fields, so
473
+ # a LATER multi-solid upload starts from a clean slate rather than
474
+ # _enter_assembly_mode briefly recoloring stale meshes/rows from a
475
+ # previous assembly session (see _enter_assembly_mode's docstring).
476
+ state.assembly_mode = False
477
+ state.assembly_bodies = []
478
+ state.assembly_error = ""
479
+ state.assembly_step_path = None
480
+ app["assembly_meshes"] = []
481
+ plotter = app["plotter"]
482
+ out_dir = "runs/castings"
483
+ try:
484
+ result = _run_prediction(step_path, out_dir, alloy, mold)
485
+ grid = viz.load_prediction(result["vtu"])
486
+ app["name"] = result["name"]
487
+ render_prediction(app, grid, summary_text(result, alloy=alloy, mold=mold))
488
+ except Exception as e: # keep the UI alive on bad/unmeshable STEP
489
+ plotter.clear_actors()
490
+ _remove_scalar_bar(plotter)
491
+ app["grid"] = None
492
+ app["hotspot_actor"] = None
493
+ state.loaded = False
494
+ state.summary = f"Prediction failed for {osp.basename(step_path)}:\n{e}"
495
+
496
+
497
+ def render_prediction(app, grid, summary):
498
+ plotter, server = app["plotter"], app["server"]
499
+ state, ctrl = server.state, server.controller
500
+ plotter.clear_actors()
501
+ _remove_scalar_bar(plotter)
502
+ tmin, tmax = viz.time_range(grid)
503
+ app["grid"] = grid
504
+ # The "ghost" actor is the single, permanent owner of the scalar bar/legend:
505
+ # it is added once here and never touched again by the tau slider, so the
506
+ # legend persists across playback without being re-added or dropped. The
507
+ # "liquid" actor (re-added on every tau change) must NEVER own a scalar bar,
508
+ # or scrubbing the slider would repeatedly add/replace it.
509
+ plotter.add_mesh(grid, scalars="solidification_time", opacity=GHOST_OPACITY,
510
+ cmap=FIELD_CMAP, name="ghost", clim=[tmin, tmax],
511
+ smooth_shading=True, show_scalar_bar=True,
512
+ scalar_bar_args=SCALAR_BAR_ARGS)
513
+ plotter.add_mesh(viz.liquid_region(grid, tmin), scalars="solidification_time",
514
+ cmap=FIELD_CMAP, name="liquid", clim=[tmin, tmax],
515
+ smooth_shading=True, show_scalar_bar=False)
516
+ state.tmin, state.tmax, state.tau = tmin, tmax, tmin
517
+ times = np.asarray(grid.point_data["solidification_time"])
518
+ state.hotspot_cutoff = float(np.percentile(times, HOTSPOT_CUTOFF_PERCENTILE))
519
+ _rebuild_hotspots(app)
520
+ state.anim_ready = False
521
+ state.anim_uri = ""
522
+ state.summary, state.loaded = summary, True
523
+ plotter.reset_camera()
524
+ ctrl.view_update()
525
+
526
+
527
+ def build_app(server=None):
528
+ if server is None:
529
+ server = get_server(client_type="vue3")
530
+ state, ctrl = server.state, server.controller
531
+ plotter = pv.Plotter()
532
+ plotter.set_background(VTK_BG)
533
+ app = {"server": server, "plotter": plotter, "grid": None, "hotspot_actor": None,
534
+ "name": "casting"}
535
+ server.app = app # exposes the app dict for tests / headless callers that
536
+ # need render_prediction()/clear_casting() without the UI
537
+
538
+ state.trame__title = "Neuralcast Viewer"
539
+
540
+ # Custom light vuetify theme (molten-metal accent) applied through
541
+ # Vuetify's own theme system, so every stock control (slider, checkbox,
542
+ # buttons) picks up the palette automatically instead of one-off color
543
+ # props.
544
+ vuetify_config = {
545
+ "theme": {
546
+ "defaultTheme": "neuralcastLight",
547
+ "themes": {
548
+ "neuralcastLight": {
549
+ "dark": False,
550
+ "colors": {
551
+ "background": BG_APP,
552
+ "surface": SURFACE,
553
+ "primary": ACCENT_MOLTEN,
554
+ "secondary": TEXT_MUTED,
555
+ "on-background": TEXT_PRIMARY,
556
+ "on-surface": TEXT_PRIMARY,
557
+ },
558
+ },
559
+ },
560
+ },
561
+ }
562
+ sleeve_names_by_type = _load_sleeve_names_by_type()
563
+ for k, v in dict(tau=0.0, tmin=0.0, tmax=1.0, show_hotspots=False,
564
+ summary=INITIAL_SUMMARY, loaded=False, uploaded_file=None,
565
+ loading=False, hotspot_size=DEFAULT_HOTSPOT_SIZE,
566
+ hotspot_cutoff=0.0, animating=False, anim_ready=False,
567
+ anim_uri="", anim_name="solidification.gif",
568
+ alloy=DEFAULT_ALLOY, mold=DEFAULT_MOLD,
569
+ alloy_options=list_alloys(), mold_options=list_molds(),
570
+ # Assembly mode (Part B Task 5): populated by
571
+ # _enter_assembly_mode() on a multi-solid STEP upload.
572
+ assembly_mode=False, assembly_bodies=[], assembly_error="",
573
+ assembly_step_path=None, role_options=ROLE_OPTIONS,
574
+ chill_material_options=sorted(load_chill_effusivities()),
575
+ exo_sleeve_options=sleeve_names_by_type["exo"],
576
+ insulating_sleeve_options=sleeve_names_by_type["insulating"],
577
+ ).items():
578
+ state.setdefault(k, v)
579
+
580
+ @state.change("tau")
581
+ def _on_tau(tau, **kwargs):
582
+ if app["grid"] is None:
583
+ return
584
+ # show_scalar_bar=False: the "ghost" actor owns the one legend (see
585
+ # render_prediction); re-adding it here would duplicate/replace it on
586
+ # every slider tick.
587
+ liq = viz.liquid_region(app["grid"], tau)
588
+ if liq.n_cells > 0:
589
+ plotter.add_mesh(liq, scalars="solidification_time",
590
+ cmap=FIELD_CMAP, name="liquid", smooth_shading=True,
591
+ clim=[state.tmin, state.tmax], show_scalar_bar=False)
592
+ else:
593
+ plotter.remove_actor("liquid")
594
+ ctrl.view_update()
595
+
596
+ @state.change("show_hotspots")
597
+ def _on_hot(show_hotspots, **kwargs):
598
+ if app["hotspot_actor"] is not None:
599
+ app["hotspot_actor"].SetVisibility(bool(show_hotspots))
600
+ ctrl.view_update()
601
+
602
+ @state.change("hotspot_cutoff", "hotspot_size")
603
+ def _on_hotspot_controls(**kwargs):
604
+ _rebuild_hotspots(app)
605
+
606
+ @state.change("uploaded_file")
607
+ def _on_upload(uploaded_file, **kwargs):
608
+ if not uploaded_file:
609
+ return
610
+ f = uploaded_file[0] if isinstance(uploaded_file, list) else uploaded_file
611
+ out_dir = "runs/castings"
612
+ os.makedirs(out_dir, exist_ok=True)
613
+ step_path = osp.join(out_dir, f.get("name", "part.step"))
614
+ with open(step_path, "wb") as fh:
615
+ fh.write(f["content"])
616
+ state.loading = True
617
+ state.flush()
618
+ try:
619
+ _process_upload(app, step_path, state.alloy, state.mold)
620
+ finally:
621
+ state.loading = False
622
+ ctrl.view_update()
623
+
624
+ @state.change("assembly_bodies")
625
+ def _on_assembly_bodies_change(**kwargs):
626
+ # Fires on every role/param edit in the body table (trame syncs the
627
+ # whole reactive list back on any nested-item mutation) -- just
628
+ # recolor the already-tessellated preview, never re-tessellate.
629
+ if state.assembly_mode:
630
+ _render_assembly_preview(app)
631
+
632
+ def run_assembly_prediction():
633
+ """The assembly panel's "Run" button: build the bodies list from the
634
+ current panel state, pre-flight it through `validate_bodies` so a
635
+ training-regime violation (see `src.assembly.validate_bodies`) shows
636
+ up in the panel (`state.assembly_error`) WITHOUT ever calling
637
+ `predict_casting` -- never a crash for a bad role combination.
638
+ `_assemble_bodies_from_state` itself is inside this same try/except:
639
+ a malformed row (e.g. a missing `solid_id`) is a caller/state bug,
640
+ not a training-regime violation, but it should surface in the panel
641
+ exactly the same way -- never a raw traceback, and never a
642
+ `predict_casting` call on a bodies list that failed to even build."""
643
+ try:
644
+ bodies = _assemble_bodies_from_state(state)
645
+ validate_bodies(bodies)
646
+ except Exception as e:
647
+ state.assembly_error = str(e)
648
+ ctrl.view_update()
649
+ return
650
+ state.assembly_error = ""
651
+ state.loading = True
652
+ state.flush()
653
+ try:
654
+ result = _run_prediction(state.assembly_step_path, "runs/castings",
655
+ state.alloy, state.mold, bodies=bodies)
656
+ grid = viz.load_prediction(result["vtu"])
657
+ app["name"] = result["name"]
658
+ state.assembly_mode = False
659
+ render_prediction(app, grid, summary_text(result, alloy=state.alloy, mold=state.mold))
660
+ except Exception as e: # keep the UI alive on a bad assembly/prediction
661
+ plotter.clear_actors()
662
+ _remove_scalar_bar(plotter)
663
+ app["grid"] = None
664
+ app["hotspot_actor"] = None
665
+ state.loaded = False
666
+ state.summary = f"Prediction failed:\n{e}"
667
+ finally:
668
+ state.loading = False
669
+ ctrl.view_update()
670
+
671
+ # Exposed on the controller so the Run button (and tests) can invoke it
672
+ # without a browser round trip.
673
+ ctrl.run_assembly_prediction = run_assembly_prediction
674
+
675
+ def clear_casting():
676
+ """Drop the loaded casting and return to a ready-for-upload state."""
677
+ plotter.clear_actors()
678
+ _remove_scalar_bar(plotter)
679
+ app["grid"] = None
680
+ app["hotspot_actor"] = None
681
+ app["assembly_meshes"] = []
682
+ state.loaded = False
683
+ state.summary = INITIAL_SUMMARY
684
+ state.uploaded_file = None
685
+ state.tau, state.tmin, state.tmax = 0.0, 0.0, 1.0
686
+ state.show_hotspots = False
687
+ state.anim_ready = False
688
+ state.anim_uri = ""
689
+ state.animating = False
690
+ state.assembly_mode = False
691
+ state.assembly_bodies = []
692
+ state.assembly_error = ""
693
+ state.assembly_step_path = None
694
+ ctrl.view_update()
695
+
696
+ # Exposed on the controller so tests (and any future non-UI caller) can
697
+ # invoke the same clear logic the "Clear" button uses.
698
+ ctrl.clear_casting = clear_casting
699
+
700
+ def generate_animation():
701
+ """Render the solidification GIF for the loaded casting and expose it
702
+ to the browser as a base64 data URI (preview + download). Blocking, like
703
+ the upload prediction -- fine for this local single-user app."""
704
+ if app["grid"] is None:
705
+ return
706
+ state.animating = True
707
+ state.flush()
708
+ try:
709
+ out_dir = "runs/castings"
710
+ os.makedirs(out_dir, exist_ok=True)
711
+ gif_path = osp.join(out_dir, f"{app['name']}_solidification.gif")
712
+ viz.solidification_animation(app["grid"], gif_path,
713
+ cmap=FIELD_CMAP, ghost_opacity=GHOST_OPACITY,
714
+ scalar_bar_args=SCALAR_BAR_ARGS, bg=VTK_BG,
715
+ camera_position=plotter.camera_position)
716
+ import base64
717
+ with open(gif_path, "rb") as fh:
718
+ b64 = base64.b64encode(fh.read()).decode("ascii")
719
+ state.anim_uri = f"data:image/gif;base64,{b64}"
720
+ state.anim_name = f"{app['name']}_solidification.gif"
721
+ state.anim_ready = True
722
+ except Exception as e:
723
+ state.anim_ready = False
724
+ state.summary = f"Animation failed:\n{e}"
725
+ finally:
726
+ state.animating = False
727
+ ctrl.view_update()
728
+ ctrl.generate_animation = generate_animation
729
+
730
+ with SinglePageWithDrawerLayout(server, vuetify_config=vuetify_config,
731
+ width=360) as layout:
732
+ html.Style(_CUSTOM_CSS)
733
+
734
+ # The nav-icon toggles the drawer, but the drawer is `permanent` (it
735
+ # stays open as the fixed left control panel), so the toggle has no
736
+ # visible effect -- hide it rather than show a button that does nothing.
737
+ layout.icon.hide()
738
+
739
+ # Dark, compact app-bar: it holds only the title and a status line, so
740
+ # it is styled as an instrument-panel strip distinct from the light
741
+ # control surfaces below it, and kept slim since it carries no controls.
742
+ layout.toolbar.color = HEADER_BG
743
+ layout.toolbar.density = "compact"
744
+ layout.toolbar.flat = True
745
+
746
+ layout.title.set_text(
747
+ '<div class="neuralcast-title">'
748
+ '<span class="neuralcast-title-text">Neuralcast Viewer</span>'
749
+ "</div>"
750
+ )
751
+ with layout.toolbar:
752
+ vuetify3.VSpacer()
753
+ html.Div(
754
+ "{{ loading ? 'Predicting…' : (loaded ? 'Prediction loaded' "
755
+ ": 'Awaiting upload') }}",
756
+ classes="neuralcast-status mr-2",
757
+ )
758
+ # Thin indeterminate progress bar pinned to the bottom edge of the
759
+ # header -- visible only while a prediction is running, so the user
760
+ # can see the model is working during the blocking STEP -> VTU call.
761
+ vuetify3.VProgressLinear(
762
+ indeterminate=True, absolute=True, location="bottom",
763
+ height=3, color=ACCENT_MOLTEN, v_show=("loading",),
764
+ )
765
+
766
+ with layout.drawer:
767
+ with html.Div(classes="d-flex flex-column fill-height pa-4 "
768
+ "neuralcast-drawer-content"):
769
+ with html.Div(classes="neuralcast-section"):
770
+ html.Div("Upload", classes="neuralcast-section-label")
771
+ vuetify3.VSelect(v_model=("alloy",), items=("alloy_options",),
772
+ label="Alloy", density="compact",
773
+ variant="outlined", hide_details=True,
774
+ classes="mb-2")
775
+ vuetify3.VSelect(v_model=("mold",), items=("mold_options",),
776
+ label="Mold system", density="compact",
777
+ variant="outlined", hide_details=True,
778
+ classes="mb-2")
779
+ vuetify3.VFileInput(v_model=("uploaded_file", None), label="STEP file",
780
+ prepend_inner_icon="mdi-file-upload-outline",
781
+ accept=".step,.stp,.STEP,.STP", density="compact",
782
+ variant="outlined", hide_details=True)
783
+ vuetify3.VBtn("Clear", click=clear_casting, variant="text",
784
+ size="small", color="secondary", disabled=("!loaded",),
785
+ classes="mt-2", block=True)
786
+
787
+ # Assembly mode (Part B Task 5): shown instead of an
788
+ # auto-run prediction whenever the STEP upload has >1
789
+ # solid. One row per body -- role dropdown (defaults
790
+ # largest=casting/rest=ignore, set by _enter_assembly_mode)
791
+ # + a role-dependent params select (chill material / sleeve
792
+ # name, the latter pre-filtered by sleeve type so the UI
793
+ # never offers a pairing validate_bodies would reject).
794
+ # `v_model="body.<path>"` (a literal string, not a
795
+ # (name, default) tuple) is trame's standard per-row
796
+ # editable-table idiom: it binds directly to the `body`
797
+ # loop variable from `v_for`, and edits to `assembly_bodies`
798
+ # sync back to the server as a whole (see
799
+ # `_on_assembly_bodies_change`).
800
+ with html.Div(classes="neuralcast-section", v_show=("assembly_mode",)):
801
+ html.Div("Assembly (multi-body STEP)",
802
+ classes="neuralcast-section-label")
803
+ with html.Template(v_for="body in assembly_bodies", key="body.solid_id"):
804
+ with html.Div(classes="mb-3"):
805
+ html.Div(
806
+ "{{ body.name || ('solid ' + body.solid_id) }} "
807
+ "— {{ Number(body.volume_mm3).toFixed(0) }} mm³",
808
+ style=f"font-size:.75rem; color:{TEXT_MUTED}; "
809
+ "margin-bottom:2px;",
810
+ )
811
+ vuetify3.VSelect(v_model="body.role", items=("role_options",),
812
+ label="role", density="compact",
813
+ variant="outlined", hide_details=True,
814
+ classes="mb-1")
815
+ vuetify3.VSelect(v_model="body.params.material",
816
+ items=("chill_material_options",),
817
+ label="chill material", density="compact",
818
+ variant="outlined", hide_details=True,
819
+ v_if="body.role === 'chill'")
820
+ vuetify3.VSelect(v_model="body.params.sleeve",
821
+ items=("exo_sleeve_options",),
822
+ label="sleeve (exo)", density="compact",
823
+ variant="outlined", hide_details=True,
824
+ v_if="body.role === 'exo_sleeve'")
825
+ vuetify3.VSelect(v_model="body.params.sleeve",
826
+ items=("insulating_sleeve_options",),
827
+ label="sleeve (insulating)", density="compact",
828
+ variant="outlined", hide_details=True,
829
+ v_if="body.role === 'insulating_sleeve'")
830
+ html.Div("{{ assembly_error }}", v_show=("assembly_error",),
831
+ style=f"color:{ACCENT_MOLTEN}; font-size:.75rem; "
832
+ "margin:4px 0 8px;")
833
+ vuetify3.VBtn("Run Assembly Prediction",
834
+ click=ctrl.run_assembly_prediction,
835
+ variant="tonal", size="small", color="primary",
836
+ disabled=("loading",), loading=("loading",), block=True)
837
+
838
+ with html.Div(classes="neuralcast-section"):
839
+ html.Div("Playback", classes="neuralcast-section-label")
840
+ vuetify3.VSlider(v_model=("tau",), min=("tmin",), max=("tmax",),
841
+ label="time (s)",
842
+ hide_details=True, disabled=("!loaded",),
843
+ thumb_label=True)
844
+ html.Div(
845
+ "{{ Number(tau).toFixed(1) }} / {{ Number(tmax).toFixed(1) }} s",
846
+ classes="neuralcast-tau-badge mt-1",
847
+ )
848
+
849
+ with html.Div(classes="neuralcast-section"):
850
+ html.Div("Hot Spots", classes="neuralcast-section-label")
851
+ vuetify3.VCheckbox(v_model=("show_hotspots",), label="show hot spots",
852
+ hide_details=True, disabled=("!loaded",))
853
+ vuetify3.VSlider(v_model=("hotspot_size",), min=2, max=40, step=1,
854
+ label="size", hide_details=True,
855
+ disabled=("!loaded",), thumb_label=True,
856
+ classes="mt-4")
857
+ vuetify3.VSlider(v_model=("hotspot_cutoff",), min=("tmin",),
858
+ max=("tmax",), label="cutoff (s)", hide_details=True,
859
+ disabled=("!loaded",), thumb_label=True,
860
+ classes="mt-2")
861
+
862
+ with html.Div(classes="neuralcast-section"):
863
+ html.Div("Animation", classes="neuralcast-section-label")
864
+ vuetify3.VBtn("Generate GIF", click=generate_animation,
865
+ variant="tonal", size="small", color="primary",
866
+ disabled=("!loaded || animating",), block=True,
867
+ loading=("animating",))
868
+ vuetify3.VProgressLinear(indeterminate=True, height=3,
869
+ color=ACCENT_MOLTEN, v_show=("animating",),
870
+ classes="mt-2")
871
+ html.Img(src=("anim_uri",), v_show=("anim_ready",),
872
+ style="max-width:100%; margin-top:10px; "
873
+ f"border:1px solid {BORDER}; border-radius:4px;")
874
+ html.A("Download GIF", href=("anim_uri",), download=("anim_name",),
875
+ v_show=("anim_ready",),
876
+ style=f"display:block; margin-top:8px; color:{ACCENT_MOLTEN}; "
877
+ "font-size:.8rem; text-decoration:underline;")
878
+
879
+ with html.Div(classes="neuralcast-section flex-grow-1 d-flex "
880
+ "flex-column neuralcast-summary-box"):
881
+ html.Div("Summary", classes="neuralcast-section-label")
882
+ vuetify3.VTextarea(v_model=("summary",), readonly=True,
883
+ variant="outlined", density="compact",
884
+ hide_details=True, no_resize=True,
885
+ classes="flex-grow-1")
886
+
887
+ with layout.content:
888
+ with vuetify3.VContainer(fluid=True, classes="fill-height pa-0"):
889
+ # add_menu=True: keep pyvista's default view-toolbar overlay --
890
+ # it gives reset-camera / view-axis / screenshot controls that
891
+ # users want when inspecting the casting.
892
+ # mode="server": render server-side (VTK on the Python process,
893
+ # frames streamed to the browser) rather than client-side
894
+ # (geometry shipped to the browser and rendered there). This
895
+ # keeps the server-side VTK camera in sync with the user's
896
+ # mouse rotations/pans/zooms, so generate_animation() below can
897
+ # read plotter.camera_position and reuse the exact view for
898
+ # the GIF -- with client-side rendering the server-side camera
899
+ # would stay frozen regardless of what the user sees.
900
+ view = plotter_ui(plotter, add_menu=True, mode="server")
901
+ ctrl.view_update = view.update
902
+
903
+ # Replace trame's default footer ("Powered by trame" / Kitware credit)
904
+ # with our own.
905
+ layout.footer.clear()
906
+ with layout.footer:
907
+ html.Div("© 2026 Eugen Miknevic", classes="neuralcast-footer-text")
908
+
909
+ # NOTE (deviation from brief): trame's `state.to_dict()` only reflects state
910
+ # that has been *flushed*, and flushing is a no-op until the state is marked
911
+ # `ready()` (normally done when a client connects, i.e. inside `.start()`).
912
+ # Since `build_app()` must be unit-constructible without starting the server,
913
+ # we mark the state ready here so the initial `setdefault(...)` values are
914
+ # visible via `to_dict()`/attribute access immediately after construction.
915
+ # This performs no networking (no client is connected yet) and is idempotent
916
+ # (a later real `.start()` calling `ready()` again is a no-op).
917
+ state.ready()
918
+
919
+ return server
920
+
921
+
922
+ def main():
923
+ build_app().start(port=8080)
924
+
925
+
926
+ if __name__ == "__main__":
927
+ main()
tests/__init__.py ADDED
File without changes
tests/conftest.py ADDED
@@ -0,0 +1,49 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Shared pytest fixtures: a minimal gmsh-2.2 mesh for unit tests.
2
+
3
+ The mesh is a unit-ish tetrahedron-based shape with 5 nodes:
4
+ - one internal-ish apex, 4 base nodes forming two tets
5
+ - two boundary triangles tagged 'Cooling', one tagged 'Adiabatic'
6
+ Coordinates are in mm.
7
+ """
8
+ import textwrap
9
+ import pytest
10
+
11
+ # node ids 1..5 ; coords in mm
12
+ MINI_MSH = textwrap.dedent("""\
13
+ $MeshFormat
14
+ 2.2 0 8
15
+ $EndMeshFormat
16
+ $PhysicalNames
17
+ 3
18
+ 2 1 "Adiabatic"
19
+ 2 2 "Cooling"
20
+ 3 3 "Volume"
21
+ $EndPhysicalNames
22
+ $Nodes
23
+ 5
24
+ 1 0 0 0
25
+ 2 10 0 0
26
+ 3 0 10 0
27
+ 4 0 0 10
28
+ 5 3 3 3
29
+ $EndNodes
30
+ $Elements
31
+ 4
32
+ 1 2 2 2 1 1 2 3
33
+ 2 2 2 2 1 1 2 4
34
+ 3 2 2 1 1 2 3 4
35
+ 4 4 2 3 1 1 2 3 4
36
+ $EndElements
37
+ """)
38
+ # Element lines: "id type ntags phys geom n1 n2 n3[ n4]"
39
+ # el1: triangle (type 2), phys 2 = Cooling, nodes 1,2,3
40
+ # el2: triangle, phys 2 = Cooling, nodes 1,2,4
41
+ # el3: triangle, phys 1 = Adiabatic, nodes 2,3,4
42
+ # el4: tetrahedron (type 4), phys 3 = Volume, nodes 1,2,3,4
43
+
44
+
45
+ @pytest.fixture
46
+ def mini_msh_file(tmp_path):
47
+ p = tmp_path / "MINI_00.msh"
48
+ p.write_text(MINI_MSH)
49
+ return str(p)