ADR-060: Collapsing the V2 walk dispatch — representation vs interface

Status: accepted (2026-09-23). Basis: the affected-site survey of rust/crates/engine/degenbot-solvers recorded in Context below, and the rounding-parity analysis of IntHopState::swap against compute_swap_step_v3. The deciding empirical evidence (a byte-parity probe over captured paths) is a later task; this ADR fixes the decision criteria that probe must meet and picks a provisional direction. Predecessors: ADR-014 (pool-state deepening), ADR-015 (resolve→solve seam), ADR-038 (CL event routing), ADR-058 (strategy crate split), ADR-059 (pool family kernel, whose D6 “hot loops stay monomorphic” discipline constrains this change).

Context

The active-set piecewise Möbius walk in src/cl/active_set.rs is the single solve path for CL and mixed V2+CL arbitrage paths. Its hop type is a crate-private sum:

pub(super) enum WalkHop<'a> {
    ConstantProduct(&'a IntHopState),
    Cl { crossings: Arc<ClCrossingTable>, profiles: Arc<ClProfileTable> },
}

The walk is otherwise family-agnostic: it discovers pieces by simulating landed ending-range tuples, projects each piece into Möbius coefficients, and refines around the argmax. Two places still branch on the enum:

  1. simulate_walk_path_inner (the forward, self-determined-crossing simulation), and

  2. walk_event_first_above_predicted_inner (the exact-out inversion that predicts the first input whose landed tuple exceeds ks).

Everything else that touches the hop either already treats both arms as one parametric piece (build_shifted_piece_hops), or reads a per-hop capability that the enum encodes (iteration_cap, single_piece_path, saturation_edge, telemetry range counts).

The epic goal is to collapse that dispatch into the unified piecewise Möbius walk. Two shapes are available:

  • A — representation collapse. Delete WalkHop::ConstantProduct and project every V2 hop as a degenerate single-range CL tick-range sequence, so the existing CL step simulators run V2 too.

  • B — interface collapse. Replace the enum with a parametric per-hop piece view that carries a family-specific step simulator; keep the V2 and CL step math separate, but delete the enum match sites.

The choice is decided by rounding parity, not by taste. The walk’s correctness is anchored on the landed ending-range tuple, and a single-wei difference in a hop’s output can move a candidate across a crossing_gross_input boundary, changing landed, the visited piece sequence, and the recorded profit.

The rounding-parity crux (verified)

V2. IntHopState::swap (degenbot-math/src/v2/hop_state.rs) is a single EVM floor on one rational:

out = (x·γ·R_out) / (R_in·fee_denom + x·γ)   // one DIV, no ceiling

CL. simulate_v3_range_swap (src/cl/hop_sim.rs) walks the range entry → interior word boundaries → exit, one compute_swap_step_v3 (degenbot-math/src/cl/swap_math.rs) per target. The exact-in step computes amount_in = ceil(Δ_in) (get_amount_delta(..., round_up=true)), amount_out = floor(Δ_out), and fee_amount = ceil(amount_in · fee_pips / (MAX_SWAP_FEE − fee_pips)); the gross consumed is amount_in + fee_amount and accumulates per step. Dense ranges route through ClWordProfile::swap, documented byte-equivalent to that walk.

The CL projection of a V2 hop is lossy at both ends: IntV3TickRangeHop is derived from an actual pool’s L/√P/bounds, and to_int_hop_state (degenbot-pools/src/int_v3_hop.rs) truncates the virtual reserves L·2^96/√P and L·√P/2^96 back to integer reserves. Even for a single-word degenerate range, the CL path performs ceil(Δ_in) + ceil(fee) against a sqrt-price-derived Δ rather than the V2 rational floor. So A is a behavioral change unless a probe proves byte-identity, whereas B is byte-identical by construction because it does not touch either simulator.

Affected-site table (verified against the tree)

File

Symbol / site

Current shape

Collapse impact

src/cl/active_set.rs

WalkHop (pub(super))

ConstantProduct(&IntHopState) / Cl { crossings, profiles }

Center of the change. B turns it into a parametric piece descriptor; A projects the V2 arm into the CL arm.

src/cl/active_set.rs

simulate_walk_path_inner (forward step)

