eugenmik commited on
Commit
20d2438
·
verified ·
1 Parent(s): 73ef2a7

Inference-only code: remove training, evaluation and experimental code

Browse files
This view is limited to 50 files because it contains too many changes.   See raw diff
.gitignore CHANGED
@@ -1,15 +1,10 @@
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/
 
1
+ # Data stays out of the repository except the property tables.
2
  data/*
3
  !data/properties/
4
+ !data/properties/*
5
+ # Prediction output and the mesh cache
6
  runs/
 
 
 
 
7
  # Python
8
  __pycache__/
9
  *.pyc
 
10
  .pytest_cache/
README.md CHANGED
@@ -166,8 +166,9 @@ Each node gets 15 features computed from the mesh and its surface tags:
166
  - part volume, surface area and the fraction of surface that cools;
167
  - local modulus, the volume-to-surface ratio inside geodesic balls of 5, 15 and 40 mm, which is
168
  a local version of Chvorinov's modulus;
169
- - two boundary-condition channels from training, which are zero in this release because the
170
- whole surface cools into the mold.
 
171
 
172
  The alloy and mold enter as five dimensionless numbers: Stefan number, normalized freezing
173
  range, thermal diffusivity ratio, normalized superheat and normalized mold effusivity. They
@@ -216,13 +217,15 @@ spread is not monotone in `a`, so the solver brackets the smallest root explicit
216
  | `weights/ood_ranges.json` | <1 KB | Training envelope (part volume, size, distance to cooling) for out-of-distribution warnings |
217
  | `weights/SHA256SUMS` | <1 KB | Checksums |
218
  | `src/model_api.py` | | `CastSolidModel`, the mesh-in, field-out interface shown above |
 
219
  | `data/properties/` | | Alloy and mold tables ([README](data/properties/README.md)) |
220
 
221
  None of the weight files can execute code when loaded. The GNN is stored as
222
  [safetensors](https://github.com/huggingface/safetensors) and the heads as
223
  [skops](https://skops.readthedocs.io/); before loading a skops file, `src/release_io.py` checks
224
- every type it names against an allowlist of scikit-learn and NumPy types. The rest of `src/`,
225
- `tools/` and `tests/` is the supporting code, described on GitHub.
 
226
 
227
  ## Training data
228
 
@@ -251,6 +254,13 @@ Training meshes have roughly 7,500 to 10,000 nodes. The target at each node is t
251
  temperature drops through the solidus. Material properties come from handbook data. The raw
252
  simulation corpus (about 100 GB) is not distributed.
253
 
 
 
 
 
 
 
 
254
  ## Training procedure
255
 
256
  v5.1 came out of a chain of fine-tunes, not a single training run.
@@ -327,7 +337,8 @@ every result, and send high-consequence cases to a calibrated simulation and a p
327
 
328
  ## Reproducibility
329
 
330
- The published files were converted from the internal checkpoints by `tools/export_release.py`:
 
331
 
332
  | Release file | Internal artifact | Check |
333
  |---|---|---|
@@ -335,8 +346,9 @@ The published files were converted from the internal checkpoints by `tools/expor
335
  | `gbm_total_v3.skops` | `hgb_v3.joblib` | Identical predictions on 10,002 probe rows spanning each feature's training split range |
336
  | `gbm_std_v2.skops` | `hgb_std_v2.joblib` | Identical predictions on 10,002 probe rows spanning each feature's training split range |
337
 
338
- On a real test mesh, the published files and the internal checkpoints give the same field. The
339
- heads were trained with scikit-learn 1.5.2, and other versions may refuse to load them or change
 
340
  their output. Other versions used: Python 3.11, PyTorch 2.3.0, PyTorch Geometric 2.7.0,
341
  NumPy 2.x.
342
 
 
166
  - part volume, surface area and the fraction of surface that cools;
167
  - local modulus, the volume-to-surface ratio inside geodesic balls of 5, 15 and 40 mm, which is
168
  a local version of Chvorinov's modulus;
169
+ - two boundary-condition channels: the mold effusivity on surface nodes (zero inside the
170
+ part), and a boundary heat-input channel, which is zero because a sand-mold surface adds no
171
+ heat.
172
 
173
  The alloy and mold enter as five dimensionless numbers: Stefan number, normalized freezing
174
  range, thermal diffusivity ratio, normalized superheat and normalized mold effusivity. They
 
217
  | `weights/ood_ranges.json` | <1 KB | Training envelope (part volume, size, distance to cooling) for out-of-distribution warnings |
218
  | `weights/SHA256SUMS` | <1 KB | Checksums |
219
  | `src/model_api.py` | | `CastSolidModel`, the mesh-in, field-out interface shown above |
220
+ | `src/features.py`, `src/model_gnn.py`, `src/gbm_heads.py`, `src/hybrid.py` | | The model: node features, graph network, gradient-boosted heads, recomposition |
221
  | `data/properties/` | | Alloy and mold tables ([README](data/properties/README.md)) |
222
 
223
  None of the weight files can execute code when loaded. The GNN is stored as
224
  [safetensors](https://github.com/huggingface/safetensors) and the heads as
225
  [skops](https://skops.readthedocs.io/); before loading a skops file, `src/release_io.py` checks
226
+ every type it names against an allowlist of scikit-learn and NumPy types. The rest of `src/`
227
+ (meshing a STEP file, shrinkage screening, result export, a browser viewer) and `tests/` are the
228
+ supporting pipeline, described on GitHub. Training code is not part of this release.
229
 
230
  ## Training data
231
 
 
254
  temperature drops through the solidus. Material properties come from handbook data. The raw
255
  simulation corpus (about 100 GB) is not distributed.
256
 
257
+ One data issue shaped this project more than any model choice. The solver stored temperatures
258
+ in dolfinx degree-of-freedom order, while coordinates and geometric features were in gmsh node
259
+ order, so about 60% of the nodal targets were silently attached to the wrong positions. Every
260
+ early model failure traced back to it. The training set was rebuilt with a recovered
261
+ permutation between the two orderings. If you train on solver output, check node identity across
262
+ every solver and mesh interface before tuning a model.
263
+
264
  ## Training procedure
265
 
266
  v5.1 came out of a chain of fine-tunes, not a single training run.
 
337
 
338
  ## Reproducibility
339
 
340
+ The published files were converted from the internal training checkpoints and checked against
341
+ them:
342
 
343
  | Release file | Internal artifact | Check |
344
  |---|---|---|
 
346
  | `gbm_total_v3.skops` | `hgb_v3.joblib` | Identical predictions on 10,002 probe rows spanning each feature's training split range |
347
  | `gbm_std_v2.skops` | `hgb_std_v2.joblib` | Identical predictions on 10,002 probe rows spanning each feature's training split range |
348
 
349
+ The code in this repository was also run side by side with the internal pipeline on five
350
+ production parts (two of them with both alloys), plus a fixed-size mesh with a custom property
351
+ set: every output field matched exactly. The heads were trained with scikit-learn 1.5.2, and other versions may refuse to load them or change
352
  their output. Other versions used: Python 3.11, PyTorch 2.3.0, PyTorch Geometric 2.7.0,
353
  NumPy 2.x.
354
 
requirements.txt CHANGED
@@ -1,11 +1,10 @@
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
 
1
+ # Install PyTorch 2.3 for your platform first, plus the matching torch-cluster
2
+ # wheel, for example on 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
  skops>=0.10
9
  safetensors>=0.4
10
  torch-geometric==2.7.0
src/baseline.py DELETED
@@ -1,63 +0,0 @@
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 DELETED
@@ -1,1199 +0,0 @@
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 DELETED
@@ -1,214 +0,0 @@
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 DELETED
@@ -1,28 +0,0 @@
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 DELETED
@@ -1,630 +0,0 @@
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 DELETED
@@ -1,150 +0,0 @@
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 CHANGED
@@ -1,88 +1,33 @@
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"])
@@ -95,119 +40,12 @@ def alloy_g_vector(props: dict) -> np.ndarray:
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))
@@ -240,6 +78,18 @@ def _node_features(mesh, version=2, spec=None):
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),
@@ -248,87 +98,12 @@ def _node_features(mesh, version=2, spec=None):
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
 
1
+ """Node features and the alloy/mold conditioning vector.
2
 
3
+ Every feature comes from the mesh and its surface tags ("Cooling", optionally
4
+ "Adiabatic"), in millimetres. Nothing depends on node numbering, file names or
5
+ absolute position.
 
6
  """
 
 
7
  import numpy as np
8
 
9
+ from src.geometry import (total_area, mesh_volume, nearest_distance, boundary_node_mask,
10
+ edges_from_tets, geodesic_distance, local_modulus)
 
 
 
 
 
11
 
12
  FEATURE_NAMES = [
13
  "geodesic_d_cool", "d_surf", "d_adiab",
14
  "is_cooling", "is_adiabatic", "is_interior",
15
  "volume_mm3", "surface_area_mm2", "cooling_area_frac",
 
 
 
16
  "geodesic_d_adiab",
17
  "local_modulus_r5", "local_modulus_r15", "local_modulus_r40",
18
+ "boundary_extraction", "boundary_heat_input",
19
  ]
20
  LOCAL_MODULUS_RADII = (5.0, 15.0, 40.0)
21
 
22
+ # Alloy/mold conditioning vector (raw, not standardized).
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
23
  G_VECTOR_NAMES = ["Ste", "dTf_norm", "alpha_ratio", "superheat_norm", "mold_eff_norm"]
24
+ T_REF = 1000.0 # deg C, normalizes temperature differences
25
+ ALPHA_REF = 1e-5 # m^2/s, typical alloy thermal diffusivity
26
+ MOLD_EFF_REF = 1250.0 # W*s^0.5/(m^2*K), reference mold effusivity
27
 
28
 
29
  def alloy_g_vector(props: dict) -> np.ndarray:
30
+ """Five dimensionless numbers describing alloy and mold.
31
 
32
  `props` keys: k, rho, cp, L_kJ_kg, TL, TS, Tpour, mold_effusivity."""
33
  k = float(props["k"]); rho = float(props["rho"]); cp = float(props["cp"])
 
40
  superheat_norm = (Tpour - TL) / T_REF
41
  mold_eff_norm = b_mold / MOLD_EFF_REF
42
  return np.array([Ste, dTf_norm, alpha_ratio, superheat_norm, mold_eff_norm],
43
+ dtype=np.float64)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
44
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
45
 
46
+ def node_features(mesh, mold_effusivity: float) -> dict:
47
+ """The (N, 15) float32 feature matrix `X`, node positions `pos` (mm) and
48
+ the unique tetrahedral mesh edges `mesh_edges` for one casting."""
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
49
  xyz = mesh.node_xyz
50
  N = xyz.shape[0]
51
  cool_tri = mesh.tris.get("Cooling", np.empty((0, 3), np.int64))
 
78
  cool_frac = area_cool / area_total if area_total > 0 else 0.0
79
  volume = mesh_volume(xyz, mesh.tets)
80
 
81
+ g_adiab = geodesic_distance(N, edges, edge_len, np.flatnonzero(adiab_mask))
82
+ g_adiab = np.where(np.isinf(g_adiab), d_adiab, g_adiab)
83
+ lm = local_modulus(xyz, mesh.tets, cool_tri, edges, edge_len, LOCAL_MODULUS_RADII)
84
+
85
+ # Boundary channels. Surface nodes carry the mold effusivity (how fast the
86
+ # mold draws heat); interior nodes carry 0. The heat-input channel is for
87
+ # heat sources on the boundary, and a plain sand-mold surface has none.
88
+ boundary_extraction = np.zeros(N, dtype=np.float32)
89
+ boundary_extraction[cool_mask] = mold_effusivity
90
+ boundary_extraction[adiab_only] = mold_effusivity
91
+ boundary_heat_input = np.zeros(N, dtype=np.float32)
92
+
93
  cols = {
94
  "geodesic_d_cool": g_cool, "d_surf": d_surf, "d_adiab": d_adiab,
95
  "is_cooling": cool_mask.astype(np.float32),
 
98
  "volume_mm3": np.full(N, volume),
99
  "surface_area_mm2": np.full(N, area_total),
100
  "cooling_area_frac": np.full(N, cool_frac),
101
+ "geodesic_d_adiab": g_adiab,
102
+ "local_modulus_r5": lm[:, 0],
103
+ "local_modulus_r15": lm[:, 1],
104
+ "local_modulus_r40": lm[:, 2],
105
+ "boundary_extraction": boundary_extraction,
106
+ "boundary_heat_input": boundary_heat_input,
107
  }
108
+ X = np.stack([cols[n] for n in FEATURE_NAMES], axis=1).astype(np.float32)
109
+ return {"X": X, "pos": xyz.astype(np.float32), "mesh_edges": edges.astype(np.int64)}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
src/gbm_ft2b.py DELETED
@@ -1,598 +0,0 @@
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/gbm_heads.py ADDED
@@ -0,0 +1,69 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """The two gradient-boosted heads: total solidification time and field spread.
2
+
3
+ Both are scikit-learn HistGradientBoostingRegressor models that see one row
4
+ per part and predict log1p of their target in seconds.
5
+
6
+ Row layout (16 columns for the total head, 17 for the spread head):
7
+ 0-6 alloy: k, rho, cp, L_kJ_kg, TL, TS, Tpour
8
+ 7 part volume / surface area (mm)
9
+ 8 mold effusivity
10
+ 9-15 boundary-condition descriptors from training. A casting that cools
11
+ into the sand mold over its whole surface is the regime encoded as
12
+ [1, 0, 0, 0, 0, 0, 0]; this release always uses it.
13
+ 16 (spread head only) fraction of surface nodes tagged adiabatic
14
+ """
15
+ import numpy as np
16
+
17
+ from src.features import FEATURE_NAMES
18
+
19
+ _IS_COOLING_IDX = FEATURE_NAMES.index("is_cooling")
20
+ _IS_ADIABATIC_IDX = FEATURE_NAMES.index("is_adiabatic")
21
+ _VOL_IDX = FEATURE_NAMES.index("volume_mm3")
22
+ _AREA_IDX = FEATURE_NAMES.index("surface_area_mm2")
23
+
24
+ _SAND_MOLD_BOUNDARY = (1.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0)
25
+ N_TOTAL_FEATURES = 16
26
+ N_SPREAD_FEATURES = 17
27
+
28
+
29
+ def total_row(props: dict, X: np.ndarray) -> np.ndarray:
30
+ """The 16-column row for the total-time head."""
31
+ v_over_a = float(X[0, _VOL_IDX]) / float(X[0, _AREA_IDX])
32
+ return np.array([
33
+ props["k"], props["rho"], props["cp"], props["L_kJ_kg"],
34
+ props["TL"], props["TS"], props["Tpour"],
35
+ float(v_over_a), props["mold_effusivity"],
36
+ *_SAND_MOLD_BOUNDARY,
37
+ ], dtype=np.float64)
38
+
39
+
40
+ def adiabatic_node_fraction(X: np.ndarray) -> float:
41
+ """Adiabatic surface nodes / (adiabatic + cooling surface nodes)."""
42
+ n_adiabatic = float(np.sum(X[:, _IS_ADIABATIC_IDX]))
43
+ n_cooling = float(np.sum(X[:, _IS_COOLING_IDX]))
44
+ return n_adiabatic / max(n_adiabatic + n_cooling, 1.0)
45
+
46
+
47
+ def spread_row(row_total: np.ndarray, X: np.ndarray) -> np.ndarray:
48
+ """The 17-column row for the spread head."""
49
+ return np.concatenate([row_total, [adiabatic_node_fraction(X)]])
50
+
51
+
52
+ def predict_total_seconds(model, row: np.ndarray) -> float:
53
+ total = float(np.expm1(float(model.predict(row.reshape(1, -1))[0])))
54
+ if total <= 0:
55
+ raise ValueError(f"total-time head returned a non-positive total ({total!r})")
56
+ return total
57
+
58
+
59
+ def predict_spread_seconds(model, row: np.ndarray) -> float:
60
+ x = np.asarray(row, dtype=np.float64).reshape(1, -1)
61
+ log_pred = float(model.predict(x)[0])
62
+ return max(0.0, float(np.expm1(log_pred)))
63
+
64
+
65
+ def check_head(model, n_features: int, name: str) -> None:
66
+ n = getattr(model, "n_features_in_", None)
67
+ if n is not None and int(n) != n_features:
68
+ raise ValueError(f"{name} head expects {n} input columns, this code builds "
69
+ f"{n_features}")
src/geometry.py CHANGED
@@ -80,53 +80,6 @@ def geodesic_distance(n_nodes: int, edges: np.ndarray, edge_len: np.ndarray,
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,
@@ -149,79 +102,17 @@ def _local_modulus_for_query(tets: np.ndarray, cool_tri: np.ndarray, radii: np.n
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)))
@@ -231,22 +122,7 @@ def local_modulus(node_xyz: np.ndarray, tets: np.ndarray, cool_tri: np.ndarray,
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)
 
80
  return d
81
 
82
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
83
 
84
 
85
  def _local_modulus_for_query(tets: np.ndarray, cool_tri: np.ndarray, radii: np.ndarray,
 
102
  return vals
103
 
104
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
105
 
106
 
107
  def local_modulus(node_xyz: np.ndarray, tets: np.ndarray, cool_tri: np.ndarray,
108
  edges: np.ndarray, edge_len: np.ndarray, radii,
109
+ chunk: int = 128) -> np.ndarray:
110
  """Local cooling modulus per node: V/A over geodesic balls (mm).
111
 
112
  For each node and radius r: (sum of volumes of tets whose 4 nodes all lie
113
  within geodesic distance r of the node) / (area of cooling triangles whose
114
  3 nodes all lie within r). Balls with no cooling area get the bbox-diagonal
115
+ sentinel (consistent with the other distance sentinels)."""
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
116
  N = node_xyz.shape[0]
117
  radii = np.asarray(radii, dtype=float)
118
  bbox_diag = float(np.linalg.norm(node_xyz.max(0) - node_xyz.min(0)))
 
122
  tvol = tet_volumes(node_xyz, tets)
123
  tarea = triangle_areas(node_xyz, cool_tri)
124
  g = _edge_csr(N, edges, edge_len)
125
+ query_idx = np.arange(N, dtype=np.int64)
126
+ out[query_idx] = _local_modulus_for_query(tets, cool_tri, radii, chunk, query_idx, g,
127
+ tvol, tarea, bbox_diag)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
128
  return out.astype(np.float32)
src/gnn_dataset.py DELETED
@@ -1,51 +0,0 @@
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 DELETED
@@ -1,52 +0,0 @@
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 CHANGED
@@ -1,15 +1,17 @@
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
 
@@ -18,52 +20,9 @@ import numpy as np
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)
@@ -72,169 +31,31 @@ def hybrid_field_max_anchor(gnn_seconds: np.ndarray, total_seconds: float,
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
@@ -250,17 +71,12 @@ def hybrid_field_affine(gnn_seconds: np.ndarray, total_seconds: float,
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
@@ -294,9 +110,8 @@ def hybrid_field_affine(gnn_seconds: np.ndarray, total_seconds: float,
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
@@ -309,166 +124,40 @@ def hybrid_field_affine(gnn_seconds: np.ndarray, total_seconds: float,
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"])
@@ -494,7 +183,6 @@ def hybrid_field_soft(gnn_seconds: np.ndarray, total_seconds: float, std_seconds
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
 
@@ -508,18 +196,13 @@ def hybrid_field_soft(gnn_seconds: np.ndarray, total_seconds: float, std_seconds
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)
@@ -527,39 +210,15 @@ def hybrid_field_soft(gnn_seconds: np.ndarray, total_seconds: float, std_seconds
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))
@@ -577,12 +236,6 @@ def hybrid_field_soft(gnn_seconds: np.ndarray, total_seconds: float, std_seconds
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]])
@@ -593,9 +246,8 @@ def hybrid_field_soft(gnn_seconds: np.ndarray, total_seconds: float, std_seconds
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)
@@ -613,11 +265,7 @@ def hybrid_field_soft(gnn_seconds: np.ndarray, total_seconds: float, std_seconds
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)
 
1
+ """Recomposition: combine the GNN field shape with the two heads' targets.
2
 
3
+ The GNN predicts the spatial pattern of the field well but is less reliable
4
+ about its absolute level and spread. The heads predict the part's total time
5
+ (used as the 99.5% quantile of the field) and the standard deviation of the
6
+ field. `hybrid_field_soft` maps the GNN field affinely,
 
7
 
8
+ pred(a, m) = clip(a * (gnn - mean(gnn)) + m, 0, None)
9
 
10
+ and picks (a, m) to minimize
11
+
12
+ J = w_total * r_total**2 + w_std * r_std**2,
13
+ r_total = (quantile(pred, q) - total) / total
14
+ r_std = (std(pred) - std_target) / std_target.
15
  """
16
  import logging
17
 
 
20
  logger = logging.getLogger(__name__)
21
 
22
 
 
 
 
 
 
 
 
 
 
 
 
23
  def hybrid_field_max_anchor(gnn_seconds: np.ndarray, total_seconds: float,
24
  q: float = 0.995) -> np.ndarray:
25
+ """Shift the GNN field so its q-quantile equals `total_seconds`, clip at 0."""
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
26
  g = np.asarray(gnn_seconds, dtype=np.float64)
27
  anchor = np.quantile(g, q)
28
  out = g - anchor + float(total_seconds)
 
31
 
32
  def hybrid_field_affine(gnn_seconds: np.ndarray, total_seconds: float,
33
  std_seconds: float, q: float = 0.995,
34
+ a_clamp: tuple = (0.3, 5.0)) -> tuple:
35
+ """Hard two-target fit: the q-quantile is anchored exactly at
36
+ `total_seconds` and the scale `a` is solved so that the clipped field's
37
+ standard deviation matches `std_seconds`.
38
+
39
+ Anchoring the quantile gives raw(a) = a * d + total with d fixed by the
40
+ GNN field. Below a_clipstart nothing is clipped and a = std/std(gnn) in
41
+ closed form. Past it, the post-clip spread is not monotone in a (it rises
42
+ to an interior peak and then falls), so the smallest root is found by a
43
+ coarse scan followed by bisection; if no root exists the closest scanned
44
+ point is used and `a_clamped` is set. Returns (field, info)."""
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
45
  g = np.asarray(gnn_seconds, dtype=np.float64)
46
  s = float(np.std(g))
 
47
  if s < 1e-9:
48
  logger.warning("hybrid_field_affine: degenerate flat GNN field (std=%.3g) -- "
49
  "falling back to quantile-anchor recomposition", s)
50
  pred = hybrid_field_max_anchor(g, total_seconds, q=q)
51
  return pred, {"a": 1.0, "a_clamped": False, "degenerate_fallback": True,
52
  "clip_frac": float((pred == 0.0).mean()),
 
53
  "std_target_effective": float(std_seconds)}
54
 
55
  std_target = float(std_seconds)
 
 
 
 
 
 
 
 
 
 
 
 
 
56
  lo, hi = float(a_clamp[0]), float(a_clamp[1])
57
  total = float(total_seconds)
58
 
 
 
59
  c = g - g.mean()
60
  shift = float(np.quantile(c, q))
61
  d = c - shift
 
71
  a_raw = std_target / s
72
 
73
  if a_raw <= a_clipstart:
74
+ # Nothing is clipped at a_raw, so the closed form is exact. The clamp
75
+ # to `lo` can still clip when the total is small next to the spread.
 
 
76
  a = float(np.clip(a_raw, lo, hi))
77
  a_clamped = bool(a != a_raw)
78
  raw = _raw_for_a(a)
79
  else:
 
 
 
80
  lo_search = max(a_clipstart, lo)
81
  if lo_search >= hi:
82
  a, a_clamped = hi, True
 
110
  a_clamped = False
111
  raw = _raw_for_a(a)
112
  else:
113
+ # The target exceeds the reachable spread: take the closest
114
+ # scanned point instead of the upper clamp.
 
115
  best_idx = int(np.argmin(np.abs(diffs)))
116
  a = float(scan_a[best_idx])
117
  a_clamped = True
 
124
  "with the GNN shape", clip_frac)
125
  return np.clip(raw, 0.0, None), {"a": a, "a_clamped": a_clamped,
126
  "degenerate_fallback": False, "clip_frac": clip_frac,
127
+ "std_target_effective": std_target}
 
128
 
129
 
130
  def hybrid_field_soft(gnn_seconds: np.ndarray, total_seconds: float, std_seconds: float,
131
  q: float = 0.995, a_clamp: tuple = (0.3, 5.0),
132
+ w_total: float = 1.0, w_std: float = 1.0) -> tuple:
133
+ """Soft two-target fit (see the module docstring for J).
134
+
135
+ Seeded by `hybrid_field_affine`. When the seed already meets both
136
+ targets it is returned unchanged; otherwise a 64 x 33 grid over (a, m),
137
+ a 9 x 9 refinement around the best point and an exact re-check decide
138
+ whether letting the total move buys a better spread match. Returns
139
+ (field, info) with info keys a, m, target_unreachable,
140
+ degenerate_fallback, clip_frac, total_deviation_frac, j_total_resid,
141
+ j_std_resid, std_target_effective."""
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
142
  g = np.asarray(gnn_seconds, dtype=np.float64)
143
  total = float(total_seconds)
144
  q = float(q)
145
  w_total = float(w_total)
146
  w_std = float(w_std)
147
 
 
 
 
 
 
148
  if w_total < 0.0 or w_std < 0.0:
149
  raise ValueError(
150
  f"hybrid_field_soft: w_total/w_std must be >= 0 (got "
151
+ f"w_total={w_total!r}, w_std={w_std!r})")
 
 
152
  if w_total == 0.0 and w_std == 0.0:
153
+ raise ValueError("hybrid_field_soft: w_total and w_std cannot both be 0")
 
 
 
 
 
 
 
154
  if float(a_clamp[0]) <= 0.0:
155
  raise ValueError(f"hybrid_field_soft: a_clamp[0] must be > 0 (got {a_clamp[0]!r}) "
156
+ "-- the a-grid is log-spaced")
 
157
 
158
+ pred_seed, info_affine = hybrid_field_affine(g, total, std_seconds, q=q, a_clamp=a_clamp)
 
 
159
 
160
+ # For every branch of the affine solve, m(a) = total - a * quantile(c, q).
 
 
 
161
  c = g - g.mean()
162
  shift = float(np.quantile(c, q))
163
  a_seed = float(info_affine["a"])
 
183
  "total_deviation_frac": final_rt,
184
  "j_total_resid": float(w_total * final_rt ** 2),
185
  "j_std_resid": float(w_std * final_rs ** 2),
 
186
  "std_target_effective": std_target_eff,
187
  }
188
 
 
196
  "total_deviation_frac": r_total_seed,
197
  "j_total_resid": float(w_total * r_total_seed ** 2),
198
  "j_std_resid": float(w_std * r_std_seed ** 2),
 
199
  "std_target_effective": std_target_eff,
200
  }
201
 
202
  target_unreachable = bool(info_affine["a_clamped"])
203
  seed_clip_frac = float((c * a_seed + m_seed < 0.0).mean())
204
 
205
+ # Both targets already met: no search needed (the common case).
 
 
 
 
206
  if j_seed < 1e-12:
207
  return pred_seed, _build_info(a_seed, m_seed, r_total_seed, r_std_seed,
208
  seed_clip_frac, target_unreachable)
 
210
  lo, hi = float(a_clamp[0]), float(a_clamp[1])
211
  n_a, n_m = 64, 33
212
  a_grid = np.logspace(np.log10(lo), np.log10(hi), n_a)
213
+ # The m-window is centred on the seed.
 
 
 
 
214
  m_lo, m_hi = sorted((m_seed - 0.3 * total, m_seed + 0.3 * total))
215
  m_grid = np.linspace(m_lo, m_hi, n_m)
216
 
217
+ # raw(a, m) = a*c + m is order-preserving and clipping only touches the
218
+ # low tail, so quantile(clip(raw), q) == a*shift + m in closed form as
219
+ # long as fewer than ~q of the nodes are clipped. Where that fails (all
220
+ # nodes clipped) J is over-estimated, so such a point is never selected.
221
+ # The winning candidate is re-scored exactly below.
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
222
  def _grid_eval(a_vals, m_vals):
223
  n_av, n_mv = len(a_vals), len(m_vals)
224
  J = np.empty((n_av, n_mv))
 
236
  Rs[i] = rs
237
  return J, Rt, Rs
238
 
 
 
 
 
 
 
239
  J_coarse, Rt_coarse, Rs_coarse = _grid_eval(a_grid, m_grid)
240
  idx = np.unravel_index(int(np.argmin(J_coarse)), J_coarse.shape)
241
  best_a, best_m = float(a_grid[idx[0]]), float(m_grid[idx[1]])
 
246
  best_a, best_m, best_J = a_seed, m_seed, j_seed
247
  best_rt, best_rs = r_total_seed, r_std_seed
248
 
249
+ # One refinement pass on a 9 x 9 sub-grid about one coarse cell wide
250
+ # around the best point so far (log-spaced in a, linear in m).
 
251
  log_lo, log_hi = np.log10(lo), np.log10(hi)
252
  a_step = (log_hi - log_lo) / max(n_a - 1, 1)
253
  log_best_a = np.log10(best_a)
 
265
  best_J = float(J_ref[idx_r])
266
  best_rt, best_rs = float(Rt_ref[idx_r]), float(Rs_ref[idx_r])
267
 
268
+ # Exact re-check of the winner before accepting it over the seed.
 
 
 
 
269
  if (best_a, best_m) != (a_seed, m_seed):
270
  best_pred = np.clip(best_a * c + best_m, 0.0, None)
271
  best_rt, best_rs = _residuals(best_pred)
src/inference_props.py CHANGED
@@ -1,112 +1,125 @@
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-GJS-400-15"
 
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)
@@ -130,7 +143,6 @@ def resolve_props(alloy: str | None = None, mold: str = "green_sand",
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),
@@ -140,63 +152,19 @@ def resolve_props(alloy: str | None = None, mold: str = "green_sand",
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
 
1
+ """Alloy and mold properties.
2
 
3
+ Turns a named alloy and mold (rows of `data/properties/materials.csv` and
4
+ `molds.csv`), or a JSON file with raw values, into the 8-key dict the model
5
+ reads:
6
 
7
  {k, rho, cp, L_kJ_kg, TL, TS, Tpour, mold_effusivity}
8
 
9
+ Units: W/(m K), kg/m^3, J/(kg K), kJ/kg, deg C, W s^0.5/(m^2 K). The pouring
10
+ temperature of a named alloy is the midpoint of its tabulated range.
 
 
11
  """
12
  from __future__ import annotations
13
 
14
  import csv
15
  import json
16
+ from dataclasses import dataclass
17
  from pathlib import Path
18
 
 
 
19
  DEFAULT_ALLOY = "EN-GJS-400-15"
20
+ DEFAULT_MOLD = "green_sand"
21
 
22
  _REQUIRED_KEYS = ("k", "rho", "cp", "L_kJ_kg", "TL", "TS", "Tpour", "mold_effusivity")
23
 
24
  _MATERIALS_PATH = "data/properties/materials.csv"
25
  _MOLDS_PATH = "data/properties/molds.csv"
26
 
27
+ # Family used for shrinkage severity when the alloy family is unknown (a raw
28
+ # property file has no grade). Steel has the largest shrinkage, so this errs
29
+ # on the side of flagging.
30
+ _UNKNOWN_ALLOY_FAMILY_FALLBACK = "steel"
31
+
32
+
33
+ @dataclass(frozen=True)
34
+ class Material:
35
+ material_id: str
36
+ alloy_family: str
37
+ k: float
38
+ rho: float
39
+ cp: float
40
+ L: float
41
+ TL: float
42
+ TS: float
43
+ Tpour: float
44
+
45
+
46
+ def load_materials(path: str = _MATERIALS_PATH) -> list[Material]:
47
+ """Rows of the materials table; rejects rows that are not TS < TL < Tpour."""
48
+ materials: list[Material] = []
49
+ with Path(path).open(newline="") as handle:
50
+ for row in csv.DictReader(handle):
51
+ material_id = row["material_id"]
52
+ TL = float(row["TL_C"])
53
+ TS = float(row["TS_C"])
54
+ tpour = 0.5 * (float(row["Tpour_lo_C"]) + float(row["Tpour_hi_C"]))
55
+ if not TS < TL < tpour:
56
+ raise ValueError(
57
+ f"material {material_id!r}: expected TS < TL < Tpour, "
58
+ f"got {TS}, {TL}, {tpour}")
59
+ materials.append(Material(
60
+ material_id=material_id,
61
+ alloy_family=row["family"],
62
+ k=float(row["k_W_mK"]),
63
+ rho=float(row["rho_kg_m3"]),
64
+ cp=float(row["cp_J_kgK"]),
65
+ L=float(row["L_kJ_kg"]),
66
+ TL=TL,
67
+ TS=TS,
68
+ Tpour=tpour,
69
+ ))
70
+ return materials
71
+
72
+
73
+ def load_molds(path: str = _MOLDS_PATH) -> dict[str, float]:
74
+ """{mold_system: effusivity} from the molds table."""
75
+ with Path(path).open(newline="") as f:
76
+ return {row["mold_system"]: float(row["effusivity_W_s05_m2K"])
77
+ for row in csv.DictReader(f)}
78
+
79
+
80
+ _materials_cache: dict[str, Material] | None = None
81
  _molds_cache: dict[str, float] | None = None
82
 
83
 
84
+ def _materials() -> dict[str, Material]:
 
85
  global _materials_cache
86
  if _materials_cache is None:
87
+ _materials_cache = {m.material_id: m for m in load_materials()}
88
  return _materials_cache
89
 
90
 
91
  def _molds() -> dict[str, float]:
 
92
  global _molds_cache
93
  if _molds_cache is None:
94
+ _molds_cache = load_molds()
 
 
 
 
 
 
 
 
 
95
  return _molds_cache
96
 
97
 
98
  def list_alloys() -> list[str]:
 
99
  return list(_materials().keys())
100
 
101
 
102
  def list_molds() -> list[str]:
 
103
  return list(_molds().keys())
104
 
105
 
106
  def _validate_tl_ts(props: dict, source: str) -> None:
107
+ """An inverted or equal liquidus/solidus pair would make the Niyama proxy
108
+ zero everywhere and flag every node, so it is rejected up front."""
 
 
 
 
 
 
 
 
 
 
 
 
 
109
  TL, TS = props["TL"], props["TS"]
110
  if TL <= TS:
111
  raise ValueError(
112
  f"resolve_props ({source}): TL ({TL!r}) must be strictly greater than "
113
+ f"TS ({TS!r})")
 
 
 
 
 
 
 
114
 
 
 
 
115
 
116
+ def resolve_props(alloy: str | None = None, mold: str = DEFAULT_MOLD,
117
+ props_json: str | None = None) -> dict:
118
+ """The 8-key property dict.
119
 
120
+ `props_json` replaces `alloy` and `mold`; the file must hold all 8 keys.
121
+ Otherwise `alloy` (default `DEFAULT_ALLOY`) and `mold` are looked up in
122
+ the tables, and unknown names raise ValueError listing the options."""
 
 
123
  if props_json is not None:
124
  with open(props_json) as f:
125
  data = json.load(f)
 
143
  if mold not in molds:
144
  raise ValueError(
145
  f"unknown mold system '{mold}'; available mold systems: {sorted(molds.keys())}")
 
146
 
147
  props = {
148
  "k": float(material.k),
 
152
  "TL": float(material.TL),
153
  "TS": float(material.TS),
154
  "Tpour": float(material.Tpour),
155
+ "mold_effusivity": float(molds[mold]),
156
  }
157
  _validate_tl_ts(props, f"alloy={alloy_id!r}")
158
  return props
159
 
160
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
161
  def resolve_alloy_family(alloy: str | None, props_json: str | None = None) -> tuple[str, bool]:
162
+ """(family, assumed) for shrinkage severity. The family comes from the
163
+ materials table; `assumed` is True when it had to fall back to steel
164
+ (a raw property file, or an alloy that is not in the table)."""
 
 
 
 
 
 
 
 
 
165
  if props_json is not None:
166
  return _UNKNOWN_ALLOY_FAMILY_FALLBACK, True
167
+ material = _materials().get(alloy if alloy is not None else DEFAULT_ALLOY)
168
+ if material is None:
169
+ return _UNKNOWN_ALLOY_FAMILY_FALLBACK, True
170
+ return material.alloy_family, False
 
 
src/mesh_cache.py CHANGED
@@ -1,57 +1,11 @@
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
@@ -61,15 +15,13 @@ 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
 
@@ -84,35 +36,16 @@ def _sha256_file(path: str) -> str:
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
 
@@ -124,45 +57,17 @@ def _paths(cache_dir: str, key: str) -> tuple[str, str]:
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):
@@ -177,26 +82,16 @@ def get_or_mesh(step_path: str, out_msh: str, target_nodes: int = DEFAULT_TARGET
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
 
@@ -207,10 +102,7 @@ def get_or_mesh(step_path: str, out_msh: str, target_nodes: int = DEFAULT_TARGET
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)
@@ -220,7 +112,7 @@ def get_or_mesh(step_path: str, out_msh: str, target_nodes: int = DEFAULT_TARGET
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)
 
1
+ """Content-addressed on-disk cache for `src.meshing.heal_and_mesh`.
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2
 
3
+ The key is sha256(STEP bytes) + target node count + `MESH_PIPELINE_VERSION`,
4
+ so a byte-identical STEP file is meshed once. Each entry is a pair
5
+ `<key>.msh` / `<key>.json` (the mesh and its meta dict), written through a
6
+ per-writer temporary file and `os.replace`, so concurrent writers never leave
7
+ a half-written entry. Failed meshes are not cached.
8
+ """
9
  from __future__ import annotations
10
 
11
  import hashlib
 
15
  import time
16
  import uuid
17
 
18
+ # MESH_PIPELINE_VERSION and heal_and_mesh are read through globals() so tests
19
+ # can monkeypatch them.
20
+ from src.meshing import DEFAULT_TARGET_NODES, MESH_PIPELINE_VERSION, heal_and_mesh # noqa: F401
21
 
22
  _HASH_CHUNK_SIZE = 1 << 20 # 1 MiB
23
+ # STEP files above this size get the defeaturing attempt first. Changing it
24
+ # changes which mesh a file resolves to, so bump MESH_PIPELINE_VERSION too.
 
 
 
 
25
  _BIG_FILE_THRESHOLD_BYTES = 8 * 1024 * 1024 # 8 MiB
26
 
27
 
 
36
  return h.hexdigest()
37
 
38
 
39
+ def mesh_cache_key(step_path: str, target_nodes: int = DEFAULT_TARGET_NODES) -> str:
40
+ """sha256 over the file digest, `target_nodes` and the pipeline version.
41
+ The version is read from module globals so tests can monkeypatch it."""
 
 
 
 
 
 
 
 
 
 
 
 
 
42
  digest = _sha256_file(step_path)
43
  pipeline_version = globals()["MESH_PIPELINE_VERSION"]
44
+ key_input = f"{digest}:{int(target_nodes)}:{int(pipeline_version)}".encode("utf-8")
45
  return hashlib.sha256(key_input).hexdigest()
46
 
47
 
48
  def _unique_tmp_path(final_path: str) -> str:
 
 
 
 
 
 
49
  return f"{final_path}.{os.getpid()}.{uuid.uuid4().hex}.tmp"
50
 
51
 
 
57
 
58
 
59
  def get_or_mesh(step_path: str, out_msh: str, target_nodes: int = DEFAULT_TARGET_NODES,
60
+ cache_dir: str = "runs/mesh_cache") -> dict:
61
+ """`heal_and_mesh`'s meta dict for `step_path`, plus a `"cache"` key.
62
+
63
+ On a hit the cached mesh is copied to `out_msh` (`"cache": "hit"`). On a
64
+ miss the part is meshed; a successful mesh is stored and returned with
65
+ `"cache": "miss"`, a failed one is returned unchanged and not stored.
66
+ Small files try a plain pass first and retry once with defeaturing; files
67
+ above 8 MiB try the defeatured pass first and retry once without it.
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
68
  """
69
  os.makedirs(cache_dir, exist_ok=True)
70
+ key = mesh_cache_key(step_path, target_nodes)
71
  msh_path, json_path = _paths(cache_dir, key)
72
 
73
  if os.path.exists(msh_path) and os.path.exists(json_path):
 
82
  t0 = time.time()
83
  big_file = os.path.getsize(step_path) > _BIG_FILE_THRESHOLD_BYTES
84
  if big_file:
85
+ meta = heal_and_mesh_fn(step_path, out_msh, target_nodes=target_nodes, defeature=True)
 
 
 
 
 
 
86
  defeature_used, retry = True, False
87
  if not meta.get("ok"):
88
+ meta = heal_and_mesh_fn(step_path, out_msh, target_nodes=target_nodes, defeature=False)
 
89
  defeature_used, retry = False, True
90
  else:
91
+ meta = heal_and_mesh_fn(step_path, out_msh, target_nodes=target_nodes, defeature=False)
 
92
  defeature_used, retry = False, False
93
  if not meta.get("ok"):
94
+ meta = heal_and_mesh_fn(step_path, out_msh, target_nodes=target_nodes, defeature=True)
 
 
95
  defeature_used, retry = True, True
96
  wall_s = time.time() - t0
97
 
 
102
  result["retry"] = retry
103
  return result
104
 
 
 
105
  stored_meta = dict(meta)
 
106
  stored_meta["mesh_wall_s"] = wall_s
107
  stored_meta["pipeline_version"] = globals()["MESH_PIPELINE_VERSION"]
108
  stored_meta["source_path"] = os.path.abspath(step_path)
 
112
 
113
  msh_tmp = _unique_tmp_path(msh_path)
114
  json_tmp = _unique_tmp_path(json_path)
115
+ shutil.copyfile(out_msh, msh_tmp)
116
  os.replace(msh_tmp, msh_path)
117
  with open(json_tmp, "w") as f:
118
  json.dump(stored_meta, f)
src/meshing.py ADDED
@@ -0,0 +1,368 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """STEP to tetrahedral mesh with Gmsh (OpenCASCADE kernel).
2
+
3
+ `heal_and_mesh` is the default path: import the STEP file, keep the largest
4
+ solid, tag every surface "Cooling" and the volume "Volume", and search the
5
+ element size until the mesh lands near `target_nodes` (the density the model
6
+ was trained on). If the unhealed geometry cannot be meshed, a second pass heals
7
+ it and retries. `mesh_step` meshes at one fixed element size instead.
8
+
9
+ Both write gmsh 2.2 ASCII meshes, the format `src.msh_parse` reads. All
10
+ lengths are in millimetres.
11
+ """
12
+ from __future__ import annotations
13
+
14
+ import numpy as np
15
+
16
+ DEFAULT_TARGET_NODES = 8500
17
+ # Part of the mesh-cache key (src/mesh_cache.py): bump it whenever a change
18
+ # here would produce a different mesh for the same STEP file.
19
+ MESH_PIPELINE_VERSION = 3
20
+
21
+ # Element-size seed: sqrt(K_SURF * surface_area / target_nodes), calibrated so
22
+ # the first guess usually lands within the density search's tolerance.
23
+ K_SURF = 2.5
24
+
25
+ # Physical-group tags are pinned (not auto-assigned) so that repeated meshing
26
+ # attempts in one Gmsh session keep the "Volume"/"Cooling" names bound.
27
+ _VOLUME_PHYS_TAG = 1
28
+ _COOLING_PHYS_TAG = 2
29
+
30
+
31
+ def _reduce_to_largest_solid():
32
+ """Keep only the largest solid of the imported OCC model.
33
+
34
+ Returns (n_solids_in, kept_tag, kept_volume_mm3)."""
35
+ import gmsh
36
+
37
+ vols = [t for (d, t) in gmsh.model.occ.getEntities(3)]
38
+ n_in = len(vols)
39
+ if n_in == 0:
40
+ raise ValueError("no 3D solids found in STEP import")
41
+ masses = [(t, gmsh.model.occ.getMass(3, t)) for t in vols]
42
+ kept_tag, kept_vol = max(masses, key=lambda tv: tv[1])
43
+ if n_in > 1:
44
+ drop = [(3, t) for (t, _v) in masses if t != kept_tag]
45
+ gmsh.model.occ.remove(drop, recursive=True)
46
+ gmsh.model.occ.synchronize()
47
+ return n_in, kept_tag, float(kept_vol)
48
+
49
+
50
+ def _import_and_reduce(step_path: str) -> tuple[int, float, str | None]:
51
+ """Import `step_path` into the current Gmsh session and keep its largest
52
+ solid. Returns (n_solids_in, kept_volume_mm3, error_or_None)."""
53
+ import gmsh
54
+
55
+ try:
56
+ gmsh.model.occ.importShapes(step_path)
57
+ gmsh.model.occ.synchronize()
58
+ except Exception as e:
59
+ return 0, 0.0, f"import failed: {e}"
60
+ try:
61
+ n_solids, _kept_tag, volume_mm3 = _reduce_to_largest_solid()
62
+ except Exception as e:
63
+ return 0, 0.0, f"largest-solid reduction failed: {e}"
64
+ return n_solids, volume_mm3, None
65
+
66
+
67
+ def _elem_size(bbox_diag_mm: float) -> float:
68
+ """Fallback element-size seed: bounding-box diagonal / 28."""
69
+ return bbox_diag_mm / 28.0
70
+
71
+
72
+ def _elem_size_from_area(area_mm2: float, target_nodes: int) -> float:
73
+ """Element-size seed from the total surface area of the model."""
74
+ return float(np.sqrt(K_SURF * area_mm2 / target_nodes))
75
+
76
+
77
+ def _total_surface_area_mm2() -> float:
78
+ """Total area of all surface entities of the current OCC model."""
79
+ import gmsh
80
+
81
+ surfs = [t for (d, t) in gmsh.model.occ.getEntities(2)]
82
+ return float(sum(gmsh.model.occ.getMass(2, t) for t in surfs))
83
+
84
+
85
+ def _first_guess_elem_size(bbox_diag_mm: float, target_nodes: int) -> float:
86
+ """Surface-area seed, or the bounding-box seed if the area query fails."""
87
+ try:
88
+ area_mm2 = _total_surface_area_mm2()
89
+ except Exception as e:
90
+ print(f"[meshing] WARNING: OCC surface-area query failed ({e}); "
91
+ f"falling back to bbox_diag/28 seed", flush=True)
92
+ return _elem_size(bbox_diag_mm)
93
+ if area_mm2 <= 0:
94
+ print(f"[meshing] WARNING: OCC surface-area query returned "
95
+ f"{area_mm2!r} (<=0); falling back to bbox_diag/28 seed", flush=True)
96
+ return _elem_size(bbox_diag_mm)
97
+ return _elem_size_from_area(area_mm2, target_nodes)
98
+
99
+
100
+ def _search_elem_size(build_fn, out_msh: str, target_nodes: int,
101
+ max_iter: int = 3) -> tuple[int, float]:
102
+ """Refine the element size assuming the node count scales as h^-3."""
103
+ n_nodes, elem_size = build_fn(out_msh, None)
104
+ for _ in range(max_iter - 1):
105
+ if n_nodes <= 0:
106
+ break
107
+ elem_size = elem_size * (n_nodes / target_nodes) ** (1.0 / 3.0)
108
+ n_nodes, elem_size = build_fn(out_msh, elem_size)
109
+ return n_nodes, elem_size
110
+
111
+
112
+ def _search_target_nodes(build_fn, out_msh: str, target_nodes: int,
113
+ tol: float = 0.35, max_iter: int = 3) -> tuple[int, float, int]:
114
+ """Density search towards `target_nodes`.
115
+
116
+ `build_fn(out_msh, elem_size) -> (n_nodes, elem_size_used)`, with
117
+ `elem_size=None` meaning "use the seed" and `n_nodes == 0` meaning the
118
+ mesh failed outright. Stops after the first attempt if it is already
119
+ within `tol` of the target (or failed); otherwise refines for up to
120
+ `max_iter` attempts. Returns (n_nodes, elem_size_used, attempts)."""
121
+ calls = {"n": 0}
122
+
123
+ def counted_build_fn(msh_path, elem_size):
124
+ calls["n"] += 1
125
+ return build_fn(msh_path, elem_size)
126
+
127
+ n0, es0 = counted_build_fn(out_msh, None)
128
+ if n0 <= 0 or abs(n0 - target_nodes) <= tol * target_nodes:
129
+ return n0, es0, calls["n"]
130
+ n, es = _search_elem_size(counted_build_fn, out_msh, target_nodes, max_iter=max_iter)
131
+ return n, es, calls["n"]
132
+
133
+
134
+ def _tag_and_mesh(size: float, out_msh: str) -> tuple[bool, int, str]:
135
+ """Tag the current model ("Volume" + every surface as "Cooling") and mesh
136
+ it at element size `size`. Returns (ok, n_nodes, note).
137
+
138
+ The tag numbers are pinned: Gmsh keeps a removed group's *name* bound to
139
+ its old tag, so auto-assigned tags on a second attempt in the same
140
+ session would leave the written mesh with unnamed groups."""
141
+ import gmsh
142
+
143
+ gmsh.model.mesh.clear()
144
+ gmsh.model.removePhysicalGroups()
145
+ vols = [t for (d, t) in gmsh.model.getEntities(3)]
146
+ surfs = [t for (d, t) in gmsh.model.getEntities(2)]
147
+ if not vols:
148
+ return False, 0, "no volume entities to mesh"
149
+ pv = gmsh.model.addPhysicalGroup(3, vols, tag=_VOLUME_PHYS_TAG)
150
+ gmsh.model.setPhysicalName(3, pv, "Volume")
151
+ pc = gmsh.model.addPhysicalGroup(2, surfs, tag=_COOLING_PHYS_TAG)
152
+ gmsh.model.setPhysicalName(2, pc, "Cooling")
153
+ # The mesh reader matches elements by group name, so an unnamed group
154
+ # would silently look like a mesh without boundary. Fail loudly instead.
155
+ if (gmsh.model.getPhysicalName(3, pv) != "Volume"
156
+ or gmsh.model.getPhysicalName(2, pc) != "Cooling"):
157
+ return False, 0, ("physical-group tagging did not register "
158
+ "(Volume/Cooling name binding missing)")
159
+ gmsh.option.setNumber("Mesh.MeshSizeMin", size)
160
+ gmsh.option.setNumber("Mesh.MeshSizeMax", size)
161
+ try:
162
+ gmsh.model.mesh.generate(3)
163
+ node_tags, _, _ = gmsh.model.mesh.getNodes()
164
+ try:
165
+ tet_tags = gmsh.model.mesh.getElementsByType(4)[0]
166
+ except Exception:
167
+ tet_tags = []
168
+ if len(node_tags) > 0 and len(tet_tags) > 0:
169
+ gmsh.option.setNumber("Mesh.MshFileVersion", 2.2)
170
+ gmsh.write(out_msh)
171
+ return True, len(node_tags), ""
172
+ return False, 0, f"empty mesh at size={size:.2f}mm"
173
+ except Exception as e:
174
+ return False, 0, f"mesh generate failed at size={size:.2f}mm: {e}"
175
+
176
+
177
+ def _apply_heal() -> tuple[bool, str]:
178
+ """Run OCC shape healing. `healShapes()` can finish without raising and
179
+ still leave no solid, so a heal only counts if a volume survives.
180
+ Returns (effective, note)."""
181
+ import gmsh
182
+
183
+ try:
184
+ gmsh.model.occ.healShapes()
185
+ gmsh.model.occ.synchronize()
186
+ except Exception as e:
187
+ return False, f"healShapes raised: {e}"
188
+ n_vols = len(gmsh.model.getEntities(3))
189
+ if n_vols == 0:
190
+ return False, ("healShapes ran but yielded 0 volume entities (solid could not be "
191
+ "reconstructed -- likely a self-intersecting B-rep)")
192
+ return True, ""
193
+
194
+
195
+ def _apply_defeature() -> tuple[bool, str]:
196
+ """Merge literally duplicate OCC entities (`removeAllDuplicates`), a
197
+ cheap clean-up for CAD exports. Returns (ok, note); not ok if the call
198
+ raises or the solid is lost."""
199
+ import gmsh
200
+
201
+ n_vols_before = len(gmsh.model.getEntities(3))
202
+ try:
203
+ gmsh.model.occ.removeAllDuplicates()
204
+ gmsh.model.occ.synchronize()
205
+ except Exception as e:
206
+ return False, f"defeature (removeAllDuplicates) raised: {e}"
207
+ n_vols_after = len(gmsh.model.getEntities(3))
208
+ if n_vols_before > 0 and n_vols_after == 0:
209
+ return False, "defeature (removeAllDuplicates) yielded 0 volume entities (solid lost)"
210
+ return True, ""
211
+
212
+
213
+ def heal_and_mesh(step_path: str, out_msh: str, target_nodes: int = DEFAULT_TARGET_NODES,
214
+ defeature: bool = False) -> dict:
215
+ """Mesh the largest solid of `step_path` near `target_nodes` nodes.
216
+
217
+ Pass 1 meshes the geometry as imported (healing can destroy a solid
218
+ that would have meshed fine, so it is not applied by default). Only if
219
+ pass 1 fails outright does pass 2, in a fresh Gmsh session, heal the
220
+ geometry and retry the same density search. `defeature=True` runs
221
+ `_apply_defeature` before meshing in both passes.
222
+
223
+ Returns {n_solids, volume_mm3, bbox_diag_mm, n_nodes, ok, healed, notes,
224
+ attempts}; `notes` collects human-readable diagnostics.
225
+ """
226
+ import gmsh
227
+
228
+ notes: list[str] = []
229
+
230
+ # ---- Pass 1: unhealed ----
231
+ gmsh.initialize()
232
+ try:
233
+ gmsh.option.setNumber("General.Terminal", 0)
234
+ gmsh.model.add("part")
235
+ n_solids, volume_mm3, err = _import_and_reduce(step_path)
236
+ if err is not None:
237
+ notes.append(err)
238
+ return dict(n_solids=0, volume_mm3=0.0, bbox_diag_mm=0.0, n_nodes=0,
239
+ ok=False, healed=False, notes="; ".join(notes), attempts=0)
240
+ if n_solids > 1:
241
+ notes.append(f"multi-body: kept largest of {n_solids} solids (vol={volume_mm3:.1f} mm3)")
242
+
243
+ if defeature:
244
+ defeature_ok, defeature_note = _apply_defeature()
245
+ if defeature_note:
246
+ notes.append(defeature_note)
247
+ if not defeature_ok:
248
+ return dict(n_solids=n_solids, volume_mm3=volume_mm3, bbox_diag_mm=0.0,
249
+ n_nodes=0, ok=False, healed=False, notes="; ".join(notes),
250
+ attempts=0)
251
+
252
+ xmin, ymin, zmin, xmax, ymax, zmax = gmsh.model.getBoundingBox(-1, -1)
253
+ bbox_diag_mm = float(np.linalg.norm([xmax - xmin, ymax - ymin, zmax - zmin]))
254
+ base_size = _first_guess_elem_size(bbox_diag_mm, target_nodes)
255
+
256
+ fail_notes: list[str] = []
257
+
258
+ def build_fn(msh_path, elem_size):
259
+ size = elem_size if elem_size is not None else base_size
260
+ ok_, n_nodes_, note_ = _tag_and_mesh(size, msh_path)
261
+ if not ok_:
262
+ fail_notes.append(note_)
263
+ return (n_nodes_ if ok_ else 0), size
264
+
265
+ n_nodes, _used_size, attempts = _search_target_nodes(build_fn, out_msh, target_nodes)
266
+ if n_nodes > 0:
267
+ return dict(n_solids=n_solids, volume_mm3=volume_mm3, bbox_diag_mm=bbox_diag_mm,
268
+ n_nodes=n_nodes, ok=True, healed=False, notes="; ".join(notes),
269
+ attempts=attempts)
270
+ notes.append(f"unhealed mesh attempt failed: {fail_notes[-1] if fail_notes else 'unknown'}")
271
+ finally:
272
+ gmsh.finalize()
273
+
274
+ # ---- Pass 2: heal + density-searched retry (fresh session) ----
275
+ healed = False
276
+ gmsh.initialize()
277
+ try:
278
+ gmsh.option.setNumber("General.Terminal", 0)
279
+ gmsh.model.add("part_healed")
280
+ n_solids2, volume_mm32, err2 = _import_and_reduce(step_path)
281
+ if err2 is not None:
282
+ notes.append(f"pass-2 {err2}")
283
+ return dict(n_solids=n_solids, volume_mm3=volume_mm3, bbox_diag_mm=bbox_diag_mm,
284
+ n_nodes=0, ok=False, healed=False, notes="; ".join(notes),
285
+ attempts=attempts)
286
+
287
+ if defeature:
288
+ defeature_ok2, defeature_note2 = _apply_defeature()
289
+ if defeature_note2:
290
+ notes.append(defeature_note2)
291
+ if not defeature_ok2:
292
+ return dict(n_solids=n_solids, volume_mm3=volume_mm3, bbox_diag_mm=bbox_diag_mm,
293
+ n_nodes=0, ok=False, healed=False, notes="; ".join(notes),
294
+ attempts=attempts)
295
+
296
+ heal_ok, heal_note = _apply_heal()
297
+ if heal_note:
298
+ notes.append(heal_note)
299
+ if not heal_ok:
300
+ notes.append("meshing failed after heal attempt")
301
+ return dict(n_solids=n_solids, volume_mm3=volume_mm3, bbox_diag_mm=bbox_diag_mm,
302
+ n_nodes=0, ok=False, healed=False, notes="; ".join(notes),
303
+ attempts=attempts)
304
+ healed = True
305
+
306
+ fail_notes2: list[str] = []
307
+
308
+ def build_fn2(msh_path, elem_size):
309
+ size = elem_size if elem_size is not None else base_size
310
+ ok_, n_nodes_, note_ = _tag_and_mesh(size, msh_path)
311
+ if not ok_:
312
+ fail_notes2.append(note_)
313
+ return (n_nodes_ if ok_ else 0), size
314
+
315
+ n_nodes2, _used_size2, attempts2 = _search_target_nodes(build_fn2, out_msh, target_nodes)
316
+ if n_nodes2 > 0:
317
+ notes.append("meshed after heal")
318
+ return dict(n_solids=n_solids, volume_mm3=volume_mm3, bbox_diag_mm=bbox_diag_mm,
319
+ n_nodes=n_nodes2, ok=True, healed=True, notes="; ".join(notes),
320
+ attempts=attempts + attempts2)
321
+ notes.append(f"post-heal mesh attempt failed: {fail_notes2[-1] if fail_notes2 else 'unknown'}")
322
+ notes.append("meshing failed after heal attempt")
323
+ return dict(n_solids=n_solids, volume_mm3=volume_mm3, bbox_diag_mm=bbox_diag_mm,
324
+ n_nodes=0, ok=False, healed=healed, notes="; ".join(notes),
325
+ attempts=attempts + attempts2)
326
+ finally:
327
+ gmsh.finalize()
328
+
329
+
330
+ def auto_elem_size(bbox_diag: float) -> float:
331
+ """Default fixed element size for `mesh_step`: bounding-box diagonal / 60."""
332
+ return bbox_diag / 60.0
333
+
334
+
335
+ def mesh_step(step_path: str, out_msh: str, elem_size: float | None = None) -> dict:
336
+ """Mesh the whole STEP model at one fixed element size (no healing, no
337
+ density search). Every surface is tagged "Cooling"."""
338
+ import gmsh
339
+
340
+ gmsh.initialize()
341
+ try:
342
+ gmsh.option.setNumber("General.Terminal", 0)
343
+ gmsh.model.add("casting")
344
+ gmsh.model.occ.importShapes(step_path)
345
+ gmsh.model.occ.synchronize()
346
+
347
+ xmin, ymin, zmin, xmax, ymax, zmax = gmsh.model.getBoundingBox(-1, -1)
348
+ bbox = (xmax - xmin, ymax - ymin, zmax - zmin)
349
+ diag = float(np.linalg.norm(bbox))
350
+ size = float(elem_size) if elem_size is not None else auto_elem_size(diag)
351
+ gmsh.option.setNumber("Mesh.MeshSizeMin", size)
352
+ gmsh.option.setNumber("Mesh.MeshSizeMax", size)
353
+
354
+ vols = [t for (d, t) in gmsh.model.getEntities(3)]
355
+ surfs = [t for (d, t) in gmsh.model.getEntities(2)]
356
+ pv = gmsh.model.addPhysicalGroup(3, vols); gmsh.model.setPhysicalName(3, pv, "Volume")
357
+ pc = gmsh.model.addPhysicalGroup(2, surfs); gmsh.model.setPhysicalName(2, pc, "Cooling")
358
+
359
+ gmsh.model.mesh.generate(3)
360
+ gmsh.option.setNumber("Mesh.MshFileVersion", 2.2)
361
+ gmsh.write(out_msh)
362
+
363
+ node_tags, _, _ = gmsh.model.mesh.getNodes()
364
+ tet_tags = gmsh.model.mesh.getElementsByType(4)[0]
365
+ return {"n_nodes": len(node_tags), "n_tets": len(tet_tags),
366
+ "bbox_mm": bbox, "elem_size": size}
367
+ finally:
368
+ gmsh.finalize()
src/metrics.py DELETED
@@ -1,50 +0,0 @@
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_api.py CHANGED
@@ -1,53 +1,80 @@
1
- """Mesh-in, field-out entry point for the published v5.1 model.
2
 
3
  from src.model_api import CastSolidModel
4
  model = CastSolidModel() # loads weights/ once
5
  seconds = model.predict("part.msh", alloy="G20Mn5")
6
 
7
- `predict` takes a gmsh 2.2 ASCII tetrahedral mesh (path or a parsed
8
- `src.msh_parse.Mesh`) and returns the solidification time in seconds for every
9
- node, in the mesh's node order. Getting from a STEP file to such a mesh (CAD
10
- healing, density-targeted meshing) is the job of `src.predict_casting`.
11
  """
12
  from __future__ import annotations
13
 
14
  import os
15
 
16
  import numpy as np
 
17
 
18
- from src.inference_props import resolve_props
 
 
 
 
19
  from src.msh_parse import Mesh, parse_msh
20
- from src.predict_casting import DEFAULT_W_STD, DEFAULT_W_TOTAL, load_hybrid, predict_field
 
 
 
 
 
 
 
 
21
 
22
 
23
  class CastSolidModel:
24
  def __init__(self, weights_dir: str = "weights", device: str | None = None):
25
- w = lambda name: os.path.join(weights_dir, name)
26
- self.device = device
27
- (self.gnn, self.gbm_total, self.norm, self.cfg, _,
28
- self.gbm_std) = load_hybrid(w("gnn_v5.1.safetensors"), w("gbm_total_v3.skops"),
29
- device, hgb_std_path=w("gbm_std_v2.skops"))
30
- self.device = device or next(self.gnn.parameters()).device.type
31
-
32
- def predict(self, mesh: str | Mesh, alloy: str | None = None, mold: str = "green_sand",
 
33
  props_json: str | None = None) -> np.ndarray:
34
- """Per-node solidification time (s) for `mesh`.
35
 
36
  `alloy`/`mold` name rows of `data/properties/` (default EN-GJS-400-15
37
- in green sand); `props_json` replaces both with a custom property set.
38
- """
 
 
 
 
 
 
 
39
  if not isinstance(mesh, Mesh):
40
  mesh = parse_msh(os.fspath(mesh))
41
- props = resolve_props(alloy=alloy, mold=mold, props_json=props_json)
42
- seconds, _ = predict_field(mesh, self.gnn, self.gbm_total, self.norm, cfg=self.cfg,
43
- device=self.device, props=props, hgb_std=self.gbm_std,
44
- w_total=DEFAULT_W_TOTAL, w_std=DEFAULT_W_STD)
45
- return np.asarray(seconds)
 
 
 
 
 
 
 
 
46
 
47
  @staticmethod
48
  def hotspots(seconds: np.ndarray, top_frac: float = 0.1) -> np.ndarray:
49
- """Boolean mask of the `top_frac` latest-solidifying nodes (the same
50
- rule as the `hotspot` field `src.predict_casting.write_vtu` writes)."""
51
  seconds = np.asarray(seconds, dtype=np.float32)
52
  k = max(1, int(round(seconds.shape[0] * top_frac)))
53
  return seconds >= np.sort(seconds)[-k]
 
1
+ """Mesh in, solidification-time field out.
2
 
3
  from src.model_api import CastSolidModel
4
  model = CastSolidModel() # loads weights/ once
5
  seconds = model.predict("part.msh", alloy="G20Mn5")
6
 
7
+ `predict` takes a gmsh 2.2 ASCII tetrahedral mesh (a path or a parsed
8
+ `src.msh_parse.Mesh`) whose cooling surface is tagged "Cooling", and returns
9
+ the solidification time in seconds for every node, in the mesh's node order.
 
10
  """
11
  from __future__ import annotations
12
 
13
  import os
14
 
15
  import numpy as np
16
+ import torch
17
 
18
+ from src import gbm_heads
19
+ from src.features import alloy_g_vector, node_features
20
+ from src.hybrid import hybrid_field_soft
21
+ from src.inference_props import DEFAULT_MOLD, resolve_props
22
+ from src.model_gnn import load_gnn, predict_graph_seconds
23
  from src.msh_parse import Mesh, parse_msh
24
+ from src.release_io import load_gbm, load_gnn_state
25
+
26
+ # Recomposition weights of the published model (see src/hybrid.py).
27
+ W_TOTAL = 1.0
28
+ W_STD = 4.0
29
+
30
+ GNN_FILE = "gnn_v5.1.safetensors"
31
+ TOTAL_HEAD_FILE = "gbm_total_v3.skops"
32
+ SPREAD_HEAD_FILE = "gbm_std_v2.skops"
33
 
34
 
35
  class CastSolidModel:
36
  def __init__(self, weights_dir: str = "weights", device: str | None = None):
37
+ self.device = device or ("cuda" if torch.cuda.is_available() else "cpu")
38
+ state = load_gnn_state(os.path.join(weights_dir, GNN_FILE), self.device)
39
+ self.gnn, self.cfg, self.norm = load_gnn(state, self.device)
40
+ self.total_head = load_gbm(os.path.join(weights_dir, TOTAL_HEAD_FILE))
41
+ self.spread_head = load_gbm(os.path.join(weights_dir, SPREAD_HEAD_FILE))
42
+ gbm_heads.check_head(self.total_head, gbm_heads.N_TOTAL_FEATURES, "total-time")
43
+ gbm_heads.check_head(self.spread_head, gbm_heads.N_SPREAD_FEATURES, "spread")
44
+
45
+ def predict(self, mesh: str | Mesh, alloy: str | None = None, mold: str = DEFAULT_MOLD,
46
  props_json: str | None = None) -> np.ndarray:
47
+ """Per-node solidification time (s).
48
 
49
  `alloy`/`mold` name rows of `data/properties/` (default EN-GJS-400-15
50
+ in green sand); `props_json` replaces both with your own values."""
51
+ props = resolve_props(alloy=alloy, mold=mold, props_json=props_json)
52
+ return self.predict_with_props(mesh, props)[0]
53
+
54
+ def predict_with_props(self, mesh: str | Mesh, props: dict) -> tuple[np.ndarray, dict]:
55
+ """(seconds, details) for an already-resolved property dict.
56
+
57
+ `details` holds the node feature matrix `X`, the heads' `total_s` and
58
+ `spread_s`, and the recomposition diagnostics `recomposition`."""
59
  if not isinstance(mesh, Mesh):
60
  mesh = parse_msh(os.fspath(mesh))
61
+ feat = node_features(mesh, props["mold_effusivity"])
62
+ X = feat["X"]
63
+ gnn_seconds = predict_graph_seconds(self.gnn, self.cfg, self.norm, X, feat["pos"],
64
+ feat["mesh_edges"], alloy_g_vector(props),
65
+ device=self.device)
66
+ row = gbm_heads.total_row(props, X)
67
+ total = gbm_heads.predict_total_seconds(self.total_head, row)
68
+ spread = gbm_heads.predict_spread_seconds(self.spread_head,
69
+ gbm_heads.spread_row(row, X))
70
+ seconds, info = hybrid_field_soft(gnn_seconds, total, spread,
71
+ w_total=W_TOTAL, w_std=W_STD)
72
+ return seconds, {"X": X, "total_s": total, "spread_s": spread,
73
+ "recomposition": info}
74
 
75
  @staticmethod
76
  def hotspots(seconds: np.ndarray, top_frac: float = 0.1) -> np.ndarray:
77
+ """Boolean mask of the `top_frac` latest-solidifying nodes."""
 
78
  seconds = np.asarray(seconds, dtype=np.float32)
79
  k = max(1, int(round(seconds.shape[0] * top_frac)))
80
  return seconds >= np.sort(seconds)[-k]
src/model_gnn.py CHANGED
@@ -1,29 +1,25 @@
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)
@@ -40,12 +36,12 @@ class ProcessorBlock(nn.Module):
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)
@@ -53,84 +49,88 @@ class FiLM(nn.Module):
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)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """The graph network: an encode-process-decode GNN in the style of
2
+ MeshGraphNets, conditioned on the alloy/mold vector through FiLM.
3
+
4
+ It predicts the standardized log1p solidification time of every node. The
5
+ graph is the union of the tetrahedral mesh edges and each node's k nearest
6
+ neighbours; an edge carries its displacement vector (mm), its length and a
7
+ flag telling whether it is a mesh edge.
8
+ """
9
+ import numpy as np
10
  import torch
11
  import torch.nn as nn
12
+ from torch_geometric.nn import knn_graph
13
+ from torch_geometric.utils import scatter, to_undirected
14
 
15
 
16
  def _mlp(i, h, o):
17
  return nn.Sequential(nn.Linear(i, h), nn.ReLU(), nn.Linear(h, o))
18
 
19
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
20
  class ProcessorBlock(nn.Module):
21
+ """One message-passing step with residual edge and node updates."""
22
+
23
  def __init__(self, h):
24
  super().__init__()
25
  self.edge_mlp = _mlp(3 * h, h, h)
 
36
 
37
 
38
  class FiLM(nn.Module):
39
+ """g -> per-channel (gamma, beta); applied as h * (1 + gamma) + beta."""
40
+
41
  def __init__(self, g_dim, hidden):
42
  super().__init__()
43
  self.net = nn.Sequential(nn.Linear(g_dim, hidden), nn.SiLU(),
44
+ nn.Linear(hidden, 2 * hidden))
 
45
 
46
  def forward(self, h, g_nodes):
47
  gamma, beta = self.net(g_nodes).chunk(2, dim=-1)
 
49
 
50
 
51
  class SolidGNN(nn.Module):
52
+ """12 processor blocks (hidden width 128 in the published model), FiLM
53
+ after each block, and a two-head decoder: a per-node deviation head and
54
+ a graph-level scale head on the mean node embedding."""
55
+
56
+ def __init__(self, node_in=15, edge_in=5, hidden=128, n_blocks=12, g_dim=5):
57
  super().__init__()
58
  self.node_encoder = nn.Sequential(_mlp(node_in, hidden, hidden), nn.LayerNorm(hidden))
59
  self.edge_encoder = nn.Sequential(_mlp(edge_in, hidden, hidden), nn.LayerNorm(hidden))
60
  self.blocks = nn.ModuleList([ProcessorBlock(hidden) for _ in range(n_blocks)])
61
  self.decoder = _mlp(hidden, hidden // 2, 1)
62
+ self.scale_head = _mlp(hidden, hidden // 2, 1)
63
+ self.films = nn.ModuleList([FiLM(g_dim, hidden) for _ in range(n_blocks)])
64
+
65
+ def forward(self, x, edge_index, edge_attr, batch, g):
 
 
 
 
 
 
 
 
 
 
 
 
66
  h = self.node_encoder(x)
67
  e = self.edge_encoder(edge_attr)
68
+ g_nodes = g[batch]
69
  for i, blk in enumerate(self.blocks):
70
  h, e = blk(h, edge_index, e)
71
+ h = self.films[i](h, g_nodes)
 
 
 
72
  y_nodal = self.decoder(h).squeeze(-1)
73
+ dev = y_nodal - scatter(y_nodal, batch, dim=0, reduce="mean")[batch]
74
+ scale = self.scale_head(scatter(h, batch, dim=0, reduce="mean")).squeeze(-1)
75
+ return dev + scale[batch]
76
+
77
+
78
+ def knn_edge_index(pos: torch.Tensor, k: int = 16) -> torch.Tensor:
79
+ ei = knn_graph(pos, k=k, loop=False) # directed k-nearest
80
+ return to_undirected(ei) # symmetric
81
+
82
+
83
+ def union_edge_index(pos: torch.Tensor, mesh_edges: torch.Tensor, k: int = 16):
84
+ """Undirected union of the mesh edges and kNN edges, deduplicated.
85
+
86
+ mesh_edges: (M, 2) long tensor of unique node pairs (any orientation).
87
+ Returns (edge_index (2, E) long, is_mesh (E,) float32 flag)."""
88
+ n = pos.shape[0]
89
+ knn = knn_edge_index(pos, k=k).to(torch.long)
90
+ me = mesh_edges.t().to(torch.long)
91
+ me = torch.cat([me, me.flip(0)], dim=1) # both directions
92
+ mesh_key = me[0] * n + me[1]
93
+ keys = torch.unique(torch.cat([mesh_key, knn[0] * n + knn[1]]))
94
+ ei = torch.stack([keys // n, keys % n])
95
+ is_mesh = torch.isin(keys, mesh_key).to(torch.float32)
96
+ return ei, is_mesh
97
+
98
+
99
+ def union_edge_attr(pos: torch.Tensor, edge_index: torch.Tensor,
100
+ is_mesh: torch.Tensor) -> torch.Tensor:
101
+ """(E, 5): displacement (3), length, mesh-edge flag."""
102
+ src, dst = edge_index
103
+ d = pos[dst] - pos[src]
104
+ return torch.cat([d, d.norm(dim=1, keepdim=True), is_mesh.unsqueeze(1)], dim=1)
105
+
106
+
107
+ def load_gnn(state: dict, device):
108
+ """Build a `SolidGNN` from a `{model, norm, cfg}` dict (see
109
+ `src.release_io.load_gnn_state`). Returns (model, cfg, norm)."""
110
+ cfg, norm = state["cfg"], state["norm"]
111
+ model = SolidGNN(node_in=cfg["node_in"], edge_in=cfg["edge_in"], hidden=cfg["hidden"],
112
+ n_blocks=cfg["n_blocks"], g_dim=cfg.get("g_dim", 5)).to(device)
113
+ model.load_state_dict(state["model"])
114
+ model.eval()
115
+ return model, cfg, norm
116
+
117
+
118
+ def predict_graph_seconds(model, cfg, norm, X, pos_np, mesh_edges, g_vec,
119
+ device="cpu") -> np.ndarray:
120
+ """Per-node solidification time (s) predicted by the GNN alone.
121
+
122
+ `X` is the raw (N, 15) feature matrix, `g_vec` the raw (5,) alloy/mold
123
+ vector; standardization uses the statistics stored with the weights."""
124
+ fmean = torch.tensor(norm["feat_mean"], dtype=torch.float32)
125
+ fstd = torch.tensor(norm["feat_std"], dtype=torch.float32)
126
+ x = (torch.tensor(X, dtype=torch.float32) - fmean) / fstd
127
+ pos = torch.tensor(pos_np, dtype=torch.float32)
128
+ me = torch.tensor(mesh_edges, dtype=torch.long)
129
+ ei, is_mesh = union_edge_index(pos, me, k=cfg["k"])
130
+ ea = union_edge_attr(pos, ei, is_mesh)
131
+ zeros = torch.zeros(x.shape[0], dtype=torch.long, device=device)
132
+ g = torch.tensor(np.asarray(g_vec), dtype=torch.float32).reshape(1, -1).to(device)
133
+ with torch.no_grad():
134
+ yn = model(x.to(device), ei.to(device), ea.to(device), zeros, g)
135
+ pred_log = yn.cpu().numpy() * norm["y_log_std"] + norm["y_log_mean"]
136
+ return np.clip(np.expm1(pred_log), 0.0, None)
src/msh_parse.py CHANGED
@@ -1,8 +1,8 @@
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
 
1
+ """Reader for gmsh 2.2 ASCII meshes.
2
 
3
+ Extracts node coordinates (mm, in the file's node order), surface triangles
4
+ grouped by physical name ("Cooling", optionally "Adiabatic") and volume
5
+ tetrahedra. Connectivity is remapped to 0-based node indices.
6
  """
7
  from dataclasses import dataclass
8
  import numpy as np
src/ood.py CHANGED
@@ -1,33 +1,7 @@
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:
 
1
+ """Out-of-distribution size check: compare a casting's volume, bounding-box
2
+ diagonal and largest distance to cooling with the training maxima
3
+ (`weights/ood_ranges.json`) and warn when any of them is exceeded."""
4
  import json
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
5
 
6
 
7
  def load_ranges(path: str) -> dict:
src/predict_casting.py CHANGED
@@ -1,387 +1,40 @@
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.
88
-
89
- bc-reach Task 4 (cfg-driven v5 feature building at inference): the film
90
- branch's `compute_features_inference` call now reads
91
- `version=cfg.get("feature_version", 4)` instead of hardcoding `version=4`
92
- -- the SAME `cfg.get("feature_version", ...)` pattern the non-film branch
93
- already used (this module, the SP1/non-film path). A v5 (bc-reach)
94
- checkpoint's saved cfg carries `feature_version=5` (`src.train_gnn`'s
95
- `_build_checkpoint_dict`, echoed verbatim from `train(feature_version=...)`,
96
- independent of but in practice equal to `node_in`), so loading one through
97
- `load_hybrid`/`load_ft2b_gnn` and calling `predict_field` now builds the
98
- 20-column v5 `X` (`src.features.FEATURE_NAMES_V5`: v4's 15 columns +
99
- `exo_mid`/`exo_long`/`chill_short`/`chill_mid`/`chill_long`) with NO other
100
- change to this module -- `compute_bc_reach_channels` (features v5) already
101
- resolves its chill/exo source patch off the SAME `spec`/`bc_spec` dicts
102
- this module already builds from the all-sand literal, so the reach channels see EXACTLY the same source magnitudes the
103
- training builder (`src.build_dataset.build_v5`) does. A v4 (or absent)
104
- `cfg["feature_version"]` is byte-identical to the pre-Task-4 code (defaults
105
- to `4`, same as before this dispatch existed)."""
106
- import hashlib
107
- import json
108
  import logging
 
109
  import os.path as osp
110
- import numpy as np
111
- import torch
112
- import joblib
113
 
114
- logger = logging.getLogger(__name__)
115
 
116
- from src.features import (compute_features_inference, FEATURE_NAMES, FEATURE_NAMES_V4,
117
- alloy_g_vector)
118
- from src.dataload import standardize
119
- from src.baseline import predict_seconds
120
- from src.evaluate_gnn import predict_graph_seconds, load_gnn, N_BASELINE_FEATURES
121
- from src.evaluate_ft2b import predict_graph_seconds as predict_graph_seconds_v4, load_ft2b_gnn
122
- from src.evaluate_ft2b import predict_graph_derivs as predict_graph_derivs_v4
123
- from src.gbm_ft2b import (gbm_v2_total_seconds, FEATURE_NAMES as GBM_FEATURE_NAMES,
124
- predict_std_seconds, build_v2_feature_row,
125
- bc_area_frac_from_graph)
126
- from src.hybrid import hybrid_field, hybrid_field_max_anchor, hybrid_field_soft
127
- from src.inference_props import resolve_props, DEFAULT_ALLOY, resolve_alloy_family
128
  from src.shrinkage import screen_casting
129
- from src.release_io import resolve_artifact, load_gbm, load_gnn_any
130
-
131
- N_GBM_V2_FEATURES = len(GBM_FEATURE_NAMES)
132
- _V4_VOL_IDX = FEATURE_NAMES_V4.index("volume_mm3")
133
- _V4_AREA_IDX = FEATURE_NAMES_V4.index("surface_area_mm2")
134
-
135
- # Production v5.1 hybrid stack (promoted 2026-07-31, soft-anchor Task 6 -- see
136
- # docs/superpowers/specs/2026-07-31-soft-anchor-design.md's ADDENDUM) -- module
137
- # constants so CLI/kwarg defaults live in one place instead of being duplicated
138
- # string literals. GNN field *shape* x GBM *scale* (total) x GBM *spread* (std,
139
- # std_v2 -- median-loss HGB, 17 features incl. bc_area_frac), recomposed via the
140
- # soft-anchor map (src.hybrid.hybrid_field_soft) at w_total=1.0, w_std=4.0
141
- # (the e_val sweep winner: v2+soft takes gold decisively -- BUKSA 90.2 s/0.700,
142
- # H17 120.1 s/0.895, best-ever MAE on both -- missing only the (owner-revised,
143
- # see tools/ft3_gates.py's DEFAULT_E_VAL_STD_RATIO_FLOOR) e_val exo std_ratio floor).
144
- #
145
- # Rollback #1 (v5, 2026-07-31 same day, pre-soft-anchor-sweep): ckpt_ep021 x
146
- # hgb_v3 x hgb_std_v1 (squared-error, 16 features), w_total=1.0, w_std=1.0 --
147
- # reachable via `--hgb-std runs/ft3_gbm/hgb_std_v1.joblib --w-std 1.0`
148
- # (hgb_std_v1.joblib is left untouched on disk by this promotion).
149
- # Rollback #2 (previous production, 2026-07-28..2026-07-31): ckpt_ep054 x hgb_v2,
150
- # quantile-anchor recomposition (no std head) -- reachable via
151
- # `--gnn runs/ft2b_stage2c/ckpt_ep054.pt --hgb runs/ft2b_gbm/hgb_v2.joblib --hgb-std none`.
152
- # Rollback #3 (SP1, still on disk): runs/sp1_run3_final/best_gnn.pt +
153
- # runs/baseline_v2/hgb_A.joblib, non-film -- reachable via --gnn/--hgb; --hgb-std needs
154
- # no extra flag here, `load_hybrid` auto-resolves the defaulted std head to None for a
155
- # non-film checkpoint (see `load_hybrid`'s docstring / the `_USE_DEFAULT` sentinel below).
156
- DEFAULT_GNN_CKPT = "runs/ft3_stage1/ckpt_ep021.pt"
157
- DEFAULT_HGB = "runs/ft3_gbm/hgb_v3.joblib"
158
- DEFAULT_HGB_STD = "runs/ft3_gbm/hgb_std_v2.joblib"
159
- DEFAULT_W_TOTAL = 1.0
160
- DEFAULT_W_STD = 4.0
161
-
162
-
163
- class _UseDefaultHgbStd:
164
- """Sentinel for `predict_casting`'s `hgb_std_path` param / CLI `--hgb-std`:
165
- means "the caller did not specify" -- `load_hybrid` resolves this to
166
- `DEFAULT_HGB_STD` when the selected checkpoint is film-capable, or to
167
- `None` when it isn't (so the SP1 rollback via `--gnn`/`--hgb` needs no
168
- extra flag). Distinct from an explicit `None` or the string `"none"`
169
- (case-insensitive), both of which ALWAYS disable the std head -- the
170
- explicit rollback switch to the quantile-anchor recomposition, usable on
171
- any film checkpoint including the new default one."""
172
-
173
- def __repr__(self):
174
- return "<DEFAULT_HGB_STD>"
175
-
176
-
177
- _USE_DEFAULT = _UseDefaultHgbStd()
178
-
179
-
180
- def _check_hgb_dim(hgb_model, expected: int, ckpt_kind: str) -> None:
181
- """Loud, clear failure on a mismatched (film vs. SP1) GNN/HGB pairing.
182
-
183
- `hgb.n_features_in_` is set by sklearn on `.fit()`; a stub without it
184
- (shouldn't happen for a real fitted estimator) is treated as "unknown"
185
- and NOT checked, rather than crashing the attribute lookup itself.
186
- """
187
- n_have = getattr(hgb_model, "n_features_in_", None)
188
- if n_have is None or n_have == expected:
189
- return
190
- raise ValueError(
191
- f"predict_field: {ckpt_kind} checkpoint expects an HGB baseline with "
192
- f"{expected} input features, but hgb_model.n_features_in_={n_have!r} -- "
193
- f"this looks like the wrong baseline for this checkpoint (a v2-total "
194
- f"GBM has {N_GBM_V2_FEATURES} features, an SP1 per-node HGB has "
195
- f"{N_BASELINE_FEATURES}); pass the matching --hgb for this --gnn checkpoint.")
196
 
 
197
 
198
- def _std_row_for_predict_field(row_16: np.ndarray, X: np.ndarray, n_features: int
199
- ) -> np.ndarray:
200
- """`predict_field`'s in-memory counterpart to `src.gbm_ft2b.std_feature_row`
201
- (soft-anchor Task 3): width-dispatches the std-head row off the loaded
202
- std model's own `n_features_in_`, without a manifest stem or graph-cache
203
- file to read from -- `predict_field` already has everything both widths
204
- need in memory.
205
-
206
- - `n_features == 16` -> `row_16` unchanged (the SAME 16-column row
207
- `predict_field`'s total head already scores -- byte-identical to the
208
- pre-soft-anchor code).
209
- - `n_features == 17` -> `row_16` + `src.gbm_ft2b.bc_area_frac_from_graph`'s
210
- 17th column, computed from the SAME in-memory per-node v4 feature
211
- array `X` (`bc_area_frac_from_graph` only ever indexes `graph["X"]`, so
212
- a plain `{"X": X}` dict satisfies its contract without a real graph
213
- npz).
214
- - anything else -> a loud `ValueError` naming the two supported widths
215
- (mirrors `std_feature_row`'s own contract).
216
- """
217
- if n_features not in (16, 17):
218
- raise ValueError(
219
- f"predict_field: unsupported std-head n_features_in_={n_features!r} -- "
220
- f"the std head only supports 16 (v1) or 17 (v2, v1 + bc_area_frac) "
221
- f"feature columns")
222
- if n_features == 16:
223
- return row_16
224
- area_frac = bc_area_frac_from_graph({"X": X})
225
- return np.concatenate([row_16, [area_frac]])
226
-
227
-
228
- def predict_field(mesh, gnn_model, hgb_model, norm, cfg=None, device=None, tta=0,
229
- k=16, baseline_norm=None, props=None, hgb_std=None,
230
- lambda_shrink: float = 1.0, w_total: float = DEFAULT_W_TOTAL,
231
- w_std: float = DEFAULT_W_STD) -> tuple:
232
- """The film branch always uses the all-sand boundary spec: the whole
233
- outer surface cools into the mold, and the model's chill/sleeve input
234
- channels stay at zero (this release supports the sand-mold regime only).
235
-
236
- `hgb_std` (variance-recomposition Task 3, recomposition swapped to
237
- `hybrid_field_soft` by soft-anchor Task 3): an optional loaded GBM
238
- std-head model (`src.gbm_ft2b.refit_gbm_std`/`refit_gbm_std_v2`'s
239
- output -- e.g. loaded via `load_hybrid`'s `hgb_std_path`). Only
240
- consulted on the film (v4) path, and only once the total head has
241
- produced a valid `total`: the 16-column total-head row is built ONCE
242
- (`src.gbm_ft2b.build_v2_feature_row`, the SAME public row-builder
243
- `gbm_v2_total_seconds` itself uses, so a `FEATURE_NAMES` reorder can
244
- never silently desync the two heads) and fed to the total head
245
- (`hgb_model.predict` directly, mathematically identical to
246
- `gbm_v2_total_seconds`); the std head is scored on a SEPARATE row,
247
- width-dispatched off `hgb_std.n_features_in_` (16 -> that same row
248
- unchanged, 17 -> that row + a graph-derived `bc_area_frac` 17th column,
249
- via `_std_row_for_predict_field` -- else a loud `ValueError`), then
250
- recomposed via `src.hybrid.hybrid_field_soft` instead of
251
- `hybrid_field_max_anchor`.
252
- `None` (the default) is BYTE-IDENTICAL to the pre-Task-3 code (the
253
- `total = gbm_v2_total_seconds(...)` / `hybrid_field_max_anchor` path
254
- below is untouched). When active, the returned `feat` dict gains
255
- `feat["recomposition"] = "soft"` and `feat["recomposition_info"]`
256
- (`hybrid_field_soft`'s `info`: `a`/`m`/`target_unreachable`/
257
- `degenerate_fallback`/`clip_frac`/`total_deviation_frac`/
258
- `j_total_resid`/`j_std_resid`/`lambda_shrink`/`std_target_effective`)
259
- -- absent entirely when `hgb_std` is `None`. Ignored entirely by the
260
- non-film (SP1) path -- SP1 has no scale-head std target.
261
-
262
- `lambda_shrink` (variance-recomposition amendment 2): forwarded straight
263
- to `hybrid_field_soft` (ignored when `hgb_std` is `None`, same as every
264
- other soft-anchor-only knob here). `1.0` (the default) is byte-identical
265
- to before this argument existed.
266
-
267
- `w_total`/`w_std` (soft-anchor Task 3; defaults changed to `DEFAULT_W_TOTAL`/
268
- `DEFAULT_W_STD` by the soft-anchor Task 6 promotion, 2026-07-31): forwarded
269
- straight to `src.hybrid.hybrid_field_soft`'s own weights (ignored when
270
- `hgb_std` is `None`). The defaults here (`1.0`/`4.0`) are the e_val-sweep
271
- winner recorded in the spec addendum -- NOT `hybrid_field_soft`'s own
272
- function-level default of `1.0`/`1.0` (that default is unchanged; a
273
- caller invoking `hybrid_field_soft` directly, bypassing `predict_field`,
274
- still gets equal weighting unless it passes these explicitly).
275
-
276
- Learned-heads Task 3: when `cfg["deriv_heads"]` is truthy (a heads
277
- checkpoint, film path only -- SP1 has no deriv heads at all), `feat`
278
- additionally carries `feat["gradT"]`/`feat["dTdt"]` -- the checkpoint's
279
- OWN learned per-node gradT (K/m) / dTdt (K/s) predictions
280
- (`src.evaluate_ft2b.predict_graph_derivs`), on the SAME features the
281
- shape head just scored. This is a `feat`-dict addition only -- the
282
- `(pred, feat)` 2-tuple return contract is unchanged, so every existing
283
- `out, feat = predict_field(...)` call site keeps working unmodified.
284
- Absent entirely for a headless checkpoint (the default): `"gradT"`/
285
- `"dTdt"` simply aren't keys of `feat` then, exactly as before this
286
- feature existed. `predict_casting` reads these (if present) and feeds
287
- them to `screen_casting`'s exact Niyama branch, labeling the result
288
- `niyama_source="learned"` (see that function's own docstring)."""
289
- device = device or ("cuda" if torch.cuda.is_available() else "cpu")
290
- cfg = cfg or {"k": k, "graph": "knn", "feature_version": 2}
291
- gnn_model = gnn_model.to(device).eval()
292
-
293
- if cfg.get("film", False):
294
- if props is None:
295
- raise ValueError(
296
- "predict_field: cfg indicates a film (v4) checkpoint but no "
297
- "`props` (alloy/mould conditioning dict) were given")
298
- if hgb_model is None:
299
- raise ValueError(
300
- "predict_field: a film (v4) checkpoint requires an hgb_model "
301
- "(the v2-total GBM scale head) -- got None")
302
- _check_hgb_dim(hgb_model, N_GBM_V2_FEATURES, "film (v4)")
303
-
304
- spec = {"mold_effusivity": props["mold_effusivity"], "bc_config": "none",
305
- "chill_effusivity": 0.0, "sleeve_MEF": 0.0, "exo_q0": 0.0}
306
- bc_spec = None
307
- feat = compute_features_inference(mesh, version=cfg.get("feature_version", 4), spec=spec)
308
- X, pos = feat["X"], feat["pos"]
309
- g_vec = alloy_g_vector(props)
310
- gnn_seconds = predict_graph_seconds_v4(gnn_model, cfg, norm, X, pos,
311
- feat["mesh_edges"], g_vec=g_vec,
312
- device=device)
313
-
314
- # Learned-heads Task 3: a deriv_heads-capable checkpoint additionally
315
- # predicts per-node gradT (K/m) / dTdt (K/s) off the SAME features --
316
- # stashed into `feat` (not the return tuple's arity, keeping every
317
- # existing `out, feat = predict_field(...)` call site unchanged) so
318
- # `predict_casting` can feed them to `screen_casting`'s exact branch
319
- # instead of the time-only proxy. Absent entirely (no "gradT"/"dTdt"
320
- # keys in `feat`) for a headless checkpoint -- byte-identical to the
321
- # pre-Task-3 code for every existing (non-heads) checkpoint.
322
- if cfg.get("deriv_heads", False):
323
- gradT_pred, dTdt_pred = predict_graph_derivs_v4(
324
- gnn_model, cfg, norm, X, pos, feat["mesh_edges"], g_vec=g_vec,
325
- device=device)
326
- feat["gradT"] = gradT_pred
327
- feat["dTdt"] = dTdt_pred
328
-
329
- v_over_a = float(X[0, _V4_VOL_IDX]) / float(X[0, _V4_AREA_IDX])
330
-
331
- if hgb_std is not None:
332
- row = build_v2_feature_row(props, v_over_a, bc_spec)
333
- total = float(np.expm1(float(hgb_model.predict(row.reshape(1, -1))[0])))
334
- if total <= 0:
335
- raise ValueError(
336
- f"predict_field: the v2-total GBM head returned a non-positive "
337
- f"total ({total!r}) for props={props!r} -- the v2-total GBM is "
338
- f"broken for this input")
339
- n_std_features = int(getattr(hgb_std, "n_features_in_", 16))
340
- std_row = _std_row_for_predict_field(row, X, n_std_features)
341
- std_seconds = predict_std_seconds(hgb_std, std_row)
342
- pred, info = hybrid_field_soft(gnn_seconds, total, std_seconds,
343
- lambda_shrink=lambda_shrink,
344
- w_total=w_total, w_std=w_std)
345
- feat["recomposition"] = "soft"
346
- feat["recomposition_info"] = info
347
- return pred, feat
348
-
349
- total = gbm_v2_total_seconds(hgb_model, props, v_over_a, bc_spec=bc_spec)
350
- if total <= 0:
351
- raise ValueError(
352
- f"predict_field: gbm_v2_total_seconds returned a non-positive "
353
- f"total ({total!r}) for props={props!r} -- the v2-total GBM is "
354
- f"broken for this input")
355
- return hybrid_field_max_anchor(gnn_seconds, total), feat
356
-
357
- # --- non-film (SP1) path: functionally unchanged (plus explicit HGB-dimension guard) ---
358
- if hgb_model is not None:
359
- _check_hgb_dim(hgb_model, N_BASELINE_FEATURES, "non-film (SP1)")
360
- feat = compute_features_inference(mesh, version=cfg.get("feature_version", 2))
361
- X, pos = feat["X"], feat["pos"]
362
- Xs = standardize(X, norm)
363
- x = torch.tensor(Xs, dtype=torch.float32)
364
- pos_t = torch.tensor(pos, dtype=torch.float32)
365
- me = (torch.tensor(feat["mesh_edges"], dtype=torch.long)
366
- if cfg.get("graph", "knn") == "union" else None)
367
- g = predict_graph_seconds(gnn_model, cfg, norm, x, pos_t, me, device=device, tta=tta)
368
- if hgb_model is None:
369
- return g, feat
370
- bn = baseline_norm if baseline_norm is not None else norm
371
- b = predict_seconds(hgb_model, standardize(X[:, :N_BASELINE_FEATURES], bn), bn)
372
- return hybrid_field(g, b), feat
373
 
374
 
375
  def write_vtu(out_path, node_xyz, tets, seconds, top_frac=0.1, extra_point_data=None):
376
- """`extra_point_data` (SP2 shrinkage Task 4): an optional
377
- `{field_name: (N,) array}` dict merged into the written `point_data`
378
- dict alongside the original `solidification_time`/`hotspot` fields --
379
- e.g. `{"niyama": ..., "porosity_risk": ..., "macro_region_id": ...}`
380
- from `src.shrinkage.screen_casting`. `None` (the default) is
381
- byte-identical to the pre-Task-4 contract (only the original two
382
- fields are written) -- this is a generic extension point, not a
383
- hardcoded shrinkage-only special case, so any future per-node field
384
- can reuse it the same way."""
385
  import meshio
386
  seconds = np.asarray(seconds, dtype=np.float32)
387
  n = seconds.shape[0]
@@ -397,37 +50,20 @@ def write_vtu(out_path, node_xyz, tets, seconds, top_frac=0.1, extra_point_data=
397
  m.write(out_path)
398
 
399
 
400
- # --------------------------------------------------------------------------- #
401
- # SP2 shrinkage Task 4: `predict_casting` integration helpers -- deriving
402
- # screen_casting's mesh-topology inputs from the parsed `.msh`, trimming its
403
- # result to a return-dict-friendly shape, and formatting the summary's risk
404
- # block. Kept as free functions (rather than inlined in `predict_casting`)
405
- # so each piece is independently testable and the main function stays
406
- # readable.
407
- # --------------------------------------------------------------------------- #
408
- def _shrinkage_surface_and_feed_nodes(mesh):
409
- """Controller directive 1 (owner-adjudicated 2026-08-01): `surface_nodes`
410
- = unique nodes of ALL surface triangles (every physical-group tag the
411
- mesh carries -- "Cooling" union "Adiabatic" -- matching `screen_casting`'s
412
- own contract of "the whole free surface", used for sink/feeder
413
- placement). `feed_nodes` = unique nodes of "Adiabatic"-tagged triangles
414
- ONLY (riser/sleeve faces, per `src.shrinkage.is_fed`'s documented
415
- convention) -- empty when the group is absent or empty (a bare sand
416
- casting with no risers, conservatively "everything unfed")."""
417
  tri_arrays = [tri for tri in mesh.tris.values() if tri.size]
418
  surface_nodes = (np.unique(np.concatenate(tri_arrays, axis=0).reshape(-1))
419
  if tri_arrays else np.zeros(0, dtype=np.int64))
420
  adiab_tri = mesh.tris.get("Adiabatic")
421
  feed_nodes = (np.unique(adiab_tri.reshape(-1))
422
- if adiab_tri is not None and adiab_tri.size else np.zeros(0, dtype=np.int64))
423
  return surface_nodes.astype(np.int64), feed_nodes.astype(np.int64)
424
 
425
 
426
  def _shrinkage_result_for_return(screen: dict) -> dict:
427
- """Directive 6: the `screen_casting` result MINUS its bulky per-node
428
- arrays (`niyama`/`porosity_risk` dropped), plus summary counts +
429
- `flagged_frac` -- everything else (`macro_shrinkage`/`top_sinks`/
430
- `feeders`/`material`/`niyama_source`/`per_orientation`) is kept as-is."""
431
  result = {k: v for k, v in screen.items() if k not in ("niyama", "porosity_risk")}
432
  risk = screen["porosity_risk"]
433
  result["flagged_frac"] = float(np.mean(risk)) if risk.size else 0.0
@@ -437,10 +73,7 @@ def _shrinkage_result_for_return(screen: dict) -> dict:
437
  return result
438
 
439
 
440
- def _shrinkage_extra_point_data(mesh, screen: dict) -> dict:
441
- """The 3 VTU point fields directive 4 asks for: `niyama` (float),
442
- `porosity_risk` (uint8 0/1), and `macro_region_id` (-1 = no macro
443
- region, else that region's own `region_id`)."""
444
  n_nodes = mesh.node_xyz.shape[0]
445
  macro_region_id = np.full(n_nodes, -1, dtype=np.int64)
446
  for region in screen["macro_shrinkage"]:
@@ -452,44 +85,19 @@ def _shrinkage_extra_point_data(mesh, screen: dict) -> dict:
452
  }
453
 
454
 
455
- def _write_shrinkage_summary_block(f, shrinkage) -> None:
456
- """Directive 5's exact skeleton: a `shrinkage screening:` section with
457
- niyama_source, flagged-node %, N macro regions (worst severity +
458
- centroid), N sinks, and feeder recommendations (location + min
459
- modulus) -- or a single `shrinkage screening: disabled` line.
460
-
461
- Only called when screening was ATTEMPTED (`screen_shrinkage=True`) but
462
- either produced a result (`shrinkage` is the trimmed dict) or was
463
- internally skipped (`shrinkage` is `None`, e.g. no surface nodes to
464
- place a feeder against) -- `screen_shrinkage=False` (the explicit
465
- opt-out) never calls this at all, so that path stays byte-identical to
466
- the pre-Task-4 summary (see `predict_casting`'s own docstring)."""
467
  if shrinkage is None:
468
  f.write("shrinkage screening: disabled\n")
469
  return
470
  f.write("shrinkage screening:\n")
471
  f.write(f" niyama_source: {shrinkage['niyama_source']}\n")
472
- flagged_line = f" flagged nodes: {shrinkage['flagged_frac'] * 100.0:.1f}%"
473
- if shrinkage["niyama_source"] == "proxy":
474
- # Review round 1, finding 4: the proxy path's risk mask is
475
- # `porosity_risk(mode="percentile")` -- ALWAYS ~the bottom decile by
476
- # construction (src.shrinkage's default `pct=10.0`), a within-part
477
- # relative ranking, not an absolute risk level. Without this note a
478
- # reader could mistake "10.0%" for a measured severity rather than
479
- # the fixed cutoff it structurally always is on this path.
480
- flagged_line += " (bottom decile by construction)"
481
- f.write(flagged_line + "\n")
482
  macro = shrinkage["macro_shrinkage"]
483
  if macro:
484
- # Review finding C1: regions with fewer than 4 nodes
485
- # (`region["sub_resolution"]`, set by `src.shrinkage.hot_regions`)
486
- # are still listed here (their volume/severity stay meaningful) but
487
- # reported SEPARATELY from the well-resolved count -- they never
488
- # get a feeder recommendation below (their V/A modulus diverges
489
- # without bound at this resolution; see `feeder_recommendations`'s
490
- # docstring), so folding them into one undifferentiated N would
491
- # overstate how many of the N regions actually have a usable
492
- # feeder placement.
493
  n_sub_resolution = sum(1 for r in macro if r.get("sub_resolution"))
494
  region_count = str(len(macro))
495
  if n_sub_resolution:
@@ -497,8 +105,8 @@ def _write_shrinkage_summary_block(f, shrinkage) -> None:
497
  worst = max(macro, key=lambda r: r["severity"])
498
  c = worst["centroid"]
499
  f.write(f" macro shrinkage regions: {region_count} "
500
- f"(worst severity={worst['severity']:.4g} mm^3 @ "
501
- f"({c[0]:.1f},{c[1]:.1f},{c[2]:.1f}))\n")
502
  else:
503
  f.write(" macro shrinkage regions: 0\n")
504
  f.write(f" sinks: {len(shrinkage['top_sinks'])}\n")
@@ -508,424 +116,114 @@ def _write_shrinkage_summary_block(f, shrinkage) -> None:
508
  for feeder in feeders:
509
  loc = feeder["location"]
510
  f.write(f" - location=({loc[0]:.1f},{loc[1]:.1f},{loc[2]:.1f}) mm "
511
- f"min_modulus_mm={feeder['min_modulus_mm']:.4f}\n")
512
  else:
513
  f.write(" feeder recommendations: none\n")
514
  if shrinkage.get("alloy_family_assumed"):
515
  f.write(f" alloy_family_assumed: {shrinkage['material']['alloy_family']} "
516
- f"(alloy grade name not recognized -- conservative fallback)\n")
517
-
518
-
519
- def _parse_gravity(s: str) -> tuple:
520
- """CLI `--gravity` parser: `"x,y,z"` -> `(float(x), float(y), float(z))`.
521
- A small free function so it's unit-testable without going through
522
- `argparse`/`sys.argv` -- and (review round 1, finding 5) passed directly
523
- as `argparse`'s own `type=` callable, so a malformed `--gravity` value
524
- surfaces as argparse's normal `ValueError`-driven usage error (exit
525
- code 2, "argument --gravity: invalid _parse_gravity value: ...") rather
526
- than an uncaught traceback deep inside `predict_casting`.
527
-
528
- Rejects the zero vector loudly (`ValueError`): `screen_casting`'s
529
- `top_sinks`/`feeder_recommendations` normalize `gravity_vec` by its own
530
- norm and would divide by zero silently otherwise (`up = up / norm`
531
- guarded only by `norm > 0`, which degrades to a NEVER-normalized,
532
- physically meaningless direction rather than raising) -- a caller
533
- typo'ing `0,0,0` deserves a clear error, not a silently-wrong feeder
534
- placement."""
535
- parts = [p.strip() for p in s.split(",")]
536
- if len(parts) != 3:
537
- raise ValueError(f"--gravity must be 'x,y,z' (3 comma-separated numbers), got {s!r}")
538
- vec = tuple(float(p) for p in parts)
539
- if all(v == 0.0 for v in vec):
540
- raise ValueError(
541
- f"--gravity must not be the zero vector (got {s!r}) -- gravity_vec is "
542
- f"normalized by its own norm for top-sink/feeder placement, and the zero "
543
- f"vector has no direction")
544
- return vec
545
-
546
-
547
- def load_hybrid(gnn_ckpt, hgb_path, device=None,
548
- baseline_features_dir="data/features_v2", hgb_std_path=None):
549
- """Load the GNN + (optional) HGB baseline (+ optional GBM std-head) for
550
- `predict_field`.
551
-
552
- Film (v4) checkpoints load via `src.evaluate_ft2b.load_ft2b_gnn` and skip
553
- `baseline_norm` entirely (v4's HGB is the v2-total scale GBM, standardized
554
- by `src.gbm_ft2b` itself -- there is no separate per-node baseline-feature
555
- norm to load). Non-film checkpoints are loaded exactly as before
556
- (`src.evaluate_gnn.load_gnn`) -- byte-identical to the pre-v4 code.
557
-
558
- `hgb_std_path` (variance-recomposition Task 3): an optional path to a
559
- `joblib`-dumped GBM std-head model (`src.gbm_ft2b.refit_gbm_std`'s
560
- output), loaded exactly like `hgb_path` (`joblib.load` if given, else
561
- `None`). `None` (the default -- unchanged by Task 6, see below) means
562
- the returned `hgb_std` is `None` and every other return value / code
563
- path is byte-identical to before this argument existed.
564
-
565
- `hgb_std_path` may also be (variance-recomposition Task 6):
566
- - the module-level `_USE_DEFAULT` sentinel: resolved HERE, using the
567
- GNN checkpoint's own `cfg["film"]` flag (already being determined
568
- below to pick the loader) -- `DEFAULT_HGB_STD` for a film-capable
569
- checkpoint, `None` for a non-film one. This is what lets
570
- `predict_casting`'s own sentinel default reach the new production
571
- std head with zero flags, while leaving a non-film (SP1) `--gnn`/
572
- `--hgb` rollback untouched: no attempt is made to load
573
- `DEFAULT_HGB_STD` at all in that case, so a missing/irrelevant v4-only
574
- artifact never breaks a non-film call.
575
- - the case-insensitive string `"none"`: always resolved to `None`,
576
- regardless of film-ness -- the explicit rollback switch to the
577
- quantile-anchor recomposition (usable on any film checkpoint,
578
- including the new default one).
579
- An explicit path (or an already-loaded model object, via `hgb_path`'s
580
- own convention) is still loaded/used unconditionally either way, exactly
581
- as before Task 6 -- `load_hybrid` never judges whether a checkpoint/
582
- std-head pairing makes sense; that is `predict_casting`'s job (it raises
583
- if the resulting `hgb_std` is non-`None` for a non-film checkpoint).
584
-
585
- Returns a 6-tuple (was 5, before this argument existed): `(gnn, hgb,
586
- norm, cfg, baseline_norm, hgb_std)` -- every existing call site was
587
- updated to the new arity when this argument was added; any NEW call site
588
- must unpack all 6.
589
- """
590
- device = device or ("cuda" if torch.cuda.is_available() else "cpu")
591
- gnn_ckpt, hgb_path = resolve_artifact(gnn_ckpt), resolve_artifact(hgb_path)
592
- hgb = load_gbm(hgb_path)
593
- state = load_gnn_any(gnn_ckpt, device)
594
- is_film = state.get("cfg", {}).get("film", False)
595
-
596
- if hgb_std_path is _USE_DEFAULT:
597
- hgb_std_path = DEFAULT_HGB_STD if is_film else None
598
- elif isinstance(hgb_std_path, str) and hgb_std_path.strip().lower() == "none":
599
- hgb_std_path = None
600
- hgb_std_path = resolve_artifact(hgb_std_path)
601
- try:
602
- hgb_std = load_gbm(hgb_std_path)
603
- except FileNotFoundError as e:
604
- raise FileNotFoundError(
605
- f"GBM std head not found: {hgb_std_path!r}. Since the 2026-07-31 v5 "
606
- f"promotion the std artifact ({DEFAULT_HGB_STD}) is a PRODUCTION "
607
- f"requirement of the default affine recomposition -- provision it "
608
- f"alongside the GNN/GBM artifacts, or pass --hgb-std none (API: "
609
- f"hgb_std_path=None) to fall back to the anchor recomposition.") from e
610
-
611
- if is_film:
612
- gnn, cfg, norm = load_ft2b_gnn(state, device)
613
- return gnn, hgb, norm, cfg, None, hgb_std
614
- gnn, cfg, norm = load_gnn(gnn_ckpt, device)
615
- from src.dataload import load_norm
616
- baseline_norm = load_norm(baseline_features_dir) if hgb is not None else None
617
- return gnn, hgb, norm, cfg, baseline_norm, hgb_std
618
-
619
-
620
- import os
621
- import argparse
622
-
623
- from src.step_mesh import mesh_step
624
- from src.msh_parse import parse_msh
625
- from src import ood as _ood
626
- from src.mesh_cache import get_or_mesh
627
- from tools.doe.prep_parts import DEFAULT_TARGET_NODES
628
 
629
 
630
- def predict_casting(step_path, out_dir="runs/castings", elem_size=None,
631
- mesh_mode="prep",
632
- gnn_ckpt=DEFAULT_GNN_CKPT,
633
- hgb_path=DEFAULT_HGB,
634
- ood_json="data/features_v2/ood_ranges.json", device=None,
635
- tta=0, alloy=None, mold="green_sand", props_json=None,
 
 
 
 
 
 
 
 
 
 
 
636
  target_nodes: int = DEFAULT_TARGET_NODES,
637
- hgb_std_path=_USE_DEFAULT,
638
- lambda_shrink: float = 1.0, w_total: float = DEFAULT_W_TOTAL,
639
- w_std: float = DEFAULT_W_STD, screen_shrinkage: bool = True,
640
- gravity: tuple = (0, 0, -1)) -> dict:
641
- """`mesh_mode`: "prep" (default) routes meshing through
642
- `tools.doe.prep_parts.heal_and_mesh` -- the SAME largest-solid + OCC
643
- heal + density-searched pipeline used to prep the training/dataset-D
644
- parts, so real-part inference and training use one honest meshing
645
- stack. "raw" keeps the old `src.step_mesh.mesh_step` single-pass
646
- uniform-density path (escape hatch). An explicit `elem_size` always
647
- implies "raw" regardless of `mesh_mode` -- density-searching towards a
648
- node-count target and pinning an exact element size are mutually
649
- exclusive requests, and the explicit size wins. "prep" mode meshes via
650
- `src.mesh_cache.get_or_mesh`, so a byte-identical STEP file (same
651
- `target_nodes`) is served from `runs/mesh_cache/` instead of re-meshed.
652
-
653
- `target_nodes` (app Phase A, 2026-08-01): the node-count target the
654
- "prep" density search aims at, forwarded to `src.mesh_cache.get_or_mesh`
655
- (and thus part of the mesh-cache key, so two densities of the same STEP
656
- cache separately). Defaults to `DEFAULT_TARGET_NODES` (8500), which is
657
- byte-identical to the previous hardcoded behaviour. The trained density
658
- band is ~7.5-10k nodes (DECISIONS-2026-07-22 §9); values outside it put
659
- the mesh outside what the model saw, and callers exposing this to a user
660
- should bound it there. Passing a non-default value together with an
661
- explicit `elem_size` raises `ValueError` rather than being ignored --
662
- `elem_size` forces "raw", which never runs the density search at all.
663
-
664
- `hgb_std_path` (variance-recomposition Task 3; default changed in Task 6;
665
- recomposition swapped to `hybrid_field_soft` by soft-anchor Task 3): an
666
- optional path to a `joblib`-dumped GBM std-head model
667
- (`src.gbm_ft2b.refit_gbm_std`/`refit_gbm_std_v2`'s output, 16 or 17
668
- features -- `predict_field` width-dispatches off the loaded model's own
669
- `n_features_in_`), forwarded to `load_hybrid`/`predict_field`. Defaults
670
- to the module-level `_USE_DEFAULT` sentinel (not `None`, since Task 6):
671
- `load_hybrid` resolves this to `DEFAULT_HGB_STD` for a film-capable
672
- checkpoint (the production call, zero extra flags, now recomposes via
673
- `hybrid_field_soft`) or to `None` for a non-film one (the SP1 rollback
674
- via `--gnn`/`--hgb` is unaffected -- no attempt is made to load
675
- `DEFAULT_HGB_STD` for a non-film checkpoint at all). Pass `None`
676
- explicitly, or the case-insensitive string `"none"` (the CLI-friendly
677
- form), to force the quantile-anchor recomposition on ANY checkpoint,
678
- including the new default film one -- this is byte-identical to the
679
- pre-Task-3 code. Passing an explicit path (or an already-loaded model
680
- object) still requires a film-capable checkpoint (SP1 has no
681
- scale-head std target) -- raises `ValueError`
682
- otherwise, BEFORE meshing. When the soft-anchor recomposition is active,
683
- the summary file gains a `soft recomposition: a=..., m=...,
684
- target_unreachable=..., clip_frac=...` line (`target_unreachable`
685
- surfaces whether the underlying hard (affine) two-target problem the
686
- soft solve seeds from was itself unreachable at some `a` -- see
687
- `src.hybrid.hybrid_field_soft`), further appending a trailing
688
- `total_dev=+x.x%` field (final review I-1, 2026-08-01) whenever
689
- `abs(info["total_deviation_frac"]) > 0.001` -- signed, one decimal
690
- (e.g. `total_dev=+4.7%`) -- surfacing the soft anchor's defining
691
- trade-off (letting the total deviate from the GBM total to buy a closer
692
- spread match) in the human-readable summary, not just the return dict's
693
- `recomposition_info`; omitted entirely below that threshold so a healthy
694
- part's summary stays clean. Otherwise (no active std head) it gains a
695
- `recomposition: anchor ...` line (film checkpoints only).
696
-
697
- `lambda_shrink` (variance-recomposition amendment 2, CLI `--affine-
698
- lambda`): forwarded straight to `predict_field`/`hybrid_field_soft`
699
- (ignored unless `hgb_std_path` is also given). `1.0` (the default) is
700
- byte-identical to before this argument existed. When active AND
701
- `lambda_shrink != 1.0`, the summary's recomposition line gains a
702
- trailing `lambda_shrink=...` field.
703
-
704
- `w_total`/`w_std` (soft-anchor Task 3, CLI `--w-total`/`--w-std`; defaults
705
- changed to `DEFAULT_W_TOTAL`/`DEFAULT_W_STD` (`1.0`/`4.0`) by the
706
- soft-anchor Task 6 promotion, 2026-07-31 -- the e_val-sweep winner
707
- recorded in the spec addendum): forwarded straight to
708
- `predict_field`/`hybrid_field_soft`'s own weights (ignored unless
709
- `hgb_std_path` is also given). When either differs from `1.0`/`1.0` (NOT
710
- from the new defaults -- this compares against `hybrid_field_soft`'s own
711
- equal-weighting baseline, so the new production default line still
712
- prints its weights), the summary's recomposition line gains a trailing
713
- `w_total=..., w_std=...` field.
714
-
715
- `screen_shrinkage` (SP2 shrinkage Task 4, CLI `--no-shrinkage`; learned
716
- source added by learned-heads Task 3): runs `src.shrinkage.screen_casting`
717
- on the recomposed hybrid field (`t_s`) AFTER recomposition, BEFORE the
718
- VTU write -- Niyama (the time-only proxy, UNLESS `gnn_ckpt` is a
719
- deriv_heads-capable checkpoint, in which case `predict_field` has already
720
- populated `feat["gradT"]`/`feat["dTdt"]` with the checkpoint's own learned
721
- per-node predictions, routing `screen_casting` through its exact branch
722
- instead; the returned `shrinkage["niyama_source"]` is then relabeled
723
- `"learned"` here -- NOT `screen_casting`'s own `"exact"`, which is
724
- reserved for a real cached solver field, e.g. Gate C's per-row
725
- comparison), macro-shrinkage region detection, top-surface sinks, and
726
- feeder recommendations. `surface_nodes`/`feed_nodes` come straight off
727
- the parsed mesh's physical-group triangles (`_shrinkage_surface_and_
728
- feed_nodes` -- ALL surface tris union for `surface_nodes`, "Adiabatic"
729
- tris only for `feed_nodes`, empty for a bare sand casting with no
730
- risers); `TL`/`TS` come from the already-resolved `props`; `alloy_family`
731
- comes from `src.inference_props.resolve_alloy_family` (a name-prefix map
732
- onto `src.shrinkage.SHRINKAGE_PCT`'s family keys -- an unrecognized grade
733
- name, or any `--props` custom-JSON override, falls back loudly to
734
- `"steel"` and is recorded as `alloy_family_assumed` in the result rather
735
- than crashing or silently mis-scaling severities). `True` (the default)
736
- adds `niyama`/`porosity_risk`/`macro_region_id` VTU point fields, a
737
- `shrinkage screening:` block in the summary, and a `"shrinkage"` key in
738
- the return dict (the `screen_casting` result minus its bulky per-node
739
- `niyama`/`porosity_risk` arrays, plus `flagged_frac`/`n_macro_regions`/
740
- `n_sinks`/`n_feeders` counts). The shrinkage dict carries
741
- `"niyama_confidence": "full"` (this release supports the all-sand regime
742
- only, where the proxy passed its validation gate). `False` skips all of
743
- that entirely -- `"shrinkage"` is `None` and the VTU/summary carry no
744
- extra fields (the `shrinkage screening: disabled` line only appears when
745
- screening was attempted but internally skipped, e.g. a mesh with no
746
- surface nodes to place a feeder against, never for this explicit
747
- opt-out).
748
-
749
- `gravity` (CLI `--gravity x,y,z`, parsed by `_parse_gravity`): forwarded
750
- straight to `screen_casting`'s `gravity_vec` (top-sink/feeder placement
751
- direction; conduction/Niyama/macro-region *detection* don't depend on
752
- it). `(0, 0, -1)` (the default) is the same "gravity along -z" convention
753
- used elsewhere in this module. Ignored entirely when `screen_shrinkage`
754
- is `False`.
755
-
756
- Returns a dict that (soft-anchor Task 4, v5 review M-1) always carries
757
- `"recomposition"` and `"recomposition_info"` alongside the existing keys:
758
- `"recomposition"` is `"soft"` (the affine two-target solve via
759
- `hybrid_field_soft` is active -- `hgb_std_path` resolved to a real std
760
- head), `"anchor"` (a film/v4 checkpoint recomposed via the plain
761
- quantile-anchor -- `hgb_std_path` resolved to `None`, e.g. via `"none"`),
762
- or `"sp1"` (a non-film checkpoint -- no recomposition-mode concept
763
- applies at all). `"recomposition_info"` is `hybrid_field_soft`'s own
764
- info dict (`a`/`m`/`target_unreachable`/`clip_frac`/
765
- `total_deviation_frac`/... -- see `predict_field`'s docstring) when
766
- `"recomposition"` is `"soft"`, else `None` -- there is no equivalent info
767
- dict on the anchor or SP1 paths.
768
- """
769
  os.makedirs(out_dir, exist_ok=True)
770
  name = osp.splitext(osp.basename(step_path))[0]
771
  out_msh = osp.join(out_dir, f"{name}.msh")
772
 
773
- # Resolve properties early (cheap, no mesh dependency) so typos/bad alloys
774
- # fail fast, before potentially lengthy meshing.
775
  props = resolve_props(alloy=alloy, mold=mold, props_json=props_json)
 
776
 
777
- effective_mode = "raw" if elem_size is not None else mesh_mode
778
- if target_nodes != DEFAULT_TARGET_NODES and effective_mode != "prep":
779
- raise ValueError(
780
- f"predict_casting: `target_nodes` ({target_nodes}) applies to the "
781
- f"prep/get_or_mesh density search only, but an explicit "
782
- f"elem_size={elem_size!r} forces mesh_mode='raw' -- pinning an "
783
- f"element size and searching towards a node count are mutually "
784
- f"exclusive requests. Drop one.")
785
- gnn_ckpt, hgb_path = resolve_artifact(gnn_ckpt), resolve_artifact(hgb_path)
786
- gnn, hgb, norm, cfg, baseline_norm, hgb_std = load_hybrid(
787
- gnn_ckpt, hgb_path, device, hgb_std_path=hgb_std_path)
788
- # Provenance label for the summary txt: the RESOLVED std artifact (the
789
- # v5.1 stack is defined by it + the weights), mirroring load_hybrid's own
790
- # sentinel/"none" resolution without re-running it.
791
- if hgb_std is None:
792
- hgb_std_path_label = "none"
793
- elif hgb_std_path is _USE_DEFAULT:
794
- hgb_std_path_label = resolve_artifact(DEFAULT_HGB_STD)
795
- else:
796
- hgb_std_path_label = str(hgb_std_path)
797
- if hgb_std is not None and not cfg.get("film", False):
798
- raise ValueError(
799
- "predict_casting: --hgb-std requires the film-conditioned v4 "
800
- "stack; the selected checkpoint is not film-capable -- rerun "
801
- "without --hgb-std or with the v4 checkpoints")
802
-
803
- if effective_mode == "raw":
804
- raw_info = mesh_step(step_path, out_msh, elem_size)
805
- mesh = parse_msh(out_msh)
806
- minfo = {"n_nodes": raw_info["n_nodes"], "n_tets": raw_info["n_tets"],
807
- "elem_size": raw_info["elem_size"], "bbox_mm": raw_info["bbox_mm"]}
808
- elif effective_mode == "prep":
809
- prep_info = get_or_mesh(step_path, out_msh, target_nodes=target_nodes)
810
- if not prep_info.get("ok", False):
811
- raise RuntimeError(
812
- f"predict_casting: prep meshing pipeline (get_or_mesh) failed for "
813
- f"{step_path!r}: {prep_info.get('notes', '(no notes)')}")
814
- mesh = parse_msh(out_msh)
815
- bbox_mm = tuple((mesh.node_xyz.max(0) - mesh.node_xyz.min(0)).tolist())
816
- cache = prep_info.get("cache", "unknown")
817
- minfo = {"n_nodes": prep_info["n_nodes"], "n_tets": int(mesh.tets.shape[0]),
818
- "elem_size": None, "bbox_mm": bbox_mm, "cache": cache}
819
- else:
820
- raise ValueError(f"predict_casting: mesh_mode must be 'prep' or 'raw', got {mesh_mode!r}")
821
-
822
- seconds, feat = predict_field(mesh, gnn, hgb, norm, cfg=cfg, device=device, tta=tta,
823
- baseline_norm=baseline_norm, props=props,
824
- hgb_std=hgb_std, lambda_shrink=lambda_shrink,
825
- w_total=w_total, w_std=w_std)
826
 
827
  warnings = []
828
- ood_json = resolve_artifact(ood_json)
829
  if osp.exists(ood_json):
830
- ranges = _ood.load_ranges(ood_json)
831
- X = feat["X"][:, :N_BASELINE_FEATURES]
832
- vol = float(X[0, FEATURE_NAMES.index("volume_mm3")])
833
- gcmax = float(X[:, FEATURE_NAMES.index("geodesic_d_cool")].max())
834
  diag = float(np.linalg.norm(mesh.node_xyz.max(0) - mesh.node_xyz.min(0)))
835
- warnings = _ood.ood_flags(vol, diag, gcmax, ranges)
 
836
 
837
- # SP2 shrinkage Task 4: screen the recomposed hybrid field for shrinkage-
838
- # porosity risk, AFTER recomposition, BEFORE the VTU write -- see
839
- # predict_casting's own docstring for the full contract.
840
  shrinkage = None
841
- shrinkage_extra_point_data = None
842
  if screen_shrinkage:
843
- surface_nodes, feed_nodes = _shrinkage_surface_and_feed_nodes(mesh)
844
  if surface_nodes.size == 0:
845
- logger.warning(
846
- "predict_casting: no surface nodes found on the meshed part -- "
847
- "shrinkage screening skipped (a feeder location requires a free "
848
- "surface)")
849
  else:
850
- alloy_family, alloy_family_assumed = resolve_alloy_family(alloy, props_json)
851
- # Learned-heads Task 3: `feat["gradT"]`/`feat["dTdt"]` are only
852
- # present when `predict_field` ran a deriv_heads-capable (film)
853
- # checkpoint (see that function's docstring) -- `None`/`None` for
854
- # every other checkpoint, which routes `screen_casting` through
855
- # its own time-only proxy branch exactly as before this feature
856
- # existed.
857
- learned_gradT = feat.get("gradT")
858
- learned_dTdt = feat.get("dTdt")
859
- shrinkage_full = screen_casting(
860
- pos=mesh.node_xyz, tets=mesh.tets, surface_nodes=surface_nodes,
861
- t_s=seconds, TL=props["TL"], TS=props["TS"], alloy_family=alloy_family,
862
- gravity_vec=gravity, feed_nodes=feed_nodes,
863
- gradT=learned_gradT, dTdt=learned_dTdt)
864
- shrinkage = _shrinkage_result_for_return(shrinkage_full)
865
- if learned_gradT is not None and learned_dTdt is not None:
866
- # `screen_casting` itself only distinguishes "exact" (real
867
- # cached solver fields, e.g. Gate C's per-row comparison)
868
- # from "proxy" -- it has no notion of "predicted by a
869
- # checkpoint at inference time". Relabel HERE, at the
870
- # inference call site, rather than teaching `screen_casting`
871
- # a third source string: that keeps `src.shrinkage`'s own
872
- # tests (which pass real cached gradT/dTdt and expect
873
- # "exact") untouched, while giving inference-time callers an
874
- # honest label distinguishing a MODEL PREDICTION from a true
875
- # solver field.
876
- shrinkage["niyama_source"] = "learned"
877
- if alloy_family_assumed:
878
  shrinkage["alloy_family_assumed"] = True
879
- # This release covers the all-sand regime only, where the
880
- # proxy Niyama passed its validation gate.
881
- shrinkage["niyama_confidence"] = "full"
882
- shrinkage_extra_point_data = _shrinkage_extra_point_data(mesh, shrinkage_full)
883
 
884
  vtu = osp.join(out_dir, f"{name}.vtu")
885
- write_vtu(vtu, mesh.node_xyz, mesh.tets, seconds, extra_point_data=shrinkage_extra_point_data)
886
 
887
- # hot-spot summary: top-5 latest-solidifying nodes
888
  order = np.argsort(seconds)[::-1][:5]
889
  hot_xyz = mesh.node_xyz[order]
890
  total = float(seconds.max())
 
891
  summary = osp.join(out_dir, f"{name}_summary.txt")
892
- alloy_label = alloy if alloy is not None else DEFAULT_ALLOY
893
- if props_json is not None:
894
- alloy_label = f"custom ({props_json})"
895
  mold_label = mold if props_json is None else "n/a (props_json)"
896
- elem_size_label = (f"{minfo['elem_size']:.2f} mm" if minfo["elem_size"] is not None
897
- else "n/a (density-searched)")
898
- cache_label = minfo.get("cache") if effective_mode == "prep" else "n/a (raw mode)"
899
  with open(summary, "w") as f:
900
  f.write(f"casting: {name}\n")
901
  f.write(f"total solidification time: {total:.1f} s\n")
902
- f.write(f"alloy: {alloy_label} mold: {mold_label} gnn: {gnn_ckpt} hgb: {hgb_path} "
903
- f"hgb_std: {hgb_std_path_label}\n")
904
- f.write(f"mesh_mode: {effective_mode} cache: {cache_label} nodes: {minfo['n_nodes']} "
905
- f"tets: {minfo['n_tets']} elem_size: {elem_size_label} bbox_mm: "
906
- f"{tuple(round(v,1) for v in minfo['bbox_mm'])}\n")
907
- if feat.get("recomposition") == "soft":
908
- info = feat["recomposition_info"]
909
- line = (f"soft recomposition: a={info['a']:.4f}, m={info['m']:.4f}, "
910
- f"target_unreachable={info['target_unreachable']}, "
911
- f"clip_frac={info['clip_frac']:.4f}")
912
- if info.get("lambda_shrink", 1.0) != 1.0:
913
- line += f", lambda_shrink={info['lambda_shrink']:.4f}"
914
- if w_total != 1.0 or w_std != 1.0:
915
- line += f", w_total={w_total:.4f}, w_std={w_std:.4f}"
916
- total_dev = info.get("total_deviation_frac")
917
- if total_dev is not None and abs(total_dev) > 0.001:
918
- line += f", total_dev={total_dev * 100.0:+.1f}%"
919
- f.write(line + "\n")
920
- elif cfg.get("film", False):
921
- # variance-recomposition Task 6: the film path without an active
922
- # std head (hgb_std_path="none", or a film checkpoint used
923
- # without one) recomposes via the quantile-anchor -- state that
924
- # explicitly, mirroring the affine line above, rather than
925
- # leaving the recomposition mode unstated.
926
- f.write("recomposition: anchor (quantile, q=0.995)\n")
927
  if screen_shrinkage:
928
- _write_shrinkage_summary_block(f, shrinkage)
929
  f.write("top hot spots (x,y,z mm : t_solidus s):\n")
930
  for xyz_i, i in zip(hot_xyz, order):
931
  f.write(f" ({xyz_i[0]:.1f},{xyz_i[1]:.1f},{xyz_i[2]:.1f}) : {seconds[i]:.1f}\n")
@@ -934,108 +232,52 @@ def predict_casting(step_path, out_dir="runs/castings", elem_size=None,
934
  for w in warnings:
935
  f.write(f" - {w}\n")
936
 
937
- # soft-anchor Task 4 (v5 review M-1): surface the recomposition mode +
938
- # its info dict as structured data, mirroring the summary text above --
939
- # "soft" (hybrid_field_soft active, feat carries its info dict), "anchor"
940
- # (film checkpoint, quantile-anchor recomposition -- no info dict exists
941
- # on this path), or "sp1" (non-film checkpoint -- no recomposition
942
- # concept at all).
943
- if feat.get("recomposition") == "soft":
944
- recomposition = "soft"
945
- recomposition_info = feat["recomposition_info"]
946
- elif cfg.get("film", False):
947
- recomposition = "anchor"
948
- recomposition_info = None
949
- else:
950
- recomposition = "sp1"
951
- recomposition_info = None
952
 
953
- result = {"name": name, "vtu": vtu, "summary": summary, "total_time_s": total,
954
- "mesh_mode": effective_mode, "n_nodes": minfo["n_nodes"],
955
- "elem_size": minfo["elem_size"], "ood_warnings": warnings,
956
- "hotspot_xyz": hot_xyz,
957
- "recomposition": recomposition,
958
- "recomposition_info": recomposition_info,
959
- "shrinkage": shrinkage}
960
- if "cache" in minfo:
961
- result["cache"] = minfo["cache"]
962
- return result
963
 
964
 
965
- if __name__ == "__main__":
966
- ap = argparse.ArgumentParser()
967
  ap.add_argument("step_path")
968
  ap.add_argument("--out-dir", default="runs/castings")
969
- ap.add_argument("--elem-size", type=float, default=None,
970
- help="explicit uniform element size (mm) -- implies --mesh-mode raw, "
971
- "regardless of --mesh-mode")
972
- ap.add_argument("--mesh-mode", choices=["prep", "raw"], default="prep",
973
- help="'prep' (default): route meshing through the same largest-solid + "
974
- "OCC heal + density-searched pipeline used to prep training/dataset-D "
975
- "parts (tools.doe.prep_parts.heal_and_mesh), served through the "
976
- "on-disk mesh cache (runs/mesh_cache/) so a repeated byte-identical "
977
- "STEP file is not re-meshed. 'raw': the old single-pass "
978
- "uniform-density src.step_mesh.mesh_step path (escape hatch). Ignored "
979
- "(forced to 'raw') when --elem-size is given.")
980
- ap.add_argument("--gnn", dest="gnn_ckpt", default=DEFAULT_GNN_CKPT,
981
- help="GNN checkpoint path (SP1 or a film/v4 checkpoint)")
982
- ap.add_argument("--hgb", dest="hgb_path", default=DEFAULT_HGB,
983
- help="HGB baseline path (SP1 per-node baseline or the v4 v2-total GBM)")
984
- ap.add_argument("--hgb-std", dest="hgb_std_path", default=_USE_DEFAULT,
985
- help="path to an optional GBM std-head model "
986
- "(src.gbm_ft2b.refit_gbm_std's output); when active, recomposition "
987
- "uses the affine (variance-preserving) map instead of the "
988
- "quantile-anchor -- only consulted for film/v4 checkpoints. Default: "
989
- f"{DEFAULT_HGB_STD} for a film/v4 checkpoint (auto-disabled, no extra "
990
- "flag needed, for a non-film/SP1 checkpoint e.g. via --gnn/--hgb "
991
- "rollback); pass 'none' (case-insensitive) to force the old "
992
- "quantile-anchor recomposition on a film/v4 stack too")
993
- ap.add_argument("--affine-lambda", dest="lambda_shrink", type=float, default=1.0,
994
- help="geometric-interpolation shrinkage of the affine spread target "
995
- "(variance-recomposition amendment 2): 1.0 (default) fully trusts "
996
- "--hgb-std's predicted spread; 0.0 targets the GNN's own spread "
997
- "(no rescaling); only used together with --hgb-std on film/v4 "
998
- "checkpoints")
999
- ap.add_argument("--w-total", dest="w_total", type=float, default=DEFAULT_W_TOTAL,
1000
- help="soft-anchor weight on the total-time residual in "
1001
- "src.hybrid.hybrid_field_soft's objective; default "
1002
- f"{DEFAULT_W_TOTAL} (the e_val-sweep winner, soft-anchor Task 6 "
1003
- "promotion, 2026-07-31) -- only used together with --hgb-std")
1004
- ap.add_argument("--w-std", dest="w_std", type=float, default=DEFAULT_W_STD,
1005
- help="soft-anchor weight on the spread residual in "
1006
- "src.hybrid.hybrid_field_soft's objective; default "
1007
- f"{DEFAULT_W_STD} (the e_val-sweep winner, soft-anchor Task 6 "
1008
- "promotion, 2026-07-31) -- only used together with --hgb-std")
1009
  ap.add_argument("--alloy", default=None,
1010
- help=f"named alloy grade (default: {DEFAULT_ALLOY}); only used by film/v4 checkpoints")
1011
- ap.add_argument("--mold", default="green_sand",
1012
- help="named mold system; only used by film/v4 checkpoints")
1013
  ap.add_argument("--props", dest="props_json", default=None,
1014
- help="path to a JSON file overriding alloy/mold with raw material properties")
 
 
 
 
 
 
 
1015
  ap.add_argument("--no-shrinkage", dest="screen_shrinkage", action="store_false",
1016
- help="disable shrinkage-porosity screening (SP2): by default, "
1017
- "predict_casting runs src.shrinkage.screen_casting on the "
1018
- "recomposed field and adds niyama/porosity_risk/macro_region_id "
1019
- "VTU point fields, a 'shrinkage screening:' summary block, and "
1020
- "a 'shrinkage' return-dict key. This flag skips all of that, "
1021
- "byte-identical to the pre-shrinkage-screening contract.")
1022
- ap.add_argument("--gravity", dest="gravity", type=_parse_gravity, default=(0, 0, -1),
1023
- help="gravity direction as 'x,y,z' (default '0,0,-1'), forwarded to "
1024
- "screen_casting's top-sink/feeder placement; ignored with "
1025
- "--no-shrinkage. Parsed by _parse_gravity as argparse's own "
1026
- "type= callable (review round 1, finding 5), so a malformed "
1027
- "value (wrong arity, non-numeric, or the zero vector) is a "
1028
- "normal argparse usage error, not a traceback.")
1029
- a = ap.parse_args()
1030
- r = predict_casting(a.step_path, out_dir=a.out_dir, elem_size=a.elem_size,
1031
- mesh_mode=a.mesh_mode,
1032
- gnn_ckpt=a.gnn_ckpt, hgb_path=a.hgb_path,
1033
- alloy=a.alloy, mold=a.mold, props_json=a.props_json,
1034
- hgb_std_path=a.hgb_std_path,
1035
- lambda_shrink=a.lambda_shrink, w_total=a.w_total, w_std=a.w_std,
1036
- screen_shrinkage=a.screen_shrinkage,
1037
- gravity=a.gravity)
1038
- print(f"{r['name']}: total {r['total_time_s']:.1f}s mesh_mode {r['mesh_mode']} "
1039
- f"nodes {r['n_nodes']} -> {r['vtu']}")
1040
  for w in r["ood_warnings"]:
1041
  print("WARNING:", w)
 
 
 
 
 
1
+ """STEP file in, solidification field out.
2
 
3
+ python -m src.predict_casting part.step --out-dir out [--alloy G20Mn5]
 
 
4
 
5
+ Steps: mesh the STEP file (healed, density-searched and cached, or at a fixed
6
+ element size with --elem-size), run the model, check the training envelope,
7
+ screen for shrinkage, and write `<name>.vtu`, `<name>_summary.txt` and the
8
+ mesh `<name>.msh` to the output directory.
9
+ """
10
+ from __future__ import annotations
 
 
 
 
11
 
12
+ import argparse
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
13
  import logging
14
+ import os
15
  import os.path as osp
 
 
 
16
 
17
+ import numpy as np
18
 
19
+ from src import ood as _ood
20
+ from src.features import FEATURE_NAMES
21
+ from src.inference_props import DEFAULT_ALLOY, DEFAULT_MOLD, resolve_alloy_family, resolve_props
22
+ from src.mesh_cache import get_or_mesh
23
+ from src.meshing import DEFAULT_TARGET_NODES, mesh_step
24
+ from src.model_api import CastSolidModel
25
+ from src.msh_parse import parse_msh
 
 
 
 
 
26
  from src.shrinkage import screen_casting
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
27
 
28
+ logger = logging.getLogger(__name__)
29
 
30
+ MODEL_VERSION = "v5.1"
31
+ _VOL_IDX = FEATURE_NAMES.index("volume_mm3")
32
+ _GCOOL_IDX = FEATURE_NAMES.index("geodesic_d_cool")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
33
 
34
 
35
  def write_vtu(out_path, node_xyz, tets, seconds, top_frac=0.1, extra_point_data=None):
36
+ """Write the mesh with `solidification_time` (s) and `hotspot` (the
37
+ `top_frac` latest-solidifying nodes) point fields, plus any extras."""
 
 
 
 
 
 
 
38
  import meshio
39
  seconds = np.asarray(seconds, dtype=np.float32)
40
  n = seconds.shape[0]
 
50
  m.write(out_path)
51
 
52
 
53
+ def _surface_and_feed_nodes(mesh):
54
+ """All tagged surface nodes, and the adiabatic-tagged ones that can feed
55
+ a shrinkage region (none on a STEP meshed by this pipeline)."""
 
 
 
 
 
 
 
 
 
 
 
 
 
 
56
  tri_arrays = [tri for tri in mesh.tris.values() if tri.size]
57
  surface_nodes = (np.unique(np.concatenate(tri_arrays, axis=0).reshape(-1))
58
  if tri_arrays else np.zeros(0, dtype=np.int64))
59
  adiab_tri = mesh.tris.get("Adiabatic")
60
  feed_nodes = (np.unique(adiab_tri.reshape(-1))
61
+ if adiab_tri is not None and adiab_tri.size else np.zeros(0, dtype=np.int64))
62
  return surface_nodes.astype(np.int64), feed_nodes.astype(np.int64)
63
 
64
 
65
  def _shrinkage_result_for_return(screen: dict) -> dict:
66
+ """The screening result without its per-node arrays, plus counts."""
 
 
 
67
  result = {k: v for k, v in screen.items() if k not in ("niyama", "porosity_risk")}
68
  risk = screen["porosity_risk"]
69
  result["flagged_frac"] = float(np.mean(risk)) if risk.size else 0.0
 
73
  return result
74
 
75
 
76
+ def _shrinkage_point_data(mesh, screen: dict) -> dict:
 
 
 
77
  n_nodes = mesh.node_xyz.shape[0]
78
  macro_region_id = np.full(n_nodes, -1, dtype=np.int64)
79
  for region in screen["macro_shrinkage"]:
 
85
  }
86
 
87
 
88
+ def _write_shrinkage_summary(f, shrinkage) -> None:
 
 
 
 
 
 
 
 
 
 
 
89
  if shrinkage is None:
90
  f.write("shrinkage screening: disabled\n")
91
  return
92
  f.write("shrinkage screening:\n")
93
  f.write(f" niyama_source: {shrinkage['niyama_source']}\n")
94
+ # The risk mask is the lowest Niyama decile of the part, so this
95
+ # percentage is fixed by construction, not a measured severity.
96
+ f.write(f" flagged nodes: {shrinkage['flagged_frac'] * 100.0:.1f}%"
97
+ f" (bottom decile by construction)\n")
 
 
 
 
 
 
98
  macro = shrinkage["macro_shrinkage"]
99
  if macro:
100
+ # Regions under 4 nodes are listed but get no feeder suggestion.
 
 
 
 
 
 
 
 
101
  n_sub_resolution = sum(1 for r in macro if r.get("sub_resolution"))
102
  region_count = str(len(macro))
103
  if n_sub_resolution:
 
105
  worst = max(macro, key=lambda r: r["severity"])
106
  c = worst["centroid"]
107
  f.write(f" macro shrinkage regions: {region_count} "
108
+ f"(worst severity={worst['severity']:.4g} mm^3 @ "
109
+ f"({c[0]:.1f},{c[1]:.1f},{c[2]:.1f}))\n")
110
  else:
111
  f.write(" macro shrinkage regions: 0\n")
112
  f.write(f" sinks: {len(shrinkage['top_sinks'])}\n")
 
116
  for feeder in feeders:
117
  loc = feeder["location"]
118
  f.write(f" - location=({loc[0]:.1f},{loc[1]:.1f},{loc[2]:.1f}) mm "
119
+ f"min_modulus_mm={feeder['min_modulus_mm']:.4f}\n")
120
  else:
121
  f.write(" feeder recommendations: none\n")
122
  if shrinkage.get("alloy_family_assumed"):
123
  f.write(f" alloy_family_assumed: {shrinkage['material']['alloy_family']} "
124
+ f"(alloy not in the materials table -- conservative fallback)\n")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
125
 
126
 
127
+ def _mesh(step_path, out_msh, elem_size, target_nodes, cache_dir):
128
+ if elem_size is not None:
129
+ info = mesh_step(step_path, out_msh, elem_size)
130
+ mesh = parse_msh(out_msh)
131
+ return mesh, {"mode": "fixed", "n_nodes": info["n_nodes"], "n_tets": info["n_tets"],
132
+ "elem_size": info["elem_size"], "cache": "n/a"}
133
+ info = get_or_mesh(step_path, out_msh, target_nodes=target_nodes, cache_dir=cache_dir)
134
+ if not info.get("ok", False):
135
+ raise RuntimeError(f"meshing failed for {step_path!r}: {info.get('notes', '(no notes)')}")
136
+ mesh = parse_msh(out_msh)
137
+ return mesh, {"mode": "density-searched", "n_nodes": info["n_nodes"],
138
+ "n_tets": int(mesh.tets.shape[0]), "elem_size": None,
139
+ "cache": info.get("cache", "unknown")}
140
+
141
+
142
+ def predict_casting(step_path, out_dir="runs/castings", alloy=None, mold=DEFAULT_MOLD,
143
+ props_json=None, elem_size=None,
144
  target_nodes: int = DEFAULT_TARGET_NODES,
145
+ screen_shrinkage: bool = True, gravity: tuple = (0, 0, -1),
146
+ model: CastSolidModel | None = None, weights_dir: str = "weights",
147
+ ood_json: str | None = None, cache_dir: str = "runs/mesh_cache",
148
+ device: str | None = None) -> dict:
149
+ """Mesh `step_path`, predict, and write the result files.
150
+
151
+ `alloy`/`mold` name rows of `data/properties/` (default EN-GJS-400-15 in
152
+ green sand); `props_json` replaces both. `elem_size` (mm) meshes at a
153
+ fixed size instead of searching for `target_nodes`. Pass a loaded
154
+ `model` to reuse it across calls.
155
+
156
+ Returns {name, vtu, summary, msh, total_time_s, hotspot_xyz (5 x 3 mm),
157
+ ood_warnings, n_nodes, mesh_mode, elem_size, cache, recomposition_info,
158
+ shrinkage}."""
159
+ if elem_size is not None and target_nodes != DEFAULT_TARGET_NODES:
160
+ raise ValueError("predict_casting: give either elem_size or target_nodes, not both")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
161
  os.makedirs(out_dir, exist_ok=True)
162
  name = osp.splitext(osp.basename(step_path))[0]
163
  out_msh = osp.join(out_dir, f"{name}.msh")
164
 
165
+ # Resolve properties before meshing so a bad alloy name fails fast.
 
166
  props = resolve_props(alloy=alloy, mold=mold, props_json=props_json)
167
+ model = model or CastSolidModel(weights_dir=weights_dir, device=device)
168
 
169
+ mesh, minfo = _mesh(step_path, out_msh, elem_size, target_nodes, cache_dir)
170
+ seconds, details = model.predict_with_props(mesh, props)
171
+ X = details["X"]
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
172
 
173
  warnings = []
174
+ ood_json = ood_json or osp.join(weights_dir, "ood_ranges.json")
175
  if osp.exists(ood_json):
 
 
 
 
176
  diag = float(np.linalg.norm(mesh.node_xyz.max(0) - mesh.node_xyz.min(0)))
177
+ warnings = _ood.ood_flags(float(X[0, _VOL_IDX]), diag, float(X[:, _GCOOL_IDX].max()),
178
+ _ood.load_ranges(ood_json))
179
 
 
 
 
180
  shrinkage = None
181
+ point_data = None
182
  if screen_shrinkage:
183
+ surface_nodes, feed_nodes = _surface_and_feed_nodes(mesh)
184
  if surface_nodes.size == 0:
185
+ logger.warning("predict_casting: no surface nodes on the mesh -- "
186
+ "shrinkage screening skipped")
 
 
187
  else:
188
+ alloy_family, assumed = resolve_alloy_family(alloy, props_json)
189
+ screen = screen_casting(pos=mesh.node_xyz, tets=mesh.tets,
190
+ surface_nodes=surface_nodes, t_s=seconds,
191
+ TL=props["TL"], TS=props["TS"],
192
+ alloy_family=alloy_family, gravity_vec=gravity,
193
+ feed_nodes=feed_nodes)
194
+ shrinkage = _shrinkage_result_for_return(screen)
195
+ if assumed:
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
196
  shrinkage["alloy_family_assumed"] = True
197
+ point_data = _shrinkage_point_data(mesh, screen)
 
 
 
198
 
199
  vtu = osp.join(out_dir, f"{name}.vtu")
200
+ write_vtu(vtu, mesh.node_xyz, mesh.tets, seconds, extra_point_data=point_data)
201
 
 
202
  order = np.argsort(seconds)[::-1][:5]
203
  hot_xyz = mesh.node_xyz[order]
204
  total = float(seconds.max())
205
+ info = details["recomposition"]
206
  summary = osp.join(out_dir, f"{name}_summary.txt")
207
+ alloy_label = (f"custom ({props_json})" if props_json is not None
208
+ else alloy if alloy is not None else DEFAULT_ALLOY)
 
209
  mold_label = mold if props_json is None else "n/a (props_json)"
210
+ bbox_mm = tuple(round(float(v), 1) for v in mesh.node_xyz.max(0) - mesh.node_xyz.min(0))
211
+ elem_label = (f"{minfo['elem_size']:.2f} mm" if minfo["elem_size"] is not None
212
+ else "n/a (density-searched)")
213
  with open(summary, "w") as f:
214
  f.write(f"casting: {name}\n")
215
  f.write(f"total solidification time: {total:.1f} s\n")
216
+ f.write(f"alloy: {alloy_label} mold: {mold_label} model: {MODEL_VERSION}\n")
217
+ f.write(f"mesh: {minfo['mode']} cache: {minfo['cache']} nodes: {minfo['n_nodes']} "
218
+ f"tets: {minfo['n_tets']} elem_size: {elem_label} bbox_mm: {bbox_mm}\n")
219
+ line = (f"recomposition: a={info['a']:.4f}, m={info['m']:.4f}, "
220
+ f"target_unreachable={info['target_unreachable']}, "
221
+ f"clip_frac={info['clip_frac']:.4f}")
222
+ if abs(info.get("total_deviation_frac", 0.0)) > 0.001:
223
+ line += f", total_dev={info['total_deviation_frac'] * 100.0:+.1f}%"
224
+ f.write(line + "\n")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
225
  if screen_shrinkage:
226
+ _write_shrinkage_summary(f, shrinkage)
227
  f.write("top hot spots (x,y,z mm : t_solidus s):\n")
228
  for xyz_i, i in zip(hot_xyz, order):
229
  f.write(f" ({xyz_i[0]:.1f},{xyz_i[1]:.1f},{xyz_i[2]:.1f}) : {seconds[i]:.1f}\n")
 
232
  for w in warnings:
233
  f.write(f" - {w}\n")
234
 
235
+ return {"name": name, "vtu": vtu, "summary": summary, "msh": out_msh,
236
+ "total_time_s": total, "hotspot_xyz": hot_xyz, "ood_warnings": warnings,
237
+ "n_nodes": minfo["n_nodes"], "mesh_mode": minfo["mode"],
238
+ "elem_size": minfo["elem_size"], "cache": minfo["cache"],
239
+ "recomposition_info": info, "shrinkage": shrinkage}
 
 
 
 
 
 
 
 
 
 
240
 
241
+
242
+ def _parse_gravity(s: str) -> tuple:
243
+ parts = [p.strip() for p in s.split(",")]
244
+ if len(parts) != 3:
245
+ raise ValueError(f"--gravity must be 'x,y,z' (3 comma-separated numbers), got {s!r}")
246
+ vec = tuple(float(p) for p in parts)
247
+ if all(v == 0.0 for v in vec):
248
+ raise ValueError(f"--gravity must not be the zero vector (got {s!r})")
249
+ return vec
 
250
 
251
 
252
+ def main(argv=None):
253
+ ap = argparse.ArgumentParser(description=__doc__.split("\n\n")[0])
254
  ap.add_argument("step_path")
255
  ap.add_argument("--out-dir", default="runs/castings")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
256
  ap.add_argument("--alloy", default=None,
257
+ help=f"alloy from data/properties/materials.csv (default {DEFAULT_ALLOY})")
258
+ ap.add_argument("--mold", default=DEFAULT_MOLD,
259
+ help=f"mold from data/properties/molds.csv (default {DEFAULT_MOLD})")
260
  ap.add_argument("--props", dest="props_json", default=None,
261
+ help="JSON file with k, rho, cp, L_kJ_kg, TL, TS, Tpour, mold_effusivity "
262
+ "(replaces --alloy and --mold)")
263
+ ap.add_argument("--elem-size", type=float, default=None,
264
+ help="mesh at this fixed element size in mm instead of searching for "
265
+ f"about {DEFAULT_TARGET_NODES} nodes")
266
+ ap.add_argument("--gravity", type=_parse_gravity, default=(0, 0, -1),
267
+ help="gravity direction 'x,y,z' for sinks and feeder placement "
268
+ "(default 0,0,-1)")
269
  ap.add_argument("--no-shrinkage", dest="screen_shrinkage", action="store_false",
270
+ help="skip the shrinkage screening")
271
+ ap.add_argument("--weights-dir", default="weights")
272
+ a = ap.parse_args(argv)
273
+ r = predict_casting(a.step_path, out_dir=a.out_dir, alloy=a.alloy, mold=a.mold,
274
+ props_json=a.props_json, elem_size=a.elem_size,
275
+ screen_shrinkage=a.screen_shrinkage, gravity=a.gravity,
276
+ weights_dir=a.weights_dir)
277
+ print(f"{r['name']}: total {r['total_time_s']:.1f}s nodes {r['n_nodes']} -> {r['vtu']}")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
278
  for w in r["ood_warnings"]:
279
  print("WARNING:", w)
280
+
281
+
282
+ if __name__ == "__main__":
283
+ main()
src/release_io.py CHANGED
@@ -1,89 +1,25 @@
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 {}
@@ -101,31 +37,11 @@ def load_gnn_state(path: str, device="cpu") -> dict:
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)
 
1
+ """Loading the published weight files.
2
 
3
+ - GNN: one safetensors file with tensors `model.<state_dict key>` and
4
+ `norm.<key>`; the architecture config (`cfg`) and the container type of
5
+ each normalization entry are JSON strings in the safetensors metadata.
6
+ - Gradient-boosted heads: skops files. Before loading, every type a file
7
+ names is checked against an allowlist (scikit-learn and NumPy only).
8
 
9
+ Neither format runs code when loaded.
 
 
 
 
 
 
 
 
 
 
 
 
 
10
  """
11
  from __future__ import annotations
12
 
13
  import json
14
  import os
15
 
 
 
 
16
  GNN_FORMAT = "foundry-gnn-release/1"
17
 
 
 
 
 
 
 
 
 
 
18
  _SKOPS_ALLOWED_PREFIXES = ("sklearn.", "numpy.")
19
 
20
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
21
  def load_gnn_state(path: str, device="cpu") -> dict:
22
+ """The `{model, norm, cfg}` dict stored in a GNN safetensors file."""
23
  from safetensors import safe_open
24
  with safe_open(path, framework="pt", device="cpu") as f:
25
  meta = f.metadata() or {}
 
37
  return {"model": model, "norm": norm, "cfg": json.loads(meta["cfg"])}
38
 
39
 
 
 
 
 
 
40
  def load_gbm(path):
41
+ """A gradient-boosted head from a skops file, after the type check."""
42
+ import skops.io as sio
43
+ untrusted = sio.get_untrusted_types(file=os.fspath(path))
44
+ bad = [t for t in untrusted if not t.startswith(_SKOPS_ALLOWED_PREFIXES)]
45
+ if bad:
46
+ raise ValueError(f"{path}: refusing to load unexpected types {bad}")
47
+ return sio.load(os.fspath(path), trusted=untrusted)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
src/run_baseline.py DELETED
@@ -1,150 +0,0 @@
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 DELETED
@@ -1,331 +0,0 @@
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 CHANGED
@@ -1,37 +1,20 @@
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
 
@@ -41,12 +24,9 @@ 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,
@@ -54,45 +34,16 @@ SHRINKAGE_PCT = {
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)
@@ -135,37 +86,12 @@ def node_gradient(pos: np.ndarray, tets: np.ndarray, field: np.ndarray) -> np.nd
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)
@@ -175,76 +101,21 @@ def niyama_proxy(pos: np.ndarray, tets: np.ndarray, t_s: np.ndarray,
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]]
@@ -253,14 +124,10 @@ def _tet_volumes(pos: np.ndarray, tets: np.ndarray) -> np.ndarray:
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:
@@ -290,28 +157,8 @@ def _induced_components(n_nodes: int, tets: np.ndarray, mask: np.ndarray):
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)
@@ -327,66 +174,17 @@ def _boundary_faces(tets: np.ndarray) -> np.ndarray:
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
@@ -433,10 +231,8 @@ def _region_surface_area(pos: np.ndarray, tets: np.ndarray, nodes: np.ndarray,
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
@@ -450,48 +246,15 @@ def _region_surface_area(pos: np.ndarray, tets: np.ndarray, nodes: np.ndarray,
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}")
@@ -536,48 +299,22 @@ def hot_regions(pos: np.ndarray, tets: np.ndarray, t_s: np.ndarray,
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)
@@ -610,16 +347,8 @@ def is_fed(region: dict, t_s: np.ndarray, tets: np.ndarray,
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:
@@ -632,13 +361,8 @@ def macro_shrinkage(pos: np.ndarray, tets: np.ndarray, t_s: np.ndarray,
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
@@ -663,51 +387,13 @@ def top_sinks(regions: list, pos: np.ndarray, surface_nodes: np.ndarray,
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)
@@ -723,7 +409,7 @@ def feeder_recommendations(regions: list, pos: np.ndarray, tets: np.ndarray,
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
@@ -769,83 +455,17 @@ def feeder_recommendations(regions: list, pos: np.ndarray, tets: np.ndarray,
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 "
@@ -859,43 +479,20 @@ def screen_casting(pos: np.ndarray, tets: np.ndarray, surface_nodes: np.ndarray,
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
  }
 
1
+ """Shrinkage screening on a predicted solidification-time field.
2
+
3
+ Pure numpy/scipy, working on node positions `pos` (mm), tetrahedra `tets` and
4
+ the per-node solidification time `t_s` (s). Four outputs:
5
+
6
+ - A Niyama-style criterion. With only `t_s` available, the identity
7
+ grad(T) = -Tdot * grad(t_s) gives Ny = sqrt(|Tdot|) * |grad(t_s)|, and the
8
+ cooling rate is approximated by the freezing-interval rate
9
+ (TL - TS) / t_s. The result is a relative ranking within one part, so the
10
+ risk mask flags its lowest decile rather than applying absolute
11
+ literature thresholds.
12
+ - Macro-shrinkage regions: connected groups of the latest-solidifying nodes
13
+ that no feed source can reach through metal that is still liquid.
14
+ - Sinks: regions whose highest point (against gravity) is on the free
15
+ surface, which would draw the surface in rather than void internally.
16
+ - Feeder suggestions: a surface point above each region and the minimum
17
+ feeder modulus (1.2 x the region's V/A, Chvorinov's rule).
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
18
  """
19
  import logging
20
 
 
24
 
25
  logger = logging.getLogger(__name__)
26
 
27
+ # Volumetric solidification shrinkage per alloy family, in percent. Severity
28
+ # of a region = shrinkage_pct / 100 * region volume. Grey iron's graphite
29
+ # expansion offsets its shrinkage, hence 0 (regions are still reported).
 
 
 
30
  SHRINKAGE_PCT = {
31
  "steel": 2.5,
32
  "low_alloy_steel": 2.5,
 
34
  "grey_iron": 0.0,
35
  }
36
 
37
+ # Guards divisions and square roots without moving any physical value.
 
 
 
 
 
 
 
 
 
 
 
38
  _EPS = 1e-9
39
 
40
 
41
  def node_gradient(pos: np.ndarray, tets: np.ndarray, field: np.ndarray) -> np.ndarray:
42
+ """(N, 3) nodal gradient of a P1 field on a tet mesh, in [field units]/mm.
43
+
44
+ The gradient is constant per tet (solve J g = df with J the tet's edge
45
+ vectors from vertex 0), then averaged onto the nodes weighted by tet
46
+ volume. Degenerate tets contribute nothing; isolated nodes get zero."""
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
47
  pos = np.asarray(pos, dtype=np.float64)
48
  tets = np.asarray(tets, dtype=np.int64)
49
  field = np.asarray(field, dtype=np.float64)
 
86
  return node_grad_sum / np.maximum(node_vol_sum, _EPS)[:, None]
87
 
88
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
89
  def niyama_proxy(pos: np.ndarray, tets: np.ndarray, t_s: np.ndarray,
90
  TL: float, TS: float) -> np.ndarray:
91
+ """sqrt((TL - TS) / max(t_s, eps)) * |grad(t_s)|, per node.
92
+
93
+ `TL`, `TS` in deg C, `t_s` in s, `pos` in mm. A relative ranking within
94
+ one part; see the module docstring."""
 
 
 
 
 
 
 
 
 
 
95
  t_s = np.asarray(t_s, dtype=np.float64)
96
  grad = node_gradient(pos, tets, t_s)
97
  grad_mag = np.linalg.norm(grad, axis=1)
 
101
  return np.sqrt(np.maximum(tdot_proxy, 0.0)) * grad_mag
102
 
103
 
104
+ def porosity_risk(ny: np.ndarray, pct: float = 10.0) -> np.ndarray:
105
+ """Boolean mask of the within-part lowest `pct`% Niyama values
106
+ (`ny <= percentile(ny, pct)`; ties at the threshold are all flagged)."""
 
 
 
 
 
 
 
 
 
 
 
 
 
 
107
  ny = np.asarray(ny, dtype=np.float64)
108
+ threshold = np.percentile(ny, pct)
109
+ return ny <= threshold
110
 
111
+
112
+ # Local vertex-index pairs/triples of a tetrahedron.
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
113
  _TET_EDGE_PAIRS = [(0, 1), (0, 2), (0, 3), (1, 2), (1, 3), (2, 3)]
114
  _TET_FACES = [(0, 1, 2), (0, 1, 3), (0, 2, 3), (1, 2, 3)]
115
 
116
 
117
  def _tet_volumes(pos: np.ndarray, tets: np.ndarray) -> np.ndarray:
118
+ """(M,) unsigned tet volumes in mm^3."""
 
 
119
  if tets.shape[0] == 0:
120
  return np.zeros(0)
121
  p0, p1, p2, p3 = pos[tets[:, 0]], pos[tets[:, 1]], pos[tets[:, 2]], pos[tets[:, 3]]
 
124
 
125
 
126
  def _induced_components(n_nodes: int, tets: np.ndarray, mask: np.ndarray):
127
+ """Connected components of the tet-adjacency graph restricted to `mask`.
128
+
129
+ Returns (n_comp, labels) with labels of shape (n_nodes,): the component
130
+ id for masked nodes, -1 elsewhere."""
 
 
 
 
131
  idx = np.flatnonzero(mask)
132
  labels_full = np.full(n_nodes, -1, dtype=np.int64)
133
  if idx.size == 0:
 
157
 
158
 
159
  def _boundary_faces(tets: np.ndarray) -> np.ndarray:
160
+ """(F, 3) vertex triples of the mesh's outer faces: faces that belong to
161
+ exactly one tet, in their original per-tet vertex order."""
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
162
  tets = np.asarray(tets, dtype=np.int64)
163
  if tets.shape[0] == 0:
164
  return np.zeros((0, 3), dtype=np.int64)
 
174
  def _region_surface_area(pos: np.ndarray, tets: np.ndarray, nodes: np.ndarray,
175
  t_s: np.ndarray, threshold: float,
176
  boundary_faces: np.ndarray = None) -> float:
177
+ """Surface area (mm^2) of a hot region, for its V/A modulus.
178
+
179
+ The interior part is the iso-surface `t_s == threshold` (the cut that
180
+ selected the region), built by marching tetrahedra on the continuous
181
+ field: a tet with 1 or 3 vertices above the threshold contributes a
182
+ triangle, one with 2 contributes a planar quadrilateral, and crossing
183
+ points sit at their interpolated position on each edge. A tet counts for
184
+ this region if any of its vertices is in `nodes`. Outer faces of the
185
+ mesh are added separately, weighted by the fraction of their 3 vertices
186
+ in the region. `boundary_faces` can be passed in to avoid recomputing
187
+ `_boundary_faces(tets)` for every region."""
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
188
  n_nodes = pos.shape[0]
189
  is_member = np.zeros(n_nodes, dtype=bool)
190
  is_member[np.asarray(nodes, dtype=np.int64)] = True
 
231
  if boundary_faces is None:
232
  boundary_faces = _boundary_faces(tets)
233
  if boundary_faces.shape[0] > 0:
234
+ # Each outer face counts with the fraction of its 3 vertices in
235
+ # the region.
 
 
236
  member_counts = is_member[boundary_faces].sum(axis=1).astype(np.float64) # (F,)
237
  frac = member_counts / 3.0
238
  nonzero = frac > 0
 
246
 
247
  def hot_regions(pos: np.ndarray, tets: np.ndarray, t_s: np.ndarray,
248
  top_frac: float = 0.10) -> list:
249
+ """Connected groups (via tet adjacency) of the top-`top_frac` latest-
250
+ solidifying nodes, sorted by `t_max` descending.
251
+
252
+ Each region is a dict: `nodes` (sorted indices), `centroid` (mm),
253
+ `volume_mm3`, `t_max`, `region_id` (its index in this list),
254
+ `hot_threshold` (the percentile cut used) and `sub_resolution` (fewer
255
+ than 4 nodes, too small for a meaningful V/A modulus). The volume sums,
256
+ over all tets touching the region, tet volume times the fraction of its
257
+ vertices that are hot, which converges with mesh refinement."""
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
258
  top_frac = float(top_frac)
259
  if not (0.0 < top_frac <= 1.0):
260
  raise ValueError(f"hot_regions: top_frac must be in (0, 1], got {top_frac!r}")
 
299
  for i, r in enumerate(regions):
300
  r["region_id"] = i
301
  r["hot_threshold"] = float(threshold)
302
+ # Below 4 nodes the iso-surface area and the lumped volume no longer
303
+ # agree, so the V/A modulus is not meaningful.
 
 
 
 
 
 
 
 
 
304
  r["sub_resolution"] = len(r["nodes"]) < 4
305
  return regions
306
 
307
 
308
  def is_fed(region: dict, t_s: np.ndarray, tets: np.ndarray,
309
  feed_nodes: np.ndarray, corridor_frac: float = 0.95) -> bool:
310
+ """Whether a feed source can still reach the region through liquid metal.
311
+
312
+ True iff some region node and some `feed_nodes` node are connected
313
+ through nodes with `t_s >= corridor_frac * region["t_max"]` (a corridor
314
+ that stays liquid until the region itself is nearly solid). Feed nodes
315
+ are adiabatic-tagged surface nodes, for example a riser face. With no
316
+ feed nodes every region is unfed, which is the conservative answer. A
317
+ region with t_max <= 0 is reported unfed."""
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
318
  t_s = np.asarray(t_s, dtype=np.float64)
319
  tets = np.asarray(tets, dtype=np.int64)
320
  feed_nodes = np.asarray(feed_nodes, dtype=np.int64)
 
347
 
348
  def macro_shrinkage(pos: np.ndarray, tets: np.ndarray, t_s: np.ndarray,
349
  feed_nodes: np.ndarray, shrinkage_pct: float) -> list:
350
+ """The hot regions that are not fed, each with `severity = shrinkage_pct /
351
+ 100 * volume_mm3`. `region_id` keeps its value from `hot_regions`."""
 
 
 
 
 
 
 
 
352
  regions = hot_regions(pos, tets, t_s)
353
  out = []
354
  for r in regions:
 
361
 
362
  def top_sinks(regions: list, pos: np.ndarray, surface_nodes: np.ndarray,
363
  gravity_vec) -> list:
364
+ """Regions whose highest node along -gravity is a surface node. Returns
365
+ `{"region_id", "location" (mm), "volume_mm3"}` for each."""
 
 
 
 
 
366
  pos = np.asarray(pos, dtype=np.float64)
367
  gravity_vec = np.asarray(gravity_vec, dtype=np.float64)
368
  up = -gravity_vec
 
387
 
388
  def feeder_recommendations(regions: list, pos: np.ndarray, tets: np.ndarray,
389
  surface_nodes: np.ndarray, gravity_vec, t_s: np.ndarray) -> list:
390
+ """Per region: a feeder `location` and `min_modulus_mm = 1.2 * V/A`.
391
+
392
+ The location is the surface node closest to the vertical line (along
393
+ -gravity) through the region's centroid, among surface nodes at or above
394
+ the centroid (all surface nodes if none are). Sub-resolution regions are
395
+ skipped. A region with zero volume or area gets a warning and
396
+ `min_modulus_mm = 0.0`. Raises ValueError if `surface_nodes` is empty."""
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
397
  pos = np.asarray(pos, dtype=np.float64)
398
  tets = np.asarray(tets, dtype=np.int64)
399
  t_s = np.asarray(t_s, dtype=np.float64)
 
409
  "feeder location exists (a mesh with no free surface is not a "
410
  "valid casting)")
411
  surface_pos = pos[surface_nodes]
412
+ boundary_faces = _boundary_faces(tets) # shared by every region
413
 
414
  recs = []
415
  n_skipped_sub_resolution = 0
 
455
  return recs
456
 
457
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
458
  def screen_casting(pos: np.ndarray, tets: np.ndarray, surface_nodes: np.ndarray,
459
  t_s: np.ndarray, TL: float, TS: float, alloy_family: str,
460
+ gravity_vec=(0, 0, -1), feed_nodes: np.ndarray = None) -> dict:
461
+ """All four screening outputs for one part.
462
+
463
+ `alloy_family` must be a key of `SHRINKAGE_PCT`. `surface_nodes` are all
464
+ surface nodes (for sinks and feeder placement); `feed_nodes` are the
465
+ nodes that can feed a region (None means none).
466
+
467
+ Returns {"niyama", "porosity_risk", "niyama_source": "proxy",
468
+ "macro_shrinkage", "top_sinks", "feeders", "material"}."""
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
469
  if alloy_family not in SHRINKAGE_PCT:
470
  raise KeyError(
471
  f"screen_casting: unknown alloy_family {alloy_family!r} -- known "
 
479
  feed_nodes_arr = (np.zeros(0, dtype=np.int64) if feed_nodes is None
480
  else np.asarray(feed_nodes, dtype=np.int64))
481
 
482
+ ny = niyama_proxy(pos, tets, t_s, TL, TS)
483
+ risk = porosity_risk(ny)
 
 
 
 
 
 
484
 
485
  shrinkage_pct = SHRINKAGE_PCT[alloy_family]
486
  macro = macro_shrinkage(pos, tets, t_s, feed_nodes_arr, shrinkage_pct)
487
  sinks = top_sinks(macro, pos, surface_nodes, gravity_vec)
488
  feeders = feeder_recommendations(macro, pos, tets, surface_nodes, gravity_vec, t_s)
489
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
490
  return {
491
  "niyama": ny,
492
  "porosity_risk": risk,
493
+ "niyama_source": "proxy",
494
  "macro_shrinkage": macro,
495
  "top_sinks": sinks,
496
  "feeders": feeders,
 
497
  "material": {"alloy_family": alloy_family, "shrinkage_pct": shrinkage_pct},
498
  }
src/splits.py DELETED
@@ -1,61 +0,0 @@
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 DELETED
@@ -1,45 +0,0 @@
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 DELETED
@@ -1,39 +0,0 @@
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 DELETED
@@ -1,627 +0,0 @@
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 DELETED
@@ -1,300 +0,0 @@
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 DELETED
@@ -1,278 +0,0 @@
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 DELETED
@@ -1,115 +0,0 @@
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/warm_start.py DELETED
@@ -1,239 +0,0 @@
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 CHANGED
@@ -1,5 +1,5 @@
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
  A multi-solid STEP is meshed through the same prep pipeline as the CLI, which
 
1
+ """Local trame + PyVista web app: upload a STEP casting, run the prediction,
2
+ 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
  A multi-solid STEP is meshed through the same prep pipeline as the CLI, which
tests/test_features.py ADDED
@@ -0,0 +1,55 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import numpy as np
2
+ import pytest
3
+
4
+ from src.features import FEATURE_NAMES, alloy_g_vector, node_features
5
+ from src.msh_parse import parse_msh
6
+
7
+ MOLD_EFF = 1018.0
8
+
9
+
10
+ def test_shapes_and_order(mini_msh_file):
11
+ mesh = parse_msh(mini_msh_file)
12
+ f = node_features(mesh, MOLD_EFF)
13
+ assert f["X"].shape == (5, len(FEATURE_NAMES)) == (5, 15)
14
+ assert f["X"].dtype == np.float32
15
+ assert f["pos"].shape == (5, 3)
16
+ assert f["mesh_edges"].shape == (6, 2) # one tet -> 6 edges
17
+
18
+
19
+ def test_flags_and_boundary_channels(mini_msh_file):
20
+ X = node_features(parse_msh(mini_msh_file), MOLD_EFF)["X"]
21
+ col = {n: X[:, i] for i, n in enumerate(FEATURE_NAMES)}
22
+ # nodes 0..3 lie on the tagged surface, node 4 (index 4) is not in any tet
23
+ assert col["is_cooling"][:4].tolist() == [1, 1, 1, 1]
24
+ assert col["is_adiabatic"].sum() == 0 # adiabatic nodes are also cooling here
25
+ assert col["is_interior"][4] == 1
26
+ np.testing.assert_allclose(col["boundary_extraction"][:4], MOLD_EFF)
27
+ assert col["boundary_extraction"][4] == 0
28
+ assert np.all(col["boundary_heat_input"] == 0)
29
+
30
+
31
+ def test_global_columns_are_constant(mini_msh_file):
32
+ X = node_features(parse_msh(mini_msh_file), MOLD_EFF)["X"]
33
+ for name in ("volume_mm3", "surface_area_mm2", "cooling_area_frac"):
34
+ c = X[:, FEATURE_NAMES.index(name)]
35
+ assert np.all(c == c[0])
36
+ assert X[0, FEATURE_NAMES.index("volume_mm3")] == pytest.approx(1000.0 / 6.0)
37
+
38
+
39
+ def test_translation_invariance(mini_msh_file):
40
+ mesh = parse_msh(mini_msh_file)
41
+ a = node_features(mesh, MOLD_EFF)["X"]
42
+ mesh.node_xyz = mesh.node_xyz + np.array([100.0, -50.0, 7.0])
43
+ b = node_features(mesh, MOLD_EFF)["X"]
44
+ np.testing.assert_allclose(a, b, rtol=1e-5, atol=1e-4)
45
+
46
+
47
+ def test_g_vector():
48
+ props = {"k": 32.9, "rho": 6800.0, "cp": 515.0, "L_kJ_kg": 220.0,
49
+ "TL": 1160.0, "TS": 1120.0, "Tpour": 1260.0, "mold_effusivity": 1250.0}
50
+ g = alloy_g_vector(props)
51
+ assert g.shape == (5,)
52
+ assert g[0] == pytest.approx(515.0 * 140.0 / 220000.0) # Stefan number
53
+ assert g[1] == pytest.approx(0.04) and g[3] == pytest.approx(0.1)
54
+ assert g[2] == pytest.approx(32.9 / (6800.0 * 515.0) / 1e-5)
55
+ assert g[4] == pytest.approx(1.0)
tests/test_features_inference.py DELETED
@@ -1,16 +0,0 @@
1
- import numpy as np
2
- from src.msh_parse import parse_msh
3
- from src.features import compute_features, compute_features_inference, FEATURE_NAMES
4
-
5
-
6
- def test_inference_matches_training_features(mini_msh_file):
7
- mesh = parse_msh(mini_msh_file)
8
- N = mesh.node_xyz.shape[0]
9
- times = np.array([0.0, 10.0]); temps = np.full((2, N), 1200.0); temps[1] = 1100.0
10
- full = compute_features(mesh, temps, times)
11
- inf = compute_features_inference(mesh)
12
- assert inf["X"].shape == (N, len(FEATURE_NAMES))
13
- assert "y" not in inf
14
- np.testing.assert_array_equal(inf["X"], full["X"]) # identical feature columns
15
- np.testing.assert_array_equal(inf["pos"], full["pos"])
16
- assert np.isfinite(inf["X"]).all()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
tests/test_features_v3.py DELETED
@@ -1,36 +0,0 @@
1
- import numpy as np
2
-
3
- from src.msh_parse import parse_msh
4
- from src.features import (_node_features, compute_features_inference,
5
- FEATURE_NAMES, FEATURE_NAMES_V3, LOCAL_MODULUS_RADII)
6
-
7
-
8
- def test_v3_names_extend_v2():
9
- assert FEATURE_NAMES_V3[:9] == FEATURE_NAMES and len(FEATURE_NAMES_V3) == 13
10
- assert LOCAL_MODULUS_RADII == (5.0, 15.0, 40.0)
11
-
12
-
13
- def test_v3_first_columns_match_v2(mini_msh_file):
14
- mesh = parse_msh(mini_msh_file)
15
- X2, pos2, e2 = _node_features(mesh, version=2)
16
- X3, pos3, e3 = _node_features(mesh, version=3)
17
- assert X2.shape[1] == 9 and X3.shape[1] == 13
18
- assert np.allclose(X3[:, :9], X2) and np.array_equal(e2, e3)
19
-
20
-
21
- def test_geodesic_d_adiab_values(mini_msh_file):
22
- mesh = parse_msh(mini_msh_file)
23
- X3, _, _ = _node_features(mesh, version=3)
24
- col = FEATURE_NAMES_V3.index("geodesic_d_adiab")
25
- # 0-based nodes 1,2,3 lie on the Adiabatic triangle -> 0; node 0 reaches one
26
- # of them along a 10 mm tet edge
27
- assert np.allclose(X3[[1, 2, 3], col], 0.0)
28
- assert np.isclose(X3[0, col], 10.0)
29
-
30
-
31
- def test_inference_dict_has_mesh_edges(mini_msh_file):
32
- mesh = parse_msh(mini_msh_file)
33
- out = compute_features_inference(mesh, version=3)
34
- assert out["X"].shape[1] == 13
35
- me = out["mesh_edges"]
36
- assert me.ndim == 2 and me.shape[1] == 2 and me.dtype == np.int64
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
tests/test_features_v4.py DELETED
@@ -1,275 +0,0 @@
1
- import numpy as np
2
- import pytest
3
- from scipy.spatial import Delaunay
4
-
5
- from src.msh_parse import parse_msh, Mesh
6
- from src.geometry import edges_from_tets
7
- from src.features import (
8
- _node_features, compute_features,
9
- FEATURE_NAMES, FEATURE_NAMES_V3, FEATURE_NAMES_V4,
10
- G_VECTOR_NAMES, T_REF, ALPHA_REF, MOLD_EFF_REF,
11
- EXO_DIFFUSION_LAMBDA_MM,
12
- alloy_g_vector, chvorinov_t_ref,
13
- )
14
-
15
- MOLD_EFFUSIVITY = 1250.0
16
- CHILL_EFFUSIVITY = 11000.0
17
- SLEEVE_MEF = 2.0
18
- EXO_Q0 = 5000.0
19
-
20
-
21
- def _base_spec(bc_config="none", exo_q0=0.0):
22
- return {
23
- "mold_effusivity": MOLD_EFFUSIVITY,
24
- "bc_config": bc_config,
25
- "chill_effusivity": CHILL_EFFUSIVITY,
26
- "sleeve_MEF": SLEEVE_MEF,
27
- "exo_q0": exo_q0,
28
- # alloy props (for g / t_ref -- only needed by compute_features tests)
29
- "k": 35.0, "rho": 7100.0, "cp": 500.0, "L_kJ_kg": 220.0,
30
- "TL": 1180.0, "TS": 1140.0, "Tpour": 1380.0,
31
- "C_material": 6.5e5,
32
- }
33
-
34
-
35
- def _cube_with_bc_patch(n=5, a=20.0):
36
- """A Delaunay-tetrahedralized cube where the z=0 face is tagged
37
- 'Adiabatic' (the BC-patch footprint for chills/sleeves) and every other
38
- hull face is tagged 'Cooling'. Interior points of the z=0 face (not on
39
- a cube edge) are touched ONLY by Adiabatic triangles -- a clean
40
- adiab_only region, unlike the tiny conftest mini_msh where every
41
- Adiabatic node is also a Cooling node."""
42
- lin = np.linspace(0.0, a, n)
43
- pts = np.array([[x, y, z] for x in lin for y in lin for z in lin], float)
44
- dt = Delaunay(pts)
45
- tets = dt.simplices.astype(np.int64)
46
- hull = dt.convex_hull.astype(np.int64)
47
- is_bottom = np.isclose(pts[hull, 2], 0.0).all(axis=1)
48
- adiab_tri = hull[is_bottom]
49
- cool_tri = hull[~is_bottom]
50
- mesh = Mesh(node_xyz=pts, tris={"Cooling": cool_tri, "Adiabatic": adiab_tri}, tets=tets)
51
- interior_bottom = (np.isclose(pts[:, 2], 0.0)
52
- & (pts[:, 0] > 1e-9) & (pts[:, 0] < a - 1e-9)
53
- & (pts[:, 1] > 1e-9) & (pts[:, 1] < a - 1e-9))
54
- on_cool = ~np.isclose(pts[:, 2], 0.0) | ~interior_bottom
55
- return mesh, interior_bottom
56
-
57
-
58
- # --------------------------------------------------------------------- #
59
- # Names / shape contract
60
- # --------------------------------------------------------------------- #
61
- def test_v4_names_extend_v3():
62
- assert FEATURE_NAMES_V4[:9] == FEATURE_NAMES
63
- assert FEATURE_NAMES_V4[:13] == FEATURE_NAMES_V3
64
- assert len(FEATURE_NAMES_V4) == 15
65
- assert FEATURE_NAMES_V4[13:] == ["boundary_extraction", "exothermic_input"]
66
-
67
-
68
- def test_g_vector_names_and_constants():
69
- assert G_VECTOR_NAMES == ["Ste", "dTf_norm", "alpha_ratio", "superheat_norm", "mold_eff_norm"]
70
- assert T_REF == 1000.0
71
- assert ALPHA_REF == 1e-5
72
- assert MOLD_EFF_REF == 1250.0
73
-
74
-
75
- # --------------------------------------------------------------------- #
76
- # version==4 requires spec
77
- # --------------------------------------------------------------------- #
78
- def test_version4_without_spec_raises(mini_msh_file):
79
- mesh = parse_msh(mini_msh_file)
80
- with pytest.raises(ValueError):
81
- _node_features(mesh, version=4)
82
-
83
-
84
- def test_compute_features_version4_without_spec_raises(mini_msh_file):
85
- mesh = parse_msh(mini_msh_file)
86
- N = mesh.node_xyz.shape[0]
87
- times = np.array([0.0, 10.0]); temps = np.full((2, N), 1200.0); temps[1] = 1100.0
88
- with pytest.raises(ValueError):
89
- compute_features(mesh, temps, times, version=4)
90
-
91
-
92
- # --------------------------------------------------------------------- #
93
- # Backward-compat: all-sand (bc_config="none", exo_q0=0)
94
- # --------------------------------------------------------------------- #
95
- def test_v4_backward_compat_first13_columns_match_v3(mini_msh_file):
96
- mesh = parse_msh(mini_msh_file)
97
- spec = _base_spec(bc_config="none", exo_q0=0.0)
98
- X4, pos4, e4 = _node_features(mesh, version=4, spec=spec)
99
- X3, pos3, e3 = _node_features(mesh, version=3)
100
- assert X4.shape[1] == 15
101
- assert np.array_equal(X4[:, :13], X3)
102
- assert np.array_equal(pos4, pos3) and np.array_equal(e4, e3)
103
-
104
-
105
- def test_v4_backward_compat_boundary_extraction_and_exo(mini_msh_file):
106
- mesh = parse_msh(mini_msh_file)
107
- spec = _base_spec(bc_config="none", exo_q0=0.0)
108
- X4, _, _ = _node_features(mesh, version=4, spec=spec)
109
- idx = {n: i for i, n in enumerate(FEATURE_NAMES_V4)}
110
- is_interior = X4[:, idx["is_interior"]].astype(bool)
111
- extraction = X4[:, idx["boundary_extraction"]]
112
- exo = X4[:, idx["exothermic_input"]]
113
- # all boundary nodes get the baseline mold effusivity, interior nodes 0
114
- assert np.allclose(extraction[~is_interior], MOLD_EFFUSIVITY)
115
- assert np.allclose(extraction[is_interior], 0.0)
116
- assert np.allclose(exo, 0.0)
117
-
118
-
119
- def test_v4_backward_compat_on_cube_with_distinct_adiab_patch():
120
- # Same all-sand check, but on a mesh with a genuinely distinct Adiabatic
121
- # footprint (not overlapping Cooling), to make sure "none" really treats
122
- # the BC patch as ordinary mold (matches fenicsx_solver's bc_config="none"
123
- # branch: patch shares the SAME h_mold term).
124
- mesh, interior_bottom = _cube_with_bc_patch()
125
- spec = _base_spec(bc_config="none", exo_q0=0.0)
126
- X4, _, _ = _node_features(mesh, version=4, spec=spec)
127
- X3, _, _ = _node_features(mesh, version=3)
128
- assert np.array_equal(X4[:, :13], X3)
129
- idx = {n: i for i, n in enumerate(FEATURE_NAMES_V4)}
130
- extraction = X4[:, idx["boundary_extraction"]]
131
- is_interior = X4[:, idx["is_interior"]].astype(bool)
132
- assert np.allclose(extraction[interior_bottom], MOLD_EFFUSIVITY)
133
- assert np.allclose(extraction[is_interior], 0.0)
134
- assert np.allclose(X4[:, idx["exothermic_input"]], 0.0)
135
-
136
-
137
- # --------------------------------------------------------------------- #
138
- # BC channel mapping: chills / insulating_sleeve / exo_sleeve
139
- # --------------------------------------------------------------------- #
140
- def test_v4_chills_mapping():
141
- mesh, interior_bottom = _cube_with_bc_patch()
142
- spec = _base_spec(bc_config="chills", exo_q0=0.0)
143
- X4, _, _ = _node_features(mesh, version=4, spec=spec)
144
- idx = {n: i for i, n in enumerate(FEATURE_NAMES_V4)}
145
- extraction = X4[:, idx["boundary_extraction"]]
146
- is_interior = X4[:, idx["is_interior"]].astype(bool)
147
- assert np.allclose(extraction[interior_bottom], CHILL_EFFUSIVITY)
148
- assert np.allclose(extraction[is_interior], 0.0)
149
- assert np.allclose(X4[:, idx["exothermic_input"]], 0.0)
150
- # cooling-only nodes still get the mold baseline
151
- cool_only = ~is_interior & ~interior_bottom
152
- assert np.allclose(extraction[cool_only], MOLD_EFFUSIVITY)
153
-
154
-
155
- def test_v4_insulating_sleeve_mapping():
156
- mesh, interior_bottom = _cube_with_bc_patch()
157
- spec = _base_spec(bc_config="insulating_sleeve", exo_q0=0.0)
158
- X4, _, _ = _node_features(mesh, version=4, spec=spec)
159
- idx = {n: i for i, n in enumerate(FEATURE_NAMES_V4)}
160
- extraction = X4[:, idx["boundary_extraction"]]
161
- expected = MOLD_EFFUSIVITY / max(SLEEVE_MEF ** 2, 1.0)
162
- assert np.allclose(extraction[interior_bottom], expected)
163
- assert np.allclose(X4[:, idx["exothermic_input"]], 0.0)
164
-
165
-
166
- def test_v4_exo_sleeve_mapping():
167
- # NEW contract (geodesic-diffused field, not a sparse indicator): peak
168
- # exo_q0/1e6 AT the patch nodes, decaying smoothly with geodesic distance
169
- # -- see test_exo_channel_is_diffused_field/test_exo_channel_zero_for_non_exo
170
- # below for the full shape/decay assertions. This test still checks that
171
- # boundary_extraction (unrelated column) keeps its sleeve-scaled mapping
172
- # and that the peak value at the patch is exactly exo_q0/1e6.
173
- mesh, interior_bottom = _cube_with_bc_patch()
174
- spec = _base_spec(bc_config="exo_sleeve", exo_q0=EXO_Q0)
175
- X4, _, _ = _node_features(mesh, version=4, spec=spec)
176
- idx = {n: i for i, n in enumerate(FEATURE_NAMES_V4)}
177
- extraction = X4[:, idx["boundary_extraction"]]
178
- exo = X4[:, idx["exothermic_input"]]
179
- expected = MOLD_EFFUSIVITY / max(SLEEVE_MEF ** 2, 1.0)
180
- assert np.allclose(extraction[interior_bottom], expected)
181
- assert np.isclose(exo[interior_bottom].max(), EXO_Q0 / 1e6, rtol=1e-5)
182
- # diffused, not sparse: some nodes OUTSIDE the patch are nonzero too.
183
- outside_patch = ~interior_bottom
184
- assert (exo[outside_patch] > 0).any()
185
-
186
-
187
- def test_exo_channel_is_diffused_field():
188
- mesh, interior_bottom = _cube_with_bc_patch()
189
- X, _, _ = _node_features(mesh, version=4, spec=_base_spec("exo_sleeve", exo_q0=EXO_Q0))
190
- ei = X[:, FEATURE_NAMES_V4.index("exothermic_input")]
191
- peak = EXO_Q0 / 1e6
192
- assert np.isclose(ei[interior_bottom].max(), peak, rtol=1e-5) # peak at patch
193
- far = np.isclose(mesh.node_xyz[:, 2], 20.0) # opposite face
194
- assert (ei[far] < peak * np.exp(-20.0 / EXO_DIFFUSION_LAMBDA_MM) * 1.5).all() # decayed far away
195
- assert (ei[far] > 0).any() # but still nonzero (diffused)
196
- mid = np.isclose(mesh.node_xyz[:, 2], 10.0)
197
- assert ei[mid].max() < ei[interior_bottom].max() # monotone trend
198
- assert ei[mid].max() > ei[far].max()
199
-
200
-
201
- def test_exo_channel_zero_for_non_exo():
202
- mesh, _ = _cube_with_bc_patch()
203
- for bc in ("none", "chills", "insulating_sleeve"):
204
- X, _, _ = _node_features(mesh, version=4, spec=_base_spec(bc))
205
- assert (X[:, FEATURE_NAMES_V4.index("exothermic_input")] == 0).all()
206
-
207
-
208
- def test_v4_sleeve_mef_floor_at_one():
209
- # max(MEF**2, 1) guards MEF < 1 from amplifying extraction above baseline
210
- mesh, interior_bottom = _cube_with_bc_patch()
211
- spec = _base_spec(bc_config="insulating_sleeve", exo_q0=0.0)
212
- spec["sleeve_MEF"] = 0.5
213
- X4, _, _ = _node_features(mesh, version=4, spec=spec)
214
- idx = {n: i for i, n in enumerate(FEATURE_NAMES_V4)}
215
- extraction = X4[:, idx["boundary_extraction"]]
216
- assert np.allclose(extraction[interior_bottom], MOLD_EFFUSIVITY) # divided by 1, not 0.25
217
-
218
-
219
- # --------------------------------------------------------------------- #
220
- # alloy_g_vector
221
- # --------------------------------------------------------------------- #
222
- def test_alloy_g_vector_hand_computed():
223
- props = {
224
- "k": 35.0, "rho": 7100.0, "cp": 500.0, "L_kJ_kg": 220.0,
225
- "TL": 1180.0, "TS": 1140.0, "Tpour": 1380.0,
226
- "mold_effusivity": MOLD_EFFUSIVITY,
227
- }
228
- g = alloy_g_vector(props)
229
- assert g.shape == (5,) and g.dtype == np.float64
230
- expected_Ste = 500.0 * (1380.0 - 1140.0) / (220.0 * 1000.0)
231
- expected_dTf_norm = (1180.0 - 1140.0) / T_REF
232
- expected_alpha_ratio = (35.0 / (7100.0 * 500.0)) / ALPHA_REF
233
- expected_superheat_norm = (1380.0 - 1180.0) / T_REF
234
- expected_mold_eff_norm = MOLD_EFFUSIVITY / MOLD_EFF_REF
235
- np.testing.assert_allclose(
236
- g, [expected_Ste, expected_dTf_norm, expected_alpha_ratio,
237
- expected_superheat_norm, expected_mold_eff_norm])
238
-
239
-
240
- # --------------------------------------------------------------------- #
241
- # chvorinov_t_ref
242
- # --------------------------------------------------------------------- #
243
- def test_chvorinov_t_ref_monotonic_in_modulus_squared():
244
- C = 6.5e5
245
- area = 100.0
246
- t_small = chvorinov_t_ref(volume_mm3=500.0, area_mm2=area, C_material=C)
247
- t_large = chvorinov_t_ref(volume_mm3=2000.0, area_mm2=area, C_material=C)
248
- assert t_large > t_small
249
- # exact formula
250
- assert np.isclose(t_small, C * (500.0 / area) ** 2)
251
- assert np.isclose(t_large, C * (2000.0 / area) ** 2)
252
-
253
-
254
- # --------------------------------------------------------------------- #
255
- # compute_features(version=4): g / t_ref / y_nondim
256
- # --------------------------------------------------------------------- #
257
- def test_compute_features_v4_adds_g_tref_ynondim(mini_msh_file):
258
- mesh = parse_msh(mini_msh_file)
259
- N = mesh.node_xyz.shape[0]
260
- times = np.array([0.0, 10.0]); temps = np.full((2, N), 1200.0); temps[1] = 1100.0
261
- spec = _base_spec(bc_config="none", exo_q0=0.0)
262
- out = compute_features(mesh, temps, times, version=4, spec=spec)
263
- assert out["X"].shape == (N, 15)
264
- assert "y" in out and "g" in out and "t_ref" in out and "y_nondim" in out
265
- assert out["g"].shape == (5,) and out["g"].dtype == np.float64
266
- expected_g = alloy_g_vector(spec)
267
- np.testing.assert_allclose(out["g"], expected_g)
268
-
269
- volume = out["X"][0, FEATURE_NAMES.index("volume_mm3")]
270
- area = out["X"][0, FEATURE_NAMES.index("surface_area_mm2")]
271
- expected_t_ref = chvorinov_t_ref(volume, area, spec["C_material"])
272
- assert np.isclose(out["t_ref"], expected_t_ref)
273
-
274
- expected_y_nondim = out["y"] / max(expected_t_ref, 1e-9)
275
- np.testing.assert_allclose(out["y_nondim"], expected_y_nondim, rtol=1e-5)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
tests/test_film.py DELETED
@@ -1,46 +0,0 @@
1
- import torch
2
- from src.model_gnn import FiLM, SolidGNN
3
-
4
-
5
- def test_film_zero_init_is_identity():
6
- f = FiLM(g_dim=5, hidden=16)
7
- h = torch.randn(7, 16); g = torch.randn(7, 5)
8
- assert torch.allclose(f(h, g), h, atol=1e-6)
9
-
10
-
11
- def test_solidgnn_film_zero_init_matches_no_film():
12
- """SolidGNN(film=True) with zero-init FiLM params must reproduce
13
- SolidGNN(film=False) output exactly, given identical base weights,
14
- identical inputs, a real batch vector, and a real (non-None) g tensor."""
15
- N, E, B = 20, 120, 3
16
- x = torch.randn(N, 9)
17
- edge_index = torch.randint(0, N, (2, E))
18
- edge_attr = torch.randn(E, 4)
19
- batch = torch.tensor([0] * 7 + [1] * 7 + [2] * 6, dtype=torch.long)
20
- g = torch.randn(B, 5)
21
-
22
- torch.manual_seed(42)
23
- base = SolidGNN(n_blocks=3, hidden=16, film=False)
24
-
25
- torch.manual_seed(42)
26
- filmed = SolidGNN(n_blocks=3, hidden=16, film=True, g_dim=5)
27
- # base weights (encoder/blocks/decoder) are seeded identically; FiLM layers
28
- # are zero-init by construction (asserted separately in the test above), so
29
- # filmed's non-FiLM parameters must match base's exactly.
30
- for (n1, p1), (n2, p2) in zip(base.named_parameters(), filmed.named_parameters()):
31
- assert n1 == n2
32
- torch.testing.assert_close(p1, p2)
33
-
34
- base.eval(); filmed.eval()
35
- with torch.no_grad():
36
- yn_base, yg_base = base(x, edge_index, edge_attr, batch)
37
- yn_film, yg_film = filmed(x, edge_index, edge_attr, batch, g=g)
38
-
39
- torch.testing.assert_close(yn_film, yn_base, atol=1e-6, rtol=1e-6)
40
- torch.testing.assert_close(yg_film, yg_base, atol=1e-6, rtol=1e-6)
41
-
42
- # also check g=None on the filmed model is identical (old positional call still works)
43
- with torch.no_grad():
44
- yn_film_noG, yg_film_noG = filmed(x, edge_index, edge_attr, batch)
45
- torch.testing.assert_close(yn_film_noG, yn_base, atol=1e-6, rtol=1e-6)
46
- torch.testing.assert_close(yg_film_noG, yg_base, atol=1e-6, rtol=1e-6)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
tests/test_gbm_heads.py ADDED
@@ -0,0 +1,55 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import numpy as np
2
+ import pytest
3
+
4
+ from src import gbm_heads
5
+ from src.features import FEATURE_NAMES
6
+
7
+ PROPS = {"k": 32.9, "rho": 6800.0, "cp": 515.0, "L_kJ_kg": 220.0,
8
+ "TL": 1160.0, "TS": 1120.0, "Tpour": 1260.0, "mold_effusivity": 1018.0}
9
+
10
+
11
+ def _X(n=10, vol=8000.0, area=2400.0, n_adiab=0):
12
+ X = np.zeros((n, len(FEATURE_NAMES)), np.float32)
13
+ X[:, FEATURE_NAMES.index("volume_mm3")] = vol
14
+ X[:, FEATURE_NAMES.index("surface_area_mm2")] = area
15
+ X[:6, FEATURE_NAMES.index("is_cooling")] = 1
16
+ X[6:6 + n_adiab, FEATURE_NAMES.index("is_adiabatic")] = 1
17
+ return X
18
+
19
+
20
+ def test_total_row_layout():
21
+ row = gbm_heads.total_row(PROPS, _X())
22
+ assert row.shape == (gbm_heads.N_TOTAL_FEATURES,)
23
+ assert row[:7].tolist() == [32.9, 6800.0, 515.0, 220.0, 1160.0, 1120.0, 1260.0]
24
+ assert row[7] == pytest.approx(8000.0 / 2400.0)
25
+ assert row[8] == 1018.0
26
+ assert row[9:].tolist() == [1, 0, 0, 0, 0, 0, 0]
27
+
28
+
29
+ def test_spread_row_appends_adiabatic_fraction():
30
+ X = _X(n_adiab=2)
31
+ row = gbm_heads.spread_row(gbm_heads.total_row(PROPS, X), X)
32
+ assert row.shape == (gbm_heads.N_SPREAD_FEATURES,)
33
+ assert row[-1] == pytest.approx(2 / 8)
34
+
35
+
36
+ class _Stub:
37
+ def __init__(self, value, n):
38
+ self.value, self.n_features_in_ = value, n
39
+
40
+ def predict(self, x):
41
+ return np.full(len(x), self.value)
42
+
43
+
44
+ def test_predictions_are_expm1_of_log_output():
45
+ row = np.zeros(16)
46
+ assert gbm_heads.predict_total_seconds(_Stub(np.log1p(500.0), 16), row) == pytest.approx(500.0)
47
+ assert gbm_heads.predict_spread_seconds(_Stub(-5.0, 17), np.zeros(17)) == 0.0
48
+ with pytest.raises(ValueError):
49
+ gbm_heads.predict_total_seconds(_Stub(-1.0, 16), row)
50
+
51
+
52
+ def test_check_head_rejects_wrong_width():
53
+ gbm_heads.check_head(_Stub(0.0, 16), 16, "total-time")
54
+ with pytest.raises(ValueError):
55
+ gbm_heads.check_head(_Stub(0.0, 9), 16, "total-time")
tests/test_geometry.py CHANGED
@@ -1,9 +1,6 @@
1
  import numpy as np
2
- import pytest
3
- from unittest.mock import patch
4
  from src.geometry import (triangle_areas, total_area, mesh_volume,
5
- nearest_distance, boundary_node_mask,
6
- geodesic_diameter_two_sweep)
7
 
8
 
9
  def test_triangle_area_right_triangle():
@@ -37,68 +34,3 @@ def test_boundary_node_mask():
37
  tri = np.array([[0, 1, 2], [1, 2, 3]])
38
  m = boundary_node_mask(5, tri)
39
  np.testing.assert_array_equal(m, [True, True, True, True, False])
40
-
41
-
42
- # --------------------------------------------------------------------- #
43
- # geodesic_diameter_two_sweep (bc-reach re-review N1): direct unit tests.
44
- # The bc-reach features-v5 tests only ever use this helper as an
45
- # expected-value oracle on real/synthetic meshes -- a "d2 = d1" single-
46
- # sweep mutant (skip the second Dijkstra entirely, just report sweep-1's
47
- # own max) passes all of them, because none of those fixtures put the
48
- # fixed `start=0` at a graph MIDPOINT where a single sweep provably can't
49
- # see the true diameter. These tests target that property directly.
50
- # --------------------------------------------------------------------- #
51
- def _mid_start_path_graph(edge_len_mm=10.0):
52
- """A 5-node path graph -- the chain order (by position) is
53
- node1-node2-node0-node3-node4 -- where node INDEX 0 (this function's
54
- default `start`) sits at the graph's MIDDLE, not at either end. True
55
- diameter (node1 <-> node4, both ends) = 4 hops = 4*edge_len_mm. A
56
- single Dijkstra sweep from node0 only reaches 2 hops in either
57
- direction (both ends are tied at that distance) -- it structurally
58
- cannot see the two ends' mutual distance -- so a "d2 = d1" mutant
59
- (reporting sweep-1's own max instead of re-sweeping from sweep-1's
60
- farthest node) returns 2*edge_len_mm here: HALF the true diameter."""
61
- edges = np.array([[1, 2], [2, 0], [0, 3], [3, 4]], dtype=np.int64)
62
- edge_len = np.full(4, edge_len_mm, dtype=float)
63
- return 5, edges, edge_len
64
-
65
-
66
- def test_geodesic_diameter_two_sweep_mid_start_finds_true_diameter():
67
- n_nodes, edges, edge_len = _mid_start_path_graph(edge_len_mm=10.0)
68
- d_geo = geodesic_diameter_two_sweep(n_nodes, edges, edge_len) # default start=0
69
- # 4 hops * 10mm = 40.0 -- a single-sweep-from-the-middle mutant would
70
- # instead report 20.0 (2 hops, the farthest EITHER end appears from
71
- # the midpoint), so this assertion fails under that mutant.
72
- assert d_geo == pytest.approx(40.0)
73
-
74
-
75
- def test_geodesic_diameter_two_sweep_edgeless_returns_zero():
76
- edges = np.empty((0, 2), dtype=np.int64)
77
- edge_len = np.empty(0, dtype=float)
78
- assert geodesic_diameter_two_sweep(5, edges, edge_len) == 0.0
79
-
80
-
81
- def test_geodesic_diameter_two_sweep_zero_nodes_returns_zero():
82
- edges = np.empty((0, 2), dtype=np.int64)
83
- edge_len = np.empty(0, dtype=float)
84
- assert geodesic_diameter_two_sweep(0, edges, edge_len) == 0.0
85
-
86
-
87
- def test_geodesic_diameter_two_sweep_no_finite_distances_returns_zero():
88
- # The documented "no finite distances" guard is unreachable through any
89
- # REAL Dijkstra call (a node's distance to itself is always 0, so
90
- # sweep-1 always has at least one finite entry) -- exercised directly
91
- # here by forcing the underlying primitive to report all-inf, to prove
92
- # the guard degrades to 0.0 instead of crashing (e.g. np.argmax over an
93
- # all-False finite mask) if that invariant is ever violated.
94
- n_nodes, edges, edge_len = _mid_start_path_graph()
95
- with patch("src.geometry.geodesic_distance",
96
- return_value=np.full(n_nodes, np.inf)):
97
- assert geodesic_diameter_two_sweep(n_nodes, edges, edge_len) == 0.0
98
-
99
-
100
- def test_geodesic_diameter_two_sweep_deterministic():
101
- n_nodes, edges, edge_len = _mid_start_path_graph()
102
- d_a = geodesic_diameter_two_sweep(n_nodes, edges, edge_len, start=0)
103
- d_b = geodesic_diameter_two_sweep(n_nodes, edges, edge_len, start=0)
104
- assert d_a == d_b # bit-equal: deterministic, fixed start, no RNG
 
1
  import numpy as np
 
 
2
  from src.geometry import (triangle_areas, total_area, mesh_volume,
3
+ nearest_distance, boundary_node_mask)
 
4
 
5
 
6
  def test_triangle_area_right_triangle():
 
34
  tri = np.array([[0, 1, 2], [1, 2, 3]])
35
  m = boundary_node_mask(5, tri)
36
  np.testing.assert_array_equal(m, [True, True, True, True, False])
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
tests/test_graph_build.py DELETED
@@ -1,52 +0,0 @@
1
- import torch
2
- from src.graph_build import knn_edge_index, edge_attr, random_rotation_matrix, rotate
3
-
4
-
5
- def test_knn_symmetric_no_selfloops():
6
- pos = torch.tensor([[0., 0, 0], [1, 0, 0], [2, 0, 0], [3, 0, 0]])
7
- ei = knn_edge_index(pos, k=1)
8
- assert ei.shape[0] == 2
9
- assert (ei[0] != ei[1]).all() # no self-loops
10
- pairs = {(int(a), int(b)) for a, b in ei.t()}
11
- assert all((b, a) in pairs for a, b in pairs) # symmetric
12
-
13
-
14
- def test_edge_attr_mm():
15
- pos = torch.tensor([[0., 0, 0], [3, 4, 0]])
16
- ei = torch.tensor([[0], [1]])
17
- ea = edge_attr(pos, ei)
18
- torch.testing.assert_close(ea[0], torch.tensor([3., 4., 0., 5.])) # dst-src + norm
19
-
20
-
21
- def test_rotation_is_orthonormal_and_preserves_distances():
22
- R = random_rotation_matrix(seed=0)
23
- torch.testing.assert_close(R @ R.t(), torch.eye(3), atol=1e-5, rtol=1e-5)
24
- pos = torch.randn(10, 3)
25
- rp = rotate(pos, R)
26
- d0 = torch.cdist(pos, pos); d1 = torch.cdist(rp, rp)
27
- torch.testing.assert_close(d0, d1, atol=1e-4, rtol=1e-4) # rotation preserves distances
28
-
29
-
30
- def test_union_contains_mesh_edges_flagged():
31
- from src.graph_build import union_edge_index, union_edge_attr
32
- pos = torch.tensor([[0., 0, 0], [1, 0, 0], [2, 0, 0], [10, 10, 10]])
33
- me = torch.tensor([[0, 3]]) # pair kNN(k=1) would never pick
34
- ei, ism = union_edge_index(pos, me, k=1)
35
- pairs = {(int(a), int(b)): float(f) for (a, b), f in zip(ei.t(), ism)}
36
- assert pairs[(0, 3)] == 1.0 and pairs[(3, 0)] == 1.0 # both directions, flagged
37
- assert all(f == 0.0 for p, f in pairs.items() if p not in {(0, 3), (3, 0)})
38
- key = ei[0] * pos.shape[0] + ei[1]
39
- assert len(torch.unique(key)) == ei.shape[1] # no duplicates
40
- assert all((b, a) in pairs for a, b in pairs) # symmetric
41
- ea = union_edge_attr(pos, ei, ism)
42
- assert ea.shape == (ei.shape[1], 5)
43
- torch.testing.assert_close(ea[:, 4], ism)
44
-
45
-
46
- def test_union_dedups_overlap():
47
- from src.graph_build import union_edge_index
48
- pos = torch.tensor([[0., 0, 0], [1, 0, 0]])
49
- me = torch.tensor([[0, 1]]) # same pair kNN also finds
50
- ei, ism = union_edge_index(pos, me, k=1)
51
- assert ei.shape[1] == 2 # (0,1) and (1,0), once each
52
- assert (ism == 1.0).all() # mesh flag wins on overlap
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
tests/test_hybrid.py CHANGED
@@ -1,34 +1,14 @@
1
  import logging
2
- import os
3
 
4
  import numpy as np
5
  import pytest
6
- from src.hybrid import (hybrid_field, hybrid_field_affine, hybrid_field_max_anchor,
7
- hybrid_field_soft)
8
 
9
 
10
- def test_hybrid_takes_shape_from_gnn_level_from_baseline():
11
- # GNN shape [-1,0,+1] around mean 5; baseline mean 20 (shape irrelevant)
12
- gnn = np.array([4.0, 5.0, 6.0]) # deviations -1,0,+1
13
- base = np.array([18.0, 22.0, 20.0]) # mean 20
14
- out = hybrid_field(gnn, base)
15
- # deviations preserved from GNN, level set to baseline mean (20)
16
- np.testing.assert_allclose(out, [19.0, 20.0, 21.0])
17
 
18
 
19
- def test_hybrid_clips_negative():
20
- gnn = np.array([0.0, 10.0]) # mean 5, devs -5,+5
21
- base = np.array([1.0, 1.0]) # mean 1 -> level 1: -4, +6 -> clip -4 to 0
22
- out = hybrid_field(gnn, base)
23
- np.testing.assert_allclose(out, [0.0, 6.0])
24
 
25
 
26
- def test_hybrid_preserves_within_graph_ranking():
27
- # hot-spot ranking (argsort) must come from the GNN shape, unchanged by re-leveling
28
- gnn = np.array([3.0, 1.0, 9.0, 5.0])
29
- base = np.array([100.0, 100.0, 100.0, 100.0])
30
- out = hybrid_field(gnn, base)
31
- assert list(np.argsort(out)) == list(np.argsort(gnn))
32
 
33
 
34
  def test_hybrid_affine_spread_match():
@@ -59,9 +39,8 @@ def test_hybrid_affine_preserves_ranking():
59
 
60
  def test_hybrid_affine_clamp_high():
61
  # gnn std is 10; std_seconds=100 -> a_raw=10.0, clamped down to the a_clamp
62
- # ceiling (widened 3.0 -> 5.0 by the 2026-07-31 spec amendment). No clipping
63
- # occurs at a=5.0 here (total=1000 is large), so this exercises the fast
64
- # (pre-amendment closed-form) path's own clamp handling.
65
  gnn = np.array([0.0, 20.0])
66
  total = 1000.0
67
  pred, info = hybrid_field_affine(gnn, total, std_seconds=100.0)
@@ -96,8 +75,8 @@ def test_hybrid_affine_clip_negative():
96
 
97
 
98
  def test_hybrid_affine_heavy_clip_achievable():
99
- # Real fields under FT3 gate 4 sometimes need heavy scaling to hit the
100
- # GBM's std target; the pre-amendment closed form ignores clipping
101
  # entirely, so its implied `a` can leave the delivered std far short of
102
  # the target. Construct such a case (right-skewed field; the naive
103
  # closed-form `a` clips ~30% of nodes) and confirm the clip-aware solve
@@ -123,8 +102,7 @@ def test_hybrid_affine_heavy_clip_achievable():
123
  def test_hybrid_affine_unachievable_due_to_clipping():
124
  # std_seconds=8.0 exceeds this field's interior PEAK (f rises past
125
  # a_clipstart to a peak around a~1.78, std~3.82, then falls -- it never
126
- # reaches 8.0 anywhere in the clamp range). Per the 2026-07-31 review
127
- # correction, the unreachable case must pick the scanned point CLOSEST to
128
  # the target (here, at/near the peak) rather than blindly clamping to
129
  # a_clamp[1]=5.0 -- clamping to 5.0 would give a worse fit (std~3.45,
130
  # clip_frac 0.7) than the peak (std~3.82, clip_frac 0.4).
@@ -142,30 +120,6 @@ def test_hybrid_affine_unachievable_due_to_clipping():
142
  assert abs(achieved - 8.0) < abs(np.std(at_hi) - 8.0)
143
 
144
 
145
- _A_00968 = "data/features_v4/graphs/A_00968.npz"
146
-
147
-
148
- @pytest.mark.skipif(not os.path.exists(_A_00968),
149
- reason="requires the FT2a v4 feature cache "
150
- "(data/features_v4/graphs/A_00968.npz) on disk")
151
- def test_hybrid_affine_real_field_returns_smallest_root_not_ceiling():
152
- # Regression for the 2026-07-31 review finding: on a REAL per-node `y`
153
- # field, f(a) has an interior peak (~a=2.67, std~420.5) then falls
154
- # (f(5.0)~390.8). A naive `f(hi) < std_target => clamp` pre-check would
155
- # wrongly land on a=5.0 (clip_frac~0.48) even though a much-less-clipped
156
- # true root exists near the peak's rising edge (a~2.6, clip_frac~0.07).
157
- # The clip-aware solve must find that smaller root, not the ceiling.
158
- data = np.load(_A_00968)
159
- y = data["y"].astype(np.float64)
160
- total = 1000.0
161
- target = 419.0 # inside the narrow (f(a_clipstart), peak_f) crossing window
162
-
163
- pred, info = hybrid_field_affine(y, total, target)
164
-
165
- assert info["a_clamped"] is False
166
- assert info["a"] != pytest.approx(5.0)
167
- assert info["clip_frac"] < 0.3
168
- assert np.std(pred) == pytest.approx(target, rel=1e-3)
169
 
170
 
171
  def test_hybrid_affine_no_clip_matches_pre_amendment_closed_form():
@@ -193,87 +147,18 @@ def test_hybrid_affine_no_clip_matches_pre_amendment_closed_form():
193
  np.testing.assert_allclose(pred_new, pred_old, rtol=1e-6)
194
 
195
 
196
- # --------------------------------------------------------------------------- #
197
- # AMENDMENT 2: lambda-shrinkage of the affine spread target (2026-07-31)
198
- # --------------------------------------------------------------------------- #
199
- def test_hybrid_affine_lambda_default_bit_identical_on_clip_heavy_case():
200
- # lambda_shrink=1.0 (the default) must be byte-for-bit identical to
201
- # calling hybrid_field_affine with no lambda_shrink argument at all --
202
- # on a CLIP-HEAVY case (the heavy-clip-achievable field from round 1),
203
- # to prove the guard skips the new code path even when the solve
204
- # actually clips (not just in a trivial no-clip case).
205
- g = np.random.default_rng(1).exponential(5.0, size=200)
206
- total = 8.0
207
- target = 0.3 * np.std(g)
208
 
209
- pred_default, info_default = hybrid_field_affine(g, total, target)
210
- pred_lambda1, info_lambda1 = hybrid_field_affine(g, total, target, lambda_shrink=1.0)
211
 
212
- assert np.array_equal(pred_default, pred_lambda1)
213
- assert info_default["a"] == info_lambda1["a"]
214
- assert info_default["clip_frac"] == info_lambda1["clip_frac"]
215
- assert info_lambda1["lambda_shrink"] == 1.0
216
- assert info_lambda1["std_target_effective"] == pytest.approx(target, rel=1e-12)
217
 
218
 
219
- def test_hybrid_affine_lambda_zero_targets_gnn_own_spread():
220
- # lambda_shrink=0.0 -> std_eff = std(gnn)**1 * std_target**0 = std(gnn)
221
- # exactly, regardless of std_target's value. In a no-clip case this
222
- # makes a==1.0 (no spread rescaling at all), so the result must equal
223
- # hybrid_field_max_anchor's pure quantile-anchor level shift exactly.
224
- gnn = np.array([3.0, 1.0, 9.0, 5.0])
225
- total = 1000.0
226
- pred, info = hybrid_field_affine(gnn, total, std_seconds=999.0, lambda_shrink=0.0)
227
- expected = hybrid_field_max_anchor(gnn, total)
228
- assert info["a"] == pytest.approx(1.0, rel=1e-9)
229
- assert info["a_clamped"] is False
230
- assert info["std_target_effective"] == pytest.approx(np.std(gnn), rel=1e-9)
231
- np.testing.assert_allclose(pred, expected, rtol=1e-9)
232
 
233
 
234
- def test_hybrid_affine_lambda_half_is_geometric_mean_of_stds():
235
- # lambda_shrink=0.5 -> std_eff = sqrt(std(gnn) * std_target). In a
236
- # no-clip case the achieved std equals std_eff exactly (fast path).
237
- gnn = np.array([0.0, 20.0]) # std(gnn) == 10
238
- total = 1000.0 # large enough that nothing clips
239
- std_target = 20.0
240
- s = np.std(gnn)
241
 
242
- pred, info = hybrid_field_affine(gnn, total, std_target, lambda_shrink=0.5)
243
 
244
- expected_std_eff = np.sqrt(s * std_target)
245
- assert info["std_target_effective"] == pytest.approx(expected_std_eff, rel=1e-9)
246
- assert info["a_clamped"] is False
247
- assert np.std(pred) == pytest.approx(expected_std_eff, rel=1e-9)
248
-
249
-
250
- def test_hybrid_affine_lambda_info_carries_new_keys():
251
- gnn = np.array([3.0, 1.0, 9.0, 5.0])
252
- pred, info = hybrid_field_affine(gnn, 1000.0, 5.0, lambda_shrink=0.7)
253
- assert "lambda_shrink" in info
254
- assert "std_target_effective" in info
255
- assert info["lambda_shrink"] == pytest.approx(0.7)
256
- assert isinstance(info["std_target_effective"], float)
257
-
258
-
259
- def test_hybrid_affine_lambda_nondefault_degenerate_std_target_falls_back(caplog):
260
- # std_seconds <= 0 is only inspected when lambda_shrink != 1.0 (the
261
- # default path never checks its sign) -- with a non-default lambda it
262
- # must fall back to the quantile-anchor with the same loud-warning
263
- # pattern as the degenerate-GNN-field guard.
264
- gnn = np.array([3.0, 1.0, 9.0, 5.0])
265
- total = 1000.0
266
- with caplog.at_level(logging.WARNING):
267
- pred, info = hybrid_field_affine(gnn, total, std_seconds=0.0, lambda_shrink=0.5)
268
- expected = hybrid_field_max_anchor(gnn, total)
269
- np.testing.assert_allclose(pred, expected)
270
- assert info["degenerate_fallback"] is True
271
- assert info["lambda_shrink"] == 0.5
272
- assert any("degenerate" in rec.message.lower() for rec in caplog.records)
273
 
274
 
275
  # --------------------------------------------------------------------------- #
276
- # hybrid_field_soft: weighted two-target soft recomposition (2026-07-31)
277
  # --------------------------------------------------------------------------- #
278
  def _squeezed_case():
279
  # A field with a small (3%) group of low-value outlier nodes and a tight
@@ -349,77 +234,20 @@ def test_hybrid_soft_degenerate_flat_field_falls_back(caplog):
349
  assert any("degenerate" in rec.message.lower() for rec in caplog.records)
350
 
351
 
352
- def test_hybrid_soft_info_dict_carries_all_keys():
353
- g, total, target = _squeezed_case()
354
- pred, info = hybrid_field_soft(g, total, target)
355
- expected_keys = {"a", "m", "target_unreachable", "degenerate_fallback", "clip_frac",
356
- "total_deviation_frac", "j_total_resid", "j_std_resid",
357
- "lambda_shrink", "std_target_effective"}
358
- assert expected_keys <= set(info.keys())
359
- # clip_frac must be measured on the FINAL (returned) field.
360
- a, m = info["a"], info["m"]
361
- c = g - g.mean()
362
- final_raw = a * c + m
363
- assert info["clip_frac"] == pytest.approx(float((final_raw < 0.0).mean()))
364
 
365
 
366
- def test_hybrid_soft_lambda_shrink_matches_v5_convention():
367
- gnn = np.array([0.0, 20.0])
368
- total = 1000.0
369
- std_seconds = 20.0
370
- s = np.std(gnn)
371
- pred, info = hybrid_field_soft(gnn, total, std_seconds, lambda_shrink=0.5)
372
- expected = (s ** 0.5) * (std_seconds ** 0.5)
373
- assert info["std_target_effective"] == pytest.approx(expected, rel=1e-9)
374
 
375
 
376
  # --------------------------------------------------------------------------- #
377
- # Review round 1 (2026-07-31) fix coverage: CRITICAL-1 (m-window centered on
378
- # the seed, not the absolute total*[0.7,1.3]), CRITICAL-2 (perf budget), LOW-6
379
- # (a_clamp[0]<=0 guard), LOW-7 (realistic-scale degeneration case).
380
  # --------------------------------------------------------------------------- #
381
- _REAL_FIELD_STEMS = ["A_00968", "A_01541", "C_00681"]
382
- _REAL_FIELD_PATHS = [f"data/features_v4/graphs/{stem}.npz" for stem in _REAL_FIELD_STEMS]
383
-
384
-
385
- @pytest.mark.parametrize("path", _REAL_FIELD_PATHS)
386
- def test_hybrid_soft_engages_with_interior_optimum_on_real_fields(path):
387
- # Reviewer-mandated re-verification for CRITICAL-1: on real per-node `y`
388
- # fields with std_t just past the affine solve's own reach (1.1-1.5x
389
- # std(g)), the soft solve at DEFAULT weights must engage (not just return
390
- # the seed) with an INTERIOR `a` (strictly inside a_clamp, not pinned to
391
- # either boundary) and a single-digit-percent total deviation -- verified
392
- # interactively before encoding: e.g. A_00968 at 1.3x gives a~1.28 (vs
393
- # a_clamp (0.3, 5.0)) and total_deviation_frac ~3.8%.
394
- if not os.path.exists(path):
395
- pytest.skip(f"requires the FT2a v4 feature cache ({path}) on disk")
396
- data = np.load(path)
397
- y = data["y"].astype(np.float64)
398
- s = float(np.std(y))
399
- total = float(np.quantile(y, 0.995))
400
-
401
- engaged = False
402
- for mult in (1.1, 1.2, 1.3, 1.4, 1.5):
403
- target = mult * s
404
- pred_affine, info_affine = hybrid_field_affine(y, total, target)
405
- if not info_affine["a_clamped"]:
406
- continue # affine already reaches this target -- nothing to trade
407
- engaged = True
408
- pred_soft, info_soft = hybrid_field_soft(y, total, target)
409
- assert info_soft["target_unreachable"] is True
410
- a_clamp = (0.3, 5.0)
411
- assert a_clamp[0] * 1.001 < info_soft["a"] < a_clamp[1] * 0.999, (
412
- f"expected an interior optimum, got a={info_soft['a']!r}")
413
- assert abs(info_soft["total_deviation_frac"]) < 0.10
414
- # the soft solve must never do WORSE than the affine seed on std:
415
- d_affine = abs(np.std(pred_affine) - target)
416
- d_soft = abs(np.std(pred_soft) - target)
417
- assert d_soft <= d_affine + 1e-9
418
- assert engaged, f"{path}: no mult in (1.1..1.5) made the affine solve a_clamped=True"
419
 
420
 
421
  def test_hybrid_soft_realistic_scale_exact_degeneration_matches_affine():
422
- # LOW-7: the original exact-degeneration test used a 2-node toy field;
423
  # this adds a realistic-scale (n=5000), right-skewed synthetic field with
424
  # an EASILY achievable std target (no clipping needed at all), so the
425
  # affine seed hits both targets and the soft solve must reduce to it
@@ -447,7 +275,7 @@ def test_hybrid_soft_a_clamp_lo_nonpositive_raises():
447
 
448
 
449
  def test_hybrid_soft_negative_weight_raises():
450
- # Review round 1, LOW: a negative weight rewards moving AWAY from that
451
  # target (minimizing J then maximizes the residual's square) -- must
452
  # raise loudly rather than silently drive a to a nonsensical optimum.
453
  gnn = np.array([1.0, 2.0, 3.0])
@@ -458,7 +286,7 @@ def test_hybrid_soft_negative_weight_raises():
458
 
459
 
460
  def test_hybrid_soft_both_weights_zero_raises():
461
- # Review round 1, LOW: w_total == w_std == 0 makes J identically zero
462
  # everywhere -- no signal for the search to optimize against.
463
  gnn = np.array([1.0, 2.0, 3.0])
464
  with pytest.raises(ValueError, match="w_total|w_std"):
@@ -476,7 +304,7 @@ def test_hybrid_soft_one_weight_zero_is_allowed():
476
 
477
 
478
  def test_hybrid_soft_accepted_optimum_majority_clipped_warns(caplog):
479
- # LOW-5: the affine path already warns when ITS OWN clip_frac > 0.5; the
480
  # soft path must emit the analogous warning when the ACCEPTED soft
481
  # optimum (which can differ from the affine seed) itself clips a
482
  # majority of the field.
@@ -492,25 +320,17 @@ def test_hybrid_soft_accepted_optimum_majority_clipped_warns(caplog):
492
  for rec in caplog.records)
493
 
494
 
495
- @pytest.mark.parametrize("path", _REAL_FIELD_PATHS)
496
- def test_hybrid_soft_perf_budget_10k_nodes(path):
497
- # CRITICAL-2: < 200ms/10k-node budget (generous 2x margin here to avoid
498
- # CI flakiness while still catching a gross regression). Measured on
499
- # dev hardware: ~40-60ms per real field at n=5.4k-8k nodes engaged.
500
- import time
501
- if not os.path.exists(path):
502
- pytest.skip(f"requires the FT2a v4 feature cache ({path}) on disk")
503
- data = np.load(path)
504
- y = data["y"].astype(np.float64)
505
- s = float(np.std(y))
506
- total = float(np.quantile(y, 0.995))
507
- target = 1.4 * s # engages the grid search on all three reference fields
508
-
509
- hybrid_field_soft(y, total, target) # warm-up (import/JIT-adjacent costs)
510
- times = []
511
- for _ in range(3):
512
- t0 = time.perf_counter()
513
- hybrid_field_soft(y, total, target)
514
- times.append(time.perf_counter() - t0)
515
- budget_s = 0.200 * (len(y) / 10_000) * 2 # scale budget to n, then 2x slack
516
- assert min(times) < budget_s, f"{path}: {min(times)*1000:.1f}ms exceeds {budget_s*1000:.1f}ms budget"
 
1
  import logging
 
2
 
3
  import numpy as np
4
  import pytest
5
+ from src.hybrid import hybrid_field_affine, hybrid_field_max_anchor, hybrid_field_soft
 
6
 
7
 
 
 
 
 
 
 
 
8
 
9
 
 
 
 
 
 
10
 
11
 
 
 
 
 
 
 
12
 
13
 
14
  def test_hybrid_affine_spread_match():
 
39
 
40
  def test_hybrid_affine_clamp_high():
41
  # gnn std is 10; std_seconds=100 -> a_raw=10.0, clamped down to the a_clamp
42
+ # ceiling 5.0. No clipping occurs at a=5.0 here (total=1000 is large), so
43
+ # this exercises the closed-form path's own clamp handling.
 
44
  gnn = np.array([0.0, 20.0])
45
  total = 1000.0
46
  pred, info = hybrid_field_affine(gnn, total, std_seconds=100.0)
 
75
 
76
 
77
  def test_hybrid_affine_heavy_clip_achievable():
78
+ # Real fields sometimes need heavy scaling to hit the spread head's
79
+ # target; a closed form that ignores clipping
80
  # entirely, so its implied `a` can leave the delivered std far short of
81
  # the target. Construct such a case (right-skewed field; the naive
82
  # closed-form `a` clips ~30% of nodes) and confirm the clip-aware solve
 
102
  def test_hybrid_affine_unachievable_due_to_clipping():
103
  # std_seconds=8.0 exceeds this field's interior PEAK (f rises past
104
  # a_clipstart to a peak around a~1.78, std~3.82, then falls -- it never
105
+ # reaches 8.0 anywhere in the clamp range). The unreachable case must pick the scanned point CLOSEST to
 
106
  # the target (here, at/near the peak) rather than blindly clamping to
107
  # a_clamp[1]=5.0 -- clamping to 5.0 would give a worse fit (std~3.45,
108
  # clip_frac 0.7) than the peak (std~3.82, clip_frac 0.4).
 
120
  assert abs(achieved - 8.0) < abs(np.std(at_hi) - 8.0)
121
 
122
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
123
 
124
 
125
  def test_hybrid_affine_no_clip_matches_pre_amendment_closed_form():
 
147
  np.testing.assert_allclose(pred_new, pred_old, rtol=1e-6)
148
 
149
 
 
 
 
 
 
 
 
 
 
 
 
 
150
 
 
 
151
 
 
 
 
 
 
152
 
153
 
 
 
 
 
 
 
 
 
 
 
 
 
 
154
 
155
 
 
 
 
 
 
 
 
156
 
 
157
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
158
 
159
 
160
  # --------------------------------------------------------------------------- #
161
+ # hybrid_field_soft: weighted two-target soft recomposition
162
  # --------------------------------------------------------------------------- #
163
  def _squeezed_case():
164
  # A field with a small (3%) group of low-value outlier nodes and a tight
 
234
  assert any("degenerate" in rec.message.lower() for rec in caplog.records)
235
 
236
 
 
 
 
 
 
 
 
 
 
 
 
 
237
 
238
 
 
 
 
 
 
 
 
 
239
 
240
 
241
  # --------------------------------------------------------------------------- #
242
+ # Edge cases: the a_clamp[0] <= 0 guard and degeneration to the affine solve
243
+ # at a realistic scale.
 
244
  # --------------------------------------------------------------------------- #
245
+
246
+
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
247
 
248
 
249
  def test_hybrid_soft_realistic_scale_exact_degeneration_matches_affine():
250
+ # The exact-degeneration test above uses a 2-node toy field;
251
  # this adds a realistic-scale (n=5000), right-skewed synthetic field with
252
  # an EASILY achievable std target (no clipping needed at all), so the
253
  # affine seed hits both targets and the soft solve must reduce to it
 
275
 
276
 
277
  def test_hybrid_soft_negative_weight_raises():
278
+ # LOW: a negative weight rewards moving AWAY from that
279
  # target (minimizing J then maximizes the residual's square) -- must
280
  # raise loudly rather than silently drive a to a nonsensical optimum.
281
  gnn = np.array([1.0, 2.0, 3.0])
 
286
 
287
 
288
  def test_hybrid_soft_both_weights_zero_raises():
289
+ # LOW: w_total == w_std == 0 makes J identically zero
290
  # everywhere -- no signal for the search to optimize against.
291
  gnn = np.array([1.0, 2.0, 3.0])
292
  with pytest.raises(ValueError, match="w_total|w_std"):
 
304
 
305
 
306
  def test_hybrid_soft_accepted_optimum_majority_clipped_warns(caplog):
307
+ # The affine path already warns when ITS OWN clip_frac > 0.5; the
308
  # soft path must emit the analogous warning when the ACCEPTED soft
309
  # optimum (which can differ from the affine seed) itself clips a
310
  # majority of the field.
 
320
  for rec in caplog.records)
321
 
322
 
323
+
324
+
325
+ def test_hybrid_soft_info_dict_carries_all_keys():
326
+ g, total, target = _squeezed_case()
327
+ pred, info = hybrid_field_soft(g, total, target)
328
+ expected_keys = {"a", "m", "target_unreachable", "degenerate_fallback", "clip_frac",
329
+ "total_deviation_frac", "j_total_resid", "j_std_resid",
330
+ "std_target_effective"}
331
+ assert expected_keys <= set(info.keys())
332
+ # clip_frac must be measured on the FINAL (returned) field.
333
+ a, m = info["a"], info["m"]
334
+ c = g - g.mean()
335
+ final_raw = a * c + m
336
+ assert info["clip_frac"] == pytest.approx(float((final_raw < 0.0).mean()))
 
 
 
 
 
 
 
 
tests/test_hybrid_max_anchor.py CHANGED
@@ -1,11 +1,6 @@
1
- """Task 2/7: max/quantile-anchor hybrid recomposition, in src/hybrid.py.
2
 
3
- See src/evaluate_ft2b.py's module docstring ("v4 hybrid recomposition:
4
- MAX-anchor, not MEAN-anchor") for why this differs from hybrid_field's
5
- MEAN-anchor. This module is the single source; evaluate_ft2b re-exports it
6
- under its old private name via an alias import.
7
-
8
- Task 7: the exact-max anchor is fragile to a handful of outlier nodes (one
9
  inflated GNN log-space prediction can drag the entire rest of the field
10
  below the zero clip). The anchor moved to a high quantile
11
  (`np.quantile(gnn, q)`, default `q=0.995`); `q=1.0` reproduces the old
@@ -17,7 +12,7 @@ from src.hybrid import hybrid_field_max_anchor
17
 
18
 
19
  def test_max_anchor_q1_sets_exact_max_and_preserves_shape():
20
- """q=1.0 regression pin: reproduces the pre-Task-7 exact-max behavior."""
21
  g = np.array([1.0, 4.0, 2.5])
22
  out = hybrid_field_max_anchor(g, 10.0, q=1.0)
23
  assert out.max() == 10.0
@@ -37,13 +32,10 @@ def test_max_anchor_clips_at_zero():
37
  assert hybrid_field_max_anchor(np.array([0.0, 100.0]), 10.0)[0] == 0.0
38
 
39
 
40
- def test_evaluate_ft2b_uses_the_same_function():
41
- from src import evaluate_ft2b, hybrid
42
- assert evaluate_ft2b._max_anchor_hybrid is hybrid.hybrid_field_max_anchor
43
 
44
 
45
  def test_max_anchor_q1_reproduces_old_exact_max_on_random_vector():
46
- """Regression pin: q=1.0 bit-for-bit equals the pre-Task-7 formula
47
  `clip(g - g.max() + total, 0, None)` on an arbitrary random vector."""
48
  rng = np.random.default_rng(42)
49
  g = rng.normal(loc=50.0, scale=20.0, size=200)
 
1
+ """max/quantile-anchor hybrid recomposition, in src/hybrid.py.
2
 
3
+ The exact-max anchor is fragile to a handful of outlier nodes (one
 
 
 
 
 
4
  inflated GNN log-space prediction can drag the entire rest of the field
5
  below the zero clip). The anchor moved to a high quantile
6
  (`np.quantile(gnn, q)`, default `q=0.995`); `q=1.0` reproduces the old
 
12
 
13
 
14
  def test_max_anchor_q1_sets_exact_max_and_preserves_shape():
15
+ """q=1.0 regression pin: reproduces the earlier exact-max behavior."""
16
  g = np.array([1.0, 4.0, 2.5])
17
  out = hybrid_field_max_anchor(g, 10.0, q=1.0)
18
  assert out.max() == 10.0
 
32
  assert hybrid_field_max_anchor(np.array([0.0, 100.0]), 10.0)[0] == 0.0
33
 
34
 
 
 
 
35
 
36
 
37
  def test_max_anchor_q1_reproduces_old_exact_max_on_random_vector():
38
+ """Regression pin: q=1.0 bit-for-bit equals the earlier formula
39
  `clip(g - g.max() + total, 0, None)` on an arbitrary random vector."""
40
  rng = np.random.default_rng(42)
41
  g = rng.normal(loc=50.0, scale=20.0, size=200)
tests/test_inference_props.py CHANGED
@@ -36,8 +36,7 @@ def test_props_json_override_and_missing_keys(tmp_path):
36
 
37
 
38
  # --------------------------------------------------------------------------- #
39
- # Review finding I2 (final whole-branch review, 2026-08-01): a `--props`
40
- # custom-JSON override with TL <= TS was previously unvalidated -- silently
41
  # collapsing src.shrinkage.niyama_proxy to 0 everywhere (100% flagged).
42
  # --------------------------------------------------------------------------- #
43
  def test_props_json_tl_less_equal_ts_raises(tmp_path):
@@ -68,21 +67,6 @@ def test_named_alloy_tl_ts_still_valid_after_i2_guard():
68
  assert p["TL"] > p["TS"]
69
 
70
 
71
- # --------------------------------------------------------------------------- #
72
- # resolve_alloy_family (SP2 shrinkage Task 4, controller directive 2):
73
- # resolve_props's returned 8-key dict has no family field, so shrinkage
74
- # screening maps the ALLOY GRADE NAME to one of src.shrinkage.SHRINKAGE_PCT's
75
- # family keys via a small name-prefix helper, rather than crashing or
76
- # silently guessing on an unrecognized grade.
77
- # --------------------------------------------------------------------------- #
78
- def test_resolve_alloy_family_known_prefixes():
79
- assert resolve_alloy_family("EN-GJL-250") == ("grey_iron", False)
80
- assert resolve_alloy_family("EN-GJL-200") == ("grey_iron", False)
81
- assert resolve_alloy_family("EN-GJS-400-15") == ("ductile_iron", False)
82
- assert resolve_alloy_family("GS-38 (GE200)") == ("low_alloy_steel", False)
83
- assert resolve_alloy_family("GS-45 (GP240GH)") == ("low_alloy_steel", False)
84
- assert resolve_alloy_family("G20Mn5") == ("low_alloy_steel", False)
85
- assert resolve_alloy_family("G17CrMo5-5") == ("low_alloy_steel", False)
86
 
87
 
88
  def test_resolve_alloy_family_default_alloy_when_none():
@@ -95,22 +79,12 @@ def test_resolve_alloy_family_unknown_prefix_falls_back_loud():
95
  assert family == "steel" and assumed is True
96
 
97
 
98
- def test_resolve_alloy_family_props_json_always_assumed_steel():
99
- # A custom --props JSON bypasses the named-material table entirely -- no
100
- # grade name to map from -- so the documented fallback is "steel",
101
- # flagged as assumed regardless of what `alloy` happens to be.
102
- family, assumed = resolve_alloy_family("EN-GJL-250", props_json="/some/path.json")
103
- assert family == "steel" and assumed is True
104
 
105
 
106
- def test_resolve_alloy_family_every_mapped_family_is_a_known_shrinkage_pct_key():
107
- for grade in ("EN-GJL-250", "EN-GJS-400-15", "GS-38 (GE200)", "unknown"):
108
- family, _ = resolve_alloy_family(grade)
109
- assert family in SHRINKAGE_PCT
110
 
111
 
112
  def test_resolve_alloy_family_covers_every_catalog_grade_exactly():
113
- """Review round 1, finding 6: a data-driven guard over the REAL property
114
  DB (`data/properties/materials.csv` via `list_alloys()`) -- every grade
115
  actually on file today must be an EXACT prefix match (`assumed is
116
  False`), not a fallback. This is the loud-failure trip-wire for a
 
36
 
37
 
38
  # --------------------------------------------------------------------------- #
39
+ # A `--props` custom-JSON override with TL <= TS was previously unvalidated -- silently
 
40
  # collapsing src.shrinkage.niyama_proxy to 0 everywhere (100% flagged).
41
  # --------------------------------------------------------------------------- #
42
  def test_props_json_tl_less_equal_ts_raises(tmp_path):
 
67
  assert p["TL"] > p["TS"]
68
 
69
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
70
 
71
 
72
  def test_resolve_alloy_family_default_alloy_when_none():
 
79
  assert family == "steel" and assumed is True
80
 
81
 
 
 
 
 
 
 
82
 
83
 
 
 
 
 
84
 
85
 
86
  def test_resolve_alloy_family_covers_every_catalog_grade_exactly():
87
+ """a data-driven guard over the REAL property
88
  DB (`data/properties/materials.csv` via `list_alloys()`) -- every grade
89
  actually on file today must be an EXACT prefix match (`assumed is
90
  False`), not a fallback. This is the loud-failure trip-wire for a
tests/test_input_encoder_extension.py DELETED
@@ -1,9 +0,0 @@
1
- import torch
2
- from src.model_gnn import SolidGNN, zero_init_input_columns
3
-
4
-
5
- def test_zero_init_last_columns():
6
- m = SolidGNN(node_in=15, hidden=16, n_blocks=1)
7
- zero_init_input_columns(m, n_new=2)
8
- w = m.node_encoder[0][0].weight # first Linear of the node_encoder _mlp
9
- assert torch.count_nonzero(w[:, -2:]) == 0 and torch.count_nonzero(w[:, :13]) > 0