@structupath/pi-steel 0.2.2 → 0.2.3

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 (47) hide show
  1. package/DATA_PROVENANCE.json +23 -0
  2. package/DATA_PROVENANCE.md +35 -0
  3. package/PUBLIC_DATA_POLICY.md +52 -0
  4. package/README.md +114 -33
  5. package/docs/assets/pi-steel-demo.gif +0 -0
  6. package/docs/assets/pi-steel-gallery.webp +0 -0
  7. package/package.json +35 -4
  8. package/pyproject.toml +28 -0
  9. package/requirements-dev.txt +5 -0
  10. package/requirements-render.txt +1 -0
  11. package/requirements-tested.txt +9 -0
  12. package/requirements.txt +7 -0
  13. package/scripts/check-data-provenance.py +140 -0
  14. package/scripts/check-public-data.py +401 -0
  15. package/scripts/doctor.py +127 -0
  16. package/skills/_shared/bootstrap.py +32 -0
  17. package/skills/_shared/pi_steel/__init__.py +47 -0
  18. package/skills/_shared/pi_steel/cli.py +167 -0
  19. package/skills/_shared/pi_steel/contracts.py +87 -0
  20. package/skills/_shared/pi_steel/geometry_verify.py +243 -0
  21. package/skills/_shared/pi_steel/parsing.py +205 -0
  22. package/skills/_shared/pi_steel/run_manifest.py +317 -0
  23. package/skills/_shared/pi_steel/validation.py +597 -0
  24. package/skills/_shared/schemas/estimate-package.schema.json +424 -0
  25. package/skills/_shared/schemas/nest-result.schema.json +443 -0
  26. package/skills/_shared/schemas/run-manifest.schema.json +145 -0
  27. package/skills/steel-estimate/SKILL.md +119 -0
  28. package/skills/steel-estimate/references/estimate-package-example.json +91 -0
  29. package/skills/steel-estimate/references/output-contract.md +47 -0
  30. package/skills/steel-estimate/scripts/acknowledge-finding.py +193 -0
  31. package/skills/steel-estimate/scripts/build-estimate-package.py +836 -0
  32. package/skills/steel-nest/SKILL.md +50 -23
  33. package/skills/steel-nest/references/FIXTURE_PROVENANCE.md +12 -0
  34. package/skills/steel-nest/references/example_job.json +21 -25
  35. package/skills/steel-nest/references/job_template.json +11 -9
  36. package/skills/steel-nest/scripts/nest.py +1276 -250
  37. package/skills/steel-rfq/SKILL.md +93 -175
  38. package/skills/steel-rfq/assets/company-profile.example.json +11 -5
  39. package/skills/steel-rfq/references/rfq-input.md +58 -0
  40. package/skills/steel-rfq/scripts/generate-rfq.py +1221 -0
  41. package/skills/steel-rfq/scripts/recalc.py +33 -10
  42. package/skills/steel-takeoff/SKILL.md +12 -8
  43. package/skills/steel-takeoff/assets/bom-template.csv +1 -1
  44. package/skills/steel-takeoff/references/takeoff-procedures.md +3 -1
  45. package/skills/steel-takeoff/scripts/calculate-weight.sh +5 -10
  46. package/skills/steel-takeoff/scripts/lookup-member.sh +2 -2
  47. package/skills/steel-takeoff/scripts/validate-bom.py +151 -196
