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
10 changes: 7 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -345,7 +345,9 @@ can reach a closing target. Short tails use exact planar-pose and height tables.
Longer tails use exact linear bounds on coordinates, diagonals and height for
each arrival heading, avoiding enumeration of every possible position.
Existing junction ports and targets created by future junctions are included;
free transits require two compatible ports on the same piece. Impossible tails
free transits require two compatible ports on the same piece. A separate turn
bound limits what the remaining stock and free routes can contribute: a crossing
can advance the path without granting it another curve's turn. Impossible tails
are rejected before collision sampling. `SolverConfig.completion_lookahead`
defaults to 6 for the short table (0 disables both checks); both checks share at
most 4096 move expansions and one eighth of `max_nodes`. Partial tables fall back
Expand All @@ -359,8 +361,10 @@ Repeated geometry queries use a per-search cache capped at 4096 entries;
reused queries. The full geometry and height must agree on an actual route, and
the independent collision audit still checks every returned candidate. Reproduce
the measurements with `PYTHONPATH=src python benchmarks/completion.py --repeats 3`.
Use `--suite slippage` for 18 cases covering offset ends, bridges, reversing targets,
intermediate joint gaps and custom 15° pieces. `--case NAME` selects individual
The benchmark has 30 cases; use `--suite slippage` for 20 covering offset ends,
bridges, reversing targets, intermediate joint gaps and custom 15° pieces.
Broad mixed inventory now finds eight closures within 25,000 nodes at both 1 and
5 mm slop. `--case NAME` selects individual
cases; `--lookahead 0` runs the unpruned reference. Each JSON row includes the
gap budget, engine, result fingerprint, forced-fit gaps, stop reason and median time.

Expand Down
15 changes: 15 additions & 0 deletions benchmarks/completion.py
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,21 @@ def cases(catalog):
ends=dict(grow_from=(left, 1), close_onto=(right, 0)),
suite="slippage"))

# No turning stock remains: the existing switch supplies the required 30°.
# Offset only the final joint, retaining exact heading and height.
base, junction = Layout().with_piece(catalog["switch"], ORIGIN)
base, left = base.attach(catalog["straight"], 1, (junction, 0))
end = base.pose_of((junction, 1))
for slop in (0.0, 4.9, 5.0):
target = Pose(end.x + (3 if slop else 0), end.y + (4 if slop else 0),
end.z, end.heading)
positioned, right = base.with_piece(catalog["straight"],
catalog["straight"].frame_for(0, target))
name = f"turn_transit_slop_{slop:g}" if slop else "turn_transit"
result.append(Case(name, Layout(positioned.placements, {}), {}, slop=slop,
ends=dict(grow_from=(left, 1), close_onto=(right, 0)),
suite="slippage" if slop else "exact"))

catalog["fine"] = parse_piece({"id": "fine", "paths": [{"segments": [
{"type": "arc", "radius": "1537/3", "degrees": 15},
]}]})
Expand Down
51 changes: 48 additions & 3 deletions docs/performance.md
Original file line number Diff line number Diff line change
Expand Up @@ -236,9 +236,9 @@ fingerprints and node counts. Exhausted slippage cases retain their fingerprints
regressions also compare complete layouts and retain the earlier candidates from
capped searches. Timings are observations, not portable promises or test thresholds.

Extra proof work is not free. The switch takes longer but now returns eight results.
The mixed case still reaches the 25,000-node cap without a result. At the editor's
existing 60,000-node budget, the 1/5 mm mixed cases now return eight results in
Extra proof work is not free. In this pass the switch took longer but returned eight
results, while the mixed case reached the 25,000-node cap without a result. At the editor's
existing 60,000-node budget, the 1/5 mm mixed cases returned eight results in
29,936/30,339 states (2.23/2.33 s), whereas the previous solver returned none before
that cap. Small field searches can spend more time building bounds than they save;
the zero-inventory transit likewise grows from roughly 0.6 to 1.0 ms. These limits
Expand All @@ -257,3 +257,48 @@ For a historical comparison, run the current benchmark script with `PYTHONPATH`
pointing at the older checkout's `src`. Run timing comparisons sequentially, without
competing test processes. The output identifies the imported solver path. CI lints
the benchmark and runs operation-count, exhaustive-fit and real-worker regressions.

## Stock-aware turning bounds for mixed inventories

The next pass separates turning capacity from the relaxed number of traversals.
A free crossing route still advances the path, but contributes no turn. The bound
also accounts for scarce turning stock and the placement used to create a future
reversing target. Existing and future curved junction transits retain conservative
turn allowances. This rejects impossible tails before the geometric table query,
without adding preprocessing or changing candidate order.

The table compares local Python 3.12 three-run medians against `c320424`, using the
same 20-piece and eight-result limits. The baseline uses 60,000 nodes so the mixed
cases finish; the new solver uses the stricter 25,000-node limit. Both builds finish
all listed searches with eight identical ordered results and the same completed
preprocessing layers. Timings are observations, not test thresholds.

