loadpath 0.1.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (220) hide show
  1. loadpath/README.md +294 -0
  2. loadpath/SKILL.md +584 -0
  3. loadpath/__init__.py +17 -0
  4. loadpath/__main__.py +1202 -0
  5. loadpath/am.py +316 -0
  6. loadpath/amgfast.py +326 -0
  7. loadpath/assembly.py +985 -0
  8. loadpath/benchmarks/__init__.py +0 -0
  9. loadpath/benchmarks/beams.py +266 -0
  10. loadpath/benchmarks/cases/ge-bracket.json +380 -0
  11. loadpath/benchmarks/evaluate.py +172 -0
  12. loadpath/benchmarks/mechanisms.py +416 -0
  13. loadpath/benchmarks/problems.py +140 -0
  14. loadpath/benchmarks/reference/bridge-3d.top3d.npz +0 -0
  15. loadpath/benchmarks/reference/cantilever-3d.top3d.npz +0 -0
  16. loadpath/benchmarks/reference/lbracket-2d.top88-ft2.npz +0 -0
  17. loadpath/benchmarks/reference/mbb-2d.top88-ft1.npz +0 -0
  18. loadpath/benchmarks/reference/mbb-2d.top88-ft2.npz +0 -0
  19. loadpath/benchmarks/reference.py +138 -0
  20. loadpath/benchmarks/run.py +301 -0
  21. loadpath/brep.py +1470 -0
  22. loadpath/bucklingopt.py +300 -0
  23. loadpath/buildsim.py +1490 -0
  24. loadpath/cadimport.py +904 -0
  25. loadpath/calibration.py +885 -0
  26. loadpath/cavities.py +211 -0
  27. loadpath/checks/__init__.py +0 -0
  28. loadpath/checks/dfm.py +354 -0
  29. loadpath/checks/exchanger.py +7 -0
  30. loadpath/checks/fea.py +212 -0
  31. loadpath/checks/fit.py +475 -0
  32. loadpath/checks/networks.py +260 -0
  33. loadpath/checks/opening.py +67 -0
  34. loadpath/checks/sdf_kit.py +7 -0
  35. loadpath/conjugate.py +267 -0
  36. loadpath/constrained.py +329 -0
  37. loadpath/contact.py +691 -0
  38. loadpath/coopt.py +342 -0
  39. loadpath/cores.py +163 -0
  40. loadpath/crease.py +1138 -0
  41. loadpath/critic.py +212 -0
  42. loadpath/crosscheck.py +1107 -0
  43. loadpath/cutcell.py +510 -0
  44. loadpath/data/lattice_buckling.json +439 -0
  45. loadpath/data/lattice_tensors.json +27449 -0
  46. loadpath/data/published_experiments.json +71 -0
  47. loadpath/decimate.py +177 -0
  48. loadpath/delivery.py +190 -0
  49. loadpath/directions.py +262 -0
  50. loadpath/domain.py +630 -0
  51. loadpath/drains.py +615 -0
  52. loadpath/durability.py +413 -0
  53. loadpath/editor.html +3135 -0
  54. loadpath/editor.py +610 -0
  55. loadpath/escape.py +354 -0
  56. loadpath/evals/__init__.py +0 -0
  57. loadpath/evals/baselines/coarse.json +1543 -0
  58. loadpath/evals/baselines/full.json +1497 -0
  59. loadpath/evals/brief_kinds/__init__.py +1 -0
  60. loadpath/evals/brief_kinds/heatx.py +387 -0
  61. loadpath/evals/brief_kinds/lattice.py +571 -0
  62. loadpath/evals/brief_kinds/multimat.py +593 -0
  63. loadpath/evals/briefs/assets/bracket.step +1201 -0
  64. loadpath/evals/briefs/assets/desk_arm.urdf +22 -0
  65. loadpath/evals/briefs/assets/elbow-gearbox.stl +0 -0
  66. loadpath/evals/briefs/assets/motor-body.stl +0 -0
  67. loadpath/evals/briefs/assets/sketch.png +0 -0
  68. loadpath/evals/briefs/base-adapter-fdm.json +492 -0
  69. loadpath/evals/briefs/bell-crank-lpbf.json +180 -0
  70. loadpath/evals/briefs/belt-tensioner-fdm.json +151 -0
  71. loadpath/evals/briefs/camera-boom-mjf.json +339 -0
  72. loadpath/evals/briefs/camera-mount-pla.json +203 -0
  73. loadpath/evals/briefs/coolant-isolator-petg.json +100 -0
  74. loadpath/evals/briefs/elbow-bearing-bracket-sls.json +300 -0
  75. loadpath/evals/briefs/elbow-gearbox-forearm-mjf.json +558 -0
  76. loadpath/evals/briefs/elbow-link-fatigue-fdm.json +251 -0
  77. loadpath/evals/briefs/elbow-stop-pad-tpu.json +39 -0
  78. loadpath/evals/briefs/forearm-fdm.json +146 -0
  79. loadpath/evals/briefs/forearm-servo-link-fdm.json +327 -0
  80. loadpath/evals/briefs/grip-pad-finger-mm.json +354 -0
  81. loadpath/evals/briefs/gripper-bracket-creep-fdm.json +340 -0
  82. loadpath/evals/briefs/gripper-finger-fdm.json +155 -0
  83. loadpath/evals/briefs/gripper-jaw-fatigue-mjf.json +328 -0
  84. loadpath/evals/briefs/gripper-palm-fdm.json +715 -0
  85. loadpath/evals/briefs/hot-stepper-mount-asa-fdm.json +471 -0
  86. loadpath/evals/briefs/hpu-oil-cooler-lpbf.json +121 -0
  87. loadpath/evals/briefs/isolated-camera-mount-mm.json +675 -0
  88. loadpath/evals/briefs/kart-charge-cooler-lpbf.json +93 -0
  89. loadpath/evals/briefs/lighten-step-bracket.json +220 -0
  90. loadpath/evals/briefs/nema17-mount-stl.json +429 -0
  91. loadpath/evals/briefs/pillow-block-sls.json +220 -0
  92. loadpath/evals/briefs/polyjet-fingertip-mm.json +305 -0
  93. loadpath/evals/briefs/rod-hook-pla.json +154 -0
  94. loadpath/evals/briefs/sensor-arm-resin.json +150 -0
  95. loadpath/evals/briefs/shelf-bracket-pla.json +158 -0
  96. loadpath/evals/briefs/shoulder-riser-mjf.json +487 -0
  97. loadpath/evals/briefs/sketch-clevis.json +229 -0
  98. loadpath/evals/briefs/spring-bracket-pacf.json +294 -0
  99. loadpath/evals/briefs/tool-flange-ti64.json +280 -0
  100. loadpath/evals/briefs/tray-arm-lattice-fdm.json +260 -0
  101. loadpath/evals/briefs/turret-fdm.json +281 -0
  102. loadpath/evals/briefs/upper-arm-sls.json +179 -0
  103. loadpath/evals/briefs/urdf-forearm.json +233 -0
  104. loadpath/evals/briefs/wrist-link-mjf.json +278 -0
  105. loadpath/evals/briefs/wrist-yoke-mjf.json +245 -0
  106. loadpath/evals/briefs.py +393 -0
  107. loadpath/evals/cases/arm-link-cast.json +157 -0
  108. loadpath/evals/cases/arm-link-driven.json +171 -0
  109. loadpath/evals/cases/arm-link-sandwich.json +315 -0
  110. loadpath/evals/cases/arm-link.json +111 -0
  111. loadpath/evals/cases/bell-crank.json +116 -0
  112. loadpath/evals/cases/grip-turret.json +479 -0
  113. loadpath/evals/cases/grip-upper-arm.json +253 -0
  114. loadpath/evals/cases/grip-wrist-link-mjf.json +929 -0
  115. loadpath/evals/cases/grip-wrist-yoke.json +831 -0
  116. loadpath/evals/cases/lpbf-intercooler-core.json +59 -0
  117. loadpath/evals/cases/mbb-beam.json +61 -0
  118. loadpath/evals/cases/motor-body.stl +0 -0
  119. loadpath/evals/cases/motor-mount.json +265 -0
  120. loadpath/evals/cases/printed-hook.json +77 -0
  121. loadpath/evals/cases/wall-bracket-fasteners.json +125 -0
  122. loadpath/evals/cases/wall-bracket.json +126 -0
  123. loadpath/evals/corpus.py +179 -0
  124. loadpath/evals/pareto.py +90 -0
  125. loadpath/evals/run.py +860 -0
  126. loadpath/examples/arm-link.json +25 -0
  127. loadpath/examples/bumpered-bracket-compression.json +225 -0
  128. loadpath/examples/bumpered-bracket-lattice.json +204 -0
  129. loadpath/examples/bumpered-bracket.json +34 -0
  130. loadpath/examples/gripper-finger-mm.json +185 -0
  131. loadpath/examples/heat-exchanger-counterflow.json +21 -0
  132. loadpath/examples/heat-exchanger-crossflow-tuned.json +48 -0
  133. loadpath/examples/heat-exchanger-crossflow.json +20 -0
  134. loadpath/examples/pedal-lattice.json +23 -0
  135. loadpath/examples/pressure-plenum.json +12 -0
  136. loadpath/examples/shelf-bracket.json +27 -0
  137. loadpath/exchanger.py +831 -0
  138. loadpath/fasteners.py +199 -0
  139. loadpath/fem.py +668 -0
  140. loadpath/femops.py +70 -0
  141. loadpath/fieldjit.py +981 -0
  142. loadpath/fieldops.py +119 -0
  143. loadpath/fields.py +3697 -0
  144. loadpath/fieldtorch.py +464 -0
  145. loadpath/flow.py +984 -0
  146. loadpath/flowtopo.py +300 -0
  147. loadpath/gpu.py +521 -0
  148. loadpath/heatx.py +1067 -0
  149. loadpath/heatx_thermal.py +432 -0
  150. loadpath/homogenized.py +886 -0
  151. loadpath/ibrep.py +552 -0
  152. loadpath/impact.py +693 -0
  153. loadpath/infill.py +376 -0
  154. loadpath/interface_library.py +340 -0
  155. loadpath/interlock.py +186 -0
  156. loadpath/isolator.py +149 -0
  157. loadpath/knn.py +204 -0
  158. loadpath/lattice.py +1065 -0
  159. loadpath/lattice3mf.py +760 -0
  160. loadpath/lattice_buckling.py +245 -0
  161. loadpath/lattice_grade.py +325 -0
  162. loadpath/lattice_orient.py +595 -0
  163. loadpath/lattice_print.py +159 -0
  164. loadpath/lattice_tensors.py +558 -0
  165. loadpath/latticestep.py +1210 -0
  166. loadpath/manufacture.py +446 -0
  167. loadpath/material_library.py +489 -0
  168. loadpath/materials.py +144 -0
  169. loadpath/mcp_server.py +786 -0
  170. loadpath/mechanism.py +218 -0
  171. loadpath/mechanism_nl.py +811 -0
  172. loadpath/meshops.py +452 -0
  173. loadpath/mg.py +853 -0
  174. loadpath/mma.py +214 -0
  175. loadpath/modal.py +210 -0
  176. loadpath/multimat.py +1788 -0
  177. loadpath/narrowband.py +418 -0
  178. loadpath/nlkernel.py +241 -0
  179. loadpath/nonlinear.py +1095 -0
  180. loadpath/orient.py +334 -0
  181. loadpath/porous.py +994 -0
  182. loadpath/pressure.py +171 -0
  183. loadpath/preview.py +335 -0
  184. loadpath/printcheck.py +263 -0
  185. loadpath/printsetup.py +207 -0
  186. loadpath/processcheck.py +376 -0
  187. loadpath/quick.py +113 -0
  188. loadpath/recall.py +406 -0
  189. loadpath/reconstruct.py +797 -0
  190. loadpath/render.py +436 -0
  191. loadpath/resolved.py +189 -0
  192. loadpath/schema.py +1701 -0
  193. loadpath/sdf_kit.py +843 -0
  194. loadpath/selfx.py +292 -0
  195. loadpath/shapes.py +584 -0
  196. loadpath/sharp.py +717 -0
  197. loadpath/simp.py +1170 -0
  198. loadpath/sizing.py +312 -0
  199. loadpath/slicer.py +321 -0
  200. loadpath/spec.py +1072 -0
  201. loadpath/step.py +806 -0
  202. loadpath/struts.py +639 -0
  203. loadpath/study.py +1937 -0
  204. loadpath/tasks.py +822 -0
  205. loadpath/thermal.py +1691 -0
  206. loadpath/threemf.py +487 -0
  207. loadpath/tune.py +437 -0
  208. loadpath/validation.py +145 -0
  209. loadpath/verify.py +1215 -0
  210. loadpath/viewer.html +694 -0
  211. loadpath/viewer.py +446 -0
  212. loadpath/viscoelastic.py +106 -0
  213. loadpath/voxelprint.py +582 -0
  214. loadpath/voxels.py +402 -0
  215. loadpath-0.1.1.dist-info/METADATA +319 -0
  216. loadpath-0.1.1.dist-info/RECORD +220 -0
  217. loadpath-0.1.1.dist-info/WHEEL +5 -0
  218. loadpath-0.1.1.dist-info/entry_points.txt +2 -0
  219. loadpath-0.1.1.dist-info/licenses/LICENSE +21 -0
  220. loadpath-0.1.1.dist-info/top_level.txt +1 -0
