ecadlib 0.3.0__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 (199) hide show
  1. ecadlib/__init__.py +3 -0
  2. ecadlib/__main__.py +5 -0
  3. ecadlib/a365_sync.py +637 -0
  4. ecadlib/agent.py +1407 -0
  5. ecadlib/agent_queue.py +780 -0
  6. ecadlib/agent_watch.py +732 -0
  7. ecadlib/altium365.py +385 -0
  8. ecadlib/altium_dblib_sync.py +530 -0
  9. ecadlib/altium_export.py +209 -0
  10. ecadlib/altium_forms.py +768 -0
  11. ecadlib/altium_lib.py +1089 -0
  12. ecadlib/altium_menu.py +1751 -0
  13. ecadlib/altium_pkg.py +396 -0
  14. ecadlib/altium_project.py +1095 -0
  15. ecadlib/app.py +1458 -0
  16. ecadlib/approvals.py +395 -0
  17. ecadlib/assets/demo_bom.csv +8 -0
  18. ecadlib/assets/kicad_action_plugin.py +607 -0
  19. ecadlib/assets/kicad_reread.py +60 -0
  20. ecadlib/assets/pcblib_template.json.gz +0 -0
  21. ecadlib/bom_inputs.py +339 -0
  22. ecadlib/bom_triage.py +519 -0
  23. ecadlib/cad_model.py +528 -0
  24. ecadlib/cfb.py +171 -0
  25. ecadlib/cli.py +185 -0
  26. ecadlib/clihelp.py +149 -0
  27. ecadlib/cliutil.py +119 -0
  28. ecadlib/completion.py +173 -0
  29. ecadlib/costs.py +276 -0
  30. ecadlib/crosscheck.py +412 -0
  31. ecadlib/datasheet.py +2168 -0
  32. ecadlib/dedupe.py +572 -0
  33. ecadlib/detect.py +170 -0
  34. ecadlib/doctor.py +450 -0
  35. ecadlib/ds_route.py +541 -0
  36. ecadlib/eagle_export.py +1409 -0
  37. ecadlib/easy_setup.py +338 -0
  38. ecadlib/easyeda_convert.py +386 -0
  39. ecadlib/easyeda_pro_export.py +972 -0
  40. ecadlib/eval/__init__.py +0 -0
  41. ecadlib/eval/baselines/kicad-v1.json +404 -0
  42. ecadlib/eval/build_set.py +299 -0
  43. ecadlib/eval/cli.py +119 -0
  44. ecadlib/eval/gate.py +358 -0
  45. ecadlib/eval/golden/demo.txt +379 -0
  46. ecadlib/eval/golden/specs.txt +3120 -0
  47. ecadlib/eval/golden/testpack.txt +463 -0
  48. ecadlib/eval/golden.py +446 -0
  49. ecadlib/eval/replay/kicad-v1.json.gz +0 -0
  50. ecadlib/eval/runner.py +456 -0
  51. ecadlib/eval/score.py +179 -0
  52. ecadlib/eval/sets/kicad-v1.json +630 -0
  53. ecadlib/firstrun.py +400 -0
  54. ecadlib/footprint_adv.py +1006 -0
  55. ecadlib/footprint_bench.py +218 -0
  56. ecadlib/footprint_check.py +562 -0
  57. ecadlib/footprint_gen.py +582 -0
  58. ecadlib/footprint_rules.py +1092 -0
  59. ecadlib/forge.py +267 -0
  60. ecadlib/fp_share.py +95 -0
  61. ecadlib/fp_tidy.py +389 -0
  62. ecadlib/fsutil.py +62 -0
  63. ecadlib/house_style.py +1051 -0
  64. ecadlib/httpcache.py +124 -0
  65. ecadlib/hub.py +1490 -0
  66. ecadlib/hub_copy.py +161 -0
  67. ecadlib/hub_tools.py +79 -0
  68. ecadlib/kicad_dblib.py +257 -0
  69. ecadlib/kicad_export.py +837 -0
  70. ecadlib/kicad_import.py +913 -0
  71. ecadlib/kicad_index.py +160 -0
  72. ecadlib/kicad_menu.py +193 -0
  73. ecadlib/kicad_pcm.py +475 -0
  74. ecadlib/launcher.py +156 -0
  75. ecadlib/librarian.py +963 -0
  76. ecadlib/lifecycle.py +691 -0
  77. ecadlib/llm/__init__.py +276 -0
  78. ecadlib/llm/cli.py +70 -0
  79. ecadlib/llm/openai_compat.py +214 -0
  80. ecadlib/llm/profiles.py +245 -0
  81. ecadlib/llm/rails.py +570 -0
  82. ecadlib/llm/run.py +126 -0
  83. ecadlib/llm/sampling.py +49 -0
  84. ecadlib/mcp_server.py +723 -0
  85. ecadlib/messages.py +419 -0
  86. ecadlib/migrate.py +407 -0
  87. ecadlib/notify.py +329 -0
  88. ecadlib/package_schema.json +1238 -0
  89. ecadlib/package_specs/CP_EIA-3216-18_Kemet-A.json +27 -0
  90. ecadlib/package_specs/C_0402_1005Metric.json +22 -0
  91. ecadlib/package_specs/C_0805_2012Metric.json +22 -0
  92. ecadlib/package_specs/DFN-8-1EP_3x3mm_P0.5mm_EP1.66x2.38mm.json +40 -0
  93. ecadlib/package_specs/D_SMA.json +27 -0
  94. ecadlib/package_specs/D_SMB.json +27 -0
  95. ecadlib/package_specs/D_SOD-123.json +43 -0
  96. ecadlib/package_specs/D_SOD-323.json +43 -0
  97. ecadlib/package_specs/LED_0603_1608Metric.json +23 -0
  98. ecadlib/package_specs/LED_0805_2012Metric.json +23 -0
  99. ecadlib/package_specs/LQFP-48_7x7mm_P0.5mm.json +31 -0
  100. ecadlib/package_specs/L_0805_2012Metric.json +22 -0
  101. ecadlib/package_specs/MSOP-8_3x3mm_P0.65mm.json +31 -0
  102. ecadlib/package_specs/QFN-16-1EP_3x3mm_P0.5mm_EP1.75x1.75mm.json +39 -0
  103. ecadlib/package_specs/QFN-32-1EP_5x5mm_P0.5mm_EP3.45x3.45mm.json +37 -0
  104. ecadlib/package_specs/R_0402_1005Metric.json +22 -0
  105. ecadlib/package_specs/R_0603_1608Metric.json +22 -0
  106. ecadlib/package_specs/R_0805_2012Metric.json +22 -0
  107. ecadlib/package_specs/R_1206_3216Metric.json +22 -0
  108. ecadlib/package_specs/SOIC-16_3.9x9.9mm_P1.27mm.json +32 -0
  109. ecadlib/package_specs/SOIC-8_3.9x4.9mm_P1.27mm.json +32 -0
  110. ecadlib/package_specs/SOT-223-3_TabPin2.json +56 -0
  111. ecadlib/package_specs/SOT-23-5.json +57 -0
  112. ecadlib/package_specs/SOT-23-6.json +62 -0
  113. ecadlib/package_specs/SOT-23.json +48 -0
  114. ecadlib/package_specs/TSSOP-16_4.4x5mm_P0.65mm.json +31 -0
  115. ecadlib/package_specs/VQFN-16-1EP_3x3mm_P0.5mm_EP1.68x1.68mm.json +39 -0
  116. ecadlib/package_specs/advanced/BGA-100_11.0x11.0mm_Layout10x10_P1.0mm_Ball0.5mm_Pad0.4mm_NSMD.json +26 -0
  117. ecadlib/package_specs/advanced/BGA-121_9.0x9.0mm_Layout11x11_P0.8mm_Ball0.4mm_Pad0.35mm_NSMD.json +26 -0
  118. ecadlib/package_specs/advanced/BGA-144_7.0x7.0mm_Layout13x13_P0.5mm_Ball0.3mm_Pad0.25mm_NSMD.json +29 -0
  119. ecadlib/package_specs/advanced/BGA-169_11.0x11.0mm_Layout13x13_P0.8mm_Ball0.5mm_Pad0.4mm_NSMD.json +23 -0
  120. ecadlib/package_specs/advanced/BGA-256_11.0x11.0mm_Layout20x20_P0.5mm_Ball0.3mm_Pad0.25mm_NSMD.json +26 -0
  121. ecadlib/package_specs/advanced/BGA-256_17.0x17.0mm_Layout16x16_P1.0mm_Ball0.5mm_Pad0.4mm_NSMD.json +26 -0
  122. ecadlib/package_specs/advanced/BGA-324_15.0x15.0mm_Layout18x18_P0.8mm_Ball0.5mm_Pad0.4mm_NSMD.json +26 -0
  123. ecadlib/package_specs/advanced/BGA-36_3.396x3.466mm_Layout6x6_P0.4mm_Ball0.25mm_Pad0.2mm_NSMD.json +24 -0
  124. ecadlib/package_specs/advanced/BGA-672_27.0x27.0mm_Layout26x26_P1.0mm_Ball0.6mm_Pad0.5mm_NSMD.json +32 -0
  125. ecadlib/package_specs/advanced/BGA-68_5.0x5.0mm_Layout9x9_P0.5mm_Ball0.3mm_Pad0.25mm_NSMD.json +30 -0
  126. ecadlib/package_specs/advanced/BGA-90_8.0x13.0mm_Layout2x3x15_P0.8mm.json +26 -0
  127. ecadlib/package_specs/advanced/DFN-8-1EP_3x3mm_P0.5mm_EP1.65x2.38mm_ThermalVias.json +60 -0
  128. ecadlib/package_specs/advanced/Example_Custom_LPad_Slot.json +24 -0
  129. ecadlib/package_specs/advanced/JST_GH_SM04B-GHS-TB_1x04-1MP_P1.25mm_Horizontal.json +49 -0
  130. ecadlib/package_specs/advanced/JST_PH_B4B-PH-K_1x04_P2.00mm_Vertical.json +33 -0
  131. ecadlib/package_specs/advanced/JST_PH_S4B-PH-SM4-TB_1x04-1MP_P2.00mm_Horizontal.json +55 -0
  132. ecadlib/package_specs/advanced/JST_SH_SM04B-SRSS-TB_1x04-1MP_P1.00mm_Horizontal.json +49 -0
  133. ecadlib/package_specs/advanced/JST_XH_B2B-XH-A_1x02_P2.50mm_Vertical.json +34 -0
  134. ecadlib/package_specs/advanced/JST_XH_B4B-XH-A_1x04_P2.50mm_Vertical.json +34 -0
  135. ecadlib/package_specs/advanced/JST_XH_B6B-XH-A_1x06_P2.50mm_Vertical.json +33 -0
  136. ecadlib/package_specs/advanced/LFBGA-144_10x10mm_Layout12x12_P0.8mm.json +23 -0
  137. ecadlib/package_specs/advanced/Molex_KK-254_AE-6410-04A_1x04_P2.54mm_Vertical.json +34 -0
  138. ecadlib/package_specs/advanced/Molex_PicoBlade_53398-0471_1x04-1MP_P1.25mm_Vertical.json +54 -0
  139. ecadlib/package_specs/advanced/PinHeader_1x05_P1.00mm_Vertical_SMD_Pin1Left.json +14 -0
  140. ecadlib/package_specs/advanced/PinHeader_1x05_P1.27mm_Vertical_SMD_Pin1Left.json +14 -0
  141. ecadlib/package_specs/advanced/PinHeader_1x05_P2.00mm_Vertical.json +12 -0
  142. ecadlib/package_specs/advanced/PinHeader_1x05_P2.00mm_Vertical_SMD_Pin1Left.json +14 -0
  143. ecadlib/package_specs/advanced/PinHeader_1x05_P2.54mm_Vertical.json +12 -0
  144. ecadlib/package_specs/advanced/PinHeader_1x05_P2.54mm_Vertical_SMD_Pin1Left.json +25 -0
  145. ecadlib/package_specs/advanced/PinHeader_1x05_P2.54mm_Vertical_SMD_Pin1Right.json +14 -0
  146. ecadlib/package_specs/advanced/PinHeader_1x10_P1.00mm_Vertical.json +15 -0
  147. ecadlib/package_specs/advanced/PinHeader_2x05_P1.00mm_Vertical_SMD.json +13 -0
  148. ecadlib/package_specs/advanced/PinHeader_2x05_P1.27mm_Vertical.json +18 -0
  149. ecadlib/package_specs/advanced/PinHeader_2x05_P1.27mm_Vertical_SMD.json +24 -0
  150. ecadlib/package_specs/advanced/PinHeader_2x05_P2.00mm_Vertical_SMD.json +13 -0
  151. ecadlib/package_specs/advanced/PinHeader_2x05_P2.54mm_Vertical.json +12 -0
  152. ecadlib/package_specs/advanced/PinHeader_2x05_P2.54mm_Vertical_SMD.json +24 -0
  153. ecadlib/package_specs/advanced/PinHeader_2x20_P2.54mm_Vertical.json +12 -0
  154. ecadlib/package_specs/advanced/QFN-16-1EP_3x3mm_P0.5mm_EP1.75x1.75mm_ThermalVias.json +87 -0
  155. ecadlib/package_specs/advanced/UFBGA-132_7x7mm_Layout12x12_P0.5mm.json +43 -0
  156. ecadlib/package_specs/advanced/USB_C_Receptacle_HRO_TYPE-C-31-M-12.json +218 -0
  157. ecadlib/package_specs/standard.json.gz +0 -0
  158. ecadlib/parity.py +1204 -0
  159. ecadlib/parts_export.py +154 -0
  160. ecadlib/partview.py +215 -0
  161. ecadlib/plugins.py +231 -0
  162. ecadlib/providers.py +226 -0
  163. ecadlib/rebuild.py +471 -0
  164. ecadlib/release_check.py +437 -0
  165. ecadlib/reports.py +274 -0
  166. ecadlib/resolve.py +556 -0
  167. ecadlib/review_cli.py +292 -0
  168. ecadlib/rules.py +979 -0
  169. ecadlib/rules_infer.py +1205 -0
  170. ecadlib/sexpr.py +149 -0
  171. ecadlib/sheetsafe.py +65 -0
  172. ecadlib/sim_models.py +1087 -0
  173. ecadlib/sourcing.py +1161 -0
  174. ecadlib/stdparts.py +755 -0
  175. ecadlib/step_gen.py +929 -0
  176. ecadlib/symbol_layout.py +798 -0
  177. ecadlib/team.py +558 -0
  178. ecadlib/team_setup.py +288 -0
  179. ecadlib/term.py +420 -0
  180. ecadlib/term_views.py +64 -0
  181. ecadlib/testpack.py +334 -0
  182. ecadlib/ui/__init__.py +1 -0
  183. ecadlib/ui/__main__.py +3 -0
  184. ecadlib/ui/server.py +1029 -0
  185. ecadlib/ui/static/hairline.js +2 -0
  186. ecadlib/ui/static/index.html +2098 -0
  187. ecadlib/ui/static/setup.html +294 -0
  188. ecadlib/upkeep.py +717 -0
  189. ecadlib/upstream.py +573 -0
  190. ecadlib/userconfig.py +257 -0
  191. ecadlib/visual_check.py +1603 -0
  192. ecadlib/ws_status.py +203 -0
  193. ecadlib-0.3.0.dist-info/METADATA +2245 -0
  194. ecadlib-0.3.0.dist-info/RECORD +199 -0
  195. ecadlib-0.3.0.dist-info/WHEEL +5 -0
  196. ecadlib-0.3.0.dist-info/entry_points.txt +2 -0
  197. ecadlib-0.3.0.dist-info/licenses/LICENSE +21 -0
  198. ecadlib-0.3.0.dist-info/licenses/NOTICE +53 -0
  199. ecadlib-0.3.0.dist-info/top_level.txt +1 -0
