Dark Matter by Feedback: Uncertainty-Driven Simulation for Strong-Lensing Detectors
An agent that simulates gravitational lenses, trains dark-matter detectors on them, and uses what those detectors get wrong to choose the next batch of simulations
Built with: Python, Pydantic AI, Lenstronomy, PyAutoLens, NumPy, SciPy, PyTorch, Modal, marimo, TypeScript
Closing the loop between simulation and learning
DeepLenseLoop is an agent that plans and runs strong gravitational lensing simulation batches, trains classifiers and anomaly detectors on the results, and uses what those models are uncertain about to decide what to simulate next. A human approves every batch before it runs.
It was built in six phases over a week on Pydantic AI, Lenstronomy and Modal, with the machine-learning architectures ported from two papers by the same research group.
- Accuracy
- 0.93 held-outResNet-18, 15k images
- Weak region
- 0.29 → 0.62after one round
- Tool cost
- 24× fewer tokensthan no tools
- Engine check
- ≤ 1.2% disagreementvs PyAutoLens
Dark matter leaves fingerprints on lensed galaxies, and finding them needs data nobody has
A massive galaxy bends the light of one behind it into arcs. Small clumps of dark matter in the lens perturb those arcs by about one percent, and what the clumps look like depends on what dark matter is. Cold dark matter predicts point-like subhalos; ultralight axion dark matter predicts vortex lines.
Both signatures are subtle and real strong lenses are rare, so the only way to train a detector is to simulate: thousands of images per hypothesis, across every plausible halo mass, redshift, mass fraction and axion mass. Alexander et al. (2019) classified such simulations with ResNet-18 and AlexNet; Alexander et al. (2021) did it without labels using autoencoders. Both spent most of their effort generating and curating simulations by hand.
HEPTAPOD (Menzo et al., 2026) showed the pattern for high-energy physics: wrap the simulation engines in schema-validated tools, coordinate them with run cards a human signs off on, and let an agent orchestrate. This project applies that pattern to lensing and adds the piece it was missing, a feedback signal.
The idea
The claim the project tests is simple: a batch of simulations chosen by where the current model is uncertain should help more than a batch of the same size chosen uniformly. If it does, the agent is planning under a real feedback signal rather than running a script. Everything else exists to make that comparison cheap enough to run repeatedly.
Try the simulator
The same engine the agent drives. Switch to the vortex class and slide the axion mass down: the vortex line stretches across the image and its perturbation of the arcs fades. That region is where the classifier turned out to be weakest.
Key decisions
The design went through three versions before any code existed. The first built everything by hand, including the agent loop. The second sat on Orchestral AI, the framework HEPTAPOD itself uses. The third, and the one that shipped, uses Pydantic AI, mostly because the run cards and dataset records were already Pydantic models, so tool arguments and structured outputs came for free. It also turned out to support approval-gated tools natively, which collapsed a planned two-agent propose-then-execute design into a single agent with one gated tool and an approval callback in our own code. That callback is a plain function returning approve, approve with edits, or deny, so it is unit-tested without an LLM, and the same function drives the terminal prompt, the benchmark's auto-approver, and the loop driver.
Compute and data went to Modal rather than a rented VM or the laptop. The pipeline is function-shaped, so each tool became a remote function behind the same submit-and-status interface as the local backend; batches shard across containers and training gets an L4 on demand. The images live on a Modal volume that mounts straight into the GPU containers, after a brief detour considering a vector database. The agent runs on Kimi K3 through Fireworks, though any Pydantic AI provider is a one-line change, and the ML models were ported layer for layer from the group's 2019 and 2021 papers rather than redesigned, since the contribution here is the loop around them, not the networks.
Architecture
Tools never talk to the simulator or to PyTorch directly. They talk to a compute backend, so a command-line flag moves the whole pipeline from the laptop to Modal.
What flows through it
The agent never sees pixels. It reasons over one JSON record per image with cheap statistics computed at generation time, and over per-image model scores produced at evaluation time. That is what makes the uncertainty step a small numpy function rather than another model.
The approval gate
Simulation batches and training jobs cost money, so those tools require approval. Pydantic AI pauses the run and returns the pending calls; a callback in our own code decides; the run resumes. The same callback drives the terminal prompt, the auto-approver used by benchmarks, and the loop driver.
What a run card looks like
{
"n_images": 2000,
"substructure": "vortex",
"halo_mass": 1e12,
"redshift_lens": 0.5,
"redshift_source": 1.0,
"substructure_mass_fraction": 0.03,
"axion_mass": 1.69e-24,
"instrument": "euclid",
"image_size": 64,
"seed": 1001
}A frozen Pydantic model with physics validation: the source must sit behind the lens, the mass fraction must be positive for substructure classes, and a card whose field of view cannot contain the Einstein radius is rejected before any pixel is rendered.
The build
Data model and simulator
LensCard (the run card a human approves), lensjsonl (one JSON record per image with cheap statistics the agent can reason over), and a port of DeepLenseSim's lenstronomy wrapper driven by a card instead of hard-coded constants. About 50 ms per image on a laptop.
What broke
- DeepLenseSim had four bugs: Einstein radii from luminosity instead of angular-diameter distances, a Poisson subhalo count drawn and then ignored, the Euclid image adding the model to itself, and a mass fraction that meant different things per class. Fixed and documented rather than copied. The correct Einstein radius for the default lens is 1.66 arcsec, not 1.35.
- A 32-pixel image at 0.05 arcsec per pixel silently produced pure noise: the arcs sat outside the field of view. A guard rejects any card whose field is narrower than the Einstein radius, as a structured failure the agent can read.
- lenstronomy had renamed its keyword arguments since DeepLenseSim was written. Two renames, found by inspecting the installed signatures instead of trusting the old code.
Agent, approval gate, compute backends
One Pydantic AI agent with simulation tools, an approval loop that pauses on expensive calls, and a ComputeBackend interface with a local implementation and a Modal implementation. Batches shard 500 images per container; per-image seeding makes a sharded remote run bit-identical to a local one.
What broke
- Pydantic AI could not resolve a tool's dependency type that was imported only for type checking. The dependency class moved to its own module and is imported for real.
- The shell running the automation was zsh, which does not word-split variables the way bash does. Six simulation runs got empty arguments before that was noticed. Explicit commands from then on.
ML tier on Modal GPUs
ResNet-18 and AlexNet with a single-channel stem, and the DCAE, VAE and AAE encoder/decoder/discriminator from the 2021 paper's appendix. Training and evaluation as remote functions; torch exists only in the Modal image.
What broke
- The 2021 autoencoders only close at 150 by 150 pixels; 64-pixel Euclid images produce a zero-size feature map. Images are resized on load, and the flattened size and decoder paddings are derived from the input size so 96 and 204 also work and 64 fails loudly.
- The first 15k-image training run died of container memory exhaustion while preloading. 16 GB per training container.
- A 44-second training job took 23 minutes because it read 15,000 files one at a time from a network volume. Threaded preloading. Same job, 44 seconds.
The uncertainty loop
sample_uncertainty joins a model's per-image scores with the simulation records, bins each class along physical axes (mass fraction, SNR, axion mass, subhalo count), ranks the cells, and proposes the next LensCard. A loop driver runs uncertainty → approve → simulate → retrain → evaluate without an LLM so the loop is testable offline.
What broke
- The first real round made the model worse: accuracy 0.92 to 0.43. The training code kept the last epoch's weights, and validation accuracy swung between 0.61 and 0.93 from epoch to epoch. Best-validation checkpointing and a cosine learning-rate schedule made rounds comparable.
- Float noise in mass fractions produced duplicate degenerate cells. Values are rounded before binning and single-valued axes are skipped.
PyAutoLens cross-check
A second engine renders the identical lens systems. The convention mapping between the two packages was found empirically by minimising the residual over every orientation and sign combination, then frozen in code.
What broke
- The first comparison was off by a factor of 94. One engine returns surface brightness, the other counts per pixel: the pixel area.
- A 'peak shift' metric reported 66-pixel offsets. On an Einstein ring the brightest pixel is ambiguous between two sides. Flux-weighted centroids agree to a twentieth of a pixel.
- PyAutoLens pins an older scikit-learn. It lives in its own virtual environment and its own Modal image.
Benchmark and report generator
A HEPTAPOD-style benchmark: the same model and prompt, one arm with the domain tools and one with only a sandboxed shell and file tools, graded by a cumulative four-stage rubric read off the sandbox. Plus a report generator that produced the figures on this page.
What broke
- The last rubric stage thresholded AUC, and a ResNet-18 trained from scratch on under 200 images sits at chance whatever the settings. The stage now checks that a held-out evaluation exists and that the number the agent reports matches the file. Correct reporting, not luck.
- The sandbox path check raised on macOS because temp directories resolve through a /private symlink. Resolve the workspace once.
What the numbers say
The models were trained on 15,000 simulated images. Every number below was measured on a separate set of 3,000 images, 1,000 per class, generated with different seeds and kept out of training, so the scores reflect what the models learned rather than what they memorised.
The dataset
5,000 training and 1,000 test images per class at 64 pixels in a Euclid-like setup, plus the batches added by the loop. 18,000 images rendered in about a minute of wall time, sharded across Modal containers.


