contrib: clboss-askrene-layer-summary, breadth/depth census of an askrene layer
Some checks failed
Code Base Sanity Check / tests (push) Has been cancelled
Code Base Sanity Check / coverage (push) Has been cancelled
Code Base Sanity Check / build-clang (push) Has been cancelled

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:
Ken Sedgwick 2026-06-26 17:48:45 -07:00
parent 16fc6b0b7b
commit 07a0c2e1d0
No known key found for this signature in database
GPG key ID: DBD2AF0849D711A9

View 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()