ecadlib/eval/gate.py ADDED
@@ -0,0 +1,358 @@
1
+ """Accuracy gate for CI: the 331-case eval set, offline, against a committed baseline (`ecadlib eval gate`).
2
+
3
+ ecadlib eval gate run and check against ecadlib/eval/baselines/kicad-v1.json (exit 1 on a drop)
4
+ ecadlib eval gate --update run and rewrite the baseline (commit it: a deliberate change is one reviewed diff)
5
+ ecadlib eval gate --make-replay rebuild the committed replay pack (ecadlib/eval/replay/kicad-v1.json.gz)
6
+
7
+ What runs: `eval run` with sources `standard,datasheet`, no network at all (every HTTP request is refused and counted)
8
+ and the datasheet step answered from the replay pack. The pack is SYNTHETIC, not a model's reading: for every case it
9
+ holds the answer a perfect reader would give, in the Messages API shape the reader validates (strict output schema):
10
+ * packages in the standard table (ecadlib/package_specs/standard.json.gz: gullwing, no-lead, QFP, chip) as printed
11
+ dimensions (min / nom / max in mm, labelled like a JEDEC drawing): the IPC-7351B generator builds the land;
12
+ * every other package (BGA, connectors, THT, power, hand-drawn standard parts) as the land-pattern table (pad_table)
13
+ copied from the case's reference pads, with the courtyard box as the body outline;
14
+ * pins: the case's pin numbers and names.
15
+ So the gate measures the pipeline (reader checks, spec conversion, footprint generator, pad tables, symbol writer,
16
+ librarian, scorer), not a model. A real model's accuracy is `eval run --record` / `--replay` with a key.
17
+
18
+ Fails when (a) a family's pass rate drops more than --tol (default 0.01) below the baseline, or (b) a case that passes
19
+ in the baseline now fails. New passes are reported; `--update` locks them in."""
20
+ import argparse, copy, datetime, gzip, json, sys, tempfile, time
21
+ from pathlib import Path
22
+
23
+ from .. import rules as RU
24
+ from . import runner as R
25
+
26
+ HERE = Path(__file__).parent
27
+ BASELINES, REPLAYS = HERE / "baselines", HERE / "replay"
28
+ SOURCES = ["standard", "datasheet"]
29
+ MODEL = "claude-opus-5-5" # the model named in the synthetic messages (the default; frontier tier, no extra rails)
30
+ LABELS = {"pitch": "e", "body_length": "D", "body_width": "E1", "lead_span": "E", "terminal_length": "L",
31
+ "terminal_width": "b", "height_max": "A", "inner_span": "E3", "pitch_y": "e1", "ball_diameter": "b",
32
+ "land_diameter": "b1", "drill": "d", "lead_span_y": "D1", "courtyard_excess": "CE"}
33
+ TYPED = ("gullwing", "qfn", "qfp", "chip")
34
+
35
+
36
+ def baseline_path(set_name="kicad-v1"):
37
+ return BASELINES / f"{Path(set_name).stem}.json"
38
+
39
+
40
+ def replay_path(set_name="kicad-v1"):
41
+ return REPLAYS / f"{Path(set_name).stem}.json.gz"
42
+
43
+
44
+ # ---------------------------------------------------------------- synthetic answers
45
+ def _complete(v, sch):
46
+ """Fill every property strict structured outputs would return (null when the schema allows it), recursively."""
47
+ alts = sch.get("anyOf")
48
+ if alts:
49
+ if v is None:
50
+ return None
51
+ for a in alts:
52
+ if a.get("type") == "null":
53
+ continue
54
+ if (a.get("type") == "object") == isinstance(v, dict) and (a.get("type") == "array") == isinstance(v, list):
55
+ return _complete(v, a)
56
+ return v
57
+ if sch.get("type") == "object" and isinstance(v, dict):
58
+ return {k: _complete(v.get(k), s) for k, s in sch.get("properties", {}).items()}
59
+ if sch.get("type") == "array" and isinstance(v, list) and "items" in sch:
60
+ return [_complete(x, sch["items"]) for x in v]
61
+ return v
62
+
63
+
64
+ def _dim(key, v):
65
+ if v is None:
66
+ return None
67
+ if isinstance(v, (int, float)):
68
+ mn = mx = None
69
+ nom = v
70
+ if key == "height_max":
71
+ nom, mx = None, v
72
+ elif "tol" in v:
73
+ nom, mn, mx = v["nom"], round(v["nom"] - v["tol"], 4), round(v["nom"] + v["tol"], 4)
74
+ else:
75
+ mn, nom, mx = v.get("min"), v.get("nom"), v.get("max")
76
+ return {"label": LABELS.get(key, key), "min": mn, "nom": nom, "max": mx, "unit": "mm", "page": 1, "figure": "package outline"}
77
+
78
+
79
+ def _typed(name, e):
80
+ from .. import datasheet as D
81
+ pkg = {"name": name, "description": f"{name} (synthetic answer from the standard table)", "package_type": e["package_type"],
82
+ "unresolved": [], "drawing": "synthetic"}
83
+ for k, v in e.items():
84
+ if k in ("lib", "source", "name", "package_type"):
85
+ continue
86
+ if k in D.DIMS or k in D.NUM_DIMS:
87
+ pkg[k] = _dim(k, v)
88
+ elif k == "exposed_pad":
89
+ pkg[k] = {d: _dim(f"exposed_pad.{d}", v[d]) for d in ("length", "width")}
90
+ else:
91
+ pkg[k] = copy.deepcopy(v)
92
+ return pkg
93
+
94
+
95
+ def _pin_count(pt):
96
+ """Distinct numbered pins of the table as footprint_adv lays it out (an exposed pad split in parts counts once;
97
+ mounting / shield pads and holes do not), the number a drawing states."""
98
+ from .. import footprint_adv as FA
99
+ try:
100
+ pads = FA.table_layout({"name": "x", "package_type": "custom", "pad_table": copy.deepcopy(pt)})[0]
101
+ except Exception: # a table the layout rejects: no count (the reader's run reports why)
102
+ return None
103
+ return len({p["num"] for p in pads if p["role"] in FA.PIN_ROLES}) or None
104
+
105
+
106
+ def _table(name, ref):
107
+ """The reference pads as the land-pattern table a reader copies from a drawing (KiCad frame: mm, +Y down)."""
108
+ pads, xs, ys = [], [], []
109
+ for num, x, y, w, h, drill in ref["pads"]:
110
+ p = {"number": num, "x": x, "y": y, "w": w, "h": h}
111
+ if drill:
112
+ p["type"], p["drill"] = ("tht" if num else "npth"), drill
113
+ pads.append(p)
114
+ xs += [x]; ys += [y]
115
+ tht = any(p.get("type") == "tht" for p in pads)
116
+ c = ref.get("courtyard") or []
117
+ body = {"x1": c[0] + 0.25, "y1": c[1] + 0.25, "x2": c[2] - 0.25, "y2": c[3] - 0.25} if len(c) == 4 else None
118
+ pt = {"unit": "mm", "y_axis": "down", "view": "top", "origin": "datum",
119
+ "center_at": {"x": round((min(xs) + max(xs)) / 2, 4), "y": round((min(ys) + max(ys)) / 2, 4)},
120
+ "footprint_origin": "pin1" if tht else "center", "pads": pads}
121
+ if body:
122
+ pt["body"] = {k: round(v, 4) for k, v in body.items()}
123
+ return {"name": name, "description": f"{name} (synthetic answer from the reference land)", "package_type": "custom",
124
+ "pin_count": _pin_count(pt), "pad_table": pt, "courtyard_excess": _dim("courtyard_excess", 0.25),
125
+ "mount": "tht" if tht else None, "unresolved": [], "drawing": "synthetic"}
126
+
127
+
128
+ def synth_answer(case, ref, table=None):
129
+ """One case -> the extraction (datasheet output schema) a perfect reader would return."""
130
+ from .. import datasheet as D, stdparts
131
+ table = stdparts.packages() if table is None else table
132
+ name = case["footprint"].split(":", 1)[1]
133
+ e = table.get(name)
134
+ pkg = _typed(name, e) if e and e["package_type"] in TYPED else _table(name, ref)
135
+ fam = case["family"]
136
+ n = len(case["pins"])
137
+ pins = [{"num": str(num), "name": nm, "type": "passive" if fam in ("chip", "diode") else "io",
138
+ "side": "left" if i < (n + 1) // 2 else "right", "page": 1} for i, (num, nm) in enumerate(case["pins"])]
139
+ ext = {"package": pkg, "pin_table": {"page": 1, "figure": "pin table", "rows": n}, "pins": pins,
140
+ "symbol_description": f"{case['description']} (synthetic)", "notes": [], "embedded_instructions": []}
141
+ return _complete(ext, D.output_schema())
142
+
143
+
144
+ def synth_check(ext):
145
+ """The perfect answer to the cross-check's second read (ecadlib.crosscheck: only the critical numbers and the pins),
146
+ from the same drawing as the first answer: same dimensions, same pin count (a pad table's own convention), same pins."""
147
+ from .. import crosscheck as CC
148
+ p = ext["package"]
149
+ pkg = {k: copy.deepcopy(p.get(k)) for k in ("name", "package_type", "pin_count", "pitch", "body_length", "body_width",
150
+ "lead_span", "exposed_pad")}
151
+ pkg["unresolved"] = []
152
+ chk = {"package": pkg, "pins": [{"num": q["num"], "name": q["name"]} for q in ext["pins"]], "embedded_instructions": []}
153
+ return _complete(chk, CC.output_schema())
154
+
155
+
156
+ def make_replay(set_name="kicad-v1", out=None):
157
+ """Every case of the set -> its synthetic answer, in one compact gzip JSON (deterministic: same input, same bytes)."""
158
+ data = R.load_set(set_name)
159
+ answers = {c["mpn"]: synth_answer(c, data["references"][c["footprint"]]) for c in data["cases"]}
160
+ pack = {"meta": {"set": data["meta"]["name"], "set_version": data["meta"].get("version"), "model": MODEL,
161
+ "about": "SYNTHETIC answers (ecadlib/eval/gate.py synth_answer), not a model's reading",
162
+ "answers": len(answers)}, "answers": dict(sorted(answers.items())),
163
+ "checks": {k: synth_check(v) for k, v in sorted(answers.items())}}
164
+ out = Path(out or replay_path(set_name))
165
+ out.parent.mkdir(parents=True, exist_ok=True)
166
+ raw = json.dumps(pack, sort_keys=True, separators=(",", ":")).encode()
167
+ with open(out, "wb") as f, gzip.GzipFile(fileobj=f, mode="wb", mtime=0, filename="") as g:
168
+ g.write(raw)
169
+ return out
170
+
171
+
172
+ def load_replay(path):
173
+ with gzip.open(path, "rt", encoding="utf-8") as f:
174
+ return json.load(f)
175
+
176
+
177
+ def _message(model, ext):
178
+ return {"id": "msg_synthetic", "type": "message", "role": "assistant", "model": model,
179
+ "content": [{"type": "text", "text": json.dumps(ext)}], "stop_reason": "end_turn", "stop_sequence": None,
180
+ "usage": {"input_tokens": 0, "output_tokens": 0}}
181
+
182
+
183
+ def expand(pack, d):
184
+ """Replay pack -> one recording per part in d (the `eval run --replay` layout), with the cross-check's second read."""
185
+ d = Path(d)
186
+ d.mkdir(parents=True, exist_ok=True)
187
+ model, checks = pack["meta"]["model"], pack.get("checks") or {}
188
+ for part, ext in pack["answers"].items():
189
+ url = f"https://example.invalid/{R._safe(part)}.pdf"
190
+ rec = {"part": part, "model": model, "estimate": {"input_tokens": 0, "output_tokens": 0, "usd": 0.0, "how": "synthetic"},
191
+ "job": {"url": url, "where": "BOM", "offset": 0, "prov": {"url": url, "sent_as": "synthetic"}},
192
+ "message": _message(model, ext)}
193
+ if part in checks:
194
+ rec["check"] = {"model": model, "message": _message(model, checks[part])}
195
+ (d / f"{R._safe(part)}.json").write_text(json.dumps(rec), encoding="utf-8")
196
+ return d
197
+
198
+
199
+ class NoNetwork:
200
+ """HTTP stand-in for the gate: every request is refused (and counted); the run must not depend on the network."""
201
+ offline = True
202
+
203
+ def __init__(self):
204
+ self.calls, self.log = [], []
205
+
206
+ def _no(self, method, url):
207
+ from ..httpcache import Response
208
+ self.calls.append((method, url))
209
+ return Response(0, "", error="eval gate: no network")
210
+
211
+ def get(self, url, *a, **kw):
212
+ return self._no("GET", url)
213
+
214
+ def post(self, url, *a, **kw):
215
+ return self._no("POST", url)
216
+
217
+ def head(self, url, *a, **kw):
218
+ return self._no("HEAD", url)
219
+
220
+
221
+ # ---------------------------------------------------------------- run, baseline, check
222
+ def run(set_name="kicad-v1", out=None, replay=None, log=lambda *a: None, ids=None):
223
+ """-> eval report (report.json) of the gate configuration. ids: only these cases (tests; check(subset=True))."""
224
+ import os
225
+ from unittest import mock
226
+ replay = Path(replay or replay_path(set_name))
227
+ tmp = tempfile.TemporaryDirectory(prefix="ecadlib-gate-")
228
+ try:
229
+ out = Path(out) if out else Path(tmp.name) / "run"
230
+ rec = expand(load_replay(replay), Path(tmp.name) / "replay")
231
+ env = {k: v for k, v in os.environ.items() if not k.endswith("_API_KEY") and not k.startswith("ECADLIB_")}
232
+ http = NoNetwork()
233
+ with mock.patch.dict(os.environ, env, clear=True):
234
+ rep = R.run(set_name, out, replay=rec, sources=SOURCES, ids=ids, offline=True, cache=Path(tmp.name) / "cache", http=http,
235
+ rules=RU.load({}), log=log) # DEFAULTS: no ecadlib.toml from the cwd
236
+ rep["meta"]["network_requests"] = len(http.calls)
237
+ return rep
238
+ finally:
239
+ tmp.cleanup()
240
+
241
+
242
+ def baseline(rep):
243
+ s = rep["summary"]
244
+ return {"meta": {"set": rep["meta"]["set"], "set_version": rep["meta"]["set_version"], "sources": rep["meta"]["sources"],
245
+ "replay": f"ecadlib/eval/replay/{rep['meta']['set']}.json.gz (synthetic)",
246
+ "updated": datetime.date.today().isoformat(),
247
+ "how": "python -m ecadlib eval gate --update; review the diff; commit"},
248
+ "overall": {k: s["overall"][k] for k in ("cases", "pass", "rate")},
249
+ "families": {k: {x: f[x] for x in ("cases", "pass", "rate")} for k, f in s["families"].items()},
250
+ "passing": sorted(c["id"] for c in rep["cases"] if c["pass"])}
251
+
252
+
253
+ def check(rep, base, tol=0.01, subset=False):
254
+ """-> {"ok", "problems": [...], "fixed": [...], "text"}. subset: the run covers only some cases (no family rates;
255
+ baseline cases that are not in the run are skipped)."""
256
+ problems, lines = [], []
257
+ fams = rep["summary"]["families"]
258
+ for k, b in sorted(base["families"].items() if not subset else ()):
259
+ f = fams.get(k)
260
+ if not f:
261
+ problems.append(f"family {k}: missing from this run (baseline {b['pass']}/{b['cases']})")
262
+ continue
263
+ drop = (b["rate"] or 0) - (f["rate"] or 0)
264
+ flag = "DROP" if drop > tol + 1e-9 else "ok"
265
+ lines.append(f" {k:<10} {f['pass']:>4}/{f['cases']:<4} {100 * (f['rate'] or 0):6.1f} % baseline {b['pass']:>4}/{b['cases']:<4} "
266
+ f"{100 * (b['rate'] or 0):6.1f} % {flag}")
267
+ if flag == "DROP":
268
+ problems.append(f"family {k}: pass rate {100 * f['rate']:.1f} % < baseline {100 * b['rate']:.1f} % - {100 * tol:.1f} %")
269
+ by = {c["id"]: c for c in rep["cases"]}
270
+ for i in base["passing"]:
271
+ c = by.get(i)
272
+ if c is None and subset:
273
+ continue
274
+ if c is None:
275
+ problems.append(f"{i}: passed in the baseline, missing from this run")
276
+ elif not c["pass"]:
277
+ why = "; ".join(c["errors"][:3]) if c["source"] != "none" else "unresolved: " + "; ".join(c["tried"][-2:])
278
+ problems.append(f"{i}: passed in the baseline, now fails ({c['source']}): {why}")
279
+ was = set(base["passing"])
280
+ fixed = sorted(c["id"] for c in rep["cases"] if c["pass"] and c["id"] not in was)
281
+ o, bo = rep["summary"]["overall"], base["overall"]
282
+ head = f"eval gate {rep['meta']['set']}: {o['pass']}/{o['cases']} pass (baseline {bo['pass']}/{bo['cases']}), " \
283
+ f"{rep['meta']['wall_s']} s, network requests {rep['meta'].get('network_requests', '?')}"
284
+ text = [head] + lines
285
+ if fixed:
286
+ text.append(f"newly passing ({len(fixed)}; `ecadlib eval gate --update` locks them in): " + ", ".join(fixed))
287
+ text += ([f"FAIL ({len(problems)}):"] + [f" {p}" for p in problems]) if problems else ["OK: no family dropped, no passing case broke"]
288
+ return {"ok": not problems, "problems": problems, "fixed": fixed, "text": "\n".join(text)}
289
+
290
+
291
+ def main(argv=None):
292
+ ap = argparse.ArgumentParser(prog="ecadlib eval gate", description=__doc__.split("\n")[0])
293
+ ap.add_argument("--set", default="kicad-v1")
294
+ ap.add_argument("--baseline", help="baseline file (default ecadlib/eval/baselines/<set>.json)")
295
+ ap.add_argument("--replay", help="replay pack (default ecadlib/eval/replay/<set>.json.gz)")
296
+ ap.add_argument("--tol", type=float, default=0.01, help="allowed drop of a family's pass rate (fraction, default 0.01)")
297
+ ap.add_argument("--update", action="store_true", help="rewrite the baseline from this run")
298
+ ap.add_argument("--make-replay", action="store_true", help="rebuild the replay pack from the set and the standard table")
299
+ ap.add_argument("-o", "--out", help="keep the run's report and library here")
300
+ ap.add_argument("--json", action="store_true")
301
+ a = ap.parse_args(argv)
302
+ if a.make_replay:
303
+ p = make_replay(a.set, a.replay)
304
+ print(f"replay pack: {p} ({load_replay(p)['meta']['answers']} answers)")
305
+ return 0
306
+ t0 = time.time()
307
+ from .. import term as TM
308
+ with TM.working(f"the {a.set} accuracy set (offline)"):
309
+ rep = run(a.set, a.out, a.replay)
310
+ bp = Path(a.baseline or baseline_path(a.set))
311
+ if a.update or not bp.exists():
312
+ bp.parent.mkdir(parents=True, exist_ok=True)
313
+ bp.write_text(json.dumps(baseline(rep), indent=1, sort_keys=True) + "\n", encoding="utf-8")
314
+ o = rep["summary"]["overall"]
315
+ print(f"baseline written: {bp} ({o['pass']}/{o['cases']} pass, {time.time() - t0:.1f} s)")
316
+ return 0
317
+ res = check(rep, json.loads(bp.read_text(encoding="utf-8")), a.tol)
318
+ if a.json:
319
+ print(json.dumps({k: v for k, v in res.items() if k != "text"}, indent=1))
320
+ else:
321
+ print_result(rep, json.loads(bp.read_text(encoding="utf-8")), res)
322
+ return 0 if res["ok"] else 1
323
+
324
+
325
+ def print_result(rep, base, res, t=None):
326
+ """Terminal view: the outcome first, a row per package family against the baseline, then what broke."""
327
+ from .. import term as TM
328
+ t = t or TM.Term()
329
+ o, bo = rep["summary"]["overall"], base["overall"]
330
+ t.write(t.bold(f"Accuracy gate {rep['meta']['set']}: " + ("passed" if res["ok"] else "FAILED")
331
+ + f" - {o['pass']}/{o['cases']} parts right (baseline {bo['pass']}/{bo['cases']}), {rep['meta']['wall_s']} s"))
332
+ rows = []
333
+ for k, b in sorted(base["families"].items()):
334
+ f = rep["summary"]["families"].get(k)
335
+ if not f:
336
+ continue
337
+ drop = any(p.startswith(f"family {k}:") for p in res["problems"])
338
+ rows.append([t.status("held" if drop else "ready", "dropped" if drop else "ok"), k,
339
+ f"{f['pass']}/{f['cases']}", f"{100 * (f['rate'] or 0):.1f} %", t.dim(f"baseline {100 * (b['rate'] or 0):.1f} %")])
340
+ if not t.quiet:
341
+ t.table(rows, right=(2, 3))
342
+ t.detail(f" network requests (all refused, as they should be): {rep['meta'].get('network_requests', '?')}")
343
+ if res["fixed"]:
344
+ t.write(f" newly passing ({len(res['fixed'])}): " + ", ".join(res["fixed"]))
345
+ for p in res["problems"]:
346
+ t.write(f" {t.status('failed', 'broke')} {p}")
347
+ nxt = []
348
+ if res["problems"]:
349
+ nxt.append(("ecadlib eval gate -o /tmp/gate", "keep the run's report and library to look at"))
350
+ if res["fixed"]:
351
+ nxt.append(("ecadlib eval gate --update", "lock the newly passing parts into the baseline; review the diff"))
352
+ if nxt and not t.quiet:
353
+ t.write("next:")
354
+ t.table([[c, f"# {w}"] for c, w in nxt])
355
+
356
+
357
+ if __name__ == "__main__":
358
+ sys.exit(main())