| Case | Before nodes | After nodes | Before time | After time |
| --- | ---: | ---: | ---: | ---: |
| Mixed gap, broad inventory, exact | 22,155 | 16,451 | 1,502.2 ms | 1,107.7 ms |
| Mixed gap, broad inventory, 1 mm | 29,936 | 24,111 | 2,429.0 ms | 1,839.9 ms |
| Mixed gap, broad inventory, 5 mm | 30,339 | 24,514 | 2,365.9 ms | 1,903.5 ms |
| Offset circle, broad inventory, 5 mm | 286 | 24 | 29.1 ms | 12.7 ms |
| Switch, broad inventory, 1 mm | 19,701 | 19,451 | 1,949.2 ms | 2,044.7 ms |
| Switch, broad inventory, 5 mm | 19,899 | 19,649 | 2,098.0 ms | 2,035.4 ms |

The mixed slippage cases now find all eight results under the benchmark's default
25,000-node cap, where the previous build found none. They use 19–20% fewer nodes
and take 20–24% less time to find the same results. Exact mixed search takes 26%
less time; the broad forced-offset case takes 56% less. All 27 existing cases
retain their ordered result fingerprints. Extra turn bookkeeping is not a
universal speedup: the 1 mm switch case is about 5% slower, and the small winding
case rises from 14.2 to 19.2 ms despite unchanged nodes.

Three additional cases bring the suite to **30 cases, including 20 with slippage**.
They close through a preplaced switch without any spare pieces, requiring its
30-degree turn with exact endpoints, a 5 mm forced joint, or an insufficient
4.9 mm allowance. Regressions also preserve these witnesses in both directions
and arithmetic engines, on both switch branches at rotated/elevated poses.
Scarce-stock exhaustive comparisons and a custom curved junction exercise
backtracking and later free transits.

```sh
PYTHONPATH=src python benchmarks/completion.py --case mixed_full_slop_1 --case mixed_full_slop_5 --repeats 3
PYTHONPATH=src python benchmarks/completion.py --case turn_transit --case turn_transit_slop_4.9 --case turn_transit_slop_5
```
33 changes: 33 additions & 0 deletions docs/search-correctness.md
Original file line number Diff line number Diff line change
Expand Up @@ -135,6 +135,39 @@ depth and total retained heading envelopes. Regressions in
compare exhaustive results on both engines, exercise custom 15-degree curves and
preprocessing exhaustion, and pin the two previously capped broad-inventory cases.

## Traversal count and turning capacity are separate bounds

The geometry tables relax stock by pooling all available route types. Counting a
free crossing transit as another traversal is necessary for distance, but must not
grant it a curve's turning capacity. Before querying geometry, completion search
also bounds the total absolute heading change available to the tail, in exact
15-degree steps.

For `k` remaining placement slots, newly placed pieces contribute at most the
smaller of `k * max_turn` and the sum of turning capacities in remaining stock.
Existing free transits contribute their own piece's maximum route turn times
their compatible-port transit allowance. Future free transits contribute at most
the smaller of the allowance for `k` junctions and the sum over remaining stock.
These bounds may select incompatible routes or piece types, but only overestimate
capacity. The circular distance between headings cannot exceed the sum of route
turns, so a larger required heading change rules out that target.

The anchor and every existing reversing target are checked separately. For a
future target, the junction that creates it consumes one placement and one copy
of its turn allowance before the tail begins. Ignoring all earlier placements
only enlarges the remaining resources. The future-target cache key includes this
turn budget as well as traversal count and remaining slop; equal-length branches
can have different amounts of turning stock. Early candidate checks conservatively
leave the candidate in stock until its recursive visit.

Slippage changes position, never heading, so it cannot enlarge the turn allowance.
`tests/test_completion_turn_budget.py` compares complete ordered solutions with
lookahead disabled on both arithmetic engines, including scarce stock, backtracking,
free turning transits on rotated and elevated switches, and a custom junction
traversed twice. Independent witnesses include forced joints and zero new pieces.
The mixed-inventory benchmarks also pin eight audited results below 25,000 nodes
for exact, 1 mm and 5 mm searches.

## Slippage uses physical distances and one remaining budget

