From 9883e799226f9b2510d9c3d6484b02c0644a4b5d Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 23 Aug 2026 06:31:09 +0000 Subject: [PATCH 1/7] 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 2/7] 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 3/7] 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 4/7] 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 5/7] 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 6/7] 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 7/7] 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"