/* ===================== configuration ===================== */ const C = { gray:'#d9d9d9', purple:'#c5b3e0', blue:'#6c8ebf', teal:'#a9d7c9', red:'#e53935', overflow:'#ef9a9a', overflowBg:'#fdecea', text:'#555', purpleText:'#6a4fb8', blueText:'#3f669f', tealText:'#3d8f78', green:'#2e8b57', arrow:'#c4c4c4' }; const P = 19, W = 14, H = 64; // column pitch, column width, column height (= layers) const N = 10, LAT = 2; // positions per sender, latent steps per sender const SEL = [[2, 7], [3, 6]]; // selected positions per sender const SX = 130, SY = [118, 250]; // sender KV origin const RX = 620, RY = 118, SEG = 10; // receiver KV row (LatentMAS/KVComm) const STX = 400, STY = 250; // staging row (colored entries only) const CY = 382, TY = 382; // CacheBack row y, token-ID row y const LIMIT_SLOT = 16; // context limit falls before this receiver slot const GROUP_GAP = 10; // gap between purple and blue groups in staging const T = { s0:0, s1:1.6, s2:4.0, s3:7.0, s4:11.0, s5:13.4, s6:17.0, s7:20.0, end:23.5 }; const STAGES = [ { t:T.s0, name:'Sender KVs', cap:'Each sender agent holds a KV cache — one column per position, spanning all layers.' }, { t:T.s1, name:'Select', cap:'Positions relevant to the receiver\'s query q are selected.' }, { t:T.s2, name:'Latent steps', cap:'Each sender generates latent steps, appending new KV entries.' }, { t:T.s3, name:'Concatenate', cap:'LatentMAS / KVComm: transfer all positions and concatenate them into the receiver\'s KV, followed by the receiver prompt.' }, { t:T.s4, name:'Overflow', cap:'The concatenated cache plus the receiver prompt exceeds the context limit.' }, { t:T.s5, name:'Pick & sort', cap:'CacheBack: keep only the selected positions and latent steps, and organize them.' }, { t:T.s6, name:'CacheBack', cap:'The compact set is written into the receiver\'s cache — well within the context limit.' }, { t:T.s7, name:'Token IDs', cap:'Selected source positions can be sent as token IDs; latent steps must be sent as continuous vectors. The receiver prefills both to build its own state.' }, ]; /* ===================== tiny timeline engine ===================== */ const NS = 'http://www.w3.org/2000/svg'; const svg = document.getElementById('communication-svg'); function el(tag, attrs = {}, parent) { const n = document.createElementNS(NS, tag); for (const k in attrs) n.setAttribute(k, attrs[k]); if (parent) parent.appendChild(n); return n; } const ease = { lin: t => t, io: t => t < .5 ? 4*t*t*t : 1 - Math.pow(-2*t + 2, 3) / 2, out: t => 1 - Math.pow(1 - t, 3), back:t => { const c1 = 1.70158, c3 = c1 + 1; return 1 + c3*Math.pow(t - 1, 3) + c1*Math.pow(t - 1, 2); }, }; const COLOR_PROPS = new Set(['fill', 'stroke']); const ATTR = { w:'width', h:'height' }; const hex = h => [0, 2, 4].map(i => parseInt(h.replace('#', '').substr(i, 2), 16)); const norm = (k, v) => COLOR_PROPS.has(k) ? hex(v) : v; const mix = (a, b, p) => Array.isArray(a) ? a.map((v, i) => v + (b[i] - v) * p) : a + (b - a) * p; const actors = []; class Actor { constructor(node, init) { this.node = node; this.init = {}; this.tracks = {}; this.last = {}; for (const k in init) this.init[k] = norm(k, init[k]); actors.push(this); } to(t0, dur, props, e = 'io') { for (const k in props) if (!(k in this.init)) this.init[k] = +this.node.getAttribute(ATTR[k] || k); for (const k in props) (this.tracks[k] ||= []).push({ t0, t1: t0 + dur, v: norm(k, props[k]), e: ease[e] }); return this; } at(t, props) { return this.to(t, 0, props, 'lin'); } eval(t) { for (const k in this.init) { let v = this.init[k]; for (const s of (this.tracks[k] || [])) { if (t >= s.t1) { v = s.v; continue; } if (t > s.t0) v = mix(v, s.v, s.e((t - s.t0) / (s.t1 - s.t0))); break; } const out = COLOR_PROPS.has(k) ? `rgb(${v.map(Math.round).join(',')})` : (+v.toFixed(2)); if (this.last[k] !== out) { this.node.setAttribute(ATTR[k] || k, out); this.last[k] = out; } } } finalize() { for (const k in this.tracks) this.tracks[k].sort((a, b) => a.t0 - b.t0); } } /* ===================== scene ===================== */ const defs = el('defs', {}, svg); const mk = el('marker', { id:'ah', viewBox:'0 0 10 10', refX:5, refY:5, markerWidth:3.2, markerHeight:3.2, orient:'auto-start-reverse' }, defs); el('path', { d:'M0,0 L10,5 L0,10 z', fill:C.arrow }, mk); const L = {}; for (const name of ['bg', 'main', 'clone', 'over', 'labels']) L[name] = el('g', {}, svg); const rect = (init, parent = L.main, rx = 2) => new Actor(el('rect', { rx }, parent), init); function txt(str, x, y, o = {}, parent = L.labels) { const n = el('text', { x, y, 'font-size':o.size || 14, 'font-weight':o.weight || 400, fill:o.fill || C.text, 'text-anchor':o.anchor || 'start', ...(o.attrs || {}) }, parent); n.innerHTML = str; return new Actor(n, { opacity:o.opacity ?? 0, ...(o.init || {}) }); } const group = (parent = L.labels, opacity = 0) => { const g = el('g', {}, parent); return [g, new Actor(g, { opacity })]; }; const colX = i => SX + i * P; const slotX = g => RX + Math.floor(g / (N + LAT)) * ((N + LAT) * P + SEG) + (g % (N + LAT)) * P; const limitX = slotX(LIMIT_SLOT) - (P - W) / 2; const recvPromptX = RX + 2 * ((N + LAT) * P + SEG); const PROMPT_W = 3 * P - (P - W); /* --- axes --- */ { const [g, a] = group(); el('line', { x1:SX, y1:76, x2:SX + 200, y2:76, stroke:C.arrow, 'stroke-width':5, 'marker-end':'url(#ah)' }, g); const t1 = el('text', { x:SX, y:62, 'font-size':16, fill:'#666' }, g); t1.textContent = 'positions'; el('line', { x1:SX - 24, y1:SY[0], x2:SX - 24, y2:SY[0] + 110, stroke:C.arrow, 'stroke-width':5, 'marker-end':'url(#ah)' }, g); const t2 = el('text', { x:SX - 34, y:SY[0] + 14, 'font-size':16, fill:'#666', 'text-anchor':'end' }, g); t2.textContent = 'layers'; a.to(0.1, .6, { opacity:1 }); } const senderLabels = [0, 1].map(s => txt(`Sender ${s + 1} KV`, SX, SY[s] - 10, { size:15, weight:700 }).to(0.1 + s * .25, .5, { opacity:1 })); /* --- stage 0/1/2: sender columns --- */ const senderCols = [[], []]; const scanStart = T.s1 + 0.3, scanDur = 1.8; for (const s of [0, 1]) { for (let i = 0; i < N + LAT; i++) { const isLat = i >= N, y = SY[s]; const a = rect({ x:colX(i), y:y + H/2, w:W, h:0, fill:isLat ? C.blue : C.gray, opacity:1 }); if (!isLat) a.to(0.15 + s * .25 + i * .05, .45, { y, h:H }, 'out'); if (SEL[s].includes(i)) { const ts = scanStart + scanDur * (i + 1) / (N + 1); a.to(ts, .25, { fill:C.purple }, 'lin') .to(ts, .15, { y:y - 4, h:H + 8 }, 'out').to(ts + .15, .25, { y, h:H }); } senderCols[s].push(a); } // scan bar with query q const scan = rect({ x:colX(-1) - 3, y:SY[s] - 6, w:W + 6, h:H + 12, fill:'#7cc4b0', opacity:0 }, L.over, 4); scan.to(scanStart - .15, .15, { opacity:.5 }, 'lin') .to(scanStart, scanDur, { x:colX(N) - 3 }, 'lin') .to(scanStart + scanDur, .2, { opacity:0 }, 'lin'); const q = txt('q', colX(-1) + W/2, SY[s] + H + 18, { size:15, weight:700, fill:C.tealText, anchor:'middle', attrs:{ 'font-style':'italic' } }); q.to(scanStart - .15, .15, { opacity:1 }, 'lin') .to(scanStart, scanDur, { x:colX(N) + W/2 }, 'lin') .to(scanStart + scanDur, .2, { opacity:0 }, 'lin'); // latent step generation: attend over existing cache, then append a blue column for (let k = 0; k < LAT; k++) { const tk = T.s2 + 0.3 + k * 1.3; const flash = rect({ x:SX - 4, y:SY[s] - 4, w:(N + k) * P - (P - W) + 8, h:H + 8, fill:C.blue, opacity:0 }, L.over, 5); flash.to(tk, .3, { opacity:.2 }, 'out').to(tk + .35, .35, { opacity:0 }); senderCols[s][N + k].to(tk + .45, .5, { y:SY[s], h:H }, 'back'); } } const typeOf = (s, i) => i >= N ? 'b' : SEL[s].includes(i) ? 'p' : 'g'; const fillOf = { p:C.purple, b:C.blue, g:C.gray }; /* --- stage 3: LatentMAS / KVComm concatenation --- */ const recvTitle = txt(`LatentMAS, KVComm · all positions`, RX, RY - 10, { size:15, weight:700 }); recvTitle.to(T.s3, .5, { opacity:1 }); const recv = []; for (const s of [0, 1]) for (let i = 0; i < N + LAT; i++) { const g = s * (N + LAT) + i; const c = rect({ x:colX(i), y:SY[s], w:W, h:H, fill:fillOf[typeOf(s, i)], opacity:0 }, L.clone); const t0 = T.s3 + 0.4 + s * 1.1 + i * .06; c.at(t0, { opacity:1 }).to(t0, .9, { x:slotX(g), y:RY }); recv.push(c); } const recvPrompt = rect({ x:recvPromptX + 60, y:RY, w:PROMPT_W, h:H, fill:C.teal, opacity:0 }, L.clone); recvPrompt.to(T.s3 + 3.0, .7, { x:recvPromptX, opacity:1 }, 'out'); /* --- stage 4: context limit & overflow --- */ const overBg = rect({ x:limitX, y:RY - 6, w:recvPromptX + PROMPT_W + 8 - limitX, h:H + 12, fill:C.overflowBg, opacity:0 }, L.bg, 4); overBg.to(T.s4 + .8, .4, { opacity:1 }); const limitLine = new Actor(el('line', { x1:limitX, x2:limitX, y1:92, stroke:C.red, 'stroke-width':2.5, 'stroke-dasharray':'8 6' }, L.labels), { y2:92, opacity:0 }); limitLine.at(T.s4 + .1, { opacity:1 }).to(T.s4 + .1, .7, { y2:478 }, 'io'); const limitText = txt('Context Limit', 0, 0, { size:17, fill:C.red, anchor:'middle', attrs:{ transform:`translate(${limitX + 16},${(RY + H + CY) / 2 + 6}) rotate(90)` } }); limitText.to(T.s4 + .6, .4, { opacity:1 }); recv.forEach((c, g) => { if (g >= LIMIT_SLOT) c.to(T.s4 + .9 + (g - LIMIT_SLOT) * .05, .3, { fill:C.overflow }, 'lin'); }); recvPrompt.to(T.s4 + .9 + (recv.length - LIMIT_SLOT) * .05, .3, { fill:C.overflow }, 'lin'); const overLabel = txt('✕ exceeds context', limitX + 10, RY - 10, { size:14, weight:700, fill:C.red }); overLabel.to(T.s4 + 1.4, .4, { opacity:1 }); /* --- stage 5: CacheBack — pick colored entries, then sort by type --- */ [...recv, recvPrompt, recvTitle, overBg, overLabel].forEach(a => a.to(T.s5, .5, { opacity:.3 })); for (const s of [0, 1]) senderCols[s].forEach((a, i) => { if (typeOf(s, i) === 'g') a.to(T.s5 + .1, .5, { opacity:.3 }); }); const picks = []; for (const s of [0, 1]) { SEL[s].forEach(i => picks.push({ s, i, type:'p' })); for (let k = 0; k < LAT; k++) picks.push({ s, i:N + k, type:'b' }); } const purples = picks.filter(p => p.type === 'p'), blues = picks.filter(p => p.type === 'b'); const sortedX = p => p.type === 'p' ? STX + purples.indexOf(p) * P : STX + (purples.length + blues.indexOf(p)) * P + GROUP_GAP; const sortT = T.s5 + 2.2; picks.forEach((p, j) => { const c = rect({ x:colX(p.i), y:SY[p.s], w:W, h:H, fill:fillOf[p.type], opacity:0 }, L.clone); c.at(T.s5 + .3, { opacity:1 }).to(T.s5 + .3, .3, { y:SY[p.s] - 8 }, 'out'); // lift out c.to(T.s5 + .8 + j * .08, .8, { x:STX + j * P, y:STY }); // gather if (p.type === 'b') { // sort: blues dodge down, slide, come back c.to(sortT, .3, { y:STY + 40 }).to(sortT + .85, .3, { y:STY }); } c.to(sortT + .35, .45, { x:sortedX(p) }); p.actor = c; }); txt('selected + latent only', STX, STY - 10, { size:13, fill:'#888' }).to(T.s5 + .8, .5, { opacity:1 }); /* --- stage 6: write into CacheBack receiver cache --- */ txt('CacheBack', RX, CY - 10, { size:15, weight:700 }).to(T.s6, .5, { opacity:1 }); const perSender = SEL[0].length + LAT; const cbX = (p) => { const m = p.type === 'p' ? SEL[p.s].indexOf(p.i) : SEL[p.s].length + (p.i - N); return RX + p.s * (perSender * P + SEG) + m * P; }; const cbOrder = [...picks]; // per-sender: selected positions, then latent steps cbOrder.forEach((p, j) => { const c = rect({ x:sortedX(p), y:STY, w:W, h:H, fill:fillOf[p.type], opacity:0 }, L.clone); const t0 = T.s6 + .3 + j * .07; c.at(t0, { opacity:1 }).to(t0, .9, { x:cbX(p), y:CY }); }); const cbPromptX = RX + 2 * (perSender * P + SEG); rect({ x:cbPromptX + 40, y:CY, w:PROMPT_W, h:H, fill:C.teal, opacity:0 }, L.clone).to(T.s6 + 1.5, .7, { x:cbPromptX, opacity:1 }, 'out'); txt('✓ fits', cbPromptX + PROMPT_W + 14, CY + H / 2 + 5, { size:15, weight:700, fill:C.green }).to(T.s6 + 2.3, .4, { opacity:1 }); /* --- stage 7: purple entries compress to token IDs --- */ picks.forEach((p, j) => { const c = rect({ x:sortedX(p), y:STY, w:W, h:H, fill:fillOf[p.type], opacity:0 }, L.clone); const t0 = T.s7 + .3 + j * .08; c.at(t0, { opacity:1 }); if (p.type === 'p') c.to(t0, .9, { y:TY + H / 2 - 4, h:8 }); else c.to(t0, .9, { y:TY }); }); txt('Token IDs', STX + (purples.length * P - (P - W)) / 2, TY + H / 2 - 12, { size:14, weight:600, fill:C.purpleText, anchor:'middle' }) .to(T.s7 + 1.6, .4, { opacity:1 }); txt('+prefill', STX + purples.length * P + GROUP_GAP + (blues.length * P - (P - W)) / 2, TY + H + 20, { size:14, weight:600, fill:C.purpleText, anchor:'middle' }) .to(T.s7 + 2.0, .4, { opacity:1 }); /* --- legend (always visible) --- */ { const ly = 524, items = [ { x:130, fill:C.purple, label:'Selected for receiver query q', color:C.purpleText }, { x:385, fill:C.gray, label:'Unselected positions', color:'#888' }, { x:580, fill:C.blue, label:'Latent steps', color:C.blueText }, { x:730, fill:C.teal, label:'Receiver prompt', color:C.tealText }, ]; for (const it of items) { el('rect', { x:it.x, y:ly - 13, width:16, height:16, rx:2, fill:it.fill }, svg); const t = el('text', { x:it.x + 24, y:ly, 'font-size':14, fill:it.color }, svg); t.textContent = it.label; } el('line', { x1:905, y1:ly - 5, x2:935, y2:ly - 5, stroke:C.red, 'stroke-width':2.5, 'stroke-dasharray':'6 4' }, svg); const t = el('text', { x:943, y:ly, 'font-size':14, fill:C.red }, svg); t.textContent = 'Context limit'; } actors.forEach(a => a.finalize()); /* ===================== playback ===================== */ const button = document.getElementById('animation-play'); let t = T.end, playing = false, lastTs = null, stageIdx = -1; function render(time) { for (const a of actors) a.eval(time); let idx = 0; for (let i = 0; i < STAGES.length; i++) if (time >= STAGES[i].t) idx = i; if (idx !== stageIdx) { stageIdx = idx; document.getElementById('animation-caption').innerHTML = STAGES[idx].cap; } } function pause() { playing = false; button.textContent = '▶'; button.title = t >= T.end ? 'Replay animation' : 'Play animation'; button.setAttribute('aria-label', button.title); } function frame(ts) { if (!playing) return; if (lastTs !== null) t = Math.min(T.end, t + (ts - lastTs) / 1000); lastTs = ts; render(t); if (t >= T.end) pause(); else requestAnimationFrame(frame); } button.onclick = () => { if (playing) { pause(); return; } if (t >= T.end) t = 0; playing = true; lastTs = null; button.textContent = 'Ⅱ'; button.title = 'Pause animation'; button.setAttribute('aria-label', button.title); requestAnimationFrame(frame); }; render(t);