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.
- loadpath/README.md +294 -0
- loadpath/SKILL.md +584 -0
- loadpath/__init__.py +17 -0
- loadpath/__main__.py +1202 -0
- loadpath/am.py +316 -0
- loadpath/amgfast.py +326 -0
- loadpath/assembly.py +985 -0
- loadpath/benchmarks/__init__.py +0 -0
- loadpath/benchmarks/beams.py +266 -0
- loadpath/benchmarks/cases/ge-bracket.json +380 -0
- loadpath/benchmarks/evaluate.py +172 -0
- loadpath/benchmarks/mechanisms.py +416 -0
- loadpath/benchmarks/problems.py +140 -0
- loadpath/benchmarks/reference/bridge-3d.top3d.npz +0 -0
- loadpath/benchmarks/reference/cantilever-3d.top3d.npz +0 -0
- loadpath/benchmarks/reference/lbracket-2d.top88-ft2.npz +0 -0
- loadpath/benchmarks/reference/mbb-2d.top88-ft1.npz +0 -0
- loadpath/benchmarks/reference/mbb-2d.top88-ft2.npz +0 -0
- loadpath/benchmarks/reference.py +138 -0
- loadpath/benchmarks/run.py +301 -0
- loadpath/brep.py +1470 -0
- loadpath/bucklingopt.py +300 -0
- loadpath/buildsim.py +1490 -0
- loadpath/cadimport.py +904 -0
- loadpath/calibration.py +885 -0
- loadpath/cavities.py +211 -0
- loadpath/checks/__init__.py +0 -0
- loadpath/checks/dfm.py +354 -0
- loadpath/checks/exchanger.py +7 -0
- loadpath/checks/fea.py +212 -0
- loadpath/checks/fit.py +475 -0
- loadpath/checks/networks.py +260 -0
- loadpath/checks/opening.py +67 -0
- loadpath/checks/sdf_kit.py +7 -0
- loadpath/conjugate.py +267 -0
- loadpath/constrained.py +329 -0
- loadpath/contact.py +691 -0
- loadpath/coopt.py +342 -0
- loadpath/cores.py +163 -0
- loadpath/crease.py +1138 -0
- loadpath/critic.py +212 -0
- loadpath/crosscheck.py +1107 -0
- loadpath/cutcell.py +510 -0
- loadpath/data/lattice_buckling.json +439 -0
- loadpath/data/lattice_tensors.json +27449 -0
- loadpath/data/published_experiments.json +71 -0
- loadpath/decimate.py +177 -0
- loadpath/delivery.py +190 -0
- loadpath/directions.py +262 -0
- loadpath/domain.py +630 -0
- loadpath/drains.py +615 -0
- loadpath/durability.py +413 -0
- loadpath/editor.html +3135 -0
- loadpath/editor.py +610 -0
- loadpath/escape.py +354 -0
- loadpath/evals/__init__.py +0 -0
- loadpath/evals/baselines/coarse.json +1543 -0
- loadpath/evals/baselines/full.json +1497 -0
- loadpath/evals/brief_kinds/__init__.py +1 -0
- loadpath/evals/brief_kinds/heatx.py +387 -0
- loadpath/evals/brief_kinds/lattice.py +571 -0
- loadpath/evals/brief_kinds/multimat.py +593 -0
- loadpath/evals/briefs/assets/bracket.step +1201 -0
- loadpath/evals/briefs/assets/desk_arm.urdf +22 -0
- loadpath/evals/briefs/assets/elbow-gearbox.stl +0 -0
- loadpath/evals/briefs/assets/motor-body.stl +0 -0
- loadpath/evals/briefs/assets/sketch.png +0 -0
- loadpath/evals/briefs/base-adapter-fdm.json +492 -0
- loadpath/evals/briefs/bell-crank-lpbf.json +180 -0
- loadpath/evals/briefs/belt-tensioner-fdm.json +151 -0
- loadpath/evals/briefs/camera-boom-mjf.json +339 -0
- loadpath/evals/briefs/camera-mount-pla.json +203 -0
- loadpath/evals/briefs/coolant-isolator-petg.json +100 -0
- loadpath/evals/briefs/elbow-bearing-bracket-sls.json +300 -0
- loadpath/evals/briefs/elbow-gearbox-forearm-mjf.json +558 -0
- loadpath/evals/briefs/elbow-link-fatigue-fdm.json +251 -0
- loadpath/evals/briefs/elbow-stop-pad-tpu.json +39 -0
- loadpath/evals/briefs/forearm-fdm.json +146 -0
- loadpath/evals/briefs/forearm-servo-link-fdm.json +327 -0
- loadpath/evals/briefs/grip-pad-finger-mm.json +354 -0
- loadpath/evals/briefs/gripper-bracket-creep-fdm.json +340 -0
- loadpath/evals/briefs/gripper-finger-fdm.json +155 -0
- loadpath/evals/briefs/gripper-jaw-fatigue-mjf.json +328 -0
- loadpath/evals/briefs/gripper-palm-fdm.json +715 -0
- loadpath/evals/briefs/hot-stepper-mount-asa-fdm.json +471 -0
- loadpath/evals/briefs/hpu-oil-cooler-lpbf.json +121 -0
- loadpath/evals/briefs/isolated-camera-mount-mm.json +675 -0
- loadpath/evals/briefs/kart-charge-cooler-lpbf.json +93 -0
- loadpath/evals/briefs/lighten-step-bracket.json +220 -0
- loadpath/evals/briefs/nema17-mount-stl.json +429 -0
- loadpath/evals/briefs/pillow-block-sls.json +220 -0
- loadpath/evals/briefs/polyjet-fingertip-mm.json +305 -0
- loadpath/evals/briefs/rod-hook-pla.json +154 -0
- loadpath/evals/briefs/sensor-arm-resin.json +150 -0
- loadpath/evals/briefs/shelf-bracket-pla.json +158 -0
- loadpath/evals/briefs/shoulder-riser-mjf.json +487 -0
- loadpath/evals/briefs/sketch-clevis.json +229 -0
- loadpath/evals/briefs/spring-bracket-pacf.json +294 -0
- loadpath/evals/briefs/tool-flange-ti64.json +280 -0
- loadpath/evals/briefs/tray-arm-lattice-fdm.json +260 -0
- loadpath/evals/briefs/turret-fdm.json +281 -0
- loadpath/evals/briefs/upper-arm-sls.json +179 -0
- loadpath/evals/briefs/urdf-forearm.json +233 -0
- loadpath/evals/briefs/wrist-link-mjf.json +278 -0
- loadpath/evals/briefs/wrist-yoke-mjf.json +245 -0
- loadpath/evals/briefs.py +393 -0
- loadpath/evals/cases/arm-link-cast.json +157 -0
- loadpath/evals/cases/arm-link-driven.json +171 -0
- loadpath/evals/cases/arm-link-sandwich.json +315 -0
- loadpath/evals/cases/arm-link.json +111 -0
- loadpath/evals/cases/bell-crank.json +116 -0
- loadpath/evals/cases/grip-turret.json +479 -0
- loadpath/evals/cases/grip-upper-arm.json +253 -0
- loadpath/evals/cases/grip-wrist-link-mjf.json +929 -0
- loadpath/evals/cases/grip-wrist-yoke.json +831 -0
- loadpath/evals/cases/lpbf-intercooler-core.json +59 -0
- loadpath/evals/cases/mbb-beam.json +61 -0
- loadpath/evals/cases/motor-body.stl +0 -0
- loadpath/evals/cases/motor-mount.json +265 -0
- loadpath/evals/cases/printed-hook.json +77 -0
- loadpath/evals/cases/wall-bracket-fasteners.json +125 -0
- loadpath/evals/cases/wall-bracket.json +126 -0
- loadpath/evals/corpus.py +179 -0
- loadpath/evals/pareto.py +90 -0
- loadpath/evals/run.py +860 -0
- loadpath/examples/arm-link.json +25 -0
- loadpath/examples/bumpered-bracket-compression.json +225 -0
- loadpath/examples/bumpered-bracket-lattice.json +204 -0
- loadpath/examples/bumpered-bracket.json +34 -0
- loadpath/examples/gripper-finger-mm.json +185 -0
- loadpath/examples/heat-exchanger-counterflow.json +21 -0
- loadpath/examples/heat-exchanger-crossflow-tuned.json +48 -0
- loadpath/examples/heat-exchanger-crossflow.json +20 -0
- loadpath/examples/pedal-lattice.json +23 -0
- loadpath/examples/pressure-plenum.json +12 -0
- loadpath/examples/shelf-bracket.json +27 -0
- loadpath/exchanger.py +831 -0
- loadpath/fasteners.py +199 -0
- loadpath/fem.py +668 -0
- loadpath/femops.py +70 -0
- loadpath/fieldjit.py +981 -0
- loadpath/fieldops.py +119 -0
- loadpath/fields.py +3697 -0
- loadpath/fieldtorch.py +464 -0
- loadpath/flow.py +984 -0
- loadpath/flowtopo.py +300 -0
- loadpath/gpu.py +521 -0
- loadpath/heatx.py +1067 -0
- loadpath/heatx_thermal.py +432 -0
- loadpath/homogenized.py +886 -0
- loadpath/ibrep.py +552 -0
- loadpath/impact.py +693 -0
- loadpath/infill.py +376 -0
- loadpath/interface_library.py +340 -0
- loadpath/interlock.py +186 -0
- loadpath/isolator.py +149 -0
- loadpath/knn.py +204 -0
- loadpath/lattice.py +1065 -0
- loadpath/lattice3mf.py +760 -0
- loadpath/lattice_buckling.py +245 -0
- loadpath/lattice_grade.py +325 -0
- loadpath/lattice_orient.py +595 -0
- loadpath/lattice_print.py +159 -0
- loadpath/lattice_tensors.py +558 -0
- loadpath/latticestep.py +1210 -0
- loadpath/manufacture.py +446 -0
- loadpath/material_library.py +489 -0
- loadpath/materials.py +144 -0
- loadpath/mcp_server.py +786 -0
- loadpath/mechanism.py +218 -0
- loadpath/mechanism_nl.py +811 -0
- loadpath/meshops.py +452 -0
- loadpath/mg.py +853 -0
- loadpath/mma.py +214 -0
- loadpath/modal.py +210 -0
- loadpath/multimat.py +1788 -0
- loadpath/narrowband.py +418 -0
- loadpath/nlkernel.py +241 -0
- loadpath/nonlinear.py +1095 -0
- loadpath/orient.py +334 -0
- loadpath/porous.py +994 -0
- loadpath/pressure.py +171 -0
- loadpath/preview.py +335 -0
- loadpath/printcheck.py +263 -0
- loadpath/printsetup.py +207 -0
- loadpath/processcheck.py +376 -0
- loadpath/quick.py +113 -0
- loadpath/recall.py +406 -0
- loadpath/reconstruct.py +797 -0
- loadpath/render.py +436 -0
- loadpath/resolved.py +189 -0
- loadpath/schema.py +1701 -0
- loadpath/sdf_kit.py +843 -0
- loadpath/selfx.py +292 -0
- loadpath/shapes.py +584 -0
- loadpath/sharp.py +717 -0
- loadpath/simp.py +1170 -0
- loadpath/sizing.py +312 -0
- loadpath/slicer.py +321 -0
- loadpath/spec.py +1072 -0
- loadpath/step.py +806 -0
- loadpath/struts.py +639 -0
- loadpath/study.py +1937 -0
- loadpath/tasks.py +822 -0
- loadpath/thermal.py +1691 -0
- loadpath/threemf.py +487 -0
- loadpath/tune.py +437 -0
- loadpath/validation.py +145 -0
- loadpath/verify.py +1215 -0
- loadpath/viewer.html +694 -0
- loadpath/viewer.py +446 -0
- loadpath/viscoelastic.py +106 -0
- loadpath/voxelprint.py +582 -0
- loadpath/voxels.py +402 -0
- loadpath-0.1.1.dist-info/METADATA +319 -0
- loadpath-0.1.1.dist-info/RECORD +220 -0
- loadpath-0.1.1.dist-info/WHEEL +5 -0
- loadpath-0.1.1.dist-info/entry_points.txt +2 -0
- loadpath-0.1.1.dist-info/licenses/LICENSE +21 -0
- 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())
|