<?xml version="1.0" encoding="utf-8"?><feed xmlns="http://www.w3.org/2005/Atom" xml:lang="en"><generator uri="https://jekyllrb.com/" version="4.4.1">Jekyll</generator><link href="https://manansuri.com/feed.xml" rel="self" type="application/atom+xml" /><link href="https://manansuri.com/" rel="alternate" type="text/html" hreflang="en" /><updated>2026-09-15T06:14:16+00:00</updated><id>https://manansuri.com/feed.xml</id><title type="html">blank</title><subtitle>Manan Suri is a Computer Science PhD student at the University of Maryland working on LLM agents: trustworthy context use, efficient parametric memory, and the benchmarks agents need.
</subtitle><entry><title type="html">Turning a video into a LoRA adapter: Frames2LoRA explained</title><link href="https://manansuri.com/blog/2026/frames2lora-explained/" rel="alternate" type="text/html" title="Turning a video into a LoRA adapter: Frames2LoRA explained" /><published>2026-09-14T12:00:00+00:00</published><updated>2026-09-14T12:00:00+00:00</updated><id>https://manansuri.com/blog/2026/frames2lora-explained</id><content type="html" xml:base="https://manansuri.com/blog/2026/frames2lora-explained/"><![CDATA[<p>Take one clip from CaReBench. A man in a black tank top sits by a window, brick wall behind him, wooden door to his right. He flicks a lighter, brings the flame to a pipe, and draws on it. Ask SmolVLM2 to describe the clip and it first turns each of the 12 sampled frames into hundreds of visual tokens, then reads your question, then answers. Ask a second question and it does all of that again, because the frames have to sit in the context window every time.</p>

<p>A few dozen frames is already tens of thousands of tokens before you have typed a word, and past the model’s capacity it does not fail gently: it starts emitting repetitive text that has nothing to do with the video. Frames2LoRA, which I worked on with Sarvesh Baskar and Dinesh Manocha at UMD, takes the video out of the context entirely. It reads the frames once, writes what it saw into the model’s weights as a small adapter, and from then on every question is answered from text alone.</p>

<h2 id="the-idea-in-one-picture">The idea in one picture</h2>

