From 9883e799226f9b2510d9c3d6484b02c0644a4b5d Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 23 Aug 2026 06:31:09 +0000 Subject: [PATCH 01/12] feat(cutlist): add linear cut-list optimization skill Add steel-cutlist, a deterministic 1D bar-nesting engine for long products (beams, HSS, angles, pipe) that packs required member lengths onto purchasable mill lengths with explicit kerf and end-trim allowances. - Strategy portfolio per designation+grade group: mixed-stock greedy plus each single-stock-length restriction, ranked by unplaced count, total stock length, known cost, then bar count. - Independent post-placement verification (overcommitment, material mismatch, duplicate or missing instances) gates publication. - Weights resolve from explicit plf or the bundled AISC database; unknown weights surface as warnings, never silent zeros. - Publishes manifested runs: bar diagrams, purchase summary, drop candidates, rfq_linear.json handoff, and a cutting_list.csv emitted only for verified fully placed runs. - Register cutlist_partial/cutlist_verified package statuses and add a versioned cutlist-result schema. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01MquyTzVAPKpV1V8Vt3XxnJ --- skills/_shared/pi_steel/run_manifest.py | 2 + .../schemas/cutlist-result.schema.json | 205 +++ .../_shared/schemas/nest-result.schema.json | 2 + skills/steel-cutlist/SKILL.md | 88 + .../references/FIXTURE_PROVENANCE.md | 13 + .../steel-cutlist/references/example_job.json | 17 + .../references/job_template.json | 53 + skills/steel-cutlist/scripts/cutlist.py | 1467 +++++++++++++++++ tests/test_cutlist_cli_contract.py | 182 ++ tests/test_cutlist_engine.py | 349 ++++ 10 files changed, 2378 insertions(+) create mode 100644 skills/_shared/schemas/cutlist-result.schema.json create mode 100644 skills/steel-cutlist/SKILL.md create mode 100644 skills/steel-cutlist/references/FIXTURE_PROVENANCE.md create mode 100644 skills/steel-cutlist/references/example_job.json create mode 100644 skills/steel-cutlist/references/job_template.json create mode 100644 skills/steel-cutlist/scripts/cutlist.py create mode 100644 tests/test_cutlist_cli_contract.py create mode 100644 tests/test_cutlist_engine.py diff --git a/skills/_shared/pi_steel/run_manifest.py b/skills/_shared/pi_steel/run_manifest.py index 5a4107c..07dbf56 100644 --- a/skills/_shared/pi_steel/run_manifest.py +++ b/skills/_shared/pi_steel/run_manifest.py @@ -39,6 +39,8 @@ "validated", "nested_partial", "nest_verified", + "cutlist_partial", + "cutlist_verified", "rfq_draft_review_required", "rfq_ready_for_review", } diff --git a/skills/_shared/schemas/cutlist-result.schema.json b/skills/_shared/schemas/cutlist-result.schema.json new file mode 100644 index 0000000..e2955df --- /dev/null +++ b/skills/_shared/schemas/cutlist-result.schema.json @@ -0,0 +1,205 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://structupath.ai/schemas/pi-steel/cutlist-result-1.0.0.json", + "title": "pi-steel linear cut-list result", + "type": "object", + "required": [ + "schema_version", + "algorithm_version", + "normalized_input_hash", + "estimate_input_hash", + "configuration_hash", + "outcome", + "package_status", + "meta", + "fit_contract", + "bars_used", + "metrics", + "bar_reports", + "purchase_summary", + "drops", + "unplaced", + "validation_findings", + "verification", + "cost", + "rfq_linear" + ], + "properties": { + "schema_version": { "const": "1.0.0" }, + "algorithm_version": { "type": "string", "minLength": 1 }, + "normalized_input_hash": { "$ref": "#/$defs/sha256" }, + "estimate_input_hash": { "$ref": "#/$defs/sha256" }, + "configuration_hash": { "$ref": "#/$defs/sha256" }, + "outcome": { + "enum": ["ready", "review_required", "blocked", "dependency_missing"] + }, + "package_status": { + "enum": ["draft", "cutlist_partial", "cutlist_verified"] + }, + "meta": { + "type": "object", + "required": [ + "job_name", + "project_id", + "revision_id", + "kerf_in", + "end_trim_in", + "min_drop_in", + "unit_system" + ] + }, + "fit_contract": { + "type": "object", + "required": [ + "usable_length", + "piece_fits_when", + "consumption_per_piece" + ] + }, + "bars_used": { "type": "integer", "minimum": 0 }, + "metrics": { + "type": "object", + "required": ["utilization_pct", "total_kerf_in"] + }, + "weight_status": { "enum": ["known", "incomplete"] }, + "cost": { + "type": "object", + "required": ["status", "total"], + "properties": { + "status": { + "enum": ["known", "not_provided", "incomplete_unplaced"] + }, + "total": { "type": ["number", "null"] } + } + }, + "bar_reports": { + "type": "array", + "items": { "$ref": "#/$defs/barReport" } + }, + "purchase_summary": { + "type": "array", + "items": { + "type": "object", + "required": [ + "stock_id", + "designation", + "grade", + "bar_length_in", + "bars_needed", + "total_length_ft", + "total_cost" + ] + } + }, + "drops": { + "type": "array", + "items": { + "type": "object", + "required": [ + "bar_index", + "stock_id", + "designation", + "grade", + "length_in", + "status" + ], + "properties": { + "status": { "const": "candidate_unverified" } + } + } + }, + "unplaced": { + "type": "array", + "items": { + "type": "object", + "required": [ + "item_id", + "label", + "designation", + "grade", + "length_in", + "quantity", + "reason" + ], + "properties": { + "reason": { + "enum": ["no_compatible_stock_fit", "stock_exhausted"] + } + } + } + }, + "verification": { + "type": "object", + "required": ["status", "findings"], + "properties": { + "status": { "enum": ["verified", "failed"] } + } + }, + "rfq_linear": { + "type": "object", + "required": [ + "schema_version", + "source_cutlist_result_version", + "project_id", + "revision_id", + "estimate_input_hash", + "rows" + ], + "properties": { + "schema_version": { "const": "1.0.0" }, + "source_cutlist_result_version": { "const": "1.0.0" } + } + } + }, + "$defs": { + "sha256": { "type": "string", "pattern": "^[0-9a-f]{64}$" }, + "barReport": { + "type": "object", + "required": [ + "index", + "stock_id", + "designation", + "grade", + "bar_length_in", + "usable_length_in", + "end_trim_in", + "cuts", + "num_cuts", + "cut_length_in", + "kerf_total_in", + "drop_in", + "drop_class", + "utilization_pct", + "bar_cost", + "cost_basis" + ], + "properties": { + "drop_class": { "enum": ["reusable_candidate", "offcut"] }, + "cuts": { + "type": "array", + "items": { + "type": "object", + "required": [ + "sequence", + "item_id", + "source_id", + "instance_id", + "placement_id", + "label", + "designation", + "grade", + "length_in", + "weight_lbs", + "weight_basis" + ], + "properties": { + "weight_basis": { + "enum": ["declared", "aisc_database", "unknown"] + } + } + } + } + } + } + } +} diff --git a/skills/_shared/schemas/nest-result.schema.json b/skills/_shared/schemas/nest-result.schema.json index e141a07..323b1af 100644 --- a/skills/_shared/schemas/nest-result.schema.json +++ b/skills/_shared/schemas/nest-result.schema.json @@ -55,6 +55,8 @@ "validated", "nested_partial", "nest_verified", + "cutlist_partial", + "cutlist_verified", "rfq_draft_review_required", "rfq_ready_for_review" ] diff --git a/skills/steel-cutlist/SKILL.md b/skills/steel-cutlist/SKILL.md new file mode 100644 index 0000000..1de06ff --- /dev/null +++ b/skills/steel-cutlist/SKILL.md @@ -0,0 +1,88 @@ +--- +name: steel-cutlist +description: "Optimize cut lengths of beams, channels, angles, HSS, tube, pipe, and any long product purchased by length — the 1D bar-nesting step. Use this skill whenever someone mentions cutting stock lengths, bar optimization, mill lengths, how many sticks/bars/lengths to buy, member cut lists, saw schedules, drops, or minimizing offcut waste on linear material. Produces a verified per-bar cutting plan, purchase summary by stock length, drop candidates, utilization, member weights, and optional cost totals only when an explicit basis exists." +--- + +# Steel Linear Cut-List Optimization + +## What This Skill Does + +This is the length-optimization step for long products: it packs required member lengths onto purchasable stock bars (mill lengths), accounting for saw kerf and end trim, then independently verifies every bar before publishing a cutting list. It answers "how many sticks do I buy, at which lengths, and how do I cut them" with a repeatable, reviewable result. + +It complements `steel-nest` (2D plates). Plates go to `steel-nest`; anything bought by the foot goes here. + +## What It Does Well vs. What It Doesn't + +**Reliable:** +- Exact 1D packing per designation + grade group. Stock never crosses groups: a W12X26 member is only cut from W12X26 stock of the same grade. +- A deterministic strategy portfolio per group — a mixed-stock greedy plus each single-stock-length restriction — ranked by fewest unplaced members, least total stock length, lowest known purchase cost, then fewest bars. The same input always produces the same plan. +- Explicit fit contract: usable length = bar length − 2 × end trim; a piece fits when its length alone fits the remainder; each placed piece then consumes its length plus one kerf, saturating at the bar end. +- Independent post-placement verification (bar overcommitment, material mismatch, duplicate or missing instances) before any cutting list is published. +- Drops classified against a reusable-candidate threshold (`min_drop_in`) — candidates are never certified reusable stock. +- Member weights from explicit `unit_weight_plf` or the bundled AISC shape database; unknown weights are a visible warning, never a silent zero. +- Optional cost by one explicit basis per stock entry (`cost_per_ft` or `cost_per_bar`, never both). +- Labeled bar diagrams (PDF + PNG) and a per-bar `cutting_list.csv` emitted only for fully verified, fully placed runs. + +**Deliberately NOT done:** +- No saw-controller programs or claims of machine-specific compatibility; the cutting list is a shop document that an operator verifies. +- No remnant-inventory or scrap-market optimization. Drop candidates need a person to measure, identify, and approve before they become stock. +- No true global optimum guarantee — the portfolio heuristic is strong and deterministic, but it is a heuristic; say so if asked. + +## Inputs to Gather + +Everything drives a single job JSON (schema in `references/job_template.json`; a worked example in `references/example_job.json`). + +1. **Members** — for each line: name/mark, designation (e.g. `W12X26`, `HSS6X6X1/2`, `L4X4X1/4`), grade, required length (exactly one of `length_in` or `length_ft`; `length_ft` accepts `28.5` or `"28'-6"`), and quantity. Optional `unit_weight_plf` overrides the AISC lookup. +2. **Stock** — for each purchasable bar length: designation, grade, length (`length_in` or `length_ft`), and finite `qty` or `"unlimited": true`. Common mill lengths: 20, 25, 30, 35, 40, 45, 50, 55, 60 ft. A price is optional; if provided, use exactly one basis (`cost_per_ft` or `cost_per_bar`). +3. **Cut settings** — `kerf_in` (band saw ~0.06", cold saw ~0.09", abrasive/miter ~0.19"), `end_trim_in` per bar end (mill-end cleanup, default 0.25"), and `min_drop_in` (drops at or above this length are reported as reusable candidates, default 24"). + +If a takeoff/BOM spreadsheet is provided, map its columns to member fields and confirm the interpretation before running. Do not silently guess quantities or lengths. + +## How to Run + +Write the job JSON, then run the engine: + +```bash +python3 scripts/cutlist.py --job --out +``` + +The designation, grade, and imperial unit basis must be explicit; the engine never infers them from display names. `` is a publication root: every invocation creates `/runs//` and atomically updates `/latest-run.json`; follow that pointer to find the current run. + +Each run contains: + +- `run-manifest.json` and `qa-report.json` — outcome, readiness, hashes, warnings, and the exact artifact allow-list +- `layout.pdf` — every bar drawn with its cuts and drop, plus a summary page (the main deliverable) +- `bars.png` — the bar diagram as one image +- `cutting_list.csv` — the per-bar cut sequence; present only when the run is `ready` (verified and fully placed) +- `rfq_linear.json` — versioned `1.0.0` linear-stock rows for the `steel-rfq` hand-off; absent on blocked runs +- `report.txt` — the text report +- `result.json` — schema-versioned result with normalized input/configuration hashes, algorithm version, bar reports, verifier findings, purchase summary, drops, and cost status + +Exit meanings: + +- `0` — `ready`; every member is placed and the plan passed independent verification +- `3` — `blocked`; validation errors or unplaced members prevented a cutting list +- `4` — a required runtime capability is missing +- `1` — usage or internal error + +Install the declared dependencies from the package root with `python3 -m pip install -r requirements-dev.txt`. + +## What to Deliver + +Always deliver the **PDF layout** and give the headline numbers in the message: bars to buy by length, length utilization, drop candidates, weight status, and cost status. Attach `cutting_list.csv` when it exists and state that saw-operator verification is still required. + +Verify before presenting: require `verification.status = verified`, reconcile known cost to its recorded per-foot or per-bar basis, and never turn a missing or incomplete cost into `$0`. If members did not fit, lead with what is unplaced and why — never present a partial plan as complete. + +## Integration with steel-rfq + +The engine writes `rfq_linear.json` — `{schema_version, source_cutlist_result_version, rows}` with one row per stock identity: bars needed, bar length, total feet, cutting plan, and drop notes. Rows stay separate by stock identity even when display names match. Resolve the current run through `latest-run.json` and reject unknown handoff versions. + +## Common Variations + +**Mixed designations in one order** — list them all; the engine forms independent designation + grade groups and optimizes each on its own stock. + +**"Just tell me how many sticks"** — still run it; the purchase summary is the answer. Cost remains absent unless an explicit basis is supplied. + +**Drop reuse** — output drops are candidates only. Measure, identify, and approve a candidate before supplying it as its own finite stock entry in a later run (a shorter `length_in` entry with `qty: 1`). + +**On-hand material first** — enter on-hand bars as a finite-quantity stock entry alongside purchasable lengths; the portfolio will use them when they reduce total length or cost. diff --git a/skills/steel-cutlist/references/FIXTURE_PROVENANCE.md b/skills/steel-cutlist/references/FIXTURE_PROVENANCE.md new file mode 100644 index 0000000..bf8b87c --- /dev/null +++ b/skills/steel-cutlist/references/FIXTURE_PROVENANCE.md @@ -0,0 +1,13 @@ +# Fixture Provenance + +`example_job.json` and `job_template.json` are synthetic public examples created +from scratch for pi-steel documentation and tests. They are not copied, transformed, +rounded, renamed, or anonymized from a company, customer, vendor, bid, drawing, +takeoff, inventory record, or production job. Designations are standard public +AISC shape names; prices are invented round numbers. + +- Creator: StructuPath pi-steel maintainers +- Creation method: deliberately invented lengths, quantities, and identifiers +- Public-data review: 2026-08-23 +- Commercial data: none +- Private source artifacts: none diff --git a/skills/steel-cutlist/references/example_job.json b/skills/steel-cutlist/references/example_job.json new file mode 100644 index 0000000..a7d4645 --- /dev/null +++ b/skills/steel-cutlist/references/example_job.json @@ -0,0 +1,17 @@ +{ + "job_name": "SYNTHETIC-CUTLIST", + "project_id": "SYNTH-PRJ", + "revision_id": "REV-A", + "unit_system": "imperial", + "settings": {"kerf_in": 0.125, "end_trim_in": 0.25, "min_drop_in": 24}, + "members": [ + {"source_id": "S1", "name": "B1", "designation": "W12X26", "grade": "A992", "length_ft": 28.5, "qty": 4}, + {"source_id": "S2", "name": "B2", "designation": "W12X26", "grade": "A992", "length_ft": "12'-6", "qty": 6}, + {"source_id": "S3", "name": "C1", "designation": "HSS6X6X1/2", "grade": "A500B", "length_in": 174, "qty": 3, "unit_weight_plf": 35.24} + ], + "stock": [ + {"stock_id": "STK-40", "designation": "W12X26", "grade": "A992", "length_ft": 40, "unlimited": true, "cost_per_ft": 31.2}, + {"stock_id": "STK-50", "designation": "W12X26", "grade": "A992", "length_ft": 50, "unlimited": true, "cost_per_ft": 39.0}, + {"stock_id": "STK-HSS", "designation": "HSS6X6X1/2", "grade": "A500B", "length_ft": 48, "qty": 2, "cost_per_bar": 1900} + ] +} diff --git a/skills/steel-cutlist/references/job_template.json b/skills/steel-cutlist/references/job_template.json new file mode 100644 index 0000000..803f98b --- /dev/null +++ b/skills/steel-cutlist/references/job_template.json @@ -0,0 +1,53 @@ +{ + "_comment": "steel-cutlist job template. Copy, fill in, and delete the _comment keys. Lengths: provide exactly one of length_in or length_ft per entry; length_ft accepts a number (28.5) or a feet-inches string (\"28'-6\").", + "job_name": "JOB NAME", + "customer": "", + "project_id": "PROJECT-ID", + "revision_id": "REV-0", + "unit_system": "imperial", + "settings": { + "_comment": "kerf_in: band saw ~0.06, cold saw ~0.09, abrasive ~0.19. end_trim_in applies per bar end. Drops >= min_drop_in are reported as reusable candidates.", + "kerf_in": 0.125, + "end_trim_in": 0.25, + "min_drop_in": 24 + }, + "members": [ + { + "source_id": "STABLE-SOURCE-ID", + "name": "B1", + "designation": "W12X26", + "grade": "A992", + "length_ft": 28.5, + "qty": 4 + }, + { + "_comment": "unit_weight_plf is optional; when omitted the bundled AISC database is consulted by designation.", + "source_id": "STABLE-SOURCE-ID-2", + "name": "C1", + "designation": "HSS6X6X1/2", + "grade": "A500B", + "length_in": 174, + "qty": 3, + "unit_weight_plf": 35.24 + } + ], + "stock": [ + { + "stock_id": "STK-W12X26-40", + "designation": "W12X26", + "grade": "A992", + "length_ft": 40, + "unlimited": true, + "cost_per_ft": 31.2 + }, + { + "_comment": "Finite quantities model on-hand material or vendor availability. Use exactly one cost basis: cost_per_ft or cost_per_bar.", + "stock_id": "STK-HSS-48", + "designation": "HSS6X6X1/2", + "grade": "A500B", + "length_ft": 48, + "qty": 2, + "cost_per_bar": 1900 + } + ] +} diff --git a/skills/steel-cutlist/scripts/cutlist.py b/skills/steel-cutlist/scripts/cutlist.py new file mode 100644 index 0000000..aac87ea --- /dev/null +++ b/skills/steel-cutlist/scripts/cutlist.py @@ -0,0 +1,1467 @@ +#!/usr/bin/env python3 +""" +Steel linear cut-list engine (1D bar nesting for members) +========================================================== +Deterministic length optimization for long products: beams, channels, angles, +HSS, tube, pipe, bar, and any stock purchased by length and cut to member +lengths. + +Reliable: + * Packs required member lengths onto stock bars with a best-fit-decreasing + heuristic plus simulated new-bar selection across multiple stock lengths. + * Explicit saw kerf per cut and end-trim allowance per bar end. + * Independent post-placement verification re-checks every bar against the + declared fit model before any cutting list is published. + * Reports drops (with a reusable-candidate threshold), utilization, member + weights from explicit plf values or the bundled AISC shape database, and + optional reconciled purchase cost. + * Labeled bar diagrams (PNG + combined PDF) and a per-bar cutting list CSV + that is only emitted for fully verified, fully placed runs. + +Deliberately NOT done: + * No cut sequencing for a specific saw controller and no claim of + machine-specific compatibility; the cutting list is a shop document. + * No scrap-market or remnant-inventory optimization. Drops are reported as + unverified candidates for a person to disposition. + +Fit model (documented contract): + * usable bar length = stock length - 2 x end_trim_in + * a piece fits when its length alone fits in the remaining usable length + * each placed piece then consumes its length plus one kerf width, + saturating at the bar end (the final cut coincides with the end trim) + +Usage: + python3 cutlist.py --job job.json --out published/ + +The output root receives isolated runs// directories plus a +latest-run.json pointer. Exit 0 is ready, 2 requires review, and 3 is blocked. +""" + +import argparse +import csv +import importlib.util +import io +import json +import math +import os +import sys +from collections import defaultdict +from pathlib import Path + + +SHARED_ROOT = Path(__file__).resolve().parents[2] / "_shared" +if str(SHARED_ROOT) not in sys.path: + sys.path.insert(0, str(SHARED_ROOT)) +from bootstrap import bootstrap_shared # noqa: E402 + +SKILLS_ROOT = bootstrap_shared(__file__) +from pi_steel import ( # noqa: E402 + RunPublisher, + StageArgumentParser, + canonical_json_bytes, + item_id_for, + outcome_exit_code, + package_version, + placement_ids, + publish_failure_diagnostic, + sha256_bytes, +) +from pi_steel.contracts import content_hash, fallback_source_id, instance_ids # noqa: E402 +from pi_steel.parsing import parse_length_ft # noqa: E402 + +CUTLIST_RESULT_VERSION = "1.0.0" +CUTLIST_ALGORITHM_VERSION = "portfolio-bfd-v1" +EPS = 1e-6 +AISC_DATABASE_RELATIVE = Path("steel-takeoff") / "assets" / "aisc-shapes-database.json" + + +def _valid_hash(value): + return ( + isinstance(value, str) + and len(value) == 64 + and all(character in "0123456789abcdef" for character in value) + ) + + +def normalize_designation(value): + """Canonical AISC-style designation key: uppercase, no spaces.""" + if not isinstance(value, str): + return "" + return value.upper().replace(" ", "") + + +def load_unit_weights(): + """Map normalized designation -> weight_per_ft from the bundled AISC data.""" + database_path = SKILLS_ROOT / AISC_DATABASE_RELATIVE + try: + rows = json.loads(database_path.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError): + return {} + weights = {} + for row in rows: + designation = normalize_designation(row.get("designation", "")) + weight = row.get("weight_per_ft") + if designation and isinstance(weight, (int, float)) and weight > 0: + weights[designation] = float(weight) + return weights + + +def _validation_finding(code, path, message, severity="error"): + return { + "code": code, + "severity": severity, + "path": path, + "message": message, + } + + +def _finite_positive(value): + return isinstance(value, (int, float)) and math.isfinite(value) and value > 0 + + +# -------------------------------------------------------------------------- +# Normalization +# -------------------------------------------------------------------------- +def _length_in(entry, path, findings): + """Resolve an explicit imperial length from length_in or length_ft.""" + has_inches = entry.get("length_in") is not None + has_feet = entry.get("length_ft") is not None + if has_inches == has_feet: + findings.append( + _validation_finding( + "ambiguous_length_basis", + path, + "Provide exactly one of length_in or length_ft.", + ) + ) + return 0.0 + try: + if has_inches: + parsed = float(entry["length_in"]) + else: + raw = entry["length_ft"] + parsed = ( + parse_length_ft(raw) if isinstance(raw, str) else float(raw) + ) * 12.0 + except (TypeError, ValueError): + parsed = math.nan + if not math.isfinite(parsed) or parsed <= 0: + findings.append( + _validation_finding( + "invalid_length", + path, + "Length must be finite and greater than zero.", + ) + ) + return 0.0 + return parsed + + +def normalize_job(job): + """Normalize and validate the direct-use JSON before any placement.""" + findings = [] + settings = job.get("settings", {}) + + def number(value, path, *, positive=False, nonnegative=False): + try: + parsed = float(value) + except (TypeError, ValueError): + parsed = math.nan + valid = math.isfinite(parsed) + if positive: + valid = valid and parsed > 0 + if nonnegative: + valid = valid and parsed >= 0 + if not valid: + findings.append( + _validation_finding( + "invalid_numeric_input", + path, + "Value must be finite" + + (" and greater than zero." if positive else " and non-negative."), + ) + ) + return 0.0 + return parsed + + kerf = number(settings.get("kerf_in", 0.125), "$.settings.kerf_in", nonnegative=True) + end_trim = number( + settings.get("end_trim_in", 0.25), + "$.settings.end_trim_in", + nonnegative=True, + ) + min_drop = number( + settings.get("min_drop_in", 24.0), + "$.settings.min_drop_in", + nonnegative=True, + ) + unit_system = job.get("unit_system") + if unit_system is None: + findings.append( + _validation_finding( + "missing_unit_basis", + "$.unit_system", + "The cut-list engine requires an explicit imperial unit basis.", + ) + ) + elif unit_system != "imperial": + findings.append( + _validation_finding( + "unsupported_unit_system", + "$.unit_system", + "The cut-list engine currently requires imperial units.", + ) + ) + + project_id = job.get("project_id") or job.get("job_name") or "LEGACY-CUTLIST" + revision_id = job.get("revision_id", "LEGACY-REVISION") + for identity_field, value in ( + ("project_id", project_id), + ("revision_id", revision_id), + ): + if not isinstance(value, str) or not value: + findings.append( + _validation_finding( + f"invalid_{identity_field}", + f"$.{identity_field}", + f"{identity_field} must be a non-empty string.", + ) + ) + if identity_field == "project_id": + project_id = "LEGACY-CUTLIST" + else: + revision_id = "LEGACY-REVISION" + estimate_input_hash = job.get("estimate_input_hash") + if estimate_input_hash is not None and not _valid_hash(estimate_input_hash): + findings.append( + _validation_finding( + "invalid_estimate_input_hash", + "$.estimate_input_hash", + "Estimate input hash must be a lowercase SHA-256 value.", + ) + ) + estimate_input_hash = None + + default_grade = job.get("grade") + unit_weights = load_unit_weights() + members = [] + for index, member in enumerate(job.get("members", [])): + path = f"$.members[{index}]" + designation = normalize_designation(member.get("designation", "")) + if not designation: + findings.append( + _validation_finding( + "missing_designation", + f"{path}.designation", + "Member designation is required for stock compatibility.", + ) + ) + grade = member.get("grade", default_grade) + if not grade: + findings.append( + _validation_finding( + "missing_material_basis", + f"{path}.grade", + "Member grade must be explicit before placement.", + ) + ) + length = _length_in(member, path, findings) + try: + quantity = int(member.get("qty", 1)) + quantity_valid = quantity > 0 and quantity == float(member.get("qty", 1)) + except (TypeError, ValueError): + quantity, quantity_valid = 0, False + if not quantity_valid: + findings.append( + _validation_finding( + "invalid_quantity", f"{path}.qty", "Quantity must be a positive integer." + ) + ) + unit_weight = member.get("unit_weight_plf") + weight_basis = "declared" + if unit_weight is not None: + unit_weight = number( + unit_weight, f"{path}.unit_weight_plf", positive=True + ) + elif designation in unit_weights: + unit_weight = unit_weights[designation] + weight_basis = "aisc_database" + else: + weight_basis = "unknown" + findings.append( + _validation_finding( + "unknown_unit_weight", + f"{path}.unit_weight_plf", + ( + f"No unit weight declared and {designation or 'the designation'} " + "is not in the bundled AISC data; weights are incomplete." + ), + severity="warning", + ) + ) + explicit_source = member.get("source_id") + source_id = explicit_source or fallback_source_id( + revision_id, + { + key: member.get(key) + for key in ("name", "designation", "grade", "length_in", "length_ft") + }, + ) + item_id = member.get("item_id") or item_id_for( + project_id, revision_id, source_id + ) + members.append( + { + "source_id": source_id, + "item_id": item_id, + "label": member.get("name", source_id), + "designation": designation, + "grade": grade, + "length_in": length, + "quantity": quantity, + "unit_weight_plf": unit_weight, + "weight_basis": weight_basis, + } + ) + + stock_types = [] + for index, stock in enumerate(job.get("stock", [])): + path = f"$.stock[{index}]" + designation = normalize_designation(stock.get("designation", "")) + if not designation: + findings.append( + _validation_finding( + "missing_designation", + f"{path}.designation", + "Stock designation is required for member compatibility.", + ) + ) + grade = stock.get("grade", default_grade) + if not grade: + findings.append( + _validation_finding( + "missing_material_basis", + f"{path}.grade", + "Stock grade must be explicit.", + ) + ) + length = _length_in(stock, path, findings) + usable = length - 2 * end_trim + if length > 0 and usable <= EPS: + findings.append( + _validation_finding( + "unusable_stock_length", + f"{path}", + "End trim consumes the entire stock length.", + ) + ) + unlimited = bool(stock.get("unlimited", False)) + try: + quantity = math.inf if unlimited else int(stock.get("qty", 1)) + quantity_valid = unlimited or ( + quantity >= 0 and quantity == float(stock.get("qty", 1)) + ) + except (TypeError, ValueError): + quantity, quantity_valid = 0, False + if not quantity_valid: + findings.append( + _validation_finding( + "invalid_stock_quantity", + f"{path}.qty", + "Stock quantity must be a non-negative integer or unlimited.", + ) + ) + per_foot = stock.get("cost_per_ft") + per_bar = stock.get("cost_per_bar") + if per_foot is not None and per_bar is not None: + findings.append( + _validation_finding( + "conflicting_cost_basis", + path, + "Use either cost_per_ft or cost_per_bar for one stock entry, not both.", + ) + ) + if per_foot is not None: + per_foot = number(per_foot, f"{path}.cost_per_ft", nonnegative=True) + if per_bar is not None: + per_bar = number(per_bar, f"{path}.cost_per_bar", nonnegative=True) + stock_id = stock.get("stock_id") or ( + "stock:" + + content_hash( + { + "name": stock.get("name", "Bar"), + "designation": designation, + "grade": grade, + "length_in": length, + } + )[:24] + ) + stock_types.append( + { + "stock_id": stock_id, + "name": stock.get("name", designation or "Bar"), + "designation": designation, + "grade": grade, + "length_in": length, + "usable_in": max(usable, 0.0), + "qty": quantity, + "cost_per_ft": per_foot, + "cost_per_bar": per_bar, + "used": 0, + } + ) + + for collection_name, values, identity_field in ( + ("members", members, "item_id"), + ("stock", stock_types, "stock_id"), + ): + seen = {} + for index, value in enumerate(values): + identity = value[identity_field] + if identity in seen: + findings.append( + _validation_finding( + f"duplicate_{identity_field}", + f"$.{collection_name}[{index}].{identity_field}", + ( + f"{identity_field} duplicates row {seen[identity]}; " + "indistinguishable rows are not merged." + ), + ) + ) + else: + seen[identity] = index + members.sort(key=lambda member: member["item_id"]) + stock_types.sort(key=lambda stock: stock["stock_id"]) + if not members: + findings.append( + _validation_finding( + "missing_members", "$.members", "At least one member is required." + ) + ) + if not stock_types: + findings.append( + _validation_finding( + "missing_stock", "$.stock", "At least one stock entry is required." + ) + ) + normalized = { + "job_name": job.get("job_name", "Cut-list job"), + "customer": job.get("customer", ""), + "project_id": project_id, + "revision_id": revision_id, + "estimate_input_hash": estimate_input_hash, + "unit_system": unit_system or "unspecified", + "settings": { + "kerf_in": kerf, + "end_trim_in": end_trim, + "min_drop_in": min_drop, + }, + "members": members, + "stock": [ + { + key: ("unlimited" if key == "qty" and math.isinf(value) else value) + for key, value in stock.items() + if key != "used" + } + for stock in stock_types + ], + } + return normalized, stock_types, findings + + +# -------------------------------------------------------------------------- +# Placement engine +# -------------------------------------------------------------------------- +def _group_key(value): + return value["designation"], value["grade"] + + +def _simulate_fill(usable, kerf, queue): + """Greedy first-fit of a sorted queue onto one bar; returns used length.""" + remaining = usable + used = 0.0 + for length in queue: + if length <= remaining + EPS: + used += length + remaining -= length + kerf + if remaining < 0: + remaining = 0.0 + return used + + +def _place_on_bar(bar, unit, kerf): + bar["cuts"].append(unit) + bar["remaining"] -= unit["length_in"] + kerf + if bar["remaining"] < 0: + bar["remaining"] = 0.0 + + +def _greedy_pack(units, stocks, kerf, classification_stock): + """Best-fit-decreasing over open bars; new bars open by simulated fill. + + ``stocks`` restricts which stock this attempt may open; unplaceable + reasons are classified against ``classification_stock`` (the full group) + so restricted attempts never mislabel a genuinely placeable member. + """ + used = {stock["stock_id"]: 0 for stock in stocks} + bars = [] + unplaced = [] + pending = [unit["length_in"] for unit in units] + + def open_bar(unit): + best = None + best_score = None + for stock in stocks: + if used[stock["stock_id"]] >= stock["qty"]: + continue + if unit["length_in"] > stock["usable_in"] + EPS: + continue + fill = _simulate_fill(stock["usable_in"], kerf, pending) + fill_ratio = fill / stock["usable_in"] if stock["usable_in"] else 0.0 + score = (round(fill_ratio, 9), round(fill, 6), stock["stock_id"]) + if best_score is None or score > best_score: + best_score = score + best = stock + if best is None: + return None + used[best["stock_id"]] += 1 + bar = {"stock": best, "cuts": [], "remaining": best["usable_in"]} + bars.append(bar) + return bar + + for unit in units: + fits_group = any( + unit["length_in"] <= stock["usable_in"] + EPS + for stock in classification_stock + ) + fits_attempt = any( + unit["length_in"] <= stock["usable_in"] + EPS for stock in stocks + ) + if not fits_group or not fits_attempt: + reason = "no_compatible_stock_fit" if not fits_group else "stock_exhausted" + unplaced.append({**unit, "reason": reason}) + pending.remove(unit["length_in"]) + continue + best_bar = None + best_leftover = math.inf + for bar in bars: + if unit["length_in"] > bar["remaining"] + EPS: + continue + leftover = bar["remaining"] - unit["length_in"] + if leftover < best_leftover - EPS: + best_leftover = leftover + best_bar = bar + if best_bar is None: + best_bar = open_bar(unit) + if best_bar is None: + unplaced.append({**unit, "reason": "stock_exhausted"}) + else: + _place_on_bar(best_bar, unit, kerf) + pending.remove(unit["length_in"]) + return bars, unplaced + + +def _bar_cost(stock): + if stock["cost_per_bar"] is not None: + return stock["cost_per_bar"] + if stock["cost_per_ft"] is not None: + return stock["cost_per_ft"] * stock["length_in"] / 12.0 + return None + + +def _solve_group(units, group_stock, kerf): + """Try a portfolio of deterministic strategies; keep the cheapest result. + + Candidates: the mixed-stock greedy plus each single-stock-length + restriction. Solutions rank by fewest unplaced members, least total stock + length, lowest known purchase cost (unknown costs rank last), then fewest + bars. Ties resolve by strategy name for determinism. + """ + strategies = [("mixed", group_stock)] + for stock in group_stock: + strategies.append((f"single:{stock['stock_id']}", [stock])) + best = None + best_rank = None + for name, stocks in strategies: + bars, unplaced = _greedy_pack(units, stocks, kerf, group_stock) + total_length = sum(bar["stock"]["length_in"] for bar in bars) + costs = [_bar_cost(bar["stock"]) for bar in bars] + cost_rank = ( + round(sum(costs), 2) if costs and None not in costs else math.inf + ) + rank = ( + sum(1 for _ in unplaced), + round(total_length, 6), + cost_rank, + len(bars), + name, + ) + if best_rank is None or rank < best_rank: + best_rank = rank + best = (bars, unplaced) + return best if best is not None else ([], []) + + +def run_job(job): + normalized, stock_types, validation_findings = normalize_job(job) + settings = normalized["settings"] + kerf = settings["kerf_in"] + normalized_hash = sha256_bytes(canonical_json_bytes(normalized)) + blockers = [ + finding + for finding in validation_findings + if finding["severity"] == "error" + ] + if blockers: + return _summarize( + normalized, [], [], validation_findings, normalized_hash + ) + + units = [] + for member in normalized["members"]: + member_instances = instance_ids(member["item_id"], member["quantity"]) + member_placements = placement_ids(member["item_id"], member["quantity"]) + for index in range(member["quantity"]): + units.append( + { + **member, + "instance_id": member_instances[index], + "placement_id": member_placements[index], + } + ) + units.sort(key=lambda unit: (-unit["length_in"], unit["instance_id"])) + + units_by_group = defaultdict(list) + for unit in units: + units_by_group[_group_key(unit)].append(unit) + stock_by_group = defaultdict(list) + for stock in stock_types: + stock_by_group[_group_key(stock)].append(stock) + + bars = [] + unplaced_units = [] + for key in sorted(units_by_group, key=repr): + group_bars, group_unplaced = _solve_group( + units_by_group[key], stock_by_group.get(key, []), kerf + ) + bars.extend(group_bars) + unplaced_units.extend(group_unplaced) + + used_bars = [bar for bar in bars if bar["cuts"]] + for index, bar in enumerate(used_bars, 1): + bar["index"] = index + return _summarize( + normalized, + used_bars, + _aggregate_unplaced(unplaced_units), + validation_findings, + normalized_hash, + ) + + +def _aggregate_unplaced(units): + grouped = {} + for unit in units: + key = (unit["item_id"], unit["reason"]) + row = grouped.setdefault( + key, + { + "item_id": unit["item_id"], + "label": unit["label"], + "designation": unit["designation"], + "grade": unit["grade"], + "length_in": unit["length_in"], + "quantity": 0, + "reason": unit["reason"], + }, + ) + row["quantity"] += 1 + return sorted(grouped.values(), key=lambda row: (row["item_id"], row["reason"])) + + +# -------------------------------------------------------------------------- +# Independent verification +# -------------------------------------------------------------------------- +def verify_cutlist_bars(bar_reports, *, kerf, expected_instances): + """Re-check published bars against the declared fit model and coverage.""" + findings = [] + seen_instances = set() + for bar in bar_reports: + cuts = bar["cuts"] + lengths = [cut["length_in"] for cut in cuts] + minimum_consumed = sum(lengths) + kerf * max(len(lengths) - 1, 0) + if minimum_consumed > bar["usable_length_in"] + 1e-6: + findings.append( + _validation_finding( + "BAR_OVERCOMMITTED", + f"$.bar_reports[{bar['index'] - 1}]", + ( + f"Bar {bar['index']} cuts plus kerf exceed its usable " + "length." + ), + ) + ) + for cut_index, cut in enumerate(cuts): + if (cut["designation"], cut["grade"]) != ( + bar["designation"], + bar["grade"], + ): + findings.append( + _validation_finding( + "BAR_MATERIAL_MISMATCH", + f"$.bar_reports[{bar['index'] - 1}].cuts[{cut_index}]", + "Cut designation or grade does not match its bar.", + ) + ) + if cut["instance_id"] in seen_instances: + findings.append( + _validation_finding( + "DUPLICATE_PLACEMENT", + f"$.bar_reports[{bar['index'] - 1}].cuts[{cut_index}]", + f"Instance {cut['instance_id']} is cut more than once.", + ) + ) + seen_instances.add(cut["instance_id"]) + missing = expected_instances - seen_instances + extra = seen_instances - expected_instances + for instance_id in sorted(missing): + findings.append( + _validation_finding( + "INSTANCE_UNACCOUNTED", + "$.bar_reports", + f"Instance {instance_id} is neither cut nor reported unplaced.", + ) + ) + for instance_id in sorted(extra): + findings.append( + _validation_finding( + "INSTANCE_UNEXPECTED", + "$.bar_reports", + f"Instance {instance_id} does not belong to this job.", + ) + ) + return findings + + +# -------------------------------------------------------------------------- +# Summary +# -------------------------------------------------------------------------- +def _summarize(normalized, used_bars, unplaced, validation_findings, normalized_hash): + settings = normalized["settings"] + kerf = settings["kerf_in"] + min_drop = settings["min_drop_in"] + estimate_input_hash = normalized.get("estimate_input_hash") or normalized_hash + + bar_reports = [] + total_stock_length = 0.0 + total_cut_length = 0.0 + total_weight_known = True + total_cut_weight = 0.0 + total_cost = 0.0 + all_costs_known = bool(used_bars) + for bar in used_bars: + stock = bar["stock"] + cuts = [] + cut_length = 0.0 + bar_weight_known = True + bar_cut_weight = 0.0 + for cut_index, unit in enumerate(bar["cuts"], 1): + weight = ( + unit["unit_weight_plf"] * unit["length_in"] / 12.0 + if unit["unit_weight_plf"] is not None + else None + ) + if weight is None: + bar_weight_known = False + else: + bar_cut_weight += weight + cuts.append( + { + "sequence": cut_index, + "item_id": unit["item_id"], + "source_id": unit["source_id"], + "instance_id": unit["instance_id"], + "placement_id": unit["placement_id"], + "label": unit["label"], + "designation": unit["designation"], + "grade": unit["grade"], + "length_in": unit["length_in"], + "weight_lbs": None if weight is None else round(weight, 1), + "weight_basis": unit["weight_basis"], + } + ) + cut_length += unit["length_in"] + kerf_total = kerf * len(cuts) + used_length = min(cut_length + kerf_total, stock["usable_in"]) + drop = max(stock["usable_in"] - used_length, 0.0) + if stock["cost_per_bar"] is not None: + bar_cost = stock["cost_per_bar"] + cost_basis = "per_bar" + elif stock["cost_per_ft"] is not None: + bar_cost = stock["cost_per_ft"] * stock["length_in"] / 12.0 + cost_basis = "per_foot" + else: + bar_cost = None + cost_basis = None + all_costs_known = False + if bar_cost is not None: + total_cost += bar_cost + report = { + "index": bar["index"], + "stock": stock["name"], + "stock_id": stock["stock_id"], + "designation": stock["designation"], + "grade": stock["grade"], + "bar_length_in": stock["length_in"], + "usable_length_in": stock["usable_in"], + "end_trim_in": settings["end_trim_in"], + "cuts": cuts, + "num_cuts": len(cuts), + "cut_length_in": round(cut_length, 3), + "kerf_total_in": round(kerf_total, 3), + "drop_in": round(drop, 3), + "drop_class": ( + "reusable_candidate" if drop >= min_drop - EPS else "offcut" + ), + "utilization_pct": round( + 100 * cut_length / stock["length_in"], 1 + ) + if stock["length_in"] + else 0.0, + "cut_weight_lbs": ( + round(bar_cut_weight, 1) if bar_weight_known else None + ), + "bar_cost": None if bar_cost is None else round(bar_cost, 2), + "cost_basis": cost_basis, + } + bar_reports.append(report) + total_stock_length += stock["length_in"] + total_cut_length += cut_length + if bar_weight_known: + total_cut_weight += bar_cut_weight + else: + total_weight_known = False + + expected_instances = set() + for member in normalized["members"]: + expected_instances.update( + instance_ids(member["item_id"], member["quantity"]) + ) + for row in unplaced: + prefix = f"{row['item_id']}:instance:" + matching = sorted( + instance + for instance in expected_instances + if instance.startswith(prefix) + ) + placed_elsewhere = { + cut["instance_id"] + for report in bar_reports + for cut in report["cuts"] + } + removable = [ + instance for instance in matching if instance not in placed_elsewhere + ][-row["quantity"]:] + expected_instances -= set(removable) + + verification_findings = ( + verify_cutlist_bars( + bar_reports, kerf=kerf, expected_instances=expected_instances + ) + if not any( + finding["severity"] == "error" for finding in validation_findings + ) + else [] + ) + + drops = [ + { + "bar_index": report["index"], + "stock_id": report["stock_id"], + "designation": report["designation"], + "grade": report["grade"], + "length_in": report["drop_in"], + "status": "candidate_unverified", + } + for report in bar_reports + if report["drop_class"] == "reusable_candidate" and report["drop_in"] > EPS + ] + drops.sort(key=lambda drop: (-drop["length_in"], drop["bar_index"])) + + purchase_rows = defaultdict( + lambda: {"bars": 0, "length_in": 0.0, "cost_known": True, "cost": 0.0} + ) + for report in bar_reports: + row = purchase_rows[report["stock_id"]] + row["bars"] += 1 + row["length_in"] += report["bar_length_in"] + row["stock_name"] = report["stock"] + row["designation"] = report["designation"] + row["grade"] = report["grade"] + row["bar_length_in"] = report["bar_length_in"] + if report["bar_cost"] is None: + row["cost_known"] = False + else: + row["cost"] += report["bar_cost"] + purchase_summary = [ + { + "stock_id": stock_id, + "stock_name": row["stock_name"], + "designation": row["designation"], + "grade": row["grade"], + "bar_length_in": row["bar_length_in"], + "bars_needed": row["bars"], + "total_length_ft": round(row["length_in"] / 12.0, 2), + "total_cost": round(row["cost"], 2) if row["cost_known"] else None, + } + for stock_id, row in sorted(purchase_rows.items()) + ] + + if unplaced: + cost_status, cost_total = "incomplete_unplaced", None + elif all_costs_known: + cost_status, cost_total = "known", round(total_cost, 2) + else: + cost_status, cost_total = "not_provided", None + + metrics = { + "utilization_pct": { + "value": round( + 100 * total_cut_length / total_stock_length + if total_stock_length + else 0, + 1, + ), + "approximation": "exact", + }, + "total_kerf_in": round( + sum(report["kerf_total_in"] for report in bar_reports), 3 + ), + } + configuration_hash = sha256_bytes( + canonical_json_bytes( + { + "algorithm_version": CUTLIST_ALGORITHM_VERSION, + "settings": settings, + "fit_contract": "length-plus-kerf-saturating-v1", + } + ) + ) + result = { + "schema_version": CUTLIST_RESULT_VERSION, + "algorithm_version": CUTLIST_ALGORITHM_VERSION, + "normalized_input_hash": normalized_hash, + "estimate_input_hash": estimate_input_hash, + "configuration_hash": configuration_hash, + "outcome": "blocked", + "package_status": "draft", + "meta": { + "job_name": normalized["job_name"], + "customer": normalized["customer"], + "project_id": normalized["project_id"], + "revision_id": normalized["revision_id"], + "kerf_in": kerf, + "end_trim_in": settings["end_trim_in"], + "min_drop_in": min_drop, + "unit_system": normalized["unit_system"], + }, + "fit_contract": { + "usable_length": "bar_length_minus_two_end_trims", + "piece_fits_when": "length_within_remaining", + "consumption_per_piece": "length_plus_one_kerf_saturating", + }, + "bars_used": len(bar_reports), + "metrics": metrics, + "total_stock_length_ft": round(total_stock_length / 12.0, 2), + "total_cut_length_ft": round(total_cut_length / 12.0, 2), + "total_cut_weight_lbs": ( + round(total_cut_weight, 1) if total_weight_known and bar_reports else None + ), + "weight_status": "known" if total_weight_known else "incomplete", + "cost": {"status": cost_status, "total": cost_total}, + "total_material_cost": cost_total, + "cost_known": cost_status == "known", + "bar_reports": bar_reports, + "purchase_summary": purchase_summary, + "drops": drops, + "unplaced": unplaced, + "validation_findings": validation_findings, + "verification": { + "status": "verified" if not verification_findings else "failed", + "findings": verification_findings, + }, + } + result["rfq_linear"] = rfq_linear_block(result) + outcome, package_status, _ = stage_decision(result) + result["outcome"] = outcome + result["package_status"] = package_status + return result + + +def stage_decision(res): + """Map the independently verified result onto the shared stage contract.""" + findings = list(res.get("validation_findings", [])) + findings.extend( + {**finding, "severity": "error"} + for finding in res.get("verification", {}).get("findings", []) + ) + if res["unplaced"]: + findings.append( + { + "code": "UNPLACED_MEMBERS", + "severity": "error", + "path": "$.unplaced", + "message": ( + f"{sum(row['quantity'] for row in res['unplaced'])} " + "required member(s) remain unplaced." + ), + } + ) + if any(finding["severity"] == "error" for finding in findings): + outcome = "blocked" + package_status = "cutlist_partial" if res["unplaced"] else "draft" + else: + outcome = "ready" + package_status = "cutlist_verified" + return outcome, package_status, findings + + +def _fmt(value): + return f"{value:g}" + + +# -------------------------------------------------------------------------- +# RFQ hand-off block (feeds steel-rfq linear stock rows) +# -------------------------------------------------------------------------- +def rfq_linear_block(res): + """Build the versioned cut-list-to-RFQ handoff grouped by stock.""" + rows = [] + for purchase in res["purchase_summary"]: + matching = [ + report + for report in res["bar_reports"] + if report["stock_id"] == purchase["stock_id"] + ] + cuts = sum(report["num_cuts"] for report in matching) + drop_candidates = [ + drop + for drop in res["drops"] + if drop["stock_id"] == purchase["stock_id"] + ][:3] + drop_text = ( + "; ".join(_fmt(drop["length_in"]) for drop in drop_candidates) or "none" + ) + rows.append( + { + "stock_id": purchase["stock_id"], + "stock_name": purchase["stock_name"], + "designation": purchase["designation"], + "grade": purchase["grade"], + "bar_length_in": purchase["bar_length_in"], + "bars_needed": purchase["bars_needed"], + "total_length_ft": purchase["total_length_ft"], + "utilization_pct": res["metrics"]["utilization_pct"]["value"], + "cutting_plan": ( + f"{purchase['bars_needed']} x " + f"{_fmt(purchase['bar_length_in'])} in bar(s) - {cuts} cuts" + ), + "drop_notes": ( + "Drop candidates (not certified reusable): " + f"{drop_text} in" + ), + "drop_candidates": drop_candidates, + "total_cost": purchase["total_cost"] if not res["unplaced"] else None, + } + ) + return { + "schema_version": "1.0.0", + "source_cutlist_result_version": CUTLIST_RESULT_VERSION, + "project_id": res["meta"]["project_id"], + "revision_id": res["meta"]["revision_id"], + "estimate_input_hash": res["estimate_input_hash"], + "rows": rows, + } + + +# -------------------------------------------------------------------------- +# Text report +# -------------------------------------------------------------------------- +def render_text(res): + meta = res["meta"] + lines = ["=" * 64, f" CUT LIST — {meta['job_name']}"] + if meta.get("customer"): + lines.append(f" Customer: {meta['customer']}") + lines.append("=" * 64) + lines.append( + f" Kerf {meta['kerf_in']}\" | End trim {meta['end_trim_in']}\"/end | " + f"Reusable drop >= {meta['min_drop_in']}\"" + ) + lines.append("") + lines.append(f" Bars used .............. {res['bars_used']}") + utilization = res["metrics"]["utilization_pct"] + lines.append(f" Length utilization ..... {utilization['value']}%") + lines.append(f" Stock length ........... {res['total_stock_length_ft']} ft") + lines.append(f" Cut length ............. {res['total_cut_length_ft']} ft") + if res["total_cut_weight_lbs"] is not None: + lines.append(f" Cut weight ............. {res['total_cut_weight_lbs']} lb") + else: + lines.append(" Cut weight ............. incomplete (missing unit weights)") + if res["cost_known"]: + lines.append(f" Material cost (bars) ... ${res['total_material_cost']:,.2f}") + else: + lines.append(" Material cost .......... (add cost_per_ft or cost_per_bar to stock)") + lines.append("") + lines.append(" " + "-" * 60) + for report in res["bar_reports"]: + lines.append( + f" BAR {report['index']} — {report['stock']} " + f"({_fmt(report['bar_length_in'])} in {report['designation']} {report['grade']})" + ) + for cut in report["cuts"]: + lines.append( + f" {cut['sequence']:>2}. {cut['label']:<22} {_fmt(cut['length_in'])} in" + ) + lines.append( + f" Cuts {report['num_cuts']} | Kerf {report['kerf_total_in']} in | " + f"Drop {report['drop_in']} in ({report['drop_class']}) | " + f"Utilization {report['utilization_pct']}%" + ) + if report["bar_cost"] is not None: + lines.append(f" Cost: ${report['bar_cost']:,.2f}") + lines.append("") + if res["purchase_summary"]: + lines.append(" PURCHASE SUMMARY:") + for row in res["purchase_summary"]: + cost_text = ( + f" ${row['total_cost']:,.2f}" if row["total_cost"] is not None else "" + ) + lines.append( + f" {row['bars_needed']} x {_fmt(row['bar_length_in'])} in " + f"{row['designation']} {row['grade']} " + f"({row['total_length_ft']} ft){cost_text}" + ) + lines.append("") + if res["drops"]: + lines.append(" DROP CANDIDATES (not certified reusable):") + for drop in res["drops"]: + lines.append( + f" Bar {drop['bar_index']}: {_fmt(drop['length_in'])} in " + f"{drop['designation']} {drop['grade']}" + ) + lines.append("") + if res["unplaced"]: + lines.append(" " + "!" * 60) + lines.append(" DID NOT FIT (need more/longer stock):") + for row in res["unplaced"]: + lines.append( + f" - {row['label']} x{row['quantity']} " + f"({_fmt(row['length_in'])} in {row['designation']}; {row['reason']})" + ) + lines.append("") + lines.append("=" * 64) + return "\n".join(lines) + + +# -------------------------------------------------------------------------- +# Cutting list CSV (only for verified, fully placed runs) +# -------------------------------------------------------------------------- +def render_cutting_list_csv(res): + buffer = io.StringIO() + writer = csv.writer(buffer, lineterminator="\n") + writer.writerow( + [ + "Bar", + "Stock", + "Designation", + "Grade", + "Bar_Length_in", + "Seq", + "Mark", + "Cut_Length_in", + "Item_ID", + "Instance_ID", + ] + ) + for report in res["bar_reports"]: + for cut in report["cuts"]: + writer.writerow( + [ + report["index"], + report["stock"], + report["designation"], + report["grade"], + _fmt(report["bar_length_in"]), + cut["sequence"], + cut["label"], + _fmt(cut["length_in"]), + cut["item_id"], + cut["instance_id"], + ] + ) + return buffer.getvalue() + + +# -------------------------------------------------------------------------- +# Visual layout (PNG + combined PDF) +# -------------------------------------------------------------------------- +def render_layout(res, outdir): + import matplotlib + + matplotlib.use("Agg") + import matplotlib.patches as mpatches + import matplotlib.pyplot as plt + from matplotlib.backends.backend_pdf import PdfPages + + end_trim = res["meta"]["end_trim_in"] + kerf = res["meta"]["kerf_in"] + pdf_path = os.path.join(outdir, "layout.pdf") + png_path = os.path.join(outdir, "bars.png") + reports = res["bar_reports"] + + with PdfPages(pdf_path) as pdf: + fig_height = max(2.5, 0.6 * len(reports) + 1.5) + fig, ax = plt.subplots(figsize=(11, fig_height)) + max_length = max( + (report["bar_length_in"] for report in reports), default=1.0 + ) + for row, report in enumerate(reports): + y = len(reports) - row - 1 + bar_length = report["bar_length_in"] + ax.add_patch( + mpatches.Rectangle( + (0, y + 0.1), bar_length, 0.8, fill=False, lw=1.5, ec="#222" + ) + ) + cursor = end_trim + for cut in report["cuts"]: + ax.add_patch( + mpatches.Rectangle( + (cursor, y + 0.1), + cut["length_in"], + 0.8, + facecolor="#a9c8e8", + edgecolor="#1a3b5c", + lw=1.0, + alpha=0.9, + ) + ) + ax.text( + cursor + cut["length_in"] / 2, + y + 0.5, + f"{cut['label']}\n{_fmt(cut['length_in'])}\"", + ha="center", + va="center", + fontsize=6, + color="#0c2233", + ) + cursor += cut["length_in"] + kerf + if report["drop_in"] > EPS: + drop_face = ( + "#bde6bd" + if report["drop_class"] == "reusable_candidate" + else "#e8e8e8" + ) + ax.add_patch( + mpatches.Rectangle( + (bar_length - end_trim - report["drop_in"], y + 0.1), + report["drop_in"], + 0.8, + facecolor=drop_face, + edgecolor="#666", + lw=0.5, + alpha=0.7, + ) + ) + ax.text( + -0.01 * max_length, + y + 0.5, + f"BAR {report['index']}", + ha="right", + va="center", + fontsize=8, + fontweight="bold", + ) + ax.set_xlim(-0.12 * max_length, max_length * 1.02) + ax.set_ylim(-0.2, len(reports) + 0.2) + ax.set_yticks([]) + ax.set_xlabel("inches") + ax.set_title( + f"CUT LIST — {res['meta']['job_name']} " + f"({res['bars_used']} bars, " + f"{res['metrics']['utilization_pct']['value']}% utilization)", + fontsize=12, + fontweight="bold", + ) + ax.grid(True, axis="x", lw=0.3, color="#eee") + fig.savefig(png_path, dpi=110, bbox_inches="tight") + pdf.savefig(fig, bbox_inches="tight") + plt.close(fig) + + fig = plt.figure(figsize=(11, 8.5)) + fig.text(0.5, 0.94, "CUT LIST SUMMARY", ha="center", fontsize=18, fontweight="bold") + fig.text( + 0.06, 0.88, render_text(res), family="monospace", fontsize=7.0, va="top" + ) + pdf.savefig(fig) + plt.close(fig) + + return pdf_path, png_path + + +def missing_render_dependencies(): + """Return optional render modules unavailable to this interpreter.""" + return [ + name + for name in ("matplotlib", "numpy") + if importlib.util.find_spec(name) is None + ] + + +# -------------------------------------------------------------------------- +# Publication +# -------------------------------------------------------------------------- +def publish_cutlist_run(job, args): + """Run a cut-list job and publish one isolated, manifested artifact set.""" + result = run_job(job) + missing_dependencies = [] if args.no_render else missing_render_dependencies() + if missing_dependencies: + outcome = "dependency_missing" + package_status = "draft" + findings = [ + { + "code": "RENDER_DEPENDENCY_MISSING", + "severity": "error", + "message": ( + "Rendering requires the missing module(s): " + + ", ".join(missing_dependencies) + ), + } + ] + else: + outcome, package_status, findings = stage_decision(result) + result["outcome"] = outcome + result["run_outcome"] = outcome + result["package_status"] = package_status + report = render_text(result) + + configuration = { + "algorithm_version": CUTLIST_ALGORITHM_VERSION, + "engine_configuration_hash": result["configuration_hash"], + "render": not args.no_render, + } + publication_configuration_hash = sha256_bytes(canonical_json_bytes(configuration)) + qa_report = { + "schema_version": "1.0.0", + "stage": "steel-cutlist", + "run_outcome": outcome, + "package_status": package_status, + "findings": findings, + } + with RunPublisher( + args.out, + stage="steel-cutlist", + run_outcome=outcome, + package_status=package_status, + input_hash=result["normalized_input_hash"], + configuration_hash=publication_configuration_hash, + schema_versions={ + "run_manifest": "1.0.0", + "cutlist_result": CUTLIST_RESULT_VERSION, + "rfq_linear": "1.0.0", + }, + tool_versions={ + "pi_steel": package_version(__file__), + "cutlist_algorithm": CUTLIST_ALGORITHM_VERSION, + }, + explicit_dates={}, + warnings=[ + finding["message"] + for finding in findings + if finding["severity"] in {"error", "warning"} + ], + approximations=[], + run_id=args.run_id, + ) as publisher: + publisher.write_qa_report(qa_report) + publisher.write_bytes( + "report.txt", + report.encode("utf-8"), + readiness="diagnostic", + media_type="text/plain", + ) + publisher.write_json("result.json", result, readiness="diagnostic") + if outcome == "ready": + publisher.write_json( + "rfq_linear.json", result["rfq_linear"], readiness="diagnostic" + ) + publisher.write_bytes( + "cutting_list.csv", + render_cutting_list_csv(result).encode("utf-8"), + readiness="geometry_verified", + media_type="text/csv", + ) + if ( + not args.no_render + and not missing_dependencies + and result["bar_reports"] + ): + publisher.register_artifact( + "layout.pdf", readiness="reference_only", media_type="application/pdf" + ) + publisher.register_artifact( + "bars.png", readiness="reference_only", media_type="image/png" + ) + render_layout(result, publisher.staging_path) + final_path = publisher.publish() + + return result, qa_report, report, final_path + + +# -------------------------------------------------------------------------- +# Main +# -------------------------------------------------------------------------- +def main(argv=None): + parser = StageArgumentParser(description="Steel linear cut-list engine") + parser.configure_failure_diagnostics( + stage="steel-cutlist", + entry_file=__file__, + input_option="--job", + ) + parser.add_argument("--job", required=True) + parser.add_argument( + "--out", + default="outputs", + help="Publication root; each invocation writes an isolated runs//", + ) + parser.add_argument("--no-render", action="store_true", help="Skip PDF/PNG") + parser.add_argument("--run-id", help=argparse.SUPPRESS) + args = parser.parse_args(argv) + + try: + with open(args.job, encoding="utf-8") as handle: + job = json.load(handle) + result, qa_report, report, final_path = publish_cutlist_run(job, args) + except (OSError, json.JSONDecodeError, TypeError, ValueError) as exc: + diagnostic_path = publish_failure_diagnostic( + args.out, + stage="steel-cutlist", + input_path=args.job, + error=exc, + tool_version=package_version(__file__), + run_id=args.run_id, + ) + suffix = ( + f"; diagnostic published: {diagnostic_path}" + if diagnostic_path is not None + else "; diagnostic publication unavailable" + ) + print(f"Cut-list failed: {exc}{suffix}", file=sys.stderr) + return 1 + print(report) + print(f"\nPublished {qa_report['run_outcome']} run: {final_path}") + return outcome_exit_code(qa_report["run_outcome"]) + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tests/test_cutlist_cli_contract.py b/tests/test_cutlist_cli_contract.py new file mode 100644 index 0000000..1e96209 --- /dev/null +++ b/tests/test_cutlist_cli_contract.py @@ -0,0 +1,182 @@ +import json +import os +import subprocess +import sys +from pathlib import Path + +import jsonschema + + +ROOT = Path(__file__).resolve().parents[1] +CUTLIST_SCRIPT = ROOT / "skills" / "steel-cutlist" / "scripts" / "cutlist.py" +CUTLIST_SCHEMA = ROOT / "skills" / "_shared" / "schemas" / "cutlist-result.schema.json" + + +def run_cli(tmp_path, job, run_id, extra_args=()): + job_path = tmp_path / f"{run_id}.json" + job_path.write_text(json.dumps(job), encoding="utf-8") + output = tmp_path / "published" + environment = os.environ.copy() + environment.pop("PYTHONPATH", None) + completed = subprocess.run( + [ + sys.executable, + CUTLIST_SCRIPT, + "--job", + job_path, + "--out", + output, + "--run-id", + run_id, + "--no-render", + *extra_args, + ], + cwd=tmp_path, + env=environment, + capture_output=True, + text=True, + ) + pointer = json.loads((output / "latest-run.json").read_text()) + run_path = output / pointer["run_directory"] + return completed, run_path + + +def valid_job(): + return { + "job_name": "SYNTHETIC-CUTLIST-CLI", + "project_id": "SYNTHETIC-PRJ", + "revision_id": "SYNTHETIC-REV", + "unit_system": "imperial", + "settings": {"kerf_in": 0.125, "end_trim_in": 0.25, "min_drop_in": 24}, + "members": [ + { + "source_id": "SYNTHETIC-CLI-M1", + "name": "SYNTHETIC-CLI-B1", + "designation": "W12X26", + "grade": "A992", + "length_in": 200, + "qty": 2, + } + ], + "stock": [ + { + "stock_id": "SYNTHETIC-CLI-STK", + "designation": "W12X26", + "grade": "A992", + "length_ft": 40, + "qty": 1, + "cost_per_ft": 30.0, + } + ], + } + + +def test_ready_run_publishes_verified_cutting_list_and_handoff(tmp_path): + completed, run_path = run_cli(tmp_path, valid_job(), "synthetic-cutlist-ready") + assert completed.returncode == 0, completed.stderr + manifest = json.loads((run_path / "run-manifest.json").read_text()) + assert manifest["run_outcome"] == "ready" + assert manifest["package_status"] == "cutlist_verified" + artifacts = {entry["path"]: entry for entry in manifest["artifacts"]} + assert artifacts["cutting_list.csv"]["readiness"] == "geometry_verified" + assert "rfq_linear.json" in artifacts + + result = json.loads((run_path / "result.json").read_text()) + schema = json.loads(CUTLIST_SCHEMA.read_text()) + jsonschema.validate(result, schema) + assert result["verification"]["status"] == "verified" + assert result["bars_used"] == 1 + assert result["total_material_cost"] == 1200.0 + + cutting_list = (run_path / "cutting_list.csv").read_text().splitlines() + assert cutting_list[0].startswith("Bar,Stock,Designation") + assert len(cutting_list) == 3 + + handoff = json.loads((run_path / "rfq_linear.json").read_text()) + assert handoff["schema_version"] == "1.0.0" + assert handoff["rows"][0]["bars_needed"] == 1 + + +def test_blocked_run_suppresses_cutting_list_and_exits_3(tmp_path): + job = valid_job() + job["members"][0]["length_in"] = 500 + completed, run_path = run_cli(tmp_path, job, "synthetic-cutlist-blocked") + assert completed.returncode == 3 + manifest = json.loads((run_path / "run-manifest.json").read_text()) + assert manifest["run_outcome"] == "blocked" + assert manifest["package_status"] == "cutlist_partial" + assert not (run_path / "cutting_list.csv").exists() + assert not (run_path / "rfq_linear.json").exists() + + qa_report = json.loads((run_path / "qa-report.json").read_text()) + assert any( + finding["code"] == "UNPLACED_MEMBERS" for finding in qa_report["findings"] + ) + result = json.loads((run_path / "result.json").read_text()) + schema = json.loads(CUTLIST_SCHEMA.read_text()) + jsonschema.validate(result, schema) + assert result["unplaced"][0]["reason"] == "no_compatible_stock_fit" + + +def test_unreadable_job_publishes_failure_diagnostic_and_exits_1(tmp_path): + output = tmp_path / "published" + environment = os.environ.copy() + environment.pop("PYTHONPATH", None) + completed = subprocess.run( + [ + sys.executable, + CUTLIST_SCRIPT, + "--job", + tmp_path / "missing.json", + "--out", + output, + "--run-id", + "synthetic-cutlist-missing", + ], + cwd=tmp_path, + env=environment, + capture_output=True, + text=True, + ) + assert completed.returncode == 1 + pointer = json.loads((output / "latest-run.json").read_text()) + run_path = output / pointer["run_directory"] + qa_report = json.loads((run_path / "qa-report.json").read_text()) + assert qa_report["run_outcome"] == "usage_or_internal_error" + + +def test_usage_error_exits_1_and_publishes_diagnostic(tmp_path): + output = tmp_path / "published" + environment = os.environ.copy() + environment.pop("PYTHONPATH", None) + completed = subprocess.run( + [ + sys.executable, + CUTLIST_SCRIPT, + "--out", + output, + ], + cwd=tmp_path, + env=environment, + capture_output=True, + text=True, + ) + assert completed.returncode == 1 + assert (output / "latest-run.json").exists() + + +def test_example_job_reference_runs_ready(tmp_path): + example = json.loads( + ( + ROOT + / "skills" + / "steel-cutlist" + / "references" + / "example_job.json" + ).read_text() + ) + completed, run_path = run_cli(tmp_path, example, "synthetic-cutlist-example") + assert completed.returncode == 0, completed.stderr + result = json.loads((run_path / "result.json").read_text()) + assert result["outcome"] == "ready" + assert result["purchase_summary"] diff --git a/tests/test_cutlist_engine.py b/tests/test_cutlist_engine.py new file mode 100644 index 0000000..7c92918 --- /dev/null +++ b/tests/test_cutlist_engine.py @@ -0,0 +1,349 @@ +import importlib.util +import sys +from copy import deepcopy +from pathlib import Path + + +ROOT = Path(__file__).resolve().parents[1] +SHARED = ROOT / "skills" / "_shared" +sys.path.insert(0, str(SHARED)) +CUTLIST_SCRIPT = ROOT / "skills" / "steel-cutlist" / "scripts" / "cutlist.py" +SPEC = importlib.util.spec_from_file_location("pi_steel_cutlist_engine", CUTLIST_SCRIPT) +cutlist = importlib.util.module_from_spec(SPEC) +sys.modules[SPEC.name] = cutlist +SPEC.loader.exec_module(cutlist) + +from pi_steel import canonical_json_bytes, sha256_bytes # noqa: E402 + + +def base_job(): + return { + "job_name": "SYNTHETIC-CUTLIST-ENGINE", + "project_id": "SYNTHETIC-PRJ", + "revision_id": "SYNTHETIC-REV", + "unit_system": "imperial", + "settings": {"kerf_in": 0.125, "end_trim_in": 0.25, "min_drop_in": 24}, + "members": [ + { + "source_id": "SYNTHETIC-M1", + "name": "SYNTHETIC-B1", + "designation": "W12X26", + "grade": "A992", + "length_in": 342, + "qty": 4, + }, + { + "source_id": "SYNTHETIC-M2", + "name": "SYNTHETIC-B2", + "designation": "W12X26", + "grade": "A992", + "length_ft": "12'-6", + "qty": 6, + }, + ], + "stock": [ + { + "stock_id": "SYNTHETIC-STK-40", + "designation": "W12X26", + "grade": "A992", + "length_ft": 40, + "unlimited": True, + "cost_per_ft": 31.2, + }, + { + "stock_id": "SYNTHETIC-STK-50", + "designation": "W12X26", + "grade": "A992", + "length_ft": 50, + "unlimited": True, + "cost_per_ft": 39.0, + }, + ], + } + + +def test_exact_fit_consumes_bar_without_trailing_kerf_rejection(): + job = { + **base_job(), + "members": [ + { + "source_id": "SYNTHETIC-EXACT", + "name": "SYNTHETIC-EXACT", + "designation": "W12X26", + "grade": "A992", + "length_in": 479.5, + "qty": 1, + } + ], + "stock": [ + { + "stock_id": "SYNTHETIC-STK-EXACT", + "designation": "W12X26", + "grade": "A992", + "length_in": 480, + "qty": 1, + } + ], + } + result = cutlist.run_job(job) + assert result["outcome"] == "ready" + assert result["unplaced"] == [] + report = result["bar_reports"][0] + assert report["usable_length_in"] == 479.5 + assert report["drop_in"] == 0 + assert result["fit_contract"]["consumption_per_piece"] == ( + "length_plus_one_kerf_saturating" + ) + + +def test_feet_inches_string_parses_and_kerf_accounting(): + result = cutlist.run_job(base_job()) + lengths = sorted( + {cut["length_in"] for report in result["bar_reports"] for cut in report["cuts"]} + ) + assert lengths == [150, 342] + for report in result["bar_reports"]: + assert report["kerf_total_in"] == round(0.125 * report["num_cuts"], 3) + consumed = report["cut_length_in"] + report["kerf_total_in"] + assert consumed <= report["usable_length_in"] + report["num_cuts"] * 0.125 + + +def test_portfolio_prefers_cheaper_single_length_at_equal_total_length(): + result = cutlist.run_job(base_job()) + assert result["outcome"] == "ready" + assert result["unplaced"] == [] + summary = result["purchase_summary"] + assert len(summary) == 1 + assert summary[0]["stock_id"] == "SYNTHETIC-STK-40" + assert summary[0]["bars_needed"] == 6 + assert result["total_material_cost"] == 7488.0 + + +def test_incompatible_groups_never_mix_and_missing_stock_blocks(): + job = base_job() + job["members"].append( + { + "source_id": "SYNTHETIC-M3", + "name": "SYNTHETIC-ANGLE", + "designation": "L4X4X1/4", + "grade": "A36", + "length_in": 100, + "qty": 2, + } + ) + result = cutlist.run_job(job) + assert result["outcome"] == "blocked" + assert result["package_status"] == "cutlist_partial" + unplaced = {row["designation"]: row for row in result["unplaced"]} + assert unplaced["L4X4X1/4"]["reason"] == "no_compatible_stock_fit" + assert unplaced["L4X4X1/4"]["quantity"] == 2 + for report in result["bar_reports"]: + for cut in report["cuts"]: + assert (cut["designation"], cut["grade"]) == ( + report["designation"], + report["grade"], + ) + + +def test_finite_stock_exhaustion_reports_reason_and_partial_status(): + job = base_job() + job["stock"] = [ + { + "stock_id": "SYNTHETIC-STK-FINITE", + "designation": "W12X26", + "grade": "A992", + "length_ft": 40, + "qty": 1, + } + ] + result = cutlist.run_job(job) + assert result["outcome"] == "blocked" + assert result["package_status"] == "cutlist_partial" + assert all(row["reason"] == "stock_exhausted" for row in result["unplaced"]) + assert result["cost"]["status"] == "incomplete_unplaced" + assert result["total_material_cost"] is None + + +def test_ambiguous_length_basis_is_an_error_finding(): + job = base_job() + job["members"][0]["length_ft"] = 28.5 + both = cutlist.run_job(job) + assert any( + finding["code"] == "ambiguous_length_basis" + for finding in both["validation_findings"] + ) + assert both["outcome"] == "blocked" + + job = base_job() + del job["members"][0]["length_in"] + neither = cutlist.run_job(job) + assert any( + finding["code"] == "ambiguous_length_basis" + for finding in neither["validation_findings"] + ) + + +def test_aisc_weight_lookup_and_unknown_weight_warning(): + known = cutlist.run_job(base_job()) + cut = known["bar_reports"][0]["cuts"][0] + assert cut["weight_basis"] == "aisc_database" + assert known["weight_status"] == "known" + expected = round(26.0 * cut["length_in"] / 12.0, 1) + assert cut["weight_lbs"] == expected + + job = base_job() + job["members"][0]["designation"] = "W12X999" + job["stock"].append( + { + "stock_id": "SYNTHETIC-STK-UNKNOWN", + "designation": "W12X999", + "grade": "A992", + "length_ft": 40, + "unlimited": True, + } + ) + unknown = cutlist.run_job(job) + assert any( + finding["code"] == "unknown_unit_weight" + and finding["severity"] == "warning" + for finding in unknown["validation_findings"] + ) + assert unknown["weight_status"] == "incomplete" + assert unknown["total_cut_weight_lbs"] is None + assert unknown["outcome"] == "ready" + + +def test_declared_unit_weight_overrides_database(): + job = base_job() + job["members"][0]["unit_weight_plf"] = 30.0 + result = cutlist.run_job(job) + declared = [ + cut + for report in result["bar_reports"] + for cut in report["cuts"] + if cut["weight_basis"] == "declared" + ] + assert declared + assert declared[0]["weight_lbs"] == round(30.0 * declared[0]["length_in"] / 12.0, 1) + + +def test_conflicting_cost_basis_is_an_error(): + job = base_job() + job["stock"][0]["cost_per_bar"] = 1000 + result = cutlist.run_job(job) + assert any( + finding["code"] == "conflicting_cost_basis" + for finding in result["validation_findings"] + ) + assert result["outcome"] == "blocked" + + +def test_drop_classification_threshold(): + result = cutlist.run_job(base_job()) + for report in result["bar_reports"]: + expected = ( + "reusable_candidate" if report["drop_in"] >= 24 else "offcut" + ) + assert report["drop_class"] == expected + for drop in result["drops"]: + assert drop["length_in"] >= 24 + assert drop["status"] == "candidate_unverified" + + +def test_determinism_same_input_same_result_hash(): + first = cutlist.run_job(deepcopy(base_job())) + second = cutlist.run_job(deepcopy(base_job())) + assert sha256_bytes(canonical_json_bytes(first)) == sha256_bytes( + canonical_json_bytes(second) + ) + + +def test_every_instance_is_cut_exactly_once_or_reported_unplaced(): + job = base_job() + job["stock"] = [ + { + "stock_id": "SYNTHETIC-STK-FINITE", + "designation": "W12X26", + "grade": "A992", + "length_ft": 40, + "qty": 3, + } + ] + result = cutlist.run_job(job) + cut_instances = [ + cut["instance_id"] + for report in result["bar_reports"] + for cut in report["cuts"] + ] + assert len(cut_instances) == len(set(cut_instances)) + total = len(cut_instances) + sum(row["quantity"] for row in result["unplaced"]) + assert total == 10 + assert result["verification"]["status"] == "verified" + + +def test_independent_verifier_catches_tampered_bars(): + result = cutlist.run_job(base_job()) + reports = deepcopy(result["bar_reports"]) + reports[0]["cuts"].append({**reports[0]["cuts"][0]}) + expected = { + cut["instance_id"] + for report in result["bar_reports"] + for cut in report["cuts"] + } + findings = cutlist.verify_cutlist_bars( + reports, kerf=0.125, expected_instances=expected + ) + codes = {finding["code"] for finding in findings} + assert "DUPLICATE_PLACEMENT" in codes + assert "BAR_OVERCOMMITTED" in codes + + tampered = deepcopy(result["bar_reports"]) + tampered[0]["cuts"][0]["grade"] = "A36" + findings = cutlist.verify_cutlist_bars( + tampered, kerf=0.125, expected_instances=expected + ) + assert any( + finding["code"] == "BAR_MATERIAL_MISMATCH" for finding in findings + ) + + missing = deepcopy(result["bar_reports"]) + removed = missing[0]["cuts"].pop() + findings = cutlist.verify_cutlist_bars( + missing, kerf=0.125, expected_instances=expected + ) + assert any( + finding["code"] == "INSTANCE_UNACCOUNTED" + and removed["instance_id"] in finding["message"] + for finding in findings + ) + + +def test_unusable_stock_length_is_an_error(): + job = base_job() + job["settings"]["end_trim_in"] = 300 + result = cutlist.run_job(job) + assert any( + finding["code"] == "unusable_stock_length" + for finding in result["validation_findings"] + ) + assert result["outcome"] == "blocked" + + +def test_designation_normalization_matches_across_case_and_spaces(): + job = base_job() + job["members"][0]["designation"] = "w12 x 26" + result = cutlist.run_job(job) + assert result["unplaced"] == [] + assert result["outcome"] == "ready" + + +def test_rfq_linear_block_carries_identity_and_rows(): + result = cutlist.run_job(base_job()) + handoff = result["rfq_linear"] + assert handoff["schema_version"] == "1.0.0" + assert handoff["project_id"] == "SYNTHETIC-PRJ" + assert handoff["estimate_input_hash"] == result["estimate_input_hash"] + assert len(handoff["rows"]) == len(result["purchase_summary"]) + row = handoff["rows"][0] + assert row["bars_needed"] == 6 + assert "cutting_plan" in row and "drop_notes" in row From 2f6fdf72e877d7a09e2d6429d1c23e222170a860 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 23 Aug 2026 06:38:49 +0000 Subject: [PATCH 02/12] feat(pipeline): run member cut-lists through estimate and RFQ Wire the steel-cutlist engine into the review-gated estimate pipeline and carry its results into the draft RFQ workbook. - Member items (designation + length, no plate geometry) now optimize onto configurable mill lengths (--mill-lengths-ft, default 40,50,60) with cut-list kerf, end-trim, and drop-threshold settings. - Cut-list validation errors, verifier findings, and unplaced members gate the pipeline exactly like nest blockers; a member longer than every mill length blocks the run as cutlist_partial. - Ready runs publish cutlist-result.json, rfq-linear.json, a verified cutting_list.csv, and reference bar diagrams alongside the nest artifacts. - The RFQ compiler accepts a validated linear handoff (--linear on the standalone CLI) and renders a LINEAR STOCK / CUT-LIST REFERENCE section, with staleness checks against the estimate identity. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01MquyTzVAPKpV1V8Vt3XxnJ --- skills/steel-cutlist/scripts/cutlist.py | 6 +- .../scripts/build-estimate-package.py | 252 +++++++++++++++++- skills/steel-rfq/scripts/generate-rfq.py | 148 +++++++++- tests/golden/pipeline/ready-artifacts.json | 3 + tests/test_estimate_pipeline.py | 59 ++++ 5 files changed, 458 insertions(+), 10 deletions(-) diff --git a/skills/steel-cutlist/scripts/cutlist.py b/skills/steel-cutlist/scripts/cutlist.py index aac87ea..68a0dd2 100644 --- a/skills/steel-cutlist/scripts/cutlist.py +++ b/skills/steel-cutlist/scripts/cutlist.py @@ -1205,7 +1205,7 @@ def render_cutting_list_csv(res): # -------------------------------------------------------------------------- # Visual layout (PNG + combined PDF) # -------------------------------------------------------------------------- -def render_layout(res, outdir): +def render_layout(res, outdir, pdf_name="layout.pdf", png_name="bars.png"): import matplotlib matplotlib.use("Agg") @@ -1215,8 +1215,8 @@ def render_layout(res, outdir): end_trim = res["meta"]["end_trim_in"] kerf = res["meta"]["kerf_in"] - pdf_path = os.path.join(outdir, "layout.pdf") - png_path = os.path.join(outdir, "bars.png") + pdf_path = os.path.join(outdir, pdf_name) + png_path = os.path.join(outdir, png_name) reports = res["bar_reports"] with PdfPages(pdf_path) as pdf: diff --git a/skills/steel-estimate/scripts/build-estimate-package.py b/skills/steel-estimate/scripts/build-estimate-package.py index 1ecddf5..a0ef8e9 100755 --- a/skills/steel-estimate/scripts/build-estimate-package.py +++ b/skills/steel-estimate/scripts/build-estimate-package.py @@ -60,6 +60,10 @@ def _load_module(name: str, path: Path): "pi_steel_estimate_rfq", SKILLS_ROOT / "steel-rfq" / "scripts" / "generate-rfq.py", ) +cutlist_engine = _load_module( + "pi_steel_estimate_cutlist", + SKILLS_ROOT / "steel-cutlist" / "scripts" / "cutlist.py", +) class PipelineInputError(ValueError): @@ -193,6 +197,94 @@ def nest_job_from_package( } +def parse_mill_lengths(raw: str) -> list[float]: + """Parse the comma-separated mill length list (feet), strictly.""" + lengths = [] + for entry in raw.split(","): + entry = entry.strip() + if not entry: + continue + value = float(entry) + if value <= 0: + raise PipelineInputError( + "--mill-lengths-ft entries must be greater than zero" + ) + lengths.append(value) + if not lengths: + raise PipelineInputError("--mill-lengths-ft must list at least one length") + return sorted(set(lengths)) + + +def cutlist_job_from_package( + package: dict[str, Any], + *, + estimate_input_hash: str, + mill_lengths_ft: list[float], + kerf_in: float, + end_trim_in: float, + min_drop_in: float, +) -> dict[str, Any] | None: + """Build the linear optimization job for member items purchased by length.""" + member_items = [ + item + for item in package["items"] + if item["intent"] == "fabricated_part" + and not item.get("geometry") + and item.get("designation") + and item.get("length_ft") is not None + ] + if not member_items: + return None + members = [] + for item in member_items: + member = { + "source_id": item["source_id"], + "item_id": item["item_id"], + "name": item.get("mark") or item["item_id"], + "designation": item["designation"], + "grade": item.get("grade"), + "length_ft": item["length_ft"], + "qty": item["quantity"], + } + if item.get("unit_weight_plf") is not None: + member["unit_weight_plf"] = item["unit_weight_plf"] + members.append(member) + groups = sorted( + { + (member["designation"], member["grade"]) + for member in members + }, + key=repr, + ) + stock = [ + { + "stock_id": f"mill:{designation}:{grade}:{length_ft:g}ft", + "name": f"{designation} {length_ft:g} ft mill length", + "designation": designation, + "grade": grade, + "length_ft": length_ft, + "unlimited": True, + } + for designation, grade in groups + for length_ft in mill_lengths_ft + ] + return { + "job_name": package["project"].get("name") + or package["project"]["project_id"], + "project_id": package["project"]["project_id"], + "revision_id": package["project"]["revision"]["revision_id"], + "estimate_input_hash": estimate_input_hash, + "unit_system": package["unit_system"], + "settings": { + "kerf_in": kerf_in, + "end_trim_in": end_trim_in, + "min_drop_in": min_drop_in, + }, + "members": members, + "stock": stock, + } + + def build_bom_projection( package: dict[str, Any], nest_result: dict[str, Any] | None ) -> dict[str, Any]: @@ -420,6 +512,12 @@ def build_pipeline(args) -> tuple[dict[str, Any], Path]: raise PipelineInputError("--prepared-date must be an ISO date (YYYY-MM-DD)") if not _valid_date(args.issued_date): raise PipelineInputError("--issued-date must be an ISO date (YYYY-MM-DD)") + try: + mill_lengths = parse_mill_lengths(args.mill_lengths_ft) + except ValueError as exc: + raise PipelineInputError( + f"--mill-lengths-ft is invalid: {exc}" + ) from exc input_path = Path(args.input) package = json.loads(input_path.read_text(encoding="utf-8")) validation = validate_estimate_package(package) @@ -517,7 +615,54 @@ def build_pipeline(args) -> tuple[dict[str, Any], Path]: ), ) ) - unplaced = bool(nest_result and nest_result["unplaced"]) + cutlist_result = None + if not validation_blocked: + cutlist_job = cutlist_job_from_package( + normalized, + estimate_input_hash=validation.input_hash, + mill_lengths_ft=mill_lengths, + kerf_in=args.cutlist_kerf_in, + end_trim_in=args.end_trim_in, + min_drop_in=args.min_drop_in, + ) + if cutlist_job is not None: + cutlist_result = cutlist_engine.run_job(cutlist_job) + for cutlist_finding in cutlist_result["validation_findings"]: + findings.append( + _finding( + cutlist_finding["code"], + "blocker" + if cutlist_finding["severity"] == "error" + else "warning", + cutlist_finding["path"], + cutlist_finding["message"], + ) + ) + for verifier_finding in cutlist_result["verification"]["findings"]: + findings.append( + _finding( + verifier_finding["code"], + "blocker", + verifier_finding["path"], + verifier_finding["message"], + ) + ) + if cutlist_result["unplaced"]: + findings.append( + _finding( + "unplaced_members", + "blocker", + "$.cutlist.unplaced", + ( + f"{sum(row['quantity'] for row in cutlist_result['unplaced'])} " + "required member(s) exceed every configured mill length." + ), + ) + ) + unplaced = bool( + (nest_result and nest_result["unplaced"]) + or (cutlist_result and cutlist_result["unplaced"]) + ) nest_blocked = bool( nest_result and ( @@ -525,6 +670,13 @@ def build_pipeline(args) -> tuple[dict[str, Any], Path]: or nest_result["verification"]["status"] != "verified" ) ) + cutlist_blocked = bool( + cutlist_result + and ( + cutlist_result["outcome"] == "blocked" + or cutlist_result["verification"]["status"] != "verified" + ) + ) reference_only = bool( nest_result and nest_result["geometry_readiness"] == "reference_only" ) @@ -538,8 +690,14 @@ def build_pipeline(args) -> tuple[dict[str, Any], Path]: ) render_missing = [] - if not args.no_render and nest_result is not None: - render_missing = nest_engine.missing_render_dependencies() + if not args.no_render and ( + nest_result is not None or cutlist_result is not None + ): + render_missing = ( + nest_engine.missing_render_dependencies() + if nest_result is not None + else cutlist_engine.missing_render_dependencies() + ) if render_missing: findings.append( _finding( @@ -553,7 +711,13 @@ def build_pipeline(args) -> tuple[dict[str, Any], Path]: rfq_normalized = None inventory_consumption = [] compiler_error = None - if not validation_blocked and not profile_blocked and not nest_blocked and not render_missing: + if ( + not validation_blocked + and not profile_blocked + and not nest_blocked + and not cutlist_blocked + and not render_missing + ): try: rfq_normalized = rfq_compiler.normalize_canonical_package(package) inventory_consumption = apply_inventory_consumption( @@ -576,6 +740,7 @@ def build_pipeline(args) -> tuple[dict[str, Any], Path]: validation_blocked or profile_blocked or nest_blocked + or cutlist_blocked or unplaced or bool(render_missing) or compiler_error is not None @@ -587,7 +752,12 @@ def build_pipeline(args) -> tuple[dict[str, Any], Path]: ] if blocked: outcome = "dependency_missing" if render_missing else "blocked" - package_status = "nested_partial" if unplaced else "draft" + if nest_result and nest_result["unplaced"]: + package_status = "nested_partial" + elif cutlist_result and cutlist_result["unplaced"]: + package_status = "cutlist_partial" + else: + package_status = "draft" elif reference_only or review_warnings: outcome = "review_required" package_status = "rfq_draft_review_required" @@ -596,9 +766,15 @@ def build_pipeline(args) -> tuple[dict[str, Any], Path]: package_status = "rfq_ready_for_review" handoff = _annotated_handoff(nest_result) + linear_handoff = ( + copy.deepcopy(cutlist_result["rfq_linear"]) + if cutlist_result is not None + else None + ) configuration = { "pipeline_version": PIPELINE_VERSION, "nest_algorithm_version": nest_engine.NEST_ALGORITHM_VERSION, + "cutlist_algorithm_version": cutlist_engine.CUTLIST_ALGORITHM_VERSION, "rfq_compiler_version": rfq_compiler.RFQ_COMPILER_VERSION, "prepared_date": args.prepared_date, "issued_date": args.issued_date, @@ -607,6 +783,10 @@ def build_pipeline(args) -> tuple[dict[str, Any], Path]: "part_gap_in": args.part_gap_in, "edge_margin_in": args.edge_margin_in, "density_lb_in3": args.density_lb_in3, + "mill_lengths_ft": mill_lengths, + "cutlist_kerf_in": args.cutlist_kerf_in, + "end_trim_in": args.end_trim_in, + "min_drop_in": args.min_drop_in, "render": not args.no_render, "bake": not args.no_bake, "profile_hash": rfq_compiler.profile_semantic_hash(profile), @@ -657,6 +837,17 @@ def build_pipeline(args) -> tuple[dict[str, Any], Path]: "verification": nest_result["verification"], } ), + "cutlist": ( + None + if cutlist_result is None + else { + "outcome": cutlist_result["outcome"], + "unplaced": cutlist_result["unplaced"], + "verification": cutlist_result["verification"], + "weight_status": cutlist_result["weight_status"], + "purchase_summary": cutlist_result["purchase_summary"], + } + ), "rfq": { "generated": not blocked, "document_status": "DRAFT — NOT SENT OR AWARDED", @@ -676,13 +867,16 @@ def build_pipeline(args) -> tuple[dict[str, Any], Path]: "estimate_package": ESTIMATE_PACKAGE_VERSION, "normalized_bom": "1.0.0", "nest_result": nest_engine.NEST_RESULT_VERSION, + "cutlist_result": cutlist_engine.CUTLIST_RESULT_VERSION, "rfq_nesting": rfq_compiler.NEST_HANDOFF_VERSION, + "rfq_linear": rfq_compiler.LINEAR_HANDOFF_VERSION, "rfq_workbook": rfq_compiler.RFQ_COMPILER_VERSION, }, tool_versions={ "pi_steel": package_version(__file__), "estimate_pipeline": PIPELINE_VERSION, "nest_algorithm": nest_engine.NEST_ALGORITHM_VERSION, + "cutlist_algorithm": cutlist_engine.CUTLIST_ALGORITHM_VERSION, "rfq_compiler": rfq_compiler.RFQ_COMPILER_VERSION, }, explicit_dates={ @@ -704,6 +898,22 @@ def build_pipeline(args) -> tuple[dict[str, Any], Path]: publisher.write_json( "rfq-nesting.json", handoff, readiness="diagnostic" ) + if cutlist_result is not None: + publisher.write_json( + "cutlist-result.json", cutlist_result, readiness="diagnostic" + ) + publisher.write_json( + "rfq-linear.json", linear_handoff, readiness="diagnostic" + ) + if cutlist_result["outcome"] == "ready": + publisher.write_bytes( + "cutting_list.csv", + cutlist_engine.render_cutting_list_csv( + cutlist_result + ).encode("utf-8"), + readiness="geometry_verified", + media_type="text/csv", + ) if inventory_consumption: publisher.write_json( "inventory-consumption.json", @@ -716,6 +926,7 @@ def build_pipeline(args) -> tuple[dict[str, Any], Path]: rfq_normalized, profile, nest_handoff=handoff, + linear_handoff=linear_handoff, issued_date=args.issued_date, project_location=args.project_location, output_directory=publisher.staging_path, @@ -739,6 +950,29 @@ def build_pipeline(args) -> tuple[dict[str, Any], Path]: readiness="diagnostic", ) + if ( + not args.no_render + and cutlist_result is not None + and not render_missing + and cutlist_result["bar_reports"] + ): + cutlist_engine.render_layout( + cutlist_result, + publisher.staging_path, + pdf_name="cutlist-layout.pdf", + png_name="cutlist-bars.png", + ) + publisher.register_artifact( + "cutlist-layout.pdf", + readiness="reference_only", + media_type="application/pdf", + ) + publisher.register_artifact( + "cutlist-bars.png", + readiness="reference_only", + media_type="image/png", + ) + if not args.no_render and nest_result is not None and not render_missing: nest_engine.render_layout(nest_result, publisher.staging_path) publisher.register_artifact( @@ -802,6 +1036,14 @@ def main(argv=None) -> int: parser.add_argument("--part-gap-in", type=float, default=0.25) parser.add_argument("--edge-margin-in", type=float, default=0.5) parser.add_argument("--density-lb-in3", type=float, default=0.2836) + parser.add_argument( + "--mill-lengths-ft", + default="40,50,60", + help="Comma-separated purchasable mill lengths for member cut-lists", + ) + parser.add_argument("--cutlist-kerf-in", type=float, default=0.125) + parser.add_argument("--end-trim-in", type=float, default=0.25) + parser.add_argument("--min-drop-in", type=float, default=24.0) parser.add_argument("--no-render", action="store_true") parser.add_argument("--no-bake", action="store_true") parser.add_argument("--run-id", help=argparse.SUPPRESS) diff --git a/skills/steel-rfq/scripts/generate-rfq.py b/skills/steel-rfq/scripts/generate-rfq.py index 74747e1..333f29f 100755 --- a/skills/steel-rfq/scripts/generate-rfq.py +++ b/skills/steel-rfq/scripts/generate-rfq.py @@ -47,9 +47,13 @@ RFQ_COMPILER_VERSION = "1.0.0" NEST_HANDOFF_VERSION = "1.0.0" +LINEAR_HANDOFF_VERSION = "1.0.0" NEST_RESULT_SCHEMA_PATH = ( SHARED_ROOT / "schemas" / "nest-result.schema.json" ) +CUTLIST_RESULT_SCHEMA_PATH = ( + SHARED_ROOT / "schemas" / "cutlist-result.schema.json" +) HEADERS = [ "Item", "Category", @@ -547,6 +551,66 @@ def validate_nest_handoff( return findings +def _linear_handoff_validator() -> jsonschema.Draft202012Validator: + schema = json.loads(CUTLIST_RESULT_SCHEMA_PATH.read_text(encoding="utf-8")) + handoff_schema = { + "$schema": schema["$schema"], + "$ref": "#/$defs/linearHandoff", + "$defs": {"linearHandoff": schema["properties"]["rfq_linear"]}, + } + return jsonschema.Draft202012Validator(handoff_schema) + + +def validate_linear_handoff( + value: dict[str, Any] | None, + *, + expected: dict[str, Any] | None = None, +) -> list[dict[str, str]]: + if value is None: + return [] + if not isinstance(value, dict): + return [ + { + "code": "invalid_linear_handoff", + "severity": "error", + "path": "$", + "message": "Linear cut-list handoff must be a JSON object.", + } + ] + findings: list[dict[str, str]] = [] + for error in sorted( + _linear_handoff_validator().iter_errors(value), + key=lambda item: tuple(str(part) for part in item.absolute_path), + ): + findings.append( + { + "code": "invalid_linear_handoff_contract", + "severity": "error", + "path": _json_path(error.absolute_path), + "message": error.message, + } + ) + if expected is not None: + for handoff_field, expected_field in ( + ("project_id", "project_id"), + ("revision_id", "revision_id"), + ("estimate_input_hash", "input_hash"), + ): + if value.get(handoff_field) != expected.get(expected_field): + findings.append( + { + "code": "stale_linear_handoff", + "severity": "error", + "path": f"$.{handoff_field}", + "message": ( + f"Linear cut-list handoff {handoff_field} does not " + "match the current estimate package." + ), + } + ) + return findings + + def _color(value): if value is None: return None @@ -660,6 +724,7 @@ def compile_workbook( output_directory: str | Path, profile_source: str, bake: bool, + linear_handoff: dict[str, Any] | None = None, ) -> dict[str, Any]: profile_findings = validate_company_profile( profile, profile.get("_profile_path") @@ -675,6 +740,12 @@ def compile_workbook( "nest handoff blocked: " + "; ".join(finding["message"] for finding in nest_findings) ) + linear_findings = validate_linear_handoff(linear_handoff, expected=normalized) + if linear_findings: + raise RfqInputError( + "linear handoff blocked: " + + "; ".join(finding["message"] for finding in linear_findings) + ) workbook = Workbook() sheet = workbook.active @@ -887,7 +958,56 @@ def compile_workbook( cell.alignment = Alignment(vertical="top", wrap_text=True) nest_row += 1 - review_header_row = nest_row + 1 + linear_header_row = None + section_end_row = nest_row + if linear_handoff and linear_handoff.get("rows"): + linear_header_row = nest_row + 1 + sheet.merge_cells( + start_row=linear_header_row, + start_column=1, + end_row=linear_header_row, + end_column=14, + ) + sheet.cell( + linear_header_row, + 1, + "LINEAR STOCK / CUT-LIST REFERENCE (For Fabricator Review)", + ) + sheet.cell(linear_header_row, 1).font = Font( + name="Arial", bold=True, color=dark_blue + ) + linear_columns_row = linear_header_row + 1 + linear_headers = [ + "Designation", + "Grade", + "Bar Length (in)", + "Bars Needed", + "Cutting Plan", + "Drop Notes", + ] + for column, header in enumerate(linear_headers, start=1): + cell = sheet.cell(linear_columns_row, column, header) + cell.fill = PatternFill("solid", fgColor=medium_blue) + cell.font = Font(name="Arial", bold=True, color=white) + cell.border = border + linear_row = linear_columns_row + 1 + for entry in linear_handoff["rows"]: + values = [ + entry.get("designation"), + entry.get("grade"), + entry.get("bar_length_in"), + entry.get("bars_needed"), + entry.get("cutting_plan"), + entry.get("drop_notes"), + ] + for column, value in enumerate(values, start=1): + cell = sheet.cell(linear_row, column, value) + cell.border = border + cell.alignment = Alignment(vertical="top", wrap_text=True) + linear_row += 1 + section_end_row = linear_row + + review_header_row = section_end_row + 1 sheet.merge_cells( start_row=review_header_row, start_column=1, @@ -1002,6 +1122,7 @@ def compile_workbook( "last_material_row": last_material_row, "total_row": total_row, "nest_header_row": nest_header_row, + "linear_header_row": linear_header_row, "review_header_row": review_header_row, "terms_header_row": terms_header_row, }, @@ -1084,7 +1205,26 @@ def publish_rfq_run(args) -> tuple[dict[str, Any], Path]: "message": str(exc), } ] - findings = input_findings + profile_findings + nest_findings + try: + linear_handoff = ( + json.loads(Path(args.linear).read_text(encoding="utf-8")) + if args.linear + else None + ) + linear_findings = validate_linear_handoff( + linear_handoff, + expected=normalized, + ) + except (OSError, json.JSONDecodeError) as exc: + linear_handoff = None + linear_findings = [ + { + "code": "invalid_linear_handoff", + "severity": "error", + "message": str(exc), + } + ] + findings = input_findings + profile_findings + nest_findings + linear_findings blockers = [finding for finding in findings if finding["severity"] == "error"] review_reasons = list(normalized["warnings"]) if any( @@ -1112,6 +1252,7 @@ def publish_rfq_run(args) -> tuple[dict[str, Any], Path]: "project_location": args.project_location, "profile_hash": profile_semantic_hash(profile), "nest_handoff": nest_handoff, + "linear_handoff": linear_handoff, "bake_requested": not args.no_bake, } ) @@ -1138,6 +1279,7 @@ def publish_rfq_run(args) -> tuple[dict[str, Any], Path]: "estimate_package": normalized["input_version"], "rfq_workbook": RFQ_COMPILER_VERSION, "rfq_nesting": NEST_HANDOFF_VERSION, + "rfq_linear": LINEAR_HANDOFF_VERSION, }, tool_versions={ "pi_steel": package_version(__file__), @@ -1154,6 +1296,7 @@ def publish_rfq_run(args) -> tuple[dict[str, Any], Path]: normalized, profile, nest_handoff=nest_handoff, + linear_handoff=linear_handoff, issued_date=args.issued_date, project_location=args.project_location, output_directory=publisher.staging_path, @@ -1188,6 +1331,7 @@ def main(argv=None) -> int: ) parser.add_argument("--input", required=True) parser.add_argument("--nest") + parser.add_argument("--linear") parser.add_argument("--out", default="outputs") parser.add_argument("--issued-date", required=True) parser.add_argument("--project-location", default="") diff --git a/tests/golden/pipeline/ready-artifacts.json b/tests/golden/pipeline/ready-artifacts.json index 0247fa3..273d711 100644 --- a/tests/golden/pipeline/ready-artifacts.json +++ b/tests/golden/pipeline/ready-artifacts.json @@ -1,10 +1,13 @@ { "artifact_paths": [ "Synthetic_Pipeline_Project_RFQ_Material_List.xlsx", + "cutlist-result.json", + "cutting_list.csv", "estimate-package.json", "nest-result.json", "normalized-bom.json", "qa-report.json", + "rfq-linear.json", "rfq-nesting.json", "workbook-semantic.json" ], diff --git a/tests/test_estimate_pipeline.py b/tests/test_estimate_pipeline.py index acc3375..845324b 100644 --- a/tests/test_estimate_pipeline.py +++ b/tests/test_estimate_pipeline.py @@ -393,3 +393,62 @@ def test_no_vendor_supply_items_publish_blocked_diagnostics(tmp_path): assert not list(run_path.glob("*.xlsx")) qa = load_json(run_path / "qa-report.json") assert any(finding["code"] == "rfq_input_blocked" for finding in qa["findings"]) + + +def test_ready_pipeline_publishes_verified_cutlist_and_linear_handoff(tmp_path): + completed, run_path = run_pipeline( + tmp_path, load_package(), "SYNTHETIC-PIPELINE-CUTLIST" + ) + assert completed.returncode == 0, completed.stdout + completed.stderr + manifest = load_json(run_path / "run-manifest.json") + cutlist = load_json(run_path / "cutlist-result.json") + assert cutlist["outcome"] == "ready" + assert cutlist["verification"]["status"] == "verified" + assert cutlist["estimate_input_hash"] == manifest["input_hash"] + assert {row["designation"] for row in cutlist["purchase_summary"]} == { + "W12X26", + "HSS6X6X3/8", + } + assert cutlist["weight_status"] == "known" + + handoff = load_json(run_path / "rfq-linear.json") + assert handoff["schema_version"] == "1.0.0" + assert handoff["estimate_input_hash"] == manifest["input_hash"] + + cutting_list = (run_path / "cutting_list.csv").read_text().splitlines() + assert cutting_list[0].startswith("Bar,Stock,Designation") + assert len(cutting_list) > 1 + + qa_report = load_json(run_path / "qa-report.json") + assert qa_report["cutlist"]["outcome"] == "ready" + assert qa_report["cutlist"]["weight_status"] == "known" + + workbook = openpyxl.load_workbook(next(run_path.glob("*.xlsx"))) + values = { + cell.value + for row in workbook["RFQ Draft"].iter_rows() + for cell in row + if isinstance(cell.value, str) + } + assert "LINEAR STOCK / CUT-LIST REFERENCE (For Fabricator Review)" in values + + +def test_member_exceeding_all_mill_lengths_blocks_as_cutlist_partial(tmp_path): + package = load_package() + for item in package["items"]: + if item.get("mark") == "W1": + item["length_ft"] = 70 + completed, run_path = run_pipeline( + tmp_path, package, "SYNTHETIC-PIPELINE-CUTLIST-BLOCKED" + ) + assert completed.returncode == 3 + manifest = load_json(run_path / "run-manifest.json") + assert manifest["run_outcome"] == "blocked" + assert manifest["package_status"] == "cutlist_partial" + qa_report = load_json(run_path / "qa-report.json") + assert any( + finding["code"] == "unplaced_members" for finding in qa_report["findings"] + ) + assert not (run_path / "cutting_list.csv").exists() + cutlist = load_json(run_path / "cutlist-result.json") + assert cutlist["unplaced"][0]["reason"] == "no_compatible_stock_fit" From 67224f21ce9f0afa37d4f743d28b6cc697bd721f Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 23 Aug 2026 06:41:45 +0000 Subject: [PATCH 03/12] docs(release): document cut-list skill and cut v0.3.0 - Add CHANGELOG.md with dated release history from the git record. - README: cut-list section, skill table row, workflow diagram, prompts. - steel-estimate and steel-rfq SKILL.md document the cut-list stage and the --linear handoff; steel-nest description defers long products to steel-cutlist to keep trigger boundaries unambiguous. - Bump package and runtime versions to 0.3.0 and ship CHANGELOG.md in the npm package. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01MquyTzVAPKpV1V8Vt3XxnJ --- CHANGELOG.md | 64 ++++++++++++++++++++++++++++++++++ README.md | 22 +++++++++--- package.json | 5 +-- pyproject.toml | 2 +- skills/steel-estimate/SKILL.md | 23 +++++++----- skills/steel-nest/SKILL.md | 2 +- skills/steel-rfq/SKILL.md | 5 ++- 7 files changed, 105 insertions(+), 18 deletions(-) create mode 100644 CHANGELOG.md diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..22515f0 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,64 @@ +# Changelog + +All notable changes to `@structupath/pi-steel` are documented here. The format +follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and the +project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +## [0.3.0] - 2026-08-23 + +### Added + +- **`steel-cutlist` skill** — deterministic 1D bar nesting for long products + (beams, channels, angles, HSS, tube, pipe). Packs required member lengths + onto purchasable mill lengths with explicit saw kerf and end-trim + allowances, a documented fit contract, and a strategy portfolio per + designation + grade group (mixed-stock greedy plus each single-stock-length + restriction) ranked by fewest unplaced members, least total stock length, + lowest known cost, then fewest bars. +- Independent post-placement cut-list verification (bar overcommitment, + material mismatch, duplicate or missing member instances) gating + publication; the per-bar `cutting_list.csv` is emitted only for verified, + fully placed runs. +- Member weights from explicit `unit_weight_plf` or the bundled AISC shape + database; unknown weights surface as warnings, never silent zeros. +- Bar diagrams (`layout.pdf`, `bars.png`), purchase summary by stock length, + drop candidates against a reusable threshold, and a versioned + `rfq_linear.json` handoff. +- **Pipeline integration** — `steel-estimate` now optimizes member items + (designation + length, no plate geometry) onto configurable mill lengths + (`--mill-lengths-ft`, default `40,50,60`; `--cutlist-kerf-in`, + `--end-trim-in`, `--min-drop-in`). Cut-list blockers, verifier findings, + and members longer than every mill length gate the run exactly like nest + blockers, publishing `cutlist_partial` diagnostics. +- **RFQ integration** — the draft workbook renders a + "LINEAR STOCK / CUT-LIST REFERENCE" section from a validated linear + handoff; the standalone `generate-rfq.py` accepts `--linear` with schema + and staleness validation against the estimate identity. +- `cutlist-result` schema (`1.0.0`) and the `cutlist_partial` / + `cutlist_verified` package statuses. + +## [0.2.3] - 2026-07-28 + +### Fixed + +- Corrected shape dataset ownership metadata in the provenance record. + +### Added + +- Pi gallery preview image, public demo recording, and public project guide. + +## [0.2.2] - 2026-07-28 + +### Added + +- Trustworthy estimate package pipeline: canonical estimate contract and + shared validation, grouped nest placement verification, deterministic draft + RFQ compilation, review-gated orchestration with immutable manifested runs, + and release gates for privacy and provenance. +- `steel-nest` plate nesting with guarded burn DXF output. + +## [0.1.0] - 2026-07-19 + +### Added + +- Initial release: `steel-takeoff` and `steel-rfq` skills for Pi. diff --git a/README.md b/README.md index 713f9d9..5672077 100644 --- a/README.md +++ b/README.md @@ -6,8 +6,8 @@ [![Pi package](https://img.shields.io/badge/Pi-package-7c3aed)](https://pi.dev/packages?name=pi-steel) Structural-steel estimating skills for the [Pi coding agent](https://pi.dev): -validated takeoffs, plate nesting, guarded DXF output, draft vendor RFQs, and a -review-gated estimate pipeline. +validated takeoffs, plate nesting, linear cut-list optimization, guarded DXF +output, draft vendor RFQs, and a review-gated estimate pipeline. Built by [StructuPath](https://structupath.ai). @@ -30,6 +30,7 @@ Then ask Pi: ```text Do a structural-steel takeoff from these drawings and build a validated BOM. Nest these rectangular plate parts on 96 × 48 stock. +Optimize these beam lengths onto 40/50/60 ft mill lengths. Prepare a draft RFQ from this estimate. ``` @@ -44,7 +45,8 @@ npm run doctor | Skill | Purpose | Primary outputs | | --- | --- | --- | | `steel-takeoff` | Validate member designations and calculate BOM weight and tonnage | Validated BOM and findings | -| `steel-nest` | Lay out plate parts with kerf, gap, and edge-margin controls | Nest results, cut list, reference drawings, and guarded burn DXFs | +| `steel-nest` | Lay out plate parts with kerf, gap, and edge-margin controls | Nest results, reference drawings, and guarded burn DXFs | +| `steel-cutlist` | Optimize member lengths onto purchasable mill lengths (1D bar nesting) | Verified cutting lists, purchase summaries, drop candidates, and bar diagrams | | `steel-rfq` | Compile estimate data into a reviewable vendor request | Draft `.xlsx` RFQ and semantic workbook record | | `steel-estimate` | Orchestrate the complete review-gated workflow | Immutable run directory, QA report, lineage, manifests, nesting, and draft RFQ | @@ -67,6 +69,15 @@ suppresses burn DXFs for the entire run and leaves clearly labeled estimating and reference artifacts for review. It does not emit G-code or claim machine-specific CAM compatibility. +### Linear cut-lists + +`steel-cutlist` packs member lengths (beams, HSS, angles, pipe) onto +purchasable mill lengths with explicit saw kerf and end-trim allowances. A +deterministic strategy portfolio per designation and grade minimizes unplaced +members, total stock length, and known cost, and every bar is independently +re-verified before a cutting list is published. Drops are reported as +candidates for review, never as certified reusable stock. + ### Draft RFQs `steel-rfq` groups material by stock family, carries approved nesting data into @@ -89,7 +100,7 @@ completed profile. ```text estimate input ↓ -contract validation → plate nesting → draft RFQ compilation +contract validation → plate nesting + linear cut-lists → draft RFQ compilation ↓ QA findings + lineage + immutable run manifest ↓ @@ -119,6 +130,7 @@ Keep operational inputs and generated artifacts outside the repository. See the - [GitHub wiki](https://github.com/StructuPath/pi-steel/wiki) - [Public data policy](PUBLIC_DATA_POLICY.md) - [Data provenance](DATA_PROVENANCE.md) +- [Changelog](CHANGELOG.md) - [Pi package catalog](https://pi.dev/packages?name=pi-steel) - [npm package](https://www.npmjs.com/package/@structupath/pi-steel) @@ -134,7 +146,7 @@ npm run provenance:check # shape-data integrity and recorded decision npm run release:check # complete release gate ``` -The current package version is `0.2.3`. `release:check` verifies the test, +The current package version is `0.3.0`. `release:check` verifies the test, privacy, package-content, shape-data integrity, ownership, license, and redistribution contracts before publication. diff --git a/package.json b/package.json index 9a5d005..073ccec 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "@structupath/pi-steel", - "version": "0.2.3", - "description": "Structural steel estimating for Pi — validated takeoffs, plate nesting, guarded DXF output, and review-ready RFQ packages.", + "version": "0.3.0", + "description": "Structural steel estimating for Pi \u2014 validated takeoffs, plate nesting, guarded DXF output, and review-ready RFQ packages.", "type": "module", "keywords": [ "pi-package", @@ -67,6 +67,7 @@ "DATA_PROVENANCE.json", "docs/assets/pi-steel-demo.gif", "docs/assets/pi-steel-gallery.webp", + "CHANGELOG.md", "README.md", "PUBLIC_DATA_POLICY.md", "LICENSE" diff --git a/pyproject.toml b/pyproject.toml index 3d985e1..3187f1b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "pi-steel-runtime" -version = "0.2.3" +version = "0.3.0" description = "Python runtime dependencies and test configuration for pi-steel" requires-python = ">=3.11,<3.14" dependencies = [ diff --git a/skills/steel-estimate/SKILL.md b/skills/steel-estimate/SKILL.md index 3701011..3ee9cec 100644 --- a/skills/steel-estimate/SKILL.md +++ b/skills/steel-estimate/SKILL.md @@ -1,6 +1,6 @@ --- name: steel-estimate -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." +description: "Build a deterministic, review-gated steel estimate package from canonical estimate JSON. Use when a complete takeoff-to-nest-to-cutlist-to-draft-RFQ workflow is needed with validation, lineage, QA, and isolated run artifacts." --- # Steel Estimate Package @@ -33,13 +33,20 @@ calculations. 2. Build the typed BOM projection without converting exclusions or allowances into vendor quantities. 3. Nest plate parts only within compatible stock groups. -4. Verify placements and retain diagnostic nest artifacts. -5. Compile a draft RFQ only when validation, placement, and company-profile - gates pass. -6. Publish an isolated run manifest and QA report. - -Validation failures, unplaced parts, invalid company data, failed nest -verification, or required rendering dependencies block workbook generation. +4. Optimize member items (designation plus length, no plate geometry) onto + configurable mill lengths (`--mill-lengths-ft`, default `40,50,60`) with + the deterministic cut-list engine; settings: `--cutlist-kerf-in`, + `--end-trim-in`, `--min-drop-in`. +5. Verify placements and cut plans; retain diagnostic nest and cut-list + artifacts (`cutlist-result.json`, `rfq-linear.json`, and a verified + `cutting_list.csv` on ready runs). +6. Compile a draft RFQ — including the linear stock reference table — only + when validation, placement, and company-profile gates pass. +7. Publish an isolated run manifest and QA report. + +Validation failures, unplaced parts, members longer than every configured mill +length, invalid company data, failed nest or cut-list verification, or +required rendering dependencies block workbook generation. Complete bounding-box nests for irregular geometry may produce a workbook, but the run and workbook remain explicitly review-required. Reference DXF is never burn-ready DXF. diff --git a/skills/steel-nest/SKILL.md b/skills/steel-nest/SKILL.md index 2546800..5f1c692 100644 --- a/skills/steel-nest/SKILL.md +++ b/skills/steel-nest/SKILL.md @@ -1,6 +1,6 @@ --- name: steel-nest -description: "Nest steel parts onto compatible stock plates and estimate material — the plate-layout / cutting step that CAM software does. Use this skill whenever someone mentions nesting, plate layout, plate optimization, cut list, cutting plan, yield, remnant candidates, how many sheets/plates a job needs, how much plate to buy, or laying parts out on a sheet. Produces a verified result, packing utilization, net material yield, guarded layouts, and optional cost totals only when an explicit basis exists." +description: "Nest steel parts onto compatible stock plates and estimate material — the plate-layout / cutting step that CAM software does. Use this skill whenever someone mentions nesting, plate layout, plate optimization, sheet cut plans, yield, remnant candidates, how many sheets/plates a job needs, how much plate to buy, or laying parts out on a sheet. For long products bought by length (beams, HSS, angle, pipe) use steel-cutlist instead. Produces a verified result, packing utilization, net material yield, guarded layouts, and optional cost totals only when an explicit basis exists." --- # Steel Plate Nesting & Estimate diff --git a/skills/steel-rfq/SKILL.md b/skills/steel-rfq/SKILL.md index b8d78af..8cabce8 100644 --- a/skills/steel-rfq/SKILL.md +++ b/skills/steel-rfq/SKILL.md @@ -1,6 +1,6 @@ --- name: steel-rfq -description: "Compile a deterministic draft steel RFQ workbook from a validated canonical estimate package or an exact-header legacy workbook. Use for material quote lists, RFQ spreadsheets, or versioned nesting references. Produces artifacts only; it never sends, awards, or authorizes purchasing." +description: "Compile a deterministic draft steel RFQ workbook from a validated canonical estimate package or an exact-header legacy workbook. Use for material quote lists, RFQ spreadsheets, or versioned nesting and cut-list references. Produces artifacts only; it never sends, awards, or authorizes purchasing." --- # Steel RFQ Compiler @@ -19,6 +19,7 @@ purchase. Every workbook and manifest records `DRAFT — NOT SENT OR AWARDED`. - A runtime company profile with company name, city/state, and an approved, hash-bound terms template. - Optional versioned nesting handoff `1.0.0`. +- Optional versioned linear cut-list handoff `1.0.0` (from `steel-cutlist`). Resolve the company profile from `PI_STEEL_CONFIG`, project-local ignored `.pi-steel/company-profile.json`, then the platform user-config directory. @@ -51,6 +52,7 @@ The compiler owns: alternating row fills. - Exact total formulas covering the deterministic material range. - Versioned nesting/remnant reference rows with visible reference-only labels. +- Versioned linear stock / cut-list reference rows when a linear handoff is supplied. - Approved terms content and approval lineage. - Landscape print setup, one-page width, repeated row-8 headers, and stable project-derived filename. @@ -64,6 +66,7 @@ branding. python3 scripts/generate-rfq.py \ --input \ --nest \ + --linear \ --issued-date \ --project-location "Example City, ST" \ --out From 59ef99d1c4dca62a91a3b5b65c4f9932df940e16 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 23 Aug 2026 06:57:19 +0000 Subject: [PATCH 04/12] fix(cutlist): resolve review findings on correctness and reuse - A negative member quantity now records its finding and publishes a structured blocked run instead of crashing instance expansion. - Members without a grade are excluded from pipeline cut-list optimization with an explicit warning, restoring the pre-cutlist review_required outcome instead of hard-blocking the estimate. - Blocked runs report verification.status 'not_run' rather than claiming a verification that never executed; schema updated. - rfq_linear rows carry their own stock group's utilization instead of the job-wide blend. - --mill-lengths-ft rejects non-finite entries as a usage error and no longer double-wraps its own failure message. - Display formatting is decimal-exact, so cutting_list.csv preserves sixteenth-inch lengths instead of rounding at six significant digits. - Deduplicate is_sha256, missing_optional_modules, and normalize_designation into the shared pi_steel package; nest, takeoff, and cutlist now share one copy. - Hoist the placed-instance set out of the per-unplaced-row loop. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01MquyTzVAPKpV1V8Vt3XxnJ --- skills/_shared/pi_steel/__init__.py | 10 ++- skills/_shared/pi_steel/cli.py | 12 ++- skills/_shared/pi_steel/contracts.py | 9 +++ skills/_shared/pi_steel/parsing.py | 7 ++ .../schemas/cutlist-result.schema.json | 2 +- skills/steel-cutlist/scripts/cutlist.py | 65 +++++++-------- .../scripts/build-estimate-package.py | 39 ++++++--- skills/steel-nest/scripts/nest.py | 13 +-- skills/steel-takeoff/scripts/validate-bom.py | 6 +- tests/test_cutlist_engine.py | 81 +++++++++++++++++++ tests/test_estimate_pipeline.py | 59 ++++++++++++++ 11 files changed, 243 insertions(+), 60 deletions(-) diff --git a/skills/_shared/pi_steel/__init__.py b/skills/_shared/pi_steel/__init__.py index 1a735a7..a4cc5e2 100644 --- a/skills/_shared/pi_steel/__init__.py +++ b/skills/_shared/pi_steel/__init__.py @@ -1,12 +1,18 @@ """Shared deterministic runtime primitives for pi-steel skills.""" -from .cli import StageArgumentParser, package_version, publish_failure_diagnostic +from .cli import ( + StageArgumentParser, + missing_optional_modules, + package_version, + publish_failure_diagnostic, +) from .contracts import ( ESTIMATE_PACKAGE_VERSION, ITEM_INTENTS, NEST_RESULT_VERSION, estimate_input_hash, instance_ids, + is_sha256, item_id_for, placement_ids, ) @@ -37,8 +43,10 @@ "canonical_json_bytes", "estimate_input_hash", "instance_ids", + "is_sha256", "item_id_for", "placement_ids", + "missing_optional_modules", "outcome_exit_code", "package_version", "publish_failure_diagnostic", diff --git a/skills/_shared/pi_steel/cli.py b/skills/_shared/pi_steel/cli.py index fdbcd2e..3f56911 100644 --- a/skills/_shared/pi_steel/cli.py +++ b/skills/_shared/pi_steel/cli.py @@ -3,10 +3,11 @@ from __future__ import annotations import argparse +import importlib.util import json import sys from pathlib import Path -from typing import Any +from typing import Any, Iterable from .run_manifest import ( ManifestError, @@ -84,6 +85,15 @@ def error(self, message): self.exit(1, f"{self.prog}: error: {message}{suffix}\n") +def missing_optional_modules(module_names: Iterable[str]) -> list[str]: + """Return the named optional modules unavailable to this interpreter.""" + return [ + name + for name in module_names + if importlib.util.find_spec(name) is None + ] + + def package_version(entry_file: str) -> str: package_path = Path(entry_file).resolve().parents[3] / "package.json" try: diff --git a/skills/_shared/pi_steel/contracts.py b/skills/_shared/pi_steel/contracts.py index 4ef62d8..447add9 100644 --- a/skills/_shared/pi_steel/contracts.py +++ b/skills/_shared/pi_steel/contracts.py @@ -21,6 +21,15 @@ ) +def is_sha256(value: Any) -> bool: + """Return whether a value is a lowercase hex SHA-256 digest.""" + return ( + isinstance(value, str) + and len(value) == 64 + and all(character in "0123456789abcdef" for character in value) + ) + + def canonical_json_bytes(value: Any) -> bytes: return json.dumps( value, sort_keys=True, separators=(",", ":"), ensure_ascii=False diff --git a/skills/_shared/pi_steel/parsing.py b/skills/_shared/pi_steel/parsing.py index 5f7fb42..8838a18 100644 --- a/skills/_shared/pi_steel/parsing.py +++ b/skills/_shared/pi_steel/parsing.py @@ -15,6 +15,13 @@ ) +def normalize_designation(value: Any) -> str: + """Canonical AISC-style designation key: uppercase, no spaces.""" + if not isinstance(value, str): + return "" + return value.upper().replace(" ", "").strip() + + def parse_length_ft(raw: str) -> float: value = raw.strip() if "'" not in value: diff --git a/skills/_shared/schemas/cutlist-result.schema.json b/skills/_shared/schemas/cutlist-result.schema.json index e2955df..874b235 100644 --- a/skills/_shared/schemas/cutlist-result.schema.json +++ b/skills/_shared/schemas/cutlist-result.schema.json @@ -132,7 +132,7 @@ "type": "object", "required": ["status", "findings"], "properties": { - "status": { "enum": ["verified", "failed"] } + "status": { "enum": ["verified", "failed", "not_run"] } } }, "rfq_linear": { diff --git a/skills/steel-cutlist/scripts/cutlist.py b/skills/steel-cutlist/scripts/cutlist.py index 68a0dd2..12aaf8f 100644 --- a/skills/steel-cutlist/scripts/cutlist.py +++ b/skills/steel-cutlist/scripts/cutlist.py @@ -39,7 +39,6 @@ import argparse import csv -import importlib.util import io import json import math @@ -59,7 +58,9 @@ RunPublisher, StageArgumentParser, canonical_json_bytes, + is_sha256, item_id_for, + missing_optional_modules, outcome_exit_code, package_version, placement_ids, @@ -67,7 +68,7 @@ sha256_bytes, ) from pi_steel.contracts import content_hash, fallback_source_id, instance_ids # noqa: E402 -from pi_steel.parsing import parse_length_ft # noqa: E402 +from pi_steel.parsing import normalize_designation, parse_length_ft # noqa: E402 CUTLIST_RESULT_VERSION = "1.0.0" CUTLIST_ALGORITHM_VERSION = "portfolio-bfd-v1" @@ -75,21 +76,6 @@ AISC_DATABASE_RELATIVE = Path("steel-takeoff") / "assets" / "aisc-shapes-database.json" -def _valid_hash(value): - return ( - isinstance(value, str) - and len(value) == 64 - and all(character in "0123456789abcdef" for character in value) - ) - - -def normalize_designation(value): - """Canonical AISC-style designation key: uppercase, no spaces.""" - if not isinstance(value, str): - return "" - return value.upper().replace(" ", "") - - def load_unit_weights(): """Map normalized designation -> weight_per_ft from the bundled AISC data.""" database_path = SKILLS_ROOT / AISC_DATABASE_RELATIVE @@ -232,7 +218,7 @@ def number(value, path, *, positive=False, nonnegative=False): else: revision_id = "LEGACY-REVISION" estimate_input_hash = job.get("estimate_input_hash") - if estimate_input_hash is not None and not _valid_hash(estimate_input_hash): + if estimate_input_hash is not None and not is_sha256(estimate_input_hash): findings.append( _validation_finding( "invalid_estimate_input_hash", @@ -277,6 +263,7 @@ def number(value, path, *, positive=False, nonnegative=False): "invalid_quantity", f"{path}.qty", "Quantity must be a positive integer." ) ) + quantity = 0 unit_weight = member.get("unit_weight_plf") weight_basis = "declared" if unit_weight is not None: @@ -848,6 +835,11 @@ def _summarize(normalized, used_bars, unplaced, validation_findings, normalized_ expected_instances.update( instance_ids(member["item_id"], member["quantity"]) ) + placed_elsewhere = { + cut["instance_id"] + for report in bar_reports + for cut in report["cuts"] + } for row in unplaced: prefix = f"{row['item_id']}:instance:" matching = sorted( @@ -855,23 +847,19 @@ def _summarize(normalized, used_bars, unplaced, validation_findings, normalized_ for instance in expected_instances if instance.startswith(prefix) ) - placed_elsewhere = { - cut["instance_id"] - for report in bar_reports - for cut in report["cuts"] - } removable = [ instance for instance in matching if instance not in placed_elsewhere ][-row["quantity"]:] expected_instances -= set(removable) + verification_ran = not any( + finding["severity"] == "error" for finding in validation_findings + ) verification_findings = ( verify_cutlist_bars( bar_reports, kerf=kerf, expected_instances=expected_instances ) - if not any( - finding["severity"] == "error" for finding in validation_findings - ) + if verification_ran else [] ) @@ -988,7 +976,11 @@ def _summarize(normalized, used_bars, unplaced, validation_findings, normalized_ "unplaced": unplaced, "validation_findings": validation_findings, "verification": { - "status": "verified" if not verification_findings else "failed", + "status": ( + "not_run" + if not verification_ran + else ("verified" if not verification_findings else "failed") + ), "findings": verification_findings, }, } @@ -1028,7 +1020,9 @@ def stage_decision(res): def _fmt(value): - return f"{value:g}" + """Decimal-exact display formatting; never rounds shop-relevant digits.""" + text = f"{value:.6f}".rstrip("0").rstrip(".") + return text or "0" # -------------------------------------------------------------------------- @@ -1044,6 +1038,13 @@ def rfq_linear_block(res): if report["stock_id"] == purchase["stock_id"] ] cuts = sum(report["num_cuts"] for report in matching) + group_bar_length = sum(report["bar_length_in"] for report in matching) + group_cut_length = sum(report["cut_length_in"] for report in matching) + group_utilization = ( + round(100 * group_cut_length / group_bar_length, 1) + if group_bar_length + else 0.0 + ) drop_candidates = [ drop for drop in res["drops"] @@ -1061,7 +1062,7 @@ def rfq_linear_block(res): "bar_length_in": purchase["bar_length_in"], "bars_needed": purchase["bars_needed"], "total_length_ft": purchase["total_length_ft"], - "utilization_pct": res["metrics"]["utilization_pct"]["value"], + "utilization_pct": group_utilization, "cutting_plan": ( f"{purchase['bars_needed']} x " f"{_fmt(purchase['bar_length_in'])} in bar(s) - {cuts} cuts" @@ -1311,11 +1312,7 @@ def render_layout(res, outdir, pdf_name="layout.pdf", png_name="bars.png"): def missing_render_dependencies(): """Return optional render modules unavailable to this interpreter.""" - return [ - name - for name in ("matplotlib", "numpy") - if importlib.util.find_spec(name) is None - ] + return missing_optional_modules(("matplotlib", "numpy")) # -------------------------------------------------------------------------- diff --git a/skills/steel-estimate/scripts/build-estimate-package.py b/skills/steel-estimate/scripts/build-estimate-package.py index a0ef8e9..de7b37e 100755 --- a/skills/steel-estimate/scripts/build-estimate-package.py +++ b/skills/steel-estimate/scripts/build-estimate-package.py @@ -7,6 +7,7 @@ import copy import importlib.util import json +import math import os import re import sys @@ -204,10 +205,15 @@ def parse_mill_lengths(raw: str) -> list[float]: entry = entry.strip() if not entry: continue - value = float(entry) - if value <= 0: + try: + value = float(entry) + except ValueError as exc: + raise PipelineInputError( + f"--mill-lengths-ft entry {entry!r} is not a number" + ) from exc + if not math.isfinite(value) or value <= 0: raise PipelineInputError( - "--mill-lengths-ft entries must be greater than zero" + "--mill-lengths-ft entries must be finite and greater than zero" ) lengths.append(value) if not lengths: @@ -232,6 +238,7 @@ def cutlist_job_from_package( and not item.get("geometry") and item.get("designation") and item.get("length_ft") is not None + and item.get("grade") ] if not member_items: return None @@ -512,12 +519,7 @@ def build_pipeline(args) -> tuple[dict[str, Any], Path]: raise PipelineInputError("--prepared-date must be an ISO date (YYYY-MM-DD)") if not _valid_date(args.issued_date): raise PipelineInputError("--issued-date must be an ISO date (YYYY-MM-DD)") - try: - mill_lengths = parse_mill_lengths(args.mill_lengths_ft) - except ValueError as exc: - raise PipelineInputError( - f"--mill-lengths-ft is invalid: {exc}" - ) from exc + mill_lengths = parse_mill_lengths(args.mill_lengths_ft) input_path = Path(args.input) package = json.loads(input_path.read_text(encoding="utf-8")) validation = validate_estimate_package(package) @@ -617,6 +619,25 @@ def build_pipeline(args) -> tuple[dict[str, Any], Path]: ) cutlist_result = None if not validation_blocked: + for item in normalized["items"]: + if ( + item["intent"] == "fabricated_part" + and not item.get("geometry") + and item.get("designation") + and item.get("length_ft") is not None + and not item.get("grade") + ): + findings.append( + _finding( + "member_missing_grade_excluded_from_cutlist", + "warning", + "$.items", + ( + f"Member {item['item_id']} has no grade and was " + "excluded from cut-list optimization." + ), + ) + ) cutlist_job = cutlist_job_from_package( normalized, estimate_input_hash=validation.input_hash, diff --git a/skills/steel-nest/scripts/nest.py b/skills/steel-nest/scripts/nest.py index 826ac31..dbf9190 100644 --- a/skills/steel-nest/scripts/nest.py +++ b/skills/steel-nest/scripts/nest.py @@ -36,7 +36,6 @@ """ import argparse -import importlib.util import json import math import os @@ -57,7 +56,9 @@ RunPublisher, StageArgumentParser, canonical_json_bytes, + is_sha256, item_id_for, + missing_optional_modules, outcome_exit_code, package_version, placement_ids, @@ -76,12 +77,7 @@ NEST_ALGORITHM_VERSION = "maxrects-bssf-u3" -def _valid_hash(value): - return ( - isinstance(value, str) - and len(value) == 64 - and all(character in "0123456789abcdef" for character in value) - ) +_valid_hash = is_sha256 # -------------------------------------------------------------------------- @@ -1491,8 +1487,7 @@ def stage_decision(res, geometry_verified_only=False): def missing_render_dependencies(): """Return optional render modules unavailable to this interpreter.""" - modules = ("ezdxf", "matplotlib", "numpy") - return [name for name in modules if importlib.util.find_spec(name) is None] + return missing_optional_modules(("ezdxf", "matplotlib", "numpy")) def publish_nest_run(job, args): diff --git a/skills/steel-takeoff/scripts/validate-bom.py b/skills/steel-takeoff/scripts/validate-bom.py index 5ca83be..f44e21b 100755 --- a/skills/steel-takeoff/scripts/validate-bom.py +++ b/skills/steel-takeoff/scripts/validate-bom.py @@ -15,7 +15,7 @@ from bootstrap import bootstrap_shared # noqa: E402 bootstrap_shared(__file__) -from pi_steel.parsing import adapt_legacy_bom_csv # noqa: E402 +from pi_steel.parsing import adapt_legacy_bom_csv, normalize_designation # noqa: E402 from pi_steel.validation import validate_estimate_package # noqa: E402 @@ -27,10 +27,6 @@ def load_shapes_db() -> dict[str, dict]: return {shape["designation"]: shape for shape in shapes} -def normalize_designation(raw: str) -> str: - return raw.upper().replace(" ", "").strip() - - def grade_warnings(shape_type: str, grade: str) -> list[str]: normalized = grade.upper().replace(" ", "").replace(".", "") expected = { diff --git a/tests/test_cutlist_engine.py b/tests/test_cutlist_engine.py index 7c92918..45e819a 100644 --- a/tests/test_cutlist_engine.py +++ b/tests/test_cutlist_engine.py @@ -347,3 +347,84 @@ def test_rfq_linear_block_carries_identity_and_rows(): row = handoff["rows"][0] assert row["bars_needed"] == 6 assert "cutting_plan" in row and "drop_notes" in row + + +def test_negative_quantity_blocks_without_crashing(): + job = base_job() + job["members"][0]["qty"] = -3 + result = cutlist.run_job(job) + assert result["outcome"] == "blocked" + assert any( + finding["code"] == "invalid_quantity" + for finding in result["validation_findings"] + ) + assert result["verification"]["status"] == "not_run" + + +def test_blocked_runs_report_verification_not_run(): + job = base_job() + del job["members"][0]["grade"] + job["grade"] = None + result = cutlist.run_job(job) + assert result["outcome"] == "blocked" + assert result["verification"]["status"] == "not_run" + + ready = cutlist.run_job(base_job()) + assert ready["verification"]["status"] == "verified" + + +def test_rfq_linear_rows_carry_per_group_utilization(): + job = base_job() + job["members"].append( + { + "source_id": "SYNTHETIC-M-HSS", + "name": "SYNTHETIC-HSS", + "designation": "HSS6X6X1/2", + "grade": "A500B", + "length_in": 200, + "qty": 1, + "unit_weight_plf": 35.24, + } + ) + job["stock"].append( + { + "stock_id": "SYNTHETIC-STK-HSS", + "designation": "HSS6X6X1/2", + "grade": "A500B", + "length_ft": 40, + "unlimited": True, + } + ) + result = cutlist.run_job(job) + rows = {row["stock_id"]: row for row in result["rfq_linear"]["rows"]} + for stock_id, row in rows.items(): + matching = [ + report + for report in result["bar_reports"] + if report["stock_id"] == stock_id + ] + expected = round( + 100 + * sum(report["cut_length_in"] for report in matching) + / sum(report["bar_length_in"] for report in matching), + 1, + ) + assert row["utilization_pct"] == expected + assert len({row["utilization_pct"] for row in rows.values()}) > 1 + + +def test_cutting_list_preserves_sixteenth_inch_precision(): + job = base_job() + job["members"] = [ + { + "source_id": "SYNTHETIC-M-PRECISE", + "name": "SYNTHETIC-PRECISE", + "designation": "W12X26", + "grade": "A992", + "length_in": 342.0625, + "qty": 1, + } + ] + result = cutlist.run_job(job) + csv_text = cutlist.render_cutting_list_csv(result) + assert "342.0625" in csv_text diff --git a/tests/test_estimate_pipeline.py b/tests/test_estimate_pipeline.py index 845324b..79e28ae 100644 --- a/tests/test_estimate_pipeline.py +++ b/tests/test_estimate_pipeline.py @@ -452,3 +452,62 @@ def test_member_exceeding_all_mill_lengths_blocks_as_cutlist_partial(tmp_path): assert not (run_path / "cutting_list.csv").exists() cutlist = load_json(run_path / "cutlist-result.json") assert cutlist["unplaced"][0]["reason"] == "no_compatible_stock_fit" + + +def test_member_without_grade_is_excluded_from_cutlist_not_blocked(tmp_path): + package = load_package() + for item in package["items"]: + if item.get("mark") == "W1": + del item["grade"] + completed, run_path = run_pipeline( + tmp_path, package, "SYNTHETIC-PIPELINE-NO-GRADE" + ) + assert completed.returncode == 2, completed.stdout + completed.stderr + manifest = load_json(run_path / "run-manifest.json") + assert manifest["run_outcome"] == "review_required" + assert next(run_path.glob("*.xlsx"), None) is not None + qa_report = load_json(run_path / "qa-report.json") + assert any( + finding["code"] == "member_missing_grade_excluded_from_cutlist" + and finding["severity"] == "warning" + for finding in qa_report["findings"] + ) + cutlist = load_json(run_path / "cutlist-result.json") + marks = { + cut["label"] + for report in cutlist["bar_reports"] + for cut in report["cuts"] + } + assert "W1" not in marks + assert "HSS1" in marks + + +def test_nonfinite_mill_lengths_fail_as_usage_error(tmp_path): + input_path = tmp_path / "input.json" + input_path.write_text(json.dumps(load_package()), encoding="utf-8") + environment = os.environ.copy() + environment.pop("PYTHONPATH", None) + completed = subprocess.run( + [ + sys.executable, + SCRIPT, + "--input", + input_path, + "--out", + tmp_path / "published", + "--prepared-date", + "2026-07-28", + "--issued-date", + "2026-07-29", + "--mill-lengths-ft", + "nan", + "--no-render", + "--no-bake", + ], + cwd=tmp_path, + env=environment, + text=True, + capture_output=True, + ) + assert completed.returncode == 1 + assert "finite" in completed.stderr From 568c95ae79b6b071ee7f53984dd475ca11e37e4f Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 23 Aug 2026 08:30:31 +0000 Subject: [PATCH 05/12] feat(contracts): model linear stock in the canonical estimate Extend the estimate-package contract with stock_form 'linear' entries so vendor lengths and on-hand sticks flow into cut-list optimization. - Schema: additive linearStock variant (designation, grade, length_ft, quantity, optional unlimited for purchasable supply, and the same hash-bound reviewer_confirmation as plate stock). - Validation: unlimited on-hand linear stock is a blocker; new eligible_on_hand_linear_stock and linear_purchasable_stock helpers mirror the plate gating. - Engine: stock entries carry stock_kind; on-hand stock must be finite and carries no cost basis, reports as cost_basis 'on_hand', and never degrades cost-known status. The portfolio now ranks by purchased length first (on-hand consumption is free), then known cost, purchased bars, and total length, so confirmed sticks eliminate purchases whenever they genuinely can. - Pipeline: declared purchasable linear lengths replace the default mill lengths for their designation+grade group; confirmed on-hand sticks join as finite inventory; plate nesting skips linear entries. - Purchase summaries, rfq_linear rows, bar reports, and the text report label on_hand rows explicitly. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01MquyTzVAPKpV1V8Vt3XxnJ --- CHANGELOG.md | 9 +++ skills/_shared/pi_steel/validation.py | 45 +++++++++++ .../schemas/cutlist-result.schema.json | 8 +- .../schemas/estimate-package.schema.json | 42 +++++++++- skills/steel-cutlist/SKILL.md | 4 +- skills/steel-cutlist/scripts/cutlist.py | 66 ++++++++++++--- skills/steel-estimate/SKILL.md | 4 + .../scripts/build-estimate-package.py | 78 +++++++++++++++--- tests/test_cutlist_engine.py | 67 +++++++++++++++ tests/test_estimate_pipeline.py | 81 +++++++++++++++++++ 10 files changed, 379 insertions(+), 25 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 22515f0..f339b9f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -36,6 +36,15 @@ project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). and staleness validation against the estimate identity. - `cutlist-result` schema (`1.0.0`) and the `cutlist_partial` / `cutlist_verified` package statuses. +- **Linear stock in the canonical contract** — `stock` entries with + `stock_form: "linear"` model vendor-declared purchasable lengths and + on-hand sticks. Declared purchasable lengths replace the default mill + lengths for their designation + grade group; on-hand sticks require the + same hash-bound reviewer confirmation as on-hand plate and enter the + optimizer as finite, cost-free inventory. The portfolio ranks by + purchased length first, so confirmed sticks reduce buying whenever they + genuinely can, and purchase summaries, handoffs, and reports label + `on_hand` rows explicitly. ## [0.2.3] - 2026-07-28 diff --git a/skills/_shared/pi_steel/validation.py b/skills/_shared/pi_steel/validation.py index c3249ac..51a6d8b 100644 --- a/skills/_shared/pi_steel/validation.py +++ b/skills/_shared/pi_steel/validation.py @@ -445,6 +445,16 @@ def validate_estimate_package(package: dict[str, Any]) -> ValidationResult: ) for index, stock in enumerate(package.get("stock", [])): + if stock.get("stock_form") == "linear" and stock.get("unlimited"): + if stock.get("stock_kind") == "on_hand": + _add( + findings, + input_hash, + "unlimited_on_hand_stock", + "blocker", + f"$.stock[{index}].unlimited", + "On-hand stock must be a finite, measured quantity.", + ) if stock.get("stock_kind") != "on_hand": continue required = ( @@ -576,6 +586,41 @@ def eligible_on_hand_stock(package: dict[str, Any]) -> list[dict[str, Any]]: return eligible +def eligible_on_hand_linear_stock(package: dict[str, Any]) -> list[dict[str, Any]]: + """Confirmed, available on-hand linear sticks eligible to reduce purchasing.""" + input_hash = estimate_input_hash(package) + eligible = [] + for stock in package.get("stock", []): + confirmation = stock.get("reviewer_confirmation", {}) + if ( + stock.get("stock_form") == "linear" + and stock.get("stock_kind") == "on_hand" + and stock.get("inventory_id") + and stock.get("designation") + and stock.get("grade") + and finite_positive(stock.get("length_ft")) + and not stock.get("unlimited") + and stock.get("measured_at") + and stock.get("source") + and stock.get("status") == "available" + and confirmation.get("actor") + and confirmation.get("timestamp") + and confirmation.get("estimate_hash") == input_hash + ): + eligible.append(stock) + return eligible + + +def linear_purchasable_stock(package: dict[str, Any]) -> list[dict[str, Any]]: + """Vendor-declared purchasable linear stock lengths.""" + return [ + stock + for stock in package.get("stock", []) + if stock.get("stock_form") == "linear" + and stock.get("stock_kind") == "purchasable" + ] + + def purchasable_items(package: dict[str, Any]) -> list[dict[str, Any]]: return [ item diff --git a/skills/_shared/schemas/cutlist-result.schema.json b/skills/_shared/schemas/cutlist-result.schema.json index 874b235..693d5dc 100644 --- a/skills/_shared/schemas/cutlist-result.schema.json +++ b/skills/_shared/schemas/cutlist-result.schema.json @@ -82,13 +82,17 @@ "type": "object", "required": [ "stock_id", + "stock_kind", "designation", "grade", "bar_length_in", "bars_needed", "total_length_ft", "total_cost" - ] + ], + "properties": { + "stock_kind": { "enum": ["purchasable", "on_hand"] } + } } }, "drops": { @@ -158,6 +162,7 @@ "required": [ "index", "stock_id", + "stock_kind", "designation", "grade", "bar_length_in", @@ -174,6 +179,7 @@ "cost_basis" ], "properties": { + "stock_kind": { "enum": ["purchasable", "on_hand"] }, "drop_class": { "enum": ["reusable_candidate", "offcut"] }, "cuts": { "type": "array", diff --git a/skills/_shared/schemas/estimate-package.schema.json b/skills/_shared/schemas/estimate-package.schema.json index 4b01dc3..63e387d 100644 --- a/skills/_shared/schemas/estimate-package.schema.json +++ b/skills/_shared/schemas/estimate-package.schema.json @@ -23,7 +23,12 @@ }, "stock": { "type": "array", - "items": { "$ref": "#/$defs/stock" } + "items": { + "oneOf": [ + { "$ref": "#/$defs/stock" }, + { "$ref": "#/$defs/linearStock" } + ] + } }, "commercial_basis": { "$ref": "#/$defs/commercialBasis" }, "assumptions": { @@ -264,6 +269,41 @@ }, "additionalProperties": false }, + "linearStock": { + "type": "object", + "required": [ + "stock_kind", + "stock_form", + "designation", + "grade", + "length_ft", + "quantity" + ], + "properties": { + "stock_kind": { "enum": ["on_hand", "purchasable"] }, + "stock_form": { "const": "linear" }, + "inventory_id": { "type": "string", "minLength": 1 }, + "designation": { "type": "string", "minLength": 1 }, + "grade": { "type": "string", "minLength": 1 }, + "length_ft": { "type": "number", "exclusiveMinimum": 0 }, + "quantity": { "type": "integer", "minimum": 1 }, + "unlimited": { "type": "boolean" }, + "status": { "enum": ["available", "reserved", "unavailable"] }, + "measured_at": { "type": "string", "format": "date" }, + "source": { "type": "string" }, + "reviewer_confirmation": { + "type": "object", + "required": ["actor", "timestamp", "estimate_hash"], + "properties": { + "actor": { "type": "string", "minLength": 1 }, + "timestamp": { "type": "string", "format": "date-time" }, + "estimate_hash": { "type": "string", "pattern": "^[0-9a-f]{64}$" } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + }, "commercialBasis": { "type": "object", "required": ["currency", "costs"], diff --git a/skills/steel-cutlist/SKILL.md b/skills/steel-cutlist/SKILL.md index 1de06ff..ce35421 100644 --- a/skills/steel-cutlist/SKILL.md +++ b/skills/steel-cutlist/SKILL.md @@ -15,7 +15,7 @@ It complements `steel-nest` (2D plates). Plates go to `steel-nest`; anything bou **Reliable:** - Exact 1D packing per designation + grade group. Stock never crosses groups: a W12X26 member is only cut from W12X26 stock of the same grade. -- A deterministic strategy portfolio per group — a mixed-stock greedy plus each single-stock-length restriction — ranked by fewest unplaced members, least total stock length, lowest known purchase cost, then fewest bars. The same input always produces the same plan. +- A deterministic strategy portfolio per group — a mixed-stock greedy plus each single-stock-length restriction — ranked by fewest unplaced members, least purchased stock length (on-hand consumption is free), lowest known purchase cost, fewest purchased bars, then least total length. The same input always produces the same plan. - Explicit fit contract: usable length = bar length − 2 × end trim; a piece fits when its length alone fits the remainder; each placed piece then consumes its length plus one kerf, saturating at the bar end. - Independent post-placement verification (bar overcommitment, material mismatch, duplicate or missing instances) before any cutting list is published. - Drops classified against a reusable-candidate threshold (`min_drop_in`) — candidates are never certified reusable stock. @@ -85,4 +85,4 @@ The engine writes `rfq_linear.json` — `{schema_version, source_cutlist_result_ **Drop reuse** — output drops are candidates only. Measure, identify, and approve a candidate before supplying it as its own finite stock entry in a later run (a shorter `length_in` entry with `qty: 1`). -**On-hand material first** — enter on-hand bars as a finite-quantity stock entry alongside purchasable lengths; the portfolio will use them when they reduce total length or cost. +**On-hand material first** — enter on-hand bars as finite-quantity stock entries with `"stock_kind": "on_hand"` alongside purchasable lengths. On-hand stock must be finite and carries no cost basis; the portfolio minimizes purchased length first, so sticks are consumed whenever they genuinely reduce buying, and every report labels on-hand rows explicitly. diff --git a/skills/steel-cutlist/scripts/cutlist.py b/skills/steel-cutlist/scripts/cutlist.py index 12aaf8f..a2a0207 100644 --- a/skills/steel-cutlist/scripts/cutlist.py +++ b/skills/steel-cutlist/scripts/cutlist.py @@ -314,6 +314,16 @@ def number(value, path, *, positive=False, nonnegative=False): stock_types = [] for index, stock in enumerate(job.get("stock", [])): path = f"$.stock[{index}]" + stock_kind = stock.get("stock_kind", "purchasable") + if stock_kind not in {"purchasable", "on_hand"}: + findings.append( + _validation_finding( + "invalid_stock_kind", + f"{path}.stock_kind", + "Stock kind must be purchasable or on_hand.", + ) + ) + stock_kind = "purchasable" designation = normalize_designation(stock.get("designation", "")) if not designation: findings.append( @@ -343,6 +353,14 @@ def number(value, path, *, positive=False, nonnegative=False): ) ) unlimited = bool(stock.get("unlimited", False)) + if unlimited and stock_kind == "on_hand": + findings.append( + _validation_finding( + "unlimited_on_hand_stock", + f"{path}.unlimited", + "On-hand stock must be a finite, measured quantity.", + ) + ) try: quantity = math.inf if unlimited else int(stock.get("qty", 1)) quantity_valid = unlimited or ( @@ -368,6 +386,14 @@ def number(value, path, *, positive=False, nonnegative=False): "Use either cost_per_ft or cost_per_bar for one stock entry, not both.", ) ) + if stock_kind == "on_hand" and (per_foot is not None or per_bar is not None): + findings.append( + _validation_finding( + "cost_basis_on_hand_stock", + path, + "On-hand stock carries no purchase cost basis; cost applies to purchasable stock only.", + ) + ) if per_foot is not None: per_foot = number(per_foot, f"{path}.cost_per_ft", nonnegative=True) if per_bar is not None: @@ -387,6 +413,7 @@ def number(value, path, *, positive=False, nonnegative=False): { "stock_id": stock_id, "name": stock.get("name", designation or "Bar"), + "stock_kind": stock_kind, "designation": designation, "grade": grade, "length_in": length, @@ -557,13 +584,21 @@ def _bar_cost(stock): return None +def _ranking_cost(stock): + """Purchase outlay used for strategy ranking; on-hand material costs nothing.""" + if stock["stock_kind"] == "on_hand": + return 0.0 + return _bar_cost(stock) + + def _solve_group(units, group_stock, kerf): """Try a portfolio of deterministic strategies; keep the cheapest result. Candidates: the mixed-stock greedy plus each single-stock-length - restriction. Solutions rank by fewest unplaced members, least total stock - length, lowest known purchase cost (unknown costs rank last), then fewest - bars. Ties resolve by strategy name for determinism. + restriction. Solutions rank by fewest unplaced members, least PURCHASED + stock length (on-hand consumption is free), lowest known purchase cost + (unknown costs rank last), fewest purchased bars, then least total + length. Ties resolve by strategy name for determinism. """ strategies = [("mixed", group_stock)] for stock in group_stock: @@ -573,15 +608,20 @@ def _solve_group(units, group_stock, kerf): for name, stocks in strategies: bars, unplaced = _greedy_pack(units, stocks, kerf, group_stock) total_length = sum(bar["stock"]["length_in"] for bar in bars) - costs = [_bar_cost(bar["stock"]) for bar in bars] + purchased = [ + bar for bar in bars if bar["stock"]["stock_kind"] == "purchasable" + ] + purchased_length = sum(bar["stock"]["length_in"] for bar in purchased) + costs = [_ranking_cost(bar["stock"]) for bar in bars] cost_rank = ( - round(sum(costs), 2) if costs and None not in costs else math.inf + round(sum(costs), 2) if None not in costs else math.inf ) rank = ( sum(1 for _ in unplaced), - round(total_length, 6), + round(purchased_length, 6), cost_rank, - len(bars), + len(purchased), + round(total_length, 6), name, ) if best_rank is None or rank < best_rank: @@ -782,7 +822,10 @@ def _summarize(normalized, used_bars, unplaced, validation_findings, normalized_ kerf_total = kerf * len(cuts) used_length = min(cut_length + kerf_total, stock["usable_in"]) drop = max(stock["usable_in"] - used_length, 0.0) - if stock["cost_per_bar"] is not None: + if stock["stock_kind"] == "on_hand": + bar_cost = None + cost_basis = "on_hand" + elif stock["cost_per_bar"] is not None: bar_cost = stock["cost_per_bar"] cost_basis = "per_bar" elif stock["cost_per_ft"] is not None: @@ -798,6 +841,7 @@ def _summarize(normalized, used_bars, unplaced, validation_findings, normalized_ "index": bar["index"], "stock": stock["name"], "stock_id": stock["stock_id"], + "stock_kind": stock["stock_kind"], "designation": stock["designation"], "grade": stock["grade"], "bar_length_in": stock["length_in"], @@ -885,6 +929,7 @@ def _summarize(normalized, used_bars, unplaced, validation_findings, normalized_ row["bars"] += 1 row["length_in"] += report["bar_length_in"] row["stock_name"] = report["stock"] + row["stock_kind"] = report["stock_kind"] row["designation"] = report["designation"] row["grade"] = report["grade"] row["bar_length_in"] = report["bar_length_in"] @@ -896,6 +941,7 @@ def _summarize(normalized, used_bars, unplaced, validation_findings, normalized_ { "stock_id": stock_id, "stock_name": row["stock_name"], + "stock_kind": row["stock_kind"], "designation": row["designation"], "grade": row["grade"], "bar_length_in": row["bar_length_in"], @@ -1057,6 +1103,7 @@ def rfq_linear_block(res): { "stock_id": purchase["stock_id"], "stock_name": purchase["stock_name"], + "stock_kind": purchase["stock_kind"], "designation": purchase["designation"], "grade": purchase["grade"], "bar_length_in": purchase["bar_length_in"], @@ -1137,10 +1184,11 @@ def render_text(res): cost_text = ( f" ${row['total_cost']:,.2f}" if row["total_cost"] is not None else "" ) + on_hand_text = " (on hand)" if row["stock_kind"] == "on_hand" else "" lines.append( f" {row['bars_needed']} x {_fmt(row['bar_length_in'])} in " f"{row['designation']} {row['grade']} " - f"({row['total_length_ft']} ft){cost_text}" + f"({row['total_length_ft']} ft){cost_text}{on_hand_text}" ) lines.append("") if res["drops"]: diff --git a/skills/steel-estimate/SKILL.md b/skills/steel-estimate/SKILL.md index 3ee9cec..4b8d957 100644 --- a/skills/steel-estimate/SKILL.md +++ b/skills/steel-estimate/SKILL.md @@ -18,6 +18,10 @@ authorize a purchase. The workbook remains `DRAFT — NOT SENT OR AWARDED`. - A runtime company profile accepted by `steel-rfq`. - Plate stock compatible with every plate part by material, grade, thickness, and usable dimensions. +- Optional linear stock (`stock_form: "linear"`): purchasable vendor lengths + (which replace the default mill lengths for their designation + grade + group) and on-hand sticks, which require the same hash-bound reviewer + confirmation as on-hand plate before they can reduce purchasing. Use `references/estimate-package-example.json` as a synthetic input example. The company profile resolution and approval rules are documented by diff --git a/skills/steel-estimate/scripts/build-estimate-package.py b/skills/steel-estimate/scripts/build-estimate-package.py index de7b37e..f097e2d 100755 --- a/skills/steel-estimate/scripts/build-estimate-package.py +++ b/skills/steel-estimate/scripts/build-estimate-package.py @@ -35,7 +35,9 @@ sha256_bytes, ) from pi_steel.contracts import ESTIMATE_PACKAGE_VERSION # noqa: E402 +from pi_steel.parsing import normalize_designation # noqa: E402 from pi_steel.validation import ( # noqa: E402 + eligible_on_hand_linear_stock, eligible_on_hand_stock, validate_estimate_package, ) @@ -142,6 +144,8 @@ def nest_job_from_package( return None stock_rows = [] for index, stock in enumerate(package.get("stock", []), start=1): + if stock.get("stock_form") == "linear": + continue if ( stock["stock_kind"] == "on_hand" and stock.get("inventory_id") not in eligible_inventory_ids @@ -229,8 +233,14 @@ def cutlist_job_from_package( kerf_in: float, end_trim_in: float, min_drop_in: float, + eligible_linear_inventory_ids: set[str] = frozenset(), ) -> dict[str, Any] | None: - """Build the linear optimization job for member items purchased by length.""" + """Build the linear optimization job for member items purchased by length. + + Declared purchasable linear stock replaces the default mill lengths for + its designation + grade group; confirmed on-hand sticks are added as + finite-quantity entries. + """ member_items = [ item for item in package["items"] @@ -263,18 +273,53 @@ def cutlist_job_from_package( }, key=repr, ) - stock = [ - { - "stock_id": f"mill:{designation}:{grade}:{length_ft:g}ft", - "name": f"{designation} {length_ft:g} ft mill length", - "designation": designation, - "grade": grade, - "length_ft": length_ft, - "unlimited": True, + stock = [] + declared_purchasable_groups = set() + for index, entry in enumerate(package.get("stock", []), start=1): + if entry.get("stock_form") != "linear": + continue + if ( + entry["stock_kind"] == "on_hand" + and entry.get("inventory_id") not in eligible_linear_inventory_ids + ): + continue + row = { + "stock_id": entry.get("inventory_id") or f"vendor-linear:{index:04d}", + "name": entry.get("inventory_id") + or ( + f"{entry['designation']} {entry['length_ft']:g} ft " + "vendor length" + ), + "stock_kind": entry["stock_kind"], + "designation": entry["designation"], + "grade": entry["grade"], + "length_ft": entry["length_ft"], + "qty": entry["quantity"], } - for designation, grade in groups - for length_ft in mill_lengths_ft - ] + if entry.get("unlimited") and entry["stock_kind"] == "purchasable": + row["unlimited"] = True + stock.append(row) + if entry["stock_kind"] == "purchasable": + declared_purchasable_groups.add( + (normalize_designation(entry["designation"]), entry["grade"]) + ) + for designation, grade in groups: + if ( + normalize_designation(designation), + grade, + ) in declared_purchasable_groups: + continue + stock.extend( + { + "stock_id": f"mill:{designation}:{grade}:{length_ft:g}ft", + "name": f"{designation} {length_ft:g} ft mill length", + "designation": designation, + "grade": grade, + "length_ft": length_ft, + "unlimited": True, + } + for length_ft in mill_lengths_ft + ) return { "job_name": package["project"].get("name") or package["project"]["project_id"], @@ -536,6 +581,14 @@ def build_pipeline(args) -> tuple[dict[str, Any], Path]: if not validation.blockers else set() ) + eligible_linear_inventory_ids = ( + { + stock["inventory_id"] + for stock in eligible_on_hand_linear_stock(package) + } + if not validation.blockers + else set() + ) findings = [ { "code": finding["code"], @@ -645,6 +698,7 @@ def build_pipeline(args) -> tuple[dict[str, Any], Path]: kerf_in=args.cutlist_kerf_in, end_trim_in=args.end_trim_in, min_drop_in=args.min_drop_in, + eligible_linear_inventory_ids=eligible_linear_inventory_ids, ) if cutlist_job is not None: cutlist_result = cutlist_engine.run_job(cutlist_job) diff --git a/tests/test_cutlist_engine.py b/tests/test_cutlist_engine.py index 45e819a..35a639b 100644 --- a/tests/test_cutlist_engine.py +++ b/tests/test_cutlist_engine.py @@ -428,3 +428,70 @@ def test_cutting_list_preserves_sixteenth_inch_precision(): result = cutlist.run_job(job) csv_text = cutlist.render_cutting_list_csv(result) assert "342.0625" in csv_text + + +def test_on_hand_stick_covering_members_eliminates_purchasing(): + job = base_job() + job["members"] = [ + { + "source_id": "SYNTHETIC-M-SHORT", + "name": "SYNTHETIC-SHORT", + "designation": "W12X26", + "grade": "A992", + "length_in": 144, + "qty": 2, + } + ] + job["stock"].append( + { + "stock_id": "SYNTHETIC-ONHAND-30", + "stock_kind": "on_hand", + "designation": "W12X26", + "grade": "A992", + "length_ft": 30, + "qty": 1, + } + ) + result = cutlist.run_job(job) + assert result["outcome"] == "ready" + assert result["bars_used"] == 1 + report = result["bar_reports"][0] + assert report["stock_kind"] == "on_hand" + assert report["cost_basis"] == "on_hand" + assert report["bar_cost"] is None + summary = result["purchase_summary"][0] + assert summary["stock_kind"] == "on_hand" + assert result["cost"]["status"] == "known" + assert result["total_material_cost"] == 0.0 + assert result["rfq_linear"]["rows"][0]["stock_kind"] == "on_hand" + + +def test_on_hand_stock_rejects_unlimited_and_cost_basis(): + job = base_job() + job["stock"].append( + { + "stock_id": "SYNTHETIC-ONHAND-BAD", + "stock_kind": "on_hand", + "designation": "W12X26", + "grade": "A992", + "length_ft": 30, + "unlimited": True, + "cost_per_ft": 10.0, + } + ) + result = cutlist.run_job(job) + codes = {finding["code"] for finding in result["validation_findings"]} + assert "unlimited_on_hand_stock" in codes + assert "cost_basis_on_hand_stock" in codes + assert result["outcome"] == "blocked" + + +def test_invalid_stock_kind_is_an_error(): + job = base_job() + job["stock"][0]["stock_kind"] = "borrowed" + result = cutlist.run_job(job) + assert any( + finding["code"] == "invalid_stock_kind" + for finding in result["validation_findings"] + ) + assert result["outcome"] == "blocked" diff --git a/tests/test_estimate_pipeline.py b/tests/test_estimate_pipeline.py index 79e28ae..2ced9db 100644 --- a/tests/test_estimate_pipeline.py +++ b/tests/test_estimate_pipeline.py @@ -511,3 +511,84 @@ def test_nonfinite_mill_lengths_fail_as_usage_error(tmp_path): ) assert completed.returncode == 1 assert "finite" in completed.stderr + + +def test_declared_vendor_linear_stock_overrides_default_mill_lengths(tmp_path): + package = load_package() + package["stock"].append( + { + "stock_kind": "purchasable", + "stock_form": "linear", + "inventory_id": "SYNTHETIC-VENDOR-W12-45", + "designation": "W12X26", + "grade": "A992", + "length_ft": 45, + "quantity": 1, + "unlimited": True, + } + ) + completed, run_path = run_pipeline( + tmp_path, package, "SYNTHETIC-PIPELINE-VENDOR-LINEAR" + ) + assert completed.returncode == 0, completed.stdout + completed.stderr + cutlist = load_json(run_path / "cutlist-result.json") + rows = {row["designation"]: row for row in cutlist["purchase_summary"]} + assert rows["W12X26"]["stock_id"] == "SYNTHETIC-VENDOR-W12-45" + assert rows["W12X26"]["bar_length_in"] == 540.0 + assert rows["HSS6X6X3/8"]["stock_id"].startswith("mill:") + + +def test_confirmed_on_hand_linear_stick_eliminates_group_purchase(tmp_path): + package = load_package() + stick = { + "stock_kind": "on_hand", + "stock_form": "linear", + "inventory_id": "SYNTHETIC-YARD-W12-30", + "designation": "W12X26", + "grade": "A992", + "length_ft": 30, + "quantity": 1, + "status": "available", + "measured_at": "2026-07-27", + "source": "synthetic yard count", + } + package["stock"].append(stick) + stick["reviewer_confirmation"] = { + "actor": "Synthetic Reviewer", + "timestamp": "2026-07-28T00:00:00Z", + "estimate_hash": estimate_input_hash(package), + } + completed, run_path = run_pipeline( + tmp_path, package, "SYNTHETIC-PIPELINE-ONHAND-LINEAR" + ) + assert completed.returncode == 0, completed.stdout + completed.stderr + cutlist = load_json(run_path / "cutlist-result.json") + rows = {row["designation"]: row for row in cutlist["purchase_summary"]} + assert rows["W12X26"]["stock_id"] == "SYNTHETIC-YARD-W12-30" + assert rows["W12X26"]["stock_kind"] == "on_hand" + assert rows["W12X26"]["total_cost"] is None + assert rows["HSS6X6X3/8"]["stock_kind"] == "purchasable" + + +def test_unconfirmed_on_hand_linear_stick_blocks_validation(tmp_path): + package = load_package() + package["stock"].append( + { + "stock_kind": "on_hand", + "stock_form": "linear", + "inventory_id": "SYNTHETIC-YARD-UNCONFIRMED", + "designation": "W12X26", + "grade": "A992", + "length_ft": 30, + "quantity": 1, + } + ) + completed, run_path = run_pipeline( + tmp_path, package, "SYNTHETIC-PIPELINE-ONHAND-UNCONFIRMED" + ) + assert completed.returncode == 3 + qa_report = load_json(run_path / "qa-report.json") + assert any( + finding["code"] == "unconfirmed_on_hand_stock" + for finding in qa_report["findings"] + ) From a16e93c0fcc53411bc7bc16a301b4c725f8101ca Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 23 Aug 2026 08:39:23 +0000 Subject: [PATCH 06/12] fix(pipeline): suppress verified cutting list on non-ready runs The full-render smoke caught two problems with the cut-list additions: - cutting_list.csv was published with geometry_verified readiness even when the overall run was review_required (e.g. irregular plate geometry), breaking the invariant that non-ready runs carry no verified-authority artifacts. The verified cutting list now requires the whole run to be ready, mirroring burn-DXF suppression, with a base-tier regression assertion. - The workbook PDF page-count assertions assumed one total page, but the linear reference section grows the sheet and pagination varies by LibreOffice version (24.2 renders one page where CI renders two). The smoke test now asserts the actual contract: uniform page width (one page wide), all words within their own page bounds, document-order section ordering across pages, page-1 logo bounds, and a sanity cap on total pages. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01MquyTzVAPKpV1V8Vt3XxnJ --- .../scripts/build-estimate-package.py | 2 +- tests/test_estimate_pipeline.py | 1 + tests/test_full_render_smoke.py | 60 +++++++++++++------ 3 files changed, 43 insertions(+), 20 deletions(-) diff --git a/skills/steel-estimate/scripts/build-estimate-package.py b/skills/steel-estimate/scripts/build-estimate-package.py index f097e2d..13464fc 100755 --- a/skills/steel-estimate/scripts/build-estimate-package.py +++ b/skills/steel-estimate/scripts/build-estimate-package.py @@ -980,7 +980,7 @@ def build_pipeline(args) -> tuple[dict[str, Any], Path]: publisher.write_json( "rfq-linear.json", linear_handoff, readiness="diagnostic" ) - if cutlist_result["outcome"] == "ready": + if cutlist_result["outcome"] == "ready" and outcome == "ready": publisher.write_bytes( "cutting_list.csv", cutlist_engine.render_cutting_list_csv( diff --git a/tests/test_estimate_pipeline.py b/tests/test_estimate_pipeline.py index 2ced9db..b5efdf4 100644 --- a/tests/test_estimate_pipeline.py +++ b/tests/test_estimate_pipeline.py @@ -480,6 +480,7 @@ def test_member_without_grade_is_excluded_from_cutlist_not_blocked(tmp_path): } assert "W1" not in marks assert "HSS1" in marks + assert not (run_path / "cutting_list.csv").exists() def test_nonfinite_mill_lengths_fail_as_usage_error(tmp_path): diff --git a/tests/test_full_render_smoke.py b/tests/test_full_render_smoke.py index 28c8d4f..7e9a91c 100644 --- a/tests/test_full_render_smoke.py +++ b/tests/test_full_render_smoke.py @@ -137,7 +137,10 @@ def test_ready_package_renders_reference_and_verified_outputs(tmp_path): for line in info.splitlines() if line.startswith("Pages:") ) - assert page_count == 1 + # The workbook contract is one page WIDE (fitToWidth=1, fitToHeight=0); + # vertical pagination varies with content and the LibreOffice version. + # The cap only guards against a runaway layout regression. + assert 1 <= page_count <= 3 text_output = tmp_path / "workbook.txt" extracted = subprocess.run( @@ -164,22 +167,41 @@ def test_ready_package_renders_reference_and_verified_outputs(tmp_path): ) root = ElementTree.parse(bbox_output).getroot() pages = [element for element in root.iter() if element.tag.endswith("page")] - words = [element for element in root.iter() if element.tag.endswith("word")] - assert len(pages) == 1 - page_width = float(pages[0].attrib["width"]) - page_height = float(pages[0].attrib["height"]) - assert words - for word in words: - assert 0 <= float(word.attrib["xMin"]) < float(word.attrib["xMax"]) <= page_width - assert 0 <= float(word.attrib["yMin"]) < float(word.attrib["yMax"]) <= page_height + assert len(pages) == page_count + first_width = float(pages[0].attrib["width"]) + for page in pages: + assert float(page.attrib["width"]) == first_width + page_width = float(page.attrib["width"]) + page_height = float(page.attrib["height"]) + page_words = [ + element for element in page.iter() if element.tag.endswith("word") + ] + assert page_words + for word in page_words: + assert 0 <= float(word.attrib["xMin"]) < float(word.attrib["xMax"]) <= page_width + assert 0 <= float(word.attrib["yMin"]) < float(word.attrib["yMax"]) <= page_height - word_positions = { - (word.text or "").upper(): float(word.attrib["yMin"]) for word in words - } - assert word_positions["REQUEST"] < page_height * 0.2 + first_page_width = float(pages[0].attrib["width"]) + first_page_height = float(pages[0].attrib["height"]) + words = [ + element for element in pages[0].iter() if element.tag.endswith("word") + ] + + # Document-order positions: (page index, yMin) compares across pages. + word_positions = {} + page_heights = {} + for page_index, page in enumerate(pages): + for word in page.iter(): + if not word.tag.endswith("word"): + continue + key = (word.text or "").upper() + position = (page_index, float(word.attrib["yMin"])) + word_positions.setdefault(key, position) + page_heights[key] = float(page.attrib["height"]) + assert word_positions["REQUEST"] < (0, first_page_height * 0.2) assert word_positions["RESPONSE"] < word_positions["TOTAL"] assert word_positions["TOTAL"] < word_positions["TERMS"] - assert word_positions["TERMS"] < page_height * 0.95 + assert word_positions["TERMS"][1] < page_heights["TERMS"] * 0.95 images = subprocess.run( [tools["pdfimages"], "-list", workbook_pdf], @@ -229,12 +251,12 @@ def test_ready_package_renders_reference_and_verified_outputs(tmp_path): logo_rows, logo_columns = numpy.where(synthetic_logo) assert logo_rows.size and logo_columns.size logo_bounds = ( - logo_columns.min() / raster.shape[1] * page_width, - logo_rows.min() / raster.shape[0] * page_height, - logo_columns.max() / raster.shape[1] * page_width, - logo_rows.max() / raster.shape[0] * page_height, + logo_columns.min() / raster.shape[1] * first_page_width, + logo_rows.min() / raster.shape[0] * first_page_height, + logo_columns.max() / raster.shape[1] * first_page_width, + logo_rows.max() / raster.shape[0] * first_page_height, ) - assert logo_bounds[1] < page_height * 0.2 + assert logo_bounds[1] < first_page_height * 0.2 for word in words: if (word.text or "").upper() not in {"RESPONSE", "TOTAL", "TERMS"}: continue From 81eedaff46a85ea377219e411db7918f4529db17 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 23 Aug 2026 08:41:08 +0000 Subject: [PATCH 07/12] fix(cutlist): address review findings on stock input strictness - Reject non-boolean 'unlimited' values with an invalid_unlimited_flag finding instead of coercing truthy strings into unlimited supply, which could understate purchase requirements. - Include stock_kind in generated stock identities so anonymous on-hand and purchasable rows of the same designation, grade, and length no longer collide as duplicates. - README: list channels and tube among supported cut-list member types, matching the changelog. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01MquyTzVAPKpV1V8Vt3XxnJ --- README.md | 2 +- skills/steel-cutlist/scripts/cutlist.py | 12 ++++++++- tests/test_cutlist_engine.py | 36 +++++++++++++++++++++++++ 3 files changed, 48 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 5672077..ebb4d5f 100644 --- a/README.md +++ b/README.md @@ -71,7 +71,7 @@ machine-specific CAM compatibility. ### Linear cut-lists -`steel-cutlist` packs member lengths (beams, HSS, angles, pipe) onto +`steel-cutlist` packs member lengths (beams, channels, angles, HSS, tube, pipe) onto purchasable mill lengths with explicit saw kerf and end-trim allowances. A deterministic strategy portfolio per designation and grade minimizes unplaced members, total stock length, and known cost, and every bar is independently diff --git a/skills/steel-cutlist/scripts/cutlist.py b/skills/steel-cutlist/scripts/cutlist.py index a2a0207..bb6de98 100644 --- a/skills/steel-cutlist/scripts/cutlist.py +++ b/skills/steel-cutlist/scripts/cutlist.py @@ -352,7 +352,16 @@ def number(value, path, *, positive=False, nonnegative=False): "End trim consumes the entire stock length.", ) ) - unlimited = bool(stock.get("unlimited", False)) + unlimited = stock.get("unlimited", False) + if not isinstance(unlimited, bool): + findings.append( + _validation_finding( + "invalid_unlimited_flag", + f"{path}.unlimited", + "Unlimited must be a JSON boolean.", + ) + ) + unlimited = False if unlimited and stock_kind == "on_hand": findings.append( _validation_finding( @@ -403,6 +412,7 @@ def number(value, path, *, positive=False, nonnegative=False): + content_hash( { "name": stock.get("name", "Bar"), + "stock_kind": stock_kind, "designation": designation, "grade": grade, "length_in": length, diff --git a/tests/test_cutlist_engine.py b/tests/test_cutlist_engine.py index 35a639b..0c595ff 100644 --- a/tests/test_cutlist_engine.py +++ b/tests/test_cutlist_engine.py @@ -495,3 +495,39 @@ def test_invalid_stock_kind_is_an_error(): for finding in result["validation_findings"] ) assert result["outcome"] == "blocked" + + +def test_string_unlimited_flag_is_rejected_not_coerced(): + job = base_job() + job["stock"][0]["unlimited"] = "false" + result = cutlist.run_job(job) + assert any( + finding["code"] == "invalid_unlimited_flag" + for finding in result["validation_findings"] + ) + assert result["outcome"] == "blocked" + + +def test_anonymous_on_hand_and_purchasable_rows_get_distinct_ids(): + job = base_job() + job["stock"] = [ + { + "designation": "W12X26", + "grade": "A992", + "length_ft": 40, + "qty": 5, + }, + { + "stock_kind": "on_hand", + "designation": "W12X26", + "grade": "A992", + "length_ft": 40, + "qty": 1, + }, + ] + result = cutlist.run_job(job) + assert not any( + finding["code"] == "duplicate_stock_id" + for finding in result["validation_findings"] + ) + assert result["outcome"] == "ready" From bb56f57d434e10e0611e5b0c4a2c3aebc607413e Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 23 Aug 2026 13:27:38 +0000 Subject: [PATCH 08/12] feat(cutlist): bounded exact search and cost-first ranking - Small designation+grade groups (up to 12 pieces) now run a deterministic branch-and-bound search over complete bar assignments, seeded and pruned by the portfolio rank with a fixed node budget; it only ever replaces the greedy plan with a strictly better one. On the classic best-fit-decreasing failure (5,5,4,4,3,3,3,3 onto capacity 10) it finds the 3-bar optimum where the greedy needs 4. - The ranking objective is now economically honest: when every stock entry in a group carries a known cost basis, lowest purchase cost decides before purchased length (buying cheaper beats buying shorter); unpriced groups keep the least-purchased-length objective. Without prices, the exact search finds a 230 ft plan on the reference case where the greedy bought 240 ft. - output-contract.md documents cutlist artifacts and cutlist_partial; the estimate-package example gains member items and vendor linear stock; version 0.3.1. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01MquyTzVAPKpV1V8Vt3XxnJ --- CHANGELOG.md | 19 ++ README.md | 2 +- package.json | 2 +- pyproject.toml | 2 +- skills/steel-cutlist/SKILL.md | 6 +- skills/steel-cutlist/scripts/cutlist.py | 182 +++++++++++++++--- .../references/estimate-package-example.json | 28 +++ .../references/output-contract.md | 10 +- tests/test_cutlist_engine.py | 81 ++++++++ 9 files changed, 301 insertions(+), 31 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f339b9f..b083bcc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,25 @@ All notable changes to `@structupath/pi-steel` are documented here. The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and the project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [0.3.1] - 2026-08-23 + +### Added + +- Bounded exact cut-list optimization: designation + grade groups with up + to 12 pieces run a deterministic branch-and-bound search (seeded and + bounded by the portfolio result, fixed node budget) that only ever + replaces the greedy plan with a strictly better one. +- `output-contract.md` documents the cut-list artifacts and + `cutlist_partial` status; the estimate-package example now includes + member items and vendor linear stock. + +### Changed + +- Ranking now minimizes purchase cost before purchased length when every + stock entry in a group carries a known cost basis — buying cheaper beats + buying shorter; groups without complete pricing keep the least-purchased- + length objective. + ## [0.3.0] - 2026-08-23 ### Added diff --git a/README.md b/README.md index ebb4d5f..4d7ce09 100644 --- a/README.md +++ b/README.md @@ -146,7 +146,7 @@ npm run provenance:check # shape-data integrity and recorded decision npm run release:check # complete release gate ``` -The current package version is `0.3.0`. `release:check` verifies the test, +The current package version is `0.3.1`. `release:check` verifies the test, privacy, package-content, shape-data integrity, ownership, license, and redistribution contracts before publication. diff --git a/package.json b/package.json index 073ccec..9fcd7f4 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@structupath/pi-steel", - "version": "0.3.0", + "version": "0.3.1", "description": "Structural steel estimating for Pi \u2014 validated takeoffs, plate nesting, guarded DXF output, and review-ready RFQ packages.", "type": "module", "keywords": [ diff --git a/pyproject.toml b/pyproject.toml index 3187f1b..0fa5fe2 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "pi-steel-runtime" -version = "0.3.0" +version = "0.3.1" description = "Python runtime dependencies and test configuration for pi-steel" requires-python = ">=3.11,<3.14" dependencies = [ diff --git a/skills/steel-cutlist/SKILL.md b/skills/steel-cutlist/SKILL.md index ce35421..21a364d 100644 --- a/skills/steel-cutlist/SKILL.md +++ b/skills/steel-cutlist/SKILL.md @@ -15,7 +15,7 @@ It complements `steel-nest` (2D plates). Plates go to `steel-nest`; anything bou **Reliable:** - Exact 1D packing per designation + grade group. Stock never crosses groups: a W12X26 member is only cut from W12X26 stock of the same grade. -- A deterministic strategy portfolio per group — a mixed-stock greedy plus each single-stock-length restriction — ranked by fewest unplaced members, least purchased stock length (on-hand consumption is free), lowest known purchase cost, fewest purchased bars, then least total length. The same input always produces the same plan. +- A deterministic strategy portfolio per group — a mixed-stock greedy plus each single-stock-length restriction — refined by a bounded exact branch-and-bound search for small groups (up to 12 pieces) that only ever replaces the portfolio result with a strictly better one. Ranking always minimizes unplaced members first; when every stock entry in the group carries a known cost basis, lowest purchase cost decides next (buying cheaper beats buying shorter), otherwise least purchased stock length decides (on-hand consumption is free). Fewest purchased bars and least total length settle ties. The same input always produces the same plan. - Explicit fit contract: usable length = bar length − 2 × end trim; a piece fits when its length alone fits the remainder; each placed piece then consumes its length plus one kerf, saturating at the bar end. - Independent post-placement verification (bar overcommitment, material mismatch, duplicate or missing instances) before any cutting list is published. - Drops classified against a reusable-candidate threshold (`min_drop_in`) — candidates are never certified reusable stock. @@ -26,7 +26,7 @@ It complements `steel-nest` (2D plates). Plates go to `steel-nest`; anything bou **Deliberately NOT done:** - No saw-controller programs or claims of machine-specific compatibility; the cutting list is a shop document that an operator verifies. - No remnant-inventory or scrap-market optimization. Drop candidates need a person to measure, identify, and approve before they become stock. -- No true global optimum guarantee — the portfolio heuristic is strong and deterministic, but it is a heuristic; say so if asked. +- No global optimum guarantee for large groups — small groups (up to 12 pieces) are solved exactly within a fixed search budget, larger ones fall back to the deterministic portfolio heuristic; say so if asked. ## Inputs to Gather @@ -85,4 +85,4 @@ The engine writes `rfq_linear.json` — `{schema_version, source_cutlist_result_ **Drop reuse** — output drops are candidates only. Measure, identify, and approve a candidate before supplying it as its own finite stock entry in a later run (a shorter `length_in` entry with `qty: 1`). -**On-hand material first** — enter on-hand bars as finite-quantity stock entries with `"stock_kind": "on_hand"` alongside purchasable lengths. On-hand stock must be finite and carries no cost basis; the portfolio minimizes purchased length first, so sticks are consumed whenever they genuinely reduce buying, and every report labels on-hand rows explicitly. +**On-hand material first** — enter on-hand bars as finite-quantity stock entries with `"stock_kind": "on_hand"` alongside purchasable lengths. On-hand stock must be finite and carries no cost basis; the optimizer treats on-hand consumption as free (zero cost, zero purchased length), so sticks are consumed whenever they genuinely reduce buying, and every report labels on-hand rows explicitly. diff --git a/skills/steel-cutlist/scripts/cutlist.py b/skills/steel-cutlist/scripts/cutlist.py index bb6de98..2cf5707 100644 --- a/skills/steel-cutlist/scripts/cutlist.py +++ b/skills/steel-cutlist/scripts/cutlist.py @@ -71,7 +71,9 @@ from pi_steel.parsing import normalize_designation, parse_length_ft # noqa: E402 CUTLIST_RESULT_VERSION = "1.0.0" -CUTLIST_ALGORITHM_VERSION = "portfolio-bfd-v1" +CUTLIST_ALGORITHM_VERSION = "portfolio-bfd-exact-v1" +EXACT_SEARCH_MAX_UNITS = 12 +EXACT_SEARCH_NODE_BUDGET = 250_000 EPS = 1e-6 AISC_DATABASE_RELATIVE = Path("steel-takeoff") / "assets" / "aisc-shapes-database.json" @@ -601,43 +603,177 @@ def _ranking_cost(stock): return _bar_cost(stock) -def _solve_group(units, group_stock, kerf): - """Try a portfolio of deterministic strategies; keep the cheapest result. +def _rank_solution(bars, unplaced_count, cost_priority): + """Solution ranking, always fewest unplaced members first. - Candidates: the mixed-stock greedy plus each single-stock-length - restriction. Solutions rank by fewest unplaced members, least PURCHASED - stock length (on-hand consumption is free), lowest known purchase cost - (unknown costs rank last), fewest purchased bars, then least total - length. Ties resolve by strategy name for determinism. + When every stock entry in the group has a known purchase basis + (``cost_priority``), lowest cost decides next — buying cheaper beats + buying shorter. Otherwise least PURCHASED stock length decides (on-hand + consumption is free) with any known cost as a later tie-break. Fewest + purchased bars, then least total length, settle remaining ties. + """ + purchased = [ + bar for bar in bars if bar["stock"]["stock_kind"] == "purchasable" + ] + purchased_length = round( + sum(bar["stock"]["length_in"] for bar in purchased), 6 + ) + costs = [_ranking_cost(bar["stock"]) for bar in bars] + cost_rank = round(sum(costs), 2) if None not in costs else math.inf + total_length = round(sum(bar["stock"]["length_in"] for bar in bars), 6) + if cost_priority: + primary, secondary = cost_rank, purchased_length + else: + primary, secondary = purchased_length, cost_rank + return (unplaced_count, primary, secondary, len(purchased), total_length) + + +class _SearchBudgetExceeded(Exception): + pass + + +def _exact_solve_group(units, group_stock, kerf, best_rank, cost_priority): + """Branch-and-bound over complete bar assignments for a small group. + + Seeded with the portfolio's rank for pruning, bounded by a fixed node + budget so runtime stays deterministic. Returns (bars, unplaced, rank) + only when a complete assignment strictly beats ``best_rank``; otherwise + None and the caller keeps the portfolio result. + """ + placeable = [] + unplaced_units = [] + for unit in units: + if any( + unit["length_in"] <= stock["usable_in"] + EPS + for stock in group_stock + ): + placeable.append(unit) + else: + unplaced_units.append({**unit, "reason": "no_compatible_stock_fit"}) + if not placeable or len(placeable) > EXACT_SEARCH_MAX_UNITS: + return None + stocks = sorted(group_stock, key=lambda stock: stock["stock_id"]) + used = {stock["stock_id"]: 0 for stock in stocks} + bars_state = [] # mutable [stock, remaining, cuts] triples + unplaced_count = len(unplaced_units) + state = {"nodes": 0, "best": None, "best_rank": best_rank} + + def primary_bound(): + """Monotone lower bound on the rank's primary component.""" + if cost_priority: + costs = [_ranking_cost(triple[0]) for triple in bars_state] + return ( + round(sum(costs), 2) if None not in costs else math.inf + ) + return round( + sum( + triple[0]["length_in"] + for triple in bars_state + if triple[0]["stock_kind"] == "purchasable" + ), + 6, + ) + + def descend(index): + state["nodes"] += 1 + if state["nodes"] > EXACT_SEARCH_NODE_BUDGET: + raise _SearchBudgetExceeded + if ( + state["best_rank"] is not None + and primary_bound() > state["best_rank"][1] + ): + return + if index == len(placeable): + solution = [ + {"stock": triple[0], "cuts": list(triple[2]), "remaining": 0.0} + for triple in bars_state + ] + rank = _rank_solution(solution, unplaced_count, cost_priority) + if state["best_rank"] is None or rank < state["best_rank"]: + state["best_rank"] = rank + state["best"] = [ + (triple[0], list(triple[2])) for triple in bars_state + ] + return + unit = placeable[index] + length = unit["length_in"] + seen = set() + for triple in bars_state: + slot = (triple[0]["stock_id"], round(triple[1], 6)) + if slot in seen: + continue + seen.add(slot) + if length <= triple[1] + EPS: + previous = triple[1] + triple[2].append(unit) + triple[1] = max(previous - length - kerf, 0.0) + descend(index + 1) + triple[1] = previous + triple[2].pop() + for stock in stocks: + if used[stock["stock_id"]] >= stock["qty"]: + continue + if length > stock["usable_in"] + EPS: + continue + used[stock["stock_id"]] += 1 + bars_state.append( + [stock, max(stock["usable_in"] - length - kerf, 0.0), [unit]] + ) + descend(index + 1) + bars_state.pop() + used[stock["stock_id"]] -= 1 + + try: + descend(0) + except _SearchBudgetExceeded: + return None + if state["best"] is None: + return None + bars = [] + for stock, cuts in state["best"]: + bar = {"stock": stock, "cuts": [], "remaining": stock["usable_in"]} + for unit in cuts: + _place_on_bar(bar, unit, kerf) + bars.append(bar) + return bars, unplaced_units, state["best_rank"] + + +def _solve_group(units, group_stock, kerf): + """Deterministic strategy portfolio, refined by a bounded exact search. + + Portfolio candidates: the mixed-stock greedy plus each + single-stock-length restriction, ranked per ``_rank_solution`` with the + strategy name as the final tie-break. Small groups then run a + branch-and-bound exact search seeded with the portfolio rank; its result + replaces the portfolio's only when strictly better, so the outcome is + never worse than the greedy portfolio. """ strategies = [("mixed", group_stock)] for stock in group_stock: strategies.append((f"single:{stock['stock_id']}", [stock])) + cost_priority = all( + _ranking_cost(stock) is not None for stock in group_stock + ) best = None best_rank = None for name, stocks in strategies: bars, unplaced = _greedy_pack(units, stocks, kerf, group_stock) - total_length = sum(bar["stock"]["length_in"] for bar in bars) - purchased = [ - bar for bar in bars if bar["stock"]["stock_kind"] == "purchasable" - ] - purchased_length = sum(bar["stock"]["length_in"] for bar in purchased) - costs = [_ranking_cost(bar["stock"]) for bar in bars] - cost_rank = ( - round(sum(costs), 2) if None not in costs else math.inf - ) rank = ( - sum(1 for _ in unplaced), - round(purchased_length, 6), - cost_rank, - len(purchased), - round(total_length, 6), + *_rank_solution(bars, sum(1 for _ in unplaced), cost_priority), name, ) if best_rank is None or rank < best_rank: best_rank = rank best = (bars, unplaced) - return best if best is not None else ([], []) + if best is None: + return [], [] + exact = _exact_solve_group( + units, group_stock, kerf, best_rank[:5], cost_priority + ) + if exact is not None: + exact_bars, exact_unplaced, _ = exact + return exact_bars, exact_unplaced + return best def run_job(job): diff --git a/skills/steel-estimate/references/estimate-package-example.json b/skills/steel-estimate/references/estimate-package-example.json index 5540146..87231f8 100644 --- a/skills/steel-estimate/references/estimate-package-example.json +++ b/skills/steel-estimate/references/estimate-package-example.json @@ -34,6 +34,24 @@ } ] }, + { + "intent": "fabricated_part", + "source_id": "SYNTHETIC-SRC-B1", + "item_id": "item:synthetic-example-b1", + "quantity": 2, + "mark": "B1", + "description": "Synthetic wide-flange beam", + "grade": "A992", + "designation": "W12X26", + "length_ft": 12, + "unit_weight_plf": 26, + "source_evidence": [ + { + "source": "SYNTHETIC-SOURCE-001", + "locator": "SYNTHETIC-DETAIL-B1" + } + ] + }, { "intent": "purchased_stock", "source_id": "SYNTHETIC-SRC-S1", @@ -72,6 +90,16 @@ "thickness": 0.5, "quantity": 1, "status": "available" + }, + { + "stock_kind": "purchasable", + "stock_form": "linear", + "inventory_id": "SYNTHETIC-VENDOR-W12X26-40", + "designation": "W12X26", + "grade": "A992", + "length_ft": 40, + "quantity": 1, + "unlimited": true } ], "commercial_basis": { diff --git a/skills/steel-estimate/references/output-contract.md b/skills/steel-estimate/references/output-contract.md index d4436ce..0b0f1fb 100644 --- a/skills/steel-estimate/references/output-contract.md +++ b/skills/steel-estimate/references/output-contract.md @@ -12,6 +12,9 @@ before using any artifact. | `normalized-bom.json` | Typed BOM and calculated weight projection | Always | | `nest-result.json` | Placements, verification, utilization, and unplaced parts | Valid input contains plate parts | | `rfq-nesting.json` | Versioned nesting lineage for RFQ compilation | A nest was attempted | +| `cutlist-result.json` | Bar plans, verification, purchase summary, drops, and unplaced members | Valid input contains member items with designation, length, and grade | +| `rfq-linear.json` | Versioned linear-stock lineage for RFQ compilation | A cut-list was attempted | +| `cutting_list.csv` | Verified per-bar cut sequence (`geometry_verified`) | Run outcome is `ready` and the cut-list is fully placed and verified | | `inventory-consumption.json` | Confirmed on-hand sheets consumed and corresponding RFQ demand reduction | Eligible on-hand inventory was consumed | | `qa-report.json` | Findings, approximations, gate decisions, and recalculation status | Always | | `run-manifest.json` | Input/configuration hashes and artifact hashes/readiness | Always | @@ -32,8 +35,11 @@ verified exact geometry. - `dependency_missing`: required rendering support is absent and no workbook exists. -Unplaced parts use `blocked` with package status `nested_partial`; their nest -diagnostics remain available. A validation failure stops before nesting. +Unplaced plate parts use `blocked` with package status `nested_partial`; +members longer than every available stock length use `blocked` with package +status `cutlist_partial`. Their nest and cut-list diagnostics remain +available. A validation failure stops before nesting and cut-list +optimization. ## Determinism and lineage diff --git a/tests/test_cutlist_engine.py b/tests/test_cutlist_engine.py index 0c595ff..716273a 100644 --- a/tests/test_cutlist_engine.py +++ b/tests/test_cutlist_engine.py @@ -531,3 +531,84 @@ def test_anonymous_on_hand_and_purchasable_rows_get_distinct_ids(): for finding in result["validation_findings"] ) assert result["outcome"] == "ready" + + +def test_exact_search_beats_greedy_on_classic_bfd_failure(): + # Best-fit-decreasing packs [5,5], [4,4], [3,3,3], [3] onto four bars of + # capacity 10; the exact search finds the optimal [5,5], [4,3,3], [4,3,3]. + job = { + "job_name": "SYNTHETIC-EXACT", + "project_id": "SYNTHETIC-PRJ", + "revision_id": "SYNTHETIC-REV", + "unit_system": "imperial", + "settings": {"kerf_in": 0, "end_trim_in": 0, "min_drop_in": 1000}, + "members": [ + { + "source_id": f"SYNTHETIC-E{length}", + "name": f"SYNTHETIC-E{length}", + "designation": "FB1X1", + "grade": "A36", + "length_in": length, + "qty": qty, + "unit_weight_plf": 3.4, + } + for length, qty in ((5, 2), (4, 2), (3, 4)) + ], + "stock": [ + { + "stock_id": "SYNTHETIC-STK-10", + "designation": "FB1X1", + "grade": "A36", + "length_in": 10, + "unlimited": True, + } + ], + } + result = cutlist.run_job(job) + assert result["outcome"] == "ready" + assert result["bars_used"] == 3 + assert result["verification"]["status"] == "verified" + assert result["metrics"]["utilization_pct"]["value"] == 100.0 + + +def test_exact_search_never_replaces_with_a_worse_solution(): + # The portfolio already finds the optimal single-length answer here; the + # exact refinement must keep it (same purchase, same cost). + result = cutlist.run_job(base_job()) + assert result["purchase_summary"][0]["stock_id"] == "SYNTHETIC-STK-40" + assert result["total_material_cost"] == 7488.0 + + +def test_large_groups_fall_back_to_portfolio_deterministically(): + job = base_job() + job["members"] = [ + { + "source_id": f"SYNTHETIC-L{index}", + "name": f"SYNTHETIC-L{index}", + "designation": "W12X26", + "grade": "A992", + "length_in": 100 + index, + "qty": 2, + } + for index in range(10) + ] + first = cutlist.run_job(deepcopy(job)) + second = cutlist.run_job(deepcopy(job)) + assert first["outcome"] == "ready" + assert sha256_bytes(canonical_json_bytes(first)) == sha256_bytes( + canonical_json_bytes(second) + ) + + +def test_without_cost_basis_exact_search_minimizes_purchased_length(): + # With no prices, the objective is purchased length: the exact search + # finds the 230 ft mixed plan (3 x 50 ft pairing 342+150, one 40 ft for + # the last 342, one 40 ft for three 150s) that the greedy misses. + job = base_job() + for stock in job["stock"]: + del stock["cost_per_ft"] + result = cutlist.run_job(job) + assert result["outcome"] == "ready" + assert result["total_stock_length_ft"] == 230.0 + assert result["bars_used"] == 5 + assert result["verification"]["status"] == "verified" From 164b69cf517eec567d953a9bc5838860041a37de Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 23 Aug 2026 13:33:04 +0000 Subject: [PATCH 09/12] feat(cutlist): exact search explores optimal partial plans MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The branch-and-bound now branches over leaving each piece unplaced (stock_exhausted) in addition to every placement, so when finite stock cannot hold everything it finds the partial plan stranding the fewest members instead of falling back to the greedy's weaker cut. Pruning compares (unplaced, primary objective) lexicographically — both grow monotonically along a path — and the never-worse guarantee is unchanged. Adds docstrings to the search internals and a regression test where the greedy strands three pieces but the optimum strands two. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01MquyTzVAPKpV1V8Vt3XxnJ --- CHANGELOG.md | 6 ++- skills/steel-cutlist/SKILL.md | 2 +- skills/steel-cutlist/scripts/cutlist.py | 61 +++++++++++++++++-------- tests/test_cutlist_engine.py | 45 ++++++++++++++++++ 4 files changed, 92 insertions(+), 22 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b083bcc..7a26c36 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,8 +10,10 @@ project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). - Bounded exact cut-list optimization: designation + grade groups with up to 12 pieces run a deterministic branch-and-bound search (seeded and - bounded by the portfolio result, fixed node budget) that only ever - replaces the greedy plan with a strictly better one. + bounded by the portfolio result, fixed node budget) that explores + complete and partial placements alike and only ever replaces the greedy + plan with a strictly better one — including stranding fewer members when + finite stock cannot hold everything. - `output-contract.md` documents the cut-list artifacts and `cutlist_partial` status; the estimate-package example now includes member items and vendor linear stock. diff --git a/skills/steel-cutlist/SKILL.md b/skills/steel-cutlist/SKILL.md index 21a364d..2f19b0d 100644 --- a/skills/steel-cutlist/SKILL.md +++ b/skills/steel-cutlist/SKILL.md @@ -15,7 +15,7 @@ It complements `steel-nest` (2D plates). Plates go to `steel-nest`; anything bou **Reliable:** - Exact 1D packing per designation + grade group. Stock never crosses groups: a W12X26 member is only cut from W12X26 stock of the same grade. -- A deterministic strategy portfolio per group — a mixed-stock greedy plus each single-stock-length restriction — refined by a bounded exact branch-and-bound search for small groups (up to 12 pieces) that only ever replaces the portfolio result with a strictly better one. Ranking always minimizes unplaced members first; when every stock entry in the group carries a known cost basis, lowest purchase cost decides next (buying cheaper beats buying shorter), otherwise least purchased stock length decides (on-hand consumption is free). Fewest purchased bars and least total length settle ties. The same input always produces the same plan. +- A deterministic strategy portfolio per group — a mixed-stock greedy plus each single-stock-length restriction — refined by a bounded exact branch-and-bound search for small groups (up to 12 pieces) that explores complete and partial placements alike and only ever replaces the portfolio result with a strictly better one. Ranking always minimizes unplaced members first; when every stock entry in the group carries a known cost basis, lowest purchase cost decides next (buying cheaper beats buying shorter), otherwise least purchased stock length decides (on-hand consumption is free). Fewest purchased bars and least total length settle ties. The same input always produces the same plan. - Explicit fit contract: usable length = bar length − 2 × end trim; a piece fits when its length alone fits the remainder; each placed piece then consumes its length plus one kerf, saturating at the bar end. - Independent post-placement verification (bar overcommitment, material mismatch, duplicate or missing instances) before any cutting list is published. - Drops classified against a reusable-candidate threshold (`min_drop_in`) — candidates are never certified reusable stock. diff --git a/skills/steel-cutlist/scripts/cutlist.py b/skills/steel-cutlist/scripts/cutlist.py index 2cf5707..c68caf6 100644 --- a/skills/steel-cutlist/scripts/cutlist.py +++ b/skills/steel-cutlist/scripts/cutlist.py @@ -629,19 +629,22 @@ def _rank_solution(bars, unplaced_count, cost_priority): class _SearchBudgetExceeded(Exception): - pass + """Raised when the exact search exhausts its deterministic node budget.""" def _exact_solve_group(units, group_stock, kerf, best_rank, cost_priority): - """Branch-and-bound over complete bar assignments for a small group. - - Seeded with the portfolio's rank for pruning, bounded by a fixed node - budget so runtime stays deterministic. Returns (bars, unplaced, rank) - only when a complete assignment strictly beats ``best_rank``; otherwise - None and the caller keeps the portfolio result. + """Branch-and-bound over bar assignments for a small group. + + Every unit branches over placements into open bars, opening each stock + type, or remaining unplaced (``stock_exhausted``), so optimal partial + plans are found when finite stock cannot hold everything. Seeded with + the portfolio's rank for pruning and bounded by a fixed node budget so + runtime stays deterministic. Returns (bars, unplaced, rank) only when a + solution strictly beats ``best_rank``; otherwise None and the caller + keeps the portfolio result. """ placeable = [] - unplaced_units = [] + prefilter_unplaced = [] for unit in units: if any( unit["length_in"] <= stock["usable_in"] + EPS @@ -649,13 +652,15 @@ def _exact_solve_group(units, group_stock, kerf, best_rank, cost_priority): ): placeable.append(unit) else: - unplaced_units.append({**unit, "reason": "no_compatible_stock_fit"}) + prefilter_unplaced.append( + {**unit, "reason": "no_compatible_stock_fit"} + ) if not placeable or len(placeable) > EXACT_SEARCH_MAX_UNITS: return None stocks = sorted(group_stock, key=lambda stock: stock["stock_id"]) used = {stock["stock_id"]: 0 for stock in stocks} bars_state = [] # mutable [stock, remaining, cuts] triples - unplaced_count = len(unplaced_units) + skipped = [] # units left unplaced on the current search path state = {"nodes": 0, "best": None, "best_rank": best_rank} def primary_bound(): @@ -675,25 +680,36 @@ def primary_bound(): ) def descend(index): + """Assign placeable[index:] and record any strictly better leaf. + + Pruning compares (unplaced so far, primary bound) with the best + rank; both components only grow along a path, so the lexicographic + comparison is a valid lower bound. + """ state["nodes"] += 1 if state["nodes"] > EXACT_SEARCH_NODE_BUDGET: raise _SearchBudgetExceeded - if ( - state["best_rank"] is not None - and primary_bound() > state["best_rank"][1] - ): + if state["best_rank"] is not None and ( + len(prefilter_unplaced) + len(skipped), + primary_bound(), + ) > (state["best_rank"][0], state["best_rank"][1]): return if index == len(placeable): solution = [ {"stock": triple[0], "cuts": list(triple[2]), "remaining": 0.0} for triple in bars_state ] - rank = _rank_solution(solution, unplaced_count, cost_priority) + rank = _rank_solution( + solution, + len(prefilter_unplaced) + len(skipped), + cost_priority, + ) if state["best_rank"] is None or rank < state["best_rank"]: state["best_rank"] = rank - state["best"] = [ - (triple[0], list(triple[2])) for triple in bars_state - ] + state["best"] = ( + [(triple[0], list(triple[2])) for triple in bars_state], + list(skipped), + ) return unit = placeable[index] length = unit["length_in"] @@ -722,6 +738,9 @@ def descend(index): descend(index + 1) bars_state.pop() used[stock["stock_id"]] -= 1 + skipped.append(unit) + descend(index + 1) + skipped.pop() try: descend(0) @@ -729,12 +748,16 @@ def descend(index): return None if state["best"] is None: return None + best_bars, best_skipped = state["best"] bars = [] - for stock, cuts in state["best"]: + for stock, cuts in best_bars: bar = {"stock": stock, "cuts": [], "remaining": stock["usable_in"]} for unit in cuts: _place_on_bar(bar, unit, kerf) bars.append(bar) + unplaced_units = prefilter_unplaced + [ + {**unit, "reason": "stock_exhausted"} for unit in best_skipped + ] return bars, unplaced_units, state["best_rank"] diff --git a/tests/test_cutlist_engine.py b/tests/test_cutlist_engine.py index 716273a..59360fa 100644 --- a/tests/test_cutlist_engine.py +++ b/tests/test_cutlist_engine.py @@ -612,3 +612,48 @@ def test_without_cost_basis_exact_search_minimizes_purchased_length(): assert result["total_stock_length_ft"] == 230.0 assert result["bars_used"] == 5 assert result["verification"]["status"] == "verified" + + +def test_exact_search_finds_optimal_partial_plan_when_stock_is_finite(): + # One capacity-10 bar. Greedy places [5,5] and strands three pieces; + # the exact search's skip branches find [4,3,3], stranding only two. + job = { + "job_name": "SYNTHETIC-EXACT-PARTIAL", + "project_id": "SYNTHETIC-PRJ", + "revision_id": "SYNTHETIC-REV", + "unit_system": "imperial", + "settings": {"kerf_in": 0, "end_trim_in": 0, "min_drop_in": 1000}, + "members": [ + { + "source_id": f"SYNTHETIC-P{length}", + "name": f"SYNTHETIC-P{length}", + "designation": "FB1X1", + "grade": "A36", + "length_in": length, + "qty": qty, + "unit_weight_plf": 3.4, + } + for length, qty in ((5, 2), (4, 1), (3, 2)) + ], + "stock": [ + { + "stock_id": "SYNTHETIC-STK-ONE", + "designation": "FB1X1", + "grade": "A36", + "length_in": 10, + "qty": 1, + } + ], + } + result = cutlist.run_job(job) + assert result["outcome"] == "blocked" + assert result["package_status"] == "cutlist_partial" + assert sum(row["quantity"] for row in result["unplaced"]) == 2 + assert all(row["reason"] == "stock_exhausted" for row in result["unplaced"]) + cuts = sorted( + cut["length_in"] + for report in result["bar_reports"] + for cut in report["cuts"] + ) + assert cuts == [3, 3, 4] + assert result["verification"]["status"] == "verified" From 0012cb61850d722ef7280e58aab862244532fecb Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 23 Aug 2026 13:34:24 +0000 Subject: [PATCH 10/12] test(cutlist): cover review's partial-plan case Two capacity-10 bars with pieces 8,6,4,4,4: the exact search strands only the 8 where the greedy stranded two 4s. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01MquyTzVAPKpV1V8Vt3XxnJ --- tests/test_cutlist_engine.py | 46 ++++++++++++++++++++++++++++++++++++ 1 file changed, 46 insertions(+) diff --git a/tests/test_cutlist_engine.py b/tests/test_cutlist_engine.py index 59360fa..84d197b 100644 --- a/tests/test_cutlist_engine.py +++ b/tests/test_cutlist_engine.py @@ -657,3 +657,49 @@ def test_exact_search_finds_optimal_partial_plan_when_stock_is_finite(): ) assert cuts == [3, 3, 4] assert result["verification"]["status"] == "verified" + + +def test_exact_partial_plan_prefers_stranding_one_large_over_two_small(): + # Two capacity-10 bars, pieces 8,6,4,4,4. Greedy packs [8] and [6,4], + # stranding two 4s; the optimum packs [6,4] and [4,4], stranding only + # the 8. (CodeRabbit review case on PR #7.) + job = { + "job_name": "SYNTHETIC-EXACT-PARTIAL-2", + "project_id": "SYNTHETIC-PRJ", + "revision_id": "SYNTHETIC-REV", + "unit_system": "imperial", + "settings": {"kerf_in": 0, "end_trim_in": 0, "min_drop_in": 1000}, + "members": [ + { + "source_id": f"SYNTHETIC-Q{length}", + "name": f"SYNTHETIC-Q{length}", + "designation": "FB1X1", + "grade": "A36", + "length_in": length, + "qty": qty, + "unit_weight_plf": 3.4, + } + for length, qty in ((8, 1), (6, 1), (4, 3)) + ], + "stock": [ + { + "stock_id": "SYNTHETIC-STK-TWO", + "designation": "FB1X1", + "grade": "A36", + "length_in": 10, + "qty": 2, + } + ], + } + result = cutlist.run_job(job) + assert result["outcome"] == "blocked" + assert sum(row["quantity"] for row in result["unplaced"]) == 1 + assert result["unplaced"][0]["length_in"] == 8 + assert result["unplaced"][0]["reason"] == "stock_exhausted" + cuts = sorted( + cut["length_in"] + for report in result["bar_reports"] + for cut in report["cuts"] + ) + assert cuts == [4, 4, 4, 6] + assert result["verification"]["status"] == "verified" From 8f8e99872311a889df6c06e0d86d1225caa85ab3 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 23 Aug 2026 15:57:17 +0000 Subject: [PATCH 11/12] feat(nest): true polygon outlines for irregular parts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Irregular plate parts may now carry geometry.outline — a simple-polygon vertex list in the part's local frame whose bounding box matches the declared size. - Exact shoelace areas and weights replace hand-declared estimates, reported as the new outline_exact approximation status. - Holes are verified against the true profile: a hole inside the bounding box but in a notch now blocks instead of passing. - Layouts and reference DXFs draw the real outline (with the bounding box dotted for context); placement stays by bounding box and burn-DXF suppression for irregular parts is unchanged. - Outline validation (>=3 finite vertices, non-crossing edges, positive area, bbox spanning 0..width x 0..height, declared-area consistency) runs in both the canonical validator and the direct nesting engine; polygon helpers live in the shared geometry module. - Schemas: estimate-package geometry.outline, nest-result placement outline and outline_exact enum. Version 0.4.0. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01MquyTzVAPKpV1V8Vt3XxnJ --- CHANGELOG.md | 14 + README.md | 7 +- package.json | 2 +- pyproject.toml | 2 +- skills/_shared/pi_steel/geometry_verify.py | 191 +++++++++++- skills/_shared/pi_steel/parsing.py | 2 + skills/_shared/pi_steel/validation.py | 56 +++- .../schemas/estimate-package.schema.json | 10 + .../_shared/schemas/nest-result.schema.json | 17 +- .../scripts/build-estimate-package.py | 2 + skills/steel-nest/SKILL.md | 4 +- skills/steel-nest/scripts/nest.py | 125 ++++++-- tests/test_outline_geometry.py | 280 ++++++++++++++++++ 13 files changed, 674 insertions(+), 38 deletions(-) create mode 100644 tests/test_outline_geometry.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 7a26c36..2ec20b9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,20 @@ All notable changes to `@structupath/pi-steel` are documented here. The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and the project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [0.4.0] - 2026-08-23 + +### Added + +- True polygon outlines for irregular plate parts: an optional + `geometry.outline` vertex list gives exact shoelace areas and weights + (`outline_exact`, replacing hand-declared estimates), exact + hole-inside-profile verification (a hole in a notch now blocks instead + of passing the bounding-box check), and real profile rendering in + layouts and reference DXFs. Outlines are validated as simple polygons + whose bounding box matches the declared part size, at the canonical + contract and in the direct nesting engine alike. Placement remains by + bounding box and burn-DXF suppression for irregular parts is unchanged. + ## [0.3.1] - 2026-08-23 ### Added diff --git a/README.md b/README.md index 4d7ce09..2f13ff4 100644 --- a/README.md +++ b/README.md @@ -60,8 +60,9 @@ does not invent pricing. ### Nesting and DXF safety `steel-nest` uses MaxRects bin packing for rectangular parts and reports yield, -scrap, reusable drops, and unplaced material. Irregular parts are estimated by -bounding box and are always flagged. +scrap, reusable drops, and unplaced material. Irregular parts may carry a true +polygon outline for exact areas, weights, hole checks, and drawn profiles; they +are placed by bounding box and always flagged. Per-sheet `burn_plate_N.dxf` files are emitted only when the full nest is complete and every supported hole remains inside its part. Otherwise, pi-steel @@ -146,7 +147,7 @@ npm run provenance:check # shape-data integrity and recorded decision npm run release:check # complete release gate ``` -The current package version is `0.3.1`. `release:check` verifies the test, +The current package version is `0.4.0`. `release:check` verifies the test, privacy, package-content, shape-data integrity, ownership, license, and redistribution contracts before publication. diff --git a/package.json b/package.json index 9fcd7f4..05d81dc 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@structupath/pi-steel", - "version": "0.3.1", + "version": "0.4.0", "description": "Structural steel estimating for Pi \u2014 validated takeoffs, plate nesting, guarded DXF output, and review-ready RFQ packages.", "type": "module", "keywords": [ diff --git a/pyproject.toml b/pyproject.toml index 0fa5fe2..0710fd2 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "pi-steel-runtime" -version = "0.3.1" +version = "0.4.0" description = "Python runtime dependencies and test configuration for pi-steel" requires-python = ">=3.11,<3.14" dependencies = [ diff --git a/skills/_shared/pi_steel/geometry_verify.py b/skills/_shared/pi_steel/geometry_verify.py index 94e5d5d..ab46264 100644 --- a/skills/_shared/pi_steel/geometry_verify.py +++ b/skills/_shared/pi_steel/geometry_verify.py @@ -44,9 +44,196 @@ def hole_within_bounds(hole: dict[str, Any], width: float, height: float) -> boo return False +def _finite_point(point: Any) -> bool: + return ( + isinstance(point, (list, tuple)) + and len(point) == 2 + and all( + isinstance(value, (int, float)) + and not isinstance(value, bool) + and math.isfinite(value) + for value in point + ) + ) + + +def polygon_area(outline: list[Any]) -> float: + """Absolute shoelace area of a closed polygon given as vertex pairs.""" + total = 0.0 + count = len(outline) + for index in range(count): + x1, y1 = outline[index] + x2, y2 = outline[(index + 1) % count] + total += x1 * y2 - x2 * y1 + return abs(total) / 2.0 + + +def _segments_properly_intersect(p1, p2, p3, p4) -> bool: + """Whether open segments p1-p2 and p3-p4 cross (shared endpoints excluded).""" + + def orient(a, b, c): + value = (b[0] - a[0]) * (c[1] - a[1]) - (b[1] - a[1]) * (c[0] - a[0]) + if value > 1e-12: + return 1 + if value < -1e-12: + return -1 + return 0 + + o1, o2 = orient(p1, p2, p3), orient(p1, p2, p4) + o3, o4 = orient(p3, p4, p1), orient(p3, p4, p2) + return o1 != o2 and o3 != o4 and 0 not in (o1, o2, o3, o4) + + +def polygon_is_simple(outline: list[Any]) -> bool: + """Whether non-adjacent edges never cross (a non-self-intersecting ring).""" + count = len(outline) + edges = [ + (outline[index], outline[(index + 1) % count]) for index in range(count) + ] + for first in range(count): + for second in range(first + 1, count): + if second == first + 1 or (first == 0 and second == count - 1): + continue + if _segments_properly_intersect(*edges[first], *edges[second]): + return False + return True + + +def point_in_polygon(point: Any, outline: list[Any]) -> bool: + """Ray-casting containment; boundary points count as inside.""" + x, y = point + inside = False + count = len(outline) + for index in range(count): + x1, y1 = outline[index] + x2, y2 = outline[(index + 1) % count] + if _point_on_segment((x, y), (x1, y1), (x2, y2)): + return True + if (y1 > y) != (y2 > y): + crossing = (x2 - x1) * (y - y1) / (y2 - y1) + x1 + if x < crossing: + inside = not inside + return inside + + +def _point_on_segment(point, start, end) -> bool: + px, py = point + x1, y1 = start + x2, y2 = end + cross = (x2 - x1) * (py - y1) - (y2 - y1) * (px - x1) + if abs(cross) > 1e-9: + return False + return ( + min(x1, x2) - 1e-9 <= px <= max(x1, x2) + 1e-9 + and min(y1, y2) - 1e-9 <= py <= max(y1, y2) + 1e-9 + ) + + +def _point_segment_distance(point, start, end) -> float: + px, py = point + x1, y1 = start + x2, y2 = end + dx, dy = x2 - x1, y2 - y1 + length_squared = dx * dx + dy * dy + if length_squared == 0: + return math.hypot(px - x1, py - y1) + t = max(0.0, min(1.0, ((px - x1) * dx + (py - y1) * dy) / length_squared)) + return math.hypot(px - (x1 + t * dx), py - (y1 + t * dy)) + + +def validate_outline( + outline: Any, width: Any, height: Any +) -> list[str]: + """Return human-readable problems with an irregular part outline. + + A valid outline is a simple polygon of at least three finite vertex + pairs whose bounding box matches the declared width and height (the + outline defines the part in its own local frame). + """ + problems: list[str] = [] + if not isinstance(outline, list) or len(outline) < 3: + return ["Outline requires at least three [x, y] vertex pairs."] + if not all(_finite_point(point) for point in outline): + return ["Outline vertices must be finite [x, y] pairs."] + if not polygon_is_simple(outline): + problems.append("Outline edges must not cross (simple polygon).") + if polygon_area(outline) <= 1e-9: + problems.append("Outline must enclose a positive area.") + if finite_positive(width) and finite_positive(height): + xs = [point[0] for point in outline] + ys = [point[1] for point in outline] + epsilon = 1e-6 + if ( + min(xs) < -epsilon + or min(ys) < -epsilon + or max(xs) > width + epsilon + or max(ys) > height + epsilon + or abs(min(xs)) > epsilon + or abs(min(ys)) > epsilon + or abs(max(xs) - width) > epsilon + or abs(max(ys) - height) > epsilon + ): + problems.append( + "Outline bounding box must span exactly 0..width and 0..height." + ) + return problems + + +def hole_within_outline(hole: dict[str, Any], outline: list[Any]) -> bool: + """Exact containment of a supported hole inside the part outline.""" + x, y = hole.get("x"), hole.get("y") + if not all( + isinstance(value, (int, float)) and math.isfinite(value) + for value in (x, y) + ): + return False + count = len(outline) + edges = [ + (outline[index], outline[(index + 1) % count]) for index in range(count) + ] + if hole.get("kind") == "round": + diameter = hole.get("diameter") + if not finite_positive(diameter): + return False + radius = diameter / 2 + if not point_in_polygon((x, y), outline): + return False + return all( + _point_segment_distance((x, y), start, end) >= radius - 1e-9 + for start, end in edges + ) + if hole.get("kind") == "rect": + hole_width, hole_height = hole.get("width"), hole.get("height") + if not finite_positive(hole_width) or not finite_positive(hole_height): + return False + corners = [ + (x - hole_width / 2, y - hole_height / 2), + (x + hole_width / 2, y - hole_height / 2), + (x + hole_width / 2, y + hole_height / 2), + (x - hole_width / 2, y + hole_height / 2), + ] + if not all(point_in_polygon(corner, outline) for corner in corners): + return False + rect_edges = [ + (corners[index], corners[(index + 1) % 4]) for index in range(4) + ] + return not any( + _segments_properly_intersect(*rect_edge, *edge) + for rect_edge in rect_edges + for edge in edges + ) + return False + + def gross_area(geometry: dict[str, Any]) -> float: - if geometry.get("shape") == "irregular" and geometry.get("area") is not None: - return geometry["area"] + if geometry.get("shape") == "irregular": + outline = geometry.get("outline") + if isinstance(outline, list) and len(outline) >= 3 and all( + _finite_point(point) for point in outline + ): + return polygon_area(outline) + if geometry.get("area") is not None: + return geometry["area"] return geometry.get("width", 0) * geometry.get("height", 0) diff --git a/skills/_shared/pi_steel/parsing.py b/skills/_shared/pi_steel/parsing.py index 8838a18..d512aba 100644 --- a/skills/_shared/pi_steel/parsing.py +++ b/skills/_shared/pi_steel/parsing.py @@ -167,6 +167,8 @@ def adapt_legacy_nest( } if "area" in part: geometry["area"] = part["area"] + if "outline" in part: + geometry["outline"] = part["outline"] item = { "intent": "fabricated_part", "source_id": source_id, diff --git a/skills/_shared/pi_steel/validation.py b/skills/_shared/pi_steel/validation.py index 51a6d8b..aa3462b 100644 --- a/skills/_shared/pi_steel/validation.py +++ b/skills/_shared/pi_steel/validation.py @@ -21,7 +21,10 @@ SUPPORTED_SHAPES, finite_positive, hole_within_bounds, + hole_within_outline, net_area, + polygon_area, + validate_outline, ) @@ -249,6 +252,42 @@ def _geometry_findings( f"{base}.{field}", f"{field} must be greater than zero.", ) + outline = geometry.get("outline") + valid_outline = False + if outline is not None: + if shape != "irregular": + _add( + findings, + input_hash, + "outline_on_rect", + "blocker", + f"{base}.outline", + "Outlines describe irregular parts; rectangular parts are exact already.", + ) + else: + problems = validate_outline( + outline, geometry.get("width"), geometry.get("height") + ) + for problem in problems: + _add( + findings, + input_hash, + "invalid_outline", + "blocker", + f"{base}.outline", + problem, + ) + valid_outline = not problems + if valid_outline and finite_positive(geometry.get("area")): + if abs(polygon_area(outline) - geometry["area"]) > 1e-6: + _add( + findings, + input_hash, + "outline_area_mismatch", + "blocker", + f"{base}.area", + "Declared area disagrees with the outline's exact area.", + ) width, height = geometry.get("width"), geometry.get("height") if finite_positive(width) and finite_positive(height): for hole_index, hole in enumerate(geometry.get("holes", [])): @@ -261,6 +300,15 @@ def _geometry_findings( f"{base}.holes[{hole_index}]", "Hole geometry must be positive and contained by the part.", ) + elif valid_outline and not hole_within_outline(hole, outline): + _add( + findings, + input_hash, + "hole_outside_outline", + "blocker", + f"{base}.holes[{hole_index}]", + "Hole must remain inside the part outline, not just its bounding box.", + ) try: area = net_area(geometry) except (TypeError, ValueError, OverflowError): @@ -274,14 +322,18 @@ def _geometry_findings( base, "Part net area after holes must be greater than zero.", ) - if shape == "irregular" and not finite_positive(geometry.get("area")): + if ( + shape == "irregular" + and outline is None + and not finite_positive(geometry.get("area")) + ): _add( findings, input_hash, "invalid_irregular_area", "blocker", f"{base}.area", - "Irregular parts require a positive true-cut area.", + "Irregular parts require a positive true-cut area or an outline.", ) diff --git a/skills/_shared/schemas/estimate-package.schema.json b/skills/_shared/schemas/estimate-package.schema.json index 63e387d..9c604b2 100644 --- a/skills/_shared/schemas/estimate-package.schema.json +++ b/skills/_shared/schemas/estimate-package.schema.json @@ -110,6 +110,16 @@ "height": { "type": "number", "exclusiveMinimum": 0 }, "thickness": { "type": "number", "exclusiveMinimum": 0 }, "area": { "type": "number", "exclusiveMinimum": 0 }, + "outline": { + "type": "array", + "items": { + "type": "array", + "items": { "type": "number" }, + "minItems": 2, + "maxItems": 2 + }, + "minItems": 3 + }, "holes": { "type": "array", "items": { "$ref": "#/$defs/hole" }, diff --git a/skills/_shared/schemas/nest-result.schema.json b/skills/_shared/schemas/nest-result.schema.json index 323b1af..47cdf6c 100644 --- a/skills/_shared/schemas/nest-result.schema.json +++ b/skills/_shared/schemas/nest-result.schema.json @@ -226,7 +226,13 @@ "properties": { "value": { "type": "number", "minimum": 0, "maximum": 100 }, "approximation": { - "enum": ["exact", "bounding_box", "declared_area", "bounding_box_estimate"] + "enum": [ + "exact", + "bounding_box", + "declared_area", + "bounding_box_estimate", + "outline_exact" + ] } }, "additionalProperties": false @@ -273,6 +279,15 @@ "ow": { "type": "number", "exclusiveMinimum": 0 }, "oh": { "type": "number", "exclusiveMinimum": 0 }, "holes": { "type": "array", "items": { "type": "object" } }, + "outline": { + "type": "array", + "items": { + "type": "array", + "items": { "type": "number" }, + "minItems": 2, + "maxItems": 2 + } + }, "base_area": { "type": "number", "exclusiveMinimum": 0 }, "holes_area": { "type": "number", "minimum": 0 }, "material": { "type": "string" }, diff --git a/skills/steel-estimate/scripts/build-estimate-package.py b/skills/steel-estimate/scripts/build-estimate-package.py index 13464fc..8d0980f 100755 --- a/skills/steel-estimate/scripts/build-estimate-package.py +++ b/skills/steel-estimate/scripts/build-estimate-package.py @@ -183,6 +183,8 @@ def nest_job_from_package( } if geometry.get("area") is not None: part["area"] = geometry["area"] + if geometry.get("outline") is not None: + part["outline"] = geometry["outline"] parts.append(part) return { "job_name": package["project"].get("name") diff --git a/skills/steel-nest/SKILL.md b/skills/steel-nest/SKILL.md index 5f1c692..fd58d65 100644 --- a/skills/steel-nest/SKILL.md +++ b/skills/steel-nest/SKILL.md @@ -28,7 +28,7 @@ Be honest with the user about the boundary — it protects the shop from over-tr - **Guarded cut-geometry files**: one DXF per sheet (`burn_plate_N.dxf`) containing only closed part outlines on `PROFILE` and holes/cutouts on `HOLES`, with origin at the sheet corner. They exist only when every part is rectangular, every required part fits, and every supported hole stays inside its part. **Approximate — always flag it:** -- **Irregular parts** (gussets, brackets, curved profiles, parts with holes) are nested by their **bounding box**, not true shape. Real yield is a little better than reported. For exact weight/cost on those, get the true cut area (in²) into the part's `area` field. This is NOT true-shape nesting like a dedicated CAM engine. +- **Irregular parts** (gussets, brackets, curved profiles, parts with holes) are nested by their **bounding box**, not true shape. Real yield is a little better than reported. For exact weight/cost, give the part an `outline` — a list of `[x, y]` vertices tracing the true profile in the part's local frame (bounding box spanning `0..width` × `0..height`, simple polygon, no crossing edges). The engine then computes the exact shoelace area (`outline_exact`), checks that holes stay inside the true profile (not just the box), and draws the real outline in layouts and reference DXFs. Without an outline, supply the true cut area (in²) in `area`, or the bounding box is used as an estimate. Placement is still by bounding box — this is NOT true-shape nesting like a dedicated CAM engine. - Any irregular part suppresses all fabrication-style DXFs for that job. The remaining PDF, PNG, report, JSON, and `reference_nest.dxf` outputs are estimating aids, not cutting instructions. **Do NOT pretend to do:** @@ -40,7 +40,7 @@ Everything drives a single job JSON (schema in `references/job_template.json`; a Gather three things: -1. **Parts** — for each unique part: name, width × height (inches; use the bounding box for odd shapes), quantity, whether it's `rect` or `irregular`, and whether rotation is allowed (`rotatable: false` locks grain/rolling direction for anisotropic material or directional finish). If a rectangular part has **holes or cutouts**, add a `holes` list — each hole's `x,y` is its center from the part's lower-left corner: round = `{"dia":, "x":, "y":}`, rectangular cutout = `{"w":, "h":, "x":, "y":}`. A supported hole must remain fully inside its part or fabrication-style DXFs are suppressed. Holes are optional; skip them if you only need the layout/estimate. +1. **Parts** — for each unique part: name, width × height (inches; use the bounding box for odd shapes), quantity, whether it's `rect` or `irregular`, and whether rotation is allowed (`rotatable: false` locks grain/rolling direction for anisotropic material or directional finish). For an irregular part, add an `outline` vertex list for exact area, weight, and hole checks. If a part has **holes or cutouts**, add a `holes` list — each hole's `x,y` is its center from the part's lower-left corner: round = `{"dia":, "x":, "y":}`, rectangular cutout = `{"w":, "h":, "x":, "y":}`. A supported hole must remain fully inside its part or fabrication-style DXFs are suppressed. Holes are optional; skip them if you only need the layout/estimate. 2. **Stock** — plate size(s), explicit material, grade, and thickness, plus finite quantity or `unlimited`. A price is optional; if provided, use exactly one approved basis (`cost_per_lb` or `cost_per_sheet`) and retain its source outside this legacy JSON boundary. 3. **Cut settings** — kerf, part gap, edge margin, material density. Sensible defaults are in the template; only ask if the user hasn't implied them. Common kerf: plasma ~0.06", oxy-fuel ~0.10", laser ~0.02", waterjet ~0.03". diff --git a/skills/steel-nest/scripts/nest.py b/skills/steel-nest/scripts/nest.py index dbf9190..ce6e008 100644 --- a/skills/steel-nest/scripts/nest.py +++ b/skills/steel-nest/scripts/nest.py @@ -70,6 +70,9 @@ SUPPORTED_SHAPES, finite_positive, hole_within_bounds, + hole_within_outline, + polygon_area, + validate_outline, verify_nest_placements, ) @@ -109,6 +112,7 @@ class Placement: ow: float # original (unrotated) part width oh: float # original (unrotated) part height holes: list = field(default_factory=list) # in original part coords + outline: list = field(default_factory=list) # true profile, original coords base_area: float = 0.0 # gross area (bbox for rect, declared area for irregular) holes_area: float = 0.0 # total area removed by holes/cutouts material: str = "" @@ -231,6 +235,13 @@ def hole_local(pc, hole): return hx, hy +def outline_local(pc, outline): + """Outline vertices in the placed part's frame, rotated with the part.""" + if pc["rotated"]: + return [(pc["oh"] - y, x) for x, y in outline] + return [(x, y) for x, y in outline] + + # -------------------------------------------------------------------------- # Job runner # -------------------------------------------------------------------------- @@ -388,13 +399,33 @@ def number(value, path, *, positive=False, nonnegative=False): "Material, grade, and thickness must be explicit before placement.", ) ) + outline = part.get("outline") + valid_outline = False + if outline is not None: + if shape != "irregular": + findings.append( + _validation_finding( + "outline_on_rect", + f"{path}.outline", + "Outlines describe irregular parts; rectangular parts are exact already.", + ) + ) + outline = None + else: + outline_problems = validate_outline(outline, width, height) + for problem in outline_problems: + findings.append( + _validation_finding( + "invalid_outline", f"{path}.outline", problem + ) + ) + valid_outline = not outline_problems holes = part.get("holes", []) or [] holes_area = 0.0 if finite_positive(width) and finite_positive(height): for hole_index, hole in enumerate(holes): - if not hole_within_bounds( - _legacy_hole_to_canonical(hole), width, height - ): + canonical_hole = _legacy_hole_to_canonical(hole) + if not hole_within_bounds(canonical_hole, width, height): findings.append( _validation_finding( "invalid_hole_geometry", @@ -402,6 +433,16 @@ def number(value, path, *, positive=False, nonnegative=False): "Hole is unsupported or extends outside the part.", ) ) + elif valid_outline and not hole_within_outline( + canonical_hole, outline + ): + findings.append( + _validation_finding( + "invalid_hole_geometry", + f"{path}.holes[{hole_index}]", + "Hole must remain inside the part outline, not just its bounding box.", + ) + ) try: holes_area += hole_area(hole) except (TypeError, ValueError): @@ -409,7 +450,26 @@ def number(value, path, *, positive=False, nonnegative=False): base_area = width * height if finite_positive(width) and finite_positive(height) else 0 approximation = "exact" if shape == "irregular": - if part.get("area") is None: + if valid_outline: + exact_area = polygon_area(outline) + if part.get("area") is not None: + declared_area = number( + part.get("area"), f"{path}.area", positive=True + ) + if ( + math.isfinite(declared_area) + and abs(declared_area - exact_area) > 1e-6 + ): + findings.append( + _validation_finding( + "outline_area_mismatch", + f"{path}.area", + "Declared area disagrees with the outline's exact area.", + ) + ) + base_area = exact_area + approximation = "outline_exact" + elif part.get("area") is None: approximation = "bounding_box_estimate" findings.append( _validation_finding( @@ -469,6 +529,7 @@ def number(value, path, *, positive=False, nonnegative=False): "rotatable": bool(part.get("rotatable", True)), "shape": shape, "holes": holes, + "outline": outline if valid_outline else [], "base_area": base_area, "holes_area": holes_area, "net_area_approximation": approximation, @@ -735,6 +796,7 @@ def commit(plate, unit, placement): ow=unit["w"], oh=unit["h"], holes=unit["holes"], + outline=unit["outline"], base_area=unit["base_area"], holes_area=unit["holes_area"], material=unit["material"], @@ -795,6 +857,14 @@ def _metric(value, approximation): return {"value": round(value, 1), "approximation": approximation} +def _net_status(statuses): + """Least-exact net-area status wins: estimates dominate exact outlines.""" + for status in ("bounding_box_estimate", "declared_area", "outline_exact"): + if status in statuses: + return status + return "exact" + + def _remnant_candidates(plate, margin, spacing): stock = plate["stock"] usable_width = stock["W"] - 2 * margin @@ -885,15 +955,7 @@ def _summarize( net_approximation_by_item[placement.item_id] for placement in plate["placements"] } - net_approximation = ( - "exact" - if plate_net_statuses == {"exact"} - else ( - "bounding_box_estimate" - if "bounding_box_estimate" in plate_net_statuses - else "declared_area" - ) - ) + net_approximation = _net_status(plate_net_statuses) report = { "index": plate["index"], "stock": stock["name"], @@ -935,11 +997,7 @@ def _summarize( else: cost_status, cost_total = "not_provided", None packing_status = "bounding_box" if has_irregular else "exact" - net_status = ( - "bounding_box_estimate" - if "bounding_box_estimate" in net_approximations - else ("declared_area" if "declared_area" in net_approximations else "exact") - ) + net_status = _net_status(net_approximations) metrics = { "packing_utilization_pct": _metric( 100 * total_packing_area / total_plate_area if total_plate_area else 0, @@ -1273,9 +1331,17 @@ def render_layout(res, outdir): w, h = pc["w"], pc["h"] irregular = pc["shape"] == "irregular" face = "#f4c9a0" if irregular else "#a9c8e8" - ax.add_patch(mpatches.Rectangle((x, y), w, h, facecolor=face, - edgecolor="#1a3b5c", lw=1.2, - hatch="///" if irregular else None, alpha=0.9)) + if pc.get("outline"): + ax.add_patch(mpatches.Rectangle( + (x, y), w, h, fill=False, ec="#c9a227", lw=0.6, ls=":")) + ax.add_patch(mpatches.Polygon( + [(x + vx, y + vy) for vx, vy in outline_local(pc, pc["outline"])], + closed=True, facecolor=face, edgecolor="#1a3b5c", + lw=1.2, alpha=0.9)) + else: + ax.add_patch(mpatches.Rectangle((x, y), w, h, facecolor=face, + edgecolor="#1a3b5c", lw=1.2, + hatch="///" if irregular else None, alpha=0.9)) for hole in pc.get("holes", []): lx, ly = hole_local(pc, hole) cx, cy = x + lx, y + ly @@ -1322,11 +1388,18 @@ def _draw_part_dxf(msp, pc, x0, y0, profile_layer, holes_layer, notes_layer, lab import ezdxf x, y = x0 + pc["x"], y0 + pc["y"] w, h = pc["w"], pc["h"] - msp.add_lwpolyline( - [(x, y), (x + w, y), (x + w, y + h), (x, y + h)], - close=True, - dxfattribs={"layer": profile_layer}, - ) + if pc.get("outline"): + msp.add_lwpolyline( + [(x + vx, y + vy) for vx, vy in outline_local(pc, pc["outline"])], + close=True, + dxfattribs={"layer": profile_layer}, + ) + else: + msp.add_lwpolyline( + [(x, y), (x + w, y), (x + w, y + h), (x, y + h)], + close=True, + dxfattribs={"layer": profile_layer}, + ) for hole in pc.get("holes", []): lx, ly = hole_local(pc, hole) cx, cy = x + lx, y + ly diff --git a/tests/test_outline_geometry.py b/tests/test_outline_geometry.py new file mode 100644 index 0000000..1524a73 --- /dev/null +++ b/tests/test_outline_geometry.py @@ -0,0 +1,280 @@ +import importlib.util +import json +import sys +from copy import deepcopy +from pathlib import Path + + +ROOT = Path(__file__).resolve().parents[1] +SHARED = ROOT / "skills" / "_shared" +sys.path.insert(0, str(SHARED)) +NEST_SCRIPT = ROOT / "skills" / "steel-nest" / "scripts" / "nest.py" +SPEC = importlib.util.spec_from_file_location("pi_steel_nest_outline", NEST_SCRIPT) +nest = importlib.util.module_from_spec(SPEC) +sys.modules[SPEC.name] = nest +SPEC.loader.exec_module(nest) + +from pi_steel.geometry_verify import ( # noqa: E402 + hole_within_outline, + point_in_polygon, + polygon_area, + polygon_is_simple, + validate_outline, +) +from pi_steel.validation import validate_estimate_package # noqa: E402 + + +# L-shape: 8 x 6 bounding box with the upper-right 5 x 3 notch removed. +L_SHAPE = [[0, 0], [8, 0], [8, 3], [3, 3], [3, 6], [0, 6]] +BOWTIE = [[0, 0], [4, 4], [4, 0], [0, 4]] + + +def test_polygon_helpers_on_l_shape_and_bowtie(): + assert polygon_area(L_SHAPE) == 33.0 + assert polygon_is_simple(L_SHAPE) + assert not polygon_is_simple(BOWTIE) + assert point_in_polygon((1.5, 1.5), L_SHAPE) + assert not point_in_polygon((6, 5), L_SHAPE) + assert validate_outline(L_SHAPE, 8, 6) == [] + assert validate_outline(L_SHAPE, 10, 6) != [] + assert validate_outline([[0, 0], [1, 1]], 8, 6) != [] + + +def test_hole_containment_uses_true_outline(): + inside = {"kind": "round", "diameter": 1, "x": 1.5, "y": 1.5} + in_notch = {"kind": "round", "diameter": 1, "x": 6, "y": 5} + touching_edge = {"kind": "round", "diameter": 2, "x": 3.5, "y": 2.5} + assert hole_within_outline(inside, L_SHAPE) + assert not hole_within_outline(in_notch, L_SHAPE) + assert not hole_within_outline(touching_edge, L_SHAPE) + rect_ok = {"kind": "rect", "width": 1, "height": 1, "x": 1.5, "y": 1.5} + rect_crossing = {"kind": "rect", "width": 4, "height": 1, "x": 4, "y": 2.8} + assert hole_within_outline(rect_ok, L_SHAPE) + assert not hole_within_outline(rect_crossing, L_SHAPE) + + +def outline_job(): + return { + "job_name": "SYNTHETIC-OUTLINE", + "material": "carbon_steel", + "grade": "A36", + "unit_system": "imperial", + "settings": { + "kerf_in": 0.05, + "part_gap_in": 0.2, + "edge_margin_in": 0.5, + "thickness_in": 0.5, + "density_lb_in3": 0.2836, + }, + "stock": [ + { + "stock_id": "SYNTHETIC-STOCK-OUTLINE", + "name": "Synthetic Plate", + "width": 20, + "height": 10, + "thickness": 0.5, + "qty": 1, + } + ], + "parts": [ + { + "source_id": "SYNTHETIC-SRC-L", + "name": "SYNTHETIC-L", + "width": 8, + "height": 6, + "qty": 1, + "shape": "irregular", + "outline": deepcopy(L_SHAPE), + } + ], + } + + +def test_outline_part_gets_exact_area_and_review_required_outcome(): + result = nest.run_job(outline_job()) + assert result["outcome"] == "review_required" + part = result["plate_reports"][0]["placements"][0] + assert part["base_area"] == 33.0 + assert part["outline"] == L_SHAPE + assert result["metrics"]["net_material_yield_pct"]["approximation"] == ( + "outline_exact" + ) + # Exact net weight: 33 in^2 x 0.5 in x 0.2836 lb/in^3. + assert result["total_part_weight_lb"] == round(33 * 0.5 * 0.2836, 1) + assert result["burn_dxf_eligible"] is False + + +def test_hole_in_bbox_but_outside_outline_blocks(): + job = outline_job() + job["parts"][0]["holes"] = [{"dia": 1, "x": 6, "y": 5}] + result = nest.run_job(job) + assert result["outcome"] == "blocked" + assert any( + finding["code"] == "invalid_hole_geometry" + for finding in result["validation_findings"] + ) + + +def test_self_intersecting_or_mismatched_outline_blocks(): + job = outline_job() + job["parts"][0]["outline"] = deepcopy(BOWTIE) + job["parts"][0]["width"], job["parts"][0]["height"] = 4, 4 + bowtie = nest.run_job(job) + assert bowtie["outcome"] == "blocked" + assert any( + finding["code"] == "invalid_outline" + for finding in bowtie["validation_findings"] + ) + + job = outline_job() + job["parts"][0]["area"] = 30 + mismatch = nest.run_job(job) + assert mismatch["outcome"] == "blocked" + assert any( + finding["code"] == "outline_area_mismatch" + for finding in mismatch["validation_findings"] + ) + + job = outline_job() + job["parts"][0]["shape"] = "rect" + on_rect = nest.run_job(job) + assert on_rect["outcome"] == "blocked" + assert any( + finding["code"] == "outline_on_rect" + for finding in on_rect["validation_findings"] + ) + + +def test_reference_renders_draw_true_outline(tmp_path): + result = nest.run_job(outline_job()) + dxf_paths = nest.render_reference_plate_dxfs(result, tmp_path) + assert dxf_paths + import ezdxf + + document = ezdxf.readfile(dxf_paths[0]) + polylines = [ + entity + for entity in document.modelspace() + if entity.dxftype() == "LWPOLYLINE" + and entity.dxf.layer == "BOUNDS" + ] + assert polylines + assert len(polylines[0]) == len(L_SHAPE) + + pdf_path, png_paths = nest.render_layout(result, tmp_path) + assert Path(pdf_path).exists() + assert all(Path(path).exists() for path in png_paths) + + +def canonical_outline_package(): + return { + "schema_version": "1.0.0", + "project": { + "project_id": "SYNTHETIC-OUTLINE-001", + "revision": {"revision_id": "SYNTHETIC-REV-A"}, + }, + "unit_system": "imperial", + "items": [ + { + "intent": "fabricated_part", + "source_id": "SYNTHETIC-SRC-L", + "item_id": "item:synthetic-outline-l", + "quantity": 1, + "mark": "L1", + "material": "carbon_steel", + "grade": "A36", + "geometry": { + "shape": "irregular", + "width": 8, + "height": 6, + "thickness": 0.5, + "outline": deepcopy(L_SHAPE), + "holes": [], + "rotatable": True, + }, + "source_evidence": [ + {"source": "SYNTHETIC-SOURCE", "locator": "L1"} + ], + } + ], + "stock": [], + "commercial_basis": {"currency": "USD", "costs": []}, + "review": {"status": "draft", "findings": [], "acknowledgements": []}, + "lineage": { + "source_type": "synthetic_test", + "source_hash": "a" * 64, + "configuration_hash": "b" * 64, + }, + } + + +def test_canonical_validator_accepts_outline_without_declared_area(): + result = validate_estimate_package(canonical_outline_package()) + codes = {finding["code"] for finding in result.findings} + assert "invalid_irregular_area" not in codes + assert "invalid_outline" not in codes + assert not result.blockers + + +def test_canonical_validator_rejects_notch_hole_and_bad_outline(): + package = canonical_outline_package() + package["items"][0]["geometry"]["holes"] = [ + {"kind": "round", "diameter": 1, "x": 6, "y": 5} + ] + notch = validate_estimate_package(package) + assert any( + finding["code"] == "hole_outside_outline" for finding in notch.blockers + ) + + package = canonical_outline_package() + package["items"][0]["geometry"]["outline"] = deepcopy(BOWTIE) + package["items"][0]["geometry"]["width"] = 4 + package["items"][0]["geometry"]["height"] = 4 + bowtie = validate_estimate_package(package) + assert any( + finding["code"] == "invalid_outline" for finding in bowtie.blockers + ) + + package = canonical_outline_package() + package["items"][0]["geometry"]["area"] = 30 + mismatch = validate_estimate_package(package) + assert any( + finding["code"] == "outline_area_mismatch" + for finding in mismatch.blockers + ) + + +def test_pipeline_carries_outline_through_nest(tmp_path): + sys.path.insert(0, str(ROOT / "tests")) + from test_estimate_pipeline import load_package, run_pipeline # noqa: E402 + + package = load_package() + for item in package["items"]: + geometry = item.get("geometry") + if geometry and item.get("mark") == "P2": + geometry["shape"] = "irregular" + width, height = geometry["width"], geometry["height"] + geometry["outline"] = [ + [0, 0], + [width, 0], + [width, height / 2], + [width / 2, height / 2], + [width / 2, height], + [0, height], + ] + completed, run_path = run_pipeline( + tmp_path, package, "SYNTHETIC-PIPELINE-OUTLINE" + ) + assert completed.returncode == 2, completed.stdout + completed.stderr + nest_result = json.loads((run_path / "nest-result.json").read_text()) + assert nest_result["metrics"]["net_material_yield_pct"]["approximation"] in { + "outline_exact", + "declared_area", + } + outlines = [ + placement["outline"] + for plate in nest_result["plate_reports"] + for placement in plate["placements"] + if placement["outline"] + ] + assert outlines From 82e767d55089cf25a93cc22b5d9ae3ce5f48b33b Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 23 Aug 2026 16:03:32 +0000 Subject: [PATCH 12/12] fix(outline): reject self-touching rings, distinct fallback identities Address review findings on the outline feature: - polygon_is_simple now rejects repeated vertices and ANY contact between non-adjacent edges (endpoint touches and collinear overlaps included, not just proper crossings), so reused-boundary and pinched rings can no longer claim exact areas or reach reference DXFs. - Fallback source identities include the outline when one is present, so two legacy parts sharing a name and bounding box but different profiles no longer collide as duplicates; identities for parts without outlines stay byte-stable. - The nest-result placement outline schema now permits only an empty list or three-plus vertex pairs, matching what the engine emits. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01MquyTzVAPKpV1V8Vt3XxnJ --- skills/_shared/pi_steel/geometry_verify.py | 45 +++++--- skills/_shared/pi_steel/parsing.py | 4 + .../_shared/schemas/nest-result.schema.json | 3 +- skills/steel-nest/scripts/nest.py | 30 +++--- tests/test_outline_geometry.py | 101 ++++++++++++++++++ 5 files changed, 157 insertions(+), 26 deletions(-) diff --git a/skills/_shared/pi_steel/geometry_verify.py b/skills/_shared/pi_steel/geometry_verify.py index ab46264..fb0618d 100644 --- a/skills/_shared/pi_steel/geometry_verify.py +++ b/skills/_shared/pi_steel/geometry_verify.py @@ -68,25 +68,46 @@ def polygon_area(outline: list[Any]) -> float: return abs(total) / 2.0 +def _orient(a, b, c): + value = (b[0] - a[0]) * (c[1] - a[1]) - (b[1] - a[1]) * (c[0] - a[0]) + if value > 1e-12: + return 1 + if value < -1e-12: + return -1 + return 0 + + def _segments_properly_intersect(p1, p2, p3, p4) -> bool: """Whether open segments p1-p2 and p3-p4 cross (shared endpoints excluded).""" + o1, o2 = _orient(p1, p2, p3), _orient(p1, p2, p4) + o3, o4 = _orient(p3, p4, p1), _orient(p3, p4, p2) + return o1 != o2 and o3 != o4 and 0 not in (o1, o2, o3, o4) - def orient(a, b, c): - value = (b[0] - a[0]) * (c[1] - a[1]) - (b[1] - a[1]) * (c[0] - a[0]) - if value > 1e-12: - return 1 - if value < -1e-12: - return -1 - return 0 - o1, o2 = orient(p1, p2, p3), orient(p1, p2, p4) - o3, o4 = orient(p3, p4, p1), orient(p3, p4, p2) - return o1 != o2 and o3 != o4 and 0 not in (o1, o2, o3, o4) +def _segments_touch(p1, p2, p3, p4) -> bool: + """Any contact between closed segments: crossing, touch, or overlap.""" + o1, o2 = _orient(p1, p2, p3), _orient(p1, p2, p4) + o3, o4 = _orient(p3, p4, p1), _orient(p3, p4, p2) + if o1 != o2 and o3 != o4: + return True + return ( + (o1 == 0 and _point_on_segment(p3, p1, p2)) + or (o2 == 0 and _point_on_segment(p4, p1, p2)) + or (o3 == 0 and _point_on_segment(p1, p3, p4)) + or (o4 == 0 and _point_on_segment(p2, p3, p4)) + ) def polygon_is_simple(outline: list[Any]) -> bool: - """Whether non-adjacent edges never cross (a non-self-intersecting ring).""" + """Whether the ring never touches itself. + + Rejects repeated vertices (which also covers zero-length edges and + spikes) and any contact between non-adjacent edges — proper crossings, + endpoint touches, and collinear overlaps alike. + """ count = len(outline) + if len({(point[0], point[1]) for point in outline}) != count: + return False edges = [ (outline[index], outline[(index + 1) % count]) for index in range(count) ] @@ -94,7 +115,7 @@ def polygon_is_simple(outline: list[Any]) -> bool: for second in range(first + 1, count): if second == first + 1 or (first == 0 and second == count - 1): continue - if _segments_properly_intersect(*edges[first], *edges[second]): + if _segments_touch(*edges[first], *edges[second]): return False return True diff --git a/skills/_shared/pi_steel/parsing.py b/skills/_shared/pi_steel/parsing.py index d512aba..5ad9b02 100644 --- a/skills/_shared/pi_steel/parsing.py +++ b/skills/_shared/pi_steel/parsing.py @@ -138,6 +138,10 @@ def adapt_legacy_nest( key: part.get(key) for key in ("name", "width", "height", "shape", "area") } + # Only outline-bearing parts add the key: existing fallback identities + # must stay byte-stable for parts without one. + if part.get("outline") is not None: + identity["outline"] = part["outline"] source_id = explicit_source or fallback_source_id(revision_id, identity) geometry = { "shape": part.get("shape", "rect"), diff --git a/skills/_shared/schemas/nest-result.schema.json b/skills/_shared/schemas/nest-result.schema.json index 47cdf6c..a4b14fc 100644 --- a/skills/_shared/schemas/nest-result.schema.json +++ b/skills/_shared/schemas/nest-result.schema.json @@ -286,7 +286,8 @@ "items": { "type": "number" }, "minItems": 2, "maxItems": 2 - } + }, + "oneOf": [{ "maxItems": 0 }, { "minItems": 3 }] }, "base_area": { "type": "number", "exclusiveMinimum": 0 }, "holes_area": { "type": "number", "minimum": 0 }, diff --git a/skills/steel-nest/scripts/nest.py b/skills/steel-nest/scripts/nest.py index ce6e008..4a4e678 100644 --- a/skills/steel-nest/scripts/nest.py +++ b/skills/steel-nest/scripts/nest.py @@ -500,20 +500,24 @@ def number(value, path, *, positive=False, nonnegative=False): ) ) explicit_source = part.get("source_id") + fallback_identity = { + key: part.get(key) + for key in ( + "name", + "material", + "grade", + "thickness", + "width", + "height", + "shape", + ) + } + # Only outline-bearing parts add the key: existing fallback identities + # must stay byte-stable for parts without one. + if part.get("outline") is not None: + fallback_identity["outline"] = part["outline"] source_id = explicit_source or fallback_source_id( - revision_id, - { - key: part.get(key) - for key in ( - "name", - "material", - "grade", - "thickness", - "width", - "height", - "shape", - ) - }, + revision_id, fallback_identity ) item_id = part.get("item_id") or item_id_for( project_id, revision_id, source_id diff --git a/tests/test_outline_geometry.py b/tests/test_outline_geometry.py index 1524a73..d7fd6b0 100644 --- a/tests/test_outline_geometry.py +++ b/tests/test_outline_geometry.py @@ -278,3 +278,104 @@ def test_pipeline_carries_outline_through_nest(tmp_path): if placement["outline"] ] assert outlines + + +def test_self_touching_rings_are_rejected(): + # Reused boundary via a repeated vertex (CodeRabbit review case). + reused = [[0, 0], [4, 0], [4, 4], [0, 4], [0, 0], [2, 0]] + assert not polygon_is_simple(reused) + # A non-adjacent edge endpoint touching another edge, without any + # repeated vertex: the (6,4)->(3,0) edge lands on the bottom edge. + t_touch = [[0, 0], [6, 0], [6, 4], [3, 0], [0, 4]] + assert not polygon_is_simple(t_touch) + # Concave but genuinely simple rings still pass. + concave = [[0, 0], [4, 0], [2, 2], [4, 4], [0, 4]] + assert polygon_is_simple(concave) + + +def test_distinct_outlines_get_distinct_fallback_identities(): + import pi_steel.parsing as parsing + + mirrored = [[0, 0], [8, 0], [8, 6], [5, 6], [5, 3], [0, 3]] + legacy = { + "material": "carbon_steel", + "grade": "A36", + "unit_system": "imperial", + "settings": {"thickness_in": 0.5}, + "stock": [{"width": 20, "height": 10, "qty": 2}], + "parts": [ + { + "name": "GUSSET", + "width": 8, + "height": 6, + "qty": 1, + "shape": "irregular", + "outline": deepcopy(L_SHAPE), + }, + { + "name": "GUSSET", + "width": 8, + "height": 6, + "qty": 1, + "shape": "irregular", + "outline": mirrored, + }, + ], + } + package = parsing.adapt_legacy_nest( + legacy, project_id="SYNTHETIC-PRJ", revision_id="SYNTHETIC-REV" + ) + source_ids = [item["source_id"] for item in package["items"]] + assert len(set(source_ids)) == 2 + + direct = nest.run_job( + { + "job_name": "SYNTHETIC-DISTINCT", + "material": "carbon_steel", + "grade": "A36", + "unit_system": "imperial", + "settings": {"thickness_in": 0.5}, + "stock": [ + { + "stock_id": "SYNTHETIC-STOCK-D", + "width": 20, + "height": 10, + "thickness": 0.5, + "qty": 2, + } + ], + "parts": deepcopy(legacy["parts"]), + } + ) + assert not any( + finding["code"].startswith("duplicate_") + for finding in direct["validation_findings"] + ) + + +def test_placement_outline_schema_rejects_degenerate_vertex_lists(): + import jsonschema + + schema = json.loads( + (SHARED / "schemas" / "nest-result.schema.json").read_text() + ) + validator = jsonschema.Draft202012Validator( + { + "$schema": schema["$schema"], + "$ref": "#/$defs/outlineOnly", + "$defs": { + "outlineOnly": { + "type": "object", + "properties": { + "outline": schema["$defs"]["placement"]["properties"][ + "outline" + ] + }, + } + }, + } + ) + assert validator.is_valid({"outline": []}) + assert validator.is_valid({"outline": [[0, 0], [1, 0], [1, 1]]}) + assert not validator.is_valid({"outline": [[0, 0]]}) + assert not validator.is_valid({"outline": [[0, 0], [1, 0]]})