mirror of
https://github.com/lightningnetwork/lnd.git
synced 2026-08-18 13:07:58 +02:00
simulation: add the exp-017 family corpus tooling
In this commit, we give the corpus generator the two knobs exp-017 sweeps over. gen_scenarios.py grows --liquidity-family (override the emitted liquidity_model string verbatim) and --amount-family (lognormal with the median pinned to the tiered amount, or amounts snapped to a round-value ladder so they collide with each other and with liquidity bounds, the way real payments do). The default path makes exactly the original rng draws in the original order, verified byte-identical across six invocation shapes against the pre-change script. gen_family_corpora.py emits the exp-017 layout: ten hard-tier base scenarios drawn once from a fixed master seed, then one directory per family where file i is identical to the base except the single field under test. That pairing property is what lets per-file paired deltas isolate the generator from topology noise. gen_mainnet_variants.py does the same transform to existing mainnet scenario files as a one-line text substitution, with a parse-and-compare assertion that nothing else moved.
This commit is contained in:
parent
817804ea50
commit
388fc426b9
3 changed files with 435 additions and 12 deletions
171
simulation/gen_family_corpora.py
Normal file
171
simulation/gen_family_corpora.py
Normal file
|
|
@ -0,0 +1,171 @@
|
|||
#!/usr/bin/env python3
|
||||
"""Emit the exp-017 robustness corpora: one tier per generator family.
|
||||
|
||||
Every champion this program has produced was evolved against one hidden
|
||||
liquidity generator (`sim_liquidity.go`'s exponential draw at 5% of capacity)
|
||||
and one payment-amount ladder (fixed fractions of a channel). Both were
|
||||
written by us, and the evolved priors fit the first of them closely enough
|
||||
that the fit is the top pre-upstream worry in the notebook. This driver builds
|
||||
the tiers that put a number on it: the same scenarios, replayed under
|
||||
liquidity families and amount families the routers never saw.
|
||||
|
||||
The layout is deliberately paired. Ten base scenarios are drawn once from
|
||||
fixed master seeds, and every tier is those same ten with exactly one thing
|
||||
changed:
|
||||
|
||||
<out>/liq-<slug>/scen-NNN.json same everything, different
|
||||
liquidity_model string
|
||||
<out>/amt-<family>/scen-NNN.json same everything, different amounts
|
||||
|
||||
So file i in liq-uniform is file i in liq-bimodal down to the byte except for
|
||||
the model string, and a per-file paired delta between two tiers measures the
|
||||
generator and nothing else -- no topology luck, no payment luck. liq-bimodal
|
||||
is the control for both axes: it is the base corpus untouched, so the amount
|
||||
tiers pair against it as well.
|
||||
|
||||
The liquidity strings pass through to the simulator verbatim; Python never
|
||||
interprets them.
|
||||
|
||||
Usage:
|
||||
python3 simulation/gen_family_corpora.py --out /tmp/exp017
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import hashlib
|
||||
import json
|
||||
import random
|
||||
from pathlib import Path
|
||||
|
||||
import gen_scenarios
|
||||
|
||||
# The liquidity families under test. "bimodal" is the legacy string and the
|
||||
# control; the rest are the parameterized families the simulator learned to
|
||||
# parse for exp-017. Ordering is control first so the summary reads as a
|
||||
# ladder away from home.
|
||||
LIQUIDITY_FAMILIES = [
|
||||
"bimodal",
|
||||
"bimodal:0.01",
|
||||
"bimodal:0.2",
|
||||
"beta:0.3:0.3",
|
||||
"beta:2:2",
|
||||
"uniform",
|
||||
"hubdrain:0.05",
|
||||
]
|
||||
|
||||
# The amount families under test. Liquidity stays on the legacy bimodal
|
||||
# generator here so the two axes never move at once.
|
||||
AMOUNT_FAMILIES = ["lognormal", "round"]
|
||||
|
||||
|
||||
def slug(family: str) -> str:
|
||||
"""Directory-safe form of a family string."""
|
||||
return family.replace(":", "_")
|
||||
|
||||
|
||||
def derive_seed(*parts) -> int:
|
||||
"""A stable seed from a tuple of labels.
|
||||
|
||||
Explicitly hashed rather than taken from hash(), which is randomized per
|
||||
process for strings and would make the amount tiers unreproducible.
|
||||
"""
|
||||
key = "|".join(str(part) for part in parts).encode()
|
||||
|
||||
return int.from_bytes(hashlib.sha256(key).digest()[:8], "big")
|
||||
|
||||
|
||||
def base_examples(count: int, seed: int) -> list:
|
||||
"""The shared hard-tier scenarios every family variant is built from."""
|
||||
gen_scenarios.use_hard_profile()
|
||||
rng = random.Random(seed)
|
||||
|
||||
return [gen_scenarios.gen_example(rng) for _ in range(count)]
|
||||
|
||||
|
||||
def write_tier(out: Path, name: str, examples: list) -> Path:
|
||||
tier = out / name
|
||||
tier.mkdir(parents=True, exist_ok=True)
|
||||
for idx, example in enumerate(examples):
|
||||
(tier / f"scen-{idx:03d}.json").write_text(
|
||||
json.dumps(example, indent=2),
|
||||
)
|
||||
|
||||
return tier
|
||||
|
||||
|
||||
def amount_summary(examples: list) -> dict:
|
||||
"""Min / median / max payment amount over a tier, in sats."""
|
||||
amts = sorted(
|
||||
scenario["amt_msat"] // 1000
|
||||
for example in examples
|
||||
for scenario in example["scenarios"]
|
||||
)
|
||||
|
||||
return {
|
||||
"payments": len(amts),
|
||||
"min_sat": amts[0],
|
||||
"median_sat": amts[len(amts) // 2],
|
||||
"max_sat": amts[-1],
|
||||
}
|
||||
|
||||
|
||||
def main() -> None:
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("--out", required=True, help="output directory")
|
||||
parser.add_argument("--files", type=int, default=10,
|
||||
help="scenario files per family tier")
|
||||
parser.add_argument("--seed", type=int, default=20260727,
|
||||
help="master seed for the shared base scenarios")
|
||||
args = parser.parse_args()
|
||||
|
||||
out = Path(args.out)
|
||||
out.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
bases = base_examples(args.files, args.seed)
|
||||
|
||||
manifest = []
|
||||
for family in LIQUIDITY_FAMILIES:
|
||||
# A fresh copy per tier: only the model string moves, and the
|
||||
# scenario lists must not be shared between tiers.
|
||||
examples = [json.loads(json.dumps(base)) for base in bases]
|
||||
for example in examples:
|
||||
example["liquidity_model"] = family
|
||||
name = f"liq-{slug(family)}"
|
||||
write_tier(out, name, examples)
|
||||
manifest.append({
|
||||
"tier": name,
|
||||
"axis": "liquidity",
|
||||
"liquidity_model": family,
|
||||
"amount_family": "tiered",
|
||||
**amount_summary(examples),
|
||||
})
|
||||
|
||||
for family in AMOUNT_FAMILIES:
|
||||
examples = [json.loads(json.dumps(base)) for base in bases]
|
||||
for idx, example in enumerate(examples):
|
||||
# One rng per file, derived from the master seed, so a tier
|
||||
# regenerates identically and one file's draws never depend on
|
||||
# how many files came before it.
|
||||
rng = random.Random(derive_seed(args.seed, family, idx))
|
||||
gen_scenarios.apply_amount_family(example, family, rng)
|
||||
name = f"amt-{slug(family)}"
|
||||
write_tier(out, name, examples)
|
||||
manifest.append({
|
||||
"tier": name,
|
||||
"axis": "amount",
|
||||
"liquidity_model": "bimodal",
|
||||
"amount_family": family,
|
||||
**amount_summary(examples),
|
||||
})
|
||||
|
||||
(out / "manifest.json").write_text(json.dumps(manifest, indent=2))
|
||||
|
||||
print(f"{len(manifest)} tiers x {args.files} files in {out}")
|
||||
for entry in manifest:
|
||||
print(f" {entry['tier']:18s} liq={entry['liquidity_model']:14s} "
|
||||
f"amt={entry['amount_family']:9s} "
|
||||
f"sats min={entry['min_sat']:,} "
|
||||
f"median={entry['median_sat']:,} max={entry['max_sat']:,}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
124
simulation/gen_mainnet_variants.py
Normal file
124
simulation/gen_mainnet_variants.py
Normal file
|
|
@ -0,0 +1,124 @@
|
|||
#!/usr/bin/env python3
|
||||
"""Re-model the liquidity of an existing mainnet scenario file.
|
||||
|
||||
The exp-009 mainnet tier is a set of scenario files that were generated once,
|
||||
by hand, against the 12,161-node describegraph snapshot: a fixed hub source, a
|
||||
fixed payment list, fixed liquidity seeds. Every mainnet number the notebook
|
||||
publishes is measured on those exact files, so exp-017 must not regenerate
|
||||
them -- redrawing the payments would move the comparison for reasons that have
|
||||
nothing to do with the liquidity generator under test.
|
||||
|
||||
This script therefore edits the files as TEXT. It rewrites the value of
|
||||
`liquidity_model` and nothing else, so a variant is byte-identical to its
|
||||
source everywhere except that one string: same graph, same source, same
|
||||
targets, same amounts, same liquidity_seed. A per-file paired delta between a
|
||||
variant and its source is then a measurement of the generator alone.
|
||||
|
||||
Usage:
|
||||
python3 simulation/gen_mainnet_variants.py \
|
||||
--scenario /path/to/scen-mainnet.json --out /tmp/exp017-mainnet
|
||||
|
||||
# the whole exp-009 ten-file set at once
|
||||
python3 simulation/gen_mainnet_variants.py \
|
||||
--scenario '/path/to/mn_*.json' --out /tmp/exp017-mainnet
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import glob as globmod
|
||||
import json
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
# The families exp-017 asks the mainnet tier: a fatter bimodal, a U-shaped
|
||||
# beta that pushes balances to the ends, and flat uniform.
|
||||
DEFAULT_FAMILIES = ["bimodal:0.2", "beta:0.3:0.3", "uniform"]
|
||||
|
||||
# Matches the liquidity_model entry whatever the file's spacing, capturing
|
||||
# everything around the value so it can be put back untouched.
|
||||
MODEL_RE = re.compile(r'("liquidity_model"\s*:\s*")([^"]*)(")')
|
||||
|
||||
|
||||
def slug(family: str) -> str:
|
||||
return family.replace(":", "_")
|
||||
|
||||
|
||||
def retarget(text: str, family: str) -> str:
|
||||
"""Swap the liquidity model in a scenario file's raw text."""
|
||||
new, count = MODEL_RE.subn(
|
||||
lambda m: m.group(1) + family + m.group(3), text,
|
||||
)
|
||||
if count != 1:
|
||||
raise ValueError(
|
||||
f"expected exactly one liquidity_model, found {count}",
|
||||
)
|
||||
|
||||
return new
|
||||
|
||||
|
||||
def check_only_model_moved(before: str, after: str, family: str) -> None:
|
||||
"""Parse both sides and assert the model string is the only change."""
|
||||
old = json.loads(before)
|
||||
new = json.loads(after)
|
||||
if new["liquidity_model"] != family:
|
||||
raise ValueError("rewrite did not take")
|
||||
|
||||
old.pop("liquidity_model")
|
||||
new.pop("liquidity_model")
|
||||
if old != new:
|
||||
raise ValueError("rewrite touched something other than the model")
|
||||
|
||||
|
||||
def main() -> None:
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("--scenario", required=True,
|
||||
help="scenario file, directory of scenario files, or "
|
||||
"glob. The exp-009 mainnet tier is scen-mainnet.json "
|
||||
"(hub vantage) or the mn_*.json set")
|
||||
parser.add_argument("--out", required=True, help="output directory")
|
||||
parser.add_argument("--family", action="append", default=None,
|
||||
help="liquidity model string; repeatable. Defaults "
|
||||
f"to {', '.join(DEFAULT_FAMILIES)}")
|
||||
parser.add_argument("--control", action="store_true",
|
||||
help="also copy each source file through unchanged, "
|
||||
"so the control sits in the same directory layout as "
|
||||
"the variants")
|
||||
args = parser.parse_args()
|
||||
|
||||
families = args.family or DEFAULT_FAMILIES
|
||||
|
||||
path = Path(args.scenario)
|
||||
if path.is_dir():
|
||||
sources = sorted(path.glob("*.json"))
|
||||
elif path.is_file():
|
||||
sources = [path]
|
||||
else:
|
||||
sources = [Path(p) for p in sorted(globmod.glob(args.scenario))]
|
||||
|
||||
if not sources:
|
||||
parser.error(f"no scenario files matched {args.scenario}")
|
||||
|
||||
out = Path(args.out)
|
||||
out.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
written = 0
|
||||
for source in sources:
|
||||
text = source.read_text()
|
||||
stem = source.stem
|
||||
|
||||
if args.control:
|
||||
(out / f"{stem}.json").write_text(text)
|
||||
written += 1
|
||||
|
||||
for family in families:
|
||||
variant = retarget(text, family)
|
||||
check_only_model_moved(text, variant, family)
|
||||
(out / f"{stem}-{slug(family)}.json").write_text(variant)
|
||||
written += 1
|
||||
|
||||
print(f"wrote {written} files to {out} "
|
||||
f"({len(sources)} source(s) x {len(families)} famil(ies)"
|
||||
f"{' + control' if args.control else ''})")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
|
|
@ -11,10 +11,18 @@ seed candidate has real failures to learn from.
|
|||
--hard drops the easy scale-free nets, --drift lets liquidity churn between
|
||||
payments (exp-008), and --split generates a corpus that isolates MPP splitting
|
||||
(exp-010).
|
||||
|
||||
--liquidity-family and --amount-family are the exp-017 robustness knobs: they
|
||||
swap the hidden-liquidity generator and the payment-amount distribution the
|
||||
corpus is drawn from, so a router can be checked against families it was never
|
||||
evolved against. Both default to the historical behaviour and, on that
|
||||
default, draw from the rng in exactly the original order: a corpus regenerated
|
||||
from a fixed seed is byte-identical to the one generated before they existed.
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import math
|
||||
import random
|
||||
from pathlib import Path
|
||||
|
||||
|
|
@ -35,6 +43,118 @@ TOPOLOGIES = [
|
|||
# Bimodal dominates: it is both the realistic and the hard regime.
|
||||
LIQUIDITY_MODELS = ["bimodal", "bimodal", "uniform"]
|
||||
|
||||
# The --hard profile: small channels with headroom, bimodal only. Kept as
|
||||
# module constants so a driver can import this module and reproduce the hard
|
||||
# corpus without shelling out (gen_family_corpora.py does exactly that).
|
||||
HARD_TOPOLOGIES = [
|
||||
{"type": "smallworld", "num_nodes": 300,
|
||||
"channel_size_sat": 2_000_000, "avg_degree": 6},
|
||||
{"type": "smallworld", "num_nodes": 600,
|
||||
"channel_size_sat": 1_000_000, "avg_degree": 6},
|
||||
{"type": "grid", "num_nodes": 150,
|
||||
"channel_size_sat": 2_000_000},
|
||||
{"type": "hubspoke", "num_nodes": 200,
|
||||
"channel_size_sat": 4_000_000},
|
||||
]
|
||||
HARD_LIQUIDITY_MODELS = ["bimodal"]
|
||||
|
||||
|
||||
def use_hard_profile() -> None:
|
||||
"""Swap the module's topology and liquidity tables for the hard ones."""
|
||||
global TOPOLOGIES, LIQUIDITY_MODELS
|
||||
TOPOLOGIES = list(HARD_TOPOLOGIES)
|
||||
LIQUIDITY_MODELS = list(HARD_LIQUIDITY_MODELS)
|
||||
|
||||
|
||||
# --- amount families (exp-017) ----------------------------------------------
|
||||
#
|
||||
# The tiered amounts below are drawn from a short list of fractions of a
|
||||
# channel, which makes every amount in the corpus a round fraction of a round
|
||||
# capacity. That is a distribution the champions were evolved against, so it
|
||||
# is exactly the kind of thing they could be overfitting to. These two
|
||||
# alternatives keep the scenario otherwise untouched and only re-draw the
|
||||
# amounts.
|
||||
|
||||
# Payments below this are dust the simulator will not route.
|
||||
MIN_AMT_MSAT = 1_000
|
||||
|
||||
# Spread of the lognormal family, in natural log units. The median is pinned
|
||||
# to the amount the tiered logic would have produced, so sigma alone decides
|
||||
# how far the family strays from it: at 1.0 the middle half of the draws lands
|
||||
# within roughly half to twice the tiered amount and the tail runs an order of
|
||||
# magnitude past it.
|
||||
LOGNORMAL_SIGMA = 1.0
|
||||
|
||||
# Real payments cluster on round numbers, and round numbers collide: with
|
||||
# everyone sending 100k sats, one node's failure bound sits exactly at the
|
||||
# amount the next node is about to send. The ladder is 1 and 5 per decade of
|
||||
# satoshis, which is where invoice amounts actually pile up.
|
||||
ROUND_LADDER_MSAT = [
|
||||
mult * 10 ** exp * 1000
|
||||
for exp in range(3, 12)
|
||||
for mult in (1, 5)
|
||||
]
|
||||
|
||||
|
||||
def amount_scale_msat(example: dict) -> int:
|
||||
"""The capacity the example's tiered amounts were sized against.
|
||||
|
||||
For the ordinary topologies that is one channel; for the corridors
|
||||
topology it is the fattest tier, which is the head every --split amount is
|
||||
quoted as a multiple of.
|
||||
"""
|
||||
topology = example["topology"]
|
||||
if topology["type"] == "corridors":
|
||||
return corridor_tiers_msat(topology)[0]
|
||||
|
||||
return topology["channel_size_sat"] * 1000
|
||||
|
||||
|
||||
def snap_round_msat(amt_msat: int) -> int:
|
||||
"""The nearest round amount on a log scale."""
|
||||
target = math.log(max(amt_msat, MIN_AMT_MSAT))
|
||||
|
||||
return min(ROUND_LADDER_MSAT, key=lambda r: abs(math.log(r) - target))
|
||||
|
||||
|
||||
def apply_amount_family(example: dict, family: str,
|
||||
rng: random.Random) -> dict:
|
||||
"""Re-draw an example's payment amounts under an amount family.
|
||||
|
||||
"tiered" is the historical behaviour and touches neither the amounts nor
|
||||
the rng, so the default path draws exactly what it always drew. The other
|
||||
families rewrite the amount of every payment in place and leave targets,
|
||||
part limits, topology and seeds alone, which is what makes a family corpus
|
||||
pair file-for-file with its control.
|
||||
"""
|
||||
if family == "tiered":
|
||||
return example
|
||||
|
||||
# A lognormal tail can run arbitrarily far; past twice the capacity the
|
||||
# amount was sized against, a payment is no longer a hard payment but an
|
||||
# impossible one, and impossible payments score every router the same.
|
||||
ceiling = max(2 * amount_scale_msat(example), MIN_AMT_MSAT)
|
||||
|
||||
for scenario in example["scenarios"]:
|
||||
tiered = max(int(scenario["amt_msat"]), MIN_AMT_MSAT)
|
||||
if family == "lognormal":
|
||||
# Median pinned to the tiered amount, so the family is a spread
|
||||
# around what this file would otherwise have asked for rather
|
||||
# than a different corpus difficulty.
|
||||
drawn = rng.lognormvariate(math.log(tiered), LOGNORMAL_SIGMA)
|
||||
amt = min(max(int(round(drawn)), MIN_AMT_MSAT), ceiling)
|
||||
elif family == "round":
|
||||
# Snapping moves an amount by at most sqrt(5), so it needs no
|
||||
# ceiling of its own: clamping it would only push amounts back
|
||||
# off the ladder, which is the whole point of the family.
|
||||
amt = max(snap_round_msat(tiered), MIN_AMT_MSAT)
|
||||
else:
|
||||
raise ValueError(f"unknown amount family: {family}")
|
||||
|
||||
scenario["amt_msat"] = amt
|
||||
|
||||
return example
|
||||
|
||||
# --- splitting pressure (exp-010) -------------------------------------------
|
||||
#
|
||||
# The corridors topology puts one source and one target at the ends of K
|
||||
|
|
@ -275,6 +395,20 @@ def main() -> None:
|
|||
"corpus; 8-10 raises per-file score resolution "
|
||||
"so minibatch selection can see the "
|
||||
"attempt-efficiency signal")
|
||||
parser.add_argument("--liquidity-family", default=None,
|
||||
help="override every scenario's liquidity_model "
|
||||
"with this exact string, e.g. bimodal:0.2, "
|
||||
"beta:0.3:0.3, uniform, hubdrain:0.05 (exp-017). "
|
||||
"The string passes through to the simulator "
|
||||
"verbatim; liquidity seeds are untouched")
|
||||
parser.add_argument("--amount-family", default="tiered",
|
||||
choices=["tiered", "lognormal", "round"],
|
||||
help="payment-amount distribution (exp-017). "
|
||||
"tiered is the historical fractions-of-a-channel "
|
||||
"ladder, lognormal spreads around it with the "
|
||||
"tiered amount as the median, round snaps to the "
|
||||
"1/5-per-decade satoshi ladder real invoices "
|
||||
"cluster on")
|
||||
args = parser.parse_args()
|
||||
|
||||
# --split isolates one variable, so it does not mix with the other corpus
|
||||
|
|
@ -283,19 +417,8 @@ def main() -> None:
|
|||
if args.split and (args.hard or args.drift):
|
||||
parser.error("--split composes with neither --hard nor --drift")
|
||||
|
||||
global TOPOLOGIES, LIQUIDITY_MODELS
|
||||
if args.hard:
|
||||
TOPOLOGIES = [
|
||||
{"type": "smallworld", "num_nodes": 300,
|
||||
"channel_size_sat": 2_000_000, "avg_degree": 6},
|
||||
{"type": "smallworld", "num_nodes": 600,
|
||||
"channel_size_sat": 1_000_000, "avg_degree": 6},
|
||||
{"type": "grid", "num_nodes": 150,
|
||||
"channel_size_sat": 2_000_000},
|
||||
{"type": "hubspoke", "num_nodes": 200,
|
||||
"channel_size_sat": 4_000_000},
|
||||
]
|
||||
LIQUIDITY_MODELS = ["bimodal"]
|
||||
use_hard_profile()
|
||||
|
||||
rng = random.Random(args.seed)
|
||||
out = Path(args.out)
|
||||
|
|
@ -311,6 +434,11 @@ def main() -> None:
|
|||
)
|
||||
else:
|
||||
example = gen_example(rng, drift=args.drift)
|
||||
# Both family overrides run after the example is complete, so
|
||||
# they never move a draw the default path makes.
|
||||
apply_amount_family(example, args.amount_family, rng)
|
||||
if args.liquidity_family is not None:
|
||||
example["liquidity_model"] = args.liquidity_family
|
||||
if args.atomic:
|
||||
for scenario in example["scenarios"]:
|
||||
scenario["atomic_mpp"] = True
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue