120 ap = argparse.ArgumentParser(description=__doc__,
121 formatter_class=argparse.RawDescriptionHelpFormatter)
122 ap.add_argument(
"--design", type=Path, required=
True)
123 ap.add_argument(
"--results", type=Path, default=HERE /
"results")
124 ap.add_argument(
"--targets", type=Path, default=HERE /
"targets.json")
125 ap.add_argument(
"--out", type=Path, default=HERE /
"report")
126 ap.add_argument(
"--name", default=
"provisional_twin_map",
127 help=
"stem for the artifact set (default provisional_twin_map)")
128 a = ap.parse_args(argv)
131 recs =
load_results(a.results, meta.get(
'design_sha256'))
132 targets = json.loads(a.targets.read_text(encoding=
"utf-8"))
133 theta_keys = meta[
"parameters"]
134 a.out.mkdir(parents=
True, exist_ok=
True)
137 head = ([
"row_id",
"condition",
"matrix",
"base_idx",
"param_j"]
138 + [f
"theta.{k}" for k
in theta_keys]
139 + [f
"obs.{k}" for k
in OBSERVABLES]
140 + [f
"adm.{k}" for k
in ADMIN]
141 + [f
"prov.{k}" for k
in PROV])
142 full, adm_rows = [], []
143 for rid
in sorted(design):
146 line = [rid, d[
"condition"], d[
"matrix"], d[
"base_idx"], d[
"param_j"]]
147 line += [d.get(k,
"")
for k
in theta_keys]
148 line += [(r
or {}).get(k)
for k
in OBSERVABLES]
149 line += [(r
or {}).get(k)
for k
in ADMIN]
150 line += [(r
or {}).get(k)
for k
in PROV]
152 if r
and not r.get(
"solver_rc")
and r.get(
"admissible"):
153 adm_rows.append(line)
155 def write(path, rows):
156 with path.open(
"w", encoding=
"utf-8", newline=
"")
as fh:
157 fh.write(
",".join(head) +
"\n")
159 fh.write(
",".join(
_s(v)
for v
in ln) +
"\n")
162 write(a.out / f
"{stem}_runs.csv", full)
163 write(a.out / f
"{stem}_runs_admissible.csv", adm_rows)
167 conds = meta[
"conditions"]
168 for cond
in conds + [
"ALL"]:
169 sel = [r
for rid, r
in recs.items()
170 if not r.get(
"solver_rc")
and r.get(
"admissible")
171 and (cond ==
"ALL" or design[rid][
"condition"] == cond)]
172 for key, t
in targets.get(
"observables", {}).items():
173 vals = np.array([r[key]
for r
in sel
174 if r.get(key)
is not None
175 and np.isfinite(r.get(key, np.nan))], dtype=float)
176 lo, hi, tgt = t.get(
"lo"), t.get(
"hi"), t.get(
"target")
177 row = {
"condition": cond,
"observable": key,
"units": t.get(
"units",
""),
178 "n_admissible": int(vals.size),
179 "model_p05":
None,
"model_median":
None,
"model_p95":
None,
180 "exp_target": tgt,
"exp_lo": lo,
"exp_hi": hi,
181 "frac_in_band":
None,
"median_over_target":
None}
183 row[
"model_p05"] = float(np.percentile(vals, 5))
184 row[
"model_median"] = float(np.median(vals))
185 row[
"model_p95"] = float(np.percentile(vals, 95))
186 if lo
is not None and hi
is not None:
187 row[
"frac_in_band"] = float(np.mean((vals >= lo) & (vals <= hi)))
189 row[
"median_over_target"] = float(np.median(vals) / tgt)
191 ohead = list(obs_rows[0].keys())
if obs_rows
else []
192 with (a.out / f
"{stem}_observables.csv").open(
"w", encoding=
"utf-8",
194 fh.write(
",".join(ohead) +
"\n")
196 fh.write(
",".join(
_s(r[k])
for k
in ohead) +
"\n")
199 with (a.out / f
"{stem}_targets.csv").open(
"w", encoding=
"utf-8",
201 fh.write(
"observable,target,lo,hi,units,note\n")
202 for k, t
in targets.get(
"observables", {}).items():
203 fh.write(
",".join([k,
_s(t.get(
"target")),
_s(t.get(
"lo")),
204 _s(t.get(
"hi")),
_s(t.get(
"units")),
205 _s(t.get(
"note",
""))]) +
"\n")
208 adm = [r
for r
in recs.values()
209 if not r.get(
"solver_rc")
and r.get(
"admissible")]
214 v = [r[c]
for c
in TEM_COLS
215 if r.get(c)
is not None and np.isfinite(r.get(c, np.nan))]
217 per_run.append(max(v) - min(v))
219 spread = {
"n": len(per_run),
"median": float(np.median(per_run)),
220 "p95": float(np.percentile(per_run, 95))}
225 md = [f
"# Provisional twin map — {a.design.name}",
"",
226 "*Provisional*: this maps where the model sits relative to the data "
227 "under the current prior box. It is not a calibrated twin — see "
228 "\"What is deliberately NOT here\" at the end.",
"",
229 f
"- design `{meta.get('design_sha256','')[:16]}`, tier {meta.get('tier')}, "
230 f
"N={meta.get('N')}, p={meta.get('p')}, conditions {', '.join(conds)}",
231 f
"- rows {n_done}/{n_tot} evaluated, {len(adm)} admissible",
""]
232 if meta.get(
"revision_pending"):
233 md += [f
"> **Design carries REVISION_PENDING parameters:** "
234 f
"{', '.join(meta['revision_pending'])}. Their ranges predate the "
235 f
"2026-08-01 absorption-gate work.",
""]
236 md += [
"## Files",
"",
237 "| file | contents |",
239 f
"| `{stem}_runs.csv` | one row per evaluation: θ, observables, admissibility, provenance |",
240 f
"| `{stem}_runs_admissible.csv` | the same, usable rows only |",
241 f
"| `{stem}_observables.csv` | model p05/median/p95 vs the experimental band, per condition |",
242 f
"| `{stem}_targets.csv` | the experimental table |",
243 "| `T2_sobol_indices.csv` | S_i, S_i^T (from `merge_and_sobol.py`) |",
""]
244 md += [
"## Model vs experiment (all conditions, admissible rows)",
"",
245 "| observable | n | p05 | median | p95 | target | in band |",
246 "|---|---:|---:|---:|---:|---:|---:|"]
247 for r
in [x
for x
in obs_rows
if x[
"condition"] ==
"ALL"]:
248 if not r[
"n_admissible"]:
250 fb =
"n/a" if r[
"frac_in_band"]
is None else f
"{100*r['frac_in_band']:.0f}%"
251 tg =
"n/a" if r[
"exp_target"]
is None else f
"{r['exp_target']:.3g}"
252 md.append(f
"| `{r['observable']}` | {r['n_admissible']} | "
253 f
"{r['model_p05']:.3g} | {r['model_median']:.3g} | "
254 f
"{r['model_p95']:.3g} | {tg} | {fb} |")
255 md += [
"",
"p05/median/p95 are the **prior-predictive** spread of the "
256 "ensemble over the prior box. They are *not* a posterior — see below.",
""]
258 md += [
"## σ_model floor from the TEM-cutoff convention",
"",
259 f
"Per-run spread of `f_100_tem` across d_min ∈ {{0.8, 1.0, 1.25, "
260 f
"1.5}} nm, over {spread['n']} admissible runs:",
"",
261 f
"- median **{spread['median']:.3f}**, p95 **{spread['p95']:.3f}**",
262 "",
"This is a *floor* on σ_model for the loop-fraction "
263 "observable: it is the variation produced by the reporting "
264 "convention alone, with θ held fixed. If a calibration returns a "
265 "posterior on the conversion parameters tighter than this, σ_model "
266 "was set too small.",
""]
267 md += [
"## What is deliberately NOT here",
"",
268 "**Results extrapolated to experimental conditions.** The Tier-2 "
269 "ensemble runs at 0.1 dpa; the database is at 15–30 dpa. There is no "
270 "defensible closed-form extrapolation across that gap — ⟨100⟩ loops "
271 "have no size cap in the present model, so a run matching at 0.1 dpa "
272 "keeps growing and any naive scaling would be confidently wrong. "
273 "Bridging LF to experimental conditions is the multi-fidelity "
274 "emulator's job (plan Tier 4, `surrogate.py` + `calibrate.py`), which "
275 "is not built. A column labelled *extrapolated* before that exists "
276 "would be invented data.",
"",
277 "**Posterior uncertainty.** Tier 2 produces *sensitivity* (which "
278 "parameters matter), not *uncertainty* (posterior spread on θ given "
279 "the data). The spread reported above is prior-predictive. Posterior "
280 "UQ requires Tier 4.",
""]
281 (a.out / f
"{stem}.md").write_text(
"\n".join(md), encoding=
"utf-8")
283 print(f
" wrote {a.out}/")
284 for f
in (f
"{stem}.md", f
"{stem}_runs.csv", f
"{stem}_runs_admissible.csv",
285 f
"{stem}_observables.csv", f
"{stem}_targets.csv"):
287 print(f
" {f:32s} {p.stat().st_size:>9,d} bytes")
288 print(f
"\n {n_done}/{n_tot} rows evaluated, {len(adm)} admissible")
289 print(
" NOTE: no extrapolation to experimental conditions and no posterior")
290 print(f
" UQ - both require Tier 4. See {stem}.md.")