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
@@ -0,0 +1,245 @@
1
+ {
2
+ "id": "wrist-yoke-mjf",
3
+ "brief": "Wrist yoke for a robot arm, MJF PA12. It sits on a 25 mm diameter servo horn: the horn's top\nface is at z = 0, its axis is Z through the origin, and the yoke is screwed to it with four\nM2.5 screws on a 16 mm bolt circle (holes at (8,0), (-8,0), (0,8), (0,-8)). Give the yoke a\nsolid 4 mm thick disc over the horn (z = 0 to 4, 25 mm diameter) with those holes through it.\nTwo cheeks rise from the base and carry the gripper's pivot: an 8 mm shaft along X at z = 45,\nthrough 8.2 mm holes in both cheeks, with at least 3 mm of wall round each hole. The wrist servo\nsits between the cheeks, so x = -15 to 15, y = -12 to 12, z = 10 to 60 has to stay empty, and\nthe cheeks can't go past x = +/-24. Overall the part fits in x = -24 to 24, y = -14 to 14,\nz = 0 to 56. The gripper hanging off the pivot is 800 g on a fast arm, and the wrist servo\ntwists it with up to 1.5 N\u00b7m about Z. Safety factor 2.",
4
+ "attachments": [],
5
+ "tags": [
6
+ "arm",
7
+ "mjf",
8
+ "moment"
9
+ ],
10
+ "gold": {
11
+ "designSpace": [
12
+ {
13
+ "type": "box",
14
+ "min": [
15
+ -24.0,
16
+ -14.0,
17
+ 0.0
18
+ ],
19
+ "max": [
20
+ 24.0,
21
+ 14.0,
22
+ 56.0
23
+ ]
24
+ }
25
+ ],
26
+ "preserve": [
27
+ {
28
+ "id": "horn-disc",
29
+ "shape": {
30
+ "type": "cylinder",
31
+ "p0": [
32
+ 0.0,
33
+ 0.0,
34
+ 0.0
35
+ ],
36
+ "p1": [
37
+ 0.0,
38
+ 0.0,
39
+ 4.0
40
+ ],
41
+ "r": 12.5
42
+ }
43
+ },
44
+ {
45
+ "id": "bosses",
46
+ "shape": {
47
+ "type": "union",
48
+ "shapes": [
49
+ {
50
+ "type": "cylinder",
51
+ "p0": [
52
+ 18.0,
53
+ 0.0,
54
+ 45.0
55
+ ],
56
+ "p1": [
57
+ 21.0,
58
+ 0.0,
59
+ 45.0
60
+ ],
61
+ "r": 7.1
62
+ },
63
+ {
64
+ "type": "cylinder",
65
+ "p0": [
66
+ -21.0,
67
+ 0.0,
68
+ 45.0
69
+ ],
70
+ "p1": [
71
+ -18.0,
72
+ 0.0,
73
+ 45.0
74
+ ],
75
+ "r": 7.1
76
+ }
77
+ ]
78
+ }
79
+ }
80
+ ],
81
+ "keepOut": [
82
+ {
83
+ "id": "horn-screws",
84
+ "shape": {
85
+ "type": "union",
86
+ "shapes": [
87
+ {
88
+ "type": "cylinder",
89
+ "p0": [
90
+ 8.0,
91
+ 0.0,
92
+ -1.0
93
+ ],
94
+ "p1": [
95
+ 8.0,
96
+ 0.0,
97
+ 6.0
98
+ ],
99
+ "r": 1.4
100
+ },
101
+ {
102
+ "type": "cylinder",
103
+ "p0": [
104
+ -8.0,
105
+ 0.0,
106
+ -1.0
107
+ ],
108
+ "p1": [
109
+ -8.0,
110
+ 0.0,
111
+ 6.0
112
+ ],
113
+ "r": 1.4
114
+ },
115
+ {
116
+ "type": "cylinder",
117
+ "p0": [
118
+ 0.0,
119
+ 8.0,
120
+ -1.0
121
+ ],
122
+ "p1": [
123
+ 0.0,
124
+ 8.0,
125
+ 6.0
126
+ ],
127
+ "r": 1.4
128
+ },
129
+ {
130
+ "type": "cylinder",
131
+ "p0": [
132
+ 0.0,
133
+ -8.0,
134
+ -1.0
135
+ ],
136
+ "p1": [
137
+ 0.0,
138
+ -8.0,
139
+ 6.0
140
+ ],
141
+ "r": 1.4
142
+ }
143
+ ]
144
+ },
145
+ "bore": false
146
+ },
147
+ {
148
+ "id": "pivot",
149
+ "shape": {
150
+ "type": "cylinder",
151
+ "p0": [
152
+ -30.0,
153
+ 0.0,
154
+ 45.0
155
+ ],
156
+ "p1": [
157
+ 30.0,
158
+ 0.0,
159
+ 45.0
160
+ ],
161
+ "r": 4.1
162
+ }
163
+ },
164
+ {
165
+ "id": "servo",
166
+ "shape": {
167
+ "type": "box",
168
+ "min": [
169
+ -15.0,
170
+ -12.0,
171
+ 10.0
172
+ ],
173
+ "max": [
174
+ 15.0,
175
+ 12.0,
176
+ 60.0
177
+ ]
178
+ },
179
+ "bore": false
180
+ }
181
+ ],
182
+ "supports": [
183
+ {
184
+ "region": "horn-screws"
185
+ },
186
+ {
187
+ "shape": {
188
+ "type": "cylinder",
189
+ "p0": [
190
+ 0.0,
191
+ 0.0,
192
+ -0.5
193
+ ],
194
+ "p1": [
195
+ 0.0,
196
+ 0.0,
197
+ 0.3
198
+ ],
199
+ "r": 12.5
200
+ },
201
+ "fix": "z"
202
+ }
203
+ ],
204
+ "loadCases": [
205
+ {
206
+ "name": "hang",
207
+ "loads": [
208
+ {
209
+ "region": "pivot",
210
+ "force": [
211
+ 0,
212
+ 0,
213
+ -11.772000000000002
214
+ ]
215
+ }
216
+ ]
217
+ },
218
+ {
219
+ "name": "twist",
220
+ "loads": [
221
+ {
222
+ "region": "pivot",
223
+ "moment": [
224
+ 0,
225
+ 0,
226
+ 1500
227
+ ]
228
+ }
229
+ ]
230
+ }
231
+ ],
232
+ "material": "pa12",
233
+ "process": "mjf",
234
+ "goal": {
235
+ "safetyFactor": 2
236
+ },
237
+ "name": "wrist-yoke-mjf",
238
+ "resolution": {
239
+ "maxElements": 60000
240
+ }
241
+ },
242
+ "pass": {
243
+ "safetyFactor": 2
244
+ }
245
+ }
@@ -0,0 +1,393 @@
1
+ """
2
+ Brief evals: from a plain-language request to a printable part, with no
3
+ human in the loop.
4
+
5
+ Each brief in briefs/*.json is what a person would type to their agent (plus
6
+ attachments in briefs/assets/: an STL, a STEP, a URDF, a dimensioned sketch)
7
+ and a gold study: the minimum defensible reading of the brief (its
8
+ interfaces, envelope, supports, loads, material and process). The agent never
9
+ sees the gold study. It writes its own study, runs it and hands back an STL;
10
+ the scorer re-verifies that STL against the gold study (verify.verify_part,
11
+ the same analysis loadpath uses on its own outcomes), so a part only passes
12
+ if it is right for the brief, not for whatever problem the agent wrote down:
13
+
14
+ pass = one body, inside the envelope, every gold keep-out clear, every
15
+ gold interface present, gold safety factor >= SF_SLACK x target in
16
+ every gold load case, deflection where the brief asks, and the
17
+ brief's mass limit (or, without one, at most half the solid
18
+ envelope's mass: exporting the design space is not a design)
19
+
20
+ Run:
21
+ python -m loadpath.evals.briefs list
22
+ python -m loadpath.evals.briefs gold # lint + build every gold study
23
+ python -m loadpath.evals.briefs prompt <id> # what the agent is told
24
+ python -m loadpath.evals.briefs score <id> part.stl
25
+ python -m loadpath.evals.briefs run --out briefs-out/ [--jobs 4] [ids...]
26
+ drives a headless agent (`claude -p`, loadpath MCP server + CLI) per
27
+ brief in its own empty folder, then scores what it left behind;
28
+ writes briefs-out/scorecard.json and scorecard.md.
29
+ python -m loadpath.evals.briefs table briefs-out/ # re-score / re-tabulate
30
+
31
+ Briefs are kept out of the agent's reach by running it in a fresh folder with
32
+ only the attachments; the prompt also says the answer key is off limits.
33
+ """
34
+ from __future__ import annotations
35
+
36
+ import argparse
37
+ import glob
38
+ import json
39
+ import os
40
+ import shutil
41
+ import subprocess
42
+ import sys
43
+ import time
44
+ from concurrent.futures import ThreadPoolExecutor
45
+
46
+ HERE = os.path.dirname(os.path.abspath(__file__))
47
+ BRIEFS = os.environ.get("LOADPATH_BRIEFS") or os.path.join(HERE, "briefs")
48
+ ASSETS = os.path.join(BRIEFS, "assets")
49
+
50
+ SF_SLACK = 0.9 # gold SF must reach 90% of the target: the agent's grid and ours differ
51
+ DEFLECTION_SLACK = 1.1
52
+ MASS_SLACK = 1.05
53
+ MAX_INTRUSION = 30.0 # mm^3 of part inside a gold keep-out (a 0.05 mm bore undersize is ~10)
54
+ MAX_MISSING = 30.0 # mm^3 of gold interface missing
55
+ MAX_OUTSIDE = 100.0 # mm^3 of part outside the envelope
56
+ MAX_SOLID_FRACTION = 0.5 # without a stated mass, at most half the solid envelope (no "export the box")
57
+ VERIFY_CELLS = 40_000
58
+
59
+ PROMPT = """{brief}
60
+
61
+ {attachments}You have loadpath for this: its MCP tools (server `loadpath`) and the `loadpath`
62
+ command line. Work it through on your own; nobody will answer questions, so make reasonable
63
+ engineering assumptions and note them. When you are done, leave in the current folder:
64
+ part.stl the part to print (your final, verified outcome)
65
+ study.json the study that produced it
66
+ notes.md two or three lines: the outcome's mass and safety factor, and your assumptions
67
+ This machine is shared: keep resolution.maxElements at or under {max_elements} and run one
68
+ optimization at a time. Do not read loadpath/evals/briefs (that is the answer key).
69
+ """
70
+
71
+
72
+ def load(name: str) -> dict:
73
+ path = name if name.endswith(".json") else os.path.join(BRIEFS, name + ".json")
74
+ with open(path) as fh:
75
+ return json.load(fh)
76
+
77
+
78
+ def all_ids() -> list[str]:
79
+ return sorted(os.path.splitext(os.path.basename(p))[0] for p in glob.glob(os.path.join(BRIEFS, "*.json")))
80
+
81
+
82
+ def gold_study(brief: dict):
83
+ from ..spec import study_from_dict
84
+ return study_from_dict(brief["gold"], base_dir=ASSETS)
85
+
86
+
87
+ def kind(brief: dict):
88
+ """The brief's kind module, for briefs that are not a single structural
89
+ part: loadpath/evals/brief_kinds/<kind>.py with DELIVER (what the agent
90
+ leaves), score(brief, folder) and gold(brief) (the gold's own check).
91
+ None for the default "study" kind (part.stl under a gold study)."""
92
+ k = brief.get("kind", "study")
93
+ if k == "study":
94
+ return None
95
+ import importlib
96
+ return importlib.import_module(f"loadpath.evals.brief_kinds.{k}")
97
+
98
+
99
+ def prompt(brief: dict, max_elements: int = 20000) -> str:
100
+ att = ""
101
+ if brief.get("attachments"):
102
+ att = "Attached (in the current folder): " + ", ".join(os.path.basename(a) for a in brief["attachments"]) + "\n\n"
103
+ text = PROMPT.format(brief=brief["brief"], attachments=att, max_elements=max_elements)
104
+ mod = kind(brief)
105
+ if mod is not None:
106
+ head, _, rest = text.partition("When you are done, leave in the current folder:\n")
107
+ tail = rest[rest.index("This machine is shared"):]
108
+ text = head + "When you are done, leave in the current folder:\n" + mod.DELIVER.rstrip("\n") + "\n" + tail
109
+ return text
110
+
111
+
112
+ def score_dir(brief: dict, folder: str, max_cells: int = VERIFY_CELLS) -> dict:
113
+ """Score what an agent left in `folder`, by the brief's kind."""
114
+ mod = kind(brief)
115
+ if mod is None:
116
+ return score(brief, os.path.join(folder, "part.stl"), max_cells)
117
+ try:
118
+ row = mod.score(brief, folder)
119
+ except Exception as exc: # noqa: BLE001 - a broken delivery is a failed brief, not a crashed eval
120
+ msg = str(exc).strip().splitlines()[-1] if str(exc).strip() else type(exc).__name__
121
+ row = {"pass": False, "failures": [f"scoring failed: {msg[:200]}"]}
122
+ row.setdefault("failures", [])
123
+ row["id"] = brief["id"]
124
+ row["pass"] = bool(row.get("pass")) and not row["failures"]
125
+ return row
126
+
127
+
128
+ def score(brief: dict, stl: str, max_cells: int = VERIFY_CELLS) -> dict:
129
+ """Re-verify the delivered STL under the gold study. Returns
130
+ {pass, failures, mass, cases: [{name, safetyFactor, deflection}], ...}."""
131
+ import trimesh
132
+
133
+ from ..verify import verify_part
134
+
135
+ want = brief["pass"]
136
+ row = {"id": brief["id"], "pass": False, "failures": []}
137
+ if not stl or not os.path.exists(stl):
138
+ row["failures"].append("no part.stl")
139
+ return row
140
+ try:
141
+ mesh = trimesh.load(stl, force="mesh")
142
+ study = gold_study(brief)
143
+ rep = verify_part(study, mesh, max_cells=max_cells, refine=3)
144
+ except Exception as exc: # noqa: BLE001 - a broken part is a failed part, not a crashed eval
145
+ msg = str(exc).strip().splitlines()[-1] if str(exc).strip() else type(exc).__name__
146
+ row["failures"].append(f"verify failed: {msg[:200]}")
147
+ return row
148
+ f = row["failures"]
149
+ row["mass"] = round(rep["mass"], 2)
150
+ row["bodies"] = rep.get("bodies")
151
+ row["keepOutIntrusion"] = round(rep.get("keepOutIntrusion", 0.0), 1)
152
+ row["preserveMissing"] = round(rep.get("preserveMissing", 0.0), 1)
153
+ row["outsideDesignSpace"] = round(rep.get("outsideDesignSpace", 0.0), 1)
154
+ row["cases"] = [{"name": c["name"], "safetyFactor": round(c["safetyFactor"], 3),
155
+ "deflection": round(c["deflection"], 4) if c.get("deflection") is not None else None}
156
+ for c in rep["cases"]]
157
+ row["safetyFactor"] = round(rep["safetyFactor"], 3)
158
+ if rep.get("bodies") != 1:
159
+ f.append(f"{rep.get('bodies')} bodies")
160
+ if row["outsideDesignSpace"] > MAX_OUTSIDE:
161
+ f.append(f"{row['outsideDesignSpace']} mm^3 outside the envelope")
162
+ if row["keepOutIntrusion"] > MAX_INTRUSION:
163
+ f.append(f"{row['keepOutIntrusion']} mm^3 in keep-outs")
164
+ if row["preserveMissing"] > MAX_MISSING:
165
+ f.append(f"{row['preserveMissing']} mm^3 of interfaces missing")
166
+ sf = want.get("safetyFactor")
167
+ if sf:
168
+ for c in row["cases"]:
169
+ if c["safetyFactor"] < SF_SLACK * sf:
170
+ f.append(f"SF {c['safetyFactor']:.2f} < {sf} in '{c['name']}'")
171
+ dsf = want.get("durabilitySafetyFactor")
172
+ if dsf:
173
+ # fatigue / creep under the gold study's durability block
174
+ dur = rep.get("durability") if isinstance(rep.get("durability"), dict) else {}
175
+ row["durabilitySafetyFactor"] = dur.get("safetyFactor")
176
+ if dur.get("safetyFactor") is None:
177
+ f.append("no durability check under the gold study")
178
+ elif dur["safetyFactor"] < SF_SLACK * dsf:
179
+ f.append(f"durability SF {dur['safetyFactor']:.2f} < {dsf}")
180
+ dmax = want.get("maxDeflection")
181
+ if dmax:
182
+ for c in row["cases"]:
183
+ if c["deflection"] is not None and c["deflection"] > DEFLECTION_SLACK * dmax:
184
+ f.append(f"deflection {c['deflection']:.3f} > {dmax} mm in '{c['name']}'")
185
+ mmax = want.get("maxMass")
186
+ row["solidFraction"] = round(rep["mass"] / rep["designSpaceMass"], 3) if rep.get("designSpaceMass") else None
187
+ if mmax and rep["mass"] > MASS_SLACK * mmax:
188
+ f.append(f"{rep['mass']:.1f} g > {mmax} g")
189
+ elif not mmax and row["solidFraction"] and row["solidFraction"] > want.get("maxSolidFraction", MAX_SOLID_FRACTION):
190
+ f.append(f"{row['solidFraction']:.0%} of the solid envelope's mass")
191
+ row["pass"] = not f
192
+ return row
193
+
194
+
195
+ # --- the agent harness -------------------------------------------------------
196
+
197
+ def _mcp_config(path: str):
198
+ with open(path, "w") as fh:
199
+ json.dump({"mcpServers": {"loadpath": {"command": shutil.which("loadpath") or "loadpath", "args": ["mcp"]}}}, fh)
200
+
201
+
202
+ def _tool_stats(transcript: str) -> dict:
203
+ """Count tool calls by name and pull the final result line out of a
204
+ stream-json transcript."""
205
+ calls, result = {}, {}
206
+ try:
207
+ with open(transcript) as fh:
208
+ for line in fh:
209
+ try:
210
+ ev = json.loads(line)
211
+ except json.JSONDecodeError:
212
+ continue
213
+ if ev.get("type") == "assistant":
214
+ for c in ev.get("message", {}).get("content", []):
215
+ if c.get("type") == "tool_use":
216
+ n = c.get("name", "?")
217
+ calls[n] = calls.get(n, 0) + 1
218
+ elif ev.get("type") == "result":
219
+ result = ev
220
+ except OSError:
221
+ pass
222
+ return {"toolCalls": calls, "turns": result.get("num_turns"), "costUSD": result.get("total_cost_usd"),
223
+ "agentSeconds": (result.get("duration_ms") or 0) / 1000.0 or None, "agentError": result.get("is_error")}
224
+
225
+
226
+ def prepare(brief: dict, out: str, max_elements: int = 20000) -> tuple[str, str]:
227
+ """A fresh folder for one brief: its attachments and PROMPT.md. Any agent
228
+ can then be pointed at it; `table` scores what it leaves behind."""
229
+ work = os.path.join(out, brief["id"])
230
+ if os.path.exists(work):
231
+ shutil.rmtree(work)
232
+ os.makedirs(work)
233
+ for a in brief.get("attachments", []):
234
+ shutil.copy(os.path.join(BRIEFS, a), os.path.join(work, os.path.basename(a)))
235
+ text = prompt(brief, max_elements)
236
+ with open(os.path.join(work, "PROMPT.md"), "w") as fh:
237
+ fh.write(text)
238
+ return work, text
239
+
240
+
241
+ # Headless agents get exactly the tools the task needs, not a blanket bypass.
242
+ ALLOWED_TOOLS = ["Read", "Write", "Edit", "Glob", "Grep", "mcp__loadpath", "Bash(loadpath:*)",
243
+ "Bash(python -m loadpath:*)", "Bash(ls:*)", "Bash(cat:*)", "Bash(head:*)", "Bash(tail:*)"]
244
+
245
+
246
+ def run_agent(brief: dict, out: str, agent: str = "claude", model: str | None = None, timeout: float = 3600,
247
+ max_elements: int = 20000, threads: int = 2) -> dict:
248
+ work, text = prepare(brief, out, max_elements)
249
+ cfg = os.path.join(out, f".{brief['id']}.mcp.json")
250
+ _mcp_config(cfg)
251
+ env = dict(os.environ, LOADPATH_HOME=os.path.join(out, f".{brief['id']}.jobs"))
252
+ for k in ("OMP_NUM_THREADS", "OPENBLAS_NUM_THREADS", "MKL_NUM_THREADS", "NUMBA_NUM_THREADS"):
253
+ env[k] = str(threads)
254
+ cmd = [agent, "-p", text, "--output-format", "stream-json", "--verbose", "--mcp-config", cfg,
255
+ "--allowedTools", *ALLOWED_TOOLS, "--max-turns", "150"]
256
+ if model:
257
+ cmd += ["--model", model]
258
+ t0 = time.time()
259
+ status = "ok"
260
+ with open(os.path.join(work, ".transcript.jsonl"), "w") as tf, open(os.path.join(work, ".agent.err"), "w") as ef:
261
+ try:
262
+ subprocess.run(cmd, cwd=work, env=env, stdout=tf, stderr=ef, timeout=timeout)
263
+ except subprocess.TimeoutExpired:
264
+ status = "timeout"
265
+ seconds = time.time() - t0
266
+ row = score_dir(brief, work)
267
+ row.update(_tool_stats(os.path.join(work, ".transcript.jsonl")))
268
+ row.update({"seconds": round(seconds, 1), "agentStatus": status,
269
+ "studyWritten": os.path.exists(os.path.join(work, "study.json")), "tags": brief.get("tags", [])})
270
+ with open(os.path.join(work, "score.json"), "w") as fh:
271
+ json.dump(row, fh, indent=1)
272
+ return row
273
+
274
+
275
+ def table(rows: list[dict]) -> str:
276
+ n = len(rows)
277
+ ok = sum(1 for r in rows if r["pass"])
278
+ lines = [f"**{ok}/{n} briefs pass** ({100.0 * ok / max(n, 1):.0f}%) with no human fixes.", "",
279
+ "| brief | pass | mass g | gold SF (worst case) | failures | turns | min |",
280
+ "| --- | --- | --- | --- | --- | --- | --- |"]
281
+ for r in sorted(rows, key=lambda r: r["id"]):
282
+ lines.append("| {} | {} | {} | {} | {} | {} | {} |".format(
283
+ r["id"], "yes" if r["pass"] else "no", r.get("mass", ""), r.get("safetyFactor", ""),
284
+ "; ".join(r["failures"]) or "", r.get("turns") or "", round(r["seconds"] / 60, 1) if r.get("seconds") else ""))
285
+ return "\n".join(lines) + "\n"
286
+
287
+
288
+ def main(argv=None):
289
+ ap = argparse.ArgumentParser(prog="python -m loadpath.evals.briefs", description=__doc__.split("\n\n")[0])
290
+ sub = ap.add_subparsers(dest="cmd", required=True)
291
+ sub.add_parser("list")
292
+ g = sub.add_parser("gold")
293
+ g.add_argument("ids", nargs="*")
294
+ p = sub.add_parser("prompt")
295
+ p.add_argument("id")
296
+ s = sub.add_parser("score")
297
+ s.add_argument("id")
298
+ s.add_argument("path", help="the agent's folder (any kind), or part.stl (a study brief)")
299
+ r = sub.add_parser("run")
300
+ r.add_argument("ids", nargs="*")
301
+ r.add_argument("--out", default="briefs-out")
302
+ r.add_argument("--jobs", type=int, default=4)
303
+ r.add_argument("--agent", default="claude")
304
+ r.add_argument("--model")
305
+ r.add_argument("--timeout", type=float, default=3600)
306
+ r.add_argument("--max-elements", type=int, default=20000)
307
+ r.add_argument("--threads", type=int, default=2)
308
+ pr = sub.add_parser("prepare")
309
+ pr.add_argument("ids", nargs="*")
310
+ pr.add_argument("--out", default="briefs-out")
311
+ pr.add_argument("--max-elements", type=int, default=20000)
312
+ t = sub.add_parser("table")
313
+ t.add_argument("out")
314
+ t.add_argument("--rescore", action="store_true")
315
+ a = ap.parse_args(argv)
316
+
317
+ if a.cmd == "list":
318
+ for i in all_ids():
319
+ b = load(i)
320
+ print(f"{i:28s} {','.join(b.get('tags', []))}")
321
+ return 0
322
+ if a.cmd == "gold":
323
+ from ..tasks import check_study
324
+ bad = 0
325
+ for i in a.ids or all_ids():
326
+ b = load(i)
327
+ mod = kind(b)
328
+ res = mod.gold(b) if mod is not None else check_study(b["gold"], base_dir=ASSETS)
329
+ bad += not res["ok"]
330
+ print(i, "ok" if res["ok"] else res["error"], res.get("cells", ""), "; ".join(res.get("warnings", []))[:300])
331
+ return bad
332
+ if a.cmd == "prompt":
333
+ print(prompt(load(a.id)))
334
+ return 0
335
+ if a.cmd == "score":
336
+ b = load(a.id)
337
+ if os.path.isdir(a.path):
338
+ row = score_dir(b, a.path)
339
+ else:
340
+ row = score(b, a.path)
341
+ print(json.dumps(row, indent=1))
342
+ return 0
343
+ if a.cmd == "run":
344
+ os.makedirs(a.out, exist_ok=True)
345
+ ids = a.ids or all_ids()
346
+ rows = []
347
+
348
+ def one(i):
349
+ row = run_agent(load(i), a.out, a.agent, a.model, a.timeout, a.max_elements, a.threads)
350
+ print(json.dumps({k: row.get(k) for k in ("id", "pass", "failures", "mass", "safetyFactor", "turns", "seconds")}),
351
+ flush=True)
352
+ return row
353
+
354
+ with ThreadPoolExecutor(max_workers=a.jobs) as ex:
355
+ rows = list(ex.map(one, ids))
356
+ _write(a.out, rows)
357
+ return sum(not r["pass"] for r in rows)
358
+ if a.cmd == "prepare":
359
+ for i in a.ids or all_ids():
360
+ print(prepare(load(i), a.out, a.max_elements)[0])
361
+ return 0
362
+ if a.cmd == "table":
363
+ rows = []
364
+ for d in sorted(glob.glob(os.path.join(a.out, "*", "PROMPT.md"))):
365
+ d = os.path.dirname(d)
366
+ f = os.path.join(d, "score.json")
367
+ old = json.load(open(f)) if os.path.exists(f) else None
368
+ row = old
369
+ stl = os.path.join(d, "part.stl")
370
+ stale = old is not None and os.path.exists(stl) and os.path.getmtime(stl) > os.path.getmtime(f)
371
+ if old is None or a.rescore or stale:
372
+ row = score_dir(load(os.path.basename(d)), d)
373
+ row["studyWritten"] = os.path.exists(os.path.join(d, "study.json"))
374
+ if old:
375
+ row.update({k: old[k] for k in old if k not in row})
376
+ with open(f, "w") as fh:
377
+ json.dump(row, fh, indent=1)
378
+ rows.append(row)
379
+ _write(a.out, rows)
380
+ return sum(not r["pass"] for r in rows)
381
+
382
+
383
+ def _write(out, rows):
384
+ with open(os.path.join(out, "scorecard.json"), "w") as fh:
385
+ json.dump(rows, fh, indent=1)
386
+ md = table(rows)
387
+ with open(os.path.join(out, "scorecard.md"), "w") as fh:
388
+ fh.write(md)
389
+ print(md)
390
+
391
+
392
+ if __name__ == "__main__":
393
+ sys.exit(main())