V2: hop_state.swap(current) one-shot floor, landed = 0. CL: landed_ending_range_index + profile.swap / simulate_v3_range_swap.

Forward dispatch site; the byte-parity crux. Under B: a per-piece forward-step capability.

src/cl/active_set.rs

build_shifted_piece_hops

Both arms yield ShiftedPieceHop { hop: IntHopState, gross_input_offset, output_offset }; CL via crossing.ending_range.to_int_hop_state().

Proof the piece math/anchor is already representation-agnostic. B extends this same view to the exact step.

src/cl/active_set.rs

walk_piece_anchor, walk_piece_anchor_transitional

#[cfg(test)] only; enum matches for the A/B anchor baselines.

Test-only; ride the same piece view.

src/cl/active_set.rs

single_piece_saturation_edge

Cl → first range max_gross_input_in_range(); ConstantProduct → None.

Under B: a bounded-first-range capability query; under A the arm vanishes (always bounded).

src/cl/active_set.rs

iteration_cap piece-count (≈L686)

ConstantProduct → 1; Cl → crossings.len().

Piece count becomes a per-piece capability.

src/cl/active_set.rs

single_piece_path detection (≈L712)

Same shape (ConstantProduct → true).

Same.

src/cl/active_set.rs

telemetry range_counts (≈L981)

Same shape.

Same; keep the warn payload shape.

src/cl/active_set.rs

landed_beyond

Calls simulate_walk_path(...).landed.

Indirect: depends only on landed, which stays per-piece.

src/cl/crossings.rs

walk_event_first_above_predicted_inner

Outer loop continues on ConstantProduct; inner inversion uses swap_exact_out (V2) vs cl_hop_min_input_for_output (CL).

Second dispatch site. Under B: a per-piece exact-out-inversion capability.

src/cl/crossings.rs

cl_walk_hop_cached, cl_walk_hop

The single CL hop assembler (crossings + profiles, Arc-cloned).

Builder fed by the piece descriptor; unchanged otherwise.

src/cl/entries.rs

solve_cl_piecewise + _inner

Production all-CL entry: builds WalkHop::Cl per ClSolveTables, memo fingerprinted.

Stays public; constructs pieces instead of enum arms.

src/cl/entries.rs

solve_mixed_piecewise

Production mixed entry: builds ConstantProduct/Cl from hop_order.

Stays public; constructs pieces.

src/cl/entries.rs

solve_v3_v3_piecewise, solve_mixed_v2_v3_piecewise

Convenience/offline entries (tests, doc comments); build fixed 2-hop hop lists.

Not production intakes. Compile/behave only; candidates for deletion.

src/cl/entries.rs

ClSolveTables, memo plumbing

Prepared Arc tables + walk_path_fingerprint/WalkMemo.

Unaffected by the enum; the piece descriptor should carry the prepared tables.

src/mixed/solve.rs

solve_path_inner dispatch pyramid

all_v2 → exact_mobius_solve; all_cl → solve_cl_piecewise; Solidly/weighted/stable/curve two-stage solves; else solve_mixed_path_int.

Only the all_cl/mixed walk arms move; the family two-stage branches are non-goals.

src/mixed/solve.rs

solve_mixed_path_int

Adapter: builds v2_hops, CL sequences, hop_order, cl_prepared, then calls solve_mixed_piecewise.

Adapter shape only. Under A the v2_hops projection becomes a degenerate CL sequence.

src/profit_envelope.rs

HopMath, hop_lines_and_cap

Already family-parametric (V2/Cl/SolidlyVolatile/Weighted/ReserveCap) with per-family bound lines.

Out of scope; evidence envelope-level unification already works.

src/cl_cache.rs

ClCacheStrategy, PreparedHop

Cache-lab seam: strategies refill crossing+profile tables and solve through the production entry.

Lab seam over IntV3TickRangeSequence, not a walk projection. Untouched.

degenbot-bot/src/bot_core/resolve/mod.rs

all_cl_solve, mixed_solve

Build ClSolveTables from the ResolvedHop as_* projections and call the entries.

Adapters; public entry shape unchanged.

degenbot-bot/src/arb_engine/solver_capture.rs

shape_matches, serialize_hops

