Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 21 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -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": [
Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
@@ -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 = [
Expand Down
6 changes: 3 additions & 3 deletions skills/steel-cutlist/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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

Expand Down Expand Up @@ -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.
205 changes: 182 additions & 23 deletions skills/steel-cutlist/scripts/cutlist.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"

Expand Down Expand Up @@ -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):
Expand Down
28 changes: 28 additions & 0 deletions skills/steel-estimate/references/estimate-package-example.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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": {
Expand Down
10 changes: 8 additions & 2 deletions skills/steel-estimate/references/output-contract.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand All @@ -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

Expand Down
Loading
Loading