From fa205d0437ab6761f67cdaef829724266634f429 Mon Sep 17 00:00:00 2001 From: senegrom <6349874+senegrom@users.noreply.github.com> Date: Sun, 13 Sep 2026 13:59:12 +0100 Subject: [PATCH 1/2] Benchmark and accelerate completion with slippage --- .github/workflows/app-check.yml | 2 +- README.md | 10 +- benchmarks/completion.py | 125 +++++++++++++++-- docs/performance.md | 63 +++++++++ docs/search-correctness.md | 52 ++++++- src/duplotrain/solver.py | 183 +++++++++++++++++++----- tests/browser/test_editor.py | 34 +++++ tests/test_completion_lookahead.py | 4 +- tests/test_completion_reuse.py | 13 +- tests/test_completion_slippage.py | 217 +++++++++++++++++++++++++++++ tests/test_completion_targets.py | 10 +- 11 files changed, 653 insertions(+), 60 deletions(-) create mode 100644 tests/test_completion_slippage.py diff --git a/.github/workflows/app-check.yml b/.github/workflows/app-check.yml index aa09498..e717683 100644 --- a/.github/workflows/app-check.yml +++ b/.github/workflows/app-check.yml @@ -44,7 +44,7 @@ jobs: python-version: ${{ matrix.python }} cache: pip - run: python -m pip install -e '.[dev]' - - run: ruff check src webapp tests + - run: ruff check src webapp tests benchmarks - run: python -m pytest -m 'not slow and not browser' base-install: runs-on: ubuntu-latest diff --git a/README.md b/README.md index 7bd35f4..996f528 100644 --- a/README.md +++ b/README.md @@ -348,8 +348,10 @@ Existing junction ports and targets created by future junctions are included; free transits require two compatible ports on the same piece. 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 and forced -fits fall back to ordinary search. `stats.pruned_completion` and +most 4096 move expansions and one eighth of `max_nodes`. Partial tables fall back +to ordinary search. With slippage enabled, outward-rounded physical bounds and +indexed nearby short tails allow the **remaining total** gap budget; heading and +height still have to match. `stats.pruned_completion` and `stats.completion_work` report the saved branches and total preprocessing work; `stats.completion_bound_depth` reports how far the longer bounds reached. Repeated geometry queries use a per-search cache capped at 4096 entries; @@ -357,6 +359,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 +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. Elevation is modelled (ramps carry `z`; closure requires returning to the anchor's height). Blanket collision clearance defaults to 120 mm; underpass-enabled pieces diff --git a/benchmarks/completion.py b/benchmarks/completion.py index ea16b5b..bb69c65 100644 --- a/benchmarks/completion.py +++ b/benchmarks/completion.py @@ -6,11 +6,22 @@ """ import argparse +import hashlib import json +from dataclasses import dataclass from statistics import median import duplotrain.solver as solver_module -from duplotrain import SolverConfig, build_chain, default_catalog, solve +from duplotrain import ( + ORIGIN, + Layout, + Pose, + SolverConfig, + build_chain, + default_catalog, + parse_piece, + solve, +) CASES = [ ("half_circle", ["curve"] * 6, {"curve": 6, "straight": 4}, False), @@ -31,31 +42,129 @@ ] +@dataclass +class Case: + name: str + base: Layout + inventory: dict + reversing: bool = False + slop: float = 0.0 + ends: dict | None = None + suite: str = "exact" + + +def cases(catalog): + """Fixed inputs shared by the timing runner and correctness regressions.""" + result = [] + for name, chain, inventory, reversing in CASES: + base = build_chain([(catalog[pid], 0, 1) for pid in chain]) + ends = dict(grow_from=(0, 1), close_onto=(0, 0)) if chain[0] == "switch" else {} + result.append(Case(name, base, inventory, reversing, ends=ends)) + # The same input with play enabled should still find its exact closures. + if name in {"half_circle", "bridge_full", "mixed_full", "switch_full", "long_gap"}: + for slop in (1.0, 5.0): + result.append(Case(f"{name}_slop_{slop:g}", base, inventory, reversing, + slop, ends, "slippage")) + + # Two disconnected halves of a circle: the selected ends differ by a 3-4-5 mm + # translation. There are no pre-existing forced links to contaminate gap totals. + first = build_chain([(catalog["curve"], 0, 1)] * 3) + second = build_chain([(catalog["curve"], 0, 1)] * 3, + start=first.pose_of((2, 1)).then(3, 4, 0, 0)) + base = Layout(first.placements + second.placements, + {**first.links, **{(i + 3, p): (j + 3, q) + for (i, p), (j, q) in second.links.items()}}) + for slop in (4.9, 5.0, 10.0): + result.append(Case(f"offset_circle_slop_{slop:g}", base, + {"curve": 6, "straight": 4}, slop=slop, + ends=dict(grow_from=(5, 1), close_onto=(0, 0)), suite="slippage")) + result.append(Case("offset_full_slop_5", base, dict(CASES[5][2]), reversing=True, + slop=5, ends=dict(grow_from=(5, 1), close_onto=(0, 0)), suite="slippage")) + first = build_chain([(catalog["straight"], 0, 1)] * 3) + second = build_chain([(catalog[pid], 0, 1) for pid in ("straight", "curve", "curve")], + start=first.pose_of((2, 1)).then(3, 4, 0, 0)) + base = Layout(first.placements + second.placements, + {**first.links, **{(i + 3, p): (j + 3, q) + for (i, p), (j, q) in second.links.items()}}) + result.append(Case("offset_long_slop_5", base, {"curve": 14, "straight": 8}, slop=5, + ends=dict(grow_from=(5, 1), close_onto=(0, 0)), suite="slippage")) + + # Climb through a preplaced junction, spending 1 mm at entry and 2 mm at exit. + catalog["ramp_switch"] = parse_piece({"id": "ramp_switch", "width": 64, "paths": [ + {"segments": [{"type": "ramp", "run": 128, "rise": 64}]}, + {"segments": [{"type": "arc", "radius": 256, "degrees": 30}]}, + ]}) + base, _junction = Layout().with_piece(catalog["ramp_switch"], ORIGIN) + base, left = base.with_piece(catalog["straight"], Pose.make(x=-129)) + base, right = base.with_piece(catalog["straight"], Pose.make(x=130, z=64)) + for slop in (2.9, 3.0): + result.append(Case(f"transit_slop_{slop:g}", base, {}, slop=slop, + ends=dict(grow_from=(left, 1), close_onto=(right, 0)), + suite="slippage")) + + catalog["fine"] = parse_piece({"id": "fine", "paths": [{"segments": [ + {"type": "arc", "radius": "1537/3", "degrees": 15}, + ]}]}) + base = build_chain([(catalog["fine"], 0, 1)] * 20) + result.append(Case("fifteen_degree_slop_1", base, {"fine": 4, "straight": 2}, + slop=1.0, suite="slippage")) + return result + + +def result_digest(result): + """Order-sensitive fingerprint; compare layouts separately in regression tests.""" + values = [(s.signature, s.kind, s.exact, s.gap) for s in result.solutions] + return hashlib.sha256(repr(values).encode()).hexdigest()[:16] + + def main(): parser = argparse.ArgumentParser(description=__doc__) parser.add_argument("--lookahead", type=int, default=SolverConfig().completion_lookahead) parser.add_argument("--max-nodes", type=int, default=25_000) parser.add_argument("--repeats", type=int, default=1, help="report the median of N searches") + parser.add_argument("--suite", choices=("exact", "slippage", "all"), default="all") + parser.add_argument("--case", action="append", help="run only this case (repeatable)") + parser.add_argument("--engine", choices=("auto", "lattice", "field"), default="auto") args = parser.parse_args() if args.repeats < 1: parser.error("--repeats must be positive") catalog = default_catalog() + selected = cases(catalog) + if args.case: + unknown = set(args.case) - {case.name for case in selected} + if unknown: + parser.error(f"unknown cases: {', '.join(sorted(unknown))}") + selected = [case for case in selected + if (args.suite == "all" or case.suite == args.suite) + and (not args.case or case.name in args.case)] + if not selected: + parser.error("no cases selected") print(json.dumps({"source": solver_module.__file__, "lookahead": args.lookahead, "max_nodes": args.max_nodes, "max_pieces": 20, "max_results": 8, - "repeats": args.repeats})) - for name, chain, inventory, reversing in CASES: - base = build_chain([(catalog[pid], 0, 1) for pid in chain]) + "repeats": args.repeats, "engine": args.engine, "suite": args.suite})) + for case in selected: config = SolverConfig(min_pieces=0, max_pieces=20, max_results=8, - max_nodes=args.max_nodes, reversing_loops=reversing, + max_nodes=args.max_nodes, reversing_loops=case.reversing, + slop=case.slop, engine=args.engine, completion_lookahead=args.lookahead) - ends = dict(grow_from=(0, 1), close_onto=(0, 0)) if chain[0] == "switch" else {} times = [] + digests = set() for _ in range(args.repeats): - result = solve(inventory, catalog, config, base=base, **ends) + result = solve(case.inventory, catalog, config, base=case.base, **(case.ends or {})) times.append(result.stats.duration_s) + digests.add(result_digest(result)) + if len(digests) != 1: + raise RuntimeError(f"non-deterministic results for {case.name}") print(json.dumps({ - "case": name, "nodes": result.stats.nodes, "found": len(result.solutions), + "case": case.name, "slop_mm": case.slop, "engine": result.stats.engine, + "inventory": case.inventory, "reversing": case.reversing, + "nodes": result.stats.nodes, "found": len(result.solutions), + "forced": sum(not s.exact for s in result.solutions), + "gaps_mm": [round(s.gap, 9) for s in result.solutions], + "result_digest": next(iter(digests)), "stop": result.stats.stop_reason, "seconds": round(median(times), 4), + "seconds_min": round(min(times), 4), "seconds_max": round(max(times), 4), + "pruned_completion": result.stats.pruned_completion, "max_pieces_searched": result.stats.max_pieces_searched, "completion_work": getattr(result.stats, "completion_work", None), "completion_bound_depth": getattr(result.stats, "completion_bound_depth", None), diff --git a/docs/performance.md b/docs/performance.md index 6b869db..713bc7e 100644 --- a/docs/performance.md +++ b/docs/performance.md @@ -194,3 +194,66 @@ The output includes `completion_checks` and `completion_cache_hits`. Regression tests in `tests/test_completion_reuse.py` require identical enumeration and search counters with caching disabled, bound the cache, verify all twelve rotations, and exercise successful cleanup, callback errors, and catalogue isolation. + +## Slippage completion after PR #12 + +Previously, any positive slop disabled all completion tables and linear bounds. +Slippage now uses outward physical intervals, widening each direction by the +remaining total gap budget. Complete short tables have an index of physical boxes +by heading and x position, with a Euclidean lower-bound distance test. Exact +heading/height constraints, cumulative joint gaps and the final collision audit +are unchanged. Geometry and future-target caches include the remaining slop; +indexes and query caches are released after each search, including callback errors. +Preprocessing retains the existing `min(4096, max_nodes // 8)` expansion cap. + +The benchmark now contains the original nine exact cases plus **18 slippage +cases**: 1/5 mm budgets on several inventories, 3-4-5 mm offset endpoints, a 4.9 mm +near miss, broad and long forced closures, a climbing transit spending 1+2 mm, +and a fractional-radius 15° piece. Rows report settings, exact/forced result counts, +ordered result fingerprints, individual gaps, stop reasons, nodes, preprocessing, +and median/minimum/maximum timings. Repeated results must have identical fingerprints. + +These are local Python 3.12 three-run medians against `3107f25`, using 20 added +pieces, eight results and 25,000 DFS nodes. A node-limit row reports 25,001 because +the existing counter records the visit that detects the limit. + +| Case | Before nodes | After nodes | Before time | After time | Results before / after | +| --- | ---: | ---: | ---: | ---: | ---: | +| Half circle, 1 mm | 1,007 | 82 | 23.4 ms | 13.9 ms | 3 / 3 | +| Bridge, broad inventory, 5 mm | 25,001 | 514 | 866.3 ms | 49.5 ms | 0 / 8 | +| Long gap, 5 mm | 25,001 | 370 | 817.9 ms | 46.2 ms | 4 / 8 | +| Offset circle, insufficient 4.9 mm | 993 | 33 | 20.5 ms | 6.6 ms | 0 / 0 | +| Offset circle, 5 mm | 1,002 | 83 | 22.6 ms | 13.6 ms | 3 / 3 forced | +| Offset circle, broad inventory, 5 mm | 25,001 | 286 | 709.1 ms | 28.3 ms | 0 / 8 forced | +| Offset long gap, 5 mm | 25,001 | 295 | 818.9 ms | 41.4 ms | 5 / 8 forced | +| Switch, broad inventory, 5 mm | 25,001 | 19,899 | 738.5 ms | 1,875.5 ms | 0 / 8 | +| Mixed gap, broad inventory, 5 mm | 25,001 | 25,001 | 724.1 ms | 1,821.1 ms | 0 / 0 | +| Fractional 15° piece, 1 mm | 118 | 19 | 18.8 ms | 95.1 ms | 1 / 1 | + +The broad and long offset cases take about **25× and 20× less time**, respectively, +while finding more forced fits. All nine exact cases retain their ordered result +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 +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 +are kept in the benchmark rather than excluded from the measurements. + +Reproduce or select cases with: + +```sh +PYTHONPATH=src python benchmarks/completion.py --suite slippage --repeats 3 +PYTHONPATH=src python benchmarks/completion.py --case offset_long_slop_5 --lookahead 0 +PYTHONPATH=src python benchmarks/completion.py --case mixed_full_slop_5 --max-nodes 60000 +PYTHONPATH=src python benchmarks/completion.py --case offset_circle_slop_5 --engine field +``` + +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. diff --git a/docs/search-correctness.md b/docs/search-correctness.md index 16482e1..78a2161 100644 --- a/docs/search-correctness.md +++ b/docs/search-correctness.md @@ -94,8 +94,8 @@ cannot prune a branch while a future junction could supply another target. A regression pins a valid teardrop even when the selected original target is 100 m away; the previous endpoint-only bound incorrectly rejected it. -All slop searches bypass the exact tables. Both projections share the same -preprocessing budget, and a layer is published only when both are complete. +Both projections share the same preprocessing budget, and a layer is published +only when both are complete. Slop queries use the physical enclosures below. Partial layers never reject a candidate: a cap falls back to DFS without changing completeness or stop reasons. `completion_work` records the actual expansions. @@ -123,7 +123,7 @@ The general field engine similarly uses rational forms on its exact coefficients including all four height coefficients. No floating tolerance enters these bounds. All routes, free-transit allowances and present/future targets use the same -conservative rules as the short tables. Slop bypasses both checks. Both share the +conservative rules as the short tables. Both share the existing `min(4096, max_nodes // 8)` preprocessing cap, and neither publishes an unfinished layer. Stable zero-motion envelopes are reused at every greater depth, so an empty move pool and a huge inventory cannot allocate endless identical @@ -135,10 +135,51 @@ 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. +## Slippage uses physical distances and one remaining budget + +A small physical displacement need not have small exact coefficients: large +rational and radical terms can nearly cancel. Expanding the coefficient bounds +by a number of millimetres would therefore discard valid forced fits. Slippage +instead uses physical x/y projections, with eight directions bounding longer +tails. Each direction is widened by its Euclidean norm times the remaining slop. +Height coefficients and headings retain their exact constraints. + +Physical projections are enclosed by integer intervals at `10**9` units per mm. +Integer square roots give rational lower/upper bounds on sqrt(2), sqrt(3) and +sqrt(6). Multiplication by signed coefficients, outward division and interval +addition preserve containment. An additional outward relative margin covers +floating distance evaluation at large translated coordinates. Rounding can only +increase the search space; these intervals never decide whether a solution is +exact. The final joint checks and independent collision audit remain authoritative. + +Short layers retain their exact poses. A lazily built index groups their physical +boxes by heading and sorts them by minimum x. Binary searches restrict candidate +boxes; the minimum box-to-box Euclidean distance must fit the remaining budget. +The maximum box width is included in the binary-search window, so overlapping or +unusually wide intervals cannot be skipped. Indexes exist only for complete layers, +with at most `(completion_lookahead + 1) * (completion_work + 1)` box entries. + +Every forced transit translates the remaining path without changing its heading +or height. By the triangle inequality the accumulated displacement is at most +the sum of those joint gaps. The bound therefore uses `slop - slack_used`, once +for the entire tail, including the final joint. Rigid retargeting preserves that +budget for existing and future reversing targets. Both geometry and future-target +cache keys include the remaining budget, preventing an answer for one allowance +from being reused for another. The indexes are cleared together with the query +cache when a search finishes or its progress callback raises. + +`tests/test_completion_slippage.py` compares complete ordered solutions against +lookahead-disabled searches on both engines, including 3-4-5 mm offset endpoints, +1+2 mm intermediate/final gaps, forced reversing targets and large cancelling +coefficients. Independent perturbed tails exercise the longer physical bounds. +Additional cases cover 15° geometry, incomplete preprocessing, finite huge slop, +cache limits and audited benchmark results. Browser coverage previews and applies +a forced closure through the real Pyodide worker under the production CSP. + ## Reused geometric proofs are independent of search state The completion cache keys contain the full exact cursor pose, including height -and heading, and the remaining traversal bound. The anchor and move pool are +and heading, the remaining traversal bound, and any remaining slop. The anchor and move pool are fixed within one solve. Published reachability layers never change, so both positive and negative answers from complete layers can be reused. A permissive answer from an unfinished layer also remains valid: the next layer already @@ -147,8 +188,7 @@ exceeds the remaining preprocessing budget, which can only decrease. Geometric cache entries contain no stock, free ports, collision decisions or candidate layouts. Each DFS node computes its current transit allowances and reversing targets once; its child visits compute their own values after consuming stock and ports. The -parent's values remain valid when backtracking restores its state. Slop searches -continue to bypass these exact checks. +parent's values remain valid when backtracking restores its state. The 4096-entry LRU belongs to one solve and is explicitly emptied on normal return or a traversal exception. This avoids retaining its poses through the recursive diff --git a/src/duplotrain/solver.py b/src/duplotrain/solver.py index 7d309f7..7d01869 100644 --- a/src/duplotrain/solver.py +++ b/src/duplotrain/solver.py @@ -23,7 +23,7 @@ * turn feasibility -- the remaining pieces (plus open stubs) must be able to swing the heading back to the anchor's; * reach -- the remaining pieces must be long enough to get home; - * exact completion reachability -- short tails must reach the target on the grid; + * completion reachability -- tails must reach the target within the remaining slop; * collisions -- a placement overlapping existing track is cut immediately. """ @@ -31,6 +31,7 @@ import math import time +from bisect import bisect_left, bisect_right from collections import OrderedDict from collections.abc import Mapping, Sequence from dataclasses import dataclass @@ -562,28 +563,64 @@ def apply(cursor: tuple) -> tuple: # -------------------------------------------------------------------------------------- -# Exact short-tail reachability, followed by step traces and their signatures +# Bounded completion reachability, followed by step traces and their signatures # -------------------------------------------------------------------------------------- +# Outward rational enclosures of physical millimetres. Unlike the exact search's +# coefficient projections, these remain valid when a tiny real gap has large, +# cancelling radical coefficients. Integer arithmetic keeps rounding one-sided. +_MM_SCALE = 10**9 +_ROOT_BOUNDS = tuple((math.isqrt(n * _MM_SCALE**2), math.isqrt(n * _MM_SCALE**2) + 1) + for n in (2, 3, 6)) +_SLIP_AXES = ((1, 0), (0, 1), (1, 1), (1, -1), (2, 1), (2, -1), (1, 2), (1, -2)) +_SLIP_NORMS = tuple(math.isqrt((a * a + b * b) * _MM_SCALE**2) + 1 + for a, b in _SLIP_AXES) + + +def _outward(low, high) -> tuple[int, int]: + low, high = math.floor(low), math.ceil(high) + # Also cover the solver's floating distance evaluation at translated layouts. + # This deliberately loose relative margin can only admit extra DFS work. + guard = (abs(low) + abs(high)) // 2**40 + 2 + return low - guard, high + guard + + +def _alg_interval(value: Alg) -> tuple[int, int]: + low = high = value.a * _MM_SCALE + for coefficient, (a, b) in zip(value.coeffs()[1:], _ROOT_BOUNDS, strict=True): + low += coefficient * (a if coefficient >= 0 else b) + high += coefficient * (b if coefficient >= 0 else a) + return _outward(low, high) + + +def _physical_envelope(x, y, height) -> tuple[tuple, tuple]: + return (tuple(a * x[0] + b * y[0 if b >= 0 else 1] for a, b in _SLIP_AXES) + height, + tuple(a * x[1] + b * y[1 if b >= 0 else 0] for a, b in _SLIP_AXES) + height) + + class _CompletionBounds: - """Exact linear envelopes, indexed by heading and maximum tail length. + """Conservative linear envelopes, indexed by heading and maximum tail length. A move adds a fixed displacement at a given heading. Minimum/maximum linear projections therefore compose without enumerating positions: translate every interval, then take the union's bounds. Intervals may describe different walks, - so membership is only necessary. This stays useful beyond the short exact table. + so membership is only necessary. Exact searches project coefficients; slippage + searches enclose physical distances. Both remain useful beyond the short table. """ - def __init__(self, eng) -> None: + def __init__(self, eng, *, slippage: bool = False) -> None: if eng.name == "lattice": self.project = self._lattice_projection + physical = self._lattice_envelope self.heading = lambda pose: pose[5] zeros = [(0, 0, 0, 0, 0, heading) for heading in range(12)] else: self.project = self._field_projection + physical = self._field_envelope self.heading = lambda pose: pose.heading zeros = [Pose.make(heading=heading) for heading in range(HEADING_STEPS)] + self.enclose = physical if slippage else lambda pose: (self.project(pose),) * 2 # Include full 3D deltas and every preplaced route, not only spare pieces. moves = {apply_move(zeros[0]): apply_move for routes in eng.moves.values() for _entry, _exit, apply_move in routes} @@ -591,12 +628,28 @@ def __init__(self, eng) -> None: for zero in zeros: predecessors = (eng.reverse(move(eng.reverse(zero))) for move in moves.values()) self.deltas.append(tuple({ - (self.heading(pose), self.project(pose)) for pose in predecessors + (self.heading(pose), *self.enclose(pose)) for pose in predecessors })) - coords = self.project(eng.anchor) - self.layers = [{self.heading(eng.anchor): (coords, coords)}] + self.layers = [{self.heading(eng.anchor): self.enclose(eng.anchor)}] self.saturated = False + @staticmethod + def _lattice_envelope(pose) -> tuple[tuple, tuple]: + a, b, c, d, z, _heading = pose + root_low, root_high = _ROOT_BOUNDS[1] + + def coordinate(rational, radical): + low = rational * _MM_SCALE + radical * (root_low if radical >= 0 else root_high) + high = rational * _MM_SCALE + radical * (root_high if radical >= 0 else root_low) + # Avoid float division, including for very large integer coefficients. + return _outward(low // 40, -(-high // 40)) + + return _physical_envelope(coordinate(2 * a + c, b), coordinate(2 * d + b, c), (z,)) + + @staticmethod + def _field_envelope(pose: Pose) -> tuple[tuple, tuple]: + return _physical_envelope(_alg_interval(pose.x), _alg_interval(pose.y), pose.z.coeffs()) + @staticmethod def _lattice_projection(pose) -> tuple: a, b, c, d, _z, _heading = pose @@ -627,9 +680,9 @@ def extend(self, traversals: int, max_work: int) -> int: spent += work layer = dict(previous) # at most k traversals also includes k - 1 for heading, (low, high) in previous.items(): - for next_heading, delta in self.deltas[heading]: - next_low = tuple(a + b for a, b in zip(low, delta, strict=True)) - next_high = tuple(a + b for a, b in zip(high, delta, strict=True)) + for next_heading, delta_low, delta_high in self.deltas[heading]: + next_low = tuple(a + b for a, b in zip(low, delta_low, strict=True)) + next_high = tuple(a + b for a, b in zip(high, delta_high, strict=True)) if next_heading in layer: old_low, old_high = layer[next_heading] next_low = tuple(map(min, old_low, next_low)) @@ -654,6 +707,18 @@ def allows(self, cursor, traversals: int) -> bool: for low, value, high in zip(interval[0], self.project(cursor), interval[1], strict=True) ) + def allows_near(self, cursor, traversals: int, slack: int) -> bool: + if traversals >= len(self.layers) and not self.saturated: + return True + interval = self.layers[min(traversals, len(self.layers) - 1)].get(self.heading(cursor)) + if interval is None: + return False + low, high = self.enclose(cursor) + padding = tuple(-(-slack * norm // _MM_SCALE) for norm in _SLIP_NORMS) + padding += (0,) * (len(low) - len(_SLIP_AXES)) + return all(a - pad <= d and c <= b + pad + for a, b, c, d, pad in zip(*interval, low, high, padding, strict=True)) + class _CompletionReachability: """Bounded reverse reachability for planar poses and heights independently. @@ -672,7 +737,7 @@ class _CompletionReachability: the completed shorter layers and let DFS handle the rest normally. """ - def __init__(self, eng, horizon: int, max_work: int) -> None: + def __init__(self, eng, horizon: int, max_work: int, *, slippage: bool = False) -> None: self.eng = eng self.horizon = horizon self.work_left = max_work @@ -691,33 +756,44 @@ def __init__(self, eng, horizon: int, max_work: int) -> None: self.frontier = self.layers[0] self.height_layers = [frozenset((eng.height(eng.anchor),))] self.height_frontier = self.height_layers[0] - self.bounds = _CompletionBounds(eng) + self.bounds = _CompletionBounds(eng, slippage=slippage) + self.near_indices: dict[int, dict] = {} # Geometry-only answers are independent of stock, stubs, and collisions. # Keep this cache on the search object; keys contain only immutable poses. self.cache: OrderedDict[tuple, bool] = OrderedDict() self.cache_hits = 0 self.checks = 0 - def allows(self, cursor, traversals: int) -> bool: + def allows(self, cursor, traversals: int, slack: float | None = None) -> bool: # Published layers never change. An unfinished depth also stays permissive: # its next layer already exceeds the remaining budget, which only decreases. # Reusing either answer cannot change later preprocessing or pruning. - key = (cursor, traversals) + key = (cursor, traversals) if slack is None else (cursor, traversals, slack) result = self.cache.get(key) if result is not None: self.cache_hits += 1 self.cache.move_to_end(key) return result - result = self._allows(cursor, traversals) + result = self._allows(cursor, traversals, slack) if len(self.cache) >= 4096: self.cache.popitem(last=False) self.cache[key] = result return result - def _allows(self, cursor, traversals: int) -> bool: + def _allows(self, cursor, traversals: int, slack: float | None = None) -> bool: self.checks += 1 self.work_left -= self.bounds.extend(traversals, self.work_left) - if not self.bounds.allows(cursor, traversals): + # One accumulated budget covers every remaining forced transit and the + # final joint. Translations add; heading and height never acquire tolerance. + padding = None + if slack is not None: + numerator, denominator = slack.as_integer_ratio() + padding = -(-numerator * _MM_SCALE // denominator) + 2 + if padding is None: + possible = self.bounds.allows(cursor, traversals) + else: + possible = self.bounds.allows_near(cursor, traversals, padding) + if not possible: return False if traversals > self.horizon: return True @@ -743,8 +819,47 @@ def _allows(self, cursor, traversals: int) -> bool: self.height_frontier = height_frontier self.layers.append(previous | frontier) self.height_layers.append(previous_heights | height_frontier) - return (self.eng.level(cursor) in self.layers[traversals] - and self.eng.height(cursor) in self.height_layers[traversals]) + if self.eng.height(cursor) not in self.height_layers[traversals]: + return False + if padding is None: + return self.eng.level(cursor) in self.layers[traversals] + return self._near_layer(cursor, traversals, padding) + + def _near_layer(self, cursor, traversals: int, slack: int) -> bool: + """Query a complete short layer by physical bounding boxes, not coefficients. + + Sorted x intervals avoid scanning the whole layer for every candidate. + Box-to-box distance bounds the Euclidean gap from below. The DFS still + decides the actual fit and audits all of its links and collisions. + """ + index = self.near_indices.get(traversals) + if index is None: + grouped: dict[int, list] = {} + for pose in self.layers[traversals]: + low, high = self.bounds.enclose(pose) + grouped.setdefault(self.bounds.heading(pose), []).append( + (low[0], high[0], low[1], high[1])) + index = {} + for heading, boxes in grouped.items(): + boxes.sort() + index[heading] = (boxes, tuple(box[0] for box in boxes), + max(box[1] - box[0] for box in boxes)) + self.near_indices[traversals] = index + group = index.get(self.bounds.heading(cursor)) + if group is None: + return False + boxes, starts, width = group + low, high = self.bounds.enclose(cursor) + left, right = low[0] - slack, high[0] + slack + bottom, top = low[1] - slack, high[1] + slack + for i in range(bisect_left(starts, left - width), bisect_right(starts, right)): + box = boxes[i] + if box[1] >= left and box[2] <= top and box[3] >= bottom: + dx = max(0, box[0] - high[0], low[0] - box[1]) + dy = max(0, box[2] - high[1], low[1] - box[3]) + if dx * dx + dy * dy <= slack * slack: + return True + return False @dataclass(frozen=True, slots=True) @@ -977,10 +1092,10 @@ class SolverConfig: #: problem fits the 30-degree grid (every built-in piece does) and falls back to #: the general field otherwise; "lattice"/"field" force one, for tests. engine: str = "auto" - #: Exact reverse reachability for this many final traversals in completion + #: Reverse reachability for this many final traversals in completion #: mode, supplemented by longer linear bounds. Zero disables both; they share #: a preprocessing cap of 4096 moves (and at most max_nodes // 8). Slop fits - #: bypass both exact checks. + #: use physical distance enclosures with the remaining total gap budget. completion_lookahead: int = 6 def __post_init__(self) -> None: @@ -1235,8 +1350,9 @@ def placement_groups( } stats.engine = eng.name completion = ( - _CompletionReachability(eng, cfg.completion_lookahead, min(4096, cfg.max_nodes // 8)) - if base is not None and cfg.slop == 0 and cfg.completion_lookahead + _CompletionReachability(eng, cfg.completion_lookahead, min(4096, cfg.max_nodes // 8), + slippage=cfg.slop > 0) + if base is not None and cfg.completion_lookahead else None ) stub_capacity = { @@ -1318,7 +1434,7 @@ def eligible(used: int) -> bool: # These queries describe a future junction's own geometry, independent of the # growing path. Cap the per-search cache now that bounds can check longer tails. - future_closure: dict[tuple[str, int], bool] = {} + future_closure: dict[tuple, bool] = {} def tail_context(): # All candidate moves at this DFS node share these allowances and targets. @@ -1339,7 +1455,8 @@ def tail_context(): future_targets = tuple(pid for pid in available if reversing_queries[pid]) return transits, capacity, future, targets, future_targets - def tail_possible(cursor, used: int, context, extra_pid: str | None = None) -> bool: + def tail_possible(cursor, used: int, context, slack: float | None, + extra_pid: str | None = None) -> bool: if completion is None: return True slots = min(total_pieces, depth_limit, f_limit) - used @@ -1352,12 +1469,12 @@ def tail_possible(cursor, used: int, context, extra_pid: str | None = None) -> b transits += future_transits[extra_pid] transits += min(slots * capacity, future) traversals = slots + transits - if completion.allows(cursor, traversals): + if 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): + if completion.allows(query, traversals, slack): return True if slots: # A future reversing target is created by one placement. Whatever @@ -1365,10 +1482,10 @@ def tail_possible(cursor, used: int, context, extra_pid: str | None = None) -> b # in at most the remaining traversals. Ignore all stock/geometry # constraints here, retaining an overapproximation of every target. for pid in future_targets: - key = (pid, traversals - 1) + key = (pid, traversals - 1, slack) possible = future_closure.get(key) if possible is None: - possible = any(completion.allows(query, traversals - 1) + possible = any(completion.allows(query, traversals - 1, slack) for query in reversing_queries[pid]) if len(future_closure) < 4096: future_closure[key] = possible @@ -1502,7 +1619,8 @@ def closing_link_legal() -> bool: return True context = tail_context() if completion is not None else None - if not tail_possible(cursor, used, context): + query_slack = slack_left if cfg.slop > 0 else None + if not tail_possible(cursor, used, context, query_slack): stats.pruned_completion += 1 return True @@ -1588,7 +1706,7 @@ def closing_link_legal() -> bool: # Reject an impossible endpoint before sampling collision geometry or # spending a DFS node. Leaving this piece in counts only enlarges the # reachability bound, so this early check remains conservative. - if not tail_possible(next_cursor, used + 1, context, pid): + if not tail_possible(next_cursor, used + 1, context, query_slack, pid): stats.pruned_completion += 1 continue piece = pieces[pid] @@ -1690,6 +1808,7 @@ def closing_link_legal() -> bool: # DFS has recursive closure references; release cached poses promptly, # including on progress-callback errors, without waiting for cyclic GC. completion.cache.clear() + completion.near_indices.clear() if stats.aborted: stats.stop_reason = "node_limit" elif len(solutions) >= cfg.max_results: diff --git a/tests/browser/test_editor.py b/tests/browser/test_editor.py index a54573c..4efd130 100644 --- a/tests/browser/test_editor.py +++ b/tests/browser/test_editor.py @@ -356,6 +356,7 @@ def export_layout(): "buffer": json.dumps(layout_to_dict(bridge)).encode(), }) expect(page.locator("#status")).to_contain_text("7 pieces") + page.locator("#slop").fill("1") page.locator("#solve").tap() candidate = page.locator(".cand").first expect(candidate).to_be_visible(timeout=30000) @@ -387,6 +388,39 @@ def export_layout(): closed = export_layout() assert len(closed["placements"]) == 20 and len(closed["links"]) == 20 assert closed["placements"][:6] == layout_to_dict(long_gap)["placements"] + + # A connected imported base already has one forced joint. The new closing + # joint needs another 5 mm; preview/apply must keep both gaps visible. + from benchmarks.completion import cases + + offset = next(case for case in cases(catalog) if case.name == "offset_circle_slop_5") + forced_base = offset.base.join((2, 1), (3, 0), force=True) + page.locator("#importfile").set_input_files({ + "name": "offset-circle.json", "mimeType": "application/json", + "buffer": json.dumps(layout_to_dict(forced_base)).encode(), + }) + expect(page.locator("#status")).to_contain_text("not exactly closed") + for pid, count, remaining in (("curve", "12", "6/"), ("straight", "4", "4/")): + control = page.locator(f'[data-piece-id="{pid}"] input') + control.fill(count) + control.press("Tab") + expect(page.locator(f'[data-piece-id="{pid}"] .count')).to_have_text(remaining) + page.locator("#slop").fill("5") + page.locator("#solve").tap() + expect(page.locator(".cand")).to_have_count(3, timeout=30000) + candidate = page.locator(".cand").first + expect(candidate).to_contain_text("forced 5") + candidate.get_by_role("button", name="Preview", exact=True).tap() + candidate.get_by_role("button", name="Apply").tap() + expect(page.locator("#status")).to_contain_text("Forced fit") + forced = export_layout() + assert len(forced["placements"]) == 12 and len(forced["links"]) == 12 + assert forced["placements"][:6] == layout_to_dict(forced_base)["placements"] + from duplotrain.layout import layout_from_dict + + issues = layout_from_dict(forced, default_catalog()).joint_issues() + assert len(issues) == 2 + assert sum(joint["gap_mm"] for joint in issues) == pytest.approx(10) assert not errors finally: context.close() diff --git a/tests/test_completion_lookahead.py b/tests/test_completion_lookahead.py index d99a51e..b22ef2c 100644 --- a/tests/test_completion_lookahead.py +++ b/tests/test_completion_lookahead.py @@ -58,7 +58,7 @@ def test_incomplete_reverse_layer_falls_back_to_full_search(monkeypatch): original = solver._CompletionReachability monkeypatch.setattr(solver, "_CompletionReachability", - lambda eng, horizon, max_work: original(eng, horizon, 1)) + lambda eng, horizon, max_work, **kw: original(eng, horizon, 1, **kw)) catalog = default_catalog() base = build_chain([(catalog["curve"], 0, 1)] * 6) cfg = SolverConfig(min_pieces=0) @@ -97,7 +97,7 @@ def test_exact_lookahead_does_not_discard_forced_fits(engine): assert fast.stats.complete and signatures(fast) == signatures(plain) assert len(fast.solutions) == 1 assert fast.solutions[0].gap == 1 and not fast.solutions[0].exact - assert fast.stats.completion_states == 0 + assert fast.stats.completion_states > 0 def test_editor_can_apply_a_completion_from_the_improved_search(): diff --git a/tests/test_completion_reuse.py b/tests/test_completion_reuse.py index b90db9d..149c691 100644 --- a/tests/test_completion_reuse.py +++ b/tests/test_completion_reuse.py @@ -142,13 +142,14 @@ def test_direct_target_rotations_match_exact_lattice_arithmetic_on_every_basis() @pytest.mark.parametrize("interrupt", [False, True]) -def test_solver_releases_cached_poses_on_success_and_callback_failure(monkeypatch, interrupt): +@pytest.mark.parametrize("slop", [0.0, 5.0]) +def test_solver_releases_cached_poses_on_success_and_callback_failure(monkeypatch, interrupt, slop): import duplotrain.solver as solver tables = [] - def capture(*args): - table = _CompletionReachability(*args) + def capture(*args, **kwargs): + table = _CompletionReachability(*args, **kwargs) tables.append(table) return table @@ -160,8 +161,9 @@ def progress(_nodes): base = build_chain([(catalog["straight"], 0, 1)] * 2 + [(catalog["curve"], 0, 1)] * 4) inventory = {"curve": 20, "straight": 6, "ramp": 2, "span": 2, "switch": 2, "crossing": 1, "slope": 2} - config = SolverConfig(min_pieces=0, max_pieces=20, max_results=8, max_nodes=25_000, - reversing_loops=True, progress=progress if interrupt else None) + config = SolverConfig(min_pieces=0, max_pieces=20, max_results=8, max_nodes=60_000, + slop=slop, reversing_loops=True, + progress=progress if interrupt else None) if interrupt: with pytest.raises(RuntimeError, match="cancelled by caller"): solve(inventory, catalog, config, base=base) @@ -169,3 +171,4 @@ def progress(_nodes): assert len(solve(inventory, catalog, config, base=base).solutions) == 8 assert len(tables) == 1 and tables[0].cache_hits > 0 assert not tables[0].cache + assert not tables[0].near_indices diff --git a/tests/test_completion_slippage.py b/tests/test_completion_slippage.py new file mode 100644 index 0000000..334e347 --- /dev/null +++ b/tests/test_completion_slippage.py @@ -0,0 +1,217 @@ +"""Slippage pruning must preserve actual forced joints, budgets, and exact geometry.""" + +import random +from dataclasses import replace + +import pytest + +from benchmarks.completion import cases +from duplotrain import ORIGIN, Layout, Pose, SolverConfig, default_catalog, solve +from duplotrain.exact import Alg +from duplotrain.solver import ( + _MM_SCALE, + _compile_lattice, + _CompletionBounds, + _CompletionReachability, + _FieldEngine, + _flat, + _moves_for, + _pose_to_lattice, + _solution_overlaps, +) + + +@pytest.fixture(scope="module") +def inputs(): + catalog = default_catalog() + return catalog, {case.name: case for case in cases(catalog)} + + +def engine_for(catalog, engine, anchor=ORIGIN): + moves = {pid: _moves_for(piece) for pid, piece in catalog.items()} + if engine == "lattice": + eng = _compile_lattice(anchor, anchor, catalog, moves) + + def convert(pose): + return _flat(_pose_to_lattice(pose)) + else: + eng = _FieldEngine(anchor, anchor, catalog, moves) + + def convert(pose): + return pose + return eng, convert, moves + + +@pytest.mark.parametrize("engine", ["lattice", "field"]) +@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", +]) +def test_exhaustive_slippage_results_match_unpruned_search(inputs, engine, name): + catalog, examples = inputs + case = examples[name] + cfg = SolverConfig(min_pieces=0, max_pieces=20, max_results=1000, max_nodes=100_000, + slop=case.slop, engine=engine) + options = dict(base=case.base, **(case.ends or {})) + reference = solve(case.inventory, catalog, replace(cfg, completion_lookahead=0), **options) + improved = solve(case.inventory, catalog, cfg, **options) + assert reference.stats.complete and improved.stats.complete + # Includes layouts, enumeration order, gaps and exact flags. + assert improved.solutions == reference.solutions + assert improved.stats.nodes <= reference.stats.nodes + for solution in improved.solutions: + assert solution.gap <= case.slop + 1e-12 + assert solution.layout.placements[:len(case.base)] == case.base.placements + assert not _solution_overlaps(solution.layout, len(case.base), 120, 8) + if name == "offset_circle_slop_5": + assert len(improved.solutions) == 3 + assert all(not s.exact and s.gap == pytest.approx(5) for s in improved.solutions) + assert improved.stats.nodes < reference.stats.nodes // 5 + elif name == "transit_slop_3": + assert len(improved.solutions) == 1 + 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"): + assert not improved.solutions + + +def test_fractional_fifteen_degree_slippage_retains_its_completion(inputs): + catalog, examples = inputs + case = examples["fifteen_degree_slop_1"] + cfg = SolverConfig(min_pieces=0, slop=1) + reference = solve(case.inventory, catalog, replace(cfg, completion_lookahead=0), base=case.base) + improved = solve(case.inventory, catalog, cfg, base=case.base) + assert reference.stats.complete and improved.stats.complete + assert improved.stats.engine == "field" and improved.solutions == reference.solutions + assert len(improved.solutions) == 1 + + +@pytest.mark.parametrize("name", [ + "bridge_full_slop_1", "switch_full_slop_5", "long_gap_slop_5", + "offset_full_slop_5", "offset_long_slop_5", +]) +def test_slippage_benchmarks_find_more_audited_results_with_same_budget(inputs, name): + catalog, examples = inputs + case = examples[name] + cfg = SolverConfig(min_pieces=0, slop=case.slop, max_pieces=20, + max_results=8, max_nodes=25_000, reversing_loops=case.reversing) + options = dict(base=case.base, **(case.ends or {})) + reference = solve(case.inventory, catalog, replace(cfg, completion_lookahead=0), **options) + improved = solve(case.inventory, catalog, cfg, **options) + assert reference.stats.aborted and len(improved.solutions) == 8 + assert not improved.stats.aborted and improved.stats.nodes < reference.stats.nodes + assert improved.solutions[:len(reference.solutions)] == reference.solutions + assert improved.stats.completion_work <= min(4096, cfg.max_nodes // 8) + for solution in improved.solutions: + assert not _solution_overlaps(solution.layout, len(case.base), 120, 8) + + +@pytest.mark.parametrize("engine", ["lattice", "field"]) +def test_near_cache_includes_remaining_slack_height_heading_and_depth(engine): + catalog = {"straight": default_catalog()["straight"]} + eng, convert, _moves = engine_for(catalog, engine) + table = _CompletionReachability(eng, 6, 4096, slippage=True) + query = convert(Pose.make(x=-133)) + assert not table.allows(query, 1, 4.9) + assert table.allows(query, 1, 5.0) + assert not table.allows(query, 0, 5.0) + assert not table.allows(convert(Pose.make(x=-133, z=1)), 1, 1000) + assert not table.allows(convert(Pose.make(x=-133, heading=2)), 1, 1000) + before = table.cache_hits + assert table.allows(query, 1, 5.0) + assert not table.allows(query, 1, 4.9) + assert table.cache_hits == before + 2 + + +@pytest.mark.parametrize("engine", ["lattice", "field"]) +def test_small_physical_gap_with_large_cancelling_coefficients_is_not_pruned(engine): + catalog = default_catalog() + # A Pell approximation of sqrt(3): huge exact coefficients but a sub-micron gap. + offset = Alg(3650401, 0, -2107560) + assert abs(float(offset)) < 1e-3 + base, left = Layout().with_piece(catalog["straight"], ORIGIN) + base, right = base.with_piece(catalog["straight"], Pose(256 + offset, 0, 0, 0)) + cfg = SolverConfig(min_pieces=1, slop=1e-3, engine=engine) + options = dict(base=base, grow_from=(left, 1), close_onto=(right, 0)) + reference = solve({"straight": 1}, catalog, replace(cfg, completion_lookahead=0), **options) + improved = solve({"straight": 1}, catalog, cfg, **options) + assert len(improved.solutions) == 1 and improved.solutions == reference.solutions + assert not improved.solutions[0].exact + + +@pytest.mark.parametrize("engine", ["lattice", "field"]) +def test_physical_envelopes_contain_independent_perturbed_tails(engine): + catalog = default_catalog() + anchor = Pose.make(x=317, y=-290, z=77, heading=4 if engine == "lattice" else 7) + eng, convert, moves = engine_for(catalog, engine, anchor) + bounds = _CompletionBounds(eng, slippage=True) + assert 0 < bounds.extend(12, 4096) <= 4096 + rng = random.Random(6152) + pool = [move for routes in moves.values() for move in routes] + for length in range(13): + for _ in range(4): + tail = rng.choices(pool, k=length) + delta = ORIGIN + for move in tail: + delta = delta.then(move.dx, move.dy, move.dz, move.dheading) + heading = (anchor.heading - delta.heading) % 24 + offset = Pose.make(heading=heading).then(delta.x, delta.y, delta.z, delta.heading) + # Translating any subset of a tail's joints by vectors summing to (3,4) + # is contained in the five-millimetre total budget by the triangle bound. + cursor = Pose(anchor.x - offset.x + 3, anchor.y - offset.y + 4, + anchor.z - offset.z, heading) + end = cursor + for move in tail: + end = end.then(move.dx, move.dy, move.dz, move.dheading) + assert end == Pose(anchor.x + 3, anchor.y + 4, anchor.z, anchor.heading) + assert bounds.allows_near(convert(cursor), length, 5 * _MM_SCALE) + + +@pytest.mark.parametrize("engine", ["lattice", "field"]) +def test_unfinished_slippage_layers_remain_permissive(engine): + catalog = {"straight": default_catalog()["straight"]} + eng, convert, _moves = engine_for(catalog, engine) + table = _CompletionReachability(eng, 6, 0, slippage=True) + impossible = convert(Pose.make(x=-999, heading=2)) + assert table.allows(impossible, 6, 1) + assert not table.allows(impossible, 0, 1) + assert table.work_left == 0 and len(table.layers) == 1 + + +def test_near_index_uses_euclidean_gap_and_outward_bounds(): + catalog = {"straight": default_catalog()["straight"]} + eng, convert, _moves = engine_for(catalog, "lattice") + table = _CompletionReachability(eng, 6, 4096, slippage=True) + query = convert(Pose.make(x=-131, y=-4)) + assert table.allows(query, 1, 5) + assert not table.allows(query, 1, 4.9) + assert table.near_indices + + +@pytest.mark.parametrize("engine", ["lattice", "field"]) +def test_forced_reversing_target_retains_every_result(engine): + catalog = default_catalog() + base, switch = Layout().with_piece(catalog["switch"], ORIGIN) + branch = base.pose_of((switch, 0)) + base, seed = base.with_piece(catalog["curve"], + catalog["curve"].frame_for(0, branch.then(1, 0, 0, 0))) + cfg = SolverConfig(min_pieces=0, slop=1.0, max_results=1000, engine=engine, + reversing_loops=True) + options = dict(base=base, grow_from=(seed, 1), close_onto=(switch, 1)) + reference = solve({"curve": 10}, catalog, replace(cfg, completion_lookahead=0), **options) + improved = solve({"curve": 10}, catalog, cfg, **options) + assert reference.stats.complete and improved.stats.complete + assert improved.solutions == reference.solutions + assert any(s.kind == "reversing" and not s.exact for s in improved.solutions) + + +def test_finite_large_slack_does_not_overflow_and_near_cache_is_bounded(): + catalog = {"straight": default_catalog()["straight"]} + eng, convert, _moves = engine_for(catalog, "lattice") + table = _CompletionReachability(eng, 6, 0, slippage=True) + for i in range(4100): + assert table.allows(convert(Pose.make(x=i + 1)), 0, 1e308) + assert len(table.cache) == 4096 + assert table.allows(convert(Pose.make(x=1)), 0, 1e308) + assert not table.allows(convert(Pose.make(x=1, z=1)), 0, 1e308) diff --git a/tests/test_completion_targets.py b/tests/test_completion_targets.py index 7c719d2..ca66b21 100644 --- a/tests/test_completion_targets.py +++ b/tests/test_completion_targets.py @@ -51,12 +51,13 @@ def test_six_step_lookahead_reduces_long_gap_work_again(): @pytest.mark.parametrize("engine", ["lattice", "field"]) @pytest.mark.parametrize("branch", [1, 2]) -def test_retargeting_preserves_every_reversing_completion(engine, branch): +@pytest.mark.parametrize("slop", [0.0, 5.0]) +def test_retargeting_preserves_every_reversing_completion(engine, branch, slop): catalog = default_catalog() base = build_chain([(catalog["switch"], 0, branch)], start=Pose.make(x=317, y=-290, z=77, heading=4)) cfg = SolverConfig(min_pieces=0, max_results=1000, max_nodes=100_000, - engine=engine, reversing_loops=True) + engine=engine, reversing_loops=True, slop=slop) options = dict(base=base, grow_from=(0, branch), close_onto=(0, 0)) reference = solve({"curve": 12}, catalog, replace(cfg, completion_lookahead=0), **options) improved = solve({"curve": 12}, catalog, cfg, **options) @@ -68,7 +69,8 @@ def test_retargeting_preserves_every_reversing_completion(engine, branch): @pytest.mark.parametrize("engine", ["lattice", "field"]) -def test_future_switch_can_close_even_when_original_target_is_unreachable(engine): +@pytest.mark.parametrize("slop", [0.0, 5.0]) +def test_future_switch_can_close_even_when_original_target_is_unreachable(engine, slop): catalog = default_catalog() base = build_chain([(catalog["curve"], 0, 1)]) base, far = base.with_piece(catalog["straight"], Pose.make(x=100_000)) @@ -83,7 +85,7 @@ def test_future_switch_can_close_even_when_original_target_is_unreachable(engine assert not _solution_overlaps(witness, len(base), 120, 8) cfg = SolverConfig(min_pieces=0, engine=engine, max_nodes=100_000, - max_results=1000, reversing_loops=True) + max_results=1000, reversing_loops=True, slop=slop) result = solve({"curve": 11, "switch": 1}, catalog, cfg, base=base, grow_from=(0, 1), close_onto=(far, 0)) assert result.stats.complete From c600a0f9de16c087132d2821c220013509e2ce45 Mon Sep 17 00:00:00 2001 From: senegrom <6349874+senegrom@users.noreply.github.com> Date: Sun, 13 Sep 2026 14:02:05 +0100 Subject: [PATCH 2/2] Assert the forced-joint status for an open imported base --- tests/browser/test_editor.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/browser/test_editor.py b/tests/browser/test_editor.py index 4efd130..9d0d5aa 100644 --- a/tests/browser/test_editor.py +++ b/tests/browser/test_editor.py @@ -399,7 +399,7 @@ def export_layout(): "name": "offset-circle.json", "mimeType": "application/json", "buffer": json.dumps(layout_to_dict(forced_base)).encode(), }) - expect(page.locator("#status")).to_contain_text("not exactly closed") + expect(page.locator("#status")).to_contain_text("2 open end(s). Forced fit: 5.000 mm") for pid, count, remaining in (("curve", "12", "6/"), ("straight", "4", "4/")): control = page.locator(f'[data-piece-id="{pid}"] input') control.fill(count)