The classifier
ResNet-18 with a single-channel stem, trained from scratch for ten epochs on the 15,000 training images. It is essentially perfect on smooth lenses and misses about one vortex in five, and the uncertainty analysis says exactly which ones.
| True \ predicted | none | subhalo | vortex | accuracy |
|---|---|---|---|---|
| none | 1000 | 0 | 0 | 1.000 |
| subhalo | 2 | 963 | 35 | 0.963 |
| vortex | 0 | 178 | 822 | 0.822 |

Explore it yourself
The same analysis, live on the 3,000 held-out scores. Pick a class and an axis, change the binning, and click a bar to see the most uncertain images in that cell.
The loop, with a control
The agent simulated 2,000 more vortex images at the axion mass of the weakest cell and retrained under an identical recipe. As a control, 2,000 images drawn uniformly across the three classes were added instead.
| Training data | images | accuracy | macro AUC | none | subhalo | vortex | weak cell |
|---|---|---|---|---|---|---|---|
| Base | 15,000 | 0.928 | 0.979 | 1.00 | 0.96 | 0.82 | 0.29 |
| + 2,000 uniform | 17,000 | 0.930 | 0.978 | 1.00 | 0.97 | 0.82 | 0.28 |
| + 2,000 targeted | 17,000 | 0.900 | 0.980 | 1.00 | 0.79 | 0.91 | 0.62 |
The uniform batch is indistinguishable from adding nothing. The targeted batch doubles accuracy in the weak region and lifts vortex accuracy from 0.82 to 0.91, at the cost of subhalos, which the added light-axion vortices most resemble and which are now outnumbered. A second round would flag subhalos as weakest and propose the balancing batch. That is the loop working as intended: the uncertainty signal finds a region uniform sampling cannot.
Tools versus no tools
Same model, same prompt, same sandbox. One arm has the domain tools; the other has only a shell and file tools, Python with lenstronomy and torch on the path, and the required output layout spelled out. Graded by a cumulative rubric read off the sandbox: simulate, label, train on training data only, report the held-out AUC correctly.
| Arm | pass | mean requests | mean tokens | mean time | AUC reached per trial |
|---|---|---|---|---|---|
| tool | 3 / 3 | 6 | 36k | 103 s | 0.61, 0.61, 0.61 |
| core | 3 / 3 | 30 | 857k | 894 s | 0.85, 0.96, 0.96 |

