diff --git a/CHANGELOG.md b/CHANGELOG.md index f339b9f..7a26c36 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,27 @@ All notable changes to `@structupath/pi-steel` are documented here. The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and the project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [0.3.1] - 2026-08-23 + +### Added + +- Bounded exact cut-list optimization: designation + grade groups with up + to 12 pieces run a deterministic branch-and-bound search (seeded and + bounded by the portfolio result, fixed node budget) that explores + complete and partial placements alike and only ever replaces the greedy + plan with a strictly better one — including stranding fewer members when + finite stock cannot hold everything. +- `output-contract.md` documents the cut-list artifacts and + `cutlist_partial` status; the estimate-package example now includes + member items and vendor linear stock. + +### Changed + +- Ranking now minimizes purchase cost before purchased length when every + stock entry in a group carries a known cost basis — buying cheaper beats + buying shorter; groups without complete pricing keep the least-purchased- + length objective. + ## [0.3.0] - 2026-08-23 ### Added diff --git a/README.md b/README.md index ebb4d5f..4d7ce09 100644 --- a/README.md +++ b/README.md @@ -146,7 +146,7 @@ npm run provenance:check # shape-data integrity and recorded decision npm run release:check # complete release gate ``` -The current package version is `0.3.0`. `release:check` verifies the test, +The current package version is `0.3.1`. `release:check` verifies the test, privacy, package-content, shape-data integrity, ownership, license, and redistribution contracts before publication. diff --git a/package.json b/package.json index 073ccec..9fcd7f4 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@structupath/pi-steel", - "version": "0.3.0", + "version": "0.3.1", "description": "Structural steel estimating for Pi \u2014 validated takeoffs, plate nesting, guarded DXF output, and review-ready RFQ packages.", "type": "module", "keywords": [ diff --git a/pyproject.toml b/pyproject.toml index 3187f1b..0fa5fe2 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "pi-steel-runtime" -version = "0.3.0" +version = "0.3.1" description = "Python runtime dependencies and test configuration for pi-steel" requires-python = ">=3.11,<3.14" dependencies = [ diff --git a/skills/steel-cutlist/SKILL.md b/skills/steel-cutlist/SKILL.md index ce35421..2f19b0d 100644 --- a/skills/steel-cutlist/SKILL.md +++ b/skills/steel-cutlist/SKILL.md @@ -15,7 +15,7 @@ It complements `steel-nest` (2D plates). Plates go to `steel-nest`; anything bou **Reliable:** - Exact 1D packing per designation + grade group. Stock never crosses groups: a W12X26 member is only cut from W12X26 stock of the same grade. -- A deterministic strategy portfolio per group — a mixed-stock greedy plus each single-stock-length restriction — ranked by fewest unplaced members, least purchased stock length (on-hand consumption is free), lowest known purchase cost, fewest purchased bars, then least total length. The same input always produces the same plan. +- A deterministic strategy portfolio per group — a mixed-stock greedy plus each single-stock-length restriction — refined by a bounded exact branch-and-bound search for small groups (up to 12 pieces) that explores complete and partial placements alike and only ever replaces the portfolio result with a strictly better one. Ranking always minimizes unplaced members first; when every stock entry in the group carries a known cost basis, lowest purchase cost decides next (buying cheaper beats buying shorter), otherwise least purchased stock length decides (on-hand consumption is free). Fewest purchased bars and least total length settle ties. The same input always produces the same plan. - Explicit fit contract: usable length = bar length − 2 × end trim; a piece fits when its length alone fits the remainder; each placed piece then consumes its length plus one kerf, saturating at the bar end. - Independent post-placement verification (bar overcommitment, material mismatch, duplicate or missing instances) before any cutting list is published. - Drops classified against a reusable-candidate threshold (`min_drop_in`) — candidates are never certified reusable stock. @@ -26,7 +26,7 @@ It complements `steel-nest` (2D plates). Plates go to `steel-nest`; anything bou **Deliberately NOT done:** - No saw-controller programs or claims of machine-specific compatibility; the cutting list is a shop document that an operator verifies. - No remnant-inventory or scrap-market optimization. Drop candidates need a person to measure, identify, and approve before they become stock. -- No true global optimum guarantee — the portfolio heuristic is strong and deterministic, but it is a heuristic; say so if asked. +- No global optimum guarantee for large groups — small groups (up to 12 pieces) are solved exactly within a fixed search budget, larger ones fall back to the deterministic portfolio heuristic; say so if asked. ## Inputs to Gather @@ -85,4 +85,4 @@ The engine writes `rfq_linear.json` — `{schema_version, source_cutlist_result_ **Drop reuse** — output drops are candidates only. Measure, identify, and approve a candidate before supplying it as its own finite stock entry in a later run (a shorter `length_in` entry with `qty: 1`). -**On-hand material first** — enter on-hand bars as finite-quantity stock entries with `"stock_kind": "on_hand"` alongside purchasable lengths. On-hand stock must be finite and carries no cost basis; the portfolio minimizes purchased length first, so sticks are consumed whenever they genuinely reduce buying, and every report labels on-hand rows explicitly. +**On-hand material first** — enter on-hand bars as finite-quantity stock entries with `"stock_kind": "on_hand"` alongside purchasable lengths. On-hand stock must be finite and carries no cost basis; the optimizer treats on-hand consumption as free (zero cost, zero purchased length), so sticks are consumed whenever they genuinely reduce buying, and every report labels on-hand rows explicitly. diff --git a/skills/steel-cutlist/scripts/cutlist.py b/skills/steel-cutlist/scripts/cutlist.py index bb6de98..c68caf6 100644 --- a/skills/steel-cutlist/scripts/cutlist.py +++ b/skills/steel-cutlist/scripts/cutlist.py @@ -71,7 +71,9 @@ from pi_steel.parsing import normalize_designation, parse_length_ft # noqa: E402 CUTLIST_RESULT_VERSION = "1.0.0" -CUTLIST_ALGORITHM_VERSION = "portfolio-bfd-v1" +CUTLIST_ALGORITHM_VERSION = "portfolio-bfd-exact-v1" +EXACT_SEARCH_MAX_UNITS = 12 +EXACT_SEARCH_NODE_BUDGET = 250_000 EPS = 1e-6 AISC_DATABASE_RELATIVE = Path("steel-takeoff") / "assets" / "aisc-shapes-database.json" @@ -601,43 +603,200 @@ def _ranking_cost(stock): return _bar_cost(stock) -def _solve_group(units, group_stock, kerf): - """Try a portfolio of deterministic strategies; keep the cheapest result. +def _rank_solution(bars, unplaced_count, cost_priority): + """Solution ranking, always fewest unplaced members first. - Candidates: the mixed-stock greedy plus each single-stock-length - restriction. Solutions rank by fewest unplaced members, least PURCHASED - stock length (on-hand consumption is free), lowest known purchase cost - (unknown costs rank last), fewest purchased bars, then least total - length. Ties resolve by strategy name for determinism. + When every stock entry in the group has a known purchase basis + (``cost_priority``), lowest cost decides next — buying cheaper beats + buying shorter. Otherwise least PURCHASED stock length decides (on-hand + consumption is free) with any known cost as a later tie-break. Fewest + purchased bars, then least total length, settle remaining ties. + """ + purchased = [ + bar for bar in bars if bar["stock"]["stock_kind"] == "purchasable" + ] + purchased_length = round( + sum(bar["stock"]["length_in"] for bar in purchased), 6 + ) + costs = [_ranking_cost(bar["stock"]) for bar in bars] + cost_rank = round(sum(costs), 2) if None not in costs else math.inf + total_length = round(sum(bar["stock"]["length_in"] for bar in bars), 6) + if cost_priority: + primary, secondary = cost_rank, purchased_length + else: + primary, secondary = purchased_length, cost_rank + return (unplaced_count, primary, secondary, len(purchased), total_length) + + +class _SearchBudgetExceeded(Exception): + """Raised when the exact search exhausts its deterministic node budget.""" + + +def _exact_solve_group(units, group_stock, kerf, best_rank, cost_priority): + """Branch-and-bound over bar assignments for a small group. + + Every unit branches over placements into open bars, opening each stock + type, or remaining unplaced (``stock_exhausted``), so optimal partial + plans are found when finite stock cannot hold everything. Seeded with + the portfolio's rank for pruning and bounded by a fixed node budget so + runtime stays deterministic. Returns (bars, unplaced, rank) only when a + solution strictly beats ``best_rank``; otherwise None and the caller + keeps the portfolio result. + """ + placeable = [] + prefilter_unplaced = [] + for unit in units: + if any( + unit["length_in"] <= stock["usable_in"] + EPS + for stock in group_stock + ): + placeable.append(unit) + else: + prefilter_unplaced.append( + {**unit, "reason": "no_compatible_stock_fit"} + ) + if not placeable or len(placeable) > EXACT_SEARCH_MAX_UNITS: + return None + stocks = sorted(group_stock, key=lambda stock: stock["stock_id"]) + used = {stock["stock_id"]: 0 for stock in stocks} + bars_state = [] # mutable [stock, remaining, cuts] triples + skipped = [] # units left unplaced on the current search path + state = {"nodes": 0, "best": None, "best_rank": best_rank} + + def primary_bound(): + """Monotone lower bound on the rank's primary component.""" + if cost_priority: + costs = [_ranking_cost(triple[0]) for triple in bars_state] + return ( + round(sum(costs), 2) if None not in costs else math.inf + ) + return round( + sum( + triple[0]["length_in"] + for triple in bars_state + if triple[0]["stock_kind"] == "purchasable" + ), + 6, + ) + + def descend(index): + """Assign placeable[index:] and record any strictly better leaf. + + Pruning compares (unplaced so far, primary bound) with the best + rank; both components only grow along a path, so the lexicographic + comparison is a valid lower bound. + """ + state["nodes"] += 1 + if state["nodes"] > EXACT_SEARCH_NODE_BUDGET: + raise _SearchBudgetExceeded + if state["best_rank"] is not None and ( + len(prefilter_unplaced) + len(skipped), + primary_bound(), + ) > (state["best_rank"][0], state["best_rank"][1]): + return + if index == len(placeable): + solution = [ + {"stock": triple[0], "cuts": list(triple[2]), "remaining": 0.0} + for triple in bars_state + ] + rank = _rank_solution( + solution, + len(prefilter_unplaced) + len(skipped), + cost_priority, + ) + if state["best_rank"] is None or rank < state["best_rank"]: + state["best_rank"] = rank + state["best"] = ( + [(triple[0], list(triple[2])) for triple in bars_state], + list(skipped), + ) + return + unit = placeable[index] + length = unit["length_in"] + seen = set() + for triple in bars_state: + slot = (triple[0]["stock_id"], round(triple[1], 6)) + if slot in seen: + continue + seen.add(slot) + if length <= triple[1] + EPS: + previous = triple[1] + triple[2].append(unit) + triple[1] = max(previous - length - kerf, 0.0) + descend(index + 1) + triple[1] = previous + triple[2].pop() + for stock in stocks: + if used[stock["stock_id"]] >= stock["qty"]: + continue + if length > stock["usable_in"] + EPS: + continue + used[stock["stock_id"]] += 1 + bars_state.append( + [stock, max(stock["usable_in"] - length - kerf, 0.0), [unit]] + ) + descend(index + 1) + bars_state.pop() + used[stock["stock_id"]] -= 1 + skipped.append(unit) + descend(index + 1) + skipped.pop() + + try: + descend(0) + except _SearchBudgetExceeded: + return None + if state["best"] is None: + return None + best_bars, best_skipped = state["best"] + bars = [] + for stock, cuts in best_bars: + bar = {"stock": stock, "cuts": [], "remaining": stock["usable_in"]} + for unit in cuts: + _place_on_bar(bar, unit, kerf) + bars.append(bar) + unplaced_units = prefilter_unplaced + [ + {**unit, "reason": "stock_exhausted"} for unit in best_skipped + ] + return bars, unplaced_units, state["best_rank"] + + +def _solve_group(units, group_stock, kerf): + """Deterministic strategy portfolio, refined by a bounded exact search. + + Portfolio candidates: the mixed-stock greedy plus each + single-stock-length restriction, ranked per ``_rank_solution`` with the + strategy name as the final tie-break. Small groups then run a + branch-and-bound exact search seeded with the portfolio rank; its result + replaces the portfolio's only when strictly better, so the outcome is + never worse than the greedy portfolio. """ strategies = [("mixed", group_stock)] for stock in group_stock: strategies.append((f"single:{stock['stock_id']}", [stock])) + cost_priority = all( + _ranking_cost(stock) is not None for stock in group_stock + ) best = None best_rank = None for name, stocks in strategies: bars, unplaced = _greedy_pack(units, stocks, kerf, group_stock) - total_length = sum(bar["stock"]["length_in"] for bar in bars) - purchased = [ - bar for bar in bars if bar["stock"]["stock_kind"] == "purchasable" - ] - purchased_length = sum(bar["stock"]["length_in"] for bar in purchased) - costs = [_ranking_cost(bar["stock"]) for bar in bars] - cost_rank = ( - round(sum(costs), 2) if None not in costs else math.inf - ) rank = ( - sum(1 for _ in unplaced), - round(purchased_length, 6), - cost_rank, - len(purchased), - round(total_length, 6), + *_rank_solution(bars, sum(1 for _ in unplaced), cost_priority), name, ) if best_rank is None or rank < best_rank: best_rank = rank best = (bars, unplaced) - return best if best is not None else ([], []) + if best is None: + return [], [] + exact = _exact_solve_group( + units, group_stock, kerf, best_rank[:5], cost_priority + ) + if exact is not None: + exact_bars, exact_unplaced, _ = exact + return exact_bars, exact_unplaced + return best def run_job(job): diff --git a/skills/steel-estimate/references/estimate-package-example.json b/skills/steel-estimate/references/estimate-package-example.json index 5540146..87231f8 100644 --- a/skills/steel-estimate/references/estimate-package-example.json +++ b/skills/steel-estimate/references/estimate-package-example.json @@ -34,6 +34,24 @@ } ] }, + { + "intent": "fabricated_part", + "source_id": "SYNTHETIC-SRC-B1", + "item_id": "item:synthetic-example-b1", + "quantity": 2, + "mark": "B1", + "description": "Synthetic wide-flange beam", + "grade": "A992", + "designation": "W12X26", + "length_ft": 12, + "unit_weight_plf": 26, + "source_evidence": [ + { + "source": "SYNTHETIC-SOURCE-001", + "locator": "SYNTHETIC-DETAIL-B1" + } + ] + }, { "intent": "purchased_stock", "source_id": "SYNTHETIC-SRC-S1", @@ -72,6 +90,16 @@ "thickness": 0.5, "quantity": 1, "status": "available" + }, + { + "stock_kind": "purchasable", + "stock_form": "linear", + "inventory_id": "SYNTHETIC-VENDOR-W12X26-40", + "designation": "W12X26", + "grade": "A992", + "length_ft": 40, + "quantity": 1, + "unlimited": true } ], "commercial_basis": { diff --git a/skills/steel-estimate/references/output-contract.md b/skills/steel-estimate/references/output-contract.md index d4436ce..0b0f1fb 100644 --- a/skills/steel-estimate/references/output-contract.md +++ b/skills/steel-estimate/references/output-contract.md @@ -12,6 +12,9 @@ before using any artifact. | `normalized-bom.json` | Typed BOM and calculated weight projection | Always | | `nest-result.json` | Placements, verification, utilization, and unplaced parts | Valid input contains plate parts | | `rfq-nesting.json` | Versioned nesting lineage for RFQ compilation | A nest was attempted | +| `cutlist-result.json` | Bar plans, verification, purchase summary, drops, and unplaced members | Valid input contains member items with designation, length, and grade | +| `rfq-linear.json` | Versioned linear-stock lineage for RFQ compilation | A cut-list was attempted | +| `cutting_list.csv` | Verified per-bar cut sequence (`geometry_verified`) | Run outcome is `ready` and the cut-list is fully placed and verified | | `inventory-consumption.json` | Confirmed on-hand sheets consumed and corresponding RFQ demand reduction | Eligible on-hand inventory was consumed | | `qa-report.json` | Findings, approximations, gate decisions, and recalculation status | Always | | `run-manifest.json` | Input/configuration hashes and artifact hashes/readiness | Always | @@ -32,8 +35,11 @@ verified exact geometry. - `dependency_missing`: required rendering support is absent and no workbook exists. -Unplaced parts use `blocked` with package status `nested_partial`; their nest -diagnostics remain available. A validation failure stops before nesting. +Unplaced plate parts use `blocked` with package status `nested_partial`; +members longer than every available stock length use `blocked` with package +status `cutlist_partial`. Their nest and cut-list diagnostics remain +available. A validation failure stops before nesting and cut-list +optimization. ## Determinism and lineage diff --git a/tests/test_cutlist_engine.py b/tests/test_cutlist_engine.py index 0c595ff..84d197b 100644 --- a/tests/test_cutlist_engine.py +++ b/tests/test_cutlist_engine.py @@ -531,3 +531,175 @@ def test_anonymous_on_hand_and_purchasable_rows_get_distinct_ids(): for finding in result["validation_findings"] ) assert result["outcome"] == "ready" + + +def test_exact_search_beats_greedy_on_classic_bfd_failure(): + # Best-fit-decreasing packs [5,5], [4,4], [3,3,3], [3] onto four bars of + # capacity 10; the exact search finds the optimal [5,5], [4,3,3], [4,3,3]. + job = { + "job_name": "SYNTHETIC-EXACT", + "project_id": "SYNTHETIC-PRJ", + "revision_id": "SYNTHETIC-REV", + "unit_system": "imperial", + "settings": {"kerf_in": 0, "end_trim_in": 0, "min_drop_in": 1000}, + "members": [ + { + "source_id": f"SYNTHETIC-E{length}", + "name": f"SYNTHETIC-E{length}", + "designation": "FB1X1", + "grade": "A36", + "length_in": length, + "qty": qty, + "unit_weight_plf": 3.4, + } + for length, qty in ((5, 2), (4, 2), (3, 4)) + ], + "stock": [ + { + "stock_id": "SYNTHETIC-STK-10", + "designation": "FB1X1", + "grade": "A36", + "length_in": 10, + "unlimited": True, + } + ], + } + result = cutlist.run_job(job) + assert result["outcome"] == "ready" + assert result["bars_used"] == 3 + assert result["verification"]["status"] == "verified" + assert result["metrics"]["utilization_pct"]["value"] == 100.0 + + +def test_exact_search_never_replaces_with_a_worse_solution(): + # The portfolio already finds the optimal single-length answer here; the + # exact refinement must keep it (same purchase, same cost). + result = cutlist.run_job(base_job()) + assert result["purchase_summary"][0]["stock_id"] == "SYNTHETIC-STK-40" + assert result["total_material_cost"] == 7488.0 + + +def test_large_groups_fall_back_to_portfolio_deterministically(): + job = base_job() + job["members"] = [ + { + "source_id": f"SYNTHETIC-L{index}", + "name": f"SYNTHETIC-L{index}", + "designation": "W12X26", + "grade": "A992", + "length_in": 100 + index, + "qty": 2, + } + for index in range(10) + ] + first = cutlist.run_job(deepcopy(job)) + second = cutlist.run_job(deepcopy(job)) + assert first["outcome"] == "ready" + assert sha256_bytes(canonical_json_bytes(first)) == sha256_bytes( + canonical_json_bytes(second) + ) + + +def test_without_cost_basis_exact_search_minimizes_purchased_length(): + # With no prices, the objective is purchased length: the exact search + # finds the 230 ft mixed plan (3 x 50 ft pairing 342+150, one 40 ft for + # the last 342, one 40 ft for three 150s) that the greedy misses. + job = base_job() + for stock in job["stock"]: + del stock["cost_per_ft"] + result = cutlist.run_job(job) + assert result["outcome"] == "ready" + assert result["total_stock_length_ft"] == 230.0 + assert result["bars_used"] == 5 + assert result["verification"]["status"] == "verified" + + +def test_exact_search_finds_optimal_partial_plan_when_stock_is_finite(): + # One capacity-10 bar. Greedy places [5,5] and strands three pieces; + # the exact search's skip branches find [4,3,3], stranding only two. + job = { + "job_name": "SYNTHETIC-EXACT-PARTIAL", + "project_id": "SYNTHETIC-PRJ", + "revision_id": "SYNTHETIC-REV", + "unit_system": "imperial", + "settings": {"kerf_in": 0, "end_trim_in": 0, "min_drop_in": 1000}, + "members": [ + { + "source_id": f"SYNTHETIC-P{length}", + "name": f"SYNTHETIC-P{length}", + "designation": "FB1X1", + "grade": "A36", + "length_in": length, + "qty": qty, + "unit_weight_plf": 3.4, + } + for length, qty in ((5, 2), (4, 1), (3, 2)) + ], + "stock": [ + { + "stock_id": "SYNTHETIC-STK-ONE", + "designation": "FB1X1", + "grade": "A36", + "length_in": 10, + "qty": 1, + } + ], + } + result = cutlist.run_job(job) + assert result["outcome"] == "blocked" + assert result["package_status"] == "cutlist_partial" + assert sum(row["quantity"] for row in result["unplaced"]) == 2 + assert all(row["reason"] == "stock_exhausted" for row in result["unplaced"]) + cuts = sorted( + cut["length_in"] + for report in result["bar_reports"] + for cut in report["cuts"] + ) + assert cuts == [3, 3, 4] + assert result["verification"]["status"] == "verified" + + +def test_exact_partial_plan_prefers_stranding_one_large_over_two_small(): + # Two capacity-10 bars, pieces 8,6,4,4,4. Greedy packs [8] and [6,4], + # stranding two 4s; the optimum packs [6,4] and [4,4], stranding only + # the 8. (CodeRabbit review case on PR #7.) + job = { + "job_name": "SYNTHETIC-EXACT-PARTIAL-2", + "project_id": "SYNTHETIC-PRJ", + "revision_id": "SYNTHETIC-REV", + "unit_system": "imperial", + "settings": {"kerf_in": 0, "end_trim_in": 0, "min_drop_in": 1000}, + "members": [ + { + "source_id": f"SYNTHETIC-Q{length}", + "name": f"SYNTHETIC-Q{length}", + "designation": "FB1X1", + "grade": "A36", + "length_in": length, + "qty": qty, + "unit_weight_plf": 3.4, + } + for length, qty in ((8, 1), (6, 1), (4, 3)) + ], + "stock": [ + { + "stock_id": "SYNTHETIC-STK-TWO", + "designation": "FB1X1", + "grade": "A36", + "length_in": 10, + "qty": 2, + } + ], + } + result = cutlist.run_job(job) + assert result["outcome"] == "blocked" + assert sum(row["quantity"] for row in result["unplaced"]) == 1 + assert result["unplaced"][0]["length_in"] == 8 + assert result["unplaced"][0]["reason"] == "stock_exhausted" + cuts = sorted( + cut["length_in"] + for report in result["bar_reports"] + for cut in report["cuts"] + ) + assert cuts == [4, 4, 4, 6] + assert result["verification"]["status"] == "verified"