~/satyajit

Solv3R: a looped Transformer that keeps solving a 3D scene

mdjsonmcp

2026-11-09 · 36 min · 3d · slam · looped-transformers · vision-transformers · point-cloud · benchmarks

Why read this

Essentialtop 10%

Solv3R's stationary looped block run from the released weights on CPU: convergence, ablations, continuation and 8-view block solves measured on real photos.

  • Analysis found nowhere else
  • Interactive explanations
  • Runs on a consumer GPU

3D & spatialMixed licencesResearch paper

How this was scored
Is it new?
2 of 3: A real new idea, method or capability
Can I trust it?
2 of 3: Measures key facts from files, code or configs
Can I run it?
2 of 3: Open code or weights with real limits
Will I understand it?
3 of 3: Mechanism carried by interactives built from real code or data
Can I act on it?
2 of 3: A concrete recipe, numbers or comparison
Will it last?
2 of 3: A reference for a year or more
Does it affect many?
1 of 3: A specialist community
Only here?
3 of 3: The only place this analysis exists

Score 76 of 100, ranked 52 of 701 rated articles. Each question is answered 0–3 by hand, and a 3 is rare. How articles are scored

Every feed-forward reconstruction model I use at work has the same awkward moment. You reconstruct 40 photos, someone walks back in with 12 more, and the only honest option is to run all 52 again. Classical structure-from-motion never had that problem: it registers the new images against the map it already has and runs a few more rounds of bundle adjustment. The map is a state, and the optimiser picks up from it.

Solv3R (Jeong-gi Kwak, Andrea Tagliasacchi, Sho Kagami, Yuki Ono, Kwang Moo Yi; UBC, SFU, Wayve, Toronto, Sony) asks whether a geometric foundation model can behave like that optimiser. Its answer is a looped Transformer whose one shared block is trained to act as the iteration of a solver: the same update every call, no idea which call it is on, and a solved state that further calls leave alone. Tagliasacchi announced it as turning "a looped Transformer into a reusable learned geometric solver"; Kwang Moo Yi followed with the line that made me look closer: "Reconstruct full scenes by just solving 8 views at a time. <1.5GB VRAM is required to solve 128 photos. Basically, block coordinate descent." Code and weights landed on 5 November.

This is the second pass at this article. The first one could only read the checkpoint and time a block with random weights. This one runs the real thing: I wrote my own inference path from the released code, loaded the released weights into it, and put the repo's sample drone photos through the solver on a CPU. That turns most of the paper's solver claims from "reported" into things I could watch happen, and it found two places where the release behaves differently from what a reader would assume.

Why a feed-forward model cannot just continue

VGGT, π³ and Depth Anything 3 push a set of image tokens through a stack of different layers. Layer 7's output is only meaningful as layer 8's input. The tokens at depth 12 are "a scene, twelve layers into being solved", and there is no layer that accepts a mix of tokens at depth 24 (the old views, finished) and depth 0 (the new views, just encoded). So adding views means restarting.

Streaming models such as CUT3R dodge this with a recurrent memory, but they only take frames in order. A pile of extra photos from the other side of the building is not a video.

DéjàView, the model Solv3R starts from, already shares one Transformer block across 16 refinement steps. Sharing weights looks like it should fix the depth problem. The paper's point is that it does not, because DéjàView conditions each update on the step index:

zk+1=Fθ(zk;τk)\mathbf{z}^{k+1} = F_\theta(\mathbf{z}^{k}; \tau_k)

The block applies a different transformation at step 3 than at step 14, so a state is still tied to the step that produced it. Run it past step 16 and it is outside anything it was trained on; hand it views at mixed steps and no single τk\tau_k fits them all. Figure 8 shows what that costs: DéjàView's error on the original views climbs again once new views join, while Solv3R's keeps falling.

The solver it imitates: Jacobi

The paper's running example is the oldest iterative solver there is. To solve Ax=bA\mathbf{x} = \mathbf{b}, Jacobi applies the same update every iteration, xk+1=Φ(xk;A,b)\mathbf{x}^{k+1} = \Phi(\mathbf{x}^{k}; A, \mathbf{b}), and Φ\Phi does not know kk. When it converges you get three properties for free:

Map that onto reconstruction: the images are the constraints, each view's token state is the current estimate, the decoded cameras and depths are the solution. A learned multi-view solver should have all three properties. DéjàView has none of them by construction.

