mirror of
https://github.com/ZmnSCPxj/clboss.git
synced 2026-08-13 12:33:20 +02:00
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.
190 lines
5.6 KiB
C++
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;
|
|
}
|
|
|
|
}}}
|