<div class="fig-svg">
<svg viewBox="0 0 900 370" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="Left: the base model keeps hundreds of visual tokens per frame in its context window and pays that cost for every question. Right: Frames2LoRA turns the frames into a LoRA adapter once, and the frozen model answers every question with zero visual tokens in context.">
  <style>
    .n{fill:none;stroke:currentColor;stroke-width:1.5}
    .t{fill:currentColor;font-size:14px}
    .b{fill:currentColor;font-size:15px;font-weight:600}
    .s{fill:currentColor;font-size:12px;opacity:.75}
    .ar{fill:none;stroke:currentColor;stroke-width:1.5;marker-end:url(#a0)}
    .flow{fill:none;stroke:#B509AC;stroke-width:2;stroke-dasharray:6 6;animation:d0 1.2s linear infinite;marker-end:url(#a0p)}
    @keyframes d0{to{stroke-dashoffset:-24}}
  </style>
  <defs>
    <marker id="a0" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto"><path d="M0,0 L10,5 L0,10 z" fill="currentColor" /></marker>
    <marker id="a0p" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto"><path d="M0,0 L10,5 L0,10 z" fill="#B509AC" /></marker>
  </defs>
  <!-- left panel -->
  <text class="b" x="225" y="26" text-anchor="middle">Video in context (base model)</text>
  <rect x="30" y="52" width="110" height="70" rx="10" fill="#2698BA" fill-opacity=".15" stroke="#2698BA" stroke-width="1.5" />
  <rect class="n" x="46" y="66" width="34" height="24" rx="3" stroke="#2698BA" />
  <rect class="n" x="58" y="74" width="34" height="24" rx="3" stroke="#2698BA" />
  <rect class="n" x="70" y="82" width="34" height="24" rx="3" stroke="#2698BA" />
  <text class="s" x="85" y="116" text-anchor="middle">12 frames</text>
  <path class="ar" d="M140,87 L168,87" />
  <rect class="n" x="170" y="46" width="250" height="82" rx="10" stroke="#9a9a9a" />
  <text class="s" x="295" y="64" text-anchor="middle">context window</text>
  <rect x="182" y="72" width="30" height="20" rx="4" fill="#2698BA" fill-opacity=".3" stroke="#2698BA" />
  <rect x="216" y="72" width="30" height="20" rx="4" fill="#2698BA" fill-opacity=".3" stroke="#2698BA" />
  <rect x="250" y="72" width="30" height="20" rx="4" fill="#2698BA" fill-opacity=".3" stroke="#2698BA" />
  <text class="t" x="296" y="87" text-anchor="middle">…</text>
  <rect x="312" y="72" width="30" height="20" rx="4" fill="#2698BA" fill-opacity=".3" stroke="#2698BA" />
  <rect x="350" y="72" width="58" height="20" rx="4" fill="currentColor" fill-opacity=".08" stroke="currentColor" />
  <text class="s" x="379" y="86" text-anchor="middle">query</text>
  <text class="s" x="295" y="116" text-anchor="middle">hundreds of visual tokens per frame</text>
  <path class="ar" d="M295,128 L295,158" />
  <rect x="170" y="160" width="250" height="46" rx="10" fill="#9a9a9a" fill-opacity=".15" stroke="#9a9a9a" stroke-width="1.5" />
  <text class="t" x="295" y="188" text-anchor="middle">Frozen SmolVLM2</text>
  <path class="ar" d="M295,206 L295,236" />
  <rect x="170" y="238" width="250" height="44" rx="10" fill="#F29105" fill-opacity=".15" stroke="#F29105" stroke-width="1.5" />
  <text class="t" x="295" y="265" text-anchor="middle">Answer</text>
  <text class="t" x="225" y="318" text-anchor="middle" fill="#F29105">Every question re-encodes the video</text>
  <text class="s" x="225" y="338" text-anchor="middle">tens of thousands of tokens for a few dozen frames</text>
  <!-- divider -->
  <path class="n" d="M450,20 L450,350" stroke-dasharray="2 5" opacity=".5" />
  <!-- right panel -->
  <text class="b" x="675" y="26" text-anchor="middle">Frames2LoRA</text>
  <rect x="470" y="52" width="100" height="70" rx="10" fill="#2698BA" fill-opacity=".15" stroke="#2698BA" stroke-width="1.5" />
  <rect class="n" x="484" y="66" width="34" height="24" rx="3" stroke="#2698BA" />
  <rect class="n" x="496" y="74" width="34" height="24" rx="3" stroke="#2698BA" />
  <rect class="n" x="508" y="82" width="34" height="24" rx="3" stroke="#2698BA" />
  <text class="s" x="520" y="116" text-anchor="middle">12 frames</text>
  <path class="flow" d="M570,87 L596,87" />
  <rect x="600" y="52" width="130" height="70" rx="10" fill="#B509AC" fill-opacity=".15" stroke="#B509AC" stroke-width="2" />
  <text class="t" x="665" y="80" text-anchor="middle">Perceiver</text>
  <text class="t" x="665" y="98" text-anchor="middle">hypernetwork</text>
  <path class="flow" d="M730,87 L756,87" />
  <rect x="760" y="52" width="115" height="70" rx="10" fill="#B509AC" fill-opacity=".15" stroke="#B509AC" stroke-width="2" />
  <text class="t" x="817" y="80" text-anchor="middle">LoRA adapter</text>
  <text class="s" x="817" y="98" text-anchor="middle">A, B · rank 16</text>
  <text class="s" x="675" y="142" text-anchor="middle">one forward pass, once per video</text>
  <!-- context -->
  <rect class="n" x="600" y="154" width="275" height="52" rx="10" stroke="#9a9a9a" />
  <text class="s" x="668" y="172" text-anchor="middle">context window</text>
  <rect x="612" y="178" width="58" height="20" rx="4" fill="currentColor" fill-opacity=".08" stroke="currentColor" />
  <text class="s" x="641" y="192" text-anchor="middle">query</text>
  <text class="t" x="790" y="186" text-anchor="middle" fill="#00ab37">0 visual tokens</text>
  <path class="flow" d="M817,122 L817,138 L888,138 L888,261 L866,261" />
  <path class="ar" d="M737,206 L737,236" />
  <rect x="600" y="238" width="275" height="46" rx="10" fill="#9a9a9a" fill-opacity=".15" stroke="#9a9a9a" stroke-width="1.5" />
  <text class="t" x="720" y="266" text-anchor="middle">Frozen SmolVLM2</text>
  <rect x="800" y="248" width="64" height="26" rx="6" fill="#B509AC" fill-opacity=".2" stroke="#B509AC" />
  <text class="s" x="832" y="266" text-anchor="middle">+ adapter</text>
  <path class="ar" d="M737,284 L737,300" />
  <rect x="600" y="302" width="275" height="40" rx="10" fill="#00ab37" fill-opacity=".15" stroke="#00ab37" stroke-width="1.5" />
  <text class="t" x="737" y="327" text-anchor="middle">Answer</text>
  <text class="s" x="675" y="362" text-anchor="middle" fill="#00ab37">internalize once, ask as many times as you like</text>
</svg>
<div class="fig-caption">Left: the base model keeps every frame in its context and pays for it on every question. Right: Frames2LoRA generates a LoRA adapter from the frames once; the frozen model plus adapter answers with zero visual tokens in context.</div>
</div>

<div class="callout"><span class="callout-label">Key idea</span>LoRA adapters are normally trained with gradient descent. Frames2LoRA predicts one instead: a hypernetwork (a network whose output is another network's weights) looks at the video once and emits the adapter in a single forward pass. Attach it to the frozen VLM and the model "knows" the video without a single visual token in its prompt.</div>

<p>Doc-to-LoRA showed this works for text documents. Video is harder in three ways. The token volume per example is orders of magnitude larger, the compression is cross-modal (visual content has to become perturbations to a language model’s weights), and video varies along frame count and resolution, axes that text does not have. The rest of this post follows the pipe-lighting clip through the method, one stage at a time.</p>

<h2 id="walkthrough-one-carebench-clip-from-frames-to-an-answer-with-no-frames">Walkthrough: one CaReBench clip, from frames to an answer with no frames</h2>

<div class="walkthrough">
  <div class="wt-title">Walkthrough: internalizing the pipe-lighting clip and asking it a question</div>
  <div class="wt-step" data-label="Sample frames">
    <h4>1. Sample the frames and add an instruction</h4>

    <div class="fig-svg">
<svg viewBox="0 0 900 250" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="Twelve frames are sampled uniformly from the clip at 384 pixels and paired with an internalization instruction. In the base model all of these frames become hundreds of visual tokens each; in Frames2LoRA none of them reach the context at query time.">
  <style>
    .n{fill:none;stroke:currentColor;stroke-width:1.5}
    .t{fill:currentColor;font-size:14px}
    .b{fill:currentColor;font-size:15px;font-weight:600}
    .s{fill:currentColor;font-size:12px;opacity:.75}
    .f{font-size:11px;fill:#2698BA}
    .ar{fill:none;stroke:currentColor;stroke-width:1.5;marker-end:url(#a1)}
  </style>
  <defs><marker id="a1" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto"><path d="M0,0 L10,5 L0,10 z" fill="currentColor" /></marker></defs>
  <text class="b" x="30" y="28">CaReBench clip: a man lights a pipe by a window</text>
  <text class="s" x="30" y="48">12 frames sampled uniformly, longest edge 384 px</text>
  <g>
    <rect x="30" y="60" width="40" height="30" rx="4" fill="#2698BA" fill-opacity=".2" stroke="#2698BA" stroke-width="1.5" /><text class="f" x="50" y="79" text-anchor="middle">f1</text>
    <rect x="74" y="60" width="40" height="30" rx="4" fill="#2698BA" fill-opacity=".2" stroke="#2698BA" stroke-width="1.5" /><text class="f" x="94" y="79" text-anchor="middle">f2</text>
    <rect x="118" y="60" width="40" height="30" rx="4" fill="#2698BA" fill-opacity=".2" stroke="#2698BA" stroke-width="1.5" /><text class="f" x="138" y="79" text-anchor="middle">f3</text>
    <rect x="162" y="60" width="40" height="30" rx="4" fill="#2698BA" fill-opacity=".2" stroke="#2698BA" stroke-width="1.5" /><text class="f" x="182" y="79" text-anchor="middle">f4</text>
    <rect x="206" y="60" width="40" height="30" rx="4" fill="#2698BA" fill-opacity=".2" stroke="#2698BA" stroke-width="1.5" /><text class="f" x="226" y="79" text-anchor="middle">f5</text>
    <rect x="250" y="60" width="40" height="30" rx="4" fill="#2698BA" fill-opacity=".2" stroke="#2698BA" stroke-width="1.5" /><text class="f" x="270" y="79" text-anchor="middle">f6</text>
    <rect x="294" y="60" width="40" height="30" rx="4" fill="#2698BA" fill-opacity=".2" stroke="#2698BA" stroke-width="1.5" /><text class="f" x="314" y="79" text-anchor="middle">f7</text>
    <rect x="338" y="60" width="40" height="30" rx="4" fill="#2698BA" fill-opacity=".2" stroke="#2698BA" stroke-width="1.5" /><text class="f" x="358" y="79" text-anchor="middle">f8</text>
    <rect x="382" y="60" width="40" height="30" rx="4" fill="#2698BA" fill-opacity=".2" stroke="#2698BA" stroke-width="1.5" /><text class="f" x="402" y="79" text-anchor="middle">f9</text>
    <rect x="426" y="60" width="40" height="30" rx="4" fill="#2698BA" fill-opacity=".2" stroke="#2698BA" stroke-width="1.5" /><text class="f" x="446" y="79" text-anchor="middle">f10</text>
    <rect x="470" y="60" width="40" height="30" rx="4" fill="#2698BA" fill-opacity=".2" stroke="#2698BA" stroke-width="1.5" /><text class="f" x="490" y="79" text-anchor="middle">f11</text>
    <rect x="514" y="60" width="40" height="30" rx="4" fill="#2698BA" fill-opacity=".2" stroke="#2698BA" stroke-width="1.5" /><text class="f" x="534" y="79" text-anchor="middle">f12</text>
  </g>
  <text class="s" x="30" y="112">lighter flicks on → flame meets the pipe → he sets the lighter down and smokes</text>
  <rect x="30" y="130" width="524" height="46" rx="10" fill="#2698BA" fill-opacity=".1" stroke="#2698BA" stroke-width="1.5" />
  <text class="t" x="44" y="149">Internalization instruction  i</text>
  <text class="s" x="44" y="167">a fixed text prompt that tells the encoder to take the video in; not the user's question</text>
  <text class="s" x="30" y="206">video  v  = the 12 frames.  The user's question  p  does not enter yet.</text>
  <!-- right: token ledger -->
  <rect class="n" x="600" y="40" width="270" height="180" rx="10" stroke="#9a9a9a" />
  <text class="b" x="735" y="66" text-anchor="middle">Visual tokens in context</text>
  <text class="s" x="735" y="84" text-anchor="middle">when the question is asked</text>
  <text class="t" x="616" y="118">Base model</text>
  <text class="t" x="854" y="118" text-anchor="end" fill="#F29105">12 × hundreds</text>
  <text class="s" x="616" y="136">every frame, every question</text>
  <text class="t" x="616" y="174">Frames2LoRA</text>
  <text class="t" x="854" y="174" text-anchor="end" fill="#00ab37">0</text>
  <text class="s" x="616" y="192">frames seen once, by the encoder</text>
  <path class="ar" d="M554,75 L598,75" stroke-dasharray="4 4" opacity=".5" />
</svg>
</div>

    <p>We sample 12 frames uniformly from the clip, longest edge 384 px, and pair them with a fixed internalization instruction (a prompt that tells the encoder to take the video in; it is not the user’s question). For the base model, these 12 frames are the expensive part: each becomes hundreds of visual tokens, and all of them ride along with every question. In Frames2LoRA they will be seen exactly once, by the encoder in the next step.</p>

    <div class="tok-row"><span class="tok tok-b">f1</span><span class="tok tok-b">f2</span><span class="tok tok-b">f3</span><span class="tok tok-b">…</span><span class="tok tok-b">f12</span><span class="tok-arrow">+</span><span class="tok tok-b">internalization instruction</span></div>

  </div>
  <div class="wt-step" data-label="Encode">
    <h4>2. Run the frozen encoder and keep every layer's hidden states</h4>

    <div class="fig-svg">
<svg viewBox="0 0 900 300" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="The frozen SmolVLM2 encoder reads the frames and the instruction; the text-side hidden state of every transformer layer is kept and stacked into a tensor C of shape L by S by D.">
  <style>
    .n{fill:none;stroke:currentColor;stroke-width:1.5}
    .t{fill:currentColor;font-size:14px}
    .b{fill:currentColor;font-size:15px;font-weight:600}
    .s{fill:currentColor;font-size:12px;opacity:.75}
    .ar{fill:none;stroke:currentColor;stroke-width:1.5;marker-end:url(#a2)}
    .tap{fill:none;stroke:#B509AC;stroke-width:1.5;stroke-dasharray:5 5;animation:d2 1.2s linear infinite;marker-end:url(#a2p)}
    @keyframes d2{to{stroke-dashoffset:-20}}
  </style>
  <defs>
    <marker id="a2" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto"><path d="M0,0 L10,5 L0,10 z" fill="currentColor" /></marker>
    <marker id="a2p" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto"><path d="M0,0 L10,5 L0,10 z" fill="#B509AC" /></marker>
  </defs>
  <!-- input -->
  <rect x="30" y="90" width="150" height="46" rx="10" fill="#2698BA" fill-opacity=".15" stroke="#2698BA" stroke-width="1.5" />
  <text class="t" x="105" y="118" text-anchor="middle">12 frames  v</text>
  <rect x="30" y="150" width="150" height="46" rx="10" fill="#2698BA" fill-opacity=".15" stroke="#2698BA" stroke-width="1.5" />
  <text class="t" x="105" y="178" text-anchor="middle">instruction  i</text>
  <path class="ar" d="M180,143 L226,143" />
  <!-- frozen encoder -->
  <rect x="230" y="30" width="220" height="230" rx="10" fill="#9a9a9a" fill-opacity=".12" stroke="#9a9a9a" stroke-width="1.5" />
  <text class="b" x="340" y="54" text-anchor="middle">Frozen SmolVLM2  E</text>
  <text class="s" x="340" y="70" text-anchor="middle">vision encoder + text decoder</text>
  <rect class="n" x="250" y="212" width="180" height="26" rx="6" stroke="#9a9a9a" /><text class="s" x="340" y="230" text-anchor="middle">layer 0</text>
  <rect class="n" x="250" y="180" width="180" height="26" rx="6" stroke="#9a9a9a" /><text class="s" x="340" y="198" text-anchor="middle">layer 1</text>
  <rect class="n" x="250" y="148" width="180" height="26" rx="6" stroke="#9a9a9a" /><text class="s" x="340" y="166" text-anchor="middle">layer 2</text>
  <text class="t" x="340" y="132" text-anchor="middle">⋮</text>
  <rect class="n" x="250" y="84" width="180" height="26" rx="6" stroke="#9a9a9a" /><text class="s" x="340" y="102" text-anchor="middle">layer L − 1</text>
  <!-- taps -->
  <path class="tap" d="M430,225 L498,225" />
  <path class="tap" d="M430,193 L498,207" />
  <path class="tap" d="M430,161 L498,189" />
  <path class="tap" d="M430,97 L498,135" />
  <!-- stack -->
  <g>
    <rect x="560" y="60" width="180" height="150" rx="6" fill="#B509AC" fill-opacity=".06" stroke="#B509AC" stroke-width="1.5" />
    <rect x="540" y="80" width="180" height="150" rx="6" fill="#B509AC" fill-opacity=".08" stroke="#B509AC" stroke-width="1.5" />
    <rect x="520" y="100" width="180" height="150" rx="6" fill="#B509AC" fill-opacity=".1" stroke="#B509AC" stroke-width="1.5" />
    <rect x="500" y="120" width="180" height="150" rx="6" fill="#B509AC" fill-opacity=".14" stroke="#B509AC" stroke-width="1.5" />
    <text class="t" x="590" y="150" text-anchor="middle">h₀</text>
    <text class="s" x="590" y="172" text-anchor="middle">S tokens × D dims</text>
    <text class="s" x="590" y="190" text-anchor="middle">text-side states</text>
    <text class="s" x="590" y="208" text-anchor="middle">after layer 0</text>
    <text class="s" x="650" y="76" text-anchor="middle">h_L−1</text>
  </g>
  <text class="b" x="780" y="120" text-anchor="start">C</text>
  <text class="s" x="780" y="140">= stack(h₀ … h_L−1)</text>
  <text class="s" x="780" y="158">shape L × S × D</text>
  <text class="s" x="780" y="186">one slice per</text>
  <text class="s" x="780" y="202">layer, not one</text>
  <text class="s" x="780" y="218">pooled vector</text>
  <text class="s" x="340" y="284" text-anchor="middle">no weights change here; hidden states are simply read out</text>
</svg>
</div>

    <p>The frames and instruction go through a frozen SmolVLM2. Instead of keeping only the final output, we keep the text-side hidden states from every transformer layer and stack them into a tensor \(\mathbf{C}\) of shape \(L \times S \times D\): one slice \(\mathbf{h}_\ell\) per layer, each with \(S\) tokens of \(D\) dimensions. Keeping the layer axis is deliberate. It lets the next stage write a different adapter for each layer instead of squeezing the whole video into one pooled vector and reusing it everywhere.</p>

  </div>
  <div class="wt-step" data-label="Perceiver">
    <h4>3. The Perceiver hypernetwork reads each layer slice</h4>

    <div class="fig-svg">
<svg viewBox="0 0 900 320" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="For one layer slice, learned latent queries attend to the hidden states in an encoder resampler; a decoder resampler then emits one latent per LoRA rank direction, sixteen for the down-projection module of that layer. This repeats for every layer.">
  <style>
    .n{fill:none;stroke:currentColor;stroke-width:1.5}
    .t{fill:currentColor;font-size:14px}
    .b{fill:currentColor;font-size:15px;font-weight:600}
    .s{fill:currentColor;font-size:12px;opacity:.75}
    .ar{fill:none;stroke:currentColor;stroke-width:1.5;marker-end:url(#a3)}
    .att{fill:none;stroke:#B509AC;stroke-width:1;opacity:.6;stroke-dasharray:3 4;animation:d3 1.5s linear infinite}
    @keyframes d3{to{stroke-dashoffset:-14}}
  </style>
  <defs><marker id="a3" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto"><path d="M0,0 L10,5 L0,10 z" fill="currentColor" /></marker></defs>
  <!-- layer slice -->
  <text class="b" x="100" y="30" text-anchor="middle">Layer slice  h_ℓ</text>
  <text class="s" x="100" y="48" text-anchor="middle">S tokens × D dims</text>
  <rect x="40" y="60" width="120" height="200" rx="8" fill="#B509AC" fill-opacity=".1" stroke="#B509AC" stroke-width="1.5" />
  <g stroke="#B509AC" opacity=".5">
    <line x1="52" y1="80" x2="148" y2="80" /><line x1="52" y1="100" x2="148" y2="100" /><line x1="52" y1="120" x2="148" y2="120" />
    <line x1="52" y1="140" x2="148" y2="140" /><line x1="52" y1="160" x2="148" y2="160" /><line x1="52" y1="180" x2="148" y2="180" />
    <line x1="52" y1="200" x2="148" y2="200" /><line x1="52" y1="220" x2="148" y2="220" /><line x1="52" y1="240" x2="148" y2="240" />
  </g>
  <text class="s" x="100" y="284" text-anchor="middle">one token per row</text>
  <!-- encoder resampler -->
  <rect x="230" y="60" width="200" height="110" rx="10" fill="#B509AC" fill-opacity=".15" stroke="#B509AC" stroke-width="2" />
  <text class="t" x="330" y="84" text-anchor="middle">Encoder resampler</text>
  <text class="s" x="330" y="104" text-anchor="middle">learned latent queries</text>
  <text class="s" x="330" y="120" text-anchor="middle">cross-attend to h_ℓ</text>
  <text class="s" x="330" y="136" text-anchor="middle">→ fixed-size summary</text>
  <text class="s" x="330" y="156" text-anchor="middle">latent size Z = 512</text>
  <path class="att" d="M160,80 L230,115" /><path class="att" d="M160,140 L230,115" /><path class="att" d="M160,200 L230,115" /><path class="att" d="M160,240 L230,115" />
  <!-- decoder resampler -->
  <path class="ar" d="M330,170 L330,196" />
  <rect x="230" y="200" width="200" height="90" rx="10" fill="#B509AC" fill-opacity=".15" stroke="#B509AC" stroke-width="2" />
  <text class="t" x="330" y="226" text-anchor="middle">Decoder resampler</text>
  <text class="s" x="330" y="246" text-anchor="middle">one output query per</text>
  <text class="s" x="330" y="262" text-anchor="middle">(target module m, rank r)</text>
  <text class="s" x="330" y="280" text-anchor="middle">M = 1 module, R = 16 ranks</text>
  <path class="ar" d="M430,245 L476,245" />
  <!-- rank latents grid -->
  <text class="b" x="640" y="30" text-anchor="middle">16 rank latents for layer ℓ</text>
  <text class="s" x="640" y="48" text-anchor="middle">module m = MLP down_proj</text>
  <g font-size="11" fill="#B509AC">
    <rect x="490" y="70" width="60" height="34" rx="6" fill="#B509AC" fill-opacity=".2" stroke="#B509AC" /><text x="520" y="91" text-anchor="middle">r = 1</text>
    <rect x="558" y="70" width="60" height="34" rx="6" fill="#B509AC" fill-opacity=".2" stroke="#B509AC" /><text x="588" y="91" text-anchor="middle">r = 2</text>
    <rect x="626" y="70" width="60" height="34" rx="6" fill="#B509AC" fill-opacity=".2" stroke="#B509AC" /><text x="656" y="91" text-anchor="middle">r = 3</text>
    <rect x="694" y="70" width="60" height="34" rx="6" fill="#B509AC" fill-opacity=".2" stroke="#B509AC" /><text x="724" y="91" text-anchor="middle">r = 4</text>
    <rect x="490" y="112" width="60" height="34" rx="6" fill="#B509AC" fill-opacity=".2" stroke="#B509AC" /><text x="520" y="133" text-anchor="middle">r = 5</text>
    <rect x="558" y="112" width="60" height="34" rx="6" fill="#B509AC" fill-opacity=".2" stroke="#B509AC" /><text x="588" y="133" text-anchor="middle">r = 6</text>
    <rect x="626" y="112" width="60" height="34" rx="6" fill="#B509AC" fill-opacity=".2" stroke="#B509AC" /><text x="656" y="133" text-anchor="middle">r = 7</text>
    <rect x="694" y="112" width="60" height="34" rx="6" fill="#B509AC" fill-opacity=".2" stroke="#B509AC" /><text x="724" y="133" text-anchor="middle">r = 8</text>
    <rect x="490" y="154" width="60" height="34" rx="6" fill="#B509AC" fill-opacity=".2" stroke="#B509AC" /><text x="520" y="175" text-anchor="middle">r = 9</text>
    <rect x="558" y="154" width="60" height="34" rx="6" fill="#B509AC" fill-opacity=".2" stroke="#B509AC" /><text x="588" y="175" text-anchor="middle">r = 10</text>
    <rect x="626" y="154" width="60" height="34" rx="6" fill="#B509AC" fill-opacity=".2" stroke="#B509AC" /><text x="656" y="175" text-anchor="middle">r = 11</text>
    <rect x="694" y="154" width="60" height="34" rx="6" fill="#B509AC" fill-opacity=".2" stroke="#B509AC" /><text x="724" y="175" text-anchor="middle">r = 12</text>
    <rect x="490" y="196" width="60" height="34" rx="6" fill="#B509AC" fill-opacity=".2" stroke="#B509AC" /><text x="520" y="217" text-anchor="middle">r = 13</text>
    <rect x="558" y="196" width="60" height="34" rx="6" fill="#B509AC" fill-opacity=".2" stroke="#B509AC" /><text x="588" y="217" text-anchor="middle">r = 14</text>
    <rect x="626" y="196" width="60" height="34" rx="6" fill="#B509AC" fill-opacity=".2" stroke="#B509AC" /><text x="656" y="217" text-anchor="middle">r = 15</text>
    <rect x="694" y="196" width="60" height="34" rx="6" fill="#B509AC" fill-opacity=".2" stroke="#B509AC" /><text x="724" y="217" text-anchor="middle">r = 16</text>
  </g>
  <text class="s" x="622" y="258" text-anchor="middle">each latent is a vector of size Z = 512</text>
  <text class="s" x="622" y="276" text-anchor="middle">full output  O ∈ R^(L × M × R × Z)</text>
  <text class="t" x="622" y="304" text-anchor="middle" fill="#B509AC">repeated for every layer ℓ = 0 … L − 1</text>
  <text class="s" x="800" y="140" text-anchor="middle">still no</text>
  <text class="s" x="800" y="156" text-anchor="middle">weights,</text>
  <text class="s" x="800" y="172" text-anchor="middle">just latents</text>
</svg>
</div>

    <p>This is the only trained component. For each layer slice, an encoder resampler lets a set of learned latent queries (latent size 512) cross-attend to the \(S\) hidden states, producing a fixed-size summary no matter how many frames went in. A decoder resampler then asks that summary one question per target module and per LoRA rank direction. With one target module (the MLP down-projection) and rank 16, that is 16 rank latents for this layer. The same thing happens for every layer, so the output is a tensor of shape \(L \times M \times R \times Z\): still latents, not weights.</p>

  </div>
  <div class="wt-step" data-label="A and B">
    <h4>4. Latents become LoRA factors and attach to the frozen model</h4>

    <div class="fig-svg">
<svg viewBox="0 0 900 300" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="A shared projection head maps each rank latent to one row of A and one row of B, giving rank-16 factors for the layer. They are added to the frozen MLP down-projection as a low-rank update; the B scale starts at zero so an untrained adapter changes nothing.">
  <style>
    .n{fill:none;stroke:currentColor;stroke-width:1.5}
    .t{fill:currentColor;font-size:14px}
    .b{fill:currentColor;font-size:15px;font-weight:600}
    .s{fill:currentColor;font-size:12px;opacity:.75}
    .ar{fill:none;stroke:currentColor;stroke-width:1.5;marker-end:url(#a4)}
    .flow{fill:none;stroke:#B509AC;stroke-width:2;stroke-dasharray:6 6;animation:d4 1.2s linear infinite;marker-end:url(#a4p)}
    @keyframes d4{to{stroke-dashoffset:-24}}
  </style>
  <defs>
    <marker id="a4" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto"><path d="M0,0 L10,5 L0,10 z" fill="currentColor" /></marker>
    <marker id="a4p" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto"><path d="M0,0 L10,5 L0,10 z" fill="#B509AC" /></marker>
  </defs>
  <!-- latents -->
  <text class="b" x="90" y="30" text-anchor="middle">16 rank latents</text>
  <text class="s" x="90" y="48" text-anchor="middle">layer ℓ, from step 3</text>
  <g fill="#B509AC" fill-opacity=".2" stroke="#B509AC">
    <rect x="40" y="62" width="100" height="16" rx="4" /><rect x="40" y="84" width="100" height="16" rx="4" /><rect x="40" y="106" width="100" height="16" rx="4" />
    <rect x="40" y="128" width="100" height="16" rx="4" /><rect x="40" y="172" width="100" height="16" rx="4" /><rect x="40" y="194" width="100" height="16" rx="4" />
  </g>
  <text class="t" x="90" y="164" text-anchor="middle">⋮</text>
  <text class="s" x="90" y="230" text-anchor="middle">r = 1 … 16</text>
  <path class="ar" d="M140,136 L196,136" />
  <!-- projection head -->
  <rect x="200" y="96" width="150" height="80" rx="10" fill="#B509AC" fill-opacity=".15" stroke="#B509AC" stroke-width="2" />
  <text class="t" x="275" y="124" text-anchor="middle">Shared</text>
  <text class="t" x="275" y="142" text-anchor="middle">projection head</text>
  <text class="s" x="275" y="162" text-anchor="middle">same head for all layers</text>
  <path class="flow" d="M350,120 L396,80" />
  <path class="flow" d="M350,152 L396,192" />
  <!-- A and B -->
  <rect x="400" y="50" width="200" height="56" rx="8" fill="#B509AC" fill-opacity=".2" stroke="#B509AC" stroke-width="1.5" />
  <text class="t" x="500" y="74" text-anchor="middle">A_ℓ   (16 × d_in)</text>
  <text class="s" x="500" y="94" text-anchor="middle">learned scale, starts at 1</text>
  <rect x="400" y="166" width="200" height="56" rx="8" fill="#B509AC" fill-opacity=".2" stroke="#B509AC" stroke-width="1.5" />
  <text class="t" x="500" y="190" text-anchor="middle">B_ℓ   (16 × d_out)</text>
  <text class="s" x="500" y="210" text-anchor="middle">learned scale, starts at 0</text>
  <text class="s" x="500" y="140" text-anchor="middle">one row of each per rank latent</text>
  <text class="s" x="500" y="248" text-anchor="middle">B at zero ⇒ untrained adapter is a no-op</text>
  <!-- injection -->
  <path class="flow" d="M600,78 L646,120" />
  <path class="flow" d="M600,194 L646,152" />
  <rect x="650" y="60" width="220" height="150" rx="10" fill="#9a9a9a" fill-opacity=".12" stroke="#9a9a9a" stroke-width="1.5" />
  <text class="b" x="760" y="86" text-anchor="middle">Layer ℓ, MLP down_proj</text>
  <text class="s" x="760" y="104" text-anchor="middle">frozen weight  W_ℓ  (d_out × d_in)</text>
  <text class="t" x="760" y="140" text-anchor="middle">y = x W_ℓᵀ + s · (x A_ℓᵀ) B_ℓ</text>
  <text class="s" x="760" y="164" text-anchor="middle">a rank-16 update ΔW_ℓ = s · B_ℓᵀ A_ℓ</text>
  <text class="s" x="760" y="180" text-anchor="middle">on top of weights that never change</text>
  <text class="s" x="760" y="198" text-anchor="middle" fill="#B509AC">specific to this video</text>
  <text class="s" x="760" y="248" text-anchor="middle">this is the adapter  θ(v): {A_ℓ, B_ℓ} for every layer</text>
</svg>
</div>

    <p>A shared projection head turns each rank latent into one row of \(\mathbf{A}_\ell\) and one row of \(\mathbf{B}_\ell\), giving rank-16 factors for the layer. Learned multipliers scale them; the \(\mathbf{B}\) scale is initialized to zero, so before training the adapter is a null perturbation and the model behaves exactly like the base. The factors are added to the frozen down-projection of that layer as a standard LoRA update. The full set, \(\theta(v) = \{\mathbf{A}_\ell, \mathbf{B}_\ell\}_{\ell}\), is the adapter for this one video. Notice what happened to the token budget along the way:</p>

    <div class="tok-row"><span class="tok tok-b tok-x">f1</span><span class="tok tok-b tok-x">f2</span><span class="tok tok-b tok-x">…</span><span class="tok tok-b tok-x">f12</span><span class="tok-arrow">→</span><span class="tok tok-a">A_0, B_0</span><span class="tok tok-a">A_1, B_1</span><span class="tok tok-a">…</span><span class="tok tok-a">A_L−1, B_L−1</span><span class="tok-arrow">→</span><span class="tok tok-c tok-hl">0 visual tokens in context</span></div>

  </div>
  <div class="wt-step" data-label="Ask">
    <h4>5. Ask the question with zero visual tokens</h4>

    <div class="fig-svg">
<svg viewBox="0 0 900 330" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="Top: the base model answers the CaReBench caption prompt with all twelve frames in context and misreads the pipe as a piece of paper, token-F1 0.32. Bottom: the same frozen model with the generated adapter answers from the text prompt alone, zero visual tokens, and describes the lighter and the lighting correctly, token-F1 0.56.">
  <style>
    .n{fill:none;stroke:currentColor;stroke-width:1.5}
    .t{fill:currentColor;font-size:14px}
    .b{fill:currentColor;font-size:15px;font-weight:600}
    .s{fill:currentColor;font-size:12px;opacity:.75}
    .ar{fill:none;stroke:currentColor;stroke-width:1.5;marker-end:url(#a5)}
  </style>
  <defs><marker id="a5" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto"><path d="M0,0 L10,5 L0,10 z" fill="currentColor" /></marker></defs>
  <!-- base row -->
  <text class="b" x="30" y="28">Base model, video in context</text>
  <rect class="n" x="30" y="40" width="380" height="92" rx="10" stroke="#9a9a9a" />
  <text class="s" x="44" y="58">context window</text>
  <g fill="#2698BA" fill-opacity=".3" stroke="#2698BA">
    <rect x="44" y="66" width="26" height="18" rx="3" /><rect x="74" y="66" width="26" height="18" rx="3" /><rect x="104" y="66" width="26" height="18" rx="3" />
    <rect x="134" y="66" width="26" height="18" rx="3" /><rect x="164" y="66" width="26" height="18" rx="3" /><rect x="194" y="66" width="26" height="18" rx="3" />
    <rect x="224" y="66" width="26" height="18" rx="3" /><rect x="254" y="66" width="26" height="18" rx="3" /><rect x="284" y="66" width="26" height="18" rx="3" />
    <rect x="314" y="66" width="26" height="18" rx="3" /><rect x="344" y="66" width="26" height="18" rx="3" /><rect x="374" y="66" width="26" height="18" rx="3" />
  </g>
  <text class="s" x="44" y="100">12 frames × hundreds of visual tokens each</text>
  <rect x="44" y="106" width="352" height="20" rx="4" fill="currentColor" fill-opacity=".08" stroke="currentColor" />
  <text class="s" x="220" y="120" text-anchor="middle">"Describe the video in as much useful visual detail…"</text>
  <path class="ar" d="M410,86 L446,86" />
  <rect x="450" y="62" width="130" height="48" rx="10" fill="#9a9a9a" fill-opacity=".15" stroke="#9a9a9a" stroke-width="1.5" />
  <text class="t" x="515" y="90" text-anchor="middle">Frozen SmolVLM2</text>
  <path class="ar" d="M580,86 L616,86" />
  <rect x="620" y="40" width="250" height="92" rx="10" fill="#F29105" fill-opacity=".12" stroke="#F29105" stroke-width="1.5" />
  <text class="s" x="632" y="58">"…holding a lighter and</text>
  <text class="s" x="632" y="74" fill="#F29105" opacity="1">a piece of paper. He is blowing</text>
  <text class="s" x="632" y="90" fill="#F29105" opacity="1">on the paper…"</text>
  <text class="t" x="632" y="120" fill="#F29105">token-F1 0.32</text>
  <!-- divider -->
  <path class="n" d="M30,158 L870,158" stroke-dasharray="2 5" opacity=".5" />
  <!-- f2l row -->
  <text class="b" x="30" y="186">Frames2LoRA, adapter attached</text>
  <rect class="n" x="30" y="198" width="380" height="92" rx="10" stroke="#9a9a9a" />
  <text class="s" x="44" y="216">context window</text>
  <text class="t" x="44" y="244" fill="#00ab37">0 visual tokens</text>
  <text class="s" x="170" y="244">(the frames were internalized in steps 1–4)</text>
  <rect x="44" y="264" width="352" height="20" rx="4" fill="currentColor" fill-opacity=".08" stroke="currentColor" />
  <text class="s" x="220" y="278" text-anchor="middle">"Describe the video in as much useful visual detail…"</text>
  <path class="ar" d="M410,244 L446,244" />
  <rect x="450" y="212" width="130" height="64" rx="10" fill="#9a9a9a" fill-opacity=".15" stroke="#9a9a9a" stroke-width="1.5" />
  <text class="t" x="515" y="236" text-anchor="middle">Frozen SmolVLM2</text>
  <rect x="470" y="246" width="90" height="22" rx="6" fill="#B509AC" fill-opacity=".2" stroke="#B509AC" />
  <text class="s" x="515" y="261" text-anchor="middle">+ θ(v)</text>
  <path class="ar" d="M580,244 L616,244" />
  <rect x="620" y="198" width="250" height="92" rx="10" fill="#00ab37" fill-opacity=".12" stroke="#00ab37" stroke-width="1.5" />
  <text class="s" x="632" y="216">"…black tank top… tattoos…</text>
  <text class="s" x="632" y="232" fill="#00ab37" opacity="1">using their left hand to light</text>
  <text class="s" x="632" y="248" fill="#00ab37" opacity="1">it with a lighter."</text>
  <text class="t" x="632" y="278" fill="#00ab37">token-F1 0.56</text>
  <text class="s" x="450" y="318" text-anchor="middle">same model weights, same prompt, same decoding; only the video's route into the model differs</text>
</svg>
</div>

    <p>Now the CaReBench caption prompt goes in: “Describe the video in as much useful visual detail as possible. Include the main activity, visible people or objects, scene context, appearance, and any important visual details that help explain what is happening.” Same frozen SmolVLM2, same prompt, same decoding in both rows. The base model, with all 12 frames in context, gets the room right but misreads the action: “He is holding a lighter and <span class="tok tok-d">a piece of paper. He is blowing on the paper and then putting it in his mouth.</span>” (token-F1 0.32 against the reference). Frames2LoRA, with nothing but the prompt in context, answers: “A person is smoking a cigarette in a room with a brick wall and a wooden door. The person is wearing a black tank top and has tattoos on their arms. <span class="tok tok-c">They are holding the cigarette in their right hand and using their left hand to light it with a lighter.</span>” (token-F1 0.56). It calls the pipe a cigarette, but it has the tank top, the tattoos, the lighter and the act of lighting.</p>

  </div>
  <div class="wt-step" data-label="Reuse">
    <h4>6. Keep the adapter and ask again</h4>

    <div class="fig-svg">
<svg viewBox="0 0 900 300" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="One generated adapter serves every question about the video. On VidCapBench, with about fifteen questions per video, average time to first token per question falls from 7.06 seconds to 0.58 seconds for the 2.2B model, including the one-time internalization.">
  <style>
    .n{fill:none;stroke:currentColor;stroke-width:1.5}
    .t{fill:currentColor;font-size:14px}
    .b{fill:currentColor;font-size:15px;font-weight:600}
    .s{fill:currentColor;font-size:12px;opacity:.75}
    .q{fill:none;stroke:#2698BA;stroke-width:1.5;marker-end:url(#a6b)}
    .o{fill:none;stroke:#00ab37;stroke-width:1.5;marker-end:url(#a6g)}
    .flow{fill:none;stroke:#B509AC;stroke-width:2;stroke-dasharray:6 6;animation:d6 1.2s linear infinite;marker-end:url(#a6p)}
    @keyframes d6{to{stroke-dashoffset:-24}}
  </style>
  <defs>
    <marker id="a6b" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="6" markerHeight="6" orient="auto"><path d="M0,0 L10,5 L0,10 z" fill="#2698BA" /></marker>
    <marker id="a6g" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="6" markerHeight="6" orient="auto"><path d="M0,0 L10,5 L0,10 z" fill="#00ab37" /></marker>
    <marker id="a6p" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto"><path d="M0,0 L10,5 L0,10 z" fill="#B509AC" /></marker>
  </defs>
  <!-- adapter -->
  <rect x="30" y="70" width="140" height="70" rx="10" fill="#B509AC" fill-opacity=".15" stroke="#B509AC" stroke-width="2" />
  <text class="t" x="100" y="98" text-anchor="middle">θ(v)</text>
  <text class="s" x="100" y="118" text-anchor="middle">generated once</text>
  <path class="flow" d="M170,105 L226,105" />
  <!-- model -->
  <rect x="230" y="40" width="170" height="130" rx="10" fill="#9a9a9a" fill-opacity=".15" stroke="#9a9a9a" stroke-width="1.5" />
  <text class="t" x="315" y="96" text-anchor="middle">Frozen SmolVLM2</text>
  <rect x="270" y="110" width="90" height="24" rx="6" fill="#B509AC" fill-opacity=".2" stroke="#B509AC" />
  <text class="s" x="315" y="126" text-anchor="middle">+ θ(v)</text>
  <!-- questions fan in from the right-top, answers out right-bottom -->
  <g fill="#2698BA" fill-opacity=".15" stroke="#2698BA" font-size="12">
    <rect x="470" y="32" width="150" height="22" rx="5" /><rect x="470" y="60" width="150" height="22" rx="5" /><rect x="470" y="88" width="150" height="22" rx="5" /><rect x="470" y="144" width="150" height="22" rx="5" />
  </g>
  <text class="s" x="545" y="47" text-anchor="middle">Q1: What is the weather like?</text>
  <text class="s" x="545" y="75" text-anchor="middle">Q2: second question</text>
  <text class="s" x="545" y="103" text-anchor="middle">Q3: third question</text>
  <text class="t" x="545" y="134" text-anchor="middle">⋮</text>
  <text class="s" x="545" y="159" text-anchor="middle">Q15: fifteenth question</text>
  <path class="q" d="M470,43 L402,80" /><path class="q" d="M470,71 L402,92" /><path class="q" d="M470,99 L402,104" /><path class="q" d="M470,155 L402,128" />
  <text class="s" x="545" y="186" text-anchor="middle">VidCapBench: 15.23 questions per video</text>
  <text class="s" x="545" y="202" text-anchor="middle">each one: text prompt only, 0 visual tokens</text>
  <!-- TTFT bars -->
  <text class="b" x="30" y="222">Average TTFT per question, 2.2B, internalization time included</text>
  <rect x="30" y="234" width="450" height="18" rx="4" fill="#F29105" fill-opacity=".25" stroke="#F29105" />
  <text class="t" x="490" y="248" fill="#F29105">7.06 s   base, video in context</text>
  <rect x="30" y="262" width="37" height="18" rx="4" fill="#00ab37" fill-opacity=".3" stroke="#00ab37" />
  <text class="t" x="77" y="276" fill="#00ab37">0.58 s   Frames2LoRA, adapter reused</text>
  <text class="s" x="870" y="296" text-anchor="end">500M: 6.45 s → 0.55 s</text>
</svg>
</div>

    <p>The adapter does not expire after one answer. Every later question about the same video is a text-only prompt through the same adapted model, and the frames are never re-encoded. VidCapBench is the natural place to measure this, because each video comes with 15.23 questions on average. Averaged over all 1,523 queries, and charging Frames2LoRA for the one-time internalization, time to first token per question drops from 7.06 s to 0.58 s at 2.2B and from 6.45 s to 0.55 s at 500M. Amortized over the first 5 questions it is already 1.44 s per question at 2.2B; after 10 it is 0.80 s.</p>

  </div>
</div>

<h2 id="under-the-hood">Under the hood</h2>

<table class="notation">
  <thead>
    <tr>
      <th>Symbol</th>
      <th>Meaning</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>\(v\), \(i\), \(p\), \(y\)</td>
      <td>video, internalization instruction, downstream text prompt, response</td>
    </tr>
    <tr>
      <td>\(E\), \(F\)</td>
      <td>frozen SmolVLM2 used as video encoder and as answer model (same weights)</td>
    </tr>
    <tr>
      <td>\(H_\phi\)</td>
      <td>the Perceiver hypernetwork; \(\phi\) are the only trained parameters</td>
    </tr>
    <tr>
      <td>\(\mathbf{h}_\ell\), \(\mathbf{C}\)</td>
      <td>text-side hidden states after layer \(\ell\); their stack, \(L \times S \times D\)</td>
    </tr>
    <tr>
      <td>\(L\), \(S\), \(D\)</td>
      <td>number of layers, fused sequence length, hidden dimension</td>
    </tr>
    <tr>
      <td>\(M\), \(R\), \(Z\)</td>
      <td>target modules per layer (1: MLP down_proj), LoRA rank (16), latent size (512)</td>
    </tr>
    <tr>
      <td>\(\mathbf{A}_{\ell,m} \in \mathbb{R}^{R \times d_{\mathrm{in}}}\), \(\mathbf{B}_{\ell,m} \in \mathbb{R}^{R \times d_{\mathrm{out}}}\)</td>
      <td>generated LoRA factors for layer \(\ell\), module \(m\)</td>
    </tr>
    <tr>
      <td>\(\theta(v)\)</td>
      <td>the generated adapter: all \(\mathbf{A}\), \(\mathbf{B}\) factors for video \(v\)</td>
    </tr>
    <tr>
      <td>\(s\)</td>
      <td>fixed LoRA scaling factor</td>
    </tr>
  </tbody>
</table>

<p>The whole method is three lines. The encoder produces video-conditioned states, the hypernetwork maps them to an adapter, and the answer model conditions on the prompt and the adapter but never on the video tokens:</p>

\[\mathbf{C} = E(v, i), \qquad \theta(v) = H_\phi(\mathbf{C}), \qquad p_\phi(y \mid p, v) = F\big(y \mid p;\, \theta(v)\big).\]

<p>Inside a frozen linear layer with weight \(\mathbf{W} \in \mathbb{R}^{d_{\mathrm{out}} \times d_{\mathrm{in}}}\), the generated factors act as an ordinary LoRA update. In the row-vector convention the layer computes \(\mathbf{x}\mathbf{W}^\top\) and the adapter adds a rank-\(R\) term, which is the same as perturbing the weight by \(\Delta\mathbf{W} = s\,\mathbf{B}^\top\mathbf{A}\):</p>

\[\mathbf{y} = \mathbf{x}\mathbf{W}^\top + s\,(\mathbf{x}\mathbf{A}_{\ell,m}^\top)\,\mathbf{B}_{\ell,m}.\]

<p>Training is teacher-forced cross-entropy over response tokens. A frozen SmolVLM2 teacher that does see the frames writes captions and summaries offline; the student answer model has to reproduce them from the prompt and the adapter alone, and the gradient flows only into \(\phi\):</p>

\[\mathcal{L}(\phi) = -\sum_t \log p_\phi\big(y_t \mid y_{&lt;t},\, p;\, \theta(v)\big).\]

<div class="fig-svg">
<svg viewBox="0 0 900 330" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="Architecture: video and instruction enter the frozen encoder E, which yields layer-wise states C; the Perceiver hypernetwork H with parameters phi maps C to the adapter theta of v; the frozen answer model F plus the adapter answers the text prompt p. Training compares the output with a caption written by a frozen teacher that saw the frames, and gradients flow only into phi.">
  <style>
    .n{fill:none;stroke:currentColor;stroke-width:1.5}
    .t{fill:currentColor;font-size:14px}
    .b{fill:currentColor;font-size:15px;font-weight:600}
    .s{fill:currentColor;font-size:12px;opacity:.75}
    .ar{fill:none;stroke:currentColor;stroke-width:1.5;marker-end:url(#a7)}
    .flow{fill:none;stroke:#B509AC;stroke-width:2;stroke-dasharray:6 6;animation:d7 1.2s linear infinite;marker-end:url(#a7p)}
    .grad{fill:none;stroke:#F29105;stroke-width:1.5;stroke-dasharray:3 4;marker-end:url(#a7o)}
    @keyframes d7{to{stroke-dashoffset:-24}}
  </style>
  <defs>
    <marker id="a7" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto"><path d="M0,0 L10,5 L0,10 z" fill="currentColor" /></marker>
    <marker id="a7p" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto"><path d="M0,0 L10,5 L0,10 z" fill="#B509AC" /></marker>
    <marker id="a7o" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto"><path d="M0,0 L10,5 L0,10 z" fill="#F29105" /></marker>
  </defs>
  <!-- inputs -->
  <rect x="20" y="70" width="110" height="60" rx="10" fill="#2698BA" fill-opacity=".15" stroke="#2698BA" stroke-width="1.5" />
  <text class="t" x="75" y="96" text-anchor="middle">video  v</text>
  <text class="s" x="75" y="116" text-anchor="middle">+ instruction  i</text>
  <path class="ar" d="M130,100 L166,100" />
  <!-- E -->
  <rect x="170" y="60" width="130" height="80" rx="10" fill="#9a9a9a" fill-opacity=".15" stroke="#9a9a9a" stroke-width="1.5" />
  <text class="t" x="235" y="92" text-anchor="middle">Encoder  E</text>
  <text class="s" x="235" y="112" text-anchor="middle">frozen SmolVLM2</text>
  <path class="ar" d="M300,100 L336,100" />
  <text class="s" x="318" y="88" text-anchor="middle">C</text>
  <!-- H -->
  <rect x="340" y="40" width="190" height="120" rx="10" fill="#B509AC" fill-opacity=".15" stroke="#B509AC" stroke-width="2" />
  <text class="t" x="435" y="64" text-anchor="middle">Hypernetwork  H_φ</text>
  <text class="s" x="435" y="86" text-anchor="middle">per layer ℓ:</text>
  <text class="s" x="435" y="102" text-anchor="middle">encoder resampler</text>
  <text class="s" x="435" y="118" text-anchor="middle">→ decoder resampler</text>
  <text class="s" x="435" y="134" text-anchor="middle">→ shared projection head</text>
  <text class="s" x="435" y="152" text-anchor="middle" fill="#B509AC" opacity="1">the only trained part</text>
  <path class="flow" d="M530,100 L566,100" />
  <text class="s" x="548" y="88" text-anchor="middle">θ(v)</text>
  <!-- F -->
  <rect x="570" y="40" width="160" height="120" rx="10" fill="#9a9a9a" fill-opacity=".15" stroke="#9a9a9a" stroke-width="1.5" />
  <text class="t" x="650" y="68" text-anchor="middle">Answer model  F</text>
  <text class="s" x="650" y="88" text-anchor="middle">same frozen SmolVLM2</text>
  <rect x="600" y="100" width="100" height="24" rx="6" fill="#B509AC" fill-opacity=".2" stroke="#B509AC" />
  <text class="s" x="650" y="116" text-anchor="middle">+ {A_ℓ, B_ℓ}</text>
  <text class="s" x="650" y="146" text-anchor="middle">on down_proj; no visual tokens</text>
  <rect x="570" y="200" width="160" height="40" rx="8" fill="#2698BA" fill-opacity=".15" stroke="#2698BA" stroke-width="1.5" />
  <text class="t" x="650" y="225" text-anchor="middle">text prompt  p</text>
  <path class="ar" d="M650,200 L650,164" />
  <path class="ar" d="M730,100 L766,100" />
  <rect x="770" y="70" width="110" height="60" rx="10" fill="#00ab37" fill-opacity=".15" stroke="#00ab37" stroke-width="1.5" />
  <text class="t" x="825" y="96" text-anchor="middle">output  y</text>
  <text class="s" x="825" y="116" text-anchor="middle">token by token</text>
  <!-- teacher and loss -->
  <rect x="20" y="200" width="200" height="60" rx="10" fill="#9a9a9a" fill-opacity=".15" stroke="#9a9a9a" stroke-width="1.5" />
  <text class="t" x="120" y="224" text-anchor="middle">Frozen teacher</text>
  <text class="s" x="120" y="244" text-anchor="middle">same SmolVLM2, sees the frames</text>
  <path class="ar" d="M75,130 L75,198" />
  <path class="ar" d="M220,230 L300,230" />
  <rect x="304" y="200" width="200" height="60" rx="10" fill="#F29105" fill-opacity=".12" stroke="#F29105" stroke-width="1.5" />
  <text class="t" x="404" y="224" text-anchor="middle">Cross-entropy loss</text>
  <text class="s" x="404" y="244" text-anchor="middle">teacher caption vs. y, teacher-forced</text>
  <path class="ar" d="M825,130 L825,274 L404,274 L404,262" />
  <path class="grad" d="M404,200 L404,164" />
  <text class="s" x="450" y="308" text-anchor="middle" fill="#F29105" opacity="1">training: gradients update φ only; E, F and the teacher stay frozen.  At inference the teacher and the loss disappear.</text>
</svg>
<div class="fig-caption">The full loop. Only the hypernetwork receives gradients; the encoder, the answer model and the teacher are the same frozen SmolVLM2.</div>
</div>

<p>Two details in the hypernetwork matter more than they look. First, the Perceiver bottleneck is what makes frame count a free variable: the latent queries produce a fixed-size summary whether \(S\) covers 8 frames or 1,024, which is why a model trained only at 12 frames and 384 px can be run at 1,024 frames and 1024 px. Second, the zero-initialized \(\mathbf{B}\) scale means training starts from the base model’s behavior and learns a perturbation, rather than starting from a random adapter that has to be unlearned. Training data are spans from FineVideo, mixed 60/30/10 across single-scene, adjacent multi-scene and full-video spans, with audio excluded.</p>

<p>The finding I did not expect is that adapters compose in rank space. Split a video into two temporal halves, internalize each independently, and concatenate the two rank-16 adapters along the rank dimension. Nothing in training ever saw a composed adapter, yet on VDC the composed adapter keeps 93.1% of the single-video adapter’s mean token-F1 at 500M (0.206 vs 0.221) and 86.2% at 2.2B (0.211 vs 0.245), and it produces coherent video-level captions rather than text tied to one half.</p>

<div class="tok-row"><span class="tok tok-b">first half</span><span class="tok-arrow">→</span><span class="tok tok-a">θ(v₁), rank 16</span><span class="tok-arrow">⊕</span><span class="tok tok-b">second half</span><span class="tok-arrow">→</span><span class="tok tok-a">θ(v₂), rank 16</span><span class="tok-arrow">=</span><span class="tok tok-c">composed adapter, rank 32</span></div>

<p>The rank directions are redundant but not interchangeable. Ranking each rank slice by the product of its factor norms and keeping only the top 8 gives 0.1264 token-F1 on ActivityNet Captions, against 0.1262 for the full rank-16 adapter, while the lowest-scoring single slice lands below the zero-adapter baseline. The ordering is the same in every one of 500 examples (direction R11 always scores highest), which suggests the hypernetwork has learned a fixed coordinate system for its output. Layer-wise removal on the 2.2B model adds that the updates whose removal hurts most sit in the later layers, close to the output logits.</p>

<h2 id="what-the-numbers-say">What the numbers say</h2>

<ul>
  <li><strong>Quality holds.</strong> On all five captioning benchmarks (ActivityNet Captions, PLM-RDCap, PLM-RCap, VDC, CaReBench), at both 500M and 2.2B, Frames2LoRA is statistically non-inferior and equivalent to video-in-context inference under an LLM judge (Spearman 0.823 with human ratings), recovering 91.9% of the base judge score at 2.2B and 84.2% at 500M. Video QA was never trained on, yet 7 of 8 benchmark-scale pairings pass, and on NExT-QA the adapter beats the base at both scales.</li>
  <li><strong>Queries get cheap.</strong> Across a sweep of 8 to 1,024 frames and 224 to 1024 px, query TTFT falls by a geometric mean of 6.7x at 500M and 20.1x at 2.2B (maximum 79.1x), and answer-time input tokens fall by 150x and 302x on average, reaching 713x and 1,507x.</li>
  <li><strong>It survives where in-context inference does not.</strong> Trained at 12 frames, the model stays stable through 1,024 frames and 1024 px (average token-F1 change of -0.012 at 500M). At 1024 px and high frame counts direct inference degenerates into repetitive output and Frames2LoRA leads by +0.12 to +0.13 token-F1.</li>
</ul>

<h2 id="try-it">Try it</h2>

<ul>
  <li>Paper page on this site: <a href="/papers/frames2lora/">/papers/frames2lora/</a></li>
  <li>arXiv: <a href="https://arxiv.org/abs/2606.04351">2606.04351</a> (v1 was titled Video2LoRA)</li>
  <li>Code: <a href="https://github.com/frames2lora/Frames2LoRA">github.com/frames2lora/Frames2LoRA</a></li>
  <li>Checkpoints: <a href="https://huggingface.co/MananSuri27/Frames2LoRA-SmolVLM-ckpts">Frames2LoRA-SmolVLM-ckpts on Hugging Face</a></li>
  <li>Project page with the qualitative-example explorer: <a href="https://frames2lora.github.io/">frames2lora.github.io</a></li>
  <li>The audio-visual follow-up: <a href="/papers/omni2lora/">Omni2LoRA</a></li>
</ul>]]></content><author><name></name></author><category term="research" /><category term="video" /><category term="vlm" /><category term="lora" /><category term="hypernetworks" /><summary type="html"><![CDATA[How Frames2LoRA turns a video into a LoRA adapter so a frozen vision-language model answers questions about it with zero visual tokens in context.]]></summary></entry><entry><title type="html">Manufacturing bugs that survive the test suite: CyberForge explained</title><link href="https://manansuri.com/blog/2026/cyberforge-explained/" rel="alternate" type="text/html" title="Manufacturing bugs that survive the test suite: CyberForge explained" /><published>2026-09-10T12:00:00+00:00</published><updated>2026-09-10T12:00:00+00:00</updated><id>https://manansuri.com/blog/2026/cyberforge-explained</id><content type="html" xml:base="https://manansuri.com/blog/2026/cyberforge-explained/"><![CDATA[<p>Guetzli is Google’s JPEG compressor. When its parser meets a metadata segment, it reads the segment’s declared length and, before copying anything, checks that the file actually holds that many bytes. Delete that one check and run the project’s test suite: 10 of 10 tests pass. Nothing in the repository notices. Now hand the modified binary a 504-byte JPEG whose length field says 65,535, and AddressSanitizer reports a heap-buffer-overflow in <code class="language-plaintext highlighter-rouge">ProcessAPP</code>. That is a security weakness in the sense that matters: invisible to the tests, real under a crafted input.</p>

<p>Software-engineering agents improved by double digits once thousands of real repositories were packaged with reproducible builds and tests. Security agents never got that corpus, because a functional bug is easy to verify (a test fails) while a security weakness has to do the opposite: stay latent under the existing tests and surface only under adversarial input. Existing runnable vulnerability datasets are mined from disclosed CVEs, so they grow at the rate humans find and publish bugs. <a href="/papers/cyberforge/">CyberForge</a> is our attempt to manufacture that data instead. I worked on it with Amine Lbath and colleagues during a NIST PREP fellowship in spring 2026, with Dinesh Manocha at UMD; Amine and I share first authorship. This post traces a single guetzli instance from the released corpus, <code class="language-plaintext highlighter-rouge">guetzli/vulnerability_FZ_24</code>, through every stage of the pipeline.</p>

<h2 id="the-idea-in-one-picture">The idea in one picture</h2>

<p>The SWE-Smith family of bug-injection pipelines accepts an edit once a unit test fails. CyberForge flips the criterion: an edit is accepted only if every unit test still passes <em>and</em> a proof-of-vulnerability input (PoV) crashes the injected build but not the clean one. Neither half means anything alone, so the oracle checks the injection and the PoV jointly.</p>

<div class="fig-svg">
<svg viewBox="0 0 900 300" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="Contrast between a functional-bug oracle, which accepts an edit once a unit test fails, and the CyberForge oracle, which accepts an edit only if every unit test still passes and a proof-of-vulnerability input crashes the injected build but not the clean build.">
  <style>
    .n { fill: none; stroke: currentColor; stroke-width: 1.5; }
    .t { fill: currentColor; font-size: 14px; }
    .h { fill: currentColor; font-size: 15px; font-weight: 600; }
    .s { fill: currentColor; font-size: 12px; opacity: 0.75; }
    .ar { fill: none; stroke: currentColor; stroke-width: 1.5; }
  </style>
  <defs>
    <marker id="a1" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto-start-reverse"><path d="M0,0 L10,5 L0,10 z" fill="currentColor" /></marker>
  </defs>
  <text class="h" x="225" y="30" text-anchor="middle">Functional bug (SWE-Smith style)</text>
  <text class="h" x="675" y="30" text-anchor="middle">Latent weakness (CyberForge)</text>
  <line x1="450" y1="15" x2="450" y2="290" stroke="currentColor" stroke-width="1" stroke-dasharray="4 4" opacity="0.4" />

  <rect class="n" x="125" y="55" width="200" height="46" rx="10" opacity="0.7" />
  <text class="t" x="225" y="83" text-anchor="middle">one edit to the project</text>
  <path class="ar" d="M225,101 L225,138" marker-end="url(#a1)" />
  <rect class="n" x="125" y="140" width="200" height="46" rx="10" stroke="#F29105" stroke-width="2" />
  <text class="t" x="225" y="168" text-anchor="middle">a unit test fails</text>
  <path class="ar" d="M225,186 L225,223" marker-end="url(#a1)" />
  <rect class="n" x="125" y="225" width="200" height="46" rx="10" opacity="0.7" />
  <text class="t" x="225" y="253" text-anchor="middle">bug accepted</text>
  <text class="s" x="225" y="290" text-anchor="middle">the test suite is the oracle</text>

  <rect class="n" x="575" y="55" width="200" height="46" rx="10" opacity="0.7" />
  <text class="t" x="675" y="83" text-anchor="middle">one edit to the project</text>
  <path class="ar" d="M675,101 C675,120 555,118 555,138" marker-end="url(#a1)" />
  <path class="ar" d="M675,101 C675,120 795,118 795,138" marker-end="url(#a1)" />
  <rect class="n" x="465" y="140" width="180" height="60" rx="10" stroke="#00ab37" stroke-width="2" />
  <text class="t" x="555" y="165" text-anchor="middle">every unit test</text>
  <text class="t" x="555" y="184" text-anchor="middle">still passes</text>
  <text class="h" x="675" y="175" text-anchor="middle" fill="#B509AC">AND</text>
  <rect class="n" x="705" y="140" width="180" height="60" rx="10" stroke="#B509AC" stroke-width="2" />
  <text class="t" x="795" y="165" text-anchor="middle">PoV crashes injected</text>
  <text class="t" x="795" y="184" text-anchor="middle">build, not clean build</text>
  <path class="ar" d="M555,200 C555,220 675,218 675,223" marker-end="url(#a1)" />
  <path class="ar" d="M795,200 C795,220 675,218 675,223" marker-end="url(#a1)" />
  <rect class="n" x="575" y="225" width="200" height="46" rx="10" stroke="#B509AC" stroke-width="2" />
  <text class="t" x="675" y="253" text-anchor="middle">weakness accepted</text>
  <text class="s" x="675" y="290" text-anchor="middle">tests must stay green; a crafted input is the oracle</text>
</svg>
<div class="fig-caption">Left: a functional-bug oracle uses the test suite as its judge. Right: CyberForge requires the tests to stay green and uses a crafted input, run differentially against the clean and injected builds, as its judge.</div>
</div>

<div class="callout"><span class="callout-label">Key idea</span>Instead of mining vulnerabilities from disclosure, create them: let an agent weaken a real check in a real project, then admit the instance only by execution, never by reading the diff. Corpus growth then depends on compute, not on CVE publication.</div>

<h2 id="walkthrough-one-guetzli-bug-from-injection-to-training-signal">Walkthrough: one guetzli bug, from injection to training signal</h2>

<p>Everything below is the actual instance <code class="language-plaintext highlighter-rouge">guetzli/vulnerability_FZ_24</code>, produced by the fuzzer-guided pipeline (<code class="language-plaintext highlighter-rouge">"producer": "fuzz_poc_guided"</code>) and labeled CWE-125, an out-of-bounds read.</p>

<div class="walkthrough">
  <div class="wt-title">Walkthrough: guetzli/vulnerability_FZ_24, end to end</div>
  <div class="wt-step" data-label="Project qualifies">
    <h4>1. The project qualifies</h4>
    <div class="fig-svg">
<svg viewBox="0 0 900 250" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="An OSS-Fuzz project, guetzli, ships a Docker image, build script, sanitizers, harnesses and unit tests; its tests are run five times unattended and must pass at 100 percent every time; flaky projects are rejected and 100 projects qualify.">
  <style>
    .n { fill: none; stroke: currentColor; stroke-width: 1.5; }
    .t { fill: currentColor; font-size: 14px; }
    .h { fill: currentColor; font-size: 15px; font-weight: 600; }
    .s { fill: currentColor; font-size: 12px; opacity: 0.75; }
    .ar { fill: none; stroke: currentColor; stroke-width: 1.5; }
  </style>
  <defs>
    <marker id="a2" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto-start-reverse"><path d="M0,0 L10,5 L0,10 z" fill="currentColor" /></marker>
  </defs>
  <rect class="n" x="20" y="40" width="230" height="170" rx="10" stroke="#2698BA" stroke-width="2" />
  <text class="h" x="135" y="66" text-anchor="middle">guetzli (OSS-Fuzz)</text>
  <text class="s" x="135" y="84" text-anchor="middle">Google's JPEG compressor, C++</text>
  <text class="t" x="40" y="112">Dockerfile + build.sh</text>
  <text class="t" x="40" y="134">ASAN / UBSAN instrumented</text>
  <text class="t" x="40" y="156">libFuzzer harnesses</text>
  <text class="t" x="40" y="178">unit tests (10)</text>
  <text class="s" x="40" y="198">no project-specific build logic written</text>

  <path class="ar" d="M250,125 L298,125" marker-end="url(#a2)" />

  <rect class="n" x="300" y="40" width="300" height="170" rx="10" />
  <text class="h" x="450" y="66" text-anchor="middle">5 unattended test runs</text>
  <text class="s" x="450" y="84" text-anchor="middle">on the unmodified code</text>
  <g fill="#00ab37">
    <circle cx="350" cy="130" r="18" /><circle cx="400" cy="130" r="18" /><circle cx="450" cy="130" r="18" /><circle cx="500" cy="130" r="18" /><circle cx="550" cy="130" r="18" />
  </g>
  <g fill="#fff" font-size="16" font-weight="700" text-anchor="middle">
    <text x="350" y="136">✓</text><text x="400" y="136">✓</text><text x="450" y="136">✓</text><text x="500" y="136">✓</text><text x="550" y="136">✓</text>
  </g>
  <text class="t" x="450" y="175" text-anchor="middle">100% pass, identical results, 5 / 5</text>
  <text class="s" x="450" y="196" text-anchor="middle">any flake → project rejected</text>

  <path class="ar" d="M600,125 L648,125" marker-end="url(#a2)" />

  <rect class="n" x="650" y="40" width="230" height="170" rx="10" stroke="#B509AC" stroke-width="2" />
  <text class="h" x="765" y="66" text-anchor="middle">qualified pool</text>
  <text class="t" x="765" y="104" text-anchor="middle">100 projects qualify</text>
  <text class="t" x="765" y="126" text-anchor="middle">80 end up contributing</text>
  <text class="t" x="765" y="148" text-anchor="middle">at least one instance</text>
  <text class="s" x="765" y="180" text-anchor="middle">73 C++ projects, 27 C projects</text>
  <text class="s" x="450" y="238" text-anchor="middle">Step 1 · admission of the project, before any edit is made</text>
</svg>
</div>

    <p>We start from C and C++ projects enrolled in OSS-Fuzz, because each already ships a Docker image, a <code class="language-plaintext highlighter-rouge">build.sh</code>, sanitizer configuration, and libFuzzer harnesses; we write no project-specific build logic. A project enters the pool only if its own tests build, run unattended, and pass at 100% with identical results across five runs on the unmodified code. Guetzli’s 10 unit tests do, so it joins the 100 qualified projects (80 of which eventually contribute at least one validated instance).</p>

  </div>
  <div class="wt-step" data-label="Site selection">
    <h4>2. The pipeline picks a site</h4>
    <div class="fig-svg">
<svg viewBox="0 0 900 300" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="The fuzzer-guided pipeline builds a map of functions reachable from a harness, scores each on a function score and a triggerability score, and selects ProcessAPP in jpeg_data_reader.cc, where a length guard sits directly before a copy.">
  <style>
    .n { fill: none; stroke: currentColor; stroke-width: 1.5; }
    .t { fill: currentColor; font-size: 14px; }
    .h { fill: currentColor; font-size: 15px; font-weight: 600; }
    .s { fill: currentColor; font-size: 12px; opacity: 0.75; }
    .m { fill: currentColor; font-size: 13px; font-family: Menlo, monospace; }
    .ar { fill: none; stroke: currentColor; stroke-width: 1.5; }
  </style>
  <defs>
    <marker id="a3" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto-start-reverse"><path d="M0,0 L10,5 L0,10 z" fill="currentColor" /></marker>
  </defs>
  <rect class="n" x="20" y="30" width="220" height="240" rx="10" stroke="#2698BA" stroke-width="2" />
  <text class="h" x="130" y="56" text-anchor="middle">reachable from a harness</text>
  <text class="s" x="130" y="74" text-anchor="middle">OSS-Fuzz coverage metadata</text>
  <rect x="40" y="90" width="180" height="26" rx="6" fill="currentColor" opacity="0.08" />
  <text class="m" x="50" y="108" opacity="0.6">other function</text>
  <rect x="40" y="122" width="180" height="26" rx="6" fill="currentColor" opacity="0.08" />
  <text class="m" x="50" y="140" opacity="0.6">other function</text>
  <rect x="40" y="154" width="180" height="26" rx="6" fill="#B509AC" opacity="0.18" />
  <text class="m" x="50" y="172" font-weight="700">ProcessAPP</text>
  <rect x="40" y="186" width="180" height="26" rx="6" fill="currentColor" opacity="0.08" />
  <text class="m" x="50" y="204" opacity="0.6">other function</text>
  <rect x="40" y="218" width="180" height="26" rx="6" fill="currentColor" opacity="0.08" />
  <text class="m" x="50" y="236" opacity="0.6">other function</text>
  <text class="s" x="130" y="262" text-anchor="middle">…</text>

  <path class="ar" d="M240,150 L288,150" marker-end="url(#a3)" />

  <rect class="n" x="290" y="30" width="300" height="115" rx="10" />
  <text class="h" x="440" y="54" text-anchor="middle">function score</text>
  <text class="t" x="440" y="78" text-anchor="middle">structural role (parser entry,</text>
  <text class="t" x="440" y="96" text-anchor="middle">buffer writer, decoder…),</text>
  <text class="t" x="440" y="114" text-anchor="middle">call depth, fanout, coverage</text>
  <text class="s" x="440" y="134" text-anchor="middle">runtime vs static reachability</text>

  <rect class="n" x="290" y="155" width="300" height="115" rx="10" />
  <text class="h" x="440" y="179" text-anchor="middle">triggerability score</text>
  <text class="t" x="440" y="203" text-anchor="middle">parser proximity, path signal,</text>
  <text class="t" x="440" y="221" text-anchor="middle">guard-to-sink distance,</text>
  <text class="t" x="440" y="239" text-anchor="middle">nearby blockers</text>
  <text class="s" x="440" y="259" text-anchor="middle">can a harness input reach it?</text>

  <path class="ar" d="M590,88 C620,88 620,150 638,150" marker-end="url(#a3)" />
  <path class="ar" d="M590,212 C620,212 620,150 638,150" marker-end="url(#a3)" />

  <rect class="n" x="640" y="70" width="240" height="160" rx="10" stroke="#B509AC" stroke-width="2" />
  <text class="h" x="760" y="96" text-anchor="middle">selected site</text>
  <text class="m" x="760" y="120" text-anchor="middle">jpeg_data_reader.cc</text>
  <text class="m" x="760" y="138" text-anchor="middle">ProcessAPP</text>
  <text class="t" x="760" y="166" text-anchor="middle">guard: VERIFY_LEN</text>
  <text class="t" x="760" y="184" text-anchor="middle">sink: std::string copy</text>
  <text class="s" x="760" y="210" text-anchor="middle">harness input format: JPEG</text>
  <text class="s" x="450" y="292" text-anchor="middle">ranked by a weighted combination, then diversified across harness, role and category buckets</text>
</svg>
</div>

    <p>The fuzzer-guided pipeline parses OSS-Fuzz metadata (Fuzz Introspector reports, harness definitions, coverage) into a map of functions reachable from at least one harness. Each is ranked by a weighted combination of two scores: a function score (structural role such as parser entry point or buffer writer, call depth, fanout, file coverage) and a triggerability score (parser proximity, path signal strength, guard-to-sink distance, nearby blockers). <code class="language-plaintext highlighter-rouge">ProcessAPP</code> in <code class="language-plaintext highlighter-rouge">jpeg_data_reader.cc</code> is a parser entry point where a guard sits directly before the copy it protects, and the harness feeds it JPEGs. Candidates are then diversified across harness, role, and category buckets so one file does not dominate.</p>

  </div>
  <div class="wt-step" data-label="One-line edit">
    <h4>3. The agent deletes the length check</h4>

    <p>The agent receives the function, the site, the inferred weakness type, the harness input format, and category-specific edit rules: no new branches, single-file edit, preserve the downstream operation. It makes one minimal change that weakens the existing check.</p>

    <div class="tok-row"><span class="tok">VERIFY_LEN(2)</span><span class="tok">marker_len = ReadUint16(data, pos)</span><span class="tok tok-c">VERIFY_INPUT(marker_len, 2, 65535)</span><span class="tok tok-d tok-x">VERIFY_LEN(marker_len - 2)</span><span class="tok tok-b">std::string app_str(…, marker_len + 1)</span></div>

    <div class="language-diff highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="p">@@ -398,7 +398,7 @@</span> bool ProcessAPP(const uint8_t* data, size_t* pos, ...)
   VERIFY_LEN(2);
   size_t marker_len = ReadUint16(data, pos);
   VERIFY_INPUT(marker_len, 2, 65535, MARKER_LEN);
<span class="gd">-  VERIFY_LEN(marker_len - 2);
</span><span class="gi">+
</span>   // Save the marker type together with the app data.
   std::string app_str(reinterpret_cast&lt;const char*&gt;(
       &amp;data[*pos - 3]), marker_len + 1);
</code></pre></div>    </div>

    <p>The range check (<code class="language-plaintext highlighter-rouge">VERIFY_INPUT</code>, 2 to 65,535) stays. The check that the buffer really contains <code class="language-plaintext highlighter-rouge">marker_len - 2</code> more bytes is gone. The copy into <code class="language-plaintext highlighter-rouge">app_str</code> now trusts a length the attacker controls.</p>

  </div>
  <div class="wt-step" data-label="Tests still pass">
    <h4>4. The unit tests still pass (Condition 1)</h4>
    <div class="fig-svg">
<svg viewBox="0 0 900 240" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="The injected build of guetzli compiles and passes all ten of its unit tests, satisfying condition one: the weakness is latent.">
  <style>
    .n { fill: none; stroke: currentColor; stroke-width: 1.5; }
    .t { fill: currentColor; font-size: 14px; }
    .h { fill: currentColor; font-size: 15px; font-weight: 600; }
    .s { fill: currentColor; font-size: 12px; opacity: 0.75; }
    .ar { fill: none; stroke: currentColor; stroke-width: 1.5; }
  </style>
  <defs>
    <marker id="a4" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto-start-reverse"><path d="M0,0 L10,5 L0,10 z" fill="currentColor" /></marker>
  </defs>
  <rect class="n" x="20" y="58" width="200" height="120" rx="10" stroke="#B509AC" stroke-width="2" />
  <text class="h" x="120" y="86" text-anchor="middle">injected build</text>
  <text class="t" x="120" y="110" text-anchor="middle">guetzli minus one line</text>
  <text class="s" x="120" y="132" text-anchor="middle">compiled with build.sh</text>
  <text class="s" x="120" y="150" text-anchor="middle">ASAN + UBSAN on</text>

  <path class="ar" d="M220,118 L268,118" marker-end="url(#a4)" />

  <rect class="n" x="270" y="30" width="400" height="176" rx="10" />
  <text class="h" x="470" y="56" text-anchor="middle">project's own unit tests</text>
  <g fill="#00ab37">
    <circle cx="310" cy="100" r="16" /><circle cx="350" cy="100" r="16" /><circle cx="390" cy="100" r="16" /><circle cx="430" cy="100" r="16" /><circle cx="470" cy="100" r="16" />
    <circle cx="510" cy="100" r="16" /><circle cx="550" cy="100" r="16" /><circle cx="590" cy="100" r="16" /><circle cx="630" cy="100" r="16" />
  </g>
  <circle cx="310" cy="140" r="16" fill="#00ab37" />
  <g fill="#fff" font-size="15" font-weight="700" text-anchor="middle">
    <text x="310" y="105">✓</text><text x="350" y="105">✓</text><text x="390" y="105">✓</text><text x="430" y="105">✓</text><text x="470" y="105">✓</text>
    <text x="510" y="105">✓</text><text x="550" y="105">✓</text><text x="590" y="105">✓</text><text x="630" y="105">✓</text><text x="310" y="145">✓</text>
  </g>
  <text class="t" x="480" y="145" text-anchor="middle">10 / 10 pass  (expected_passing_count: 10)</text>
  <text class="s" x="470" y="174" text-anchor="middle">conforming JPEGs declare lengths that match their data,</text>
  <text class="s" x="470" y="190" text-anchor="middle">so the missing check is never exercised</text>

  <path class="ar" d="M670,118 L718,118" marker-end="url(#a4)" />

  <rect class="n" x="720" y="58" width="160" height="120" rx="10" stroke="#00ab37" stroke-width="2" />
  <text class="h" x="800" y="88" text-anchor="middle">Condition 1</text>
  <text class="t" x="800" y="114" text-anchor="middle">weakness is</text>
  <text class="t" x="800" y="132" text-anchor="middle">latent</text>
  <text class="s" x="800" y="158" text-anchor="middle">a test failure → retry</text>
  <text class="s" x="450" y="230" text-anchor="middle">Step 4 · a failing test would admit this edit under a SWE-Smith oracle; here it sends the agent back to retry</text>
</svg>
</div>

    <p>The modified project is compiled and run against guetzli’s own tests: 10 of 10 pass (<code class="language-plaintext highlighter-rouge">"expected_passing_count": 10</code> in the instance metadata). JPEGs produced by conforming encoders carry segment lengths that match their data, so the deleted check never fires on them. Had a test failed, the agent would have been prompted to retry; a failing test means a functional bug, which is exactly what we do not want.</p>

  </div>
  <div class="wt-step" data-label="PoV and oracle">
    <h4>5. A crafted JPEG separates the two builds (Condition 2)</h4>

    <p>The agent then writes a deterministic PoV, guided by the harness input model and seed extensions. Here it is a 504-byte file that opens with:</p>

    <div class="tok-row"><span class="tok tok-b">FF D8</span><span class="tok-arrow">SOI</span><span class="tok tok-b">FF E0</span><span class="tok-arrow">APP0</span><span class="tok tok-a tok-hl">FF FF</span><span class="tok-arrow">length = 65,535</span><span class="tok tok-b">4A 46 49 46</span><span class="tok-arrow">"JFIF"</span><span class="tok">… 504 bytes total</span></div>

    <div class="fig-svg">
<svg viewBox="0 0 900 380" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="The 504-byte proof-of-vulnerability JPEG declares an APP0 segment length of 65535 bytes; the clean build rejects it through VERIFY_LEN with no crash, while the injected build reads far past the end of the input and AddressSanitizer reports a heap-buffer-overflow in ProcessAPP at jpeg_data_reader.cc line 403.">
  <style>
    .n { fill: none; stroke: currentColor; stroke-width: 1.5; }
    .t { fill: currentColor; font-size: 14px; }
    .h { fill: currentColor; font-size: 15px; font-weight: 600; }
    .s { fill: currentColor; font-size: 12px; opacity: 0.75; }
    .m { fill: currentColor; font-size: 13px; font-family: Menlo, monospace; }
    .ar { fill: none; stroke: currentColor; stroke-width: 1.5; }
    .ov { animation: pulse 2s ease-in-out infinite; }
    @keyframes pulse { 0%,100% { opacity: 0.35; } 50% { opacity: 0.8; } }
  </style>
  <defs>
    <marker id="a5" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto-start-reverse"><path d="M0,0 L10,5 L0,10 z" fill="currentColor" /></marker>
  </defs>
  <text class="h" x="20" y="28">PoV: a 504-byte JPEG with a lying length field</text>
  <rect x="20" y="42" width="240" height="34" rx="6" fill="#2698BA" opacity="0.35" />
  <text class="m" x="140" y="64" text-anchor="middle">504 bytes actually present</text>
  <rect class="ov" x="262" y="42" width="618" height="34" rx="6" fill="#F29105" />
  <text class="m" x="571" y="64" text-anchor="middle">…65,035 bytes past the end of the file</text>
  <text class="s" x="20" y="96">bytes 0-1 FF D8 = SOI  ·  bytes 2-3 FF E0 = APP0  ·  bytes 4-5 FF FF = declared length 65,535 (largest VERIFY_INPUT allows)</text>
  <path class="ar" d="M140,105 C140,135 250,120 250,150" marker-end="url(#a5)" />
  <path class="ar" d="M140,105 C140,135 650,120 650,150" marker-end="url(#a5)" />

  <rect class="n" x="60" y="152" width="380" height="150" rx="10" />
  <text class="h" x="250" y="178" text-anchor="middle">clean build</text>
  <text class="m" x="250" y="204" text-anchor="middle">VERIFY_LEN(marker_len - 2)</text>
  <text class="t" x="250" y="228" text-anchor="middle">buffer holds fewer than 65,533 bytes</text>
  <text class="t" x="250" y="248" text-anchor="middle">→ input rejected, parsing stops</text>
  <rect x="160" y="264" width="180" height="26" rx="8" fill="#00ab37" opacity="0.2" />
  <text class="t" x="250" y="282" text-anchor="middle" fill="#00ab37" font-weight="600">no crash</text>

  <rect class="n" x="460" y="152" width="380" height="150" rx="10" stroke="#B509AC" stroke-width="2" />
  <text class="h" x="650" y="178" text-anchor="middle">injected build</text>
  <text class="m" x="650" y="204" text-anchor="middle">std::string app_str(…, marker_len + 1)</text>
  <text class="t" x="650" y="228" text-anchor="middle">copies from the declared length</text>
  <text class="t" x="650" y="248" text-anchor="middle">→ reads beyond the input buffer</text>
  <rect x="490" y="264" width="320" height="26" rx="8" fill="#F29105" opacity="0.25" />
  <text class="m" x="650" y="282" text-anchor="middle" font-weight="700">ASAN: heap-buffer-overflow</text>

  <path class="ar" d="M250,302 C250,330 450,318 450,336" marker-end="url(#a5)" />
  <path class="ar" d="M650,302 C650,330 450,318 450,336" marker-end="url(#a5)" />
  <rect x="290" y="338" width="320" height="32" rx="10" fill="#B509AC" opacity="0.15" stroke="#B509AC" stroke-width="2" />
  <text class="t" x="450" y="359" text-anchor="middle" font-weight="600">Condition 2 · crash on injected only → accepted</text>
</svg>
</div>

    <p>65,535 is the largest value <code class="language-plaintext highlighter-rouge">VERIFY_INPUT</code> accepts, so the surviving check lets it through. On the clean build, <code class="language-plaintext highlighter-rouge">VERIFY_LEN</code> rejects the file and nothing crashes. On the injected build, the <code class="language-plaintext highlighter-rouge">std::string</code> constructor copies from the declared length and reads 65,035 bytes past the end of the input. The verifier also checks that the sanitizer reports the expected error type at the expected location:</p>

    <div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>==432==ERROR: AddressSanitizer: heap-buffer-overflow
READ of size 65531 at 0x6fc93620b1f8 thread T0
    #0 ProcessAPP jpeg_data_reader.cc:403:15
SUMMARY: AddressSanitizer: heap-buffer-overflow
    jpeg_data_reader.cc:403:15 in guetzli::ProcessAPP
</code></pre></div>    </div>

    <p>Heap-buffer-overflow read, in <code class="language-plaintext highlighter-rouge">ProcessAPP</code>, at the copy: this matches the CWE-125 target, so the instance is accepted and saved as the diff, the PoV, the metadata, and this report.</p>

  </div>
  <div class="wt-step" data-label="Task to student">
    <h4>6. From instance to training signal</h4>
    <div class="fig-svg">
<svg viewBox="0 0 900 270" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="The accepted guetzli instance becomes a SEC-bench style repair task; a teacher agent works on it inside the project container with no network and no reference patch; the differential oracle decides whether the trajectory succeeded; only successful trajectories are used to fine-tune a Gemma 4 student with LoRA.">
  <style>
    .n { fill: none; stroke: currentColor; stroke-width: 1.5; }
    .t { fill: currentColor; font-size: 14px; }
    .h { fill: currentColor; font-size: 15px; font-weight: 600; }
    .s { fill: currentColor; font-size: 12px; opacity: 0.75; }
    .flow { fill: none; stroke: #B509AC; stroke-width: 2; stroke-dasharray: 6 6; animation: dash 1.2s linear infinite; }
    @keyframes dash { to { stroke-dashoffset: -24; } }
  </style>
  <defs>
    <marker id="a6" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto-start-reverse"><path d="M0,0 L10,5 L0,10 z" fill="#B509AC" /></marker>
  </defs>
  <rect class="n" x="20" y="50" width="190" height="150" rx="10" stroke="#2698BA" stroke-width="2" />
  <text class="h" x="115" y="76" text-anchor="middle">repair task</text>
  <text class="s" x="115" y="94" text-anchor="middle">SEC-bench format</text>
  <text class="t" x="115" y="120" text-anchor="middle">injected guetzli repo</text>
  <text class="t" x="115" y="140" text-anchor="middle">PoV at /testcase</text>
  <text class="t" x="115" y="160" text-anchor="middle">sanitizer report</text>
  <text class="s" x="115" y="184" text-anchor="middle">reference patch withheld</text>

  <path class="flow" d="M210,125 L248,125" marker-end="url(#a6)" />

  <rect class="n" x="250" y="50" width="200" height="150" rx="10" />
  <text class="h" x="350" y="76" text-anchor="middle">teacher agent</text>
  <text class="s" x="350" y="94" text-anchor="middle">Mini-SWE-Agent scaffold</text>
  <text class="t" x="350" y="120" text-anchor="middle">GPT-5.4-mini or</text>
  <text class="t" x="350" y="140" text-anchor="middle">Gemma 4 31B</text>
  <text class="s" x="350" y="166" text-anchor="middle">runs in the OSS-Fuzz container,</text>
  <text class="s" x="350" y="182" text-anchor="middle">no network access</text>

  <path class="flow" d="M450,125 L488,125" marker-end="url(#a6)" />

  <rect class="n" x="490" y="50" width="190" height="150" rx="10" stroke="#B509AC" stroke-width="2" />
  <text class="h" x="585" y="76" text-anchor="middle">same oracle</text>
  <text class="s" x="585" y="94" text-anchor="middle">decides success, not the agent</text>
  <text class="t" x="585" y="120" text-anchor="middle">patched build: tests</text>
  <text class="t" x="585" y="140" text-anchor="middle">pass, PoV no longer</text>
  <text class="t" x="585" y="160" text-anchor="middle">crashes</text>
  <text class="s" x="585" y="184" text-anchor="middle">failed trajectories discarded</text>

  <path class="flow" d="M680,125 L718,125" marker-end="url(#a6)" />

  <rect class="n" x="720" y="50" width="160" height="150" rx="10" stroke="#00ab37" stroke-width="2" />
  <text class="h" x="800" y="76" text-anchor="middle">student SFT</text>
  <text class="s" x="800" y="94" text-anchor="middle">LoRA r=32, α=64</text>
  <text class="t" x="800" y="120" text-anchor="middle">Gemma 4</text>
  <text class="t" x="800" y="140" text-anchor="middle">E4B / 12B / 31B</text>
  <text class="s" x="800" y="166" text-anchor="middle">1,194 GPT trajectories,</text>
  <text class="s" x="800" y="182" text-anchor="middle">880 Gemma trajectories</text>
  <text class="s" x="450" y="240" text-anchor="middle">Step 6 · the guetzli instance is now one of 1,034 tasks; the corpus of successful teacher runs is the training set</text>
</svg>
</div>

    <p>The accepted pair becomes a SEC-bench-style repair task: the injected repository, the PoV, and the sanitizer report, with the reference patch withheld. A teacher agent (GPT-5.4-mini or Gemma 4 31B on Mini-SWE-Agent) works inside the OSS-Fuzz container with no network access. Success is decided by the same differential oracle, not by the agent’s own claim, so a trajectory that announces a fix without passing validation is discarded. Only successful trajectories are used to fine-tune Gemma 4 students at E4B, 12B, and 31B.</p>

  </div>
</div>

<h2 id="under-the-hood">Under the hood</h2>

<table class="notation">
  <thead>
    <tr>
      <th>Symbol</th>
      <th>Meaning</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>\(P, T\)</td>
      <td>a qualified OSS-Fuzz project and its unit test suite</td>
    </tr>
    <tr>
      <td>\(B_0\)</td>
      <td>the clean build of \(P\)</td>
    </tr>
    <tr>
      <td>\(\delta\)</td>
      <td>an injected edit (the diff)</td>
    </tr>
    <tr>
      <td>\(B_\delta\)</td>
      <td>the build of \(P\) with \(\delta\) applied</td>
    </tr>
    <tr>
      <td>\(x\)</td>
      <td>a proof-of-vulnerability input</td>
    </tr>
    <tr>
      <td>\(\mathrm{pass}(B, t)\)</td>
      <td>build \(B\) passes unit test \(t\)</td>
    </tr>
    <tr>
      <td>\(\mathrm{crash}(B, x)\)</td>
      <td>running \(B\) on \(x\) yields a sanitizer report of the expected type at the expected location</td>
    </tr>
    <tr>
      <td>\(F_n, G_m\)</td>
      <td>empirical distributions of an edit statistic over injected and real patches</td>
    </tr>
    <tr>
      <td>\(D_{n,m}\)</td>
      <td>two-sample Kolmogorov-Smirnov distance</td>
    </tr>
  </tbody>
</table>

<p><strong>The admission predicate.</strong> Both pipelines end at the same criterion. Written out, an instance \((\delta, x)\) is admitted when</p>

\[\mathrm{accept}(\delta, x) \iff \underbrace{\forall t \in T:\ \mathrm{pass}(B_\delta, t)}_{\text{Condition 1: latent}} \;\wedge\; \underbrace{\mathrm{crash}(B_\delta, x) \wedge \neg\,\mathrm{crash}(B_0, x)}_{\text{Condition 2: differential PoV}}\]

<p>Condition 1 says the weakness survives normal execution, which is what real weaknesses that pass code review and production testing do. Condition 2 is a differential test under identical input, and it is what makes the pair meaningful: a crash on both builds is a pre-existing bug, a crash on neither is an injection nobody can reach. The verifier’s check on sanitizer type and location, folded into \(\mathrm{crash}\) above, rules out spurious failures.</p>

<div class="fig-svg">
<svg viewBox="0 0 900 300" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="CyberForge architecture: 100 qualified OSS-Fuzz projects feed a fuzzer-guided pipeline and an agentic in-context pipeline; 16,172 injection attempts pass through the differential oracle; 1,034 validated instances (643 and 391) become tasks for teacher trajectory collection and student fine-tuning.">
  <style>
    .n { fill: none; stroke: currentColor; stroke-width: 1.5; }
    .t { fill: currentColor; font-size: 14px; }
    .h { fill: currentColor; font-size: 15px; font-weight: 600; }
    .s { fill: currentColor; font-size: 12px; opacity: 0.75; }
    .flow { fill: none; stroke: #B509AC; stroke-width: 2; stroke-dasharray: 6 6; animation: dash 1.2s linear infinite; }
    @keyframes dash { to { stroke-dashoffset: -24; } }
  </style>
  <defs>
    <marker id="a7" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto-start-reverse"><path d="M0,0 L10,5 L0,10 z" fill="#B509AC" /></marker>
  </defs>
  <rect class="n" x="20" y="100" width="140" height="100" rx="10" stroke="#2698BA" stroke-width="2" />
  <text class="h" x="90" y="128" text-anchor="middle">OSS-Fuzz</text>
  <text class="t" x="90" y="148" text-anchor="middle">100 projects</text>
  <text class="s" x="90" y="170" text-anchor="middle">containers, tests,</text>
  <text class="s" x="90" y="186" text-anchor="middle">harnesses, sanitizers</text>

  <rect class="n" x="210" y="30" width="200" height="100" rx="10" />
  <text class="h" x="310" y="56" text-anchor="middle">P1 fuzzer-guided</text>
  <text class="s" x="310" y="76" text-anchor="middle">harness-reachable site,</text>
  <text class="s" x="310" y="92" text-anchor="middle">one minimal edit, PoV,</text>
  <text class="s" x="310" y="108" text-anchor="middle">90 s libFuzzer replay</text>

  <rect class="n" x="210" y="170" width="200" height="100" rx="10" />
  <text class="h" x="310" y="196" text-anchor="middle">P2 agentic in-context</text>
  <text class="s" x="310" y="216" text-anchor="middle">PrimeVul CVE retrieval or</text>
  <text class="s" x="310" y="232" text-anchor="middle">specialist exploration, CodeQL,</text>
  <text class="s" x="310" y="248" text-anchor="middle">taint analysis, retry loops</text>

  <rect class="n" x="460" y="80" width="180" height="140" rx="10" stroke="#B509AC" stroke-width="2" />
  <text class="h" x="550" y="108" text-anchor="middle">differential oracle</text>
  <text class="t" x="550" y="134" text-anchor="middle">all unit tests pass</text>
  <text class="t" x="550" y="154" text-anchor="middle">PoV: injected crashes,</text>
  <text class="t" x="550" y="172" text-anchor="middle">clean does not</text>
  <text class="s" x="550" y="196" text-anchor="middle">sanitizer type + location</text>
  <text class="s" x="550" y="210" text-anchor="middle">must match the target</text>

  <rect class="n" x="690" y="30" width="190" height="100" rx="10" stroke="#00ab37" stroke-width="2" />
  <text class="h" x="785" y="56" text-anchor="middle">1,034 instances</text>
  <text class="t" x="785" y="80" text-anchor="middle">643 from P1, 391 from P2</text>
  <text class="s" x="785" y="100" text-anchor="middle">80 projects, 63 CWEs</text>
  <text class="s" x="785" y="116" text-anchor="middle">median edit: 2 lines</text>

  <rect class="n" x="690" y="170" width="190" height="100" rx="10" />
  <text class="h" x="785" y="196" text-anchor="middle">tasks → SFT</text>
  <text class="t" x="785" y="220" text-anchor="middle">teacher trajectories,</text>
  <text class="t" x="785" y="238" text-anchor="middle">oracle-verified only</text>
  <text class="s" x="785" y="258" text-anchor="middle">Gemma 4 E4B / 12B / 31B</text>

  <path class="flow" d="M160,135 C185,135 185,80 208,80" marker-end="url(#a7)" />
  <path class="flow" d="M160,165 C185,165 185,220 208,220" marker-end="url(#a7)" />
  <path class="flow" d="M410,80 C435,80 435,130 458,130" marker-end="url(#a7)" />
  <path class="flow" d="M410,220 C435,220 435,170 458,170" marker-end="url(#a7)" />
  <path class="flow" d="M640,120 C665,120 665,80 688,80" marker-end="url(#a7)" />
  <path class="flow" d="M785,130 L785,168" marker-end="url(#a7)" />
  <text class="s" x="435" y="60" text-anchor="middle">16,172</text>
  <text class="s" x="435" y="74" text-anchor="middle">attempts</text>
  <text class="s" x="550" y="248" text-anchor="middle">largest failure: PoV never</text>
  <text class="s" x="550" y="264" text-anchor="middle">triggers (44.6% P1, 33.1% P2)</text>
</svg>
<div class="fig-caption">The two pipelines differ in how they find sites and build PoVs; they share the oracle, the task format, and the training recipe. Both run Gemma 4 31B through Mini-SWE-Agent, capped at 200 iterations per invocation.</div>
</div>

<p><strong>Yield.</strong> CyberForge made 16,172 injection attempts and 1,034 passed validation: 643 from the fuzzer-guided pipeline and 391 from the agentic one. Writing a plausible injection is easy; producing one the oracle accepts is the hard part. In the Pipeline 2 ablation, a naive single-pass agent compiles and passes unit tests on 68.2% of attempts and passes validation on 0%; taint analysis alone reaches 2.8% validated, retry loops alone 3.5%, and the full workflow lifts plausible injections to 77.6% and validated yield to 7.5%. Pipeline 1 shows the same from the other side: a post-hoc 90-second libFuzzer replay over a seeded corpus raises yield with the injection stage untouched. The pipelines fail at different stages, Pipeline 1 losing 64.2% of candidates at validation and Pipeline 2 58.8% at injection, but a PoV that never triggers is the largest single cause in both (44.6% and 33.1%).</p>

<p><strong>Realism.</strong> For an edit statistic (functions modified, files touched, hunks, lines changed), let \(F_n\) and \(G_m\) be the empirical distributions over the injected corpus and over the 300 real SEC-bench instances. The two-sample Kolmogorov-Smirnov statistic is</p>

\[D_{n,m} = \sup_x \,\bigl| F_n(x) - G_m(x) \bigr|\]

<p>A KS distance has no universal threshold, so the floor is measured between two real corpora, the SEC-bench <code class="language-plaintext highlighter-rouge">cve</code> and <code class="language-plaintext highlighter-rouge">oss</code> splits. For functions modified, injected-to-real is 0.165 against a real-to-real floor of 0.190; files touched and hunk count give 0.123 and 0.243 against 0.065 and 0.205. The edits are small and local in the way CVE patches are: 1,025 of 1,034 instances change one file, 944 confine the change to one hunk, and the median edit is 2 lines. The guetzli instance, one deleted line, is typical.</p>

<p><strong>Training.</strong> Over the 1,034 instances the teachers yielded 1,194 accepted trajectories from GPT-5.4-mini and 880 from Gemma 4 31B. Raw trajectories are recorded in the teacher’s environment, so before fine-tuning they are aligned to the SEC-bench harness (<code class="language-plaintext highlighter-rouge">secb repro</code> / <code class="language-plaintext highlighter-rouge">secb build</code>, PoV paths remapped to <code class="language-plaintext highlighter-rouge">/testcase</code>, <code class="language-plaintext highlighter-rouge">rg</code> rewritten to <code class="language-plaintext highlighter-rouge">grep</code>), linearized to one command per assistant turn, and compressed with a sliding window over old observations so the turn that writes the patch is never truncated. Every student uses the same recipe: LoRA adapters (\(r = 32\), \(\alpha = 64\), dropout 0.05) on the attention and MLP projections with the base frozen, learning rate \(1 \times 10^{-4}\) with a cosine schedule, batch size 1 with gradient accumulation 2, three epochs, BFloat16, one H200. Holding the 12B student and Gemma teacher fixed, SEC-bench rises from 3.6% to 12.1% to 16.0% as the trajectory corpus doubles twice; at 220 trajectories the student scores below its own base, so a corpus too small to teach the workflow is worse than none.</p>

<h2 id="what-the-numbers-say">What the numbers say</h2>

<table>
  <thead>
    <tr>
      <th>Student</th>
      <th>Teacher</th>
      <th>SEC-bench (%)</th>
      <th>PatchEval strict (%)</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>Gemma 4 E4B</td>
      <td>base</td>
      <td>6.0</td>
      <td>2.6</td>
    </tr>
    <tr>
      <td>CyberForge-E4B</td>
      <td>GPT-5.4-mini</td>
      <td>9.3 (+3.3)</td>
      <td>9.1 (+6.5)</td>
    </tr>
    <tr>
      <td>Gemma 4 12B</td>
      <td>base</td>
      <td>8.7</td>
      <td>3.9</td>
    </tr>
    <tr>
      <td>CyberForge-12B</td>
      <td>GPT-5.4-mini</td>
      <td>16.7 (+8.0)</td>
      <td>12.8 (+8.9)</td>
    </tr>
    <tr>
      <td>Gemma 4 31B</td>
      <td>base</td>
      <td>58.0</td>
      <td>12.2</td>
    </tr>
    <tr>
      <td>CyberForge-31B</td>
      <td>GPT-5.4-mini</td>
      <td>72.7 (+14.7)</td>
      <td>14.8 (+2.6)</td>
    </tr>
    <tr>
      <td>GPT-5.4-mini</td>
      <td>(teacher)</td>
      <td>74.0</td>
      <td>13.0</td>
    </tr>
  </tbody>
</table>

<p>All six student-teacher configurations improve on SEC-bench, by 3.3 to 14.7 points; the self-distilled students taught by Gemma 4 31B gain too (31B: 64.7%). The corpus is entirely C/C++, yet every configuration also improves on PatchEval’s Go, JavaScript, and Python CVEs, and the 31B student passes its teacher there. What changed inside the agent explains the scores: the 12B base completes an edit-then-verify cycle on 20.7% of instances, and after fine-tuning on 82.7%.</p>

<h2 id="try-it">Try it</h2>

<ul>
  <li>Paper page on this site: <a href="/papers/cyberforge/">/papers/cyberforge/</a></li>
  <li>arXiv: <a href="https://arxiv.org/abs/2608.06471">2608.06471</a></li>
  <li>Code: <a href="https://github.com/Cyb3rForge/CyberForge">github.com/Cyb3rForge/CyberForge</a></li>
  <li>Data: <a href="https://huggingface.co/datasets/AmL-hug/cyberforge-projects">cyberforge-projects on Hugging Face</a>, one archive per project with the diff, metadata, sanitizer report, and PoV files for every instance, including <code class="language-plaintext highlighter-rouge">guetzli/vulnerability_FZ_24</code></li>
  <li>Project page: <a href="https://cyb3rforge.github.io/">cyb3rforge.github.io</a></li>
  <li>UMD CS wrote about the project: <a href="https://www.cs.umd.edu/article/2026/09/when-ai-goes-defense">When AI Goes on Defense</a></li>
</ul>]]></content><author><name></name></author><category term="research" /><category term="security" /><category term="agents" /><category term="datasets" /><category term="nist" /><summary type="html"><![CDATA[How CyberForge injects execution-verified vulnerabilities into real C/C++ repositories, and what training on them does to a security agent.]]></summary></entry><entry><title type="html">Ask fewer, better questions: SAGE-Agent and ClarifyBench explained</title><link href="https://manansuri.com/blog/2026/sage-clarification-explained/" rel="alternate" type="text/html" title="Ask fewer, better questions: SAGE-Agent and ClarifyBench explained" /><published>2026-09-07T12:00:00+00:00</published><updated>2026-09-07T12:00:00+00:00</updated><id>https://manansuri.com/blog/2026/sage-clarification-explained</id><content type="html" xml:base="https://manansuri.com/blog/2026/sage-clarification-explained/"><![CDATA[<p>You hand your phone to an assistant and say “Hey! can you call Alex from work for me?” Your contacts list has two people named Alex: Alex C., who has a work number, and Alex M., who has a home number. A careful human glances at the list and dials Alex C. A typical LLM agent does one of two things instead. It asks “Which Alex do you want to call?”, a question the contacts list already answers, or it dials whoever comes first.</p>

<p>Both failures have the same root. The agent reasons about ambiguity in the space of words: it is prompted to write a clarifying question as free text, so it has no explicit picture of which argument is missing or whether the tool’s own schema already pins it down. In our ACL 2026 Findings paper, <a href="/papers/sage-clarification/">Structured Uncertainty Guided Clarification for LLM Agents</a>, we with collaborators at Adobe Research and UMD move the uncertainty into the space of tool parameters and their domains. Once it lives there, “should I ask?” and “what should I ask?” become quantities the agent can compute.</p>

<h2 id="the-idea-in-one-picture">The idea in one picture</h2>

<div class="fig-svg">
<svg viewBox="0 0 900 350" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="Two requests, call Alex from work and text Maya, handled in token space by a baseline that asks a redundant question and silently picks a default, versus in parameter-domain space by SAGE-Agent, which executes directly for Alex and asks one question for Maya.">
  <style>
    .n { fill: none; stroke: currentColor; stroke-width: 1.5; }
    .t { fill: currentColor; font-size: 14px; }
    .s { fill: currentColor; font-size: 13px; opacity: 0.8; }
    .h { fill: currentColor; font-size: 15px; font-weight: 600; }
    .in { stroke: #2698BA; }
    .bad { stroke: #F29105; }
    .acc { stroke: #B509AC; }
    .ok { fill: #00ab37; }
    .no { fill: #F29105; }
    .ar { fill: none; stroke: currentColor; stroke-width: 1.5; opacity: 0.5; }
  </style>
  <text class="h" x="130" y="30" text-anchor="middle" fill="#2698BA">Request</text>
  <text class="h" x="435" y="30" text-anchor="middle" fill="#F29105">Token space (prompted baseline)</text>
  <text class="h" x="750" y="30" text-anchor="middle" fill="#B509AC">Parameter domains (SAGE-Agent)</text>
  <!-- row 1 -->
  <rect class="n in" x="20" y="55" width="220" height="100" rx="10" />
  <text class="t" x="130" y="90" text-anchor="middle">"Hey! can you call</text>
  <text class="t" x="130" y="110" text-anchor="middle">Alex from work</text>
  <text class="t" x="130" y="130" text-anchor="middle">for me?"</text>
  <path class="ar" d="M240 105 H280" />
  <rect class="n bad" x="285" y="55" width="300" height="100" rx="10" />
  <text class="t" x="420" y="88" text-anchor="middle">"Which Alex do you</text>
  <text class="t" x="420" y="108" text-anchor="middle">want to call?"</text>
  <text class="s" x="420" y="138" text-anchor="middle">redundant: only one Alex has a work number</text>
  <circle class="no" cx="560" cy="80" r="11" /><text x="560" y="85" text-anchor="middle" font-size="14" fill="#fff" font-weight="700">x</text>
  <path class="ar" d="M585 105 H620" />
  <rect class="n acc" x="625" y="55" width="255" height="100" rx="10" />
  <text class="t" x="740" y="90" text-anchor="middle" font-weight="600">Call([Alex C. (Work)])</text>
  <text class="s" x="740" y="118" text-anchor="middle">domain has one entry,</text>
  <text class="s" x="740" y="136" text-anchor="middle">so it executes without asking</text>
  <circle class="ok" cx="856" cy="80" r="11" /><path d="M850 80 l4 4 l8 -8" fill="none" stroke="#fff" stroke-width="2.5" />
  <!-- row 2 -->
  <rect class="n in" x="20" y="195" width="220" height="120" rx="10" />
  <text class="t" x="130" y="228" text-anchor="middle">"Please ask Maya on</text>
  <text class="t" x="130" y="248" text-anchor="middle">text if she can pick</text>
  <text class="t" x="130" y="268" text-anchor="middle">the party supplies</text>
  <text class="t" x="130" y="288" text-anchor="middle">on Saturday?"</text>
  <path class="ar" d="M240 255 H280" />
  <rect class="n bad" x="285" y="195" width="300" height="120" rx="10" />
  <text class="t" x="420" y="228" text-anchor="middle">SMS([Maya S.], "Can you</text>
  <text class="t" x="420" y="248" text-anchor="middle">pick the party supplies</text>
  <text class="t" x="420" y="268" text-anchor="middle">on Saturday?")</text>
  <text class="s" x="420" y="298" text-anchor="middle">assumes the default number: her work phone</text>
  <circle class="no" cx="560" cy="220" r="11" /><text x="560" y="225" text-anchor="middle" font-size="14" fill="#fff" font-weight="700">x</text>
  <path class="ar" d="M585 255 H620" />
  <rect class="n acc" x="625" y="195" width="255" height="120" rx="10" />
  <text class="t" x="740" y="238" text-anchor="middle" font-weight="600">"Should I text her on</text>
  <text class="t" x="740" y="258" text-anchor="middle" font-weight="600">her home phone?"</text>
  <text class="s" x="740" y="288" text-anchor="middle">two valid numbers, one question</text>
  <circle class="ok" cx="856" cy="220" r="11" /><path d="M850 220 l4 4 l8 -8" fill="none" stroke="#fff" stroke-width="2.5" />
  <text class="s" x="450" y="340" text-anchor="middle">Same agent scaffold, same tools. The only change is where the uncertainty is represented.</text>
</svg>
<div class="fig-caption">The two examples from Figure 1 of the paper. Reasoning in token space asks when it should act and acts when it should ask. Reasoning over the tool's parameter domains gets both right.</div>
</div>

<div class="callout"><span class="callout-label">Key idea</span>Keep a probability distribution over candidate tool calls, factored over the tool and each argument's remaining domain. A clarifying question is worth asking only if a perfect answer would raise the probability of the best call (its Expected Value of Perfect Information, EVPI) by more than the cost of asking it.</div>

<h2 id="walkthrough-calling-alex-then-texting-maya">Walkthrough: calling Alex, then texting Maya</h2>

<p>The walkthrough traces both requests through SAGE-Agent (Structured Argument Uncertainty guided Elicitation Agent). The belief values are worked by hand from the paper’s Equation 2 with the settings we used in the experiments, a redundancy penalty of \(\lambda = 0.5\) and a stopping coefficient of \(\alpha = 0.1\).</p>

<div class="walkthrough">
  <div class="wt-title">Walkthrough: "call Alex from work", then "text Maya"</div>
  <div class="wt-step" data-label="Inputs">
    <h4>1. The request, the contacts, and the tool schema</h4>

    <div class="fig-svg">
<svg viewBox="0 0 900 260" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="Three input boxes: the user request, the contacts list with Alex C. work, Alex M. home, and Maya S. work default and home, and the tool schema with Call(contact) and SMS(contact, message).">
  <style>
    .n { fill: none; stroke: currentColor; stroke-width: 1.5; }
    .t { fill: currentColor; font-size: 14px; }
    .s { fill: currentColor; font-size: 13px; opacity: 0.8; }
    .h { fill: currentColor; font-size: 15px; font-weight: 600; }
    .in { stroke: #2698BA; }
    .gr { stroke: currentColor; opacity: 0.55; }
    .mono { font-family: "Roboto Mono", Menlo, monospace; font-size: 13px; }
  </style>
  <rect class="n in" x="20" y="30" width="250" height="200" rx="10" />
  <text class="h" x="145" y="60" text-anchor="middle" fill="#2698BA">User request</text>
  <text class="t" x="145" y="110" text-anchor="middle">"Hey! can you call</text>
  <text class="t" x="145" y="132" text-anchor="middle">Alex from work</text>
  <text class="t" x="145" y="154" text-anchor="middle">for me?"</text>
  <rect class="n in" x="310" y="30" width="270" height="200" rx="10" />
  <text class="h" x="445" y="60" text-anchor="middle" fill="#2698BA">Contacts (the domain)</text>
  <text class="t" x="335" y="98">Alex C.</text><text class="s" x="440" y="98">Work  +1 202 XXXX</text>
  <text class="t" x="335" y="128">Alex M.</text><text class="s" x="440" y="128">Home  +1 231 XXXX</text>
  <text class="t" x="335" y="158">Maya S.</text><text class="s" x="440" y="158">Work  +1 XXX 2002</text>
  <text class="s" x="440" y="176" font-size="11">[default]</text>
  <text class="s" x="440" y="200">Home  +1 XXX 2703</text>
  <rect class="n gr" x="620" y="30" width="260" height="200" rx="10" />
  <text class="h" x="750" y="60" text-anchor="middle">Tool schema</text>
  <text class="mono" x="640" y="98" fill="currentColor">Call(contact)</text>
  <text class="mono" x="640" y="128" fill="currentColor">SMS(contact, message)</text>
  <text class="s" x="640" y="162">contact: one entry from</text>
  <text class="s" x="640" y="180">the contacts list (finite)</text>
  <text class="s" x="640" y="204">message: free text</text>
</svg>
</div>

    <p>Everything the agent needs is already on the table. The request (blue) names a person and a label. The contacts list is the domain of the <code class="language-plaintext highlighter-rouge">contact</code> argument: a finite set of four phone entries. The tool schema (grey) says which arguments each tool takes and which are required. SAGE-Agent never reasons about ambiguity without this schema in view.</p>

  </div>
  <div class="wt-step" data-label="Candidates">
    <h4>2. Propose candidate tool calls with unknowns</h4>

    <div class="fig-svg">
<svg viewBox="0 0 900 250" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="The LLM reads the request and emits a candidate call with an unknown contact argument; the words Alex and work act as constraints that shrink the contact domain from four entries to one.">
  <style>
    .n { fill: none; stroke: currentColor; stroke-width: 1.5; }
    .t { fill: currentColor; font-size: 14px; }
    .s { fill: currentColor; font-size: 13px; opacity: 0.8; }
    .h { fill: currentColor; font-size: 15px; font-weight: 600; }
    .in { stroke: #2698BA; }
    .acc { stroke: #B509AC; }
    .ok { stroke: #00ab37; }
    .flow { fill: none; stroke: #B509AC; stroke-width: 2; stroke-dasharray: 6 6; animation: dash 1.2s linear infinite; }
    @keyframes dash { to { stroke-dashoffset: -24; } }
    .mono { font-family: "Roboto Mono", Menlo, monospace; font-size: 13px; fill: currentColor; }
  </style>
  <rect class="n in" x="20" y="70" width="170" height="90" rx="10" />
  <text class="t" x="105" y="105" text-anchor="middle">"call Alex from</text>
  <text class="t" x="105" y="125" text-anchor="middle">work for me?"</text>
  <path class="flow" d="M190 115 H235" />
  <rect class="n" x="240" y="70" width="120" height="90" rx="10" />
  <text class="h" x="300" y="110" text-anchor="middle">LLM</text>
  <text class="s" x="300" y="132" text-anchor="middle">reason</text>
  <path class="flow" d="M360 115 H405" />
  <rect class="n acc" x="410" y="40" width="230" height="150" rx="10" />
  <text class="h" x="525" y="68" text-anchor="middle" fill="#B509AC">Candidate call</text>
  <text class="mono" x="525" y="98" text-anchor="middle">Call(contact=&lt;UNK&gt;)</text>
  <text class="s" x="525" y="128" text-anchor="middle">constraints read from the query:</text>
  <text class="s" x="525" y="148" text-anchor="middle">name = Alex, label = Work</text>
  <text class="s" x="525" y="172" text-anchor="middle">tool prior: uniform over Call, SMS</text>
  <path class="flow" d="M640 115 H685" />
  <rect class="n ok" x="690" y="40" width="190" height="150" rx="10" />
  <text class="h" x="785" y="68" text-anchor="middle" fill="#00ab37">Remaining domain</text>
  <text class="s" x="785" y="98" text-anchor="middle">4 entries</text>
  <text class="s" x="785" y="118" text-anchor="middle">→ "Alex": 2 entries</text>
  <text class="s" x="785" y="138" text-anchor="middle">→ "work": 1 entry</text>
  <text class="t" x="785" y="170" text-anchor="middle" font-weight="600">{ Alex C. (Work) }</text>
  <text class="s" x="450" y="225" text-anchor="middle">an aspect = (tool, parameter). This candidate has one aspect: (Call, contact)</text>
</svg>
</div>

    <p>An LLM reads the request and the schema and proposes candidate tool calls, filling each argument with a value or <code class="language-plaintext highlighter-rouge">&lt;UNK&gt;</code>. What it says in the query becomes constraints on the argument’s domain (the paper’s <code class="language-plaintext highlighter-rouge">Update</code> operation), and the contact domain shrinks:</p>

    <div class="tok-row"><span class="tok tok-b">Alex C. Work</span><span class="tok tok-b">Alex M. Home</span><span class="tok tok-b">Maya S. Work</span><span class="tok tok-b">Maya S. Home</span><span class="tok-arrow">→ "Alex" →</span><span class="tok tok-b">Alex C. Work</span><span class="tok tok-b">Alex M. Home</span><span class="tok-arrow">→ "work" →</span><span class="tok tok-c tok-hl">Alex C. Work</span></div>

    <p>The word “call” also settles the tool. The paper uses a uniform prior over tools, so nothing forces <code class="language-plaintext highlighter-rouge">Call</code> over <code class="language-plaintext highlighter-rouge">SMS</code> a priori; it is the candidate generator that proposes only <code class="language-plaintext highlighter-rouge">Call</code> here.</p>

  </div>
  <div class="wt-step" data-label="Belief">
    <h4>3. Turn the domains into a belief over calls</h4>

    <div class="fig-svg">
<svg viewBox="0 0 900 250" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="Belief table for the Alex request: the candidate Call with contact Alex C. Work has one aspect, a domain of size one, and belief 1.0, shown as a full green bar. A note contrasts specification uncertainty with model uncertainty.">
  <style>
    .n { fill: none; stroke: currentColor; stroke-width: 1.5; }
    .t { fill: currentColor; font-size: 14px; }
    .s { fill: currentColor; font-size: 13px; opacity: 0.8; }
    .h { fill: currentColor; font-size: 15px; font-weight: 600; }
    .acc { stroke: #B509AC; }
    .bar { fill: #00ab37; opacity: 0.85; }
    .grid { stroke: currentColor; opacity: 0.25; }
    .mono { font-family: "Roboto Mono", Menlo, monospace; font-size: 13px; fill: currentColor; }
  </style>
  <rect class="n acc" x="20" y="25" width="860" height="140" rx="10" />
  <text class="h" x="40" y="52" fill="#B509AC">Belief over candidate calls, π(c) ∝ p(tool) × Π p(argument)</text>
  <line class="grid" x1="40" y1="66" x2="860" y2="66" />
  <text class="s" x="40" y="88">candidate</text>
  <text class="s" x="330" y="88">unresolved aspects</text>
  <text class="s" x="520" y="88">p(contact)</text>
  <text class="s" x="640" y="88">π(c)</text>
  <text class="mono" x="40" y="120">Call(contact = Alex C. (Work))</text>
  <text class="t" x="330" y="120">none (domain size 1)</text>
  <text class="t" x="520" y="120">1 / 1 = 1.0</text>
  <rect class="bar" x="640" y="106" width="200" height="18" rx="4" />
  <text class="t" x="850" y="120" text-anchor="end" fill="#fff" font-weight="600">1.00</text>
  <text class="s" x="40" y="150">max π(c) = 1.0, at the ceiling: this already clears the execution threshold τ_exec</text>
  <text class="t" x="40" y="200" font-weight="600">Two kinds of uncertainty, kept apart</text>
  <text class="s" x="40" y="222">specification: the user has not said which entry (domain size &gt; 1)</text>
  <text class="s" x="480" y="222">model: the LLM is unsure how to read the request (lives in the LLM, not here)</text>
</svg>
</div>

    <p>Here is the move that makes the rest computable. A candidate with an unspecified argument is spread evenly over that argument’s remaining domain, so its per-argument certainty is one over the domain size. For Alex, the domain has one entry, so <code class="language-plaintext highlighter-rouge">p(contact) = 1</code> and the belief in <code class="language-plaintext highlighter-rouge">Call([Alex C. (Work)])</code> is 1.0. Because the belief is defined over the schema and not over generated text, it measures what the user has left unsaid (specification uncertainty) separately from what the LLM might misread (model uncertainty).</p>

  </div>
  <div class="wt-step" data-label="Score &amp; decide">
    <h4>4. Score the candidate question by EVPI minus cost, then decide not to ask</h4>

    <div class="fig-svg">
<svg viewBox="0 0 900 300" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="Scoring the question Which Alex do you want to call: EVPI is one minus one equals zero, cost is zero, score zero is below the stopping bar, so SAGE-Agent executes Call Alex C. Work, while the token-space baseline asks the redundant question.">
  <style>
    .n { fill: none; stroke: currentColor; stroke-width: 1.5; }
    .t { fill: currentColor; font-size: 14px; }
    .s { fill: currentColor; font-size: 13px; opacity: 0.8; }
    .h { fill: currentColor; font-size: 15px; font-weight: 600; }
    .acc { stroke: #B509AC; }
    .ok { stroke: #00ab37; }
    .bad { stroke: #F29105; }
    .flow { fill: none; stroke: #B509AC; stroke-width: 2; stroke-dasharray: 6 6; animation: dash 1.2s linear infinite; }
    .flowb { fill: none; stroke: #F29105; stroke-width: 2; stroke-dasharray: 6 6; animation: dash 1.2s linear infinite; }
    @keyframes dash { to { stroke-dashoffset: -24; } }
  </style>
  <rect class="n acc" x="20" y="30" width="380" height="180" rx="10" />
  <text class="h" x="210" y="58" text-anchor="middle" fill="#B509AC">Candidate question q</text>
  <text class="t" x="210" y="84" text-anchor="middle">"Which Alex do you want to call?"</text>
  <text class="s" x="210" y="104" text-anchor="middle">targets aspect (Call, contact), asked 0 times so far</text>
  <text class="t" x="40" y="138">EVPI(q) = E[max π after answer] − max π now = 1.0 − 1.0 = 0</text>
  <text class="t" x="40" y="162">Cost(q) = λ × n = 0.5 × 0 = 0</text>
  <text class="t" x="40" y="190" font-weight="600">Score(q) = 0 &lt; α × max π = 0.1 × 1.0 = 0.1 → stop asking</text>
  <path class="flow" d="M400 120 H455" />
  <rect class="n ok" x="460" y="60" width="200" height="120" rx="10" />
  <text class="h" x="560" y="92" text-anchor="middle" fill="#00ab37">SAGE-Agent executes</text>
  <text class="t" x="560" y="125" text-anchor="middle" font-weight="600">Call([Alex C. (Work)])</text>
  <text class="s" x="560" y="155" text-anchor="middle">0 questions asked</text>
  <path class="flowb" d="M660 120 H715" />
  <rect class="n bad" x="720" y="60" width="160" height="120" rx="10" />
  <text class="h" x="800" y="92" text-anchor="middle" fill="#F29105">Token-space baseline</text>
  <text class="t" x="800" y="118" text-anchor="middle">"Which Alex do</text>
  <text class="t" x="800" y="136" text-anchor="middle">you want to call?"</text>
  <text class="s" x="800" y="162" text-anchor="middle">redundant question</text>
  <text class="s" x="450" y="250" text-anchor="middle">EVPI simulates a perfect answer: how much would the best candidate's probability rise?</text>
  <text class="s" x="450" y="272" text-anchor="middle">A question whose answer cannot change the best call has EVPI = 0, whatever its wording.</text>
</svg>
</div>

    <p>The LLM also drafts candidate questions, each tagged with the candidate it targets and the aspects (tool, parameter pairs) it would resolve. For each one we simulate a perfect answer and ask how much the probability of the best candidate would rise in expectation. That is the question’s EVPI. “Which Alex do you want to call?” targets <code class="language-plaintext highlighter-rouge">(Call, contact)</code>, whose domain is already a single entry, so the answer cannot move the belief and EVPI is exactly zero. The score falls below the stopping bar, so SAGE-Agent executes:</p>

    <div class="tok-row"><span class="tok tok-x tok-d">"Which Alex do you want to call?"</span><span class="tok-arrow">→</span><span class="tok tok-c">Call([Alex C. (Work)])</span></div>

  </div>
  <div class="wt-step" data-label="Second request">
    <h4>5. "Text Maya": a domain of size two, so the agent asks</h4>

    <div class="fig-svg">
<svg viewBox="0 0 900 340" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="For the Maya request the candidate SMS call has a contact domain of two entries, work and home, each with belief 0.5. The question Should I text her on her home phone has EVPI 0.5 and cost 0, which is above the stopping bar of 0.05, so SAGE-Agent asks. The baseline sends to the default work number.">
  <style>
    .n { fill: none; stroke: currentColor; stroke-width: 1.5; }
    .t { fill: currentColor; font-size: 14px; }
    .s { fill: currentColor; font-size: 13px; opacity: 0.8; }
    .h { fill: currentColor; font-size: 15px; font-weight: 600; }
    .in { stroke: #2698BA; }
    .acc { stroke: #B509AC; }
    .bad { stroke: #F29105; }
    .bar { fill: #2698BA; opacity: 0.8; }
    .flow { fill: none; stroke: #B509AC; stroke-width: 2; stroke-dasharray: 6 6; animation: dash 1.2s linear infinite; }
    @keyframes dash { to { stroke-dashoffset: -24; } }
    .mono { font-family: "Roboto Mono", Menlo, monospace; font-size: 13px; fill: currentColor; }
  </style>
  <rect class="n in" x="20" y="30" width="200" height="120" rx="10" />
  <text class="t" x="120" y="62" text-anchor="middle">"Please ask Maya on</text>
  <text class="t" x="120" y="82" text-anchor="middle">text if she can pick</text>
  <text class="t" x="120" y="102" text-anchor="middle">the party supplies</text>
  <text class="t" x="120" y="122" text-anchor="middle">on Saturday?"</text>
  <path class="flow" d="M220 90 H265" />
  <rect class="n acc" x="270" y="30" width="330" height="120" rx="10" />
  <text class="h" x="435" y="56" text-anchor="middle" fill="#B509AC">Candidate call and belief</text>
  <text class="mono" x="290" y="80">SMS(contact=&lt;UNK&gt;, message="Can you</text>
  <text class="mono" x="290" y="98">pick the party supplies on Saturday?")</text>
  <text class="s" x="290" y="120">contact domain: {Maya S. Work, Maya S. Home}</text>
  <text class="s" x="290" y="138">message: specified, p = 1</text>
  <rect class="n" x="640" y="30" width="240" height="120" rx="10" />
  <text class="s" x="660" y="56">π over completions</text>
  <text class="t" x="660" y="84">Work</text><rect class="bar" x="720" y="72" width="70" height="16" rx="3" /><text class="t" x="800" y="84">0.50</text>
  <text class="t" x="660" y="112">Home</text><rect class="bar" x="720" y="100" width="70" height="16" rx="3" /><text class="t" x="800" y="112">0.50</text>
  <text class="s" x="660" y="138">max π = 0.5, below τ_exec</text>
  <rect class="n acc" x="20" y="180" width="580" height="130" rx="10" />
  <text class="h" x="40" y="206" fill="#B509AC">Candidate question: "Should I text her on her home phone?"</text>
  <text class="s" x="40" y="226">targets aspect (SMS, contact), asked 0 times so far</text>
  <text class="t" x="40" y="254">EVPI = E[max π after answer] − max π now = 1.0 − 0.5 = 0.5</text>
  <text class="t" x="40" y="276">Cost = 0.5 × 0 = 0</text>
  <text class="t" x="40" y="298" font-weight="600">Score = 0.5 ≥ α × max π = 0.1 × 0.5 = 0.05 → ask</text>
  <rect class="n bad" x="640" y="180" width="240" height="130" rx="10" />
  <text class="h" x="760" y="206" text-anchor="middle" fill="#F29105">Token-space baseline</text>
  <text class="t" x="760" y="236" text-anchor="middle">SMS([Maya S.], "...")</text>
  <text class="s" x="760" y="262" text-anchor="middle">picks the default number,</text>
  <text class="s" x="760" y="280" text-anchor="middle">which is her work phone</text>
</svg>
</div>

    <p>The follow-up request lands on the same machinery. “Maya” narrows the contact domain to two entries, and nothing in the request chooses between them, so the belief splits evenly:</p>

    <div class="tok-row"><span class="tok tok-b">Alex C. Work</span><span class="tok tok-b">Alex M. Home</span><span class="tok tok-b">Maya S. Work</span><span class="tok tok-b">Maya S. Home</span><span class="tok-arrow">→ "Maya" →</span><span class="tok tok-b tok-hl">Maya S. Work</span><span class="tok tok-b tok-hl">Maya S. Home</span></div>

    <p>Whichever way the user answers “Should I text her on her home phone?”, the domain collapses to one entry and the best candidate’s probability jumps from 0.5 to 1.0. That gives the question an EVPI of 0.5, far above the stopping bar, so this time SAGE-Agent asks. The token-space baseline, which never sees that the domain has two entries, sends the text to Maya’s default number.</p>

  </div>
  <div class="wt-step" data-label="Update &amp; execute">
    <h4>6. Fold the answer into the domain, then execute</h4>

    <div class="fig-svg">
<svg viewBox="0 0 900 250" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="The user answers home. The contact domain is intersected with home, leaving Maya S. Home with belief 1.0; the aspect counter for SMS contact becomes 1; the agent executes SMS to Maya S. Home with the party supplies message.">
  <style>
    .n { fill: none; stroke: currentColor; stroke-width: 1.5; }
    .t { fill: currentColor; font-size: 14px; }
    .s { fill: currentColor; font-size: 13px; opacity: 0.8; }
    .h { fill: currentColor; font-size: 15px; font-weight: 600; }
    .in { stroke: #2698BA; }
    .acc { stroke: #B509AC; }
    .ok { stroke: #00ab37; }
    .bar { fill: #00ab37; opacity: 0.85; }
    .flow { fill: none; stroke: #B509AC; stroke-width: 2; stroke-dasharray: 6 6; animation: dash 1.2s linear infinite; }
    @keyframes dash { to { stroke-dashoffset: -24; } }
    .mono { font-family: "Roboto Mono", Menlo, monospace; font-size: 13px; fill: currentColor; }
  </style>
  <rect class="n in" x="20" y="60" width="150" height="100" rx="10" />
  <text class="h" x="95" y="90" text-anchor="middle" fill="#2698BA">User answer</text>
  <text class="t" x="95" y="120" text-anchor="middle">"Yes, her</text>
  <text class="t" x="95" y="140" text-anchor="middle">home phone."</text>
  <path class="flow" d="M170 110 H215" />
  <rect class="n acc" x="220" y="40" width="270" height="140" rx="10" />
  <text class="h" x="355" y="68" text-anchor="middle" fill="#B509AC">Belief update</text>
  <text class="s" x="240" y="96">domain ← domain ∩ Update(answer)</text>
  <text class="s" x="240" y="116">{Work, Home} ∩ {Home} = {Home}</text>
  <text class="t" x="240" y="142">π(Home) = 1.0</text>
  <rect class="bar" x="340" y="130" width="130" height="16" rx="3" />
  <text class="s" x="240" y="166">n(SMS, contact) ← 1</text>
  <path class="flow" d="M490 110 H535" />
  <rect class="n ok" x="540" y="40" width="340" height="140" rx="10" />
  <text class="h" x="710" y="68" text-anchor="middle" fill="#00ab37">Execute</text>
  <text class="mono" x="710" y="100" text-anchor="middle">SMS([Maya S. (Home)],</text>
  <text class="mono" x="710" y="118" text-anchor="middle">"Can you pick the party</text>
  <text class="mono" x="710" y="136" text-anchor="middle">supplies on Saturday?")</text>
  <text class="s" x="710" y="164" text-anchor="middle">1 question asked, correct number</text>
  <text class="s" x="450" y="220" text-anchor="middle">Asking about (SMS, contact) again would now cost λ × 1 = 0.5, so a repeat question is priced out.</text>
</svg>
</div>

    <p>The answer is not pasted into a prompt and forgotten. It becomes a constraint on the <code class="language-plaintext highlighter-rouge">contact</code> domain, the belief is recomputed, and the counter for the aspect <code class="language-plaintext highlighter-rouge">(SMS, contact)</code> goes up by one. Belief 1.0 clears the execution threshold, and the agent sends the text to the right number:</p>

    <div class="tok-row"><span class="tok tok-b">Maya S. Work</span><span class="tok tok-b">Maya S. Home</span><span class="tok-arrow">→ "home" →</span><span class="tok tok-x">Maya S. Work</span><span class="tok tok-c tok-hl">Maya S. Home</span><span class="tok-arrow">→</span><span class="tok tok-c">SMS([Maya S. (Home)], "...")</span></div>

    <p>If the tool call had failed at runtime, the agent would propose a corrected call or an error-specific question and re-enter the scoring step (step 4) rather than retrying blindly.</p>

  </div>
</div>

<h2 id="under-the-hood">Under the hood</h2>

<p>The clarification loop is a sequential decision problem: at each turn the agent chooses between executing its best candidate and asking a question, with the user’s true intent hidden. The paper frames this as a POMDP whose observations are user answers and whose belief state is a distribution over structured tool calls, and uses a Bayesian value-of-information objective to choose questions.</p>

<table class="notation">
  <thead>
    <tr>
      <th>Symbol</th>
      <th>Meaning</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>\(T_i = (name_i, \Theta_i, \mathcal{D}_i, \mathcal{R}_i)\)</td>
      <td>Tool schema: parameter set, per-parameter domains, required parameters</td>
    </tr>
    <tr>
      <td>\(c\)</td>
      <td>Candidate call: a partial assignment of a tool’s parameters; unspecified ones are <code class="language-plaintext highlighter-rouge">&lt;UNK&gt;</code></td>
    </tr>
    <tr>
      <td>\(\mathcal{C}_t, \mathcal{Q}_t\)</td>
      <td>Candidate calls and candidate questions proposed by the LLM at step \(t\)</td>
    </tr>
    <tr>
      <td>\(\pi_c(t)\)</td>
      <td>Belief that candidate \(c\) matches the user’s intent after answers \(r_{1:t}\)</td>
    </tr>
    <tr>
      <td>\(\mathcal{D}_{c,j}(t)\)</td>
      <td>Remaining domain of parameter \(j\) of candidate \(c\)</td>
    </tr>
    <tr>
      <td>\(a = (T_i, \theta_j)\)</td>
      <td>An aspect: one parameter of one tool; \(\mathcal{A}(q)\) is the set a question targets</td>
    </tr>
    <tr>
      <td>\(n_a(t)\)</td>
      <td>How many times aspect \(a\) has been asked about</td>
    </tr>
    <tr>
      <td>\(\lambda, \alpha, \tau_{\mathrm{exec}}, \epsilon\)</td>
      <td>Redundancy penalty, stopping coefficient, execution threshold, certainty for continuous domains</td>
    </tr>
  </tbody>
</table>

<p><strong>Belief.</strong> With a uniform prior over tools and conditional independence across parameters, the belief in a candidate factors over its parameters:</p>

\[\pi_c(t) \propto \prod_{j=1}^{m_c} p(\theta_{c,j} \mid T_c, u, r_{1:t}),
\qquad
p(\theta_{c,j}) =
\begin{cases}
1 &amp; \text{specified} \\
\lvert \mathcal{D}_{c,j}(t) \rvert^{-1} &amp; \text{unspecified, finite domain} \\
\epsilon &amp; \text{unspecified, continuous domain}
\end{cases}\]

<p>An answer \(r_t\) to question \(q_t\) updates each targeted domain by intersection, \(\mathcal{D}_\theta(t+1) = \mathcal{D}_\theta(t) \cap \mathrm{Update}(\theta, r_t, q_t)\), and the beliefs are renormalized. Answers can be explicit values, cross-parameter dependencies, or exclusions such as “not business class”; all three are domain constraints, which is what keeps the update exact.</p>

<p><strong>Value of a question.</strong> The Expected Value of Perfect Information of \(q\) is the expected gain in best-candidate certainty if the aspects it targets were resolved perfectly:</p>

\[\mathrm{EVPI}(q, \mathcal{B}(t)) = \mathbb{E}_{r}\!\left[\max_c \pi_c(t \mid q, r)\right] - \max_c \pi_c(t).\]

<p>In practice we simulate the resolution directly: for each candidate, multiply \(\pi_c(t)\) by \(\lvert \mathcal{D}_a \rvert\) for every targeted aspect it leaves unspecified, then take the expected maximum. EVPI is non-negative, has diminishing returns over question sequences, and goes to zero as the belief concentrates, which is exactly what happened to “Which Alex?” in step 4.</p>

<p><strong>Cost, selection, stopping.</strong> Asking about the same aspect twice is discouraged by a redundancy cost, and the agent picks the question with the best net score:</p>

\[q^*(t) = \arg\max_{q \in \mathcal{Q}_t}\Big[\mathrm{EVPI}(q, \mathcal{B}(t)) - \lambda \sum_{a \in \mathcal{A}(q)} n_a(t)\Big],
\qquad
\text{execute } c^*(t) \text{ if } \max_q \mathrm{Score}(q,t) &lt; \alpha \cdot \max_c \pi_c(t).\]

<p>The agent also executes immediately when \(\max_c \pi_c(t) \geq \tau_{\mathrm{exec}}\), and stops after \(n_s\) steps. Because the stopping bar scales with the current best belief, the loop ends either because the belief has become confident or because no remaining question is worth its cost. In the experiments we set \(\lambda = 0.5\), \(\alpha = 0.1\), and \(\epsilon = 10^{-4}\).</p>

<div class="fig-svg">
<svg viewBox="0 0 900 300" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="SAGE-Agent loop inserted into the Reason stage: LLM proposes candidate calls, belief is computed over the schema, if belief clears the execution threshold the call runs, otherwise the LLM proposes questions which are scored by EVPI minus cost; a score below alpha times the best belief executes, otherwise the best question is asked, the answer updates the domains, and the loop repeats.">
  <style>
    .n { fill: none; stroke: currentColor; stroke-width: 1.5; }
    .t { fill: currentColor; font-size: 14px; }
    .s { fill: currentColor; font-size: 12px; opacity: 0.8; }
    .h { fill: currentColor; font-size: 14px; font-weight: 600; }
    .in { stroke: #2698BA; }
    .acc { stroke: #B509AC; }
    .ok { stroke: #00ab37; }
    .flow { fill: none; stroke: #B509AC; stroke-width: 2; stroke-dasharray: 6 6; animation: dash 1.2s linear infinite; }
    .ar { fill: none; stroke: currentColor; stroke-width: 1.5; opacity: 0.6; }
    @keyframes dash { to { stroke-dashoffset: -24; } }
  </style>
  <rect class="n in" x="20" y="95" width="120" height="70" rx="10" />
  <text class="h" x="80" y="124" text-anchor="middle">Query u,</text>
  <text class="h" x="80" y="142" text-anchor="middle">history O_t</text>
  <path class="ar" d="M140 130 H175" />
  <rect class="n" x="180" y="95" width="130" height="70" rx="10" />
  <text class="h" x="245" y="118" text-anchor="middle">LLM: propose</text>
  <text class="h" x="245" y="136" text-anchor="middle">candidates C_t</text>
  <text class="s" x="245" y="154" text-anchor="middle">values or &lt;UNK&gt;</text>
  <path class="flow" d="M310 130 H345" />
  <rect class="n acc" x="350" y="85" width="150" height="90" rx="10" />
  <text class="h" x="425" y="112" text-anchor="middle" fill="#B509AC">Belief π_c(t)</text>
  <text class="s" x="425" y="132" text-anchor="middle">1 / |domain| per</text>
  <text class="s" x="425" y="148" text-anchor="middle">unspecified argument</text>
  <text class="s" x="425" y="166" text-anchor="middle">max π ≥ τ_exec ?</text>
  <path class="flow" d="M425 175 V220" />
  <text class="s" x="440" y="200">yes</text>
  <path class="flow" d="M500 130 H545" />
  <text class="s" x="522" y="120" text-anchor="middle">no</text>
  <rect class="n" x="550" y="95" width="130" height="70" rx="10" />
  <text class="h" x="615" y="118" text-anchor="middle">LLM: propose</text>
  <text class="h" x="615" y="136" text-anchor="middle">questions Q_t</text>
  <text class="s" x="615" y="154" text-anchor="middle">with aspects A(q)</text>
  <path class="flow" d="M680 130 H715" />
  <rect class="n acc" x="720" y="85" width="160" height="90" rx="10" />
  <text class="h" x="800" y="112" text-anchor="middle" fill="#B509AC">Score questions</text>
  <text class="s" x="800" y="132" text-anchor="middle">EVPI(q) − λ Σ n_a</text>
  <text class="s" x="800" y="150" text-anchor="middle">best &lt; α · max π ?</text>
  <text class="s" x="800" y="166" text-anchor="middle">yes: execute</text>
  <path class="flow" d="M800 175 V220 H520" />
  <rect class="n ok" x="360" y="220" width="160" height="50" rx="10" />
  <text class="h" x="440" y="242" text-anchor="middle" fill="#00ab37">Execute c*(t)</text>
  <text class="s" x="440" y="260" text-anchor="middle">Act, then Observe</text>
  <path class="flow" d="M880 130 H895 V12 H245 V95" />
  <rect class="n in" x="560" y="24" width="270" height="42" rx="10" />
  <text class="t" x="695" y="42" text-anchor="middle">no: ask q*, answer r_t constrains domains,</text>
  <text class="s" x="695" y="59" text-anchor="middle">n_a += 1 for a in A(q*), back to Reason</text>
</svg>
<div class="fig-caption">SAGE-Agent's clarification loop sits inside the Reason stage of a Reason-Act-Observe agent. Purple boxes are the structured-uncertainty computations; the two LLM calls per turn are plain boxes.</div>
</div>

<p><strong>The same belief as a training signal.</strong> The certainty \(\max_c \pi_c(t)\) also makes a reward. In GRPO training on 9K When2Call examples, we multiply the action-classification reward by \(\mathrm{Cert}(a_t)\), which is \(\max_c \pi_c(t)\) for a tool call, \(1 - \max_c \pi_c(t)\) for a clarifying question, and 1 otherwise, so confident correct calls get full payoff, low-certainty calls are penalized, and asking is rewarded only when uncertainty is high, with no critic to judge question quality.</p>

<p><strong>Where the evaluation comes from.</strong> We built ClarifyBench to test this in multi-turn use: 716 tasks across document processing, vehicle control, stock trading, travel, and file systems, over 92 tools, with an LLM user simulator that holds the true intent, answers questions, and issues follow-up requests. Queries are split into explicit, ambiguous, and infeasible (which should be declined), and every generated query was checked by two annotators.</p>

<h2 id="what-the-numbers-say">What the numbers say</h2>

<table>
  <thead>
    <tr>
      <th>Method (GPT-4o, ambiguous split)</th>
      <th>Coverage</th>
      <th>TMR</th>
      <th>PMR</th>
      <th>Avg. #Q</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>ReAct + <code class="language-plaintext highlighter-rouge">ask_question()</code></td>
      <td>42.88</td>
      <td>70.41</td>
      <td>62.55</td>
      <td>2.68</td>
    </tr>
    <tr>
      <td>ProCOT</td>
      <td>54.27</td>
      <td>75.62</td>
      <td>66.82</td>
      <td>2.07</td>
    </tr>
    <tr>
      <td>Active Task Disambiguation</td>
      <td>45.60</td>
      <td>77.10</td>
      <td>60.78</td>
      <td>3.42</td>
    </tr>
    <tr>
      <td>Domain-aware ReAct</td>
      <td>55.70</td>
      <td>79.83</td>
      <td>68.04</td>
      <td>2.56</td>
    </tr>
    <tr>
      <td>SAGE-Agent, heuristic-based</td>
      <td>56.42</td>
      <td>82.31</td>
      <td>69.81</td>
      <td>1.82</td>
    </tr>
    <tr>
      <td><strong>SAGE-Agent</strong></td>
      <td><strong>59.73</strong></td>
      <td><strong>86.02</strong></td>
      <td><strong>71.79</strong></td>
      <td><strong>1.39</strong></td>
    </tr>
  </tbody>
</table>

<p>Coverage counts tool calls that fully match the ground truth; TMR and PMR are tool and parameter match rates; #Q is the mean number of clarifying questions per task. Across baselines and both base models (GPT-4o and Qwen2.5-14B-Instruct), SAGE-Agent reaches 7-39% higher coverage on ambiguous tasks while asking 1.5-2.7x fewer questions. The heuristic row triggers questions on <code class="language-plaintext highlighter-rouge">&lt;UNK&gt;</code> alone without EVPI scoring, and gives up 1-3 points across metrics while asking 0.2-0.4 more questions, which is the value of steps 4 and 5 above. Raising \(\lambda\) from 0 to 0.5 cuts questions by 18.1% (ambiguous), 26.6% (explicit), and 24.2% (infeasible) with Coverage, TMR, and PMR within 3%, so the pruned questions were redundant. On the training side, certainty-weighted GRPO lifts When2Call accuracy from 36.5% to 65.2% for Qwen2.5-3B and from 36.7% to 62.9% for Qwen2.5-7B; the 3B model with our reward beats the 7B model with the standard reward (45.1%).</p>

<h2 id="try-it">Try it</h2>

<ul>
  <li>Paper page: <a href="/papers/sage-clarification/">/papers/sage-clarification/</a></li>
  <li>arXiv: <a href="https://arxiv.org/abs/2511.08798">2511.08798</a></li>
  <li>Code and benchmark: <a href="https://github.com/MananSuri27/ClarifyBench">github.com/MananSuri27/ClarifyBench</a></li>
  <li>ACL Anthology: <a href="https://aclanthology.org/2026.findings-acl.2028/">2026.findings-acl.2028</a></li>
</ul>]]></content><author><name></name></author><category term="research" /><category term="agents" /><category term="tool-use" /><category term="clarification" /><category term="uncertainty" /><summary type="html"><![CDATA[How modeling uncertainty over tool parameters, not tokens, lets an LLM agent decide which clarifying question to ask and when to stop asking.]]></summary></entry><entry><title type="html">Which bar is the model talking about? ChartLens explained</title><link href="https://manansuri.com/blog/2026/chartlens-explained/" rel="alternate" type="text/html" title="Which bar is the model talking about? ChartLens explained" /><published>2026-09-04T12:00:00+00:00</published><updated>2026-09-04T12:00:00+00:00</updated><id>https://manansuri.com/blog/2026/chartlens-explained</id><content type="html" xml:base="https://manansuri.com/blog/2026/chartlens-explained/"><![CDATA[<p>Put a bar chart in front of a multimodal LLM and ask in how many countries the number of import documents in 2005 is above the 2005 average across countries. It answers “3” in a full sentence, instantly, and with total confidence. Now ask yourself how you would check that. You would have to read six bars, compute a mean, and count. The model did none of that visibly. If it misread one bar, the text would look exactly the same.</p>

<p>Text LLMs got a partial fix for this years ago: citations. Make the model point at its source and a human can verify the claim in seconds. Charts never had an equivalent. Our ACL 2025 paper, <a href="/papers/chartlens/">ChartLens: Fine-grained Visual Attribution in Charts</a>, with Puneet Mathur, Nedim Lipka, Franck Dernoncourt, Ryan Rossi, and Dinesh Manocha, gives charts their citations: the specific bars, points, or pie sectors that an answer rests on.</p>

<h2 id="the-idea-in-one-picture">The idea in one picture</h2>

<div class="fig-svg">
<svg viewBox="0 0 900 290" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="Before: a chart answer with no way to check it. After: ChartLens highlights the bars that support the answer so a reader can verify it.">
  <style>
    .n { fill: none; stroke: currentColor; stroke-width: 1.5; }
    .t { fill: currentColor; font-size: 14px; }
    .s { fill: currentColor; font-size: 13px; opacity: 0.8; }
    .b { font-weight: 600; }
    .acc { stroke: #B509AC; }
    .inp { stroke: #2698BA; }
    .ok { stroke: #00ab37; }
    .bad { stroke: #F29105; }
    .grid { stroke: currentColor; stroke-width: 1; opacity: 0.18; }
    .flow { fill: none; stroke: #B509AC; stroke-width: 2; stroke-dasharray: 6 6; animation: dash 1.2s linear infinite; }
    @keyframes dash { to { stroke-dashoffset: -24; } }
  </style>

<text class="t b" x="225" y="30" text-anchor="middle">Chart QA today</text>
<rect class="n" x="30" y="45" width="390" height="230" rx="10" />
<rect x="60" y="80" width="20" height="90" fill="#2698BA" opacity="0.8" />
<rect x="90" y="90" width="20" height="80" fill="#2698BA" opacity="0.8" />
<rect x="120" y="120" width="20" height="50" fill="#2698BA" opacity="0.8" />
<rect x="150" y="110" width="20" height="60" fill="#2698BA" opacity="0.8" />
<rect x="180" y="110" width="20" height="60" fill="#2698BA" opacity="0.8" />
<rect x="210" y="100" width="20" height="70" fill="#2698BA" opacity="0.8" />
<path class="n" d="M54 170 H244" />
<rect class="n" x="270" y="70" width="130" height="90" rx="10" />
<text class="s" x="335" y="95" text-anchor="middle">Q: how many</text>
<text class="s" x="335" y="113" text-anchor="middle">countries above</text>
<text class="s" x="335" y="131" text-anchor="middle">the 2005 average?</text>
<text class="t b" x="335" y="152" text-anchor="middle">Ans: 3</text>
<text class="t b" x="225" y="215" text-anchor="middle" fill="#F29105">Which bars is that based on?</text>
<text class="s" x="225" y="238" text-anchor="middle">The answer looks fine whether it is right or wrong.</text>
<text class="s" x="225" y="256" text-anchor="middle">Nothing in the output points back at the chart.</text>

<path class="flow" d="M425 160 H470" />
<path d="M470 154 L480 160 L470 166 Z" fill="#B509AC" />

<text class="t b" x="675" y="30" text-anchor="middle">Chart QA + ChartLens</text>
<rect class="n acc" x="480" y="45" width="390" height="230" rx="10" />
<rect x="510" y="80" width="20" height="90" fill="#2698BA" opacity="0.8" />
<rect x="507" y="77" width="26" height="93" rx="4" fill="none" stroke="#00ab37" stroke-width="3" />
<rect x="540" y="90" width="20" height="80" fill="#2698BA" opacity="0.8" />
<rect x="537" y="87" width="26" height="83" rx="4" fill="none" stroke="#00ab37" stroke-width="3" />
<rect x="570" y="120" width="20" height="50" fill="#2698BA" opacity="0.8" />
<rect x="600" y="110" width="20" height="60" fill="#2698BA" opacity="0.8" />
<rect x="630" y="110" width="20" height="60" fill="#2698BA" opacity="0.8" />
<rect x="660" y="100" width="20" height="70" fill="#2698BA" opacity="0.8" />
<rect x="657" y="97" width="26" height="73" rx="4" fill="none" stroke="#00ab37" stroke-width="3" />
<path class="n" d="M504 170 H694" />
<rect class="n" x="720" y="70" width="130" height="90" rx="10" />
<text class="s" x="785" y="95" text-anchor="middle">Q: how many</text>
<text class="s" x="785" y="113" text-anchor="middle">countries above</text>
<text class="s" x="785" y="131" text-anchor="middle">the 2005 average?</text>
<text class="t b" x="785" y="152" text-anchor="middle">Ans: 3</text>
<text class="t b" x="675" y="215" text-anchor="middle" fill="#00ab37">These three bars. Count them: 3.</text>
<text class="s" x="675" y="238" text-anchor="middle">Attribution = the bars, points, or sectors that</text>
<text class="s" x="675" y="256" text-anchor="middle">support the answer, so a reader can verify it.</text>

</svg>
<div class="fig-caption">Left: a chart answer with nothing pointing back at the chart. Right: the same answer with the three bars that support it highlighted, so verifying it means counting to three.</div>
</div>

<div class="callout"><span class="callout-label">Key idea</span>Do not ask the model for pixel coordinates; MLLMs are bad at that. Find the chart's elements first with segmentation, stamp a label on each one, and ask the model which labels support the answer (set-of-marks prompting). Attribution runs after the answer exists, so it is a check on any chart QA system rather than a new one.</div>

<h2 id="walkthrough-the-documents-per-shipment-chart">Walkthrough: the documents-per-shipment chart</h2>

<p>The running example is Figure 1 of the paper, a PlotQA-style chart of the number of documents required per shipment to import goods, for six countries in 2005, 2006, and 2007. I redraw it below so each stage can be shown on the same bars.</p>

<div class="walkthrough">
  <div class="wt-title">Walkthrough: attributing "Ans: 3" to the bars that support it</div>
  <div class="wt-step" data-label="Input">
    <h4>1. A chart, a question, and someone's answer</h4>
    <div class="fig-svg">
<svg viewBox="0 0 900 312" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="The input: a grouped bar chart of documents required per shipment for six countries in 2005, 2006, and 2007, with the question and the answer 3.">
  <style>
    .n { fill: none; stroke: currentColor; stroke-width: 1.5; }
    .t { fill: currentColor; font-size: 14px; }
    .s { fill: currentColor; font-size: 13px; opacity: 0.8; }
    .b { font-weight: 600; }
    .acc { stroke: #B509AC; }
    .inp { stroke: #2698BA; }
    .ok { stroke: #00ab37; }
    .bad { stroke: #F29105; }
    .grid { stroke: currentColor; stroke-width: 1; opacity: 0.18; }
    .flow { fill: none; stroke: #B509AC; stroke-width: 2; stroke-dasharray: 6 6; animation: dash 1.2s linear infinite; }
    @keyframes dash { to { stroke-dashoffset: -24; } }
  </style>
<text class="t b" x="315" y="20" text-anchor="middle">Number of documents required per shipment to import goods</text>
<rect x="230" y="30" width="12" height="12" fill="#1b6f88" /><text class="s" x="246" y="41">2005</text>
<rect x="300" y="30" width="12" height="12" fill="#2698BA" /><text class="s" x="316" y="41">2006</text>
<rect x="370" y="30" width="12" height="12" fill="#9dd3e6" /><text class="s" x="386" y="41">2007</text>
<line class="grid" x1="70" y1="270" x2="590" y2="270" /><text class="s" x="62" y="274" text-anchor="end">0</text>
<line class="grid" x1="70" y1="222" x2="590" y2="222" /><text class="s" x="62" y="226" text-anchor="end">2</text>
<line class="grid" x1="70" y1="174" x2="590" y2="174" /><text class="s" x="62" y="178" text-anchor="end">4</text>
<line class="grid" x1="70" y1="126" x2="590" y2="126" /><text class="s" x="62" y="130" text-anchor="end">6</text>
<line class="grid" x1="70" y1="78" x2="590" y2="78" /><text class="s" x="62" y="82" text-anchor="end">8</text>
<path class="n" d="M70 50 V270 H590" />
<text class="s" transform="translate(22,160) rotate(-90)" text-anchor="middle">Documents required</text>
<rect x="80" y="54" width="22" height="216" fill="#1b6f88" opacity="0.9" />
<rect x="102" y="54" width="22" height="216" fill="#2698BA" opacity="0.9" />
<rect x="124" y="54" width="22" height="216" fill="#9dd3e6" opacity="0.9" />
<rect x="167" y="78" width="22" height="192" fill="#1b6f88" opacity="0.9" />
<rect x="189" y="78" width="22" height="192" fill="#2698BA" opacity="0.9" />
<rect x="211" y="78" width="22" height="192" fill="#9dd3e6" opacity="0.9" />
<rect x="253" y="150" width="22" height="120" fill="#1b6f88" opacity="0.9" />
<rect x="275" y="150" width="22" height="120" fill="#2698BA" opacity="0.9" />
<rect x="297" y="150" width="22" height="120" fill="#9dd3e6" opacity="0.9" />
<rect x="340" y="126" width="22" height="144" fill="#1b6f88" opacity="0.9" />
<rect x="362" y="126" width="22" height="144" fill="#2698BA" opacity="0.9" />
<rect x="384" y="126" width="22" height="144" fill="#9dd3e6" opacity="0.9" />
<rect x="427" y="126" width="22" height="144" fill="#1b6f88" opacity="0.9" />
<rect x="449" y="126" width="22" height="144" fill="#2698BA" opacity="0.9" />
<rect x="471" y="78" width="22" height="192" fill="#9dd3e6" opacity="0.9" />
<rect x="514" y="102" width="22" height="168" fill="#1b6f88" opacity="0.9" />
<rect x="536" y="102" width="22" height="168" fill="#2698BA" opacity="0.9" />
<rect x="558" y="102" width="22" height="168" fill="#9dd3e6" opacity="0.9" />
<text class="s" x="113" y="290" text-anchor="middle">Algeria</text>
<text class="s" x="200" y="290" text-anchor="middle">Angola</text>
<text class="s" x="286" y="290" text-anchor="middle">Antigua and</text>
<text class="s" x="286" y="306" text-anchor="middle">Barbuda</text>
<text class="s" x="373" y="290" text-anchor="middle">Argentina</text>
<text class="s" x="460" y="290" text-anchor="middle">Armenia</text>
<text class="s" x="546" y="290" text-anchor="middle">Australia</text>
<rect class="n inp" x="630" y="60" width="260" height="204" rx="10" />
<text class="s b" x="642" y="80" fill="#2698BA" style="opacity:1">Question</text>
<text class="s" x="642" y="100">In how many countries, is the</text>
<text class="s" x="642" y="117">number of documents required</text>
<text class="s" x="642" y="134">per shipment to import goods</text>
<text class="s" x="642" y="151">in 2005 greater than the</text>
<text class="s" x="642" y="168">average number of documents</text>
<text class="s" x="642" y="185">required per shipment to</text>
<text class="s" x="642" y="202">import goods in 2005 taken</text>
<text class="s" x="642" y="219">over all countries?</text>
<rect x="642" y="230" width="70" height="22" rx="6" fill="#2698BA" opacity="0.18" /><text class="t b" x="677" y="246" text-anchor="middle">Ans: 3</text>
</svg>
</div>

    <p>The input (chart \(c\) plus response \(v\)) is the image and a question-answer pair. The answer could come from any system, a chart QA model or a person; ChartLens does not regenerate it. The job is to return the set of chart regions that justify “3”, and it has to be relevant (every region matters), complete (nothing needed is missing), and precise (nothing extra).</p>

  </div>
  <div class="wt-step" data-label="Segment">
    <h4>2. Find every bar</h4>
    <div class="fig-svg">
<svg viewBox="0 0 900 330" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="Segmentation: each of the 18 bars becomes a dashed contour with sampled points inside it, produced by Otsu thresholding, contour splitting, filtering, and SAM.">
  <style>
    .n { fill: none; stroke: currentColor; stroke-width: 1.5; }
    .t { fill: currentColor; font-size: 14px; }
    .s { fill: currentColor; font-size: 13px; opacity: 0.8; }
    .b { font-weight: 600; }
    .acc { stroke: #B509AC; }
    .inp { stroke: #2698BA; }
    .ok { stroke: #00ab37; }
    .bad { stroke: #F29105; }
    .grid { stroke: currentColor; stroke-width: 1; opacity: 0.18; }
    .flow { fill: none; stroke: #B509AC; stroke-width: 2; stroke-dasharray: 6 6; animation: dash 1.2s linear infinite; }
    @keyframes dash { to { stroke-dashoffset: -24; } }
  </style>
<text class="t b" x="315" y="20" text-anchor="middle">Number of documents required per shipment to import goods</text>
<rect x="230" y="30" width="12" height="12" fill="#1b6f88" /><text class="s" x="246" y="41">2005</text>
<rect x="300" y="30" width="12" height="12" fill="#2698BA" /><text class="s" x="316" y="41">2006</text>
<rect x="370" y="30" width="12" height="12" fill="#9dd3e6" /><text class="s" x="386" y="41">2007</text>
<line class="grid" x1="70" y1="270" x2="590" y2="270" /><text class="s" x="62" y="274" text-anchor="end">0</text>
<line class="grid" x1="70" y1="222" x2="590" y2="222" /><text class="s" x="62" y="226" text-anchor="end">2</text>
<line class="grid" x1="70" y1="174" x2="590" y2="174" /><text class="s" x="62" y="178" text-anchor="end">4</text>
<line class="grid" x1="70" y1="126" x2="590" y2="126" /><text class="s" x="62" y="130" text-anchor="end">6</text>
<line class="grid" x1="70" y1="78" x2="590" y2="78" /><text class="s" x="62" y="82" text-anchor="end">8</text>
<path class="n" d="M70 50 V270 H590" />
<text class="s" transform="translate(22,160) rotate(-90)" text-anchor="middle">Documents required</text>
<rect x="80" y="54" width="22" height="216" fill="#1b6f88" opacity="0.25" />
<rect class="n acc" x="80" y="54" width="22" height="216" stroke-dasharray="4 3" />
<circle cx="91" cy="108" r="2.5" fill="#B509AC" />
<circle cx="91" cy="162" r="2.5" fill="#B509AC" />
<circle cx="91" cy="216" r="2.5" fill="#B509AC" />
<rect x="102" y="54" width="22" height="216" fill="#2698BA" opacity="0.25" />
<rect class="n acc" x="102" y="54" width="22" height="216" stroke-dasharray="4 3" />
<circle cx="113" cy="108" r="2.5" fill="#B509AC" />
<circle cx="113" cy="162" r="2.5" fill="#B509AC" />
<circle cx="113" cy="216" r="2.5" fill="#B509AC" />
<rect x="124" y="54" width="22" height="216" fill="#9dd3e6" opacity="0.25" />
<rect class="n acc" x="124" y="54" width="22" height="216" stroke-dasharray="4 3" />
<circle cx="135" cy="108" r="2.5" fill="#B509AC" />
<circle cx="135" cy="162" r="2.5" fill="#B509AC" />
<circle cx="135" cy="216" r="2.5" fill="#B509AC" />
<rect x="167" y="78" width="22" height="192" fill="#1b6f88" opacity="0.25" />
<rect class="n acc" x="167" y="78" width="22" height="192" stroke-dasharray="4 3" />
<circle cx="178" cy="126" r="2.5" fill="#B509AC" />
<circle cx="178" cy="174" r="2.5" fill="#B509AC" />
<circle cx="178" cy="222" r="2.5" fill="#B509AC" />
<rect x="189" y="78" width="22" height="192" fill="#2698BA" opacity="0.25" />
<rect class="n acc" x="189" y="78" width="22" height="192" stroke-dasharray="4 3" />
<circle cx="200" cy="126" r="2.5" fill="#B509AC" />
<circle cx="200" cy="174" r="2.5" fill="#B509AC" />
<circle cx="200" cy="222" r="2.5" fill="#B509AC" />
<rect x="211" y="78" width="22" height="192" fill="#9dd3e6" opacity="0.25" />
<rect class="n acc" x="211" y="78" width="22" height="192" stroke-dasharray="4 3" />
<circle cx="222" cy="126" r="2.5" fill="#B509AC" />
<circle cx="222" cy="174" r="2.5" fill="#B509AC" />
<circle cx="222" cy="222" r="2.5" fill="#B509AC" />
<rect x="253" y="150" width="22" height="120" fill="#1b6f88" opacity="0.25" />
<rect class="n acc" x="253" y="150" width="22" height="120" stroke-dasharray="4 3" />
<circle cx="264" cy="180" r="2.5" fill="#B509AC" />
<circle cx="264" cy="210" r="2.5" fill="#B509AC" />
<circle cx="264" cy="240" r="2.5" fill="#B509AC" />
<rect x="275" y="150" width="22" height="120" fill="#2698BA" opacity="0.25" />
<rect class="n acc" x="275" y="150" width="22" height="120" stroke-dasharray="4 3" />
<circle cx="286" cy="180" r="2.5" fill="#B509AC" />
<circle cx="286" cy="210" r="2.5" fill="#B509AC" />
<circle cx="286" cy="240" r="2.5" fill="#B509AC" />
<rect x="297" y="150" width="22" height="120" fill="#9dd3e6" opacity="0.25" />
<rect class="n acc" x="297" y="150" width="22" height="120" stroke-dasharray="4 3" />
<circle cx="308" cy="180" r="2.5" fill="#B509AC" />
<circle cx="308" cy="210" r="2.5" fill="#B509AC" />
<circle cx="308" cy="240" r="2.5" fill="#B509AC" />
<rect x="340" y="126" width="22" height="144" fill="#1b6f88" opacity="0.25" />
<rect class="n acc" x="340" y="126" width="22" height="144" stroke-dasharray="4 3" />
<circle cx="351" cy="162" r="2.5" fill="#B509AC" />
<circle cx="351" cy="198" r="2.5" fill="#B509AC" />
<circle cx="351" cy="234" r="2.5" fill="#B509AC" />
<rect x="362" y="126" width="22" height="144" fill="#2698BA" opacity="0.25" />
<rect class="n acc" x="362" y="126" width="22" height="144" stroke-dasharray="4 3" />
<circle cx="373" cy="162" r="2.5" fill="#B509AC" />
<circle cx="373" cy="198" r="2.5" fill="#B509AC" />
<circle cx="373" cy="234" r="2.5" fill="#B509AC" />
<rect x="384" y="126" width="22" height="144" fill="#9dd3e6" opacity="0.25" />
<rect class="n acc" x="384" y="126" width="22" height="144" stroke-dasharray="4 3" />
<circle cx="395" cy="162" r="2.5" fill="#B509AC" />
<circle cx="395" cy="198" r="2.5" fill="#B509AC" />
<circle cx="395" cy="234" r="2.5" fill="#B509AC" />
<rect x="427" y="126" width="22" height="144" fill="#1b6f88" opacity="0.25" />
<rect class="n acc" x="427" y="126" width="22" height="144" stroke-dasharray="4 3" />
<circle cx="438" cy="162" r="2.5" fill="#B509AC" />
<circle cx="438" cy="198" r="2.5" fill="#B509AC" />
<circle cx="438" cy="234" r="2.5" fill="#B509AC" />
<rect x="449" y="126" width="22" height="144" fill="#2698BA" opacity="0.25" />
<rect class="n acc" x="449" y="126" width="22" height="144" stroke-dasharray="4 3" />
<circle cx="460" cy="162" r="2.5" fill="#B509AC" />
<circle cx="460" cy="198" r="2.5" fill="#B509AC" />
<circle cx="460" cy="234" r="2.5" fill="#B509AC" />
<rect x="471" y="78" width="22" height="192" fill="#9dd3e6" opacity="0.25" />
<rect class="n acc" x="471" y="78" width="22" height="192" stroke-dasharray="4 3" />
<circle cx="482" cy="126" r="2.5" fill="#B509AC" />
<circle cx="482" cy="174" r="2.5" fill="#B509AC" />
<circle cx="482" cy="222" r="2.5" fill="#B509AC" />
<rect x="514" y="102" width="22" height="168" fill="#1b6f88" opacity="0.25" />
<rect class="n acc" x="514" y="102" width="22" height="168" stroke-dasharray="4 3" />
<circle cx="524" cy="144" r="2.5" fill="#B509AC" />
<circle cx="524" cy="186" r="2.5" fill="#B509AC" />
<circle cx="524" cy="228" r="2.5" fill="#B509AC" />
<rect x="536" y="102" width="22" height="168" fill="#2698BA" opacity="0.25" />
<rect class="n acc" x="536" y="102" width="22" height="168" stroke-dasharray="4 3" />
<circle cx="546" cy="144" r="2.5" fill="#B509AC" />
<circle cx="546" cy="186" r="2.5" fill="#B509AC" />
<circle cx="546" cy="228" r="2.5" fill="#B509AC" />
<rect x="558" y="102" width="22" height="168" fill="#9dd3e6" opacity="0.25" />
<rect class="n acc" x="558" y="102" width="22" height="168" stroke-dasharray="4 3" />
<circle cx="568" cy="144" r="2.5" fill="#B509AC" />
<circle cx="568" cy="186" r="2.5" fill="#B509AC" />
<circle cx="568" cy="228" r="2.5" fill="#B509AC" />
<text class="s" x="113" y="290" text-anchor="middle">Algeria</text>
<text class="s" x="200" y="290" text-anchor="middle">Angola</text>
<text class="s" x="286" y="290" text-anchor="middle">Antigua and</text>
<text class="s" x="286" y="306" text-anchor="middle">Barbuda</text>
<text class="s" x="373" y="290" text-anchor="middle">Argentina</text>
<text class="s" x="460" y="290" text-anchor="middle">Armenia</text>
<text class="s" x="546" y="290" text-anchor="middle">Australia</text>
<rect class="n" x="640" y="45" width="240" height="48" rx="10" /><text class="t b" x="652" y="65">Otsu threshold</text><text class="s" x="652" y="83">RGB + HSV, invert if dark</text>
<path class="flow" d="M760 93 V107" />
<rect class="n" x="640" y="107" width="240" height="48" rx="10" /><text class="t b" x="652" y="127">Contours</text><text class="s" x="652" y="145">split by unique pixel value</text>
<path class="flow" d="M760 155 V169" />
<rect class="n" x="640" y="169" width="240" height="48" rx="10" /><text class="t b" x="652" y="189">Filter</text><text class="s" x="652" y="207">solidity + area thresholds</text>
<path class="flow" d="M760 217 V231" />
<rect class="n acc" x="640" y="231" width="240" height="48" rx="10" /><text class="t b" x="652" y="251">SAM</text><text class="s" x="652" y="269">n points per candidate as prompts</text>
<text class="s" x="760" y="300" text-anchor="middle">Output: one tight mask per bar; grid lines</text><text class="s" x="760" y="318" text-anchor="middle">and labels get weak masks and drop out</text>
</svg>
</div>

    <p>Before any language model is involved, classical vision finds the candidates. We binarize the image with Otsu thresholding on both RGB and HSV, invert if the background is dark, extract contours, and split each contour by unique pixel value so a group of touching bars separates into three. Solidity and area thresholds drop stray shapes. Because these heuristics are brittle on low-contrast charts and can pick up grid lines or labels, we sample \(n\) points inside each candidate and hand them to the Segment Anything Model (SAM), which returns a tight mask per bar and only weak masks for grid lines and text, so those fall away. For this chart the result is eighteen masks.</p>

  </div>
  <div class="wt-step" data-label="Marks">
    <h4>3. Stamp a label on each element</h4>
    <div class="fig-svg">
<svg viewBox="0 0 900 312" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="Set-of-marks: every bar carries a numeric label from 1 to 18, drawn on the image.">
  <style>
    .n { fill: none; stroke: currentColor; stroke-width: 1.5; }
    .t { fill: currentColor; font-size: 14px; }
    .s { fill: currentColor; font-size: 13px; opacity: 0.8; }
    .b { font-weight: 600; }
    .acc { stroke: #B509AC; }
    .inp { stroke: #2698BA; }
    .ok { stroke: #00ab37; }
    .bad { stroke: #F29105; }
    .grid { stroke: currentColor; stroke-width: 1; opacity: 0.18; }
    .flow { fill: none; stroke: #B509AC; stroke-width: 2; stroke-dasharray: 6 6; animation: dash 1.2s linear infinite; }
    @keyframes dash { to { stroke-dashoffset: -24; } }
  </style>
<text class="t b" x="315" y="20" text-anchor="middle">Number of documents required per shipment to import goods</text>
<rect x="230" y="30" width="12" height="12" fill="#1b6f88" /><text class="s" x="246" y="41">2005</text>
<rect x="300" y="30" width="12" height="12" fill="#2698BA" /><text class="s" x="316" y="41">2006</text>
<rect x="370" y="30" width="12" height="12" fill="#9dd3e6" /><text class="s" x="386" y="41">2007</text>
<line class="grid" x1="70" y1="270" x2="590" y2="270" /><text class="s" x="62" y="274" text-anchor="end">0</text>
<line class="grid" x1="70" y1="222" x2="590" y2="222" /><text class="s" x="62" y="226" text-anchor="end">2</text>
<line class="grid" x1="70" y1="174" x2="590" y2="174" /><text class="s" x="62" y="178" text-anchor="end">4</text>
<line class="grid" x1="70" y1="126" x2="590" y2="126" /><text class="s" x="62" y="130" text-anchor="end">6</text>
<line class="grid" x1="70" y1="78" x2="590" y2="78" /><text class="s" x="62" y="82" text-anchor="end">8</text>
<path class="n" d="M70 50 V270 H590" />
<text class="s" transform="translate(22,160) rotate(-90)" text-anchor="middle">Documents required</text>
<rect x="80" y="54" width="22" height="216" fill="#1b6f88" opacity="0.9" />
<circle cx="91" cy="67" r="10" fill="#fff" stroke="#B509AC" stroke-width="1.5" /><text x="91" y="71.5" text-anchor="middle" font-size="12" font-weight="600" fill="#B509AC">1</text>
<rect x="102" y="54" width="22" height="216" fill="#2698BA" opacity="0.9" />
<circle cx="113" cy="67" r="10" fill="#fff" stroke="#B509AC" stroke-width="1.5" /><text x="113" y="71.5" text-anchor="middle" font-size="12" font-weight="600" fill="#B509AC">2</text>
<rect x="124" y="54" width="22" height="216" fill="#9dd3e6" opacity="0.9" />
<circle cx="135" cy="67" r="10" fill="#fff" stroke="#B509AC" stroke-width="1.5" /><text x="135" y="71.5" text-anchor="middle" font-size="12" font-weight="600" fill="#B509AC">3</text>
<rect x="167" y="78" width="22" height="192" fill="#1b6f88" opacity="0.9" />
<circle cx="178" cy="91" r="10" fill="#fff" stroke="#B509AC" stroke-width="1.5" /><text x="178" y="95.5" text-anchor="middle" font-size="12" font-weight="600" fill="#B509AC">4</text>
<rect x="189" y="78" width="22" height="192" fill="#2698BA" opacity="0.9" />
<circle cx="200" cy="91" r="10" fill="#fff" stroke="#B509AC" stroke-width="1.5" /><text x="200" y="95.5" text-anchor="middle" font-size="12" font-weight="600" fill="#B509AC">5</text>
<rect x="211" y="78" width="22" height="192" fill="#9dd3e6" opacity="0.9" />
<circle cx="222" cy="91" r="10" fill="#fff" stroke="#B509AC" stroke-width="1.5" /><text x="222" y="95.5" text-anchor="middle" font-size="12" font-weight="600" fill="#B509AC">6</text>
<rect x="253" y="150" width="22" height="120" fill="#1b6f88" opacity="0.9" />
<circle cx="264" cy="163" r="10" fill="#fff" stroke="#B509AC" stroke-width="1.5" /><text x="264" y="167.5" text-anchor="middle" font-size="12" font-weight="600" fill="#B509AC">7</text>
<rect x="275" y="150" width="22" height="120" fill="#2698BA" opacity="0.9" />
<circle cx="286" cy="163" r="10" fill="#fff" stroke="#B509AC" stroke-width="1.5" /><text x="286" y="167.5" text-anchor="middle" font-size="12" font-weight="600" fill="#B509AC">8</text>
<rect x="297" y="150" width="22" height="120" fill="#9dd3e6" opacity="0.9" />
<circle cx="308" cy="163" r="10" fill="#fff" stroke="#B509AC" stroke-width="1.5" /><text x="308" y="167.5" text-anchor="middle" font-size="12" font-weight="600" fill="#B509AC">9</text>
<rect x="340" y="126" width="22" height="144" fill="#1b6f88" opacity="0.9" />
<circle cx="351" cy="139" r="10" fill="#fff" stroke="#B509AC" stroke-width="1.5" /><text x="351" y="143.5" text-anchor="middle" font-size="12" font-weight="600" fill="#B509AC">10</text>
<rect x="362" y="126" width="22" height="144" fill="#2698BA" opacity="0.9" />
<circle cx="373" cy="139" r="10" fill="#fff" stroke="#B509AC" stroke-width="1.5" /><text x="373" y="143.5" text-anchor="middle" font-size="12" font-weight="600" fill="#B509AC">11</text>
<rect x="384" y="126" width="22" height="144" fill="#9dd3e6" opacity="0.9" />
<circle cx="395" cy="139" r="10" fill="#fff" stroke="#B509AC" stroke-width="1.5" /><text x="395" y="143.5" text-anchor="middle" font-size="12" font-weight="600" fill="#B509AC">12</text>
<rect x="427" y="126" width="22" height="144" fill="#1b6f88" opacity="0.9" />
<circle cx="438" cy="139" r="10" fill="#fff" stroke="#B509AC" stroke-width="1.5" /><text x="438" y="143.5" text-anchor="middle" font-size="12" font-weight="600" fill="#B509AC">13</text>
<rect x="449" y="126" width="22" height="144" fill="#2698BA" opacity="0.9" />
<circle cx="460" cy="139" r="10" fill="#fff" stroke="#B509AC" stroke-width="1.5" /><text x="460" y="143.5" text-anchor="middle" font-size="12" font-weight="600" fill="#B509AC">14</text>
<rect x="471" y="78" width="22" height="192" fill="#9dd3e6" opacity="0.9" />
<circle cx="482" cy="91" r="10" fill="#fff" stroke="#B509AC" stroke-width="1.5" /><text x="482" y="95.5" text-anchor="middle" font-size="12" font-weight="600" fill="#B509AC">15</text>
<rect x="514" y="102" width="22" height="168" fill="#1b6f88" opacity="0.9" />
<circle cx="524" cy="115" r="10" fill="#fff" stroke="#B509AC" stroke-width="1.5" /><text x="524" y="119.5" text-anchor="middle" font-size="12" font-weight="600" fill="#B509AC">16</text>
<rect x="536" y="102" width="22" height="168" fill="#2698BA" opacity="0.9" />
<circle cx="546" cy="115" r="10" fill="#fff" stroke="#B509AC" stroke-width="1.5" /><text x="546" y="119.5" text-anchor="middle" font-size="12" font-weight="600" fill="#B509AC">17</text>
<rect x="558" y="102" width="22" height="168" fill="#9dd3e6" opacity="0.9" />
<circle cx="568" cy="115" r="10" fill="#fff" stroke="#B509AC" stroke-width="1.5" /><text x="568" y="119.5" text-anchor="middle" font-size="12" font-weight="600" fill="#B509AC">18</text>
<text class="s" x="113" y="290" text-anchor="middle">Algeria</text>
<text class="s" x="200" y="290" text-anchor="middle">Angola</text>
<text class="s" x="286" y="290" text-anchor="middle">Antigua and</text>
<text class="s" x="286" y="306" text-anchor="middle">Barbuda</text>
<text class="s" x="373" y="290" text-anchor="middle">Argentina</text>
<text class="s" x="460" y="290" text-anchor="middle">Armenia</text>
<text class="s" x="546" y="290" text-anchor="middle">Australia</text>
<rect class="n acc" x="640" y="60" width="240" height="150" rx="10" />
<text class="t b" x="652" y="84" fill="#B509AC">Set of marks</text>
<text class="s" x="652" y="108">18 elements, 18 labels.</text>
<text class="s" x="652" y="128">Label k is drawn on bar k.</text>
<text class="s" x="652" y="148">The model never sees pixel</text>
<text class="s" x="652" y="168">coordinates; it refers to</text>
<text class="s" x="652" y="188">bars by label only.</text>
<text class="s" x="760" y="245" text-anchor="middle">Algeria = 1, 2, 3 | Angola = 4, 5, 6</text>
<text class="s" x="760" y="265" text-anchor="middle">... | Armenia = 13, 14, 15 | Australia = 16, 17, 18</text>
</svg>
</div>

    <p>Each mask gets an alphanumeric label drawn directly on the image, following set-of-marks (SoM) prompting. Reading left to right, Algeria’s three bars become 1, 2, 3, Angola’s 4, 5, 6, and so on through Australia’s 16, 17, 18. From here on the model never has to name a position in pixels; it names a label, and we already know which mask that label points to.</p>

    <div class="tok-row"><span class="tok tok-b">Algeria 2005</span><span class="tok tok-b">Algeria 2006</span><span class="tok tok-b">Algeria 2007</span><span class="tok tok-b">...</span><span class="tok tok-b">Australia 2007</span><span class="tok-arrow">→</span><span class="tok tok-a">1</span><span class="tok tok-a">2</span><span class="tok tok-a">3</span><span class="tok tok-a">...</span><span class="tok tok-a">18</span></div>

  </div>
  <div class="wt-step" data-label="Validate">
    <h4>4. The model checks the answer against the chart</h4>
    <div class="fig-svg">
<svg viewBox="0 0 900 260" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="Validation: the prompt with the marked chart and the response goes to the MLLM, which checks that the answer 3 is consistent with the chart.">
  <style>
    .n { fill: none; stroke: currentColor; stroke-width: 1.5; }
    .t { fill: currentColor; font-size: 14px; }
    .s { fill: currentColor; font-size: 13px; opacity: 0.8; }
    .b { font-weight: 600; }
    .acc { stroke: #B509AC; }
    .inp { stroke: #2698BA; }
    .ok { stroke: #00ab37; }
    .bad { stroke: #F29105; }
    .grid { stroke: currentColor; stroke-width: 1; opacity: 0.18; }
    .flow { fill: none; stroke: #B509AC; stroke-width: 2; stroke-dasharray: 6 6; animation: dash 1.2s linear infinite; }
    @keyframes dash { to { stroke-dashoffset: -24; } }
  </style>

<rect class="n inp" x="20" y="40" width="250" height="200" rx="10" />
<text class="t b" x="145" y="66" text-anchor="middle" fill="#2698BA">Prompt</text>
<text class="s" x="34" y="92">Marked chart image (18 labels)</text>
<text class="s" x="34" y="112">What attribution means</text>
<text class="s" x="34" y="132">Few-shot text examples of</text>
<text class="s" x="34" y="150">question, answer, attribution</text>
<text class="s" x="34" y="176">Question + "Ans: 3"</text>
<text class="s" x="34" y="200">Think step by step:</text>
<text class="s" x="34" y="218">validate, then attribute</text>
<path class="flow" d="M275 140 H325" />
<rect class="n acc" x="330" y="90" width="150" height="100" rx="10" />
<text class="t b" x="405" y="132" text-anchor="middle">MLLM</text>
<text class="s" x="405" y="152" text-anchor="middle">(GPT-4o)</text>
<path class="flow" d="M485 140 H535" />
<rect class="n ok" x="540" y="40" width="340" height="200" rx="10" />
<text class="t b" x="710" y="66" text-anchor="middle" fill="#00ab37">Step 1: validation</text>
<text class="s" x="554" y="94">Read the 2005 bars (labels 1, 4, 7, 10, 13, 16):</text>
<text class="s" x="554" y="114">9, 8, 5, 6, 6, 7 documents</text>
<text class="s" x="554" y="140">Mean over six countries = 41 / 6 = 6.83</text>
<text class="s" x="554" y="166">Above the mean: 9, 8, 7 (three bars)</text>
<text class="t b" x="710" y="200" text-anchor="middle" fill="#00ab37">Answer 3 is consistent with the chart</text>
<text class="s" x="710" y="222" text-anchor="middle">continue to step 2</text>

</svg>
</div>

    <p>The marked image goes to the MLLM (GPT-4o in our experiments) with a prompt that explains what chart attribution is, gives a few textual examples of question, answer, and attribution, and asks for chain-of-thought reasoning in two steps. Step one is validation: is the answer consistent with the chart? For our question the model has to read the six 2005 bars, take their mean, and count how many exceed it.</p>

    <div class="tok-row"><span class="tok tok-b">1: 9</span><span class="tok tok-b">4: 8</span><span class="tok tok-b">7: 5</span><span class="tok tok-b">10: 6</span><span class="tok tok-b">13: 6</span><span class="tok tok-b">16: 7</span><span class="tok-arrow">→</span><span class="tok">mean 6.83</span><span class="tok-arrow">→</span><span class="tok tok-c">9, 8, 7 above</span><span class="tok-arrow">→</span><span class="tok tok-c">count 3 ✓</span></div>

  </div>
  <div class="wt-step" data-label="Attribute">
    <h4>5. The model names the marks that support it</h4>
    <div class="fig-svg">
<svg viewBox="0 0 900 312" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="Attribution: the MLLM returns labels 1, 4, and 16; those three 2005 bars for Algeria, Angola, and Australia are highlighted and the others fade.">
  <style>
    .n { fill: none; stroke: currentColor; stroke-width: 1.5; }
    .t { fill: currentColor; font-size: 14px; }
    .s { fill: currentColor; font-size: 13px; opacity: 0.8; }
    .b { font-weight: 600; }
    .acc { stroke: #B509AC; }
    .inp { stroke: #2698BA; }
    .ok { stroke: #00ab37; }
    .bad { stroke: #F29105; }
    .grid { stroke: currentColor; stroke-width: 1; opacity: 0.18; }
    .flow { fill: none; stroke: #B509AC; stroke-width: 2; stroke-dasharray: 6 6; animation: dash 1.2s linear infinite; }
    @keyframes dash { to { stroke-dashoffset: -24; } }
  </style>
<text class="t b" x="315" y="20" text-anchor="middle">Number of documents required per shipment to import goods</text>
<rect x="230" y="30" width="12" height="12" fill="#1b6f88" /><text class="s" x="246" y="41">2005</text>
<rect x="300" y="30" width="12" height="12" fill="#2698BA" /><text class="s" x="316" y="41">2006</text>
<rect x="370" y="30" width="12" height="12" fill="#9dd3e6" /><text class="s" x="386" y="41">2007</text>
<line class="grid" x1="70" y1="270" x2="590" y2="270" /><text class="s" x="62" y="274" text-anchor="end">0</text>
<line class="grid" x1="70" y1="222" x2="590" y2="222" /><text class="s" x="62" y="226" text-anchor="end">2</text>
<line class="grid" x1="70" y1="174" x2="590" y2="174" /><text class="s" x="62" y="178" text-anchor="end">4</text>
<line class="grid" x1="70" y1="126" x2="590" y2="126" /><text class="s" x="62" y="130" text-anchor="end">6</text>
<line class="grid" x1="70" y1="78" x2="590" y2="78" /><text class="s" x="62" y="82" text-anchor="end">8</text>
<path class="n" d="M70 50 V270 H590" />
<text class="s" transform="translate(22,160) rotate(-90)" text-anchor="middle">Documents required</text>
<rect x="80" y="54" width="22" height="216" fill="#1b6f88" opacity="0.9" />
<rect x="77" y="51" width="28" height="219" rx="4" fill="none" stroke="#00ab37" stroke-width="3" />
<circle cx="91" cy="67" r="10" fill="#fff" stroke="#00ab37" stroke-width="1.5" /><text x="91" y="71.5" text-anchor="middle" font-size="12" font-weight="600" fill="#00ab37">1</text>
<rect x="102" y="54" width="22" height="216" fill="#2698BA" opacity="0.22" />
<circle cx="113" cy="67" r="10" fill="#fff" stroke="#B509AC" stroke-width="1.5" /><text x="113" y="71.5" text-anchor="middle" font-size="12" font-weight="600" fill="#B509AC">2</text>
<rect x="124" y="54" width="22" height="216" fill="#9dd3e6" opacity="0.22" />
<circle cx="135" cy="67" r="10" fill="#fff" stroke="#B509AC" stroke-width="1.5" /><text x="135" y="71.5" text-anchor="middle" font-size="12" font-weight="600" fill="#B509AC">3</text>
<rect x="167" y="78" width="22" height="192" fill="#1b6f88" opacity="0.9" />
<rect x="164" y="75" width="28" height="195" rx="4" fill="none" stroke="#00ab37" stroke-width="3" />
<circle cx="178" cy="91" r="10" fill="#fff" stroke="#00ab37" stroke-width="1.5" /><text x="178" y="95.5" text-anchor="middle" font-size="12" font-weight="600" fill="#00ab37">4</text>
<rect x="189" y="78" width="22" height="192" fill="#2698BA" opacity="0.22" />
<circle cx="200" cy="91" r="10" fill="#fff" stroke="#B509AC" stroke-width="1.5" /><text x="200" y="95.5" text-anchor="middle" font-size="12" font-weight="600" fill="#B509AC">5</text>
<rect x="211" y="78" width="22" height="192" fill="#9dd3e6" opacity="0.22" />
<circle cx="222" cy="91" r="10" fill="#fff" stroke="#B509AC" stroke-width="1.5" /><text x="222" y="95.5" text-anchor="middle" font-size="12" font-weight="600" fill="#B509AC">6</text>
<rect x="253" y="150" width="22" height="120" fill="#1b6f88" opacity="0.22" />
<circle cx="264" cy="163" r="10" fill="#fff" stroke="#B509AC" stroke-width="1.5" /><text x="264" y="167.5" text-anchor="middle" font-size="12" font-weight="600" fill="#B509AC">7</text>
<rect x="275" y="150" width="22" height="120" fill="#2698BA" opacity="0.22" />
<circle cx="286" cy="163" r="10" fill="#fff" stroke="#B509AC" stroke-width="1.5" /><text x="286" y="167.5" text-anchor="middle" font-size="12" font-weight="600" fill="#B509AC">8</text>
<rect x="297" y="150" width="22" height="120" fill="#9dd3e6" opacity="0.22" />
<circle cx="308" cy="163" r="10" fill="#fff" stroke="#B509AC" stroke-width="1.5" /><text x="308" y="167.5" text-anchor="middle" font-size="12" font-weight="600" fill="#B509AC">9</text>
<rect x="340" y="126" width="22" height="144" fill="#1b6f88" opacity="0.22" />
<circle cx="351" cy="139" r="10" fill="#fff" stroke="#B509AC" stroke-width="1.5" /><text x="351" y="143.5" text-anchor="middle" font-size="12" font-weight="600" fill="#B509AC">10</text>
<rect x="362" y="126" width="22" height="144" fill="#2698BA" opacity="0.22" />
<circle cx="373" cy="139" r="10" fill="#fff" stroke="#B509AC" stroke-width="1.5" /><text x="373" y="143.5" text-anchor="middle" font-size="12" font-weight="600" fill="#B509AC">11</text>
<rect x="384" y="126" width="22" height="144" fill="#9dd3e6" opacity="0.22" />
<circle cx="395" cy="139" r="10" fill="#fff" stroke="#B509AC" stroke-width="1.5" /><text x="395" y="143.5" text-anchor="middle" font-size="12" font-weight="600" fill="#B509AC">12</text>
<rect x="427" y="126" width="22" height="144" fill="#1b6f88" opacity="0.22" />
<circle cx="438" cy="139" r="10" fill="#fff" stroke="#B509AC" stroke-width="1.5" /><text x="438" y="143.5" text-anchor="middle" font-size="12" font-weight="600" fill="#B509AC">13</text>
<rect x="449" y="126" width="22" height="144" fill="#2698BA" opacity="0.22" />
<circle cx="460" cy="139" r="10" fill="#fff" stroke="#B509AC" stroke-width="1.5" /><text x="460" y="143.5" text-anchor="middle" font-size="12" font-weight="600" fill="#B509AC">14</text>
<rect x="471" y="78" width="22" height="192" fill="#9dd3e6" opacity="0.22" />
<circle cx="482" cy="91" r="10" fill="#fff" stroke="#B509AC" stroke-width="1.5" /><text x="482" y="95.5" text-anchor="middle" font-size="12" font-weight="600" fill="#B509AC">15</text>
<rect x="514" y="102" width="22" height="168" fill="#1b6f88" opacity="0.9" />
<rect x="510" y="99" width="28" height="171" rx="4" fill="none" stroke="#00ab37" stroke-width="3" />
<circle cx="524" cy="115" r="10" fill="#fff" stroke="#00ab37" stroke-width="1.5" /><text x="524" y="119.5" text-anchor="middle" font-size="12" font-weight="600" fill="#00ab37">16</text>
<rect x="536" y="102" width="22" height="168" fill="#2698BA" opacity="0.22" />
<circle cx="546" cy="115" r="10" fill="#fff" stroke="#B509AC" stroke-width="1.5" /><text x="546" y="119.5" text-anchor="middle" font-size="12" font-weight="600" fill="#B509AC">17</text>
<rect x="558" y="102" width="22" height="168" fill="#9dd3e6" opacity="0.22" />
<circle cx="568" cy="115" r="10" fill="#fff" stroke="#B509AC" stroke-width="1.5" /><text x="568" y="119.5" text-anchor="middle" font-size="12" font-weight="600" fill="#B509AC">18</text>
<text class="s" x="113" y="290" text-anchor="middle">Algeria</text>
<text class="s" x="200" y="290" text-anchor="middle">Angola</text>
<text class="s" x="286" y="290" text-anchor="middle">Antigua and</text>
<text class="s" x="286" y="306" text-anchor="middle">Barbuda</text>
<text class="s" x="373" y="290" text-anchor="middle">Argentina</text>
<text class="s" x="460" y="290" text-anchor="middle">Armenia</text>
<text class="s" x="546" y="290" text-anchor="middle">Australia</text>
<rect class="n acc" x="640" y="60" width="240" height="130" rx="10" />
<text class="t b" x="652" y="84" fill="#B509AC">Step 2: attribution</text>
<text class="s" x="652" y="108">"Supporting elements:</text>
<text class="t b" x="652" y="130">1, 4, 16"</text>
<text class="s" x="652" y="156">Algeria 2005, Angola 2005,</text>
<text class="s" x="652" y="176">Australia 2005</text>
<text class="t b" x="760" y="230" text-anchor="middle" fill="#00ab37">3 highlighted bars = Ans: 3</text>
<text class="s" x="760" y="252" text-anchor="middle">A reader checks the answer</text>
<text class="s" x="760" y="270" text-anchor="middle">against three bars, not eighteen.</text>
</svg>
</div>

    <p>Step two is attribution: which labeled elements support the answer? The model returns 1, 4, and 16, the 2005 bars for Algeria, Angola, and Australia. We map those labels back to their SAM masks and highlight them. The reader’s verification problem has shrunk from “read this chart” to “count these three bars”, and the count matches the answer.</p>

    <div class="tok-row"><span class="tok tok-b">Ans: 3</span><span class="tok-arrow">→</span><span class="tok tok-a">MLLM</span><span class="tok-arrow">→</span><span class="tok tok-c">1</span><span class="tok tok-c">4</span><span class="tok tok-c">16</span><span class="tok-arrow">→</span><span class="tok tok-c">3 bars, consistent</span></div>

  </div>
  <div class="wt-step" data-label="Catching a hallucination">
    <h4>6. The same pipeline on a wrong answer</h4>
    <div class="fig-svg">
<svg viewBox="0 0 900 322" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="The inconsistent case: for the Armenia question with answer 2006, the model flags the response as inconsistent and grounds it to bar 14, while the tallest Armenia bar is 15 (2007).">
  <style>
    .n { fill: none; stroke: currentColor; stroke-width: 1.5; }
    .t { fill: currentColor; font-size: 14px; }
    .s { fill: currentColor; font-size: 13px; opacity: 0.8; }
    .b { font-weight: 600; }
    .acc { stroke: #B509AC; }
    .inp { stroke: #2698BA; }
    .ok { stroke: #00ab37; }
    .bad { stroke: #F29105; }
    .grid { stroke: currentColor; stroke-width: 1; opacity: 0.18; }
    .flow { fill: none; stroke: #B509AC; stroke-width: 2; stroke-dasharray: 6 6; animation: dash 1.2s linear infinite; }
    @keyframes dash { to { stroke-dashoffset: -24; } }
  </style>
<text class="t b" x="315" y="20" text-anchor="middle">Number of documents required per shipment to import goods</text>
<rect x="230" y="30" width="12" height="12" fill="#1b6f88" /><text class="s" x="246" y="41">2005</text>
<rect x="300" y="30" width="12" height="12" fill="#2698BA" /><text class="s" x="316" y="41">2006</text>
<rect x="370" y="30" width="12" height="12" fill="#9dd3e6" /><text class="s" x="386" y="41">2007</text>
<line class="grid" x1="70" y1="270" x2="590" y2="270" /><text class="s" x="62" y="274" text-anchor="end">0</text>
<line class="grid" x1="70" y1="222" x2="590" y2="222" /><text class="s" x="62" y="226" text-anchor="end">2</text>
<line class="grid" x1="70" y1="174" x2="590" y2="174" /><text class="s" x="62" y="178" text-anchor="end">4</text>
<line class="grid" x1="70" y1="126" x2="590" y2="126" /><text class="s" x="62" y="130" text-anchor="end">6</text>
<line class="grid" x1="70" y1="78" x2="590" y2="78" /><text class="s" x="62" y="82" text-anchor="end">8</text>
<path class="n" d="M70 50 V270 H590" />
<text class="s" transform="translate(22,160) rotate(-90)" text-anchor="middle">Documents required</text>
<rect x="80" y="54" width="22" height="216" fill="#1b6f88" opacity="0.22" />
<circle cx="91" cy="67" r="10" fill="#fff" stroke="#B509AC" stroke-width="1.5" /><text x="91" y="71.5" text-anchor="middle" font-size="12" font-weight="600" fill="#B509AC">1</text>
<rect x="102" y="54" width="22" height="216" fill="#2698BA" opacity="0.22" />
<circle cx="113" cy="67" r="10" fill="#fff" stroke="#B509AC" stroke-width="1.5" /><text x="113" y="71.5" text-anchor="middle" font-size="12" font-weight="600" fill="#B509AC">2</text>
<rect x="124" y="54" width="22" height="216" fill="#9dd3e6" opacity="0.22" />
<circle cx="135" cy="67" r="10" fill="#fff" stroke="#B509AC" stroke-width="1.5" /><text x="135" y="71.5" text-anchor="middle" font-size="12" font-weight="600" fill="#B509AC">3</text>
<rect x="167" y="78" width="22" height="192" fill="#1b6f88" opacity="0.22" />
<circle cx="178" cy="91" r="10" fill="#fff" stroke="#B509AC" stroke-width="1.5" /><text x="178" y="95.5" text-anchor="middle" font-size="12" font-weight="600" fill="#B509AC">4</text>
<rect x="189" y="78" width="22" height="192" fill="#2698BA" opacity="0.22" />
<circle cx="200" cy="91" r="10" fill="#fff" stroke="#B509AC" stroke-width="1.5" /><text x="200" y="95.5" text-anchor="middle" font-size="12" font-weight="600" fill="#B509AC">5</text>
<rect x="211" y="78" width="22" height="192" fill="#9dd3e6" opacity="0.22" />
<circle cx="222" cy="91" r="10" fill="#fff" stroke="#B509AC" stroke-width="1.5" /><text x="222" y="95.5" text-anchor="middle" font-size="12" font-weight="600" fill="#B509AC">6</text>
<rect x="253" y="150" width="22" height="120" fill="#1b6f88" opacity="0.22" />
<circle cx="264" cy="163" r="10" fill="#fff" stroke="#B509AC" stroke-width="1.5" /><text x="264" y="167.5" text-anchor="middle" font-size="12" font-weight="600" fill="#B509AC">7</text>
<rect x="275" y="150" width="22" height="120" fill="#2698BA" opacity="0.22" />
<circle cx="286" cy="163" r="10" fill="#fff" stroke="#B509AC" stroke-width="1.5" /><text x="286" y="167.5" text-anchor="middle" font-size="12" font-weight="600" fill="#B509AC">8</text>
<rect x="297" y="150" width="22" height="120" fill="#9dd3e6" opacity="0.22" />
<circle cx="308" cy="163" r="10" fill="#fff" stroke="#B509AC" stroke-width="1.5" /><text x="308" y="167.5" text-anchor="middle" font-size="12" font-weight="600" fill="#B509AC">9</text>
<rect x="340" y="126" width="22" height="144" fill="#1b6f88" opacity="0.22" />
<circle cx="351" cy="139" r="10" fill="#fff" stroke="#B509AC" stroke-width="1.5" /><text x="351" y="143.5" text-anchor="middle" font-size="12" font-weight="600" fill="#B509AC">10</text>
<rect x="362" y="126" width="22" height="144" fill="#2698BA" opacity="0.22" />
<circle cx="373" cy="139" r="10" fill="#fff" stroke="#B509AC" stroke-width="1.5" /><text x="373" y="143.5" text-anchor="middle" font-size="12" font-weight="600" fill="#B509AC">11</text>
<rect x="384" y="126" width="22" height="144" fill="#9dd3e6" opacity="0.22" />
<circle cx="395" cy="139" r="10" fill="#fff" stroke="#B509AC" stroke-width="1.5" /><text x="395" y="143.5" text-anchor="middle" font-size="12" font-weight="600" fill="#B509AC">12</text>
<rect x="427" y="126" width="22" height="144" fill="#1b6f88" opacity="0.22" />
<circle cx="438" cy="139" r="10" fill="#fff" stroke="#B509AC" stroke-width="1.5" /><text x="438" y="143.5" text-anchor="middle" font-size="12" font-weight="600" fill="#B509AC">13</text>
<rect x="449" y="126" width="22" height="144" fill="#2698BA" opacity="0.9" />
<rect x="446" y="123" width="28" height="147" rx="4" fill="none" stroke="#F29105" stroke-width="3" />
<circle cx="460" cy="139" r="10" fill="#fff" stroke="#F29105" stroke-width="1.5" /><text x="460" y="143.5" text-anchor="middle" font-size="12" font-weight="600" fill="#F29105">14</text>
<rect x="471" y="78" width="22" height="192" fill="#9dd3e6" opacity="0.9" />
<rect x="468" y="75" width="28" height="195" rx="4" fill="none" stroke="#00ab37" stroke-width="2" stroke-dasharray="5 4" />
<circle cx="482" cy="91" r="10" fill="#fff" stroke="#B509AC" stroke-width="1.5" /><text x="482" y="95.5" text-anchor="middle" font-size="12" font-weight="600" fill="#B509AC">15</text>
<rect x="514" y="102" width="22" height="168" fill="#1b6f88" opacity="0.22" />
<circle cx="524" cy="115" r="10" fill="#fff" stroke="#B509AC" stroke-width="1.5" /><text x="524" y="119.5" text-anchor="middle" font-size="12" font-weight="600" fill="#B509AC">16</text>
<rect x="536" y="102" width="22" height="168" fill="#2698BA" opacity="0.22" />
<circle cx="546" cy="115" r="10" fill="#fff" stroke="#B509AC" stroke-width="1.5" /><text x="546" y="119.5" text-anchor="middle" font-size="12" font-weight="600" fill="#B509AC">17</text>
<rect x="558" y="102" width="22" height="168" fill="#9dd3e6" opacity="0.22" />
<circle cx="568" cy="115" r="10" fill="#fff" stroke="#B509AC" stroke-width="1.5" /><text x="568" y="119.5" text-anchor="middle" font-size="12" font-weight="600" fill="#B509AC">18</text>
<text class="s" x="113" y="290" text-anchor="middle">Algeria</text>
<text class="s" x="200" y="290" text-anchor="middle">Angola</text>
<text class="s" x="286" y="290" text-anchor="middle">Antigua and</text>
<text class="s" x="286" y="306" text-anchor="middle">Barbuda</text>
<text class="s" x="373" y="290" text-anchor="middle">Argentina</text>
<text class="s" x="460" y="290" text-anchor="middle">Armenia</text>
<text class="s" x="546" y="290" text-anchor="middle">Australia</text>
<rect class="n inp" x="630" y="40" width="260" height="136" rx="10" />
<text class="s b" x="642" y="60" fill="#2698BA" style="opacity:1">Question</text>
<text class="s" x="642" y="80">In which year did Armenia</text>
<text class="s" x="642" y="97">have the maximum number of</text>
<text class="s" x="642" y="114">documents required per</text>
<text class="s" x="642" y="131">shipment to import goods?</text>
<rect x="642" y="142" width="70" height="22" rx="6" fill="#F29105" opacity="0.18" /><text class="t b" x="677" y="158" text-anchor="middle">Ans: 2006</text>
<rect class="n bad" x="630" y="188" width="260" height="122" rx="10" />
<text class="t b" x="642" y="212" fill="#F29105">Validation: inconsistent</text>
<text class="s" x="642" y="234">Armenia: 6, 6, 8 documents,</text>
<text class="s" x="642" y="254">so the maximum is 2007 (15),</text>
<text class="s" x="642" y="274">not 2006. Attribution: 14.</text>
<text class="s" x="642" y="298" fill="#00ab37" style="opacity:1">dashed = the bar that would be right</text>
</svg>
</div>

    <p>Now the second question from Figure 1: “In which year did Armenia have the maximum number of documents required per shipment to import goods?” with the answer 2006. Armenia’s bars read 6, 6, 8, so the maximum is 2007, and the validation step flags the response as inconsistent. The attribution still grounds the answer to the bar it depends on, Armenia’s 2006 bar (mark 14), and one glance shows it is not the tallest of the three. That is what a chart-response misalignment looks like when it is made visible.</p>

    <div class="tok-row"><span class="tok tok-b">Ans: 2006</span><span class="tok-arrow">→</span><span class="tok tok-a">MLLM</span><span class="tok-arrow">→</span><span class="tok tok-d">inconsistent</span><span class="tok tok-d">14</span><span class="tok-arrow">→</span><span class="tok tok-c">15 is taller</span></div>

    <figure>

  <picture>
    
    <source class="responsive-img-srcset" media="(max-width: 480px)" srcset="/assets/img/blog/chartlens/fig-intro-480.webp" />
    <source class="responsive-img-srcset" media="(max-width: 800px)" srcset="/assets/img/blog/chartlens/fig-intro-800.webp" />
    <source class="responsive-img-srcset" media="(max-width: 1400px)" srcset="/assets/img/blog/chartlens/fig-intro-1400.webp" />
    

    <!-- Fallback to the original file -->
    <img src="/assets/img/blog/chartlens/fig-intro.png" class="img-fluid rounded z-depth-1" width="auto" height="auto" data-zoomable="" loading="lazy" onerror="this.onerror=null; $('.responsive-img-srcset').remove();" />
  </picture><figcaption class="caption">The same two cases as drawn in Figure 1 of the paper. (1) Attribution grounds each response to specific bars. (2) Verification: the count of 3 is consistent with the highlighted 2005 bars; the answer 2006 for Armenia is inconsistent with the highlighted bar.</figcaption>

</figure>

  </div>
</div>

<h2 id="under-the-hood">Under the hood</h2>

<table class="notation">
  <thead>
    <tr>
      <th>Symbol</th>
      <th>Meaning</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>\(c \in \mathcal{C}\)</td>
      <td>A chart image, \(c = \mathcal{I}^{w \times h \times 3}\)</td>
    </tr>
    <tr>
      <td>\(v \in \mathcal{R}_c\)</td>
      <td>A response associated with chart \(c\) (a question and its answer)</td>
    </tr>
    <tr>
      <td>\(\mathcal{A}_{c,v}\)</td>
      <td>The attribution set: chart regions that support \(v\)</td>
    </tr>
    <tr>
      <td>\(a_i\)</td>
      <td>One region, corresponding to an element of \(c\) (bar, sector, point, line segment)</td>
    </tr>
    <tr>
      <td>\(f\)</td>
      <td>The attribution function ChartLens implements</td>
    </tr>
    <tr>
      <td>\(n\)</td>
      <td>Number of points sampled per candidate as SAM prompts</td>
    </tr>
    <tr>
      <td>\(D, G\)</td>
      <td>Detected regions (after IoU matching) and ground-truth regions</td>
    </tr>
  </tbody>
</table>

<p>The task is post-hoc fine-grained visual attribution for charts. Given a chart and a response, produce</p>

\[\mathcal{A}_{c,v} = \{a_1, a_2, \ldots, a_n\}\]

<p>where each \(a_i\) is a distinct chart element that supports \(v\), subject to relevance (each \(a_i\) bears on \(v\)), completeness (\(\mathcal{A}_{c,v}\) covers all the evidence needed to justify \(v\)), and precision (no irrelevant parts of the chart). The whole method is a mapping</p>

\[f : (c, v) \mapsto \mathcal{A}_{c,v}\]

<p>and the design question is how to make an MLLM implement \(f\) when it cannot reliably output coordinates. ChartLens answers by turning region selection into label selection: the candidate set of elements is computed by segmentation, and the model chooses a subset of labels.</p>

<div class="fig-svg">
<svg viewBox="0 0 900 300" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="ChartLens architecture: chart and response, segmentation branches for bars/pies (heuristics plus SAM) and lines (LineFormer), a marked image, an MLLM that validates then attributes, and the output label set.">
  <style>
    .n { fill: none; stroke: currentColor; stroke-width: 1.5; }
    .t { fill: currentColor; font-size: 14px; }
    .s { fill: currentColor; font-size: 13px; opacity: 0.8; }
    .b { font-weight: 600; }
    .acc { stroke: #B509AC; }
    .inp { stroke: #2698BA; }
    .ok { stroke: #00ab37; }
    .bad { stroke: #F29105; }
    .grid { stroke: currentColor; stroke-width: 1; opacity: 0.18; }
    .flow { fill: none; stroke: #B509AC; stroke-width: 2; stroke-dasharray: 6 6; animation: dash 1.2s linear infinite; }
    @keyframes dash { to { stroke-dashoffset: -24; } }
  </style>

<rect class="n inp" x="20" y="90" width="130" height="110" rx="10" />
<text class="t b" x="85" y="118" text-anchor="middle">Chart c</text>
<text class="s" x="85" y="142" text-anchor="middle">+ response v</text>
<text class="s" x="85" y="162" text-anchor="middle">(question,</text>
<text class="s" x="85" y="180" text-anchor="middle">answer)</text>
<path class="flow" d="M155 145 H195" />
<rect class="n acc" x="200" y="30" width="200" height="110" rx="10" />
<text class="t b" x="300" y="55" text-anchor="middle">Bars, pie sectors</text>
<text class="s" x="300" y="77" text-anchor="middle">Otsu + contours</text>
<text class="s" x="300" y="95" text-anchor="middle">(pies: unroll radially)</text>
<text class="s" x="300" y="113" text-anchor="middle">then SAM point prompts</text>
<rect class="n acc" x="200" y="160" width="200" height="90" rx="10" />
<text class="t b" x="300" y="185" text-anchor="middle">Lines</text>
<text class="s" x="300" y="207" text-anchor="middle">LineFormer, then split</text>
<text class="s" x="300" y="225" text-anchor="middle">into equal x-segments</text>
<path class="flow" d="M405 85 H445 V145" />
<path class="flow" d="M405 205 H445 V145 H455" />
<rect class="n acc" x="460" y="95" width="130" height="100" rx="10" />
<text class="t b" x="525" y="122" text-anchor="middle">Marked image</text>
<text class="s" x="525" y="144" text-anchor="middle">one label per</text>
<text class="s" x="525" y="162" text-anchor="middle">element (SoM)</text>
<path class="flow" d="M595 145 H635" />
<rect class="n acc" x="640" y="70" width="140" height="150" rx="10" />
<text class="t b" x="710" y="96" text-anchor="middle">MLLM</text>
<text class="s" x="710" y="120" text-anchor="middle">few-shot + CoT</text>
<text class="s" x="710" y="142" text-anchor="middle">1. validate</text>
<text class="s" x="710" y="160" text-anchor="middle">2. attribute</text>
<text class="s" x="710" y="184" text-anchor="middle">lines: pairs of</text>
<text class="s" x="710" y="202" text-anchor="middle">marked points</text>
<path class="flow" d="M785 145 H825" />
<rect class="n ok" x="830" y="105" width="60" height="80" rx="10" />
<text class="t b" x="860" y="140" text-anchor="middle">A</text>
<text class="s" x="860" y="164" text-anchor="middle">labels</text>
<text class="s" x="450" y="285" text-anchor="middle">Scoring: labels map back to masks; bars and sectors match ground truth at IoU &gt;= 0.9 and are scored with P, R, F1</text>

</svg>
<div class="fig-caption">The two stages. Mark generation produces one referable element per bar, sector, or line segment; attribution prompts an MLLM with the marked image and reads back the labels it cites.</div>
</div>

<p><strong>Mark generation.</strong> Bars follow the pipeline in step 2. Pie charts take the largest contour of the binarized image, fit its minimum enclosing circle, unroll the pie along the radial axis into a strip, and detect complete edges in that strip as sector boundaries, which map back to slices. Both then pass through SAM with sampled point prompts (<code class="language-plaintext highlighter-rouge">facebook/sam-vit-large</code>). Lines are thin, overlapping, and intersecting, so contour heuristics do not apply; we use LineFormer, a transformer-based line extractor, and divide each recovered line into equally spaced segments along its horizontal extent so that a point on a line has a mark to refer to.</p>

<p><strong>Attribution prompt.</strong> The prompt has three parts: a description of chart attribution, few-shot textual examples of question-answer pairs with their attributions, and an instruction to reason step by step through validation (is the QA pair consistent with the chart?) and then attribution (which labeled elements support the answer?). For line charts the model returns pairs of marked points between which the attributed span lies; those pairs are treated as bounding-box corners.</p>

<p><strong>Scoring.</strong> For bars and sectors, a predicted region counts as a match if it overlaps a ground-truth region at \(\text{IoU} \geq 0.9\). With \(D\) the matched detections and \(G\) the ground truth,</p>

\[P = \frac{|D \cap G|}{|D|}, \qquad R = \frac{|D \cap G|}{|G|}, \qquad F1 = \frac{2 \cdot P \cdot R}{P + R}.\]

<p>Lines are scored by detection rate (the fraction of ground-truth points covered, a recall-like measure) and by the percentage of the chart’s area the attribution covers, because a method can reach high detection by painting most of the chart.</p>

<p><strong>ChartVA-Eval.</strong> To measure any of this we built a benchmark of 1,244 queries over three subsets. ChartVA-AITQA renders synthetic charts from airline SEC-filing tables (301 queries, 203 bar and 98 line charts, one attribution each). ChartVA-PlotQA uses synthetic scientific charts from World Bank Open Data, Open Government Data, and the Global Terrorism Database (595 queries, 396 bar and 199 line charts, 2.4 attributions on average, up to 12). ChartVA-ChartQA uses real charts from Statista, Pew Research, Our World in Data, and OECD, with pie charts oversampled (348 queries: 121 bar, 109 pie, 118 line). For the last two, GPT-4o drafted attributions from the underlying tables with template-specific prompts, and three annotators verified relevance and completeness with Cohen’s kappa of 0.89 and 0.84.</p>

<h2 id="what-the-numbers-say">What the numbers say</h2>

<p>Baselines are zero-shot GPT-4o bounding-box prompting, Kosmos-2, and LISA; ChartLens uses GPT-4o as its MLLM.</p>

<table>
  <thead>
    <tr>
      <th>Method</th>
      <th>Bar: AITQA F1</th>
      <th>Bar: PlotQA F1</th>
      <th>Bar: ChartQA F1</th>
      <th>Pie: ChartQA F1</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>Zero-shot GPT-4o</td>
      <td>22.77</td>
      <td>3.30</td>
      <td>7.75</td>
      <td>7.17</td>
    </tr>
    <tr>
      <td>Kosmos-2</td>
      <td>0.51</td>
      <td>1.01</td>
      <td>3.13</td>
      <td>11.70</td>
    </tr>
    <tr>
      <td>LISA</td>
      <td>1.62</td>
      <td>0.34</td>
      <td>1.01</td>
      <td>2.41</td>
    </tr>
    <tr>
      <td><strong>ChartLens</strong></td>
      <td><strong>69.28</strong></td>
      <td><strong>34.65</strong></td>
      <td><strong>64.14</strong></td>
      <td><strong>48.56</strong></td>
    </tr>
  </tbody>
</table>

<p>On line charts ChartLens covers 59.14%, 51.84%, and 77.8% of ground-truth points on the three subsets while flagging 1.25%, 9.98%, and 5.34% of the chart area; LISA and Kosmos-2 reach high detection mainly by covering 27% to 63% of the chart, so ChartLens uses 3 to 50 times less area. Across chart types this is a 26-66% improvement in fine-grained attribution over the baselines. Qualitatively, GPT-4o tries to be specific but cannot localize through text coordinates, and LISA and Kosmos-2 return generic components such as the whole pie regardless of the question.</p>

<h2 id="try-it">Try it</h2>

<ul>
  <li>Paper page: <a href="/papers/chartlens/">/papers/chartlens/</a></li>
  <li>arXiv: <a href="https://arxiv.org/abs/2505.19360">2505.19360</a></li>
  <li>Code and the ChartVA-Eval benchmark: <a href="https://github.com/MananSuri27/ChartLens">github.com/MananSuri27/ChartLens</a></li>
  <li>ACL Anthology: <a href="https://aclanthology.org/2025.acl-long.1094/">2025.acl-long.1094</a></li>
</ul>]]></content><author><name></name></author><category term="research" /><category term="charts" /><category term="multimodal" /><category term="attribution" /><category term="hallucination" /><summary type="html"><![CDATA[How ChartLens grounds a multimodal LLM's chart answers to specific bars, points, and sectors, and why that makes hallucinations easy to catch.]]></summary></entry><entry><title type="html">Answering questions over a pile of PDFs: VisDoM explained</title><link href="https://manansuri.com/blog/2026/visdom-explained/" rel="alternate" type="text/html" title="Answering questions over a pile of PDFs: VisDoM explained" /><published>2026-09-02T12:00:00+00:00</published><updated>2026-09-02T12:00:00+00:00</updated><id>https://manansuri.com/blog/2026/visdom-explained</id><content type="html" xml:base="https://manansuri.com/blog/2026/visdom-explained/"><![CDATA[<p>Picture a folder of eight arXiv papers, about 129 pages in all, and one question: “Which model performs best on the Ubuntu dataset for text lengths between 60 and 90 words?” The answer is a single cell in a single table on a single page of one of those papers. Nothing in the running text says it out loud. To get it right you have to find the right paper, find the right page, find the right column, and then read a number off a table.</p>

<p>Most document QA demos skip all of that by starting from one PDF. Our NAACL 2025 paper, <a href="/papers/visdom/">VisDoM</a>, is about the version of the problem that analysts, scientists and lawyers actually have: a question over a collection of documents where the evidence is usually visual. We built a benchmark for it (VisDoMBench) and a retrieval-augmented method that answers it (VisDoMRAG). This post traces that Ubuntu question through VisDoMRAG one stage at a time.</p>

<h2 id="the-idea-in-one-picture">The idea in one picture</h2>

<p>A retrieval-augmented generation (RAG) system for PDFs has to pick a modality. Retrieve text and the model reads tables as flattened OCR strings. Retrieve page images and the model sees the table as pixels, which is better for tables and charts but leaves it alone with whatever it misreads. Our answer is to do both, in two independent pipelines, and then make a language model check the two reasoning chains against each other before committing.</p>

<div class="fig-svg">
<svg viewBox="0 0 900 330" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="Three ways to answer a question over a document collection: text-only RAG, visual-only RAG, and VisDoMRAG, which runs both and fuses them with a consistency check. GPT-4o averages on VisDoMBench: 37.3, 49.0, 50.0.">
  <style>
    .t { fill: currentColor; font-size: 14px; font-family: sans-serif; }
    .h { fill: currentColor; font-size: 15px; font-weight: bold; font-family: sans-serif; }
    .s { fill: currentColor; font-size: 12px; opacity: 0.8; font-family: sans-serif; }
    .in { fill: rgba(38,152,186,0.15); stroke: #2698BA; stroke-width: 1.5; }
    .base { fill: rgba(242,145,5,0.15); stroke: #F29105; stroke-width: 1.5; }
    .acc { fill: rgba(181,9,172,0.15); stroke: #B509AC; stroke-width: 1.5; }
    .ok { fill: rgba(0,171,55,0.15); stroke: #00ab37; stroke-width: 1.5; }
    .box { fill: none; stroke: currentColor; stroke-width: 1.5; opacity: 0.85; }
    .ar { fill: none; stroke: currentColor; stroke-width: 1.5; opacity: 0.7; }
    .aacc { fill: none; stroke: #B509AC; stroke-width: 2; }
  </style>
  <defs>
    <marker id="m" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto-start-reverse"><path d="M0 0 L10 5 L0 10 z" fill="currentColor" opacity="0.7" /></marker>
    <marker id="ma" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto-start-reverse"><path d="M0 0 L10 5 L0 10 z" fill="#B509AC" /></marker>
  </defs>
  <!-- column 1: text RAG -->
  <text class="h" x="150" y="26" text-anchor="middle">Text-only RAG</text>
  <rect class="in" x="60" y="42" width="180" height="44" rx="10" />
  <text class="t" x="150" y="61" text-anchor="middle">PDFs, OCR'd into</text>
  <text class="t" x="150" y="78" text-anchor="middle">text chunks</text>
  <path class="ar" d="M150 86 V108" marker-end="url(#m)" />
  <rect class="base" x="60" y="110" width="180" height="44" rx="10" />
  <text class="t" x="150" y="129" text-anchor="middle">retrieve chunks,</text>
  <text class="t" x="150" y="146" text-anchor="middle">LLM reads text</text>
  <path class="ar" d="M150 154 V176" marker-end="url(#m)" />
  <rect class="box" x="60" y="178" width="180" height="44" rx="10" />
  <text class="t" x="150" y="197" text-anchor="middle">table rows arrive as</text>
  <text class="t" x="150" y="214" text-anchor="middle">flattened OCR text</text>
  <text class="h" x="150" y="262" text-anchor="middle" style="fill:#F29105">37.3</text>
  <text class="s" x="150" y="282" text-anchor="middle">GPT-4o avg. on VisDoMBench</text>

  <!-- column 2: visual RAG -->
  <text class="h" x="450" y="26" text-anchor="middle">Visual-only RAG</text>
  <rect class="in" x="360" y="42" width="180" height="44" rx="10" />
  <text class="t" x="450" y="61" text-anchor="middle">PDFs, rendered as</text>
  <text class="t" x="450" y="78" text-anchor="middle">page images</text>
  <path class="ar" d="M450 86 V108" marker-end="url(#m)" />
  <rect class="base" x="360" y="110" width="180" height="44" rx="10" />
  <text class="t" x="450" y="129" text-anchor="middle">retrieve pages,</text>
  <text class="t" x="450" y="146" text-anchor="middle">LLM looks at pixels</text>
  <path class="ar" d="M450 154 V176" marker-end="url(#m)" />
  <rect class="box" x="360" y="178" width="180" height="44" rx="10" />
  <text class="t" x="450" y="197" text-anchor="middle">sees the table, but</text>
  <text class="t" x="450" y="214" text-anchor="middle">may misread a cell</text>
  <text class="h" x="450" y="262" text-anchor="middle" style="fill:#F29105">49.0</text>
  <text class="s" x="450" y="282" text-anchor="middle">GPT-4o avg. on VisDoMBench</text>

  <!-- column 3: VisDoMRAG -->
  <text class="h" x="750" y="26" text-anchor="middle" style="fill:#B509AC">VisDoMRAG (ours)</text>
  <rect class="in" x="660" y="42" width="180" height="44" rx="10" />
  <text class="t" x="750" y="61" text-anchor="middle">PDFs, as page images</text>
  <text class="t" x="750" y="78" text-anchor="middle">and as text chunks</text>
  <path class="ar" d="M705 86 V108" marker-end="url(#m)" />
  <path class="ar" d="M795 86 V108" marker-end="url(#m)" />
  <rect class="box" x="660" y="110" width="84" height="44" rx="10" />
  <text class="t" x="702" y="129" text-anchor="middle">visual</text>
  <text class="t" x="702" y="146" text-anchor="middle">branch</text>
  <rect class="box" x="756" y="110" width="84" height="44" rx="10" />
  <text class="t" x="798" y="129" text-anchor="middle">textual</text>
  <text class="t" x="798" y="146" text-anchor="middle">branch</text>
  <path class="aacc" d="M702 154 V176" marker-end="url(#ma)" />
  <path class="aacc" d="M798 154 V176" marker-end="url(#ma)" />
  <rect class="acc" x="660" y="178" width="180" height="44" rx="10" />
  <text class="t" x="750" y="197" text-anchor="middle">compare the two</text>
  <text class="t" x="750" y="214" text-anchor="middle">reasoning chains</text>
  <text class="h" x="750" y="262" text-anchor="middle" style="fill:#00ab37">50.0</text>
  <text class="s" x="750" y="282" text-anchor="middle">GPT-4o avg. on VisDoMBench</text>

  <text class="s" x="450" y="316" text-anchor="middle">Long context (every page in the prompt) scores 32.8 with the same model.</text>
</svg>
<div class="fig-caption">Three ways to answer the same question over a document collection. The averages are GPT-4o's end-to-end accuracy across the five VisDoMBench splits (Table 3 of the paper).</div>
</div>

<div class="callout"><span class="callout-label">Key idea</span>Run a visual RAG pipeline and a textual RAG pipeline in parallel, make each one write down its evidence and its reasoning, and let a final LLM call resolve the two chains (consistency-constrained modality fusion). The fusion step is where a number read off a table image gets reconciled with the OCR text that describes it.</div>

<h2 id="walkthrough-one-question-129-pages">Walkthrough: one question, 129 pages</h2>

<div class="walkthrough">
  <div class="wt-title">Walkthrough: "Which model performs best on the Ubuntu dataset for text lengths between 60 and 90 words?"</div>
  <div class="wt-step" data-label="The pile">
    <h4>1. The pile of PDFs and the question</h4>

    <figure>

  <picture>
    
    <source class="responsive-img-srcset" media="(max-width: 480px)" srcset="/assets/img/blog/visdom/fig-intro-480.webp" />
    <source class="responsive-img-srcset" media="(max-width: 800px)" srcset="/assets/img/blog/visdom/fig-intro-800.webp" />
    <source class="responsive-img-srcset" media="(max-width: 1400px)" srcset="/assets/img/blog/visdom/fig-intro-1400.webp" />
    

    <!-- Fallback to the original file -->
    <img src="/assets/img/blog/visdom/fig-intro.png" class="img-fluid rounded z-depth-1" width="auto" height="auto" data-zoomable="" loading="lazy" onerror="this.onerror=null; $('.responsive-img-srcset').remove();" />
  </picture><figcaption class="caption">Figure 1 of the paper. In single-document QA (top) the grounding context is somewhere in the one PDF you were handed. In multi-document QA (bottom) the same question has to be answered from a collection: the KEHNN text-matching paper plus distractor papers on graph matching, time-series clustering, visual grounding, appearance maps and robust loss functions.</figcaption>

</figure>

    <p>This is a PaperTab-style query from VisDoMBench. The document that answers it is “Knowledge Enhanced Hybrid Neural Network for Text Matching” (the KEHNN paper), and the evidence is its Table (b), the Ubuntu dataset results. Every query in the benchmark comes with distractor documents, so the collection spans roughly 50 to 200 pages and the system has to locate the document before it can locate the table.</p>

  </div>
  <div class="wt-step" data-label="Two indexes">
    <h4>2. Index the collection twice, in parallel</h4>

    <div class="fig-svg">
<svg viewBox="0 0 900 300" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="The document collection is indexed twice in parallel: every page as an image with ColQwen2, and every page's OCR text as 3,000-character chunks embedded with BGE-1.5.">
  <style>
    .t { fill: currentColor; font-size: 14px; font-family: sans-serif; }
    .h { fill: currentColor; font-size: 15px; font-weight: bold; font-family: sans-serif; }
    .s { fill: currentColor; font-size: 12px; opacity: 0.8; font-family: sans-serif; }
    .in { fill: rgba(38,152,186,0.15); stroke: #2698BA; stroke-width: 1.5; }
    .pg { fill: rgba(38,152,186,0.10); stroke: #2698BA; stroke-width: 1.2; }
    .box { fill: none; stroke: currentColor; stroke-width: 1.5; opacity: 0.85; }
    .ok { fill: rgba(0,171,55,0.15); stroke: #00ab37; stroke-width: 1.5; }
    .ar { fill: none; stroke: currentColor; stroke-width: 1.5; opacity: 0.7; }
    .ln { stroke: currentColor; stroke-width: 1; opacity: 0.35; }
  </style>
  <defs>
    <marker id="m" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto-start-reverse"><path d="M0 0 L10 5 L0 10 z" fill="currentColor" opacity="0.7" /></marker>
  </defs>
  <!-- collection -->
  <rect class="in" x="20" y="60" width="200" height="180" rx="10" />
  <text class="h" x="120" y="84" text-anchor="middle">Document collection</text>
  <rect class="pg" x="60" y="100" width="60" height="78" rx="4" />
  <rect class="pg" x="72" y="108" width="60" height="78" rx="4" />
  <rect class="pg" x="84" y="116" width="60" height="78" rx="4" />
  <rect class="pg" x="96" y="124" width="60" height="78" rx="4" />
  <text class="s" x="120" y="222" text-anchor="middle">8.4 documents, ~129 pages</text>
  <!-- visual index -->
  <path class="ar" d="M220 120 H300" marker-end="url(#m)" />
  <rect class="box" x="305" y="40" width="270" height="110" rx="10" />
  <text class="h" x="440" y="64" text-anchor="middle">Page images</text>
  <rect class="pg" x="325" y="78" width="40" height="52" rx="3" />
  <rect class="pg" x="372" y="78" width="40" height="52" rx="3" />
  <rect class="pg" x="419" y="78" width="40" height="52" rx="3" />
  <rect class="pg" x="466" y="78" width="40" height="52" rx="3" />
  <text class="t" x="530" y="100" text-anchor="middle">...</text>
  <text class="s" x="440" y="144" text-anchor="middle">one image per page, no OCR</text>
  <path class="ar" d="M575 95 H655" marker-end="url(#m)" />
  <rect class="box" x="660" y="55" width="220" height="80" rx="10" />
  <text class="h" x="770" y="80" text-anchor="middle">Visual index</text>
  <text class="t" x="770" y="100" text-anchor="middle">ColQwen2 embeds</text>
  <text class="t" x="770" y="118" text-anchor="middle">each page image</text>
  <!-- text index -->
  <path class="ar" d="M220 180 H300" marker-end="url(#m)" />
  <rect class="box" x="305" y="165" width="270" height="110" rx="10" />
  <text class="h" x="440" y="189" text-anchor="middle">OCR text chunks</text>
  <rect class="pg" x="325" y="200" width="70" height="40" rx="4" />
  <line class="ln" x1="332" y1="212" x2="388" y2="212" /><line class="ln" x1="332" y1="220" x2="388" y2="220" /><line class="ln" x1="332" y1="228" x2="372" y2="228" />
  <rect class="pg" x="402" y="200" width="70" height="40" rx="4" />
  <line class="ln" x1="409" y1="212" x2="465" y2="212" /><line class="ln" x1="409" y1="220" x2="465" y2="220" /><line class="ln" x1="409" y1="228" x2="449" y2="228" />
  <rect class="pg" x="479" y="200" width="70" height="40" rx="4" />
  <line class="ln" x1="486" y1="212" x2="542" y2="212" /><line class="ln" x1="486" y1="220" x2="542" y2="220" /><line class="ln" x1="486" y1="228" x2="526" y2="228" />
  <text class="s" x="440" y="262" text-anchor="middle">PyTesseract, 3,000 chars, 10% overlap</text>
  <path class="ar" d="M575 220 H655" marker-end="url(#m)" />
  <rect class="box" x="660" y="180" width="220" height="80" rx="10" />
  <text class="h" x="770" y="205" text-anchor="middle">Text index</text>
  <text class="t" x="770" y="225" text-anchor="middle">BGE-1.5 embeds each chunk</text>
  <text class="s" x="770" y="245" text-anchor="middle">keeps doc + page metadata</text>
</svg>
</div>

    <p>VisDoMRAG never chooses between pixels and text; it builds both indexes. The visual branch renders every page as an image and embeds it with a late-interaction visual retriever (ColQwen2; ColPali also works), with no OCR at all. The textual branch OCRs every page with PyTesseract, splits the text into 3,000-character chunks with 10% overlap (recursive split), keeps the source document and page number as metadata, and embeds each chunk with BGE-1.5. Both indexes are built once per collection.</p>

  </div>
  <div class="wt-step" data-label="Retrieve">
    <h4>3. Retrieve in both modalities</h4>

    <div class="fig-svg">
<svg viewBox="0 0 900 330" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="The query is run against both indexes. The visual retriever returns the top-5 pages, one of which is the page of the KEHNN paper that holds the Ubuntu table. The text retriever returns the top-7 chunks, one of which is the OCR of that table. Distractor pages from other papers are also returned.">
  <style>
    .t { fill: currentColor; font-size: 14px; font-family: sans-serif; }
    .h { fill: currentColor; font-size: 15px; font-weight: bold; font-family: sans-serif; }
    .s { fill: currentColor; font-size: 12px; opacity: 0.8; font-family: sans-serif; }
    .xs { fill: currentColor; font-size: 11px; opacity: 0.75; font-family: sans-serif; }
    .in { fill: rgba(38,152,186,0.15); stroke: #2698BA; stroke-width: 1.5; }
    .gr { fill: none; stroke: currentColor; stroke-width: 1.2; opacity: 0.5; }
    .ok { fill: rgba(0,171,55,0.15); stroke: #00ab37; stroke-width: 1.8; }
    .box { fill: none; stroke: currentColor; stroke-width: 1.5; opacity: 0.85; }
    .ar { fill: none; stroke: currentColor; stroke-width: 1.5; opacity: 0.7; }
  </style>
  <defs>
    <marker id="m" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto-start-reverse"><path d="M0 0 L10 5 L0 10 z" fill="currentColor" opacity="0.7" /></marker>
  </defs>
  <!-- query -->
  <rect class="in" x="20" y="105" width="190" height="120" rx="10" />
  <text class="h" x="115" y="130" text-anchor="middle">Query</text>
  <text class="t" x="115" y="152" text-anchor="middle">Which model performs</text>
  <text class="t" x="115" y="170" text-anchor="middle">best on the Ubuntu dataset</text>
  <text class="t" x="115" y="188" text-anchor="middle">for text lengths between</text>
  <text class="t" x="115" y="206" text-anchor="middle">60 and 90 words?</text>
  <path class="ar" d="M210 145 H250 V90 H285" marker-end="url(#m)" />
  <path class="ar" d="M210 185 H250 V240 H285" marker-end="url(#m)" />
  <!-- visual retrieval -->
  <rect class="box" x="290" y="20" width="590" height="135" rx="10" />
  <text class="h" x="305" y="42">Visual retriever (ColQwen2), top-5 pages</text>
  <rect class="ok" x="305" y="55" width="92" height="82" rx="6" />
  <text class="xs" x="351" y="76" text-anchor="middle">KEHNN paper,</text>
  <text class="xs" x="351" y="91" text-anchor="middle">page with</text>
  <text class="xs" x="351" y="106" text-anchor="middle">Table (b)</text>
  <text class="xs" x="351" y="121" text-anchor="middle" style="fill:#00ab37;opacity:1">Ubuntu dataset</text>
  <rect class="gr" x="410" y="55" width="92" height="82" rx="6" />
  <text class="xs" x="456" y="88" text-anchor="middle">KEHNN paper,</text>
  <text class="xs" x="456" y="103" text-anchor="middle">another page</text>
  <rect class="gr" x="515" y="55" width="92" height="82" rx="6" />
  <text class="xs" x="561" y="81" text-anchor="middle">Time Series</text>
  <text class="xs" x="561" y="96" text-anchor="middle">Clustering</text>
  <text class="xs" x="561" y="111" text-anchor="middle">paper</text>
  <rect class="gr" x="620" y="55" width="92" height="82" rx="6" />
  <text class="xs" x="666" y="81" text-anchor="middle">Graph</text>
  <text class="xs" x="666" y="96" text-anchor="middle">Matching</text>
  <text class="xs" x="666" y="111" text-anchor="middle">paper</text>
  <rect class="gr" x="725" y="55" width="92" height="82" rx="6" />
  <text class="xs" x="771" y="81" text-anchor="middle">Deep</text>
  <text class="xs" x="771" y="96" text-anchor="middle">Appearance</text>
  <text class="xs" x="771" y="111" text-anchor="middle">Maps paper</text>
  <text class="t" x="850" y="100" text-anchor="middle">k=5</text>
  <!-- textual retrieval -->
  <rect class="box" x="290" y="170" width="590" height="140" rx="10" />
  <text class="h" x="305" y="192">Text retriever (BGE-1.5), top-7 chunks</text>
  <rect class="ok" x="305" y="205" width="150" height="48" rx="6" />
  <text class="xs" x="380" y="225" text-anchor="middle">OCR of Table (b):</text>
  <text class="xs" x="380" y="240" text-anchor="middle" style="fill:#00ab37;opacity:1">"KEHNN 0.724 0.774 0.785 ..."</text>
  <rect class="gr" x="465" y="205" width="150" height="48" rx="6" />
  <text class="xs" x="540" y="225" text-anchor="middle">KEHNN paper,</text>
  <text class="xs" x="540" y="240" text-anchor="middle">experiments section</text>
  <rect class="gr" x="625" y="205" width="150" height="48" rx="6" />
  <text class="xs" x="700" y="225" text-anchor="middle">Robust Loss</text>
  <text class="xs" x="700" y="240" text-anchor="middle">Function paper</text>
  <rect class="gr" x="305" y="260" width="150" height="40" rx="6" />
  <text class="xs" x="380" y="284" text-anchor="middle">Visual Grounding paper</text>
  <rect class="gr" x="465" y="260" width="150" height="40" rx="6" />
  <text class="xs" x="540" y="284" text-anchor="middle">Time Series paper</text>
  <rect class="gr" x="625" y="260" width="150" height="40" rx="6" />
  <text class="xs" x="700" y="284" text-anchor="middle">... two more chunks</text>
  <text class="t" x="850" y="255" text-anchor="middle">k=7</text>
</svg>
</div>

    <p>The same question is scored against both indexes. The visual retriever returns the top-5 pages, and the page of the KEHNN paper that carries Table (b) is among them; so are pages from distractor papers, which is what makes the later curation step necessary. The textual retriever returns the top-7 chunks, one of which is the OCR of that same table, flattened into a string like “KEHNN 0.724 0.774 0.785 0.791”. We picked k=5 and k=7 as the smallest windows whose retrieved evidence overlapped the ground-truth evidence at ANLCS 0.7 or better (Figure 3 of the paper).</p>

  </div>
  <div class="wt-step" data-label="Curate + reason">
    <h4>4. Each branch curates evidence, reasons, then answers</h4>

    <div class="fig-svg">
<svg viewBox="0 0 900 340" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="Each branch runs the same three prompts: evidence curation, chain-of-thought reasoning, answer. The visual branch reads the [60,90) column off the page image; the textual branch reads it from the OCR chunk. Both reach KEHNN.">
  <style>
    .t { fill: currentColor; font-size: 13px; font-family: sans-serif; }
    .h { fill: currentColor; font-size: 15px; font-weight: bold; font-family: sans-serif; }
    .s { fill: currentColor; font-size: 12px; opacity: 0.8; font-family: sans-serif; }
    .in { fill: rgba(38,152,186,0.15); stroke: #2698BA; stroke-width: 1.5; }
    .ok { fill: rgba(0,171,55,0.15); stroke: #00ab37; stroke-width: 1.5; }
    .box { fill: none; stroke: currentColor; stroke-width: 1.5; opacity: 0.85; }
    .ar { fill: none; stroke: currentColor; stroke-width: 1.5; opacity: 0.7; }
    .lane { fill: none; stroke: currentColor; stroke-width: 1; opacity: 0.25; stroke-dasharray: 4 4; }
  </style>
  <defs>
    <marker id="m" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto-start-reverse"><path d="M0 0 L10 5 L0 10 z" fill="currentColor" opacity="0.7" /></marker>
  </defs>
  <!-- headers -->
  <text class="h" x="290" y="24" text-anchor="middle">1. Evidence curation</text>
  <text class="h" x="565" y="24" text-anchor="middle">2. Chain of thought</text>
  <text class="h" x="810" y="24" text-anchor="middle">3. Answer</text>
  <!-- visual lane -->
  <rect class="lane" x="10" y="36" width="880" height="140" rx="10" />
  <rect class="in" x="20" y="70" width="120" height="70" rx="10" />
  <text class="t" x="80" y="96" text-anchor="middle">Visual branch</text>
  <text class="s" x="80" y="114" text-anchor="middle">5 page images</text>
  <path class="ar" d="M140 105 H165" marker-end="url(#m)" />
  <rect class="box" x="170" y="48" width="240" height="116" rx="10" />
  <text class="t" x="290" y="70" text-anchor="middle">Table (b) Ubuntu, column [60, 90):</text>
  <text class="t" x="290" y="90" text-anchor="middle">LSTM 0.732</text>
  <text class="t" x="290" y="108" text-anchor="middle">MV-LSTM 0.725</text>
  <text class="t" x="290" y="126" text-anchor="middle">KEHNN 0.785</text>
  <text class="s" x="290" y="150" text-anchor="middle">read off the page image</text>
  <path class="ar" d="M410 105 H435" marker-end="url(#m)" />
  <rect class="box" x="440" y="48" width="250" height="116" rx="10" />
  <text class="t" x="565" y="72" text-anchor="middle">"60 to 90 words" is the</text>
  <text class="t" x="565" y="90" text-anchor="middle">[60, 90) column. The largest</text>
  <text class="t" x="565" y="108" text-anchor="middle">value in it is 0.785, which</text>
  <text class="t" x="565" y="126" text-anchor="middle">belongs to the KEHNN row.</text>
  <path class="ar" d="M690 105 H715" marker-end="url(#m)" />
  <rect class="ok" x="720" y="80" width="160" height="50" rx="10" />
  <text class="h" x="800" y="111" text-anchor="middle" style="fill:#00ab37">KEHNN</text>
  <!-- textual lane -->
  <rect class="lane" x="10" y="188" width="880" height="140" rx="10" />
  <rect class="in" x="20" y="222" width="120" height="70" rx="10" />
  <text class="t" x="80" y="248" text-anchor="middle">Textual branch</text>
  <text class="s" x="80" y="266" text-anchor="middle">7 text chunks</text>
  <path class="ar" d="M140 257 H165" marker-end="url(#m)" />
  <rect class="box" x="170" y="200" width="240" height="116" rx="10" />
  <text class="t" x="290" y="222" text-anchor="middle">From the OCR chunk, the row</text>
  <text class="t" x="290" y="240" text-anchor="middle">"KEHNN 0.724 0.774 0.785 0.791"</text>
  <text class="t" x="290" y="258" text-anchor="middle">under header "[0,30) [30,60)</text>
  <text class="t" x="290" y="276" text-anchor="middle">[60,90) [90,inf)"</text>
  <text class="s" x="290" y="302" text-anchor="middle">verbalized from flattened text</text>
  <path class="ar" d="M410 257 H435" marker-end="url(#m)" />
  <rect class="box" x="440" y="200" width="250" height="116" rx="10" />
  <text class="t" x="565" y="224" text-anchor="middle">Third column is [60, 90).</text>
  <text class="t" x="565" y="242" text-anchor="middle">Third values: LSTM 0.732,</text>
  <text class="t" x="565" y="260" text-anchor="middle">MV-LSTM 0.725, KEHNN 0.785.</text>
  <text class="t" x="565" y="278" text-anchor="middle">KEHNN is highest.</text>
  <path class="ar" d="M690 257 H715" marker-end="url(#m)" />
  <rect class="ok" x="720" y="232" width="160" height="50" rx="10" />
  <text class="h" x="800" y="263" text-anchor="middle" style="fill:#00ab37">KEHNN</text>
</svg>
</div>

    <p>Neither branch is asked for an answer straight away. The LLM is prompted three times: first to pull out and write down the evidence that matters (evidence curation), then to link that evidence into an explicit argument (chain-of-thought reasoning), and only then to answer in the format the question type calls for (answer generation). In the visual branch the multimodal LLM reads the [60, 90) column off the page image: LSTM 0.732, MV-LSTM 0.725, KEHNN 0.785. In the textual branch the LLM has to recover the same column from the flattened OCR row. Curation is what stops the distractor pages and chunks from leaking into the reasoning.</p>

  </div>
  <div class="wt-step" data-label="Fuse">
    <h4>5. Fuse the two chains with a consistency check</h4>

    <div class="fig-svg">
<svg viewBox="0 0 900 360" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="Modality fusion takes the curated evidence, reasoning chain and answer from both branches. If the chains agree, as for the Ubuntu question (KEHNN and KEHNN), the answer is confirmed. If they conflict, as in the PaperTab StackEx example (57.5% versus 298k), the LLM re-examines the evidence and reconciles to 330k.">
  <style>
    .t { fill: currentColor; font-size: 13px; font-family: sans-serif; }
    .h { fill: currentColor; font-size: 15px; font-weight: bold; font-family: sans-serif; }
    .s { fill: currentColor; font-size: 12px; opacity: 0.8; font-family: sans-serif; }
    .acc { fill: rgba(181,9,172,0.15); stroke: #B509AC; stroke-width: 2; }
    .ok { fill: rgba(0,171,55,0.15); stroke: #00ab37; stroke-width: 1.5; }
    .bad { fill: rgba(242,145,5,0.15); stroke: #F29105; stroke-width: 1.5; }
    .box { fill: none; stroke: currentColor; stroke-width: 1.5; opacity: 0.85; }
    .ar { fill: none; stroke: currentColor; stroke-width: 1.5; opacity: 0.7; }
    .aacc { fill: none; stroke: #B509AC; stroke-width: 2; stroke-dasharray: 6 6; animation: dash 1.2s linear infinite; }
    @keyframes dash { to { stroke-dashoffset: -24; } }
  </style>
  <defs>
    <marker id="m" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto-start-reverse"><path d="M0 0 L10 5 L0 10 z" fill="currentColor" opacity="0.7" /></marker>
    <marker id="ma" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto-start-reverse"><path d="M0 0 L10 5 L0 10 z" fill="#B509AC" /></marker>
  </defs>
  <!-- inputs -->
  <rect class="box" x="20" y="60" width="180" height="90" rx="10" />
  <text class="h" x="110" y="84" text-anchor="middle">Visual branch</text>
  <text class="s" x="110" y="104" text-anchor="middle">evidence + chain</text>
  <text class="s" x="110" y="122" text-anchor="middle">+ answer</text>
  <rect class="box" x="20" y="210" width="180" height="90" rx="10" />
  <text class="h" x="110" y="234" text-anchor="middle">Textual branch</text>
  <text class="s" x="110" y="254" text-anchor="middle">evidence + chain</text>
  <text class="s" x="110" y="272" text-anchor="middle">+ answer</text>
  <path class="aacc" d="M200 105 H240 V150 H265" marker-end="url(#ma)" />
  <path class="aacc" d="M200 255 H240 V210 H265" marker-end="url(#ma)" />
  <!-- fusion -->
  <rect class="acc" x="270" y="110" width="200" height="140" rx="10" />
  <text class="h" x="370" y="138" text-anchor="middle" style="fill:#B509AC">Modality fusion</text>
  <text class="t" x="370" y="162" text-anchor="middle">Are the two reasoning</text>
  <text class="t" x="370" y="180" text-anchor="middle">chains consistent?</text>
  <text class="s" x="370" y="206" text-anchor="middle">one LLM call over both</text>
  <text class="s" x="370" y="224" text-anchor="middle">branches' outputs</text>
  <path class="ar" d="M470 150 H510 V80 H535" marker-end="url(#m)" />
  <path class="ar" d="M470 210 H510 V280 H535" marker-end="url(#m)" />
  <!-- agree -->
  <rect class="ok" x="540" y="30" width="340" height="110" rx="10" />
  <text class="h" x="710" y="54" text-anchor="middle" style="fill:#00ab37">Chains agree: confirm</text>
  <text class="t" x="710" y="78" text-anchor="middle">Ubuntu question: KEHNN vs KEHNN,</text>
  <text class="t" x="710" y="96" text-anchor="middle">same cell, same column, same row</text>
  <text class="s" x="710" y="122" text-anchor="middle">final answer: KEHNN</text>
  <!-- disagree -->
  <rect class="bad" x="540" y="220" width="340" height="120" rx="10" />
  <text class="h" x="710" y="244" text-anchor="middle" style="fill:#F29105">Chains conflict: re-examine</text>
  <text class="t" x="710" y="268" text-anchor="middle">StackEx question: visual says 57.5%,</text>
  <text class="t" x="710" y="286" text-anchor="middle">textual says 298k (the train split only)</text>
  <text class="t" x="710" y="304" text-anchor="middle">Fusion re-reads the table and sums</text>
  <text class="s" x="710" y="326" text-anchor="middle">final answer: 330k (train + valid + test)</text>
</svg>
</div>

    <p>A final LLM call receives the curated evidence, the reasoning chain and the answer from both branches and is asked whether the two chains are consistent (modality fusion). When they agree, as they do for the Ubuntu question, the answer is confirmed with two independent lines of evidence behind it. When they conflict, the LLM has to re-examine the evidence and reconcile the difference. The paper’s PaperTab example (Figure 5) shows the conflict case: asked for the size of the StackEx keyphrase dataset, visual RAG latched onto 57.5% and textual RAG onto 298k, both wrong numbers from the right table; the fused reasoning summed the splits and answered around 330k.</p>

  </div>
  <div class="wt-step" data-label="Answer">
    <h4>6. The final answer, with its evidence attached</h4>

    <div class="tok-row"><span class="tok tok-b">query</span><span class="tok-arrow">→</span><span class="tok tok-b">5 pages</span><span class="tok tok-b">7 chunks</span><span class="tok-arrow">→</span><span class="tok tok-a">visual chain</span><span class="tok tok-a">textual chain</span><span class="tok-arrow">→</span><span class="tok tok-a">fusion</span><span class="tok-arrow">→</span><span class="tok tok-c tok-hl">KEHNN</span></div>

    <p>The curated evidence survives to the output, so the answer arrives with the table it came from. Rebuilt from Table (b) of the KEHNN paper (the grounding context in Figure 1):</p>

    <table>
      <thead>
        <tr>
          <th>Length</th>
          <th>[0, 30)</th>
          <th>[30, 60)</th>
          <th><strong>[60, 90)</strong></th>
          <th>[90, ∞)</th>
        </tr>
      </thead>
      <tbody>
        <tr>
          <td>#Pair</td>
          <td>253578</td>
          <td>207772</td>
          <td>33618</td>
          <td>5032</td>
        </tr>
        <tr>
          <td>LSTM</td>
          <td>0.707</td>
          <td>0.748</td>
          <td>0.732</td>
          <td>0.718</td>
        </tr>
        <tr>
          <td>MV-LSTM</td>
          <td>0.726</td>
          <td>0.752</td>
          <td>0.725</td>
          <td>0.694</td>
        </tr>
        <tr>
          <td><strong>KEHNN</strong></td>
          <td>0.724</td>
          <td>0.774</td>
          <td><strong>0.785</strong></td>
          <td>0.791</td>
        </tr>
      </tbody>
    </table>

    <p>Final answer: <strong>KEHNN</strong>. Because both branches wrote their evidence down before answering, the response is verifiable: you can point at the cell that produced it (the paper calls this implicit context attribution).</p>

  </div>
</div>

<h2 id="under-the-hood">Under the hood</h2>

<table class="notation">
  <thead>
    <tr>
      <th>Symbol</th>
      <th>Meaning</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>\(q\)</td>
      <td>the query</td>
    </tr>
    <tr>
      <td>\(\mathcal{D} = \{d_1, \dots, d_n\}\)</td>
      <td>the document collection for that query (8.4 documents, 129 pages on average)</td>
    </tr>
    <tr>
      <td>\(\mathcal{P}\), \(\mathcal{C}\)</td>
      <td>all page images, and all OCR text chunks, of \(\mathcal{D}\)</td>
    </tr>
    <tr>
      <td>\(\mathbf{q}_i\), \(\mathbf{p}_j\)</td>
      <td>token-level embeddings of the query and of a page image (late-interaction retriever)</td>
    </tr>
    <tr>
      <td>\(k_v = 5\), \(k_t = 7\)</td>
      <td>number of retrieved pages and chunks</td>
    </tr>
    <tr>
      <td>\(E_m, R_m, a_m\)</td>
      <td>curated evidence, reasoning chain and answer of branch \(m \in \{v, t\}\)</td>
    </tr>
    <tr>
      <td>\(f_\theta\)</td>
      <td>the (frozen) LLM, prompted differently at each step</td>
    </tr>
  </tbody>
</table>

<p><strong>Retrieval.</strong> The visual retriever scores a page by late interaction (MaxSim): every query token embedding is matched to its most similar patch embedding of the page, and the similarities are summed.</p>

\[s(q, p) = \sum_{i=1}^{|q|} \max_{j} \, \langle \mathbf{q}_i, \mathbf{p}_j \rangle, \qquad
\mathcal{P}_q = \operatorname{top\text{-}k_v}_{p \in \mathcal{P}} s(q, p)\]

<p>The textual retriever is a dense bi-encoder (BGE-1.5), scoring chunks by embedding similarity to give \(\mathcal{C}_q\), the top-\(k_t\) chunks. We benchmarked BM25, MiniLM, MPNet and BGE-1.5 on the text side and ColPali and ColQwen2 on the visual side; ColQwen2 and BGE-1.5 were the best of each and are what the end-to-end numbers use.</p>

<p><strong>Per-branch prompting.</strong> Each branch is the same three-call chain, applied to its own context:</p>

\[E_m = f_\theta(q, \mathcal{X}_m;\ \text{curate}), \quad
R_m = f_\theta(q, E_m;\ \text{reason}), \quad
a_m = f_\theta(q, E_m, R_m;\ \text{answer}), \qquad \mathcal{X}_v = \mathcal{P}_q,\ \mathcal{X}_t = \mathcal{C}_q\]

<p>The curation call is the one that does the multi-document work: it must isolate the paragraphs, table rows or figure details that bear on \(q\) and verbalize them in a structured form, dropping the distractor content that retrieval let through.</p>

<p><strong>Consistency-constrained fusion.</strong> The final answer is one more LLM call over everything both branches produced, with the instruction to judge whether \(R_v\) and \(R_t\) are consistent and to reconcile them if not:</p>

\[a = f_\theta\big(q, (E_v, R_v, a_v), (E_t, R_t, a_t);\ \text{consistency}\big)\]

<p>This is a late-fusion design, in the spirit of self-consistency over chains of thought: each modality is processed independently and the chains are compared afterwards. The early-fusion alternative, appending the OCR text of the visually retrieved pages to the image prompt, scores 43.63 average with GPT-4o against 50.01 for late fusion.</p>

<div class="fig-svg">
<svg viewBox="0 0 900 340" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="VisDoMRAG architecture: query and document collection go to a visual RAG branch (ColQwen2, top-5 pages, multimodal LLM) and a textual RAG branch (PyTesseract OCR, 3,000-character chunks, BGE-1.5, top-7 chunks, LLM). Each branch runs evidence curation, chain-of-thought and answer prompts. Modality fusion checks consistency and emits the final answer.">
  <style>
    .t { fill: currentColor; font-size: 13px; font-family: sans-serif; }
    .h { fill: currentColor; font-size: 15px; font-weight: bold; font-family: sans-serif; }
    .s { fill: currentColor; font-size: 12px; opacity: 0.8; font-family: sans-serif; }
    .in { fill: rgba(38,152,186,0.15); stroke: #2698BA; stroke-width: 1.5; }
    .acc { fill: rgba(181,9,172,0.15); stroke: #B509AC; stroke-width: 2; }
    .ok { fill: rgba(0,171,55,0.15); stroke: #00ab37; stroke-width: 1.5; }
    .box { fill: none; stroke: currentColor; stroke-width: 1.5; opacity: 0.85; }
    .flow { fill: none; stroke: currentColor; stroke-width: 1.5; opacity: 0.7; stroke-dasharray: 6 6; animation: dash 1.4s linear infinite; }
    .facc { fill: none; stroke: #B509AC; stroke-width: 2; stroke-dasharray: 6 6; animation: dash 1.4s linear infinite; }
    @keyframes dash { to { stroke-dashoffset: -24; } }
  </style>
  <defs>
    <marker id="m" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto-start-reverse"><path d="M0 0 L10 5 L0 10 z" fill="currentColor" opacity="0.7" /></marker>
    <marker id="ma" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto-start-reverse"><path d="M0 0 L10 5 L0 10 z" fill="#B509AC" /></marker>
  </defs>
  <!-- input -->
  <rect class="in" x="15" y="120" width="130" height="100" rx="10" />
  <text class="h" x="80" y="146" text-anchor="middle">q, D</text>
  <text class="s" x="80" y="168" text-anchor="middle">query and</text>
  <text class="s" x="80" y="184" text-anchor="middle">collection of</text>
  <text class="s" x="80" y="200" text-anchor="middle">n documents</text>
  <path class="flow" d="M145 150 H180 V95 H205" marker-end="url(#m)" />
  <path class="flow" d="M145 190 H180 V245 H205" marker-end="url(#m)" />
  <!-- visual branch -->
  <rect class="box" x="210" y="20" width="440" height="140" rx="10" />
  <text class="h" x="225" y="42">Visual RAG branch</text>
  <rect class="box" x="225" y="55" width="130" height="90" rx="10" />
  <text class="t" x="290" y="78" text-anchor="middle">page images</text>
  <text class="t" x="290" y="96" text-anchor="middle">ColQwen2</text>
  <text class="s" x="290" y="116" text-anchor="middle">late interaction</text>
  <text class="s" x="290" y="132" text-anchor="middle">top k=5 pages</text>
  <path class="flow" d="M355 100 H380" marker-end="url(#m)" />
  <rect class="box" x="385" y="55" width="250" height="90" rx="10" />
  <text class="t" x="510" y="78" text-anchor="middle">multimodal LLM, three prompts</text>
  <text class="s" x="510" y="100" text-anchor="middle">curate evidence E_v</text>
  <text class="s" x="510" y="116" text-anchor="middle">reason step by step R_v</text>
  <text class="s" x="510" y="132" text-anchor="middle">answer a_v</text>
  <!-- textual branch -->
  <rect class="box" x="210" y="180" width="440" height="140" rx="10" />
  <text class="h" x="225" y="202">Textual RAG branch</text>
  <rect class="box" x="225" y="215" width="130" height="90" rx="10" />
  <text class="t" x="290" y="238" text-anchor="middle">OCR + chunks</text>
  <text class="t" x="290" y="256" text-anchor="middle">BGE-1.5</text>
  <text class="s" x="290" y="276" text-anchor="middle">3,000-char chunks</text>
  <text class="s" x="290" y="292" text-anchor="middle">top k=7 chunks</text>
  <path class="flow" d="M355 260 H380" marker-end="url(#m)" />
  <rect class="box" x="385" y="215" width="250" height="90" rx="10" />
  <text class="t" x="510" y="238" text-anchor="middle">LLM, same three prompts</text>
  <text class="s" x="510" y="260" text-anchor="middle">curate evidence E_t</text>
  <text class="s" x="510" y="276" text-anchor="middle">reason step by step R_t</text>
  <text class="s" x="510" y="292" text-anchor="middle">answer a_t</text>
  <!-- fusion -->
  <path class="facc" d="M650 100 H680 V140 H700" marker-end="url(#ma)" />
  <path class="facc" d="M650 260 H680 V200 H700" marker-end="url(#ma)" />
  <rect class="acc" x="705" y="110" width="120" height="120" rx="10" />
  <text class="h" x="765" y="138" text-anchor="middle" style="fill:#B509AC">Fusion</text>
  <text class="s" x="765" y="160" text-anchor="middle">consistency</text>
  <text class="s" x="765" y="176" text-anchor="middle">check on</text>
  <text class="s" x="765" y="192" text-anchor="middle">R_v vs R_t</text>
  <text class="s" x="765" y="214" text-anchor="middle">late fusion</text>
  <path class="facc" d="M825 170 H845" marker-end="url(#ma)" />
  <rect class="ok" x="850" y="145" width="40" height="50" rx="10" />
  <text class="h" x="870" y="176" text-anchor="middle" style="fill:#00ab37">a</text>
</svg>
<div class="fig-caption">VisDoMRAG end to end. Grey boxes are retrieval and prompting steps shared with unimodal RAG; the fusion step is the contribution. Retrieval is bounded (5 pages and 7 chunks), so the context handed to the LLM does not grow with the size of the collection.</div>
</div>

<p><strong>The benchmark.</strong> VisDoMBench re-purposes five datasets that have public source documents and grounded evidence, de-duplicates questions across splits, drops trivial ones, and augments each question with distractor documents. Ambiguous questions (common in PaperTab and SciGraphQA) are rewritten by GPT-4o into more specific variants and a human annotator picks one, keeps the original, or discards the item, so that exactly one document answers each question.</p>

<table>
  <thead>
    <tr>
      <th>Split</th>
      <th>Content</th>
      <th>Queries</th>
      <th>Docs</th>
      <th>Avg. docs / query</th>
      <th>Avg. pages / query</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>PaperTab</td>
      <td>tables, text (scientific papers)</td>
      <td>377</td>
      <td>297</td>
      <td>10.82</td>
      <td>113.10</td>
    </tr>
    <tr>
      <td>FetaTab</td>
      <td>tables (Wikipedia)</td>
      <td>350</td>
      <td>300</td>
      <td>7.77</td>
      <td>124.33</td>
    </tr>
    <tr>
      <td>SciGraphQA</td>
      <td>charts (scientific papers)</td>
      <td>407</td>
      <td>319</td>
      <td>5.91</td>
      <td>129.71</td>
    </tr>
    <tr>
      <td>SPIQA</td>
      <td>tables, charts (scientific papers)</td>
      <td>586</td>
      <td>117</td>
      <td>9.51</td>
      <td>135.58</td>
    </tr>
    <tr>
      <td>SlideVQA</td>
      <td>slides (presentation decks)</td>
      <td>551</td>
      <td>244</td>
      <td>6.99</td>
      <td>139.71</td>
    </tr>
    <tr>
      <td><strong>VisDoMBench</strong></td>
      <td>tables, charts, slides, text</td>
      <td><strong>2,271</strong></td>
      <td><strong>1,277</strong></td>
      <td><strong>8.36</strong></td>
      <td><strong>128.69</strong></td>
    </tr>
  </tbody>
</table>

<p>Answers are scored with word-overlap F1 (the UDA variant for PaperTab, which handles binary and short-text answers). A retriever is credited with identifying the source document when at least \(\lceil k/2 \rceil\) of its top-\(k\) results come from the ground-truth document.</p>

<h2 id="what-the-numbers-say">What the numbers say</h2>

<table>
  <thead>
    <tr>
      <th>Method</th>
      <th>LLM</th>
      <th>PaperTab</th>
      <th>FetaTab</th>
      <th>SciGraphQA</th>
      <th>SPIQA</th>
      <th>SlideVQA</th>
      <th>Average</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>Long context</td>
      <td>GPT-4o</td>
      <td>28.37</td>
      <td>60.03</td>
      <td>24.12</td>
      <td>36.30</td>
      <td>15.06</td>
      <td>32.78</td>
    </tr>
    <tr>
      <td>Text RAG</td>
      <td>GPT-4o</td>
      <td>37.34</td>
      <td>60.82</td>
      <td>29.74</td>
      <td>42.80</td>
      <td>15.97</td>
      <td>37.33</td>
    </tr>
    <tr>
      <td>Visual RAG</td>
      <td>GPT-4o</td>
      <td>42.01</td>
      <td>61.89</td>
      <td>31.12</td>
      <td>43.28</td>
      <td>66.82</td>
      <td>49.02</td>
    </tr>
    <tr>
      <td><strong>VisDoMRAG</strong></td>
      <td>GPT-4o</td>
      <td><strong>44.11</strong></td>
      <td><strong>63.28</strong></td>
      <td><strong>31.36</strong></td>
      <td><strong>44.09</strong></td>
      <td><strong>67.22</strong></td>
      <td><strong>50.01</strong></td>
    </tr>
    <tr>
      <td>Long context</td>
      <td>Qwen2-VL-7B</td>
      <td>8.23</td>
      <td>23.10</td>
      <td>16.74</td>
      <td>9.93</td>
      <td>2.46</td>
      <td>12.09</td>
    </tr>
    <tr>
      <td><strong>VisDoMRAG</strong></td>
      <td>Qwen2-VL-7B</td>
      <td><strong>29.89</strong></td>
      <td><strong>59.24</strong></td>
      <td><strong>27.98</strong></td>
      <td><strong>42.80</strong></td>
      <td><strong>39.77</strong></td>
      <td><strong>39.94</strong></td>
    </tr>
  </tbody>
</table>

<p>Across the benchmark VisDoMRAG improves end-to-end QA over long-context, text-only and visual-only baselines by 12-20%, for every LLM we tried (GPT-4o, Gemini 1.5 Flash, Qwen2-VL-7B). The biggest jump is for the small open model: Qwen2-VL goes from 12.09 with the whole collection in context to 39.94 with VisDoMRAG. On the retrieval side, ColQwen2 finds the right source document 96.94% of the time at k=5 versus 92.40% for BGE-1.5, and on SlideVQA the dense text retrievers collapse below 1% because slides carry almost no running text. Removing evidence curation, chain-of-thought and consistency prompting drops VisDoMRAG from 50.01 to 45.98.</p>

<h2 id="try-it">Try it</h2>

<ul>
  <li>Paper page on this site: <a href="/papers/visdom/">/papers/visdom/</a></li>
  <li>arXiv: <a href="https://arxiv.org/abs/2412.10704">2412.10704</a>; ACL Anthology: <a href="https://aclanthology.org/2025.naacl-long.310/">2025.naacl-long.310</a></li>
  <li>Code and the VisDoMBench splits: <a href="https://github.com/MananSuri27/VisDoM">github.com/MananSuri27/VisDoM</a></li>
</ul>]]></content><author><name></name></author><category term="research" /><category term="rag" /><category term="multimodal" /><category term="documents" /><category term="benchmarks" /><summary type="html"><![CDATA[Why multi-document QA breaks when the answer is in a table or a chart, and how VisDoMRAG fuses visual and textual retrieval to fix it.]]></summary></entry><entry><title type="html">A Dummy’s Guide to Word2Vec</title><link href="https://manansuri.com/blog/2022/a-dummys-guide-to-word2vec/" rel="alternate" type="text/html" title="A Dummy’s Guide to Word2Vec" /><published>2022-01-21T12:06:00+00:00</published><updated>2022-01-21T12:06:00+00:00</updated><id>https://manansuri.com/blog/2022/a-dummys-guide-to-word2vec</id><content type="html" xml:base="https://manansuri.com/blog/2022/a-dummys-guide-to-word2vec/"><![CDATA[<p><em>Originally published on <a href="https://medium.com/@manansuri/a-dummys-guide-to-word2vec-456444f3c673">Medium</a> in January 2022; migrated and lightly updated in September 2026. The code has been updated for Gensim 4 (<code class="language-plaintext highlighter-rouge">size</code> → <code class="language-plaintext highlighter-rouge">vector_size</code>, <code class="language-plaintext highlighter-rouge">iter</code> → <code class="language-plaintext highlighter-rouge">epochs</code>, <code class="language-plaintext highlighter-rouge">wv.vocab</code> → <code class="language-plaintext highlighter-rouge">wv.index_to_key</code>), and a short postscript on what Word2Vec still teaches us in the LLM era has been added at the end.</em></p>

<figure>

  <picture>
    
    <source class="responsive-img-srcset" media="(max-width: 480px)" srcset="/assets/img/blog/word2vec/cover-480.webp" />
    <source class="responsive-img-srcset" media="(max-width: 800px)" srcset="/assets/img/blog/word2vec/cover-800.webp" />
    <source class="responsive-img-srcset" media="(max-width: 1400px)" srcset="/assets/img/blog/word2vec/cover-1400.webp" />
    

    <!-- Fallback to the original file -->
    <img src="/assets/img/blog/word2vec/cover.jpg" class="img-fluid rounded z-depth-1" width="auto" height="auto" alt="Word2Vec cover illustration" data-zoomable="" loading="lazy" onerror="this.onerror=null; $('.responsive-img-srcset').remove();" />
  </picture>

</figure>

<p>I have always been interested in learning different languages, though the only French the Duolingo owl has taught me is <em>Je m’appelle Manan</em>. My short stints at learning a third language helped me realise that vocabulary is not sufficient: contexts, semantics and syntactic features are also important to truly grasp meaning from a language. So when I started doing NLP tasks, I felt perplexed about how something like a bag-of-words, which considers each word in a very independent light, could really be effective. I got answers to this question when I learnt what word embeddings are and how they work. In this article, I’m going to talk about word embeddings, specifically the Word2Vec model, and how to take advantage of it using the easy-to-use Gensim library.</p>

<h2 id="word-embeddings">Word embeddings</h2>

<p>The emergence of language was a pivotal moment in the evolution of humanity. Although all species have their ways of communicating, we as humans are unique in having mastered cognitive language communication. So while I know that “rat” refers to a small hairy rodent, my dog or my computer (at least in essence) doesn’t know that.</p>

<p>Therefore, any task aimed at processing language must first begin with how to represent words.</p>

<p>A preliminary method is a “bag of words” model which encodes words using a one-hot scheme. If our dataset contains the sentences:</p>

<blockquote>
  <p>“I like the new movie!”, “I love the weather.”</p>
</blockquote>

<figure>

  <picture>
    
    <source class="responsive-img-srcset" media="(max-width: 480px)" srcset="/assets/img/blog/word2vec/bag-of-words-480.webp" />
    <source class="responsive-img-srcset" media="(max-width: 800px)" srcset="/assets/img/blog/word2vec/bag-of-words-800.webp" />
    <source class="responsive-img-srcset" media="(max-width: 1400px)" srcset="/assets/img/blog/word2vec/bag-of-words-1400.webp" />
    

    <!-- Fallback to the original file -->
    <img src="/assets/img/blog/word2vec/bag-of-words.png" class="img-fluid rounded z-depth-1" width="auto" height="auto" alt="Bag-of-words one-hot table" data-zoomable="" loading="lazy" onerror="this.onerror=null; $('.responsive-img-srcset').remove();" />
  </picture><figcaption class="caption">Visualising the bag-of-words representation</figcaption>

</figure>

<p>Then we can have a vector representation of the words as:</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>I       [1,0,0,0,0,0,0]
like    [0,1,0,0,0,0,0]
the     [0,0,1,0,0,0,0]
new     [0,0,0,1,0,0,0]
movie   [0,0,0,0,1,0,0]
love    [0,0,0,0,0,1,0]
weather [0,0,0,0,0,0,1]
</code></pre></div></div>

<p>The sentences will then be represented as <code class="language-plaintext highlighter-rouge">[1,1,1,1,1,0,0]</code> and <code class="language-plaintext highlighter-rouge">[1,0,1,0,0,1,1]</code>.</p>

<p>However, as you might have noticed, this representation is not very effective at showing the semantic and syntactic relationships between words. They are encoded as individual bits in a vector space, and there is no way you can tell that the words “love” and “like” have a similar connotation.</p>

<p>This is where word embeddings come in. Word embeddings are representations where contexts and similarities are captured by encoding in a vector space: similar words have similar representations. We’re going to discuss Word2Vec, which is an effective word embedding technique.</p>

<h2 id="word2vec">Word2Vec</h2>

<p>Word2Vec creates a representation of each word in our vocabulary as a vector. Words used in similar contexts, or having semantic relationships, are captured through their closeness in the vector space. Effectively, similar words will have similar word vectors! Word2Vec was created, patented, and published in 2013 by a team of researchers led by Tomas Mikolov at Google.</p>

<p>Let us consider a classic example: “king”, “queen”, “man”, “girl”, “prince”.</p>

<figure>

  <picture>
    
    <source class="responsive-img-srcset" media="(max-width: 480px)" srcset="/assets/img/blog/word2vec/hypothetical-features-480.webp" />
    <source class="responsive-img-srcset" media="(max-width: 800px)" srcset="/assets/img/blog/word2vec/hypothetical-features-800.webp" />
    <source class="responsive-img-srcset" media="(max-width: 1400px)" srcset="/assets/img/blog/word2vec/hypothetical-features-1400.webp" />
    

    <!-- Fallback to the original file -->
    <img src="/assets/img/blog/word2vec/hypothetical-features.png" class="img-fluid rounded z-depth-1" width="auto" height="auto" alt="Table of hypothetical feature weights for king, queen, man, girl, prince" data-zoomable="" loading="lazy" onerror="this.onerror=null; $('.responsive-img-srcset').remove();" />
  </picture><figcaption class="caption">Hypothetical features to understand word embeddings</figcaption>

</figure>

<p>In a hypothetical world, vectors could define the weight of each criterion (for example royalty, masculinity, femininity, age) for each of the words in our vocabulary. What we then observe is:</p>

<ul>
  <li>As expected, “king”, “queen”, “prince” have similar scores for “royalty”, and “girl”, “queen” have similar scores for “femininity”.</li>
  <li>An operation that removes “man” from “king” would yield a vector very close to “queen” (“king” − “man” = “queen”).</li>
  <li>Vectors “king” and “prince” have the same characteristics except for age, telling us how they might be semantically related to each other.</li>
</ul>

<p>Word2Vec forms word embeddings that work in a similar fashion, except that the criteria used for each word are not clearly determinable. What matters to us is the semantic and syntactic relations between words, which can still be determined by our model without explicitly defining features for units of the vector.</p>

<p>Word2Vec has also been shown to identify relations like country–capital over larger datasets, showing how powerful word embeddings can be. Embeddings generated by Word2Vec can further be used in NLP tasks, such as feeding them into a CNN to classify text.</p>

<h3 id="model-architecture">Model architecture</h3>

<p>Word2Vec is essentially a shallow, 2-layer neural network.</p>

<ul>
  <li>The input contains all the documents/texts in our training set. For the network to process these texts, they are represented as one-hot encodings of the words.</li>
  <li>The number of neurons in the hidden layer equals the length of the embedding we want. That is, if we want all our words to be vectors of length 300, then the hidden layer will contain 300 neurons.</li>
</ul>

<figure>

  <picture>
    
    <source class="responsive-img-srcset" media="(max-width: 480px)" srcset="/assets/img/blog/word2vec/network-training-480.webp" />
    <source class="responsive-img-srcset" media="(max-width: 800px)" srcset="/assets/img/blog/word2vec/network-training-800.webp" />
    <source class="responsive-img-srcset" media="(max-width: 1400px)" srcset="/assets/img/blog/word2vec/network-training-1400.webp" />
    

    <!-- Fallback to the original file -->
    <img src="/assets/img/blog/word2vec/network-training.png" class="img-fluid rounded z-depth-1" width="auto" height="auto" alt="Diagram of the two-layer Word2Vec network" data-zoomable="" loading="lazy" onerror="this.onerror=null; $('.responsive-img-srcset').remove();" />
  </picture><figcaption class="caption">Understanding the neural network training of the Word2Vec model</figcaption>

</figure>

<ul>
  <li>The output layer contains probabilities for a target word (given an input to the model, what word is expected).</li>
  <li>At the end of the training process, the hidden weights are treated as the word embedding. Intuitively, this can be thought of as each word having a set of <em>n</em> weights (300 in the example above) “weighing” their different characteristics, the analogy we used earlier.</li>
</ul>

<figure>

  <picture>
    
    <source class="responsive-img-srcset" media="(max-width: 480px)" srcset="/assets/img/blog/word2vec/weight-matrix-lookup-480.webp" />
    <source class="responsive-img-srcset" media="(max-width: 800px)" srcset="/assets/img/blog/word2vec/weight-matrix-lookup-800.webp" />
    <source class="responsive-img-srcset" media="(max-width: 1400px)" srcset="/assets/img/blog/word2vec/weight-matrix-lookup-1400.webp" />
    

    <!-- Fallback to the original file -->
    <img src="/assets/img/blog/word2vec/weight-matrix-lookup.png" class="img-fluid rounded z-depth-1" width="auto" height="auto" alt="Hidden-layer weight matrix as a lookup table" data-zoomable="" loading="lazy" onerror="this.onerror=null; $('.responsive-img-srcset').remove();" />
  </picture><figcaption class="caption">The weight matrix of the hidden layer ends up becoming a lookup table for the given words and their vector representations</figcaption>

</figure>

<p>There are two ways in which we can develop these embeddings.</p>

<p><strong>1. Continuous Bag-Of-Words (CBOW).</strong> CBOW predicts the target word based on its surrounding words. For example, consider the sentence “The cake was chocolate flavoured”. The model will iterate over this sentence for different target words, such as “The <em>__</em> was chocolate flavoured” being the input and “cake” being the target word. CBOW thus smoothes over the distribution of the information, as it treats the entire context as one observation. CBOW is faster than skip-gram and works well with frequent words.</p>

<p><strong>2. Skip-gram.</strong> Skip-gram works in the exact opposite way to CBOW. Here we take an input word and expect the model to tell us what words it is expected to be surrounded by. Taking the same example, with “cake” we would expect the model to give us “The”, “was”, “chocolate”, “flavoured”. The statistical interpretation of this is that we treat each context–target pair as a new observation. Skip-gram works well with small datasets and can better represent less frequent words.</p>

<figure>

  <picture>
    
    <source class="responsive-img-srcset" media="(max-width: 480px)" srcset="/assets/img/blog/word2vec/cbow-skipgram-480.webp" />
    <source class="responsive-img-srcset" media="(max-width: 800px)" srcset="/assets/img/blog/word2vec/cbow-skipgram-800.webp" />
    <source class="responsive-img-srcset" media="(max-width: 1400px)" srcset="/assets/img/blog/word2vec/cbow-skipgram-1400.webp" />
    

    <!-- Fallback to the original file -->
    <img src="/assets/img/blog/word2vec/cbow-skipgram.png" class="img-fluid rounded z-depth-1" width="auto" height="auto" alt="CBOW versus skip-gram training diagram" data-zoomable="" loading="lazy" onerror="this.onerror=null; $('.responsive-img-srcset').remove();" />
  </picture><figcaption class="caption">Training CBOW and skip-gram for Word2Vec</figcaption>

</figure>

<h2 id="using-gensim-to-train-our-own-embeddings">Using Gensim to train our own embeddings</h2>

<p>We can easily train Word2Vec embeddings using <a href="https://radimrehurek.com/gensim/">Gensim</a>, which is “a free open-source Python library for representing documents as semantic vectors, as efficiently (computer-wise) and painlessly (human-wise) as possible.”</p>

<p>The dataset I used for this demo is the <a href="https://www.kaggle.com/datatattle/covid-19-nlp-text-classification">Coronavirus tweets NLP dataset</a> from Kaggle. I am omitting the parts involving loading the dataset and preprocessing the text, but you can check out the complete implementation in this <a href="https://colab.research.google.com/drive/1YkSrvfWR_EBFFrhV5E15Z6k5es4Kluom?usp=sharing">Colab notebook</a>. I preferred this over larger datasets like IMDB because many real applications involve similar-sized datasets, so it makes for a better representation of the model’s average performance.</p>

<h3 id="1-training-the-embeddings">1. Training the embeddings</h3>

<p>We import <code class="language-plaintext highlighter-rouge">Word2Vec</code> from <code class="language-plaintext highlighter-rouge">gensim.models</code>. Each input to the model must be a list of tokens, so we generate the input by calling <code class="language-plaintext highlighter-rouge">split()</code> on each line in our corpus of texts.</p>

<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="kn">from</span> <span class="n">gensim.models</span> <span class="kn">import</span> <span class="n">Word2Vec</span>

<span class="n">sentences</span> <span class="o">=</span> <span class="p">[</span><span class="n">line</span><span class="p">.</span><span class="nf">split</span><span class="p">()</span> <span class="k">for</span> <span class="n">line</span> <span class="ow">in</span> <span class="n">texts</span><span class="p">]</span>

<span class="n">w2v</span> <span class="o">=</span> <span class="nc">Word2Vec</span><span class="p">(</span><span class="n">sentences</span><span class="p">,</span> <span class="n">vector_size</span><span class="o">=</span><span class="mi">100</span><span class="p">,</span> <span class="n">window</span><span class="o">=</span><span class="mi">5</span><span class="p">,</span> <span class="n">workers</span><span class="o">=</span><span class="mi">4</span><span class="p">,</span> <span class="n">epochs</span><span class="o">=</span><span class="mi">10</span><span class="p">,</span> <span class="n">min_count</span><span class="o">=</span><span class="mi">5</span><span class="p">)</span>

<span class="nf">print</span><span class="p">(</span><span class="n">sentences</span><span class="p">[</span><span class="mi">20</span><span class="p">:</span><span class="mi">25</span><span class="p">])</span>
<span class="c1"># [['with', 'nations', 'inficted', 'with', 'covid', 'the', 'world', 'must', 'not', 'play', 'fair', ...
</span></code></pre></div></div>

<p>We then set up the model and specify different parameters. Briefly, what they mean:</p>

<ul>
  <li><code class="language-plaintext highlighter-rouge">vector_size</code> (called <code class="language-plaintext highlighter-rouge">size</code> in Gensim 3) is the size of the word embedding it will output.</li>
  <li><code class="language-plaintext highlighter-rouge">window</code> is the maximum distance between the current and predicted word within a sentence.</li>
  <li><code class="language-plaintext highlighter-rouge">min_count</code> sets a minimum frequency for a word to be part of the model; all words with count less than <code class="language-plaintext highlighter-rouge">min_count</code> are ignored.</li>
  <li><code class="language-plaintext highlighter-rouge">workers</code> is the number of worker threads used to train the model. This can be adjusted to the number of cores your system has. In simple words, it is the parallelism while training.</li>
  <li><code class="language-plaintext highlighter-rouge">epochs</code> (called <code class="language-plaintext highlighter-rouge">iter</code> in Gensim 3) is the number of passes over the corpus during training.</li>
</ul>

<h3 id="2-using-the-word2vec-model">2. Using the Word2Vec model</h3>

<p>Finding the vocabulary of the model can be useful in several general applications, and in this case it gives us a list of words we can try with the other functions.</p>

<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="n">words</span> <span class="o">=</span> <span class="nf">list</span><span class="p">(</span><span class="n">w2v</span><span class="p">.</span><span class="n">wv</span><span class="p">.</span><span class="n">index_to_key</span><span class="p">)</span>   <span class="c1"># w2v.wv.vocab in Gensim 3
</span><span class="nf">print</span><span class="p">(</span><span class="n">words</span><span class="p">)</span>
<span class="c1"># ['phil', 'advice', 'talk', 'to', 'your', 'neighbours', 'family', 'exchange', 'phone', 'numbers', ...
</span></code></pre></div></div>

<p>Finding the embedding of a given word is useful when we’re trying to represent sentences as a collection of word embeddings, like when building a weight matrix for the embedding layer of a network. I include this so it can help your intuition of what a word vector looks like.</p>

<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nf">print</span><span class="p">(</span><span class="n">w2v</span><span class="p">.</span><span class="n">wv</span><span class="p">[</span><span class="sh">'</span><span class="s">computer</span><span class="sh">'</span><span class="p">])</span>
<span class="c1"># [ 1.57469660e-01  1.40157074e-01 -3.25907797e-01 -6.61702231e-02
#   3.14891905e-01  6.28795177e-02 -4.47840840e-02  4.59685735e-02
#   ...  (100 values)
</span></code></pre></div></div>

<p>As you can see, it is not possible to make sense of what these individual values mean, unlike the completely hypothetical example I gave above.</p>

<p>We can also find the similarity between given words (the cosine similarity between their vectors). Here we compare “vladimir” with “putin” and with “modi”, and a stark distinction exists.</p>

<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="n">w2v</span><span class="p">.</span><span class="n">wv</span><span class="p">.</span><span class="nf">similarity</span><span class="p">(</span><span class="sh">'</span><span class="s">vladimir</span><span class="sh">'</span><span class="p">,</span> <span class="sh">'</span><span class="s">putin</span><span class="sh">'</span><span class="p">)</span>
<span class="c1"># 0.81842446
</span>
<span class="n">w2v</span><span class="p">.</span><span class="n">wv</span><span class="p">.</span><span class="nf">similarity</span><span class="p">(</span><span class="sh">'</span><span class="s">vladimir</span><span class="sh">'</span><span class="p">,</span> <span class="sh">'</span><span class="s">modi</span><span class="sh">'</span><span class="p">)</span>
<span class="c1"># 0.6622772
</span></code></pre></div></div>

<p>With Gensim we can also find the most similar words to a given word. This particularly shows the contextualising power of the model. Look at words similar to “covid”: we get “coronavirus”, “virus”, “corona”, “disease” as the top words. When we try “india”, we get a list of words that are also countries! When we try a verb such as “pay”, we get other forms of the same verb, “paid”, “paying”, and associated terms like “wages” and “bills”. This is exciting considering our vocabulary is not very large and the dataset covers a very specific situation.</p>

<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nf">print</span><span class="p">(</span><span class="n">w2v</span><span class="p">.</span><span class="n">wv</span><span class="p">.</span><span class="nf">most_similar</span><span class="p">(</span><span class="sh">'</span><span class="s">pay</span><span class="sh">'</span><span class="p">))</span>
<span class="c1"># [('paying', 0.702), ('paid', 0.686), ('wages', 0.651), ('bills', ...), ...]
</span>
<span class="nf">print</span><span class="p">(</span><span class="n">w2v</span><span class="p">.</span><span class="n">wv</span><span class="p">.</span><span class="nf">most_similar</span><span class="p">(</span><span class="sh">'</span><span class="s">covid</span><span class="sh">'</span><span class="p">))</span>
<span class="c1"># [('coronavirus', 0.603), ('virus', 0.529), ('corona', 0.507), ...]
</span>
<span class="nf">print</span><span class="p">(</span><span class="n">w2v</span><span class="p">.</span><span class="n">wv</span><span class="p">.</span><span class="nf">most_similar</span><span class="p">(</span><span class="sh">'</span><span class="s">india</span><span class="sh">'</span><span class="p">))</span>
<span class="c1"># [('nigeria', 0.753), ('pakistan', 0.751), ('kenya', 0.683), ...]
</span></code></pre></div></div>

<p>Similarly, we can use the same function to find analogies of the form <em>if x : y, then z : ?</em>. We enter the known relation in the <code class="language-plaintext highlighter-rouge">positive</code> parameter and the term whose analogue we want in the <code class="language-plaintext highlighter-rouge">negative</code> parameter. Here our model seems to have learnt something about nationalities: if “russian” → “russia”, then “arab” → “saudi”, “arabia” (taking the first two terms, because the model did not treat multi-word phrases as single tokens).</p>

<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nf">print</span><span class="p">(</span><span class="n">w2v</span><span class="p">.</span><span class="n">wv</span><span class="p">.</span><span class="nf">most_similar</span><span class="p">(</span><span class="n">positive</span><span class="o">=</span><span class="p">[</span><span class="sh">'</span><span class="s">russian</span><span class="sh">'</span><span class="p">,</span> <span class="sh">'</span><span class="s">russia</span><span class="sh">'</span><span class="p">],</span> <span class="n">negative</span><span class="o">=</span><span class="p">[</span><span class="sh">'</span><span class="s">arab</span><span class="sh">'</span><span class="p">]))</span>
<span class="c1"># [('saudi', 0.798), ('arabia', 0.774), ('putin', 0.735), ...]
</span></code></pre></div></div>

<p>There is also a method that works like an “odd one out” puzzle. Here the model identifies “grocery” as different from “covid” and “coronavirus”.</p>

<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="n">w2v</span><span class="p">.</span><span class="n">wv</span><span class="p">.</span><span class="nf">doesnt_match</span><span class="p">([</span><span class="sh">'</span><span class="s">grocery</span><span class="sh">'</span><span class="p">,</span> <span class="sh">'</span><span class="s">covid</span><span class="sh">'</span><span class="p">,</span> <span class="sh">'</span><span class="s">coronavirus</span><span class="sh">'</span><span class="p">])</span>
<span class="c1"># 'grocery'
</span></code></pre></div></div>

<h3 id="3-visualising-word-embeddings">3. Visualising word embeddings</h3>

<p>Word2Vec embeddings are usually of size 100 or 300, and it is not practical to visualise a 100- or 300-dimensional space meaningfully. I used a snippet from <a href="http://web.stanford.edu/class/cs224n/materials/Gensim%20word%20vector%20visualization.html">Stanford’s CS224N course site</a>, which lets you either pass a list of words or a number of random samples to display. In either case, it uses PCA to reduce the dimensionality and plots the words on a 2-dimensional plane. The actual axis values hold no significance; what we can see is that similar vectors are densely located with respect to each other.</p>

<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="kn">import</span> <span class="n">numpy</span> <span class="k">as</span> <span class="n">np</span>
<span class="kn">import</span> <span class="n">matplotlib.pyplot</span> <span class="k">as</span> <span class="n">plt</span>
<span class="kn">from</span> <span class="n">sklearn.decomposition</span> <span class="kn">import</span> <span class="n">PCA</span>

<span class="k">def</span> <span class="nf">display_pca_scatterplot</span><span class="p">(</span><span class="n">model</span><span class="p">,</span> <span class="n">words</span><span class="o">=</span><span class="bp">None</span><span class="p">,</span> <span class="n">sample</span><span class="o">=</span><span class="mi">0</span><span class="p">):</span>
    <span class="k">if</span> <span class="n">words</span> <span class="ow">is</span> <span class="bp">None</span><span class="p">:</span>
        <span class="n">words</span> <span class="o">=</span> <span class="nf">list</span><span class="p">(</span><span class="n">model</span><span class="p">.</span><span class="n">wv</span><span class="p">.</span><span class="n">index_to_key</span><span class="p">)</span> <span class="k">if</span> <span class="n">sample</span> <span class="o">==</span> <span class="mi">0</span> <span class="k">else</span> \
                <span class="n">np</span><span class="p">.</span><span class="n">random</span><span class="p">.</span><span class="nf">choice</span><span class="p">(</span><span class="nf">list</span><span class="p">(</span><span class="n">model</span><span class="p">.</span><span class="n">wv</span><span class="p">.</span><span class="n">index_to_key</span><span class="p">),</span> <span class="n">sample</span><span class="p">,</span> <span class="n">replace</span><span class="o">=</span><span class="bp">False</span><span class="p">)</span>
    <span class="n">word_vectors</span> <span class="o">=</span> <span class="n">np</span><span class="p">.</span><span class="nf">array</span><span class="p">([</span><span class="n">model</span><span class="p">.</span><span class="n">wv</span><span class="p">[</span><span class="n">w</span><span class="p">]</span> <span class="k">for</span> <span class="n">w</span> <span class="ow">in</span> <span class="n">words</span><span class="p">])</span>
    <span class="n">twodim</span> <span class="o">=</span> <span class="nc">PCA</span><span class="p">().</span><span class="nf">fit_transform</span><span class="p">(</span><span class="n">word_vectors</span><span class="p">)[:,</span> <span class="p">:</span><span class="mi">2</span><span class="p">]</span>

    <span class="n">plt</span><span class="p">.</span><span class="nf">figure</span><span class="p">(</span><span class="n">figsize</span><span class="o">=</span><span class="p">(</span><span class="mi">6</span><span class="p">,</span> <span class="mi">6</span><span class="p">))</span>
    <span class="n">plt</span><span class="p">.</span><span class="nf">scatter</span><span class="p">(</span><span class="n">twodim</span><span class="p">[:,</span> <span class="mi">0</span><span class="p">],</span> <span class="n">twodim</span><span class="p">[:,</span> <span class="mi">1</span><span class="p">],</span> <span class="n">edgecolors</span><span class="o">=</span><span class="sh">'</span><span class="s">k</span><span class="sh">'</span><span class="p">,</span> <span class="n">c</span><span class="o">=</span><span class="sh">'</span><span class="s">r</span><span class="sh">'</span><span class="p">)</span>
    <span class="k">for</span> <span class="n">word</span><span class="p">,</span> <span class="p">(</span><span class="n">x</span><span class="p">,</span> <span class="n">y</span><span class="p">)</span> <span class="ow">in</span> <span class="nf">zip</span><span class="p">(</span><span class="n">words</span><span class="p">,</span> <span class="n">twodim</span><span class="p">):</span>
        <span class="n">plt</span><span class="p">.</span><span class="nf">text</span><span class="p">(</span><span class="n">x</span> <span class="o">+</span> <span class="mf">0.05</span><span class="p">,</span> <span class="n">y</span> <span class="o">+</span> <span class="mf">0.05</span><span class="p">,</span> <span class="n">word</span><span class="p">)</span>

<span class="nf">display_pca_scatterplot</span><span class="p">(</span><span class="n">w2v</span><span class="p">,</span> <span class="p">[</span><span class="sh">'</span><span class="s">coronavirus</span><span class="sh">'</span><span class="p">,</span> <span class="sh">'</span><span class="s">covid</span><span class="sh">'</span><span class="p">,</span> <span class="sh">'</span><span class="s">virus</span><span class="sh">'</span><span class="p">,</span> <span class="sh">'</span><span class="s">corona</span><span class="sh">'</span><span class="p">,</span> <span class="sh">'</span><span class="s">disease</span><span class="sh">'</span><span class="p">,</span>
                              <span class="sh">'</span><span class="s">saudiarabia</span><span class="sh">'</span><span class="p">,</span> <span class="sh">'</span><span class="s">doctor</span><span class="sh">'</span><span class="p">,</span> <span class="sh">'</span><span class="s">hospital</span><span class="sh">'</span><span class="p">,</span> <span class="sh">'</span><span class="s">pakistan</span><span class="sh">'</span><span class="p">,</span> <span class="sh">'</span><span class="s">kenya</span><span class="sh">'</span><span class="p">,</span>
                              <span class="sh">'</span><span class="s">pay</span><span class="sh">'</span><span class="p">,</span> <span class="sh">'</span><span class="s">paying</span><span class="sh">'</span><span class="p">,</span> <span class="sh">'</span><span class="s">paid</span><span class="sh">'</span><span class="p">,</span> <span class="sh">'</span><span class="s">wages</span><span class="sh">'</span><span class="p">,</span> <span class="sh">'</span><span class="s">raise</span><span class="sh">'</span><span class="p">,</span> <span class="sh">'</span><span class="s">bills</span><span class="sh">'</span><span class="p">,</span> <span class="sh">'</span><span class="s">rent</span><span class="sh">'</span><span class="p">,</span> <span class="sh">'</span><span class="s">charge</span><span class="sh">'</span><span class="p">])</span>
</code></pre></div></div>

<p>On the graph, you can see how “coronavirus”, “covid”, “virus” form one group, separate from the others, while “paying”, “paid”, “bills”, “wages” are in another group altogether. Similarly, the countries “saudiarabia”, “kenya”, “pakistan” form one very dense cluster.</p>

<figure>

  <picture>
    
    <source class="responsive-img-srcset" media="(max-width: 480px)" srcset="/assets/img/blog/word2vec/pca-plot-480.webp" />
    <source class="responsive-img-srcset" media="(max-width: 800px)" srcset="/assets/img/blog/word2vec/pca-plot-800.webp" />
    <source class="responsive-img-srcset" media="(max-width: 1400px)" srcset="/assets/img/blog/word2vec/pca-plot-1400.webp" />
    

    <!-- Fallback to the original file -->
    <img src="/assets/img/blog/word2vec/pca-plot.png" class="img-fluid rounded z-depth-1" width="auto" height="auto" alt="PCA scatter plot of selected word vectors" data-zoomable="" loading="lazy" onerror="this.onerror=null; $('.responsive-img-srcset').remove();" />
  </picture><figcaption class="caption">Reducing dimensionality with PCA and visualising the given words</figcaption>

</figure>

<h3 id="4-saving-models-and-using-pre-trained-models">4. Saving models, and using pre-trained models</h3>

<p>Gensim ships with several pre-trained models in the <code class="language-plaintext highlighter-rouge">gensim-data</code> repository. We can import the downloader from the Gensim library and print the list of available pre-trained models. This also includes models like GloVe and fastText, not only Word2Vec.</p>

<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="kn">import</span> <span class="n">gensim.downloader</span>

<span class="nf">print</span><span class="p">(</span><span class="nf">list</span><span class="p">(</span><span class="n">gensim</span><span class="p">.</span><span class="n">downloader</span><span class="p">.</span><span class="nf">info</span><span class="p">()[</span><span class="sh">'</span><span class="s">models</span><span class="sh">'</span><span class="p">].</span><span class="nf">keys</span><span class="p">()))</span>
<span class="c1"># ['fasttext-wiki-news-subwords-300', 'conceptnet-numberbatch-17-06-300',
#  'word2vec-ruscorpora-300', 'word2vec-google-news-300', 'glove-wiki-gigaword-50', ...]
</span></code></pre></div></div>

<p>Here we use <code class="language-plaintext highlighter-rouge">word2vec-google-news-300</code> (trained on Google News, with 300-dimensional vectors) and find words similar to “twitter”.</p>

<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="n">google_news</span> <span class="o">=</span> <span class="n">gensim</span><span class="p">.</span><span class="n">downloader</span><span class="p">.</span><span class="nf">load</span><span class="p">(</span><span class="sh">'</span><span class="s">word2vec-google-news-300</span><span class="sh">'</span><span class="p">)</span>
<span class="n">google_news</span><span class="p">.</span><span class="nf">most_similar</span><span class="p">(</span><span class="sh">'</span><span class="s">twitter</span><span class="sh">'</span><span class="p">)</span>
<span class="c1"># [('Twitter', 0.891), ('Twitter.com', 0.754), ('tweet', 0.743), ('tweeting', 0.716),
#  ('tweeted', 0.714), ('facebook', 0.699), ('tweets', 0.697), ...]
</span></code></pre></div></div>

<p>We can save our trained models, load them again, and even continue training them.</p>

<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="n">w2v</span><span class="p">.</span><span class="nf">save</span><span class="p">(</span><span class="sh">"</span><span class="s">word2vec.model</span><span class="sh">"</span><span class="p">)</span>

<span class="n">model</span> <span class="o">=</span> <span class="n">Word2Vec</span><span class="p">.</span><span class="nf">load</span><span class="p">(</span><span class="sh">"</span><span class="s">word2vec.model</span><span class="sh">"</span><span class="p">)</span>
<span class="n">model</span><span class="p">.</span><span class="nf">train</span><span class="p">([[</span><span class="sh">"</span><span class="s">hello</span><span class="sh">"</span><span class="p">,</span> <span class="sh">"</span><span class="s">world</span><span class="sh">"</span><span class="p">]],</span> <span class="n">total_examples</span><span class="o">=</span><span class="mi">1</span><span class="p">,</span> <span class="n">epochs</span><span class="o">=</span><span class="mi">1</span><span class="p">)</span>
</code></pre></div></div>

<h2 id="summary">Summary</h2>

<ul>
  <li>Word embeddings are a better way to represent natural language than a skeletal bag-of-words. They capture the semantic and syntactic relationships present in text.</li>
  <li>Word2Vec represents each word in our vocabulary as a vector, so that similar words have similar word vectors.</li>
  <li>Word2Vec embeddings can be trained in two ways: CBOW predicts the target word from its surrounding words; skip-gram does the opposite, predicting surrounding words for a given input word.</li>
  <li>We can easily train Word2Vec embeddings using Gensim, a free open-source Python library, on our own corpus, or use pre-trained embeddings.</li>
  <li>Gensim provides functions for working with embeddings, including finding similar words, computing similarities, and solving analogies.</li>
  <li>The Gensim downloader gives easy access to embeddings trained on large datasets like Google News. We can save our trained models and continue training them later.</li>
</ul>

<p>Code: <a href="https://colab.research.google.com/drive/1YkSrvfWR_EBFFrhV5E15Z6k5es4Kluom?usp=sharing">Colab notebook</a></p>

<h2 id="postscript-2026-what-word2vec-got-right-that-we-forgot-in-the-llm-era">Postscript (2026): what Word2Vec got right that we forgot in the LLM era</h2>

<p>Four years after writing this, I spend most of my time on LLM agents, and it is easy to treat Word2Vec as a museum piece. It isn’t. A few things it got right that are worth remembering:</p>

<ul>
  <li><strong>The distributional hypothesis is still the whole game.</strong> “You shall know a word by the company it keeps” is exactly what a transformer’s next-token objective optimises, just with a much bigger context window and a much bigger model. Word2Vec was the cleanest possible demonstration that predicting neighbours is enough to learn meaning. Everything since is scaling that same idea.</li>
  <li><strong>Static embeddings are underrated.</strong> A lookup table is the cheapest model there is: no forward pass, no GPU, no latency. For retrieval pre-filtering, deduplication, vocabulary analysis, or anything that runs millions of times, a good static embedding is often the right tool, and modern “static” models distilled from LLMs bring most of the quality at a tiny fraction of the cost.</li>
  <li><strong>Efficiency was a design goal, not an afterthought.</strong> Negative sampling, hierarchical softmax, and subsampling of frequent words exist because Mikolov’s team wanted to train on billions of tokens on a CPU. That mindset, do the simplest thing that scales, is one I keep coming back to when thinking about how agents should hold context without paying for it on every query.</li>
  <li><strong>Probing the geometry is how you build intuition.</strong> <code class="language-plaintext highlighter-rouge">most_similar</code>, analogies, and <code class="language-plaintext highlighter-rouge">doesnt_match</code> are toy tools, but they teach you to ask “what has this model actually learnt?” That habit transfers directly to interpreting, attributing, and trusting what large models produce.</li>
</ul>

<h2 id="references--further-reading">References / further reading</h2>

<ul>
  <li><a href="https://israelg99.github.io/2017-03-23-Word2Vec-Explained/">Word2Vec explained</a></li>
  <li><a href="https://jalammar.github.io/illustrated-word2vec/">The Illustrated Word2Vec</a> by Jay Alammar</li>
  <li><a href="https://www.kaggle.com/pierremegret/gensim-word2vec-tutorial">Gensim Word2Vec tutorial</a> on Kaggle</li>
  <li><a href="https://remykarem.github.io/word2vec-demo/">Word2Vec demo</a></li>
  <li>Mikolov et al., <a href="https://arxiv.org/abs/1301.3781">Efficient Estimation of Word Representations in Vector Space</a> (2013)</li>
</ul>]]></content><author><name></name></author><category term="tutorials" /><category term="nlp" /><category term="embeddings" /><category term="tutorial" /><summary type="html"><![CDATA[What word embeddings are, how Word2Vec learns them (CBOW and skip-gram), and how to train, probe, and visualise your own with Gensim.]]></summary></entry></feed>