What Solv3R changes

The update becomes

zk+1=Tθ(zk;z0)\mathbf{z}^{k+1} = \mathcal{T}_\theta(\mathbf{z}^{k}; \mathbf{z}^{0})

with one Transformer block (frame attention within each view, then global attention across all of them) shared by every call. Three changes to DéjàView make it stationary and keep it from drifting:

  1. Constant gates. DéjàView scales each sub-block's channels with gates computed from τk\tau_k. Solv3R replaces them with learned constant vectors, initialised to the average of DéjàView's gates over its 16 steps.
  2. Initial-state injection. The encoded images z0\mathbf{z}^0 are added back at every call through a zero-initialised projection, the way Jacobi reads AA and b\mathbf{b} every iteration. Without it, an error in the state has nothing to be corrected against.
  3. One RMS rescale. After each call the whole state is scaled by a single factor to a fixed root-mean-square norm ρ\rho, so its magnitude cannot creep over 64 calls. One factor for all views keeps their relative scale.
Left, a single transformer block of frame attention, global attention and state normalisation updates the state z^k to z^(k+1), with the initial state z^0 from the encoded images fed into every call. Right, the same operator applied across solver calls, with new views joining at any stage and cameras, depth maps and ray maps decoded from the state.
One stationary block, looped. New views join the state at any call and are updated with the old ones; cameras, depths and ray maps can be decoded after any call (paper, Figure 4).

One call, term by term

The supplementary material writes the call as (reported)

u=Uθ ⁣(zk+W zˉ0),zk+1=ρ u∥u∥RMS\mathbf{u} = \mathcal{U}_\theta\!\left(\mathbf{z}^{k} + W\,\bar{\mathbf{z}}^{0}\right), \qquad \mathbf{z}^{k+1} = \frac{\rho\,\mathbf{u}}{\lVert \mathbf{u} \rVert_{\mathrm{RMS}}}

and the released code fills in every symbol. Take eight of the repo's park photos, 504 x 336 pixels each.

The third gate, g2g_2, is the one I had not noticed in the paper. The code calls it the "output carry": it multiplies the whole residual stream, not an update. In the checkpoint every one of its channels is positive, between 0.20 and 1.04 in the frame sub-block and 0.25 and 1.01 in the global one, with means of 0.739 and 0.780 (measured). So each call shrinks the state channel by channel, to roughly 0.74 x 0.78 ≈ 0.58 of its size on average, adds the two updates, and lets the rescale bring the total back to 0.85 (reasoned). That is a contraction with a learned per-channel rate, which is what you would design on purpose if you wanted a fixed-point iteration to forget its starting point. The two update gates are less tidy: means 0.68 to 0.87, about one channel in seven negative, extremes from -1.08 to 2.48.

The released solv3r/network/block.py is short enough to read in one sitting, and it is exactly this:

# solv3r/network/block.py (abridged)
def _frame(self, z, x0, pos):
    return self.frame_attn(z + self.inj(self.inj_norm(x0)), self.g_frame, pos)
 
def forward(self, z, x0, pos):
    S, P, C = z.shape
    y = self._frame(z, x0, pos)                                   # within each view
    y = self.global_attn(y.reshape(1, S * P, C), self.g_global)   # across all views
    rms = y.float().pow(2).mean((1, 2), keepdim=True).clamp_min(1e-12).sqrt()
    return self._rescale(y, rms).reshape(S, P, C)                 # one scale for the whole state

No argument carries a step. model.solve(ctx, calls=16) is a plain for loop over that call, and model.add_views(z, ctx, images) concatenates the new views' encodings onto the solved state, which is P3 as an API. Cameras come out of the state through the ray head: its two-layer, 384-wide Transformer runs on each view's tokens, and its output at the camera token goes through a small MLP to a 9-vector: translation, a unit quaternion and two fields of view.

What the checkpoint says

I read the safetensors header of jgkwak/solv3r and pulled the small tensors that pin the design down (measured):

PartParametersTrained in Solv3R?
DINOv2 ViT-B/14 encoder86.58Mfrozen, from DéjàView
Solver block (frame + global)14.18Myes
Injection projection WW0.59Myes
Depth, ray and camera heads16.00Mfrozen
Gates, tokens, RMS target0.01Mgates yes
Total117.36M14.78M trained (12.6%)

