clboss/Boss/Mod/XRebalancePredict.cpp
Ken Sedgwick d9f7b6a8a6
XMoveFunds: transit observations from the forwarded hops of failed parts
When a part fails at hop k, hops 0..k-1 provably forwarded the
HTLC -- a lower bound on each hop's liquidity exactly as strong
as a settled part's evidence.  Previously only the erring hop
produced any feedback and that forwarding proof was dropped.

Now every hop strictly before the failure point gets an
inform-unconstrained on the clboss-xrebalance layer plus a
history observation of a new kind, transit.  Transit is kept
distinct from success because the two decay differently: a
settled part consumes the liquidity it proves, while a failed
part unwinds and puts it back.  The predictor treats both as
floor-side lower bounds; the distinction exists so the planned
floor-factor calibration can measure proven-and-restored
separately from proven-and-consumed.

Details:
- own channels filtered out (auto.localchans owns their truth)
- amount_out_msat, matching the settled-part success informs
- for 0x100c the incoming hop route[k-1] may itself be blamed
  (inbound-fee PolicyFail), so transit stops one hop short on
  all 0x100c: never assert carried-fine and policy-excluded
  about the same hop from the same part
- failcode/erring_node are NULL for transit, as for success

Tests: kind_is_bound maps transit as a non-fail bound; a plan()
case where the floor bound comes from a transit record (larger
than the settled success beside it) proves transit feeds the
floor side.
2026-08-04 11:02:04 -07:00

190 lines
5.6 KiB
C++

#include"Boss/Mod/XRebalancePredict.hpp"
#include<algorithm>
#include<cmath>
#include<limits>
#include<sstream>
namespace {
auto constexpr no_wall = std::numeric_limits<std::uint64_t>::max();
std::string stale_reason( std::uint64_t data_age
, std::uint64_t horizon
, std::uint64_t span
, std::size_t records
) {
auto os = std::ostringstream();
/* A zero span means all the regime's records carry one
* timestamp (typically several MPP parts of a single flow,
* informed together) -- many bounds but ONE temporal sample,
* so there is no demonstrated persistence to extrapolate. */
if (span == 0) {
if (records == 1)
os << "a single observation has no time span";
else
os << "zero evidence span (all " << records
<< " records simultaneous)";
os << "; nothing to extrapolate until the channel is "
"observed again later";
return os.str();
}
os << "stale: data age " << data_age
<< "s exceeds horizon " << horizon << "s";
return os.str();
}
std::string samples_reason(std::size_t have, std::size_t need) {
auto os = std::ostringstream();
os << "insufficient samples (" << have << " < " << need << ")";
return os.str();
}
}
namespace Boss { namespace Mod { namespace XRebalancePredict {
bool kind_is_bound(std::string const& kind, bool& is_fail) {
/* "transit" (hop forwarded a part that later failed
* downstream and unwound) proves the same lower bound as
* "success" (hop on a settled part); they are distinct
* kinds only so statistics can separate proven-and-restored
* liquidity from proven-and-consumed. */
if (kind == "success" || kind == "transit") {
is_fail = false;
return true;
}
if (kind == "liquidity_fail" || kind == "policy_fail") {
is_fail = true;
return true;
}
return false;
}
Result predict( std::vector<Bound> bounds
, std::uint64_t now
, Params const& params
) {
auto result = Result();
result.regime_records = 0;
result.regime_start_time = 0;
result.regime_span_secs = 0;
result.data_age_secs = 0;
result.horizon_secs = 0;
result.truncated = false;
result.wall.would_assert = false;
result.wall.has_amount = false;
result.wall.amount_msat = 0;
result.wall.samples = 0;
result.floor = result.wall;
/* Newest first; deterministic tiebreak so equal-timestamp
* records always walk in the same order. */
std::sort( bounds.begin(), bounds.end()
, [](Bound const& a, Bound const& b) {
if (a.time != b.time)
return a.time > b.time;
if (a.is_fail != b.is_fail)
return a.is_fail < b.is_fail;
return a.amount_msat < b.amount_msat;
});
/* Regime walk: intersect bounds newest -> oldest until the
* implied interval [lo, hi) for a static liquidity becomes
* empty -- the contradiction marks where the channel CHANGED,
* and only the newer, mutually-consistent records form the
* current regime. */
auto lo = std::uint64_t(0);
auto hi = no_wall;
auto regime_newest = std::uint64_t(0);
for (auto const& b : bounds) {
auto new_lo = lo;
auto new_hi = hi;
if (b.is_fail)
new_hi = std::min(new_hi, b.amount_msat);
else
new_lo = std::max(new_lo, b.amount_msat);
if (new_lo >= new_hi) {
result.truncated = true;
break;
}
lo = new_lo;
hi = new_hi;
if (result.regime_records == 0)
regime_newest = b.time;
result.regime_start_time = b.time;
++result.regime_records;
if (b.is_fail)
++result.wall.samples;
else
++result.floor.samples;
}
if (result.regime_records == 0) {
result.wall.decline_reason =
"no bounds-relevant observations";
result.floor.decline_reason =
"no bounds-relevant observations";
return result;
}
result.regime_span_secs = regime_newest - result.regime_start_time;
result.data_age_secs = (now > regime_newest) ? (now - regime_newest)
: std::uint64_t(0);
auto horizon = double(params.horizon_frac)
* double(result.regime_span_secs);
if (horizon > double(params.horizon_max_secs))
horizon = double(params.horizon_max_secs);
result.horizon_secs = std::uint64_t(horizon);
auto fresh = result.data_age_secs <= result.horizon_secs;
/* Wall: assert "liquidity < amount". The raw bound is hi
* (the smallest attempted amount that failed); the margin
* biases errors high, which self-corrects via a fresh failed
* part, where a too-low wall is sticky until the horizon. */
if (hi != no_wall) {
result.wall.has_amount = true;
result.wall.amount_msat = std::uint64_t(
std::llround(double(hi) * params.wall_margin));
if (result.wall.samples < params.min_samples)
result.wall.decline_reason = samples_reason(
result.wall.samples, params.min_samples);
else if (!fresh)
result.wall.decline_reason = stale_reason(
result.data_age_secs, result.horizon_secs,
result.regime_span_secs,
result.regime_records);
else
result.wall.would_assert = true;
} else {
result.wall.decline_reason =
"no failure observations in regime";
}
/* Floor: assert "liquidity >= amount". */
if (lo > 0) {
result.floor.has_amount = true;
result.floor.amount_msat = std::uint64_t(
std::llround(double(lo) * params.floor_factor));
if (params.floor_factor <= 0)
result.floor.decline_reason =
"floor forecasts disabled (factor <= 0)";
else if (result.floor.samples < params.min_samples)
result.floor.decline_reason = samples_reason(
result.floor.samples, params.min_samples);
else if (!fresh)
result.floor.decline_reason = stale_reason(
result.data_age_secs, result.horizon_secs,
result.regime_span_secs,
result.regime_records);
else
result.floor.would_assert = true;
} else {
result.floor.decline_reason =
"no success observations in regime";
}
return result;
}
}}}