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
2 changes: 1 addition & 1 deletion .github/workflows/app-check.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
10 changes: 8 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -348,15 +348,21 @@ 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;
`stats.completion_checks` and `stats.completion_cache_hits` report evaluated and
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
Expand Down
125 changes: 117 additions & 8 deletions benchmarks/completion.py
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand All @@ -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),
Expand Down
63 changes: 63 additions & 0 deletions docs/performance.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
52 changes: 46 additions & 6 deletions docs/search-correctness.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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
Expand Down
Loading
Loading