@@ -0,0 +1,597 @@
1
+ """Schema and domain validation for canonical estimate packages."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import math
7
+ from dataclasses import dataclass
8
+ from functools import lru_cache
9
+ from pathlib import Path
10
+ from typing import Any
11
+
12
+ import jsonschema
13
+
14
+ from .contracts import (
15
+ ESTIMATE_PACKAGE_VERSION,
16
+ content_hash,
17
+ estimate_input_hash,
18
+ finding_id_for,
19
+ )
20
+ from .geometry_verify import (
21
+ SUPPORTED_SHAPES,
22
+ finite_positive,
23
+ hole_within_bounds,
24
+ net_area,
25
+ )
26
+
27
+
28
+ _SCHEMA_PATH = (
29
+ Path(__file__).resolve().parents[1] / "schemas" / "estimate-package.schema.json"
30
+ )
31
+ _REVIEWABLE_BLOCKERS = frozenset(
32
+ {
33
+ "missing_material_basis",
34
+ "unconfirmed_on_hand_stock",
35
+ }
36
+ )
37
+
38
+
39
+ @dataclass(frozen=True)
40
+ class ValidationResult:
41
+ status: str
42
+ input_hash: str
43
+ findings: list[dict[str, Any]]
44
+ active_findings: list[dict[str, Any]]
45
+
46
+ @property
47
+ def blockers(self) -> list[dict[str, Any]]:
48
+ return [
49
+ finding
50
+ for finding in self.active_findings
51
+ if finding["severity"] == "blocker"
52
+ ]
53
+
54
+ @property
55
+ def warnings(self) -> list[dict[str, Any]]:
56
+ return [
57
+ finding
58
+ for finding in self.active_findings
59
+ if finding["severity"] == "warning"
60
+ ]
61
+
62
+
63
+ def _path(parts: Any) -> str:
64
+ result = "$"
65
+ for part in parts:
66
+ result += f"[{part}]" if isinstance(part, int) else f".{part}"
67
+ return result
68
+
69
+
70
+ def _finding(
71
+ *,
72
+ code: str,
73
+ severity: str,
74
+ path: str,
75
+ message: str,
76
+ relevant_hash: str,
77
+ ) -> dict[str, Any]:
78
+ return {
79
+ "finding_id": finding_id_for(code, path),
80
+ "code": code,
81
+ "severity": severity,
82
+ "path": path,
83
+ "message": message,
84
+ "relevant_hash": relevant_hash,
85
+ }
86
+
87
+
88
+ def _add(
89
+ findings: list[dict[str, Any]],
90
+ input_hash: str,
91
+ code: str,
92
+ severity: str,
93
+ path: str,
94
+ message: str,
95
+ ) -> None:
96
+ findings.append(
97
+ _finding(
98
+ code=code,
99
+ severity=severity,
100
+ path=path,
101
+ message=message,
102
+ relevant_hash=input_hash,
103
+ )
104
+ )
105
+
106
+
107
+ @lru_cache(maxsize=1)
108
+ def _schema_validator():
109
+ schema = json.loads(_SCHEMA_PATH.read_text(encoding="utf-8"))
110
+ return jsonschema.Draft202012Validator(
111
+ schema, format_checker=jsonschema.FormatChecker()
112
+ )
113
+
114
+
115
+ def _schema_findings(
116
+ package: dict[str, Any], input_hash: str
117
+ ) -> list[dict[str, Any]]:
118
+ findings = []
119
+ for error in sorted(
120
+ _schema_validator().iter_errors(package),
121
+ key=lambda item: list(item.path),
122
+ ):
123
+ _add(
124
+ findings,
125
+ input_hash,
126
+ "schema_validation",
127
+ "blocker",
128
+ _path(error.absolute_path),
129
+ error.message,
130
+ )
131
+ return findings
132
+
133
+
134
+ def _nonfinite_number_findings(
135
+ value: Any,
136
+ input_hash: str,
137
+ *,
138
+ path: str = "$",
139
+ ) -> list[dict[str, Any]]:
140
+ """Reject non-JSON numeric values throughout the canonical package.
141
+
142
+ Python's JSON and JSON Schema implementations can accept NaN and infinity
143
+ even though they are not interoperable JSON values. Keep this domain guard
144
+ recursive so newly added numeric contract fields are protected by default.
145
+ """
146
+ findings: list[dict[str, Any]] = []
147
+ if isinstance(value, dict):
148
+ for key, child in value.items():
149
+ findings.extend(
150
+ _nonfinite_number_findings(
151
+ child,
152
+ input_hash,
153
+ path=f"{path}.{key}",
154
+ )
155
+ )
156
+ elif isinstance(value, list):
157
+ for index, child in enumerate(value):
158
+ findings.extend(
159
+ _nonfinite_number_findings(
160
+ child,
161
+ input_hash,
162
+ path=f"{path}[{index}]",
163
+ )
164
+ )
165
+ elif isinstance(value, float) and not math.isfinite(value):
166
+ _add(
167
+ findings,
168
+ input_hash,
169
+ "nonfinite_number",
170
+ "blocker",
171
+ path,
172
+ "Numeric values must be finite JSON numbers.",
173
+ )
174
+ return findings
175
+
176
+
177
+ def _merge_supplied_review_findings(
178
+ package: dict[str, Any],
179
+ input_hash: str,
180
+ findings: list[dict[str, Any]],
181
+ ) -> None:
182
+ """Carry canonical review findings forward and expose stale review state."""
183
+ existing = {
184
+ (finding["finding_id"], finding["relevant_hash"])
185
+ for finding in findings
186
+ }
187
+ for index, supplied in enumerate(
188
+ package.get("review", {}).get("findings", [])
189
+ ):
190
+ key = (supplied["finding_id"], supplied["relevant_hash"])
191
+ if key not in existing:
192
+ findings.append(dict(supplied))
193
+ existing.add(key)
194
+ if supplied["relevant_hash"] != input_hash:
195
+ stale = _finding(
196
+ code="stale_review_finding",
197
+ severity="blocker",
198
+ path=f"$.review.findings[{index}].relevant_hash",
199
+ message=(
200
+ f"Review finding {supplied['finding_id']!r} was produced for "
201
+ "a different estimate input and must be regenerated."
202
+ ),
203
+ relevant_hash=input_hash,
204
+ )
205
+ stale_key = (stale["finding_id"], stale["relevant_hash"])
206
+ if stale_key not in existing:
207
+ findings.append(stale)
208
+ existing.add(stale_key)
209
+
210
+
211
+ def _geometry_findings(
212
+ item: dict[str, Any],
213
+ index: int,
214
+ input_hash: str,
215
+ findings: list[dict[str, Any]],
216
+ ) -> None:
217
+ geometry = item.get("geometry")
218
+ if not isinstance(geometry, dict):
219
+ return
220
+ base = f"$.items[{index}].geometry"
221
+ shape = geometry.get("shape")
222
+ if shape not in SUPPORTED_SHAPES:
223
+ _add(
224
+ findings,
225
+ input_hash,
226
+ "unsupported_shape",
227
+ "blocker",
228
+ f"{base}.shape",
229
+ f"Unsupported shape {shape!r}; expected rect or irregular.",
230
+ )
231
+ dimensions = ("width", "height", "thickness")
232
+ for field in dimensions:
233
+ value = geometry.get(field)
234
+ if isinstance(value, (int, float)) and not math.isfinite(value):
235
+ _add(
236
+ findings,
237
+ input_hash,
238
+ "nonfinite_dimension",
239
+ "blocker",
240
+ f"{base}.{field}",
241
+ f"{field} must be finite.",
242
+ )
243
+ elif not finite_positive(value):
244
+ _add(
245
+ findings,
246
+ input_hash,
247
+ "nonpositive_dimension",
248
+ "blocker",
249
+ f"{base}.{field}",
250
+ f"{field} must be greater than zero.",
251
+ )
252
+ width, height = geometry.get("width"), geometry.get("height")
253
+ if finite_positive(width) and finite_positive(height):
254
+ for hole_index, hole in enumerate(geometry.get("holes", [])):
255
+ if not hole_within_bounds(hole, width, height):
256
+ _add(
257
+ findings,
258
+ input_hash,
259
+ "hole_out_of_bounds",
260
+ "blocker",
261
+ f"{base}.holes[{hole_index}]",
262
+ "Hole geometry must be positive and contained by the part.",
263
+ )
264
+ try:
265
+ area = net_area(geometry)
266
+ except (TypeError, ValueError, OverflowError):
267
+ area = math.nan
268
+ if not math.isfinite(area) or area <= 0:
269
+ _add(
270
+ findings,
271
+ input_hash,
272
+ "nonpositive_net_area",
273
+ "blocker",
274
+ base,
275
+ "Part net area after holes must be greater than zero.",
276
+ )
277
+ if shape == "irregular" and not finite_positive(geometry.get("area")):
278
+ _add(
279
+ findings,
280
+ input_hash,
281
+ "invalid_irregular_area",
282
+ "blocker",
283
+ f"{base}.area",
284
+ "Irregular parts require a positive true-cut area.",
285
+ )
286
+
287
+
288
+ def validate_estimate_package(package: dict[str, Any]) -> ValidationResult:
289
+ if not isinstance(package, dict):
290
+ input_hash = content_hash(package)
291
+ finding = _finding(
292
+ code="schema_validation",
293
+ severity="blocker",
294
+ path="$",
295
+ message="Estimate package must be a JSON object.",
296
+ relevant_hash=input_hash,
297
+ )
298
+ return ValidationResult("invalid", input_hash, [finding], [finding])
299
+ input_hash = estimate_input_hash(package)
300
+ version = package.get("schema_version")
301
+ if version != ESTIMATE_PACKAGE_VERSION:
302
+ finding = _finding(
303
+ code="unsupported_contract_version",
304
+ severity="blocker",
305
+ path="$.schema_version",
306
+ message=(
307
+ f"Unsupported estimate package version {version!r}; "
308
+ f"migrate to {ESTIMATE_PACKAGE_VERSION} before processing."
309
+ ),
310
+ relevant_hash=input_hash,
311
+ )
312
+ return ValidationResult("invalid", input_hash, [finding], [finding])
313
+
314
+ findings = _nonfinite_number_findings(package, input_hash)
315
+ schema_findings = _schema_findings(package, input_hash)
316
+ if schema_findings:
317
+ findings.extend(schema_findings)
318
+ # Retain the pre-existing field-specific diagnostics for the common
319
+ # scalar errors that are now also constrained directly by the schema.
320
+ items = package.get("items", [])
321
+ if isinstance(items, list):
322
+ for index, item in enumerate(items):
323
+ if not isinstance(item, dict):
324
+ continue
325
+ quantity = item.get("quantity")
326
+ if (
327
+ not isinstance(quantity, int)
328
+ or isinstance(quantity, bool)
329
+ or quantity <= 0
330
+ ):
331
+ _add(
332
+ findings,
333
+ input_hash,
334
+ "invalid_quantity",
335
+ "blocker",
336
+ f"$.items[{index}].quantity",
337
+ "Quantity must be a positive integer.",
338
+ )
339
+ if item.get("intent") == "fabricated_part":
340
+ _geometry_findings(item, index, input_hash, findings)
341
+ return ValidationResult("invalid", input_hash, findings, findings)
342
+ source_ids: dict[str, int] = {}
343
+ item_ids: dict[str, int] = {}
344
+ for index, item in enumerate(package.get("items", [])):
345
+ base = f"$.items[{index}]"
346
+ quantity = item.get("quantity")
347
+ if (
348
+ not isinstance(quantity, int)
349
+ or isinstance(quantity, bool)
350
+ or quantity <= 0
351
+ ):
352
+ _add(
353
+ findings,
354
+ input_hash,
355
+ "invalid_quantity",
356
+ "blocker",
357
+ f"{base}.quantity",
358
+ "Quantity must be a positive integer.",
359
+ )
360
+ for field, seen, code in (
361
+ ("source_id", source_ids, "duplicate_source_id"),
362
+ ("item_id", item_ids, "duplicate_item_id"),
363
+ ):
364
+ identity = item.get(field)
365
+ if identity in seen:
366
+ _add(
367
+ findings,
368
+ input_hash,
369
+ code,
370
+ "blocker",
371
+ f"{base}.{field}",
372
+ f"{field} duplicates item {seen[identity]}; rows were not merged.",
373
+ )
374
+ elif identity is not None:
375
+ seen[identity] = index
376
+
377
+ if item.get("identity_warning"):
378
+ _add(
379
+ findings,
380
+ input_hash,
381
+ "unstable_source_identity",
382
+ "warning",
383
+ f"{base}.source_id",
384
+ "No explicit source ID or stable mark was supplied; identity is revision-scoped.",
385
+ )
386
+ if not item.get("source_evidence"):
387
+ _add(
388
+ findings,
389
+ input_hash,
390
+ "missing_source_evidence",
391
+ "warning",
392
+ f"{base}.source_evidence",
393
+ "No drawing or structured-source locator was supplied; none was invented.",
394
+ )
395
+ if item.get("intent") == "fabricated_part":
396
+ _geometry_findings(item, index, input_hash, findings)
397
+ if item.get("geometry") and (
398
+ not item.get("material")
399
+ or not item.get("grade")
400
+ or not finite_positive(item.get("geometry", {}).get("thickness"))
401
+ ):
402
+ _add(
403
+ findings,
404
+ input_hash,
405
+ "missing_material_basis",
406
+ "blocker",
407
+ base,
408
+ "Plate material, grade, and thickness must be explicit before nesting, RFQ, or burn.",
409
+ )
410
+ if not item.get("geometry"):
411
+ if not item.get("designation"):
412
+ _add(
413
+ findings,
414
+ input_hash,
415
+ "missing_designation",
416
+ "blocker",
417
+ f"{base}.designation",
418
+ "A legacy member requires a section designation.",
419
+ )
420
+ for field, code, label in (
421
+ ("length_ft", "invalid_member_length", "Member length"),
422
+ (
423
+ "unit_weight_plf",
424
+ "invalid_unit_weight",
425
+ "Member unit weight",
426
+ ),
427
+ ):
428
+ if not finite_positive(item.get(field)):
429
+ _add(
430
+ findings,
431
+ input_hash,
432
+ code,
433
+ "blocker",
434
+ f"{base}.{field}",
435
+ f"{label} must be finite and greater than zero.",
436
+ )
437
+ if not item.get("grade"):
438
+ _add(
439
+ findings,
440
+ input_hash,
441
+ "missing_grade",
442
+ "warning",
443
+ f"{base}.grade",
444
+ "No material grade was supplied; none was inferred.",
445
+ )
446
+
447
+ for index, stock in enumerate(package.get("stock", [])):
448
+ if stock.get("stock_kind") != "on_hand":
449
+ continue
450
+ required = (
451
+ stock.get("inventory_id"),
452
+ stock.get("measured_at"),
453
+ stock.get("source"),
454
+ stock.get("status") in {"available", "reserved"},
455
+ )
456
+ confirmation = stock.get("reviewer_confirmation", {})
457
+ confirmed = (
458
+ all(required)
459
+ and confirmation.get("estimate_hash") == input_hash
460
+ and confirmation.get("actor")
461
+ and confirmation.get("timestamp")
462
+ )
463
+ if not confirmed:
464
+ _add(
465
+ findings,
466
+ input_hash,
467
+ "unconfirmed_on_hand_stock",
468
+ "blocker",
469
+ f"$.stock[{index}]",
470
+ "On-hand stock cannot reduce purchasing without traceable measurements and hash-bound reviewer confirmation.",
471
+ )
472
+
473
+ basis = package.get("commercial_basis", {})
474
+ for index, cost in enumerate(basis.get("costs", [])):
475
+ if cost.get("currency") != basis.get("currency") or not all(
476
+ cost.get(field)
477
+ for field in ("unit_basis", "effective_date", "source")
478
+ ):
479
+ _add(
480
+ findings,
481
+ input_hash,
482
+ "invalid_cost_basis",
483
+ "blocker",
484
+ f"$.commercial_basis.costs[{index}]",
485
+ "Cost currency, unit basis, effective date, and source must be preserved.",
486
+ )
487
+
488
+ for index, assumption in enumerate(package.get("assumptions", [])):
489
+ if assumption.get("status") == "unresolved":
490
+ _add(
491
+ findings,
492
+ input_hash,
493
+ "unresolved_assumption",
494
+ "warning",
495
+ f"$.assumptions[{index}]",
496
+ (
497
+ f"Assumption {assumption['assumption_id']!r} remains "
498
+ "unresolved and requires estimator review."
499
+ ),
500
+ )
501
+
502
+ _merge_supplied_review_findings(package, input_hash, findings)
503
+
504
+ acknowledgements = package.get("review", {}).get("acknowledgements", [])
505
+ accepted_findings = {
506
+ (acknowledgement.get("finding_id"), acknowledgement.get("input_hash"))
507
+ for acknowledgement in acknowledgements
508
+ if acknowledgement.get("disposition") == "accepted"
509
+ }
510
+ active = []
511
+ for finding in findings:
512
+ acknowledged = (
513
+ finding["severity"] != "blocker"
514
+ and (finding["finding_id"], finding["relevant_hash"])
515
+ in accepted_findings
516
+ )
517
+ if not acknowledged:
518
+ active.append(finding)
519
+
520
+ blockers = [f for f in active if f["severity"] == "blocker"]
521
+ if blockers:
522
+ status = (
523
+ "review_required"
524
+ if all(f["code"] in _REVIEWABLE_BLOCKERS for f in blockers)
525
+ else "invalid"
526
+ )
527
+ elif any(f["severity"] == "warning" for f in active):
528
+ status = "review_required"
529
+ else:
530
+ status = "validated"
531
+ return ValidationResult(status, input_hash, findings, active)
532
+
533
+
534
+ def acknowledge_finding(
535
+ package: dict[str, Any],
536
+ finding: dict[str, Any],
537
+ *,
538
+ actor: str,
539
+ timestamp: str,
540
+ disposition: str,
541
+ ) -> dict[str, Any]:
542
+ if disposition not in {"accepted", "rejected", "deferred"}:
543
+ raise ValueError("unsupported acknowledgement disposition")
544
+ acknowledgement = {
545
+ "finding_id": finding["finding_id"],
546
+ "actor": actor,
547
+ "timestamp": timestamp,
548
+ "disposition": disposition,
549
+ "input_hash": finding["relevant_hash"],
550
+ }
551
+ package.setdefault("review", {}).setdefault("acknowledgements", []).append(
552
+ acknowledgement
553
+ )
554
+ return acknowledgement
555
+
556
+
557
+ def eligible_on_hand_stock(package: dict[str, Any]) -> list[dict[str, Any]]:
558
+ input_hash = estimate_input_hash(package)
559
+ eligible = []
560
+ for stock in package.get("stock", []):
561
+ confirmation = stock.get("reviewer_confirmation", {})
562
+ if (
563
+ stock.get("stock_kind") == "on_hand"
564
+ and stock.get("inventory_id")
565
+ and finite_positive(stock.get("width"))
566
+ and finite_positive(stock.get("height"))
567
+ and finite_positive(stock.get("thickness"))
568
+ and stock.get("measured_at")
569
+ and stock.get("source")
570
+ and stock.get("status") == "available"
571
+ and confirmation.get("actor")
572
+ and confirmation.get("timestamp")
573
+ and confirmation.get("estimate_hash") == input_hash
574
+ ):
575
+ eligible.append(stock)
576
+ return eligible
577
+
578
+
579
+ def purchasable_items(package: dict[str, Any]) -> list[dict[str, Any]]:
580
+ return [
581
+ item
582
+ for item in package.get("items", [])
583
+ if item.get("intent") in {"fabricated_part", "purchased_stock", "hardware"}
584
+ ]
585
+
586
+
587
+ def stock_requiring_purchase(package: dict[str, Any]) -> list[dict[str, Any]]:
588
+ """Return only stock explicitly modeled as purchasable.
589
+
590
+ On-hand entries that are unavailable or not confirmed remain validation
591
+ findings; they never silently become vendor demand.
592
+ """
593
+ return [
594
+ stock
595
+ for stock in package.get("stock", [])
596
+ if stock.get("stock_kind") == "purchasable"
597
+ ]