All 117,360,839 values are fp32, which is the 469,484,684-byte file and the "only 500mb weights" one early user on X was pleased about. The paper is explicit that only the shared block, the gates and WW are trained; the header agrees, with the encoder and heads carrying DéjàView's names (base.patch_embed_encoder, base.depth_decoder) unchanged.

One detail the paper does not mention: the target_rms buffer holds four numbers, 1.0045, 0.9783, 0.8501 and 0.8523, and the block reads only the last (measured). The paper says ρ\rho is "the RMS of the final DéjàView state, measured once before training (ρ≈0.85\rho \approx 0.85)". My guess is that the other three are the same measurement at other points of DéjàView's stack or schedule, left in the buffer; they do nothing at inference (reasoned).

What the code does that the paper does not say

Reading the release against the paper turns up five things that matter if you run it (measured, from the code):

How you train an operator to be a solver

Stationarity is a property of the architecture. Convergence is not; it has to be trained. The recipe (reported, from the appendix) is built to put the block in front of states it would meet in use:

The paper compares it to denoising training and to Diffusion Forcing, and the comparison fits: the block learns from corrupted states it would otherwise never visit.

A solver you can poke

To see what P1 to P3 buy without a GPU, here is the paper's Jacobi analogy made concrete. Forty cameras sit on a ring; any two within eight places of each other share a noisy relative-offset measurement; camera 0 is the reference and fixes the world frame, as Solv3R's reference camera token does for view 0. One call is one Jacobi sweep. The stopping line is the paper's rule: stop when the relative state update rk=∥zk+1−zk∥/∥zk∥r^k = \lVert \mathbf{z}^{k+1} - \mathbf{z}^k \rVert / \lVert \mathbf{z}^k \rVert falls below γ=0.02\gamma = 0.02.

Running the same code under Node gives the numbers (measured, on the toy):

The toy is linear and its properties are guaranteed by algebra. Solv3R's are trained into a nonlinear block, so they hold where training put them. The next three sections check them on the trained block itself.

Running the released block on real photos

The setup (measured): 8 of the 39 park photos, evenly spaced through the flight (DJI_0737 to DJI_0777), encoded in 11.9 s, then 64 calls of the solver at a median 2.36 s a call on 2 CPU threads, peak resident memory 1.43 GB for the whole process. After each call I record rkr^k and, at a set of calls, decode the cameras. The reference is the block's own answer at call 64: the widget's table gives the mean disagreement between that and the cameras at earlier calls, as the angle between relative rotations over all 28 pairs of views, so the world frame does not enter.

What the trace shows, with the released block as is:

P2 on the real block (measured). I took the call-64 state, added Gaussian noise at 0.25, 0.5 and 1.0 times its RMS, and kept calling. The noise moved the cameras 0.41°, 0.85° and 1.93° off the solved answer. Sixteen calls later they were 0.040°, 0.138° and 0.236° off: back by a factor of 6 to 10 in every case, with rkr^k at 0.008 and 0.012 for the two smaller perturbations and 0.021 for the largest, still settling. Recovery is not monotone, though: at the smallest noise the error dipped to 0.25° after one call, rose to 0.35° at call 4, then fell. That is a damped wobble, the signature of a contraction whose slowest mode is not a straight line home. The paper perturbs at call 8 with one and two times the state RMS and reports recovery from every one; my largest perturbation matches its smallest, and recovers.

Taking the three changes back out

The paper argues that injection, the RMS rescale and constant gates each earn their place. Its Table 4 (reported) retrains each ablation for 3k steps and reports pose AUC@5 at 16, 128 and 1,024 calls, and the rotation error after new views join:

Variant (3k fine-tuning steps)AUC@5, 16 calls1281,024Join error (°)
DéjàView as released76.50.00.01.80
Solv3R at 3k steps72.570.865.40.62
step-conditioned gates, planned for 1671.552.123.50.81
no input injection71.633.10.00.64
no state normalisation74.370.50.00.59
no random-depth pre-calls73.13.40.04.37
no perturbation training71.069.161.74.24
Solv3R, final77.279.178.80.54

At 16 calls every variant looks fine, within about 6 points of the others. The table's lesson is that a fixed-budget benchmark cannot see any of this: the differences only show at 128 and 1,024 calls, or when views join.

I ran a cheaper, harsher version on the released checkpoint (measured, the widget's other buttons): take one component out at inference from a block that was trained with it, and run the same 8 views for 64 calls. This answers a different question from Table 4: not "could you train without it" but "what is it doing in this checkpoint". Three distinct failures:

The three failure modes line up with the paper's retrained ones: no injection and no normalisation both collapse at long horizons there too. What the inference-time version adds is the shape of each failure: drift, thrash, and a confident wrong answer.

The evidence that the block behaves like a solver

The paper's own evidence, at scale (reported, from Section 4 and the appendix):

Panel a shows a building reconstructed from four views, then from four plus twelve views, by DéjàView (with inconsistent red cameras) and by Solv3R (consistent green cameras). Panels b and c plot median rotation error on a log axis against calls since the first view for the original and the new views; Solv3R's curves fall below one degree and stay, DéjàView's rise after the new views arrive.
Four views solved for eight calls, then twelve more join. Solv3R keeps improving the original views and brings the new ones in; DéjàView, continued or restarted, does not hold a consistent reconstruction. Median over 96 windows (paper, Figure 8).

Does accuracy suffer?

Not measurably against its own parent, which is the bar that matters for a fine-tune. The offline benchmark (reported, camera pose AUC@3, higher is better; TFLOPs for 24 views):

ModelParamsTFLOPsDTUETH3D7-ScenesScanNet++nuScenes
VGGT1.26B190.096.535.411.115.139.2
VGGT-Ω1.14B99.877.464.121.329.942.4
π³959M153.870.243.211.376.524.8
DA3-L411M71.479.442.011.47.010.9
DéjàView, 16 steps117M75.983.366.013.979.543.4
Solv3R, 16 calls117M76.584.565.413.880.741.5
Solv3R, adaptive117M91.385.570.414.281.241.5

Read it plainly. At 16 calls Solv3R is DéjàView within a point or two either way, which is what a fine-tune that adds properties without losing accuracy looks like. The adaptive stop costs 3 to 4 extra calls on average and buys the best ETH3D and ScanNet++ numbers in the table. It does not win everywhere: VGGT owns DTU at AUC@3 and VGGT-Ω owns 7-Scenes. For a model a tenth of VGGT's size, that is a strong showing, but "state of the art" would overstate it. On two large aerial scenes from Mill19, with 96 images in one window, Solv3R has the best AUC@3 of the six methods (48.9 on Rubble, 22.5 on Building).

Six point clouds of the same two-room apartment reconstructed from 100 images, each with an inset of camera positions seen from above. Solv3R, DéjàView and pi-cubed keep both rooms in one piece; VGGT folds the second room onto the first, and DA3-L and VGGT-Omega misplace the second room.
All six methods see the same 100 ScanNet++ images at once. VGGT, DA3-L and VGGT-Ω misplace the second room; Solv3R keeps both rooms in one piece (paper, Figure 21).

One cache, every call

The property I find most useful is a side effect of P1. If a map view is solved and held fixed, its keys and values in the global attention depend only on its own state. In a stationary block that state does not change, and neither does the block, so one cache is valid at every call. DéjàView needs a cache per step, and a stacked model like VGGT needs one per global layer.

That claim can be checked with nothing but shapes. A view's tokens are its patches plus a camera token and four registers; 7-Scenes frames are 640 x 480, which Solv3R resizes to 504 x 378 (36 x 27 patches) and VGGT to 518 x 392 (37 x 28):

977 tokens x 768 wide x 2 (K and V) x 2 bytes x 1 layer is 2.86 MiB. Sixteen step-specific copies make DéjàView's 45.8. VGGT's 24 global layers at width 1024 make 97.6. All three match the paper's Table 2 to the digit (measured arithmetic). The 16x saving is a counting fact about stationarity, not a compression trick.

What the small cache buys, in map-based relocalization (reported; queries attend to cached map views and only the query is refined):

ModelCache MB/viewms/query7-Scenes within 5 cm, 5°Cambridge within 25 cm, 2°
VGGT97.618645.5%16.4%
π³89.716166.9%14.7%
DéjàView, one step's cache2.86500.0%0.0%
Solv3R2.865172.8%24.9%
Seven top-down panels of one 7-Scenes scene with ground-truth query cameras as grey rings and estimates as green dots (within 5 cm and 5 degrees) or red crosses. Above each panel, the cache size per map view and the fraction localised: VGGT 97.6 MB 52%, VGGT-Omega 78.5 MB 0%, pi-cubed 89.7 MB 95%, DA3-L 30.4 MB 0%, DéjàView with every step cached 45.8 MB 0%, DéjàView with one step 2.86 MB 0%, Solv3R 2.86 MB 95%.
Relocalising queries against a frozen map, one scene. Solv3R matches π³'s 95% with a cache 31 times smaller; DéjàView with one step's cache localises nothing (paper, Figure 10).

The DéjàView row is the control that makes the argument: give a step-conditioned model only one step's cache and it localises nothing.

How exact is the cache? (measured). The keys and values of a held view are exact by construction. The RMS rescale is not: it needs the sum of squares of every view's output, and cache() computes the held views' share by running global attention among the held views only. The docstring says this is "close to the full call when the reference view (view 0) is held". I checked both cases on the 16-view state at call 16, splitting the views into a held half and an updated half, and compared one cached call with the same rows of the full joint call:

So the docstring is right about the case it names, and the streaming setup holds view 0 among its anchors, so streaming is in the good case. Anything else that holds views should keep view 0 in the held set, or recompute the scale.

Eight views at a time

Now Kwang Moo Yi's claim: solve 128 photos 8 at a time, under 1.5 GB of VRAM. The authors' version is not in the release. The ballroom image of 128 photos solved 8 random photos at a time links to a video, not to code, so how they choose blocks, and whether the reference view rides along, is unpublished. The pieces are in the API (Context.subset, model.cache, model.step_cached), so I wrote the obvious version and ran it.

The memory claim is plausible from the sizes (reasoned). For 128 photos at 504 x 336 (869 tokens a view) the state and the encodings are 2 x 128 x 869 x 768 fp32 values, 652 MB. The weights are 469 MB in fp32. Together that is about 1.12 GB, leaving a few hundred MB for one 8-view call's activations in bf16. Under 1.5 GB is believable; a joint call on all 128 would not be close.

The compute side, on a shape-matched block (measured). Before I had the real weights running, I timed one call of a block with Solv3R's shapes and random weights, on 2 CPU threads in fp32:

One 8-view call took 1.94 s and one 64-view call 68.67 s, 35 times longer for 8 times the views, because global attention grows as the square of the tokens in the call. The real block, on a busier box, ran 8 views in 2.36 s and 16 in 6.57 s, about 20% over the shape-matched curve. A sweep that gives each of 128 views one call in blocks of 8 is 16 calls, about 31 s, against an extrapolated 4.3 minutes for a single joint call.

Does block-at-a-time reach the joint answer? (measured). This was the question the first version of this article could not answer. I took 16 park photos, solved them jointly for 48 calls as the reference, and reached the same 16-view solve three other ways:

The numbers, as mean disagreement with the joint 48-call answer:

Calls per view48121624
restart, all 162.53°0.63°0.19°0.116°0.100°
8 solved, 8 join1.45°0.27°0.13°0.099°0.097°
blocks of 8, cached2.82°0.50°0.20°0.116°0.097°

Three things to read from it:

The cost result is the one I did not expect (measured). On 16 views a sweep of two cached half-updates took 11.0 s, against 6.6 s for one joint call. The reason is in the cache: in block coordinate descent the held views change every time a block is updated, so the cache is rebuilt every call (frame attention and a key/value projection for every held view), and the release's scale computation runs global attention among all the held views, which is the quadratic term again. Below some size the joint call is simply cheaper. At 128 photos in blocks of 8 the query side is 8 views against 128, so that attention grows linearly in the scene and the memory story holds; but the held-scale term has to go (keep a running sum of squares instead, the obvious fix, reasoned) before the compute story does too.

Streaming falls out, too

The same weights stream without streaming-specific training (reported). The default L8 setup keeps 8 anchor frames, 6 recent frames that keep refining, and up to 64 memory frames taken every 16th frame; anchors and memory are held fixed and enter every call through that one-layer cache. A new frame does not start from its bare encoding: it starts from its encoding plus the previous frame's solved offset, roughly znew0+(zprev−zprev0)\mathbf{z}^0_{\text{new}} + (\mathbf{z}_{\text{prev}} - \mathbf{z}^0_{\text{prev}}), a warm start that makes sense only because a neighbouring frame's state means the same thing at any call. Every four frames, one extra call measures rkr^k on the frozen views; above 0.02 they rejoin the solve and their cache is rebuilt, which is P2 used as a consistency check. Memory stops growing once the memory bank fills, after about 1,030 frames. On the long 2,000-frame TUM office sequence, offline VGGT runs out of an 80 GB H100 at 320 frames. The README's expected camera ATE for L8 is 0.157 on Sintel, 0.015 on TUM-dynamics and 0.077 on ScanNet. Pipelining also works because views at different stages are fine: the window can advance after every call, so a new frame's latency stays the same while throughput is 16x DéjàView's re-solve-per-frame (10x end to end). The DA3 streaming piece covers the other way to bound memory, with a keyframe bank around a frozen backbone.

Where it breaks, and what it costs

A failure case: eight camera frusta and a point cloud where the second group of four cameras, which share little content with the first four, is placed at the wrong position and orientation.
Views 5 to 8 share little content with views 1 to 4; Solv3R places them wrong within 16 calls and keeps them there up to call 64 (paper, Figure 18).

What I could not check

Where it sits

The idea is not "loop a Transformer"; DéjàView did that, and the looped language models before it (what separates Ouro from Huginn, looped versus deep at matched compute). Nor is the fixed-point view new: deep equilibrium models and path-independent equilibrium models argued that an iterated block should converge to the same answer from any start, and the paper cites them. What Solv3R adds is a geometric reason to want that property and three cheap changes that get it from a pretrained step-conditioned model: constant gates, injection, one RMS scale. My two roundups of this year's feed-forward reconstruction work, the first and the second, were mostly about bolting classical machinery onto networks that cannot iterate. Solv3R puts the iteration inside the network.

Running it

From the README, on a machine that will run the package (mine would not, so everything measured above went through my own re-implementation):

git clone https://github.com/ubc-vision/solv3r.git && cd solv3r
pip install -e .
python examples/offline.py     --images examples/data/park --out outputs/offline --sky-mask
python examples/incremental.py --images examples/data/park --out outputs/incremental --initial 8 --group 4
python examples/streaming.py   --frames examples/data/desk --out outputs/streaming

--adaptive 0.02 turns on the stopping rule for offline solves, and --config L4 or S4 picks a lighter streaming setting. The park sample is 39 drone photos at 504 x 336, the photos every measurement above used.

ubc-vision/solv3r@ef8da34 · snapshot 2026-11-10
tracked files
374
license
Apache-2.0
branch
main
tests
none found
source
145.5 kB
commit date
2026-11-06
source by language
Python145.5 kB(22)

by size of tracked source at this commit, file counts in brackets; docs, data and vendored trees excluded

local clone, 2026-11-10 at ef8da34 — branch, commit, commitDate, fileCount, hasTests, languages, license, licenseFile, shallow

shallow clone: counts describe the pinned tree, not the history

jgkwak/solv3r@c95998a · snapshot 2026-11-10
repo size
469.5 MB
task
image-to-3d
license
other
safetensors
1 shard
largest file
469.5 MB
files
4
downloads
0
likes
9
3d-reconstructioncamera-pose-estimationmulti-view

repo last modified 2026-11-05

What to take from it

Make the loop stationary, then train it to have a solver's properties. Everything practical follows from that one constraint, and on the released weights I could watch each piece work: a solve that stops at call 16 with its cameras already still, noise that the same calls remove, eight new photos that join a running solve and reach the joint answer faster than a restart, and half the scene updated at a time against a cache of the other half landing on the same answer as the whole. The two cautions are just as concrete. The cached call's scale is only right when view 0 is held, and a stable fixed point can be a wrong one. What I want next is the authors' 128-photo block example, run against ground truth.

Cite this article

For attribution, please use the following reference or BibTeX:

Satyajit Ghana, "Solv3R: a looped Transformer that keeps solving a 3D scene", ai.satyajit.si, November 2026.

bibtex
@misc{ghana2026solv3r,
  author = {Satyajit Ghana},
  title  = {Solv3R: a looped Transformer that keeps solving a 3D scene},
  url    = {https://ai.satyajit.si/articles/solv3r},
  year   = {2026}
}
share