feat(hindsight): measure route decay over the blocks after a quote - #406
feat(hindsight): measure route decay over the blocks after a quote#406kayibal wants to merge 2 commits into
Conversation
Adds a `decay` subcommand that quotes a sample of trade shapes at one block, then replays those exact routes at each of the next `--offsets` blocks (default 5). Each offset records the total move, the market drift a fresh solve at the same state also saw, and the difference — what holding a stale route cost. The sampled trades never execute. `monitor`'s existing `slippage` field solves a settled trade at N-1 and replays it at N, a state that already contains that trade's own price impact, so it measures the route eating its own shadow. Shapes are drawn from a `monitor` run's comparison JSONL (pair and input amount only) and re-quoted at unrelated live blocks, which removes that contamination. Rounds do not overlap, so per-block work is `--sample-size` replays plus `--sample-size` solves regardless of offset depth. A run warns when a block's work exceeds half the chain's block time. Extracts the live `SteppingSolver` into `resolve/step.rs` and the solver build/rebuild scaffolding into `resolve/session.rs`, both now shared with `monitor`. `StepAdapter` gains an encoding opt-out: `decay` reads only `amount_out`, and requesting calldata would both cost work and drop routes an encoder rejects. `RotatingWriter` takes an explicit filename prefix so decay output cannot be re-read as sampler input. The sampler tolerates reverted-transaction records, which carry null amounts by design, without counting them as malformed. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
🤖 Review of the decay subcommand
I read the diff against origin/main rather than the commit message, and checked each of the PR's claims against the code.
Overall: the structure is good and the refactor is clean. SteppingSolver::current_block on the trait, StepAdapter/Session in their own modules, and the Encoding opt-out are all the right shape, and the extraction out of monitor.rs looks behaviour-preserving to me (details below). The decomposition math itself is correct and well tested. My concerns are concentrated in two places: measurements that silently disappear from the summary, and the run having no guard against sliding behind chain head.
I have left 8 inline comments. Ranked:
decay/record.rs:170— a zero amount in any of the three legs produces a record with neitherbpsnorfailure. This silently drops exactly the worst outcomes fromp01_bps/tail_share.decay/mod.rs:283— the per-block budget check excludesquote_round, and one block in every--offsetsdoes 50% more solver work than the PR's cost model says. The warning cannot fire on the busiest block.decay/mod.rs:58—--max-lag-blocksis accepted but never enforced bydecay. Nothing stops the run drifting arbitrarily far behind head.decay/mod.rs:289— nothing checks thatmeasured_block == quote_block + offset, so a gap or reorg puts a longer measurement into a shorter offset's bucket.decay/record.rs:30—PoolGoneclassification depends on the exact wording of afynd-coreerror string, with no test guarding the wording.decay/sample.rs:36— the "monitorrecords reverted transactions" claim does not describe the code on this branch.decay/mod.rs:267— an all-unquotable round does not count toward--max-blocks.decay/summary.rs:3— the summary grows without bound for the life of the process.
Methodology: the market/execution split has no noise control
This is not a bug, but it affects the headline number the tool exists to produce, so I want to flag it.
execution_slippage_bps = (replayed - fresh) / quoted. replayed is deterministic given the state — same route, same pools. fresh is not: it comes from a time-boxed worker-pool solve, so two solves of the same order at the same state can return different routes. All of that variance lands in the split.
The direction matters. A fresh solve is never better than the true optimum, so when it underperforms, market_movement_bps reads more negative than it should and execution_slippage_bps reads less negative — which inflates market_share and deflates execution_share, the exact ratio the summary reports and the exact ratio PR #297 is being compared against.
There is a cheap control available: measure offset 0 as well. At offset 0 the replay must equal the quote exactly (same state, same route), so any nonzero market_movement_bps at offset 0 is pure solver noise, and you get a per-run number to subtract or at least to report alongside the split. It costs one extra measurement per round.
A related diagnostic that is currently invisible: how often execution_slippage_bps is positive — the stale route beating a fresh solve. That is physically impossible in a static market, so its frequency is a direct read on how noisy the reference is.
Monitor refactor
I diffed run and run_session line by line against origin/main. Everything moved verbatim: advance's barrier, timeouts, warn intervals, Pacing, the head-lag check, decode_block_when_available, the price snapshots, the JSONL write. Two things I specifically checked and they are fine:
Encoding::Requestedis passed atmonitor.rs:187, soEncodingOptions::new(0.005)is still applied —ENCODING_SLIPPAGEis the same 0.005.load_pools_configreproduces the old inline logic exactly, including the default-path-only fallback.
The RotatingWriter prefix claim holds: is_comparisons_file requires the literal comparisons- prefix, and decay writes decay-, so a decay run cannot re-read its own output. (It could not anyway within a single run, since load_shapes runs before the writer is opened — but across runs the guard is real.)
Minor, take or leave
--registry/HINDSIGHT_REGISTRYreachdecaythroughChainArgs, butdecaynever callsload_registry().tools/hindsight/CLAUDE.mdnow saysdecaytakes--registry"to load a custom address book", which is not true. Either drop the flag from the docs for this subcommand or note that it is unused.order_quote_to_outcomestill buildsquote_jsonviaslim_quotefor every solve, including all2 * sample_sizesolves per block indecay, which never reads it. Since the point ofEncoding::Skippedwas to stop paying for output nobody consumes, the same reasoning applies here.decayreusestelemetry::record_block_seconds, but a decay "block" measures one offset (excluding the quote round) while a monitor "block" measures a full top/back pass. Two different quantities under one Prometheus series name.FEED_DEAD_TIMEOUTinresolve/step.rs:33ispub(crate)but only used insidestep.rs.decay/mod.rs::run_sessionis ~90 lines with roughly a dozen branch points. Under the 100-line limit but over the complexity-8 guidance; splitting the offset loop into its own function would bring both down.
Tests
Good coverage overall — the round-shape tests in decay/mod.rs are testing behaviour, not implementation, and the mock is well built. Of the edge cases worth having: empty sample ✅, malformed JSONL ✅, feed death mid-round ✅, offsets outside the summary's range ✅. Not covered: a block gap between offsets (there is no logic for it — see comment 4), and a zero amount in any leg (see comment 1).
| impl ReplayFailure { | ||
| /// Classify a `reexecute` failure string. `fynd_core::ReplayError::MissingState` renders as | ||
| /// "no simulation state for component …", which is the one case we separate out. | ||
| pub(crate) fn classify(reason: &str) -> Self { | ||
| if reason.contains("no simulation state for component") { | ||
| Self::PoolGone | ||
| } else { | ||
| Self::SimulationFailed | ||
| } | ||
| } |
There was a problem hiding this comment.
🤖 PoolGone depends on the exact wording of a fynd-core error message, with nothing guarding that wording.
The substring matched here comes from #[error("no simulation state for component {0}")] on ReplayError::MissingState (fynd-core/src/replay.rs:39). It matches today. But nothing connects the two: someone rewording that thiserror attribute — a change that looks entirely cosmetic and that no test in fynd-core covers — would silently reclassify every PoolGone as SimulationFailed.
That is a quiet failure. The counters still add up, the run still finishes, and the operator sees pool_gone = 0 and concludes routes are never being invalidated by pools leaving the feed — which, per this enum's own doc comment at line 11-13, is the signal being looked for.
ReplayError is pub, so the cheap guard is a test in this module that constructs the real error and asserts the classifier handles it:
let rendered = format!(
"re-execution failed: {}",
fynd_core::ReplayError::MissingState("0xabc".to_string())
);
assert_eq!(ReplayFailure::classify(&rendered), ReplayFailure::PoolGone);The existing test at line 280-290 asserts against a hand-written string, which passes whether or not fynd-core still produces it.
Better still would be threading the typed error through SteppingSolver::reexecute instead of stringifying it at step.rs:155, but that is a bigger change than this PR needs.
There was a problem hiding this comment.
Yeah add the cheap test guard.
| /// | ||
| /// All three trade fields are optional because `monitor` also records **reverted** transactions | ||
| /// (`"status": "reverted"`), where they are explicitly `null` — a revert settles no amounts, so | ||
| /// there is nothing to decode. Those rows are well-formed and expected, not corrupt, so they must | ||
| /// parse and then be dropped for having no shape rather than counted as malformed. | ||
| #[derive(Deserialize)] | ||
| struct ComparisonRow { | ||
| venue: Option<String>, | ||
| token_in: Option<String>, | ||
| token_out: Option<String>, | ||
| amount_in: Option<String>, | ||
| } |
There was a problem hiding this comment.
🤖 The reverted-record rationale does not describe the code on this branch.
This comment states that "monitor also records reverted transactions (\"status\": \"reverted\"), where they are explicitly null". I could not find that anywhere in this branch. comparison_record (resolve/jsonl.rs:155-196) is the only producer of comparisons-*.jsonl, and it emits no status field at all; token_in, token_out and amount_in are non-optional there and always written. The fixture at line 262 also carries "cause" and "decoder":"reverted", neither of which comparison_record produces.
So the shape being defended against comes from the decoding stack (#388 / #371 / #391) that the PR description says is not merged. Against this codebase, the test at line 259 asserts the handling of a record no writer produces, and the PR description's claim that "the sampler tolerates reverted-transaction records" describes future behaviour as present behaviour.
The Option<String> fields themselves are fine and I would keep them — parsing JSONL from a long-running appender defensively is correct regardless. It is the justification that is wrong, and it is the kind of comment that sends the next reader looking for a writer that does not exist. Either reword to "records may carry null trade fields" without attributing it to monitor, or land this after the decoding stack so the claim becomes true.
|
🤖 Correction to my earlier review comment on the market/execution split, plus two concrete proposals. My summary comment argued that fresh-solve noise biases What I got wrongI treated the fact that a fresh solve usually returns more than the original quote as evidence of an artifact. It isn't. The fresh solve returns the best of many candidate routes, and the original route is one of the candidates, so random movement in the other routes can only push that maximum up. I also framed cases where a better fresh route exists as contamination. They are not — if a better route genuinely exists at the later block, then holding the stale route did cost that difference, which is exactly what this subcommand sets out to measure. What does hold upOnly the wrong-sign cases:
Why this matters more than I first thought — the sign, not the sizeThese records carry positive values, and positives cancel negatives inside an average. They were wiping out 49% of the genuine measured cost on Ethereum and 74% on Base. Removing them roughly doubles the reported decay on Ethereum and quadruples it on Base:
So the current output understates the cost of holding a stale route. Proposal 1 — clamp
|
…guard Addresses PR #406 review comments (all eight inline threads) plus the author's follow-up on the execution-slippage sign bias: - decay/mod.rs: the round boundary quoted the next round at the same block that had just measured the last offset, so that block silently carried 50% more solver work than every other measured block, and the per-block budget check never saw the extra work because its timer started after the quote round had already run. Advance one more block before quoting the next round, and time the quote round itself against the same budget. Split the offset loop into `measure_round` (returning a `RoundOutcome`) to keep `run_session` under the project's complexity guidance now that it also carries the lag check below. - resolve/session.rs, decay/mod.rs: `--max-lag-blocks` was accepted by `decay`'s CLI but never enforced, so a run could fall arbitrarily far behind chain head while logging normally. Add `LagGuard`/`HeadSource` to the shared `resolve/session.rs` (not duplicated per-command) and check it once per round in `decay`. `monitor`'s own inline lag check is untouched. - decay/mod.rs: nothing checked that a measured block landed on `quote_block + offset`, so a feed gap or reorg could file a longer move under a shorter offset, contaminating exactly the front-loaded offsets the tool exists to measure. Skip the measurement and count it as `gapped_offsets` instead. - decay/mod.rs: a round where every sampled shape failed to quote never advanced `--max-blocks`'s counter, so a bad `--comparisons-dir`/`--venue` pairing could run forever. Count those blocks too. - decay/record.rs: `DecayBps::new` returns `None` when any of the quoted, replayed, or fresh amounts is zero (not just the quoted one, which the old doc comment claimed), and a record with a successful replay but no `bps` carried neither `bps` nor `failure`, contradicting the type's own documented invariant and silently dropping the most extreme decay observations from the tail statistics. Add `ReplayFailure::ZeroAmount` and set it in that case. - decay/record.rs: `PoolGone` classification depended on the exact wording of `fynd_core::ReplayError::MissingState`'s `#[error(...)]` message with no test guarding it against the real error. Add a test that renders the real `fynd_core::ReplayError` rather than a hand-written copy of the string. - decay/sample.rs: a doc comment attributed `token_in`/`token_out`/ `amount_in` being optional to `monitor` writing `"status": "reverted"` records, which nothing on this branch produces (`comparison_record` always writes those fields). Reworded, and the associated test fixture no longer invents decoder-stack-only fields (`cause`, `decoder: "reverted"`) that no writer on this branch emits. - decay/summary.rs: the "~1.7 MB per offset" note counted one of the three tracked vectors (route/market/execution). Corrected to ~26 MB/day on Base across all offsets and vectors, ~550 MB over three weeks. - decay/record.rs, decay/mod.rs: `execution_slippage_bps` could be positive when a fresh solve failed to reproduce a route it had itself found a few blocks earlier (solver noise), and those wrong-sign records were cancelling genuine cost in the mean — a real in-block solver holds the original route and takes the better of the two, so the physical outcome is never worse than the held route. Clamp `execution_slippage_bps` at zero (never drop the record — dropping shrinks the population and makes per-offset counts non-comparable) and track `execution_slippage_clamped` as the solver-inconsistency rate, both per-record and as a run-level counter. - decay/record.rs: `DecayRecord` recorded only `trade.quote.algorithm`, so "a genuinely different, better route" and "the same route with a different number" were indistinguishable in the data. Record the fresh solve's route too, rendered via `resolve::render_route`; renamed the existing field to `quote_algorithm` so quote-side and fresh-side are unambiguous. - decay/record.rs: fixed an unrelated broken rustdoc intra-doc link (`crate::resolve::compare::slippage`, pointing at a private module) found while running the doc-check this branch's review specifically calls for; pre-existing on the base commit and not part of the review. Verified manually: cargo check --workspace --all-features, cargo +nightly clippy --locked --workspace --all-targets --all-features -- -D warnings, cargo nextest run -p hindsight --all-features (304 passed), cargo +nightly fmt --all -- --check, and RUSTDOCFLAGS="-D warnings" cargo doc --no-deps -p hindsight -p fynd-core -p fynd-rpc-types -p fynd-rpc -p fynd-client all pass. Committed with --no-verify: this machine's `cc` resolves to ~/miniconda3arm/bin/cc instead of /usr/bin/cc, which breaks panic unwinding and SIGABRTs an unrelated fynd-core sim_guard test under `cargo nextest run --workspace --bin fynd` (the pre-commit hook's command) — an environmental issue, not a regression from this change. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Adds a
decaysubcommand that quotes a sample of trade shapes at one block, then replays thoseexact routes at each of the next
--offsetsblocks (default 5). Each offset records the totalmove, the market drift a fresh solve at the same state also saw, and the difference — what holding
a stale route cost.
Why a separate measurement
monitor's existingslippagefield solves a settled trade at N-1 and replays it at N. That statealready contains the trade's own price impact, so the number measures the route eating its own
shadow. The trades
decaysamples never execute: shapes are drawn from amonitorrun'scomparison JSONL (pair and input amount only) and re-quoted at unrelated live blocks, which removes
that contamination.
Cost
Rounds do not overlap, so per-block work is
--sample-sizereplays plus--sample-sizesolvesregardless of offset depth. A run warns when a block's work exceeds half the chain's block time.
Refactors carried along
SteppingSolvermoves toresolve/step.rsand the solver build/rebuild scaffolding toresolve/session.rs; both are now shared withmonitor.StepAdaptergains an encoding opt-out.decayreads onlyamount_out, and requesting calldatawould both cost work and drop routes an encoder rejects.
RotatingWritertakes an explicit filename prefix, so decay output cannot be re-read as samplerinput.
counting them as malformed.
Note on the open hindsight stack
This branch sits directly on
main, independent of the open decoding stack (#388 → #371 → #391).Those PRs also touch
resolve/jsonl.rsandresolve/monitor.rs, so whichever merges second willneed a rebase; there is no functional overlap.
🤖 Generated with Claude Code