loadpath/evals/run.py ADDED
@@ -0,0 +1,860 @@
1
+ """
2
+ Eval suite: real parts with hard requirements, run end to end and scored on
3
+ the delivered mesh.
4
+
5
+ Each case in cases/*.json is an ordinary study plus an `expect` block (a
6
+ case with a `graph` is a field graph instead, analyzed with its lattice
7
+ homogenized on `cells` cells under `study`, and each of its `mustFail`
8
+ variants must miss a limit: run_field_case):
9
+
10
+ "expect": {
11
+ "maxMass": 45, # g, best outcome
12
+ "minMassReduction": 0.5, # vs the solid design space
13
+ "minSafetyFactor": 3.0, # every load case
14
+ "maxDeflection": 1.0, # mm at the loads, every case
15
+ "maxKeepOutIntrusion": 1.0, # mm^3 of part inside keep-outs
16
+ "maxPreserveMissing": 1.0, # mm^3 of preserved geometry lost
17
+ "bodies": 1,
18
+ "minMemberAtLeast": 2.4, # mm, 5th percentile member thickness (surface rays,
19
+ # verify.surface_thickness; the voxel ridge reading
20
+ # is quantized to the analysis cell)
21
+ "maxCreaseLength": 10, # mm of sharp concave edge (verify._creases)
22
+ "maxClosedBores": 0, # bore ends the part closes over (verify._closed_bores)
23
+ "maxUndercutVolume": 150, # mm^3 a cast/milled part's mold or cutter cannot reach,
24
+ # outside keep-outs (report.manufacturing.access)
25
+ "minProcessWall": 0.8, # mm, 1st-percentile wall (SLS/MJF/SLA/DLP: processcheck)
26
+ "maxTrappedVolume": 1, # mm^3 of powder or resin that cannot get out
27
+ "maxWarpSpan": 100, # mm, longest thin flat panel (SLS/MJF)
28
+ "maxCupArea": 10, # mm^2, largest suction cup (SLA/DLP)
29
+ "maxSeconds": 600
30
+ }
31
+
32
+ A case with a "core" is a heat exchanger (loadpath.tasks.heat_exchanger)
33
+ instead, scored on its own checks (HX_CHECKS):
34
+
35
+ "expect": {
36
+ "maxMass": 140, # g, solid volume x the duty's wall material
37
+ "minWallBetweenFluids": 0.9, # mm, measured on the grid
38
+ "maxInternalFlatArea": 200, # mm^2 of flat ceiling inside (unsupportable in metal)
39
+ "maxTrappedVolume": 5, # mm^3 of open space no port reaches (trapped powder)
40
+ "minEffectiveness": 0.5, # rated (needs duty)
41
+ "maxPressureDrop": {"air": 20000}, # Pa per fluid
42
+ "maxSeconds": 300
43
+ }
44
+
45
+ and always on isolated, connected and watertight.
46
+
47
+ Run: python -m loadpath.evals.run [case ...] --out evals-out/
48
+ Writes scorecard.json, scorecard.md and a render per case. Exit code is the
49
+ number of failing cases, so it can gate a change to the solver.
50
+
51
+ Tiers (--tier):
52
+ full the suite as written (~1 h 50 min on the 4-core cloud box at its default --jobs 2; every
53
+ case in its own fresh process).
54
+ coarse every case at <= COARSE["maxElements"] cells, fewer iterations,
55
+ a shorter search and no fine-grid confirm: a smoke run for every
56
+ push (minutes, not hours). Its numbers are not the full tier's;
57
+ compare coarse with coarse (--baseline).
58
+
59
+ Every study row also gets a Pareto score (pareto.py): massAtTarget, the
60
+ front's mass at the goal SF, and its hypervolume. They move when a case gets
61
+ lighter at the same SF, which pass/fail does not.
62
+
63
+ --baseline <scorecard.json> diffs against an earlier run of the same tier:
64
+ a case regresses when it passed and now fails, or its massAtTarget grew more
65
+ than --tolerance (default 5%), and the regressions are listed. With
66
+ --gate regressions the exit code counts regressions instead of failures, so
67
+ a coarse run can gate a push on "no worse than before".
68
+
69
+ --corpus 8 (or 100:108) adds generated cases (corpus.py): parts nobody has
70
+ tuned on. --critic anthropic|agent renders interface close-ups of each pick
71
+ and has a vision model file findings (loadpath/critic.py); expect keys
72
+ maxCriticBlockers and maxCriticScore gate on them.
73
+ """
74
+ from __future__ import annotations
75
+
76
+ import argparse
77
+ import glob
78
+ import json
79
+ import os
80
+ import sys
81
+ import time
82
+
83
+ HERE = os.path.dirname(os.path.abspath(__file__))
84
+
85
+ CHECKS = {
86
+ # name: (expect key, how to measure from (summary, report), comparison)
87
+ "mass": ("maxMass", lambda o: o["mass"], "<="),
88
+ "massReduction": ("minMassReduction", lambda o: o["massReduction"], ">="),
89
+ "safetyFactor": ("minSafetyFactor", lambda o: o["safetyFactor"], ">="),
90
+ "deflection": ("maxDeflection", lambda o: max(c["deflection"] for c in o["cases"]), "<="),
91
+ "keepOutIntrusion": ("maxKeepOutIntrusion", lambda o: o["keepOutIntrusion"], "<="),
92
+ "preserveMissing": ("maxPreserveMissing", lambda o: o["preserveMissing"], "<="),
93
+ "bodies": ("bodies", lambda o: o["bodies"], "=="),
94
+ # organic material sawn flat on the design box where nothing mates
95
+ "boundaryCut": ("maxBoundaryCutArea", lambda o: o.get("boundaryCutArea") or 0.0, "<="),
96
+ "member": ("minMemberAtLeast", lambda o: o["p5Thickness"] if o.get("p5Thickness") is not None
97
+ else o["p5Member"], ">="),
98
+ # sharp concave edges the optimizer left (verify._creases), mm
99
+ "crease": ("maxCreaseLength", lambda o: o.get("creaseLength"), "<="),
100
+ # bores the part closes over: no screw can be driven through (verify._closed_bores)
101
+ "closedBores": ("maxClosedBores", lambda o: len(o.get("closedBores") or []), "<="),
102
+ "overhang": ("maxOverhangFraction", lambda o: o.get("designOverhangFraction"), "<="),
103
+ # SLS/MJF/SLA/DLP process checks on the delivered part (processcheck.py)
104
+ "processWall": ("minProcessWall", lambda o: _proc(o, "walls", "p1WallMm"), ">="),
105
+ "trappedVolume": ("maxTrappedVolume", lambda o: _proc(o, "trappedVolumeMm3"), "<="),
106
+ "warpSpan": ("maxWarpSpan", lambda o: _proc(o, "warp", "maxSpan"), "<="),
107
+ "cupArea": ("maxCupArea", lambda o: _proc(o, "cups", "maxArea"), "<="),
108
+ # cast or milled: mm^3 of void no mold half or cutter reaches, outside the
109
+ # keep-outs (an undercut keep-out is the study's own, and needs a core)
110
+ "undercut": ("maxUndercutVolume", lambda o: _undercut(o), "<="),
111
+ }
112
+
113
+
114
+ # coarse tier: cells, optimizer iterations, outcome-search runs, fine-grid
115
+ # confirm runs, heat-exchanger grid step multiplier
116
+ COARSE = {"maxElements": 6000, "maxIterations": 30, "searchRuns": 3, "confirmRuns": 0, "hxStep": 1.0}
117
+ # checks the coarse grid cannot be held to (they resolve features finer than
118
+ # its cells, or time a different run)
119
+ COARSE_SKIP = {"member", "crease", "seconds", "processWall", "boundaryCut", "overhang", "warpSpan",
120
+ "maxMass", "mass", "massReduction", "deflection", "wallBetweenFluids", "internalFlatArea",
121
+ "trappedVolume"}
122
+ CRITIC = {"criticBlockers": ("maxCriticBlockers", "<="), "criticScore": ("maxCriticScore", "<=")}
123
+
124
+
125
+ def apply_tier(raw: dict, tier: str) -> dict:
126
+ """The case as the tier runs it (a copy). LOADPATH_EVAL_CELLS_SCALE
127
+ scales every case's maxElements (the pitch moves by its cube root): run
128
+ the suite at 1 and at 0.91 to see how far each result moves with a 3%
129
+ pitch change."""
130
+ raw = _apply_tier(raw, tier)
131
+ scale = float(os.environ.get("LOADPATH_EVAL_CELLS_SCALE") or 1.0)
132
+ if scale != 1.0 and "core" not in raw and "graph" not in raw:
133
+ raw = json.loads(json.dumps(raw))
134
+ res = raw.setdefault("resolution", {})
135
+ if "pitch" not in res:
136
+ res["maxElements"] = max(1, int(round(int(res.get("maxElements", 60000)) * scale)))
137
+ return raw
138
+
139
+
140
+ def _apply_tier(raw: dict, tier: str) -> dict:
141
+ if tier == "full":
142
+ return raw
143
+ if tier != "coarse":
144
+ raise ValueError(f"unknown tier {tier!r} (full, coarse)")
145
+ raw = json.loads(json.dumps(raw))
146
+ # a case can ask for more cells (expect.coarse): thin load pads and
147
+ # pin-held pieces vanish below a few thousand cells
148
+ cfg = COARSE | {k: v for k, v in ((raw.get("expect") or {}).get("coarse") or {}).items() if k != "skip"}
149
+ if "core" in raw:
150
+ raw["step"] = raw.get("step", 0.4) * cfg["hxStep"]
151
+ return raw
152
+ res = raw.get("resolution") or {}
153
+ raw["resolution"] = {"maxElements": min(int(res.get("maxElements", 80000)), cfg["maxElements"]),
154
+ **{k: v for k, v in res.items() if k == "pitchStarts"}}
155
+ raw["maxIterations"] = min(int(raw.get("maxIterations", 120)), cfg["maxIterations"])
156
+ return raw
157
+
158
+
159
+ def _tier_globals(tier: str):
160
+ """Search limits are study.py constants; the coarse tier lowers them in
161
+ this process only."""
162
+ if tier == "coarse":
163
+ # recall.py's design memory would start a rerun where the last one
164
+ # ended: a baseline diff must compare cold runs
165
+ os.environ.pop("LOADPATH_RECALL", None)
166
+ from .. import study as st
167
+ st._MAX_SEARCH_RUNS = COARSE["searchRuns"]
168
+ st._CONFIRM_RUNS = COARSE["confirmRuns"]
169
+
170
+
171
+ def gate_critic(row: dict, expect: dict | None = None):
172
+ """Add the critic's checks to a row (expect from the row's case file when
173
+ not given) and redo its pass."""
174
+ c = row.get("critic")
175
+ if not c:
176
+ return row
177
+ if expect is None:
178
+ expect = _case_expect(row.get("case")) or {}
179
+ row["checks"] = [k for k in row.get("checks", []) if k["check"] not in CRITIC]
180
+ for name, (key, op) in CRITIC.items():
181
+ if key in expect:
182
+ v = c.get(name)
183
+ row["checks"].append({"check": name, "value": v, "op": op, "limit": expect[key],
184
+ "pass": _cmp(v, op, expect[key])})
185
+ row["pass"] = row.get("error") is None and all(k["pass"] for k in row["checks"])
186
+ return row
187
+
188
+
189
+ def _case_expect(name):
190
+ f = os.path.join(HERE, "cases", f"{name}.json")
191
+ if os.path.exists(f):
192
+ with open(f) as fh:
193
+ return json.load(fh).get("expect")
194
+ return None
195
+
196
+
197
+ def _undercut(o):
198
+ a = (o.get("manufacturing") or {}).get("access")
199
+ return None if a is None else a["undercutVolume"] - a["undercutInKeepOuts"]
200
+
201
+
202
+ def _proc(o, *keys):
203
+ v = o.get("process")
204
+ for k in keys:
205
+ v = v.get(k) if isinstance(v, dict) else None
206
+ return v
207
+
208
+
209
+ HX_CHECKS = {
210
+ "mass": ("maxMass", lambda r: r.get("mass"), "<="),
211
+ "wallBetweenFluids": ("minWallBetweenFluids", lambda r: r["report"].get("minWallBetweenFluids"), ">="),
212
+ "internalFlatArea": ("maxInternalFlatArea",
213
+ lambda r: (r["report"].get("internalCeilings") or {}).get("flatArea"), "<="),
214
+ "trappedVolume": ("maxTrappedVolume", lambda r: r["report"].get("trappedVolume"), "<="),
215
+ "effectiveness": ("minEffectiveness", lambda r: (r.get("rating") or {}).get("effectiveness"), ">="),
216
+ }
217
+
218
+
219
+ def _cmp(v, op, ref):
220
+ if v is None:
221
+ return False
222
+ return {"<=": v <= ref + 1e-9, ">=": v >= ref - 1e-9, "==": v == ref}[op]
223
+
224
+
225
+ def score_case(raw: dict, result, seconds: float, study=None, tier: str = "full") -> dict:
226
+ from .pareto import score as pareto
227
+
228
+ expect = raw.get("expect", {})
229
+ best = result.best
230
+ buckles = (raw.get("goal") or {}).get("buckling", True)
231
+ # the safety factor a part holds counts buckling as well (verify._buckling)
232
+ held = [{**o.report, "safetyFactor": min(o.report["safetyFactor"], o.report["bucklingFactor"])}
233
+ if buckles and o.report.get("bucklingFactor") is not None and o.report.get("safetyFactor") is not None
234
+ else o.report for o in result.outcomes]
235
+ out = {"case": raw.get("name"), "seconds": seconds, "checks": [], "outcomes": len(result.outcomes),
236
+ "tier": tier,
237
+ "pareto": pareto(held,
238
+ (raw.get("goal") or {}).get("safetyFactor") or expect.get("minSafetyFactor"))}
239
+ if best is None:
240
+ out["pass"] = False
241
+ out["error"] = "no outcome met the goal"
242
+ best = min(range(len(result.outcomes)),
243
+ key=lambda i: -(result.outcomes[i].report.get("safetyFactor") or 0)) if result.outcomes else None
244
+ if best is None:
245
+ return out
246
+ o = result.outcomes[best]
247
+ flat = dict(o.report)
248
+ if getattr(o, "mesh", None) is not None:
249
+ from ..verify import surface_thickness
250
+
251
+ flat["p1Thickness"], flat["p5Thickness"] = surface_thickness(o.mesh, study=study)
252
+ out["best"] = {"volumeFraction": o.volume_fraction, "mass": flat["mass"],
253
+ "massReduction": flat["massReduction"], "safetyFactor": flat["safetyFactor"],
254
+ "p5Member": flat["p5Member"], "minMember": flat["minMember"],
255
+ "creaseLength": flat.get("creaseLength"),
256
+ "p5Thickness": flat.get("p5Thickness"), "p1Thickness": flat.get("p1Thickness"),
257
+ "deflection": max(c["deflection"] for c in flat["cases"]),
258
+ "iterations": o.optimizer["iterations"], "optimizerSeconds": o.optimizer["seconds"],
259
+ **({"bucklingFactor": flat["bucklingFactor"]} if flat.get("bucklingFactor") is not None else {})}
260
+ ok = out.get("error") is None
261
+ for name, (key, measure, op) in CHECKS.items():
262
+ if key not in expect or (tier == "coarse" and (name in COARSE_SKIP or key in COARSE_SKIP)):
263
+ continue
264
+ v = measure(flat)
265
+ passed = _cmp(v, op, expect[key])
266
+ ok &= passed
267
+ out["checks"].append({"check": name, "value": v, "op": op, "limit": expect[key], "pass": passed})
268
+ if buckles and "minSafetyFactor" in expect and flat.get("bucklingFactor") is not None:
269
+ passed = _cmp(flat["bucklingFactor"], ">=", expect["minSafetyFactor"])
270
+ ok &= passed
271
+ out["checks"].append({"check": "buckling", "value": flat["bucklingFactor"], "op": ">=",
272
+ "limit": expect["minSafetyFactor"], "pass": passed})
273
+ if "maxSeconds" in expect and tier != "coarse":
274
+ passed = seconds <= expect["maxSeconds"]
275
+ ok &= passed
276
+ out["checks"].append({"check": "seconds", "value": seconds, "op": "<=",
277
+ "limit": expect["maxSeconds"], "pass": passed})
278
+ out["pass"] = bool(ok)
279
+ return out
280
+
281
+
282
+ def score_hx_case(raw: dict, out: dict, seconds: float, tier: str = "full") -> dict:
283
+ """A heat-exchanger case: isolated, connected and watertight always, then
284
+ the `expect` limits (HX_CHECKS, maxPressureDrop per fluid, maxSeconds)."""
285
+ from ..materials import material_from_spec
286
+
287
+ expect = raw.get("expect", {})
288
+ rep = out["report"]
289
+ row = {"case": raw.get("name"), "kind": "heatExchanger", "seconds": seconds, "checks": []}
290
+ wall = ((raw.get("duty") or {}).get("wall"))
291
+ try:
292
+ density = material_from_spec(wall).density if isinstance(wall, str) else None
293
+ except (KeyError, ValueError):
294
+ density = None
295
+ r = {"report": rep, "rating": out.get("rating"),
296
+ "mass": rep["solidVolume"] * density / 1000.0 if density else None}
297
+ ok = True
298
+ for name, v in (("isolated", rep.get("isolated")), ("connected", all((rep.get("connected") or {}).values())),
299
+ ("watertight", rep.get("watertight"))):
300
+ ok &= bool(v)
301
+ row["checks"].append({"check": name, "value": bool(v), "op": "==", "limit": True, "pass": bool(v)})
302
+ for name, (key, measure, op) in HX_CHECKS.items():
303
+ if key not in expect or (tier == "coarse" and name in COARSE_SKIP):
304
+ continue
305
+ v = measure(r)
306
+ passed = _cmp(v, op, expect[key])
307
+ ok &= passed
308
+ row["checks"].append({"check": name, "value": v, "op": op, "limit": expect[key], "pass": passed})
309
+ for fl, limit in (expect.get("maxPressureDrop") or {}).items():
310
+ v = (((r["rating"] or {}).get("fluids") or {}).get(fl) or {}).get("pressureDrop")
311
+ passed = _cmp(v, "<=", limit)
312
+ ok &= passed
313
+ row["checks"].append({"check": f"pressureDrop {fl}", "value": v, "op": "<=", "limit": limit, "pass": passed})
314
+ if "maxSeconds" in expect and tier != "coarse":
315
+ passed = seconds <= expect["maxSeconds"]
316
+ ok &= passed
317
+ row["checks"].append({"check": "seconds", "value": seconds, "op": "<=", "limit": expect["maxSeconds"],
318
+ "pass": passed})
319
+ if out.get("ratingError"):
320
+ row["error"] = out["ratingError"]
321
+ ok = False
322
+ row["pass"] = bool(ok)
323
+ ic = rep.get("internalCeilings") or {}
324
+ rt = r["rating"] or {}
325
+ row["exchanger"] = {"mass": r["mass"], "solidVolume": rep["solidVolume"],
326
+ "minWallBetweenFluids": rep.get("minWallBetweenFluids"),
327
+ "internalFlatArea": ic.get("flatArea"), "internalOverhangArea": ic.get("overhangArea"),
328
+ "trappedVolume": rep.get("trappedVolume"), "effectiveness": rt.get("effectiveness"),
329
+ "duty": rt.get("duty"),
330
+ "pressureDrop": {k: v.get("pressureDrop") for k, v in (rt.get("fluids") or {}).items()},
331
+ "cleanouts": len(rep.get("cleanouts") or [])}
332
+ return row
333
+
334
+
335
+ def _md_hx(rows):
336
+ lines = ["| Exchanger | Pass | Mass g | Wall mm | Flat ceiling mm² | Trapped mm³ | Effectiveness | "
337
+ "Pressure drop Pa | Time s | Failed checks |",
338
+ "| --- | --- | --- | --- | --- | --- | --- | --- | --- | --- |"]
339
+ fmt = lambda v, f: (f.format(v) if v is not None else "-") # noqa: E731
340
+ for r in rows:
341
+ x = r.get("exchanger") or {}
342
+ failed = ", ".join(f"{c['check']} {c['value']} (limit {c['op']} {c['limit']})"
343
+ for c in r.get("checks", []) if not c["pass"])
344
+ if r.get("error"):
345
+ failed = (r["error"] + "; " + failed).strip("; ")
346
+ dp = ", ".join(f"{k} {v:.0f}" for k, v in (x.get("pressureDrop") or {}).items() if v is not None) or "-"
347
+ lines.append(f"| {r['case']} | {'yes' if r.get('pass') else 'NO'} | {fmt(x.get('mass'), '{:.1f}')} | "
348
+ f"{fmt(x.get('minWallBetweenFluids'), '{:.2f}')} | {fmt(x.get('internalFlatArea'), '{:.0f}')} | "
349
+ f"{fmt(x.get('trappedVolume'), '{:.1f}')} | {fmt(x.get('effectiveness'), '{:.2f}')} | {dp} | "
350
+ f"{r['seconds']:.0f} | {failed} |")
351
+ return "\n".join(lines) + "\n"
352
+
353
+
354
+ def _md(rows):
355
+ hx = [r for r in rows if r.get("kind") == "heatExchanger"]
356
+ rows = [r for r in rows if r.get("kind") != "heatExchanger"]
357
+ return _md_study(rows) + ("\n" + _md_hx(hx) if hx else "") + _md_spread(rows)
358
+
359
+
360
+ def _md_spread(rows):
361
+ rows = [r for r in rows if r.get("spread")]
362
+ if not rows:
363
+ return ""
364
+ lines = ["", "Run-to-run spread: massAtTarget (g) with the search started at each fraction", "",
365
+ "| Case | Starts | massAtTarget g | Spread | Delivered g | Spread |",
366
+ "| --- | --- | --- | --- | --- | --- |"]
367
+ for r in rows:
368
+ sp = r["spread"]
369
+ fmt = lambda v: "-" if v is None else f"{v:.1f}" # noqa: E731
370
+ rng = "-" if sp.get("range") is None else f"{100 * sp['range']:.1f}%"
371
+ mrng = "-" if sp.get("massRange") is None else f"{100 * sp['massRange']:.1f}%"
372
+ lines.append(f"| {r['case']} | {', '.join(str(x['start']) for x in sp['runs'])} | "
373
+ f"{', '.join(fmt(x['massAtTarget']) for x in sp['runs'])} | {rng} | "
374
+ f"{', '.join(fmt(x.get('mass')) for x in sp['runs'])} | {mrng} |")
375
+ return "\n".join(lines) + "\n"
376
+
377
+
378
+ def _mat(pa):
379
+ """Pareto massAtTarget for the table: '~' marks an upper bound (no
380
+ failing outcome below it to interpolate from)."""
381
+ m = (pa or {}).get("massAtTarget")
382
+ if m is None:
383
+ return "-"
384
+ return f"{m:.1f}" if pa.get("bracketed") else f"~{m:.1f}"
385
+
386
+
387
+ def _critic_cell(r):
388
+ c = r.get("critic")
389
+ if not c:
390
+ return "pending" if r.get("criticPending") else "-"
391
+ if c.get("error"):
392
+ return "error"
393
+ return f"{c['criticScore']} ({c['criticBlockers']}B/{c['criticMajors']}M)"
394
+
395
+
396
+ def _md_study(rows):
397
+ lines = ["| Case | Pass | Mass g | Lighter | SF | Mass at target SF g | Defl. mm | p5 member mm | p5 ray mm | "
398
+ "Crease mm | Critic | Time s | Failed checks |",
399
+ "| --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- |"]
400
+ for r in rows:
401
+ if r.get("skipped"):
402
+ lines.append(f"| {r['case']} | skipped | " + "- | " * 10 + f"0 | {r['skipped']} |")
403
+ continue
404
+ b = r.get("best") or {}
405
+ failed = ", ".join(f"{c['check']} {c['value']:.3g} (limit {c['op']} {c['limit']})"
406
+ for c in r.get("checks", []) if not c["pass"] and c["value"] is not None)
407
+ if r.get("error"):
408
+ failed = (r["error"] + "; " + failed).strip("; ")
409
+ fmt = lambda v, f: (f.format(v) if v is not None else "-") # noqa: E731
410
+ lines.append(f"| {r['case']} | {'yes' if r.get('pass') else 'NO'} | {fmt(b.get('mass'), '{:.1f}')} | "
411
+ f"{fmt(b.get('massReduction'), '{:.0%}')} | {fmt(b.get('safetyFactor'), '{:.2f}')} | "
412
+ f"{_mat(r.get('pareto'))} | "
413
+ f"{fmt(b.get('deflection'), '{:.3f}')} | {fmt(b.get('p5Member'), '{:.2f}')} | {fmt(b.get('p5Thickness'), '{:.2f}')} | "
414
+ f"{fmt(b.get('creaseLength'), '{:.1f}')} | {_critic_cell(r)} | "
415
+ f"{r['seconds']:.0f} | {failed} |")
416
+ return "\n".join(lines) + "\n"
417
+
418
+
419
+ def run_hx_case(raw: dict, path: str, out: str, tier: str = "full") -> dict:
420
+ from ..__main__ import _jsonable
421
+ from ..tasks import heat_exchanger
422
+
423
+ name = raw.get("name") or os.path.basename(path)
424
+ t0 = time.time()
425
+ try:
426
+ spec = {k: v for k, v in raw.items() if k != "expect"}
427
+ res = heat_exchanger(spec, out, base_dir=os.path.dirname(path), render=tier == "full", viewer=False)
428
+ row = score_hx_case(raw, res, time.time() - t0, tier)
429
+ except Exception as exc: # noqa: BLE001 - a crashing case is a failing case
430
+ import traceback
431
+ row = {"case": name, "kind": "heatExchanger", "pass": False, "seconds": time.time() - t0,
432
+ "error": f"{type(exc).__name__}: {exc}", "traceback": traceback.format_exc()}
433
+ print(json.dumps(_jsonable({k: row.get(k) for k in ("case", "pass", "exchanger", "error")})),
434
+ file=sys.stderr, flush=True)
435
+ return _jsonable(row)
436
+
437
+
438
+ def run_field_case(raw: dict, base: str, out: str, tier: str = "full") -> dict:
439
+ """A field-graph case ("graph"): the part the graph makes, its lattice
440
+ homogenized, analyzed under the case's study on `cells` cells (the
441
+ coarse tier: expect.coarse.cells, default 120k) and held to `expect`.
442
+ Each `mustFail` variant (the graph with some numbers set, tune paths)
443
+ must miss a limit: a design the search was once fooled into passing."""
444
+ from ..__main__ import _jsonable
445
+ from ..homogenized import homogenized_check
446
+ from ..tune import _study, set_at
447
+
448
+ name = raw.get("name")
449
+ expect = raw.get("expect", {})
450
+ cells = int(raw.get("cells", 1_000_000))
451
+ if tier == "coarse":
452
+ cells = int((expect.get("coarse") or {}).get("cells", 120_000))
453
+ t0 = time.time()
454
+ row = {"case": name, "kind": "field", "tier": tier, "checks": []}
455
+ try:
456
+ study = _study(raw, base)
457
+ graph = raw["graph"]
458
+ rep = homogenized_check(graph, study, cells=cells, base_dir=base)
459
+ row["best"] = {"mass": rep["mass"], "safetyFactor": rep["safetyFactor"],
460
+ "deflection": max(c["deflection"] for c in rep["cases"]),
461
+ "analysisCells": rep["analysisCells"], "latticeDensity": rep.get("latticeDensity")}
462
+ ok = True
463
+ for check, (key, measure, op) in CHECKS.items():
464
+ if key not in expect:
465
+ continue
466
+ v = measure(rep)
467
+ passed = _cmp(v, op, expect[key])
468
+ ok &= passed
469
+ row["checks"].append({"check": check, "value": v, "op": op, "limit": expect[key], "pass": passed})
470
+ for var in raw.get("mustFail") or []:
471
+ d = json.loads(json.dumps(graph))
472
+ for path, v in var["set"].items():
473
+ set_at(d, path, v)
474
+ r = homogenized_check(d, study, cells=cells, base_dir=base)
475
+ defl = max(c["deflection"] for c in r["cases"])
476
+ missed = (defl > expect.get("maxDeflection", float("inf"))
477
+ or r["safetyFactor"] < expect.get("minSafetyFactor", 0.0))
478
+ ok &= missed
479
+ row["checks"].append({"check": f"rejects {var['name']}", "value": defl, "op": ">",
480
+ "limit": expect.get("maxDeflection"), "pass": bool(missed),
481
+ "mass": r["mass"], "safetyFactor": r["safetyFactor"]})
482
+ row["pass"] = bool(ok)
483
+ except Exception as exc: # noqa: BLE001 - a crashing case is a failing case
484
+ import traceback
485
+ row.update({"pass": False, "error": f"{type(exc).__name__}: {exc}", "traceback": traceback.format_exc()})
486
+ row["seconds"] = time.time() - t0
487
+ print(json.dumps(_jsonable({k: row.get(k) for k in ("case", "pass", "best", "error")})),
488
+ file=sys.stderr, flush=True)
489
+ return _jsonable(row)
490
+
491
+
492
+ # --spread: the search started this much lower and higher than the case's own
493
+ # goal.volumeFraction (one extra full search each)
494
+ SPREAD_STARTS = (0.8, 1.25)
495
+
496
+
497
+ def run_case(path, out: str, tier: str = "full", critic: str | None = None, spread: bool = False) -> dict:
498
+ """Run one case: `path` is a case file, or a generated case dict. With
499
+ `spread`, a safety-factor case is searched again from other starting
500
+ fractions (SPREAD_STARTS) and row["spread"] says how far its massAtTarget
501
+ moved: the search is deterministic on one machine, so this is the honest
502
+ run-to-run number (a different start, a different machine's rounding, a
503
+ small change to the code all send it down another path)."""
504
+ from ..__main__ import _jsonable
505
+ from ..render import render_interfaces, render_views
506
+ from ..spec import study_from_dict
507
+ from ..study import run_study
508
+
509
+ _tier_globals(tier)
510
+ if isinstance(path, dict):
511
+ raw, base = path, HERE
512
+ else:
513
+ with open(path) as fh:
514
+ raw = json.load(fh)
515
+ base = os.path.dirname(path)
516
+ name = raw.get("name") or os.path.basename(str(path))
517
+ skip = ((raw.get("expect") or {}).get("coarse") or {}).get("skip") if tier == "coarse" else None
518
+ if skip:
519
+ print(f"== {name} (skipped in the coarse tier: {skip})", file=sys.stderr, flush=True)
520
+ return {"case": name, "tier": tier, "skipped": skip, "pass": True, "seconds": 0.0, "checks": []}
521
+ if "graph" in raw:
522
+ print(f"== {name} ({tier})", file=sys.stderr, flush=True)
523
+ return run_field_case(raw, base, out, tier)
524
+ raw = apply_tier(raw, tier)
525
+ print(f"== {name} ({tier})", file=sys.stderr, flush=True)
526
+ if "core" in raw:
527
+ return run_hx_case(raw, path if isinstance(path, str) else os.path.join(HERE, name), out, tier)
528
+ t0 = time.time()
529
+ try:
530
+ study = study_from_dict(raw, base_dir=base)
531
+ result = run_study(study, on_progress=lambda e, _x: (
532
+ print(json.dumps(_jsonable({k: v for k, v in e.items()
533
+ if k in ("stage", "volumeFraction", "safetyFactor", "mass")}
534
+ | {"case": name})),
535
+ file=sys.stderr, flush=True) if e.get("stage") in ("outcome",) else None))
536
+ row = score_case(raw, result, time.time() - t0, study, tier)
537
+ if result.best is not None or result.outcomes:
538
+ o = result.outcomes[result.best if result.best is not None else -1]
539
+ o.mesh.export(os.path.join(out, f"{name}.stl"))
540
+ render_views(o.mesh, os.path.join(out, f"{name}.png"), study,
541
+ title=f"{name}: {o.report['mass']:.1f} g, SF {o.report['safetyFactor']:.2f}")
542
+ if critic:
543
+ from ..critic import critique
544
+
545
+ shot = render_interfaces(o.mesh, study, os.path.join(out, f"{name}.interfaces.png"))
546
+ try:
547
+ c = critique([shot], study, backend=critic, out_dir=out, name=name)
548
+ except Exception as exc: # noqa: BLE001 - a critic outage is not a part failure
549
+ c = {"error": f"{type(exc).__name__}: {exc}"}
550
+ if c is not None:
551
+ row["critic"] = c
552
+ gate_critic(row, raw.get("expect", {}))
553
+ elif critic == "agent":
554
+ row["criticPending"] = f"{name}.critic-request.md"
555
+ row["trace"] = [o.summary() for o in result.outcomes]
556
+ for t in row["trace"]:
557
+ t.pop("cases", None)
558
+ if spread and study.goal.safety_factor is not None:
559
+ row["spread"] = _spread(raw, base, row, tier)
560
+ except Exception as exc: # noqa: BLE001 - a crashing case is a failing case
561
+ import traceback
562
+ row = {"case": name, "pass": False, "seconds": time.time() - t0, "tier": tier,
563
+ "error": f"{type(exc).__name__}: {exc}", "traceback": traceback.format_exc()}
564
+ print(json.dumps(_jsonable({k: row.get(k) for k in ("case", "pass", "best", "error")})),
565
+ file=sys.stderr, flush=True)
566
+ return _jsonable(row)
567
+
568
+
569
+ def _spread(raw: dict, base: str, row: dict, tier: str) -> dict:
570
+ """massAtTarget from the case's own start and from each SPREAD_STARTS
571
+ multiple of it; "range" is (max - min) / min."""
572
+ import copy
573
+
574
+ from ..spec import study_from_dict
575
+ from ..study import run_study
576
+
577
+ vf0 = float((raw.get("goal") or {}).get("volumeFraction") or 0.3)
578
+ runs = [{"start": vf0, "massAtTarget": (row.get("pareto") or {}).get("massAtTarget"),
579
+ "mass": (row.get("best") or {}).get("mass")}]
580
+ for f in SPREAD_STARTS:
581
+ r2 = copy.deepcopy(raw)
582
+ r2.setdefault("goal", {})["volumeFraction"] = round(vf0 * f, 4)
583
+ st = study_from_dict(r2, base_dir=base)
584
+ t0 = time.time()
585
+ res = run_study(st)
586
+ sc = score_case(r2, res, time.time() - t0, st, tier)
587
+ runs.append({"start": r2["goal"]["volumeFraction"], "massAtTarget": (sc.get("pareto") or {}).get("massAtTarget"),
588
+ "mass": (sc.get("best") or {}).get("mass"), "pass": sc.get("pass")})
589
+ def rng(key):
590
+ v = [x[key] for x in runs if x.get(key) is not None]
591
+ return (max(v) - min(v)) / min(v) if len(v) > 1 and min(v) > 0 else None
592
+ # range: of massAtTarget (the front's mass at the goal SF); massRange: of
593
+ # the delivered part's mass, which can swing wider (the wall bracket:
594
+ # 68.7, 80.6, 71.4 g delivered, 67.1, 71.9, 68.9 g at target)
595
+ return {"runs": runs, "range": rng("massAtTarget"), "massRange": rng("mass")}
596
+
597
+
598
+ def compare(rows, base_rows, tolerance=0.05):
599
+ """Regressions against an earlier scorecard: a case that passed and now
600
+ fails, or whose Pareto massAtTarget grew by more than `tolerance`, or by
601
+ more than the case's own measured run-to-run spread (row "spread", from
602
+ --spread, in either scorecard) where that is wider: the wall bracket's
603
+ massAtTarget moved 7% with the starting fraction alone, so a 6% move
604
+ there is not a regression. Also lists improvements. Adds
605
+ row["vsBaseline"]."""
606
+ base = {r["case"]: r for r in base_rows}
607
+ regress, improve = [], []
608
+ for r in rows:
609
+ b = base.get(r["case"])
610
+ if b is None or r.get("skipped") or b.get("skipped"):
611
+ continue
612
+ d = {}
613
+ if b.get("pass") and not r.get("pass"):
614
+ d["pass"] = "lost"
615
+ elif r.get("pass") and not b.get("pass"):
616
+ d["pass"] = "gained"
617
+ m, mb = (r.get("pareto") or {}).get("massAtTarget"), (b.get("pareto") or {}).get("massAtTarget")
618
+ if m is not None and mb:
619
+ d["massAtTarget"] = m / mb - 1.0
620
+ elif mb and m is None:
621
+ d["massAtTarget"] = "lost"
622
+ tol = max([tolerance] + [(x.get("spread") or {}).get("range") or 0.0 for x in (r, b)])
623
+ if tol > tolerance:
624
+ d["tolerance"] = tol
625
+ r["vsBaseline"] = d
626
+ worse = d.get("pass") == "lost" or d.get("massAtTarget") == "lost" or \
627
+ (isinstance(d.get("massAtTarget"), float) and d["massAtTarget"] > tol)
628
+ better = d.get("pass") == "gained" or \
629
+ (isinstance(d.get("massAtTarget"), float) and d["massAtTarget"] < -tol)
630
+ (regress if worse else improve if better else []).append(r["case"])
631
+ return regress, improve
632
+
633
+
634
+ def _md_compare(rows, regress, improve):
635
+ lines = ["", f"vs baseline: {len(regress)} regressed, {len(improve)} improved", "",
636
+ "| Case | Pass | massAtTarget g | vs baseline |", "| --- | --- | --- | --- |"]
637
+ for r in rows:
638
+ d = r.get("vsBaseline")
639
+ if r.get("skipped"):
640
+ continue
641
+ if d is None:
642
+ continue
643
+ m = (r.get("pareto") or {}).get("massAtTarget")
644
+ dm = d.get("massAtTarget")
645
+ txt = (f"{dm:+.1%}" if isinstance(dm, float) else (dm or "-")) + (f", pass {d['pass']}" if "pass" in d else "")
646
+ flag = " **regressed**" if r["case"] in regress else ""
647
+ lines.append(f"| {r['case']} | {'yes' if r.get('pass') else 'NO'} | "
648
+ f"{'-' if m is None else f'{m:.1f}'} | {txt}{flag} |")
649
+ return "\n".join(lines) + "\n"
650
+
651
+
652
+ def _case_bytes(path: str) -> float:
653
+ """Peak memory a case is expected to need (study._run_bytes by the most
654
+ cells it solves at once)."""
655
+ from ..spec import study_from_dict
656
+ from ..study import _run_bytes, _study_cells
657
+
658
+ with open(path) as fh:
659
+ raw = json.load(fh)
660
+ if "core" in raw:
661
+ return _run_bytes(60_000)
662
+ if "graph" in raw:
663
+ return 2.5e9 # a 1M-cell homogenized check on multigrid peaks near 1.4 GB
664
+ try:
665
+ return _run_bytes(_study_cells(study_from_dict(raw, base_dir=os.path.dirname(path))))
666
+ except Exception: # noqa: BLE001 - it fails properly when run
667
+ return _run_bytes(60_000)
668
+
669
+
670
+ def _died(f, tier, exc) -> dict:
671
+ """The row of a case whose worker process died (killed for memory, a
672
+ crash in native code): a failure of that case, not of the run."""
673
+ if isinstance(f, dict):
674
+ name = f.get("name", "case")
675
+ else:
676
+ name = os.path.splitext(os.path.basename(f))[0]
677
+ return {"case": name, "tier": tier, "pass": False, "skipped": False, "checks": [], "seconds": 0.0,
678
+ "error": f"worker process died ({type(exc).__name__}: {exc}); out of memory?"}
679
+
680
+
681
+ def _run_isolated(files: list, out: str, tier: str = "full", critic=None, jobs: int = 1, spread=False) -> list:
682
+ """Run each case in a fresh spawned process, `jobs` at a time. In one
683
+ long process the cases add up: a finished case leaves its peak behind
684
+ (glibc keeps freed arenas and Python's allocator keeps its pools), the
685
+ next case sits on top, and a full --jobs 1 run on the 15 GB cloud box
686
+ was killed at ~14 GB on its seventh case although every case fits
687
+ alone. A worker that dies (out of memory) costs its own case: the pool
688
+ is rebuilt for the rest."""
689
+ import multiprocessing as mp
690
+ from concurrent.futures import ProcessPoolExecutor
691
+ from concurrent.futures.process import BrokenProcessPool
692
+
693
+ # keyed by position: a generated corpus case is a dict, not a path
694
+ rows = {}
695
+ todo = list(range(len(files)))
696
+ while todo:
697
+ ex = ProcessPoolExecutor(jobs, mp_context=mp.get_context("spawn"), max_tasks_per_child=1)
698
+ futs = {ex.submit(run_case, files[i], out, tier, critic, spread): i for i in todo}
699
+ broken = False
700
+ for fut, i in futs.items():
701
+ try:
702
+ rows[i] = fut.result()
703
+ except BrokenProcessPool as exc:
704
+ # one at a time, the case that died is the first without a
705
+ # result; with several running it could be any of them, so
706
+ # the ones cut off run again one at a time
707
+ if not broken and jobs == 1:
708
+ rows[i] = _died(files[i], tier, exc)
709
+ print(json.dumps({"stage": "caseDied", "case": rows[i]["case"]}), flush=True)
710
+ broken = True
711
+ except Exception as exc: # noqa: BLE001 - one case's failure is its own row
712
+ rows[i] = _died(files[i], tier, exc)
713
+ ex.shutdown(wait=True, cancel_futures=True)
714
+ todo = [i for i in todo if i not in rows]
715
+ if broken:
716
+ jobs = 1
717
+ return [rows[i] for i in range(len(files))]
718
+
719
+
720
+ def _run_budgeted(files: list, out: str, tier: str = "full", critic=None, spread=False) -> list:
721
+ """Run cases in processes, largest first, starting each only while the
722
+ expected peaks of everything running fit in 80% of the memory available (the
723
+ container's limit, when it has one)
724
+ (and no more at once than there are cores). The 4-core 15 GB cloud box ran
725
+ out of memory at a flat --jobs 2: two GRIP cases at their 150k-cell
726
+ confirmation solves."""
727
+ import multiprocessing as mp
728
+ from concurrent.futures import FIRST_COMPLETED, ProcessPoolExecutor, wait
729
+
730
+ from ..study import _available_bytes
731
+
732
+ os.environ.setdefault("LOADPATH_WORKERS", "1")
733
+ need = {f: _case_bytes(f) for f in files}
734
+ budget = 0.8 * (_available_bytes() or 8e9)
735
+ slots = max(1, os.cpu_count() or 1)
736
+ queue = sorted(files, key=lambda f: -need[f])
737
+ rows, running = {}, {}
738
+ # a fresh process per case: a finished case's worker keeps its peak
739
+ # (Python does not hand freed memory back), which the next case then
740
+ # sits on top of
741
+ with ProcessPoolExecutor(slots, mp_context=mp.get_context("spawn"), max_tasks_per_child=1) as ex:
742
+ while queue or running:
743
+ used = sum(need[f] for f in running.values())
744
+ for f in list(queue):
745
+ if len(running) >= slots:
746
+ break
747
+ if not running or used + need[f] <= budget:
748
+ running[ex.submit(run_case, f, out, tier, critic, spread)] = f
749
+ used += need[f]
750
+ queue.remove(f)
751
+ done, _ = wait(running, return_when=FIRST_COMPLETED)
752
+ for fut in done:
753
+ f = running.pop(fut)
754
+ try:
755
+ rows[f] = fut.result()
756
+ except Exception as exc: # noqa: BLE001 - a dead worker fails its case, not the run
757
+ rows[f] = _died(f, tier, exc)
758
+ return [rows[f] for f in files]
759
+
760
+
761
+ def main(argv=None):
762
+ from ..__main__ import _jsonable
763
+
764
+ p = argparse.ArgumentParser(prog="loadpath.evals.run")
765
+ p.add_argument("cases", nargs="*", help="case names (default: all)")
766
+ p.add_argument("--out", default="evals-out")
767
+ p.add_argument("--jobs",
768
+ help="cases to run at once (each case also parallelizes its own outcomes); default 2 (full tier), "
769
+ "or every core for the coarse tier (its cases are small); 'auto': as many as fit in "
770
+ "the memory available, by each case's size")
771
+ p.add_argument("--tier", choices=["full", "coarse"], default="full")
772
+ p.add_argument("--corpus", help="add generated cases: N (seeds 0..N-1), A:B, or a,b,c")
773
+ p.add_argument("--only-corpus", action="store_true", help="run the generated cases only")
774
+ p.add_argument("--spread", action="store_true",
775
+ help="search each safety-factor case again from other starting fractions and report how far "
776
+ "its massAtTarget moves (row spread; about 3x the time)")
777
+ p.add_argument("--critic", choices=["anthropic", "agent"], help="vision critic on interface close-ups")
778
+ p.add_argument("--baseline", help="an earlier scorecard.json to diff against")
779
+ p.add_argument("--tolerance", type=float, default=0.05, help="massAtTarget growth that counts as a regression")
780
+ p.add_argument("--save-baseline", help="also write this run's scores (no traces) here, for --baseline")
781
+ p.add_argument("--gate", choices=["failures", "regressions"], default="failures",
782
+ help="what the exit code counts")
783
+ p.add_argument("--allow-voxel-confirm", action="store_true",
784
+ help="run although no tetrahedral mesher is installed (the safety-factor cases then "
785
+ "confirm on voxels and come out heavier than the baselines)")
786
+ args = p.parse_args(argv)
787
+ from ..crosscheck import mesher_missing
788
+
789
+ no_tets = mesher_missing()
790
+ if no_tets and not args.allow_voxel_confirm:
791
+ # The baselines were taken with the tet confirm: without it every
792
+ # safety-factor case steps down on the voxel reading alone, and
793
+ # the scorecard reads as a regression of the optimizer (13/14 with
794
+ # the MBB beam at 40.4 g against 14.5 g) when it is the install.
795
+ print(f"loadpath.evals.run: {no_tets}. The safety-factor cases confirm on tetrahedra; install it, "
796
+ "or pass --allow-voxel-confirm to run without (not comparable to the baselines).", file=sys.stderr)
797
+ return 2
798
+ # a scorecard measures the optimizer, not what earlier runs left behind
799
+ os.environ.pop("LOADPATH_RECALL", None)
800
+ # and the same optimizer whatever else the box is running: the confirm
801
+ # grid otherwise shrinks to the memory free when it starts (study.py
802
+ # _confirm_cells), so a case's numbers would depend on its neighbours
803
+ os.environ.setdefault("LOADPATH_CONFIRM_CELLS", "150000")
804
+ files = sorted(glob.glob(os.path.join(HERE, "cases", "*.json")))
805
+ if args.cases:
806
+ files = [f for f in files if os.path.splitext(os.path.basename(f))[0] in args.cases]
807
+ if args.only_corpus:
808
+ files = []
809
+ if args.corpus:
810
+ from .corpus import generate, seeds
811
+ files += [generate(s) for s in seeds(args.corpus)]
812
+ os.makedirs(args.out, exist_ok=True)
813
+ if args.jobs is None:
814
+ # full: two at a time. With the AMG coarse-factor leak fixed (fem.py)
815
+ # a GRIP case peaks near 2 GB, and two ran the suite in 1 h 50 min
816
+ # against 2 h 30 min one at a time on the 4-core 15 GB box, the
817
+ # box at 3.8 GB, every number the same as --jobs 1
818
+ args.jobs = (os.cpu_count() or 1) if args.tier == "coarse" else min(2, os.cpu_count() or 1)
819
+ if args.jobs == "auto":
820
+ rows = _run_budgeted(files, args.out, args.tier, args.critic, args.spread)
821
+ elif os.environ.get("LOADPATH_EVAL_INPROCESS") == "1":
822
+ # debugging: every case in this process (it accumulates; see _run_isolated)
823
+ rows = [run_case(f, args.out, args.tier, args.critic, args.spread) for f in files]
824
+ else:
825
+ if int(args.jobs) > 1:
826
+ # One process per case; outcomes inside a case stay in that process.
827
+ os.environ.setdefault("LOADPATH_WORKERS", "1")
828
+ # and one BLAS / OpenMP thread each: four cases each spinning up a
829
+ # core's worth of BLAS threads ran 3-10x slower than one at a time
830
+ for v in ("OMP_NUM_THREADS", "OPENBLAS_NUM_THREADS", "MKL_NUM_THREADS"):
831
+ os.environ.setdefault(v, "1")
832
+ rows = _run_isolated(files, args.out, args.tier, args.critic, int(args.jobs), args.spread)
833
+ md = _md(rows)
834
+ if no_tets:
835
+ md = f"**No tet confirm** ({no_tets}): safety-factor cases confirmed on voxels; not comparable to the baselines.\n\n" + md
836
+ regress = []
837
+ if args.baseline:
838
+ with open(args.baseline) as fh:
839
+ regress, improve = compare(rows, json.load(fh), args.tolerance)
840
+ md += _md_compare(rows, regress, improve)
841
+ with open(os.path.join(args.out, "scorecard.json"), "w") as fh:
842
+ json.dump(_jsonable(rows), fh, indent=2)
843
+ with open(os.path.join(args.out, "scorecard.md"), "w") as fh:
844
+ fh.write(md)
845
+ if args.save_baseline:
846
+ keep = ("case", "tier", "pass", "skipped", "best", "pareto", "checks", "seconds", "error", "spread")
847
+ with open(args.save_baseline, "w") as fh:
848
+ json.dump(_jsonable([{k: r[k] for k in keep if k in r} for r in rows]), fh, indent=1)
849
+ print(md)
850
+ pending = [r["criticPending"] for r in rows if r.get("criticPending")]
851
+ if pending:
852
+ print(f"critic: {len(pending)} request(s) in {args.out} for the agent to answer "
853
+ f"(<case>.critic.json), then: python -m loadpath.critic score {args.out}", file=sys.stderr)
854
+ if args.gate == "regressions":
855
+ return len(regress)
856
+ return sum(0 if r.get("pass") else 1 for r in rows)
857
+
858
+
859
+ if __name__ == "__main__":
860
+ sys.exit(main())