A strong model builds the whole pipeline from scratch and even reaches a higher AUC by engineering its own preprocessing: an asinh stretch, dihedral augmentation and a small-image ResNet stem. The domain tools trade that ceiling for 24 times fewer tokens, nine times less wall time, and an identical result on every trial.
Two engines, one answer
The same lens systems rendered through PyAutoLens agree with the lenstronomy port to about one percent of peak brightness. The residual is the packages' slightly different elliptical-radius conventions, and the point-mass substructure agrees as well as the smooth lens does.
| Class | shape residual, mean / max | flux ratio | centroid shift |
|---|---|---|---|
| none | 0.0063 / 0.0099 | 0.996 | ≤ 0.05 px |
| subhalo | 0.0074 / 0.0118 | 0.996 | ≤ 0.04 px |
| vortex | 0.0057 / 0.0065 | 0.996 | ≤ 0.04 px |
See it run
Limitations
- Three trials per arm. The pass@k estimators are exact for the sample, but the sample is small. Ten or more trials and a second model are the obvious next benchmark.
- The anomaly detector is at chance. The AAE reconstructs well but separates poorly, AUC 0.54, far from the 2021 paper's 0.93. At this resolution and mass fraction the substructure signal is one to three percent of peak, and ten epochs is a fraction of what the paper trained for.
- Targeting has a cost. The loop's gain in the weak region came with a subhalo regression from class imbalance. Class-balanced batches or re-weighting is the natural fix and the natural second round.
- The core arm out-engineered the fixed recipe. Its asinh stretch and augmentation are data-pipeline choices the ML tier should adopt. Its architecture change stays out, because the port is meant to match the papers.
Clone to trained model in five commands
Everything on this page came from the repository.
git clone https://github.com/amanibobo/lensgrav && cd lensgrav && uv synccore dependencies; torch is never installed on the laptop
uv run modal setup && uv run modal deploy -m lenscraft.compute.modal_apponce; builds the simulation, GPU and PyAutoLens images
uv run lenscraft simulate --backend modal --substructure vortex --n 5000 --seed 0 --run-id vor5k18k images rendered in about a minute across shards
uv run lenscraft train --backend modal --kind classifier --arch resnet18 --model-id clf --runs none5k sub5k vor5kabout 2 minutes on an L4
uv run lenscraft loop --backend modal --model-id clf --train-runs none5k sub5k vor5k --test-runs none1k sub1k vor1k --rounds 1uncertainty → approve → simulate → retrain → evaluate
uv run lenscraft bench --trials 10 --arms tool coreneeds an LLM key in .env; the core arm is slow
Source, design document, tests and the generated report are at github.com/amanibobo/lensgrav.
References
- HEPTAPOD: Orchestrating High Energy Physics Workflows Towards Autonomous AgencyMenzo, Roman, Gleyzer, Matchev, Fleming, Höche, Mrenna, Shyamsundar, 2026
- Deep Learning the Morphology of Dark Matter SubstructureAlexander, Gleyzer, McDonough, Toomey, Usai, 2019
- Decoding Dark Matter Substructure without SupervisionAlexander, Gleyzer, Parul, Reddy, Toomey, Usai, Von Klar, 2021
- DeepLenseSimToomey, GitHub