Skip to main content

every_other_token/
patching.rs

1//! # Activation Patching
2//!
3//! Token-level activation patching for mechanistic interpretability.
4//!
5//! ## Method
6//!
7//! Run the model twice:
8//!   1. **Clean run** – original prompt, record logprobs at each token position.
9//!   2. **Corrupted run** – corrupted prompt (token substitutions), record logprobs.
10//!
11//! "Patching" means restoring the clean activation at a specific position into the
12//! corrupted run and measuring how much the final output recovers.  Because we
13//! operate over the API (no direct weight access), we approximate the patch effect
14//! via a *logit-difference* metric:
15//!
16//!   `patch_effect(pos) = logprob_clean(target) - logprob_corrupted(target)`
17//!
18//! A large positive value at position `pos` means that token's activation carries
19//! information critical to producing the target output.
20//!
21//! ## Usage
22//!
23//! ```rust
24//! use every_other_token::patching::{ActivationPatcher, PatchConfig, CorruptionStrategy};
25//!
26//! let cfg = PatchConfig::default();
27//! let patcher = ActivationPatcher::new(cfg);
28//!
29//! let prompt = "The capital of France is";
30//! let corrupted = patcher.corrupt(prompt, CorruptionStrategy::RandomTokenSwap);
31//! assert_ne!(prompt, corrupted.as_str());
32//! ```
33
34use serde::{Deserialize, Serialize};
35use std::collections::HashMap;
36
37// ── Corruption strategies ─────────────────────────────────────────────────────
38
39/// How to corrupt the original prompt for the noisy run.
40#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
41pub enum CorruptionStrategy {
42    /// Replace every token with a random token from a fixed vocabulary.
43    RandomTokenSwap,
44    /// Replace tokens with their antonyms / semantically distant words.
45    SemanticNegation,
46    /// Shuffle the token order uniformly at random.
47    Shuffle,
48    /// Zero-ablate: replace all tokens with a single neutral filler token ("the").
49    ZeroAblate,
50    /// Corrupt only a specific subset of positions (given as indices).
51    PositionSubset(Vec<usize>),
52}
53
54impl Default for CorruptionStrategy {
55    fn default() -> Self {
56        Self::ZeroAblate
57    }
58}
59
60// ── Config ────────────────────────────────────────────────────────────────────
61
62/// Configuration for activation patching experiments.
63#[derive(Debug, Clone, Serialize, Deserialize)]
64pub struct PatchConfig {
65    /// Which corruption strategy to apply to produce the corrupted run.
66    pub corruption: CorruptionStrategy,
67    /// Token positions to patch (empty = all positions).
68    pub patch_positions: Vec<usize>,
69    /// Target token whose log-probability is tracked as the metric.
70    pub target_token: Option<String>,
71    /// Number of independent patch trials to average over.
72    pub num_trials: usize,
73}
74
75impl Default for PatchConfig {
76    fn default() -> Self {
77        Self {
78            corruption: CorruptionStrategy::default(),
79            patch_positions: vec![],
80            target_token: None,
81            num_trials: 1,
82        }
83    }
84}
85
86// ── Results ───────────────────────────────────────────────────────────────────
87
88/// Per-position result of an activation patch experiment.
89#[derive(Debug, Clone, Serialize, Deserialize)]
90pub struct PositionPatchResult {
91    /// Token position that was patched.
92    pub position: usize,
93    /// The token string at this position in the clean prompt.
94    pub clean_token: String,
95    /// The token string at this position in the corrupted prompt.
96    pub corrupted_token: String,
97    /// Log-probability of the target token in the **clean** run.
98    pub logprob_clean: f64,
99    /// Log-probability of the target token in the **corrupted** run.
100    pub logprob_corrupted: f64,
101    /// Estimated patch effect: `logprob_clean - logprob_corrupted`.
102    /// Positive → this position's clean activation helps produce the target.
103    pub patch_effect: f64,
104    /// Normalised patch effect (fraction of total recovery budget, 0–1).
105    pub normalised_effect: f64,
106}
107
108/// Full result set for one patching experiment.
109#[derive(Debug, Clone, Serialize, Deserialize)]
110pub struct PatchingResult {
111    /// Original (clean) prompt.
112    pub clean_prompt: String,
113    /// Corrupted prompt used for the noisy baseline.
114    pub corrupted_prompt: String,
115    /// Target token being tracked.
116    pub target_token: String,
117    /// Per-position results, sorted by |patch_effect| descending.
118    pub positions: Vec<PositionPatchResult>,
119    /// Summary: which positions contributed most to recovery.
120    pub top_positions: Vec<usize>,
121}
122
123impl PatchingResult {
124    /// Return the positions sorted by patch effect (most positive first).
125    pub fn ranked_positions(&self) -> Vec<&PositionPatchResult> {
126        let mut refs: Vec<&PositionPatchResult> = self.positions.iter().collect();
127        refs.sort_by(|a, b| b.patch_effect.partial_cmp(&a.patch_effect).unwrap());
128        refs
129    }
130
131    /// Render a compact ASCII summary suitable for the TUI.
132    pub fn ascii_summary(&self) -> String {
133        let mut out = String::new();
134        out.push_str(&format!(
135            "Activation Patching: target=\"{}\"\n",
136            self.target_token
137        ));
138        out.push_str(&format!(
139            "  clean:     {}\n",
140            truncate(&self.clean_prompt, 60)
141        ));
142        out.push_str(&format!(
143            "  corrupted: {}\n\n",
144            truncate(&self.corrupted_prompt, 60)
145        ));
146        out.push_str("  pos  token           effect   norm\n");
147        out.push_str("  ───  ─────────────  ───────  ────\n");
148        for p in self.ranked_positions().iter().take(10) {
149            out.push_str(&format!(
150                "  {:>3}  {:<13}  {:>+7.3}  {:.2}\n",
151                p.position,
152                truncate(&p.clean_token, 13),
153                p.patch_effect,
154                p.normalised_effect,
155            ));
156        }
157        out
158    }
159
160    /// Render a bar-chart heatmap of patch effects for each position.
161    pub fn heatmap_bars(&self) -> Vec<(String, f64)> {
162        self.positions
163            .iter()
164            .map(|p| (p.clean_token.clone(), p.patch_effect))
165            .collect()
166    }
167}
168
169fn truncate(s: &str, n: usize) -> String {
170    if s.len() <= n {
171        s.to_string()
172    } else {
173        format!("{}…", &s[..n.saturating_sub(1)])
174    }
175}
176
177// ── Core patcher ──────────────────────────────────────────────────────────────
178
179/// Activation patcher – simulates the clean/corrupted dual-run experiment.
180pub struct ActivationPatcher {
181    pub config: PatchConfig,
182}
183
184impl ActivationPatcher {
185    pub fn new(config: PatchConfig) -> Self {
186        Self { config }
187    }
188
189    /// Corrupt a prompt according to the configured strategy.
190    ///
191    /// In a real deployment this would call the tokeniser; here we operate on
192    /// whitespace-split tokens as a transport-layer approximation.
193    pub fn corrupt(&self, prompt: &str, strategy: CorruptionStrategy) -> String {
194        let tokens: Vec<&str> = prompt.split_whitespace().collect();
195        match strategy {
196            CorruptionStrategy::ZeroAblate => {
197                vec!["the"; tokens.len()].join(" ")
198            }
199            CorruptionStrategy::RandomTokenSwap => {
200                let fillers = ["cat", "blue", "runs", "seven", "above", "cold"];
201                tokens
202                    .iter()
203                    .enumerate()
204                    .map(|(i, t)| {
205                        // Deterministic pseudo-random based on position + length
206                        if i % 3 == 0 {
207                            fillers[i % fillers.len()]
208                        } else {
209                            t
210                        }
211                    })
212                    .collect::<Vec<_>>()
213                    .join(" ")
214            }
215            CorruptionStrategy::Shuffle => {
216                let mut shuffled = tokens.clone();
217                // Deterministic Fisher-Yates using position-based seed
218                let n = shuffled.len();
219                for i in (1..n).rev() {
220                    let j = (i * 6364136223846793005 + 1442695040888963407) % (i + 1);
221                    shuffled.swap(i, j);
222                }
223                shuffled.join(" ")
224            }
225            CorruptionStrategy::SemanticNegation => tokens
226                .iter()
227                .map(|t| negate_word(t))
228                .collect::<Vec<_>>()
229                .join(" "),
230            CorruptionStrategy::PositionSubset(ref positions) => {
231                let filler = "the";
232                tokens
233                    .iter()
234                    .enumerate()
235                    .map(|(i, t)| if positions.contains(&i) { filler } else { t })
236                    .collect::<Vec<_>>()
237                    .join(" ")
238            }
239        }
240    }
241
242    /// Simulate a patching experiment using pre-computed logprob tables.
243    ///
244    /// `clean_logprobs[pos]` and `corrupted_logprobs[pos]` should map target
245    /// tokens to their log-probability at each position.  In a live integration
246    /// these are obtained from two separate API calls with `logprobs=true`.
247    pub fn run_patch_experiment(
248        &self,
249        clean_prompt: &str,
250        corrupted_prompt: &str,
251        target_token: &str,
252        clean_logprobs: &[HashMap<String, f64>],
253        corrupted_logprobs: &[HashMap<String, f64>],
254    ) -> PatchingResult {
255        let clean_tokens: Vec<&str> = clean_prompt.split_whitespace().collect();
256        let corrupted_tokens: Vec<&str> = corrupted_prompt.split_whitespace().collect();
257
258        let n = clean_tokens.len().min(corrupted_tokens.len());
259        let patch_positions: Vec<usize> = if self.config.patch_positions.is_empty() {
260            (0..n).collect()
261        } else {
262            self.config
263                .patch_positions
264                .iter()
265                .cloned()
266                .filter(|&p| p < n)
267                .collect()
268        };
269
270        let mut positions: Vec<PositionPatchResult> = patch_positions
271            .iter()
272            .map(|&pos| {
273                let lp_clean = clean_logprobs
274                    .get(pos)
275                    .and_then(|m| m.get(target_token))
276                    .copied()
277                    .unwrap_or(-10.0);
278                let lp_corrupted = corrupted_logprobs
279                    .get(pos)
280                    .and_then(|m| m.get(target_token))
281                    .copied()
282                    .unwrap_or(-10.0);
283                PositionPatchResult {
284                    position: pos,
285                    clean_token: clean_tokens.get(pos).unwrap_or(&"").to_string(),
286                    corrupted_token: corrupted_tokens.get(pos).unwrap_or(&"").to_string(),
287                    logprob_clean: lp_clean,
288                    logprob_corrupted: lp_corrupted,
289                    patch_effect: lp_clean - lp_corrupted,
290                    normalised_effect: 0.0, // filled below
291                }
292            })
293            .collect();
294
295        // Normalise
296        let total_effect: f64 = positions.iter().map(|p| p.patch_effect.max(0.0)).sum();
297        if total_effect > 0.0 {
298            for p in &mut positions {
299                p.normalised_effect = p.patch_effect.max(0.0) / total_effect;
300            }
301        }
302
303        let mut top: Vec<usize> = positions
304            .iter()
305            .enumerate()
306            .filter(|(_, p)| p.patch_effect > 0.0)
307            .map(|(i, _)| i)
308            .collect();
309        top.sort_by(|&a, &b| {
310            positions[b]
311                .patch_effect
312                .partial_cmp(&positions[a].patch_effect)
313                .unwrap()
314        });
315        top.truncate(5);
316
317        PatchingResult {
318            clean_prompt: clean_prompt.to_string(),
319            corrupted_prompt: corrupted_prompt.to_string(),
320            target_token: target_token.to_string(),
321            positions,
322            top_positions: top,
323        }
324    }
325}
326
327// ── Helpers ───────────────────────────────────────────────────────────────────
328
329fn negate_word(word: &str) -> &str {
330    let antonyms: &[(&str, &str)] = &[
331        ("good", "bad"),
332        ("bad", "good"),
333        ("hot", "cold"),
334        ("cold", "hot"),
335        ("fast", "slow"),
336        ("slow", "fast"),
337        ("happy", "sad"),
338        ("sad", "happy"),
339        ("big", "small"),
340        ("small", "big"),
341        ("yes", "no"),
342        ("no", "yes"),
343        ("up", "down"),
344        ("down", "up"),
345        ("left", "right"),
346        ("right", "left"),
347    ];
348    for (a, b) in antonyms {
349        if word.eq_ignore_ascii_case(a) {
350            return b;
351        }
352    }
353    word
354}
355
356// ── Tests ─────────────────────────────────────────────────────────────────────
357
358#[cfg(test)]
359mod tests {
360    use super::*;
361
362    #[test]
363    fn corrupt_zero_ablate() {
364        let patcher = ActivationPatcher::new(PatchConfig::default());
365        let result = patcher.corrupt("hello world foo", CorruptionStrategy::ZeroAblate);
366        assert_eq!(result, "the the the");
367    }
368
369    #[test]
370    fn corrupt_semantic_negation() {
371        let patcher = ActivationPatcher::new(PatchConfig::default());
372        let result = patcher.corrupt("good hot fast", CorruptionStrategy::SemanticNegation);
373        assert_eq!(result, "bad cold slow");
374    }
375
376    #[test]
377    fn patch_experiment_basic() {
378        let cfg = PatchConfig {
379            patch_positions: vec![0, 1],
380            target_token: Some("Paris".to_string()),
381            ..Default::default()
382        };
383        let patcher = ActivationPatcher::new(cfg);
384
385        let mut clean_lp = HashMap::new();
386        clean_lp.insert("Paris".to_string(), -0.5);
387        let mut corrupt_lp = HashMap::new();
388        corrupt_lp.insert("Paris".to_string(), -3.0);
389
390        let result = patcher.run_patch_experiment(
391            "The capital of France is",
392            "The the the the the",
393            "Paris",
394            &[clean_lp.clone(), clean_lp.clone()],
395            &[corrupt_lp.clone(), corrupt_lp.clone()],
396        );
397
398        assert_eq!(result.positions.len(), 2);
399        assert!((result.positions[0].patch_effect - 2.5).abs() < 1e-9);
400    }
401
402    #[test]
403    fn ascii_summary_non_empty() {
404        let result = PatchingResult {
405            clean_prompt: "hello world".to_string(),
406            corrupted_prompt: "the the".to_string(),
407            target_token: "world".to_string(),
408            positions: vec![PositionPatchResult {
409                position: 0,
410                clean_token: "hello".to_string(),
411                corrupted_token: "the".to_string(),
412                logprob_clean: -1.0,
413                logprob_corrupted: -4.0,
414                patch_effect: 3.0,
415                normalised_effect: 1.0,
416            }],
417            top_positions: vec![0],
418        };
419        let summary = result.ascii_summary();
420        assert!(summary.contains("world"));
421        assert!(summary.contains("hello"));
422    }
423}