diff --git a/simulation/gen_family_corpora.py b/simulation/gen_family_corpora.py new file mode 100644 index 000000000..64a6ed5f0 --- /dev/null +++ b/simulation/gen_family_corpora.py @@ -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: + + /liq-/scen-NNN.json same everything, different + liquidity_model string + /amt-/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() diff --git a/simulation/gen_mainnet_variants.py b/simulation/gen_mainnet_variants.py new file mode 100644 index 000000000..83598fbec --- /dev/null +++ b/simulation/gen_mainnet_variants.py @@ -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() diff --git a/simulation/gen_scenarios.py b/simulation/gen_scenarios.py index 880f1fc38..99145cf1c 100644 --- a/simulation/gen_scenarios.py +++ b/simulation/gen_scenarios.py @@ -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