@structupath/pi-steel 0.2.1 → 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 (48) 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
  48. package/skills/steel-rfq/scripts/__pycache__/recalc.cpython-312.pyc +0 -0
@@ -0,0 +1,119 @@
1
+ ---
2
+ name: steel-estimate
3
+ description: "Build a deterministic, review-gated steel estimate package from canonical estimate JSON. Use when a complete takeoff-to-nest-to-draft-RFQ workflow is needed with validation, lineage, QA, and isolated run artifacts."
4
+ ---
5
+
6
+ # Steel Estimate Package
7
+
8
+ ## Boundary
9
+
10
+ This skill produces estimating and draft purchasing artifacts for human review.
11
+ It does not send an RFQ, select a vendor, approve substitutions, award work, or
12
+ authorize a purchase. The workbook remains `DRAFT — NOT SENT OR AWARDED`.
13
+
14
+ ## Required inputs
15
+
16
+ - Canonical estimate-package JSON at schema version `1.0.0`.
17
+ - Explicit prepared and RFQ issue dates.
18
+ - A runtime company profile accepted by `steel-rfq`.
19
+ - Plate stock compatible with every plate part by material, grade, thickness,
20
+ and usable dimensions.
21
+
22
+ Use `references/estimate-package-example.json` as a synthetic input example.
23
+ The company profile resolution and approval rules are documented by
24
+ `../steel-rfq/SKILL.md`.
25
+
26
+ ## Workflow and gates
27
+
28
+ The orchestrator composes the shared estimate validator, deterministic nesting
29
+ engine, RFQ compiler, and run publisher. It does not duplicate their
30
+ calculations.
31
+
32
+ 1. Normalize and validate the canonical package.
33
+ 2. Build the typed BOM projection without converting exclusions or allowances
34
+ into vendor quantities.
35
+ 3. Nest plate parts only within compatible stock groups.
36
+ 4. Verify placements and retain diagnostic nest artifacts.
37
+ 5. Compile a draft RFQ only when validation, placement, and company-profile
38
+ gates pass.
39
+ 6. Publish an isolated run manifest and QA report.
40
+
41
+ Validation failures, unplaced parts, invalid company data, failed nest
42
+ verification, or required rendering dependencies block workbook generation.
43
+ Complete bounding-box nests for irregular geometry may produce a workbook, but
44
+ the run and workbook remain explicitly review-required. Reference DXF is never
45
+ burn-ready DXF.
46
+
47
+ ## Run
48
+
49
+ ```bash
50
+ python3 scripts/build-estimate-package.py \
51
+ --input <estimate-package.json> \
52
+ --out <publication-root> \
53
+ --prepared-date <YYYY-MM-DD> \
54
+ --issued-date <YYYY-MM-DD> \
55
+ --project-location "Example City, ST"
56
+ ```
57
+
58
+ Use `--no-render` when only JSON and workbook artifacts are needed. Use
59
+ `--no-bake` to defer formula calculation to spreadsheet open. Explicit dates
60
+ and unchanged input/configuration yield stable semantic artifacts.
61
+
62
+ Exit codes:
63
+
64
+ - `0`: package is ready for human RFQ review.
65
+ - `2`: a draft exists, but warnings or reference-only geometry require review.
66
+ - `3`: blocked; diagnostics are published and no workbook is produced.
67
+ - `4`: a required runtime dependency is missing; no workbook is produced.
68
+ - `1`: usage, file, or input parsing error.
69
+
70
+ See `references/output-contract.md` for artifact and status semantics.
71
+
72
+ ## Review acknowledgements
73
+
74
+ The acknowledgement helper records a decision already made by a named human.
75
+ It never infers a disposition, chooses an actor or timestamp, or changes source
76
+ facts. Read the finding in `qa-report.json`, then supply every decision field
77
+ explicitly and write a new package:
78
+
79
+ ```bash
80
+ python3 scripts/acknowledge-finding.py \
81
+ --input <estimate-package.json> \
82
+ --output <reviewed-estimate-package.json> \
83
+ --finding-id <finding-id> \
84
+ --input-hash <finding-relevant-hash> \
85
+ --actor "<reviewer name>" \
86
+ --timestamp <ISO-8601-date-time> \
87
+ --disposition accepted
88
+ ```
89
+
90
+ Allowed dispositions are `accepted`, `rejected`, and `deferred`. An accepted
91
+ warning is cleared only while its relevant input hash still matches. Recording
92
+ acceptance of a blocker does not waive that blocker. Re-run the estimate
93
+ pipeline with the newly written package and inspect the new QA report.
94
+
95
+ ## Dependency recovery
96
+
97
+ If a command reports exit `4` or names a missing capability, run the package
98
+ doctor from the package root:
99
+
100
+ ```bash
101
+ python3 scripts/doctor.py --json
102
+ ```
103
+
104
+ Install the calculation, workbook, PNG, and DXF dependencies from
105
+ `requirements.txt`. Full PDF and LibreOffice-assisted checks also require the
106
+ system tools documented by `requirements-render.txt`.
107
+ `requirements-tested.txt` records the exact dependency versions exercised by
108
+ CI; it is a reproducibility reference, not the general installation range. Run
109
+ the doctor again after installation before retrying the estimate.
110
+
111
+ ## Delivery checks
112
+
113
+ - Read `run-manifest.json` and `qa-report.json`; do not infer readiness from a
114
+ workbook filename.
115
+ - Confirm there are no blocker findings or unplaced parts.
116
+ - Confirm material, grade, thickness, source IDs, and replacement lineage.
117
+ - Surface every warning and approximation to the reviewer.
118
+ - Treat all workbook and rendered artifacts according to their manifest
119
+ readiness labels.
@@ -0,0 +1,91 @@
1
+ {
2
+ "schema_version": "1.0.0",
3
+ "project": {
4
+ "project_id": "SYNTHETIC-EXAMPLE-001",
5
+ "name": "Synthetic Estimate Example",
6
+ "revision": {
7
+ "revision_id": "SYNTHETIC-REV-A",
8
+ "source_document": "SYNTHETIC-SOURCE-001"
9
+ }
10
+ },
11
+ "unit_system": "imperial",
12
+ "items": [
13
+ {
14
+ "intent": "fabricated_part",
15
+ "source_id": "SYNTHETIC-SRC-P1",
16
+ "item_id": "item:synthetic-example-p1",
17
+ "quantity": 2,
18
+ "mark": "P1",
19
+ "description": "Synthetic rectangular plate",
20
+ "material": "carbon_steel",
21
+ "grade": "A36",
22
+ "geometry": {
23
+ "shape": "rect",
24
+ "width": 8,
25
+ "height": 6,
26
+ "thickness": 0.5,
27
+ "holes": [],
28
+ "rotatable": true
29
+ },
30
+ "source_evidence": [
31
+ {
32
+ "source": "SYNTHETIC-SOURCE-001",
33
+ "locator": "SYNTHETIC-DETAIL-P1"
34
+ }
35
+ ]
36
+ },
37
+ {
38
+ "intent": "purchased_stock",
39
+ "source_id": "SYNTHETIC-SRC-S1",
40
+ "item_id": "item:synthetic-example-s1",
41
+ "quantity": 1,
42
+ "description": "Synthetic stock plate purchase",
43
+ "material": "carbon_steel",
44
+ "grade": "A36",
45
+ "specification": "PLATE",
46
+ "dimensions": {
47
+ "category": "PLATE STOCK",
48
+ "width": 24,
49
+ "height": 12,
50
+ "thickness": 0.5,
51
+ "size": "24 x 12 x 0.5",
52
+ "replaces_item_ids": [
53
+ "item:synthetic-example-p1"
54
+ ]
55
+ },
56
+ "source_evidence": [
57
+ {
58
+ "source": "SYNTHETIC-SOURCE-001",
59
+ "locator": "SYNTHETIC-STOCK-S1"
60
+ }
61
+ ]
62
+ }
63
+ ],
64
+ "stock": [
65
+ {
66
+ "stock_kind": "purchasable",
67
+ "inventory_id": "SYNTHETIC-STOCK-A36",
68
+ "material": "carbon_steel",
69
+ "grade": "A36",
70
+ "width": 24,
71
+ "height": 12,
72
+ "thickness": 0.5,
73
+ "quantity": 1,
74
+ "status": "available"
75
+ }
76
+ ],
77
+ "commercial_basis": {
78
+ "currency": "USD",
79
+ "costs": []
80
+ },
81
+ "review": {
82
+ "status": "validated",
83
+ "findings": [],
84
+ "acknowledgements": []
85
+ },
86
+ "lineage": {
87
+ "source_type": "synthetic_example",
88
+ "source_hash": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
89
+ "configuration_hash": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"
90
+ }
91
+ }
@@ -0,0 +1,47 @@
1
+ # Estimate package output contract
2
+
3
+ Each invocation publishes an immutable directory at `runs/<run-id>/` and
4
+ updates `latest-run.json` only after the run is complete. Read the manifest
5
+ before using any artifact.
6
+
7
+ ## Core artifacts
8
+
9
+ | Artifact | Purpose | Produced when |
10
+ | --- | --- | --- |
11
+ | `estimate-package.json` | Deterministically ordered canonical input | Always |
12
+ | `normalized-bom.json` | Typed BOM and calculated weight projection | Always |
13
+ | `nest-result.json` | Placements, verification, utilization, and unplaced parts | Valid input contains plate parts |
14
+ | `rfq-nesting.json` | Versioned nesting lineage for RFQ compilation | A nest was attempted |
15
+ | `inventory-consumption.json` | Confirmed on-hand sheets consumed and corresponding RFQ demand reduction | Eligible on-hand inventory was consumed |
16
+ | `qa-report.json` | Findings, approximations, gate decisions, and recalculation status | Always |
17
+ | `run-manifest.json` | Input/configuration hashes and artifact hashes/readiness | Always |
18
+ | `<project>_RFQ_Material_List.xlsx` | Draft RFQ workbook | All blocking gates pass |
19
+ | `workbook-semantic.json` | Stable workbook content projection | Workbook exists |
20
+
21
+ Rendered PDF, PNG, and DXF files are optional. Reference artifacts are not
22
+ fabrication authority. Burn DXF is eligible only for a fully ready run with
23
+ verified exact geometry.
24
+
25
+ ## Outcomes
26
+
27
+ - `ready` / `rfq_ready_for_review`: all gates pass; the workbook is still a
28
+ draft requiring human review.
29
+ - `review_required` / `rfq_draft_review_required`: a workbook exists, but
30
+ warnings or reference-only geometry must be resolved or accepted by a human.
31
+ - `blocked`: diagnostics exist, but no workbook exists.
32
+ - `dependency_missing`: required rendering support is absent and no workbook
33
+ exists.
34
+
35
+ Unplaced parts use `blocked` with package status `nested_partial`; their nest
36
+ diagnostics remain available. A validation failure stops before nesting.
37
+
38
+ ## Determinism and lineage
39
+
40
+ Canonical JSON is sorted before publication. The manifest records the canonical
41
+ input hash, effective configuration hash, tool/schema versions, explicit dates,
42
+ and SHA-256 for each artifact. Run IDs and filesystem paths are volatile and do
43
+ not affect the semantic hash. A changed revision, quantity, profile, date, or
44
+ nest setting changes the applicable downstream lineage.
45
+
46
+ Never treat warnings, approximations, exclusions, allowances, or source
47
+ evidence as presentation-only metadata. They are part of the review contract.
@@ -0,0 +1,193 @@
1
+ #!/usr/bin/env python3
2
+ """Record one explicit human review decision in a canonical estimate package."""
3
+
4
+ from __future__ import annotations
5
+
6
+ import argparse
7
+ import json
8
+ import os
9
+ import sys
10
+ import tempfile
11
+ from datetime import datetime
12
+ from pathlib import Path
13
+ from typing import Any
14
+
15
+
16
+ SHARED_ROOT = Path(__file__).resolve().parents[2] / "_shared"
17
+ if str(SHARED_ROOT) not in sys.path:
18
+ sys.path.insert(0, str(SHARED_ROOT))
19
+ from bootstrap import bootstrap_shared # noqa: E402
20
+
21
+ bootstrap_shared(__file__)
22
+ from pi_steel import canonical_json_bytes # noqa: E402
23
+ from pi_steel.validation import ( # noqa: E402
24
+ acknowledge_finding,
25
+ validate_estimate_package,
26
+ )
27
+
28
+
29
+ class AcknowledgementInputError(ValueError):
30
+ """Raised when an explicit acknowledgement cannot be recorded safely."""
31
+
32
+
33
+ def _valid_timestamp(value: str) -> bool:
34
+ try:
35
+ parsed = datetime.fromisoformat(value.replace("Z", "+00:00"))
36
+ except ValueError:
37
+ return False
38
+ return "T" in value and parsed.tzinfo is not None
39
+
40
+
41
+ def _findings_for_decision(
42
+ package: dict[str, Any], current_hash: str
43
+ ) -> list[dict[str, Any]]:
44
+ generated = validate_estimate_package(package).findings
45
+ supplied = package.get("review", {}).get("findings", [])
46
+ combined: dict[tuple[str, str], dict[str, Any]] = {}
47
+ for finding in [*generated, *supplied]:
48
+ finding_id = finding.get("finding_id")
49
+ relevant_hash = finding.get("relevant_hash")
50
+ if isinstance(finding_id, str) and isinstance(relevant_hash, str):
51
+ combined[(finding_id, relevant_hash)] = finding
52
+ return [
53
+ finding
54
+ for finding in combined.values()
55
+ if finding.get("relevant_hash") == current_hash
56
+ ]
57
+
58
+
59
+ def record_acknowledgement(args: argparse.Namespace) -> dict[str, Any]:
60
+ input_path = Path(args.input).resolve()
61
+ output_path = Path(args.output).resolve()
62
+ if input_path == output_path:
63
+ raise AcknowledgementInputError(
64
+ "--output must be a new file; the source package is never overwritten"
65
+ )
66
+ if output_path.exists():
67
+ raise AcknowledgementInputError(
68
+ f"output already exists: {output_path}"
69
+ )
70
+ if not args.actor.strip():
71
+ raise AcknowledgementInputError("--actor must name the human reviewer")
72
+ if not _valid_timestamp(args.timestamp):
73
+ raise AcknowledgementInputError(
74
+ "--timestamp must be an explicit ISO-8601 date-time"
75
+ )
76
+
77
+ try:
78
+ package = json.loads(input_path.read_text(encoding="utf-8"))
79
+ except (OSError, json.JSONDecodeError) as exc:
80
+ raise AcknowledgementInputError(
81
+ f"estimate package could not be read: {exc}"
82
+ ) from exc
83
+ if not isinstance(package, dict):
84
+ raise AcknowledgementInputError("estimate package must be a JSON object")
85
+
86
+ validation = validate_estimate_package(package)
87
+ if args.input_hash != validation.input_hash:
88
+ raise AcknowledgementInputError(
89
+ "--input-hash is stale for the current estimate input"
90
+ )
91
+ matches = [
92
+ finding
93
+ for finding in _findings_for_decision(package, validation.input_hash)
94
+ if finding["finding_id"] == args.finding_id
95
+ ]
96
+ if not matches:
97
+ stale = [
98
+ finding
99
+ for finding in package.get("review", {}).get("findings", [])
100
+ if finding.get("finding_id") == args.finding_id
101
+ ]
102
+ if stale:
103
+ raise AcknowledgementInputError(
104
+ "finding exists but is stale for the current estimate input hash"
105
+ )
106
+ raise AcknowledgementInputError(
107
+ "finding ID is not active for the current estimate input"
108
+ )
109
+ if len(matches) != 1:
110
+ raise AcknowledgementInputError(
111
+ "finding ID is ambiguous for the current estimate input"
112
+ )
113
+ finding = matches[0]
114
+ duplicate = any(
115
+ acknowledgement.get("finding_id") == args.finding_id
116
+ and acknowledgement.get("input_hash") == validation.input_hash
117
+ and acknowledgement.get("actor") == args.actor.strip()
118
+ and acknowledgement.get("timestamp") == args.timestamp
119
+ and acknowledgement.get("disposition") == args.disposition
120
+ for acknowledgement in package.get("review", {}).get(
121
+ "acknowledgements", []
122
+ )
123
+ )
124
+ if duplicate:
125
+ raise AcknowledgementInputError(
126
+ "this exact acknowledgement is already recorded"
127
+ )
128
+
129
+ acknowledgement = acknowledge_finding(
130
+ package,
131
+ finding,
132
+ actor=args.actor.strip(),
133
+ timestamp=args.timestamp,
134
+ disposition=args.disposition,
135
+ )
136
+ output_path.parent.mkdir(parents=True, exist_ok=True)
137
+ file_descriptor, temporary_name = tempfile.mkstemp(
138
+ prefix=f".{output_path.name}.", dir=output_path.parent
139
+ )
140
+ try:
141
+ with os.fdopen(file_descriptor, "wb") as stream:
142
+ stream.write(canonical_json_bytes(package))
143
+ os.link(temporary_name, output_path)
144
+ finally:
145
+ if os.path.exists(temporary_name):
146
+ os.unlink(temporary_name)
147
+ return acknowledgement
148
+
149
+
150
+ def parser() -> argparse.ArgumentParser:
151
+ result = argparse.ArgumentParser(description=__doc__)
152
+ result.add_argument("--input", required=True)
153
+ result.add_argument("--output", required=True)
154
+ result.add_argument("--finding-id", required=True)
155
+ result.add_argument(
156
+ "--input-hash",
157
+ required=True,
158
+ help="Relevant estimate input hash copied from the reviewed finding.",
159
+ )
160
+ result.add_argument("--actor", required=True)
161
+ result.add_argument("--timestamp", required=True)
162
+ result.add_argument(
163
+ "--disposition",
164
+ required=True,
165
+ choices=("accepted", "rejected", "deferred"),
166
+ )
167
+ return result
168
+
169
+
170
+ def main(argv: list[str] | None = None) -> int:
171
+ args = parser().parse_args(argv)
172
+ try:
173
+ acknowledgement = record_acknowledgement(args)
174
+ except AcknowledgementInputError as exc:
175
+ print(f"Acknowledgement failed: {exc}", file=sys.stderr)
176
+ return 1
177
+ print(
178
+ json.dumps(
179
+ {
180
+ "finding_id": acknowledgement["finding_id"],
181
+ "disposition": acknowledgement["disposition"],
182
+ "input_hash": acknowledgement["input_hash"],
183
+ "recorded": True,
184
+ },
185
+ sort_keys=True,
186
+ separators=(",", ":"),
187
+ )
188
+ )
189
+ return 0
190
+
191
+
192
+ if __name__ == "__main__":
193
+ raise SystemExit(main())