A small physical displacement need not have small exact coefficients: large
Expand Down
59 changes: 43 additions & 16 deletions src/duplotrain/solver.py
Original file line number Diff line number Diff line change
Expand Up @@ -1443,17 +1443,33 @@ def tail_context():
free_by_placement: dict[int, set[int]] = {}
for index, port, _pose in stubs:
free_by_placement.setdefault(index, set()).add(port)
transits = sum(
transit_bound(piece_obj[placements[index][0]], ports)
for index, ports in free_by_placement.items()
)
available = [pid for pid in piece_ids if counts[pid]]
capacity = max((future_transits[pid] for pid in available), default=0)
future = sum(counts[pid] * future_transits[pid] for pid in available)
transits = transit_turns = 0
for index, ports in free_by_placement.items():
if len(ports) < 2:
continue
pid = placements[index][0]
count = transit_bound(piece_obj[pid], ports)
transits += count
transit_turns += count * turn_of[pid]
capacity = future = max_turn = max_free_turn = future_turns = 0
future_targets = []
for pid in piece_ids:
count = counts[pid]
if not count:
continue
free = future_transits[pid]
turn = turn_of[pid]
capacity = max(capacity, free)
future += count * free
max_turn = max(max_turn, turn)
max_free_turn = max(max_free_turn, free * turn)
future_turns += count * free * turn
if reversing_queries[pid]:
future_targets.append(pid)
targets = (tuple(eng.reverse(pose) for _index, _port, pose in stubs)
if cfg.reversing_loops else ())
future_targets = tuple(pid for pid in available if reversing_queries[pid])
return transits, capacity, future, targets, future_targets
return (transits, capacity, future, targets, future_targets,
transit_turns, max_turn, max_free_turn, future_turns)

def tail_possible(cursor, used: int, context, slack: float | None,
extra_pid: str | None = None) -> bool:
Expand All @@ -1464,28 +1480,39 @@ def tail_possible(cursor, used: int, context, slack: float | None,
# Its new targets need the placement frame. The recursive visit checks
# them after placement, with its own updated context.
return True
transits, capacity, future, targets, future_targets = context
(transits, capacity, future, targets, future_targets,
transit_turns, max_turn, max_free_turn, future_turns) = context
if extra_pid:
transits += future_transits[extra_pid]
transit_turns += future_transits[extra_pid] * turn_of[extra_pid]
transits += min(slots * capacity, future)
transit_turns += min(slots * max_free_turn, future_turns)
traversals = slots + transits
if completion.allows(cursor, traversals, slack):
# A free crossing traversal advances the path but cannot turn it. Keep
# its actual turning capacity separate from the relaxed traversal count.
turns = min(slots * max_turn, remaining_turn) + transit_turns
if eng.need_turn24(cursor) <= turns and completion.allows(cursor, traversals, slack):
return True
if cfg.reversing_loops:
for target in targets:
query = eng.retarget(cursor, target)
if completion.allows(query, traversals, slack):
if eng.need_turn24(query) <= turns and completion.allows(query, traversals, slack):
return True
if slots:
# A future reversing target is created by one placement. Whatever
# precedes that placement, its exit must reach one of its free ports
# in at most the remaining traversals. Ignore all stock/geometry
# constraints here, retaining an overapproximation of every target.
# in at most the remaining traversals. Ignoring the prefix's stock
# consumption and geometry keeps an overapproximation of every tail.
for pid in future_targets:
key = (pid, traversals - 1, slack)
# The junction creating this target consumes one placement
# and its stock allowance before the tail starts.
tail_turns = min((slots - 1) * max_turn,
remaining_turn - turn_of[pid]) + transit_turns
key = (pid, traversals - 1, tail_turns, slack)
possible = future_closure.get(key)
if possible is None:
possible = any(completion.allows(query, traversals - 1, slack)
possible = any(eng.need_turn24(query) <= tail_turns
and completion.allows(query, traversals - 1, slack)
for query in reversing_queries[pid])
if len(future_closure) < 4096:
future_closure[key] = possible
Expand Down
8 changes: 7 additions & 1 deletion tests/test_completion_slippage.py
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@ def convert(pose):
@pytest.mark.parametrize("name", [
"half_circle_slop_1", "offset_circle_slop_4.9", "offset_circle_slop_5",
"offset_circle_slop_10", "transit_slop_2.9", "transit_slop_3",
"turn_transit_slop_4.9", "turn_transit_slop_5",
])
def test_exhaustive_slippage_results_match_unpruned_search(inputs, engine, name):
catalog, examples = inputs
Expand All @@ -72,7 +73,11 @@ def test_exhaustive_slippage_results_match_unpruned_search(inputs, engine, name)
solution = improved.solutions[0]
assert solution.gap == 3 and not solution.exact
assert sorted(j["gap_mm"] for j in solution.layout.joint_issues()) == [1, 2]
elif name in ("transit_slop_2.9", "offset_circle_slop_4.9"):
elif name == "turn_transit_slop_5":
assert len(improved.solutions) == 1
assert not improved.solutions[0].exact
assert improved.solutions[0].gap == pytest.approx(5)
elif name in ("transit_slop_2.9", "offset_circle_slop_4.9", "turn_transit_slop_4.9"):
assert not improved.solutions


Expand All @@ -90,6 +95,7 @@ def test_fractional_fifteen_degree_slippage_retains_its_completion(inputs):
@pytest.mark.parametrize("name", [
"bridge_full_slop_1", "switch_full_slop_5", "long_gap_slop_5",
"offset_full_slop_5", "offset_long_slop_5",
"mixed_full", "mixed_full_slop_1", "mixed_full_slop_5",
])
def test_slippage_benchmarks_find_more_audited_results_with_same_budget(inputs, name):
catalog, examples = inputs
Expand Down
Loading
Loading