lnd/simulation/evaluate.py
Olaoluwa Osuntokun f7ad893bdd simulation: add GEPA optimization harness
In this commit, we add the Python harness that drives GEPA's
optimize_anything over the routesim evaluator. Two modes are supported:
parameter mode tunes the existing heuristic's knobs as a JSON candidate,
while code mode evolves the entire routing algorithm as the full Go
source of candidate_impl.go, compiled per-eval via go build -overlay
with compiler errors fed back to the proposer as reflection signal.

The evaluator scores success rate with small saturating penalties for
retry attempts and fees, and guards against reward hacking by rejecting
candidates that reach for unsafe, reflect, or exec surfaces. The
reflection LM runs through the Codex CLI in headless mode via a small
LM-protocol wrapper, and omni-style two-phase composition (parallel
explore, then a fresh engine seeded with the winner) is available
alongside plain and adaptive runs. The background prompt encodes the
insights discovered by prior champion runs so follow-up evolution builds
on them rather than rediscovering them.
2026-07-24 13:01:06 -07:00

136 lines
4.4 KiB
Python

#!/usr/bin/env python3
"""The routesim evaluator: candidate params JSON in, (score, feedback) out.
The score composes the three signals a Lightning sender cares about, in
strict priority order:
1. success rate (dominant term),
2. attempts per payment (latency proxy, small penalty),
3. fees paid in ppm on delivered value (small penalty).
The feedback dict carries the aggregate plus compact traces of the failed
payments (failure code and position per attempt), which is what a reflective
optimizer needs to reason about *why* a parameter set underperforms.
"""
import json
import os
import subprocess
import tempfile
ROUTESIM = os.environ.get("ROUTESIM_BIN", "routesim")
# Score weights: one extra attempt costs 1% success rate; 1000 ppm in fees
# costs 2%. Both penalties saturate so that success rate always dominates:
# the worst possible score is success_rate - 0.25, keeping deltas in
# success rate visible to the optimizer even on pathological runs.
ATTEMPT_WEIGHT = 0.01
ATTEMPT_CAP = 15
FEE_WEIGHT = 0.00002
FEE_PPM_CAP = 5_000
def run_routesim(params_json: str, scenario_path: str) -> dict:
"""Run one scenario file under the candidate params, returning the
parsed output."""
with tempfile.NamedTemporaryFile(
mode="w", suffix=".json", delete=False) as f:
f.write(params_json)
params_path = f.name
try:
proc = subprocess.run(
[ROUTESIM, "--params", params_path,
"--scenarios", scenario_path],
capture_output=True, text=True, timeout=300,
)
if proc.returncode != 0:
raise RuntimeError(proc.stderr.strip())
return json.loads(proc.stdout)
finally:
os.unlink(params_path)
def summarize_failures(results: list, limit: int = 5) -> list:
"""Compact per-failure traces for reflection feedback."""
failures = []
for result in results:
if result["success"]:
continue
attempts = result.get("attempts") or []
failures.append({
"target": result["scenario"]["target"],
"amt_msat": result["scenario"]["amt_msat"],
"num_attempts": len(attempts),
"terminal_error": result.get("error", ""),
"attempt_failures": [
{
"hops": len(a["hops"]),
"failure": a.get("failure", ""),
"failed_at_hop": a.get("failure_hop", -1),
}
for a in attempts[-4:] # the last attempts show the endgame
],
})
if len(failures) >= limit:
break
return failures
def evaluate(candidate: str, example) -> tuple[float, dict]:
"""The optimize_anything evaluator contract.
`example` is the path to one scenario file.
"""
# Reject malformed candidates with actionable feedback.
try:
json.loads(candidate)
except json.JSONDecodeError as exc:
return 0.0, {"error": f"candidate is not valid JSON: {exc}"}
try:
output = run_routesim(candidate, str(example))
except Exception as exc: # simulator rejected the params
return 0.0, {"error": f"routesim failed: {exc}"}
agg = output["aggregate"]
extra_attempts = min(
max(agg["attempts_per_scenario"] - 1.0, 0.0), ATTEMPT_CAP,
)
fee_ppm = min(agg["fee_ppm_on_success"], FEE_PPM_CAP)
score = (
agg["success_rate"]
- ATTEMPT_WEIGHT * extra_attempts
- FEE_WEIGHT * fee_ppm
)
return score, {
"score": score,
"aggregate": agg,
"failed_payments": summarize_failures(output["results"]),
"hint": (
"success_rate dominates the score; extra attempts and fee ppm "
"apply small penalties. Failed payments show per-attempt "
"failure codes and the hop index where each failed. "
"TemporaryChannelFailure = liquidity miss (probability model "
"was wrong), FeeInsufficient/IncorrectCltvExpiry = policy "
"modeling bug."
),
}
if __name__ == "__main__":
# Manual check: evaluate the lnd defaults on one example.
import sys
defaults = subprocess.run(
[ROUTESIM, "--dump-defaults"], capture_output=True, text=True,
check=True,
).stdout
score, info = evaluate(defaults, sys.argv[1])
print(f"score={score:.4f}")
print(json.dumps(info["aggregate"], indent=2))
print(json.dumps(info["failed_payments"], indent=2))