From 873f7897aefbd64ddcbe5e32338b7d72e3dd6fca Mon Sep 17 00:00:00 2001 From: senegrom <6349874+senegrom@users.noreply.github.com> Date: Mon, 14 Sep 2026 04:36:43 +0100 Subject: [PATCH] Tighten completion turn budgets for mixed slippage search --- README.md | 10 ++- benchmarks/completion.py | 15 +++++ docs/performance.md | 51 ++++++++++++++- docs/search-correctness.md | 33 ++++++++++ src/duplotrain/solver.py | 59 ++++++++++++----- tests/test_completion_slippage.py | 8 ++- tests/test_completion_turn_budget.py | 94 ++++++++++++++++++++++++++++ 7 files changed, 247 insertions(+), 23 deletions(-) create mode 100644 tests/test_completion_turn_budget.py diff --git a/README.md b/README.md index 996f528..403c5be 100644 --- a/README.md +++ b/README.md @@ -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 @@ -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. diff --git a/benchmarks/completion.py b/benchmarks/completion.py index bb69c65..7f415c6 100644 --- a/benchmarks/completion.py +++ b/benchmarks/completion.py @@ -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}, ]}]}) diff --git a/docs/performance.md b/docs/performance.md index 713bc7e..6e27cc9 100644 --- a/docs/performance.md +++ b/docs/performance.md @@ -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 @@ -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 +``` diff --git a/docs/search-correctness.md b/docs/search-correctness.md index 78a2161..35c6bbe 100644 --- a/docs/search-correctness.md +++ b/docs/search-correctness.md @@ -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 diff --git a/src/duplotrain/solver.py b/src/duplotrain/solver.py index 7d01869..671efc2 100644 --- a/src/duplotrain/solver.py +++ b/src/duplotrain/solver.py @@ -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: @@ -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 diff --git a/tests/test_completion_slippage.py b/tests/test_completion_slippage.py index 334e347..5108da0 100644 --- a/tests/test_completion_slippage.py +++ b/tests/test_completion_slippage.py @@ -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 @@ -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 @@ -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 diff --git a/tests/test_completion_turn_budget.py b/tests/test_completion_turn_budget.py new file mode 100644 index 0000000..91b93f4 --- /dev/null +++ b/tests/test_completion_turn_budget.py @@ -0,0 +1,94 @@ +"""Turning stock, free routes and future targets must retain complete solutions.""" + +from dataclasses import replace + +import pytest + +from duplotrain import Layout, Pose, SolverConfig, build_chain, default_catalog, parse_piece, solve +from duplotrain.solver import _solution_overlaps + + +def compare_searches(catalog, inventory, cfg, base, grow, close): + options = dict(base=base, grow_from=grow, close_onto=close) + reference = solve(inventory, catalog, replace(cfg, completion_lookahead=0), **options) + improved = solve(inventory, catalog, cfg, **options) + assert reference.stats.complete and improved.stats.complete + # Equality includes enumeration order, links, forced gaps and closure kinds. + assert improved.solutions == reference.solutions + assert improved.stats.nodes <= reference.stats.nodes + for solution in improved.solutions: + assert solution.layout.placements[:len(base)] == base.placements + assert solution.gap <= cfg.slop + 1e-12 + assert not _solution_overlaps(solution.layout, len(base), 120, 8) + return improved + + +@pytest.mark.parametrize("engine", ["lattice", "field"]) +@pytest.mark.parametrize("branch", [1, 2]) +@pytest.mark.parametrize("reverse", [False, True]) +@pytest.mark.parametrize("slop", [0.0, 5.0]) +def test_existing_switch_can_supply_all_turning_without_new_stock(engine, branch, reverse, slop): + catalog = default_catalog() + base, switch = Layout().with_piece(catalog["switch"], + Pose.make(x=317, y=-290, z=77, heading=4)) + base, left = base.attach(catalog["straight"], 1, (switch, 0)) + end = base.pose_of((switch, branch)) + target = Pose(end.x + (3 if slop else 0), end.y + (4 if slop else 0), end.z, end.heading) + base, right = base.with_piece(catalog["straight"], catalog["straight"].frame_for(0, target)) + base = Layout(base.placements, {}) + witness = base.join((left, 1), (switch, 0)).join((switch, branch), (right, 0), force=bool(slop)) + grow, close = (left, 1), (right, 0) + if reverse: + grow, close = close, grow + cfg = SolverConfig(min_pieces=0, max_pieces=1, max_results=1000, engine=engine, slop=slop) + result = compare_searches(catalog, {}, cfg, base, grow, close) + assert len(result.solutions) == 1 and result.solutions[0].layout == witness + assert result.solutions[0].gap == pytest.approx(slop) + assert result.solutions[0].exact is (slop == 0) + + +@pytest.mark.parametrize("engine", ["lattice", "field"]) +@pytest.mark.parametrize("slop", [0.0, 5.0]) +def test_new_junction_retains_its_later_turning_transit(engine, slop): + catalog = default_catalog() + start = catalog["curve"].paths[0].end().then(128, 0, 0, 0) + # Two independent curved routes on one custom junction. A spare straight + # bridges them; traversing the second route turns another 30° for free. + catalog["double_curve"] = parse_piece({"id": "double_curve", "paths": [ + {"segments": [{"type": "arc", "radius": 256, "degrees": 30}]}, + {"start": {"x": start.x, "y": start.y, "heading_deg": start.degrees}, + "segments": [{"type": "arc", "radius": 256, "degrees": 30}]}, + ]}) + base, left = Layout().with_piece(catalog["straight"], Pose.make(x=-128)) + end = catalog["double_curve"].ports[3].pose + target = Pose(end.x + (3 if slop else 0), end.y + (4 if slop else 0), end.z, end.heading) + base, right = base.with_piece(catalog["straight"], catalog["straight"].frame_for(0, target)) + witness, junction = base.attach(catalog["double_curve"], 0, (left, 1)) + witness, bridge = witness.attach(catalog["straight"], 0, (junction, 1)) + witness = witness.join((bridge, 1), (junction, 2)).join((junction, 3), (right, 0), + force=bool(slop)) + assert not _solution_overlaps(witness, len(base), 120, 8) + cfg = SolverConfig(min_pieces=2, max_results=1000, engine=engine, + reversing_loops=True, slop=slop) + result = compare_searches(catalog, {"double_curve": 1, "straight": 1}, cfg, + base, (left, 1), (right, 0)) + assert any(s.layout == witness for s in result.solutions) + + +@pytest.mark.parametrize("engine", ["lattice", "field"]) +@pytest.mark.parametrize("curves", [1, 2]) +@pytest.mark.parametrize("slop", [0.0, 512.0]) +def test_scarce_turning_stock_preserves_results_across_backtracking(engine, curves, slop): + catalog = default_catalog() + base = build_chain([(catalog["switch"], 0, 1)]) + inventory = {"curve": curves, "switch": 1, "crossing": 1} + # Deliberately generous position slack isolates exact heading constraints. + # Branches with equal lengths can retain different amounts of turning stock. + cfg = SolverConfig(min_pieces=0, max_results=1000, max_nodes=100_000, + reversing_loops=True, engine=engine, slop=slop) + result = compare_searches(catalog, inventory, cfg, base, (0, 1), (0, 0)) + if slop: + assert {s.kind for s in result.solutions} == {"loop", "reversing"} + assert all(not s.exact for s in result.solutions) + else: + assert not result.solutions