mirror of
https://github.com/ZmnSCPxj/clboss.git
synced 2026-08-13 12:33:20 +02:00
contrib: clboss-askrene-layer-summary, breadth/depth census of an askrene layer
askrene-listlayers only dumps a layer's raw constraint list (tens of thousands
of entries on a busy node) and offers no aggregate view. This rolls that dump
up into the two numbers that describe a layer's routing knowledge:
breadth = distinct channel-directions the layer knows about (cardinality of
short_channel_id_dir)
depth = constraint entries stacked per direction (min/avg/median/p90/max)
It also splits the breadth by bound kind: dirs_with_max (maximum_msat upper
bounds, learned from sendpay 204s) versus dirs_with_min (minimum_msat lower
bounds, from positive reinforcement). dirs_with_max is the load-bearing
number when diagnosing a sendpay-204 rebalance storm: a doomed route is only
short-circuited at getroutes time once the dry direction on it carries a
maximum_msat bound, so dirs_with_max climbing is what predicts the storm
self-resolving into immediate rejections rather than futile sends.
Runs lightning-cli itself with the usual --network / --lightning-dir flags,
matching the other contrib scripts (clboss-earnings-history). --input FILE
(or -) replays a captured askrene-listlayers dump offline. --top N lists the
deepest directions; --json emits the summary as JSON. Works on any persistent
layer (clboss, xpay, ...); default layer is clboss.
This commit is contained in:
parent
16fc6b0b7b
commit
07a0c2e1d0
1 changed files with 191 additions and 0 deletions
191
contrib/clboss-askrene-layer-summary
Executable file
191
contrib/clboss-askrene-layer-summary
Executable file
|
|
@ -0,0 +1,191 @@
|
|||
#!/usr/bin/env python3
|
||||
|
||||
# clboss-askrene-layer-summary — breadth/depth census of an askrene layer.
|
||||
#
|
||||
# askrene-listlayers only dumps a layer's raw constraint list (tens of
|
||||
# thousands of entries on a busy node); it has no aggregate view. This
|
||||
# rolls that dump up into the two numbers that actually describe a layer's
|
||||
# routing knowledge:
|
||||
#
|
||||
# breadth = how many distinct channel-directions the layer knows about
|
||||
# (cardinality of short_channel_id_dir).
|
||||
# depth = how many constraint entries are stacked on each direction
|
||||
# (min / avg / median / p90 / max across directions).
|
||||
#
|
||||
# Each constraint carries a maximum_msat (an upper bound learned from a
|
||||
# sendpay 204 — "this direction could NOT move that much") and/or a
|
||||
# minimum_msat (a lower bound from positive reinforcement — "this direction
|
||||
# DID move at least that much"). The max-bound breadth is the load-bearing
|
||||
# number: a doomed route is only short-circuited at getroutes time once the
|
||||
# dry direction on it carries a maximum_msat bound, so dirs_with_max climbing
|
||||
# is what predicts a 204-storm self-resolving into immediate rejections.
|
||||
#
|
||||
# Usage (runs lightning-cli itself, like the other contrib scripts):
|
||||
# clboss-askrene-layer-summary [layer] [--top N] [network/dir options]
|
||||
# clboss-askrene-layer-summary clboss --top 20
|
||||
# clboss-askrene-layer-summary clboss --signet --lightning-dir=/path/to/dir
|
||||
# clboss-askrene-layer-summary xpay --json
|
||||
#
|
||||
# Offline replay of a captured dump (no node access needed):
|
||||
# clboss-askrene-layer-summary --input dump.json clboss
|
||||
# lightning-cli askrene-listlayers clboss | clboss-askrene-layer-summary --input -
|
||||
#
|
||||
# Works on any persistent layer (clboss, xpay, ...). Default layer: clboss.
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
|
||||
|
||||
def run_lightning_cli_command(lightning_dir, network_option, command, *args):
|
||||
command = ["lightning-cli", network_option, command, *args]
|
||||
if lightning_dir:
|
||||
command = command[:2] + [f"--lightning-dir={lightning_dir}"] + command[2:]
|
||||
result = subprocess.run(command, capture_output=True, text=True, check=True)
|
||||
return json.loads(result.stdout)
|
||||
|
||||
|
||||
def percentile(sorted_vals, q):
|
||||
"""Nearest-rank percentile (q in [0,1]); sorted_vals must be sorted."""
|
||||
if not sorted_vals:
|
||||
return 0
|
||||
if len(sorted_vals) == 1:
|
||||
return sorted_vals[0]
|
||||
idx = int(round(q * (len(sorted_vals) - 1)))
|
||||
return sorted_vals[idx]
|
||||
|
||||
|
||||
def load_layers(args, network_option):
|
||||
if args.input is not None:
|
||||
raw = sys.stdin.read() if args.input == "-" else open(args.input).read()
|
||||
return json.loads(raw).get("layers", [])
|
||||
res = run_lightning_cli_command(
|
||||
args.lightning_dir, network_option, "askrene-listlayers", args.layer
|
||||
)
|
||||
return (res or {}).get("layers", [])
|
||||
|
||||
|
||||
def summarize(layer):
|
||||
constraints = layer.get("constraints", [])
|
||||
|
||||
# Group entries by channel-direction; tally bound kinds.
|
||||
depth = {}
|
||||
dirs_with_max = set()
|
||||
dirs_with_min = set()
|
||||
max_entries = 0
|
||||
min_entries = 0
|
||||
for c in constraints:
|
||||
d = c.get("short_channel_id_dir")
|
||||
depth[d] = depth.get(d, 0) + 1
|
||||
if c.get("maximum_msat") is not None:
|
||||
dirs_with_max.add(d)
|
||||
max_entries += 1
|
||||
if c.get("minimum_msat") is not None:
|
||||
dirs_with_min.add(d)
|
||||
min_entries += 1
|
||||
|
||||
depths = sorted(depth.values())
|
||||
ndir = len(depths)
|
||||
return {
|
||||
"layer": layer.get("layer", "?"),
|
||||
"raw_constraints": len(constraints),
|
||||
"chan_dirs": ndir,
|
||||
"dirs_with_max": len(dirs_with_max),
|
||||
"dirs_with_min": len(dirs_with_min),
|
||||
"max_entries": max_entries,
|
||||
"min_entries": min_entries,
|
||||
"depth_min": depths[0] if depths else 0,
|
||||
"depth_avg": round(sum(depths) / ndir, 2) if ndir else 0,
|
||||
"depth_median": percentile(depths, 0.5),
|
||||
"depth_p90": percentile(depths, 0.9),
|
||||
"depth_max": depths[-1] if depths else 0,
|
||||
"disabled_nodes": len(layer.get("disabled_nodes", [])),
|
||||
"created_channels": len(layer.get("created_channels", [])),
|
||||
"channel_updates": len(layer.get("channel_updates", [])),
|
||||
"_depth": depth,
|
||||
}
|
||||
|
||||
|
||||
def main():
|
||||
ap = argparse.ArgumentParser(
|
||||
description="Breadth/depth census of an askrene layer."
|
||||
)
|
||||
ap.add_argument("layer", nargs="?", default="clboss",
|
||||
help="layer name (default: clboss)")
|
||||
ap.add_argument("--top", type=int, default=0, metavar="N",
|
||||
help="also list the N deepest channel-directions")
|
||||
ap.add_argument("--json", action="store_true",
|
||||
help="emit the summary as JSON")
|
||||
ap.add_argument("--input", metavar="FILE",
|
||||
help="read askrene-listlayers JSON from FILE ('-' = stdin) "
|
||||
"instead of calling lightning-cli")
|
||||
# Network / data-dir selection — same convention as the other contrib
|
||||
# scripts (clboss-earnings-history, ...).
|
||||
ap.add_argument("--mainnet", action="store_true", help="Run on mainnet")
|
||||
ap.add_argument("--testnet", action="store_true", help="Run on testnet")
|
||||
ap.add_argument("--signet", action="store_true", help="Run on signet")
|
||||
ap.add_argument("--regtest", action="store_true", help="Run on regtest")
|
||||
ap.add_argument("--network", help="Set the network explicitly")
|
||||
ap.add_argument("--lightning-dir", help="lightning data location")
|
||||
args = ap.parse_args()
|
||||
|
||||
# Reconcile network option (lightning-cli wants "bitcoin" for mainnet).
|
||||
if args.network:
|
||||
network_option = f"--network={args.network}"
|
||||
elif args.testnet:
|
||||
network_option = "--network=testnet"
|
||||
elif args.signet:
|
||||
network_option = "--network=signet"
|
||||
elif args.regtest:
|
||||
network_option = "--network=regtest"
|
||||
else:
|
||||
network_option = "--network=bitcoin"
|
||||
|
||||
if args.lightning_dir:
|
||||
assert os.path.isdir(args.lightning_dir), \
|
||||
f'"{args.lightning_dir}" is not a valid directory'
|
||||
|
||||
layers = load_layers(args, network_option)
|
||||
target = next((l for l in layers if l.get("layer") == args.layer), None)
|
||||
if target is None:
|
||||
if len(layers) == 1:
|
||||
# A piped/offline dump of a single layer: use it, but say so —
|
||||
# never silently answer about a different layer than was asked.
|
||||
target = layers[0]
|
||||
print(f"note: layer {args.layer!r} not found; "
|
||||
f"using the only layer present: {target.get('layer')!r}",
|
||||
file=sys.stderr)
|
||||
else:
|
||||
names = ", ".join(l.get("layer", "?") for l in layers) or "(none)"
|
||||
sys.exit(f"layer {args.layer!r} not found; present: {names}")
|
||||
|
||||
s = summarize(target)
|
||||
depth = s.pop("_depth")
|
||||
|
||||
if args.json:
|
||||
print(json.dumps(s, indent=2))
|
||||
else:
|
||||
print(f"layer {s['layer']}")
|
||||
print(f"raw constraints {s['raw_constraints']}")
|
||||
print(f"chan-dirs {s['chan_dirs']} (breadth)")
|
||||
print(f" with max-bound {s['dirs_with_max']} "
|
||||
f"({s['max_entries']} entries; 204-learned upper bounds)")
|
||||
print(f" with min-bound {s['dirs_with_min']} "
|
||||
f"({s['min_entries']} entries; reinforcement lower bounds)")
|
||||
print(f"depth per dir min={s['depth_min']} median={s['depth_median']} "
|
||||
f"avg={s['depth_avg']} p90={s['depth_p90']} max={s['depth_max']}")
|
||||
print(f"disabled nodes {s['disabled_nodes']}")
|
||||
if s["created_channels"] or s["channel_updates"]:
|
||||
print(f"created chans {s['created_channels']} "
|
||||
f"chan updates {s['channel_updates']}")
|
||||
|
||||
if args.top > 0:
|
||||
print(f"\ntop {args.top} deepest channel-directions:")
|
||||
for d, n in sorted(depth.items(), key=lambda kv: -kv[1])[:args.top]:
|
||||
print(f" {n:6d} {d}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Loading…
Add table
Add a link
Reference in a new issue