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:
Olaoluwa Osuntokun 2026-07-27 00:07:17 -07:00
parent 817804ea50
commit 388fc426b9
3 changed files with 435 additions and 12 deletions

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

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

View file

@ -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