Selects HeavyCl / HeavyMixed by as_int_sequence; serializes ranges.

Capture source for the parity corpus; reads projections only.

degenbot-bot/src/arb_engine/solve_cycle.rs

comment reference only

No WalkHop use.

Unaffected.

tests

cl_cache_lab_goldens.rs, offline_heavy_replay.rs

Drive solve_cl_piecewise / derive_and_solve_cl_piecewise.

Golden parity harness for the collapse.

examples

cl_solve_replay.rs, mixed_solve_replay.rs, cl_capture_gen.rs

Replay/generate fixtures through the entries.

Corpus generation and offline parity.

Discrepancies with the working survey

  • Only two of the four solve entries are production intakes: solve_cl_piecewise and solve_mixed_piecewise. solve_v3_v3_piecewise and solve_mixed_v2_v3_piecewise appear only in tests and doc comments.

  • cl_cache.rs is a cache-lab seam over IntV3TickRangeSequence / PreparedHop; it contains no WalkHop reference. The production CL projections are the ResolvedHop::{V3,V4} fields read through as_int_sequence / as_crossing_table / as_word_profiles and assembled into ClSolveTables.

  • walk_piece_anchor* are #[cfg(test)] only, not production arms.

  • arb_engine/solve_cycle.rs has no walk site (a comment mention of a cached entry only); workload_partition.rs reads as_int_sequence only.

Options

Option A — representation collapse

Delete WalkHop::ConstantProduct; project each V2 hop into a degenerate single-range IntV3TickRangeSequence, so simulate_walk_path_inner, walk_event_first_above_predicted_inner, the piece count, the saturation edge, and the piece anchor all run the CL path unchanged. The win is one fewer enum variant and one fewer simulator; the cost is that V2 output rounding is now whatever the CL step math produces.

Against the code read above, this is not representation-only. The walk’s landing test (landed_ending_range_index) partitions crossings by crossing_gross_input, which for the degenerate range is a single boundary at max_gross_input_in_range(). A per-hop rounding shift can move a candidate across that boundary, flip landed, change the visited piece, and change rec.profit — an observable result change, not an internal one. A is therefore admissible only if a byte-parity probe shows the CL path reproduces V2 bytes exactly on the corpus.

Option B — interface collapse

Keep both step simulators; replace the enum match sites with a parametric per-hop piece view. The view already exists in a narrower form: ShiftedPieceHop { hop: IntHopState, gross_input_offset, output_offset } is exactly what the Möbius anchor consumes, and it is produced identically from both enum arms. B promotes that to the whole walk by giving each piece:

  • its forward step (V2 swap or CL landing + range walk), producing the per-hop output and its own landed component;

  • its exact-out inversion (swap_exact_out or cl_hop_min_input_for_output) for the event predictor;

  • its piece-count / bounded-first-range capabilities used by iteration_cap, single_piece_path, single_piece_saturation_edge, and the telemetry payload.

The two match sites disappear without either simulator changing. The “unified piecewise Möbius walk” is achieved at the seam that actually unifies — the piece view — while the per-family EVM-exact step math stays where it is. Per ADR-059 D6 the view must be a monomorphic sum/trait-object-resolved-at-construction, never dyn dispatch inside the per-candidate loop.

Decision

D1 — Provisional direction: Option B (interface collapse)

Adopt the parametric per-hop piece view and preserve the per-family step simulators. Rationale: the rounding path difference between IntHopState::swap and compute_swap_step_v3 is structural, not an accumulation that a probe could reasonably erase; the walk’s result depends on exact landing, so a representation swap is a behavioral change dressed as a refactor. B delivers the stated goal (one walk, no dispatch, unified piece view) with a byte-identical-by-construction change, which is the standard the surrounding retrofit work already holds itself to. A remains available and is the better end state if the probe clears it.

D2 — The probe decides A; zero returned-byte divergence is the bar

A is accepted only if the byte-parity probe (below) shows the collapsed implementation returns byte-identical WalkOutcome.result on 100% of the corpus. “Byte-identical” means the returned (optimal_input, profit, hop_outputs) and the derived consumed_inputs; internal probe values that cannot affect the returned tuple are not compared. Under this bar, B is the default and A must earn its way in.

D3 — The piece view is the unification seam, not a new math layer

The view carries capability and a step function; it does not re-derive V2 or CL math. ShiftedPieceHop stays the anchor-time projection; the walk-time view may wrap it or sit beside it, but there is exactly one place each simulator is named, and no family math is duplicated.

D4 — Public entry surface is preserved during the change

The four cl entries and ClSolveTables keep their signatures; only their internal hop construction changes. The engine adapters in bot_core/resolve and mixed::solve_mixed_path_int are not part of the change. The memo fingerprint and WalkStats fields keep their meaning.

Outcome

The collapse shipped as Option B. WalkHop is gone: PieceView is the sibling view of ShiftedPieceHop that carries each hop’s forward step, exact-out inversion, piece count, and bounded-first-range capability behind a single family enum matched only inside the view’s methods. The CL hop is assembled in one cl_hop_view seam shared by both solve entries, and the all-CL and mixed dispatch arms are consolidated into one solve_walkable_path_int construction flow. Neither per-family simulator changed: V2 still floors its rational in one DIV, and CL still runs compute_swap_step_v3 per target.

The byte-parity probe refuted Option A (representation collapse). Against the zero-tolerance bar it found 18.2% of the 1,236 swap-step comparisons divergent (10% on the real-capture subset), including real captured hops whose output differs by hundreds of millions of wei at x = 1 and multi-thousand-wei at production-scale inputs. The cause is structural - the CL step floors the post-fee net input before the invariant, and floors both the geometric-mean liquidity and the spot price - so it is not an accumulation a probe could erase. Evidence: docs/architecture/v2-cl-parity-probe.md.

The all-V2 fast arm stays on exact_mobius_solve. Routing all-V2 paths through the walk’s constant-product projection is neither byte-identical (83.3% of the A/B corpus agreed overall; the 2-hop and captured subsets matched fully, while the 3-hop grid is where it diverges) nor competitive (the walk runs ~239x slower at the median). Evidence: examples/all_v2_fastpath_ab.rs.

Non-goals

  • Solidly, Balancer weighted/stable, and Curve two-stage solves stay family-specific. Their solve_path_inner branches and their per-hop swap_fn golden-section solves are not piecewise-Möbius over CL ending ranges and are untouched by this collapse.

  • The all-V2 exact_mobius_solve fast path is not decided here. Whether it survives, or whether all-V2 paths move onto the unified walk, is deferred to the benchmark task’s cost evidence.

  • profit_envelope::HopMath and the gate are untouched. The envelope enum is the existing example of family-parametric unification and already carries V2/CL/Solidly/Weighted bounds; the walk change must not perturb bound lines, the clamp, or the gate.

  • No change to CL step math, crossing/word-profile builders, the memo fingerprint, or telemetry counter semantics.

Byte-parity criteria the probing task must meet

Corpus

  • Provenance. Production captures through solver_capture (HeavyCl and HeavyMixed) replayed by cl_solve_replay / mixed_solve_replay; the offline_heavy_replay fixtures; the cl_capture_gen sequences; and the cl_cache_lab_goldens golden states.

  • Coverage gate. The corpus must span: all-CL 2-hop and ≥3-hop; mixed V2+CL in every interleave (V2 first, V2 middle, V2 last); CL word-boundary densities of 0, 1, and at least one range at DENSE_OBSERVE_THRESHOLD; single-range and multi-range CL hops; inputs landing exactly on a crossing_gross_input boundary; both profitable and unprofitable paths; and repeated states so the memo is exercised. A corpus that misses the boundary-exact cases does not satisfy this criterion, because that is where a rounding shift first changes landed.

Comparisons (candidate vs status-quo baseline, per path)

  1. optimal_input — byte equality (U256).

  2. hop_outputs[i] for every i — byte equality (feeds downstream hops and the executor).

  3. profit — byte equality.

  4. consumed_inputs[i] for every i — byte equality (the executor-facing flash/swap amounts).

  5. Diagnostic, recorded even on a passing run: the landed tuple, pieces visited, and sims. A landed difference is a fail even if the returned tuple happens to agree, because it means the walk’s geometry moved and the agreement is incidental.

For fields that are not exactly equal, report the wei-divergence distribution: count/percent of paths diverging, min, median, max, sign, and whether the divergence is one-sided. This report is required output of the probe regardless of pass/fail.

Pass/fail lines (the A↔B flip)

  • Clear A iff every path is byte-identical on all four returned fields, no path differs in landed, and the memo-on vs memo-off runs of the candidate agree with each other and with the baseline. A candidate that is byte-identical except for internal probe history that cannot change the returned tuple may still clear A.

  • Fail A, keep B if on any single path: profit, consumed_inputs, optimal_input, or any hop_outputs[i] differs, or landed differs. The threshold is zero, not a wei tolerance: a representation collapse claims equivalence, so any observable divergence refutes it. A bounded wei divergence is not a partial pass; it is the measured cost of the approximation and belongs in the report, not in the accept gate.

  • Executor cross-check (both options). For the captured paths whose plans are executable, re-simulate the chosen plan in the in-process REVM oracle and compare consumed_inputs/hop_outputs to the solver bytes. This catches a collapse that is self-consistent against the baseline but both wrong against the chain.

Consequences

  • The collapse lands as a pure interface change; the existing golden and offline replay suites remain the behavior guard, and no new parity oracle is needed to prove B safe.

  • The probe’s wei-divergence report becomes the evidence record for the A question; if it ever clears A, deleting WalkHop::ConstantProduct is a second, separately reviewed change.

  • The piece view gives a single home for “what a hop can do” (forward step, exact-out inversion, piece count, bounded-first-range), so future families admitted to the walk add a capability rather than an enum arm.

  • The family two-stage solves keep their own dispatch; the walk’s monomorphism discipline (ADR-059 D6) is preserved.

  • The envelope side lands on the same piece boundary and feeds the walk: constant-product rise lines now carry the exact post-fee entry tangent, the gate’s bound scan exits early once a segment clears the skip floor, and exact concave composition (at most K1+K2 pieces, sampled fallback retained) replaces product-plus-sampling for the refine-window hull the active-set walk intersects. Terminology: CONTEXT.md profit envelope.

  • The hull-edge seed of the walk’s first-piece right-edge bisection is a measured no-op under the production event solver and is not shipped. Evaluation: docs/architecture/hull-edge-seed-evaluation.md.

Open questions

  1. Does the collapse target the all-V2 fast path as well? If all-V2 paths stay on exact_mobius_solve, A need not represent V2 at all and the probe’s V2 corpus shrinks to mixed paths only. Disposition: No - the fast path stays. The A/B evidence shows the walk’s all-V2 projection is divergent on the 3-hop grid and far slower, so exact_mobius_solve remains the all-V2 arm. The 3-hop divergence itself is tracked in the task graph, outside this ADR.

  2. Corpus ownership and size: captured fixtures are state-dependent. Is the cl_capture_gen sequence set sufficient, or must the probe mine fresh captures (and under what chain-state conditions)? Disposition: The shipped corpus (production captures, offline replay fixtures, cl_capture_gen sequences, and golden states) was sufficient to settle the step-level question. Boundary-exact and multi-hop composition coverage stay noted as gaps for any future representation proposal.

  3. Placement of the walk-time piece view: extend ShiftedPieceHop (already the anchor’s view) or introduce a sibling that carries landed and the simulators? ShiftedPieceHop deliberately models the ending-range approximation only, so overloading it may blur the anchor versus simulation boundary. Disposition: A sibling. PieceView carries landed and the simulators beside ShiftedPieceHop, keeping the anchor’s ending-range-approximation view separate from the simulation view.

  4. Disposition of solve_v3_v3_piecewise and solve_mixed_v2_v3_piecewise: test-only conveniences may be deleted rather than ported. Disposition: Deleted. The two test-only entries had no production intake and were removed with the CL assembly unification; the two production entries remain.

  5. If the probe clears A, is deleting WalkHop::ConstantProduct still a win once degenerate-range CL profiles are built and walked for every V2 hop, or does the profile build cost outweigh the variant removal? Disposition: Moot. The probe failed A on the zero-tolerance bar, so the constant-product arm is never deleted and no degenerate-range profile is built for a V2 hop.