1use serde::{Deserialize, Serialize};
35use std::collections::HashMap;
36
37#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
41pub enum CorruptionStrategy {
42 RandomTokenSwap,
44 SemanticNegation,
46 Shuffle,
48 ZeroAblate,
50 PositionSubset(Vec<usize>),
52}
53
54impl Default for CorruptionStrategy {
55 fn default() -> Self {
56 Self::ZeroAblate
57 }
58}
59
60#[derive(Debug, Clone, Serialize, Deserialize)]
64pub struct PatchConfig {
65 pub corruption: CorruptionStrategy,
67 pub patch_positions: Vec<usize>,
69 pub target_token: Option<String>,
71 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#[derive(Debug, Clone, Serialize, Deserialize)]
90pub struct PositionPatchResult {
91 pub position: usize,
93 pub clean_token: String,
95 pub corrupted_token: String,
97 pub logprob_clean: f64,
99 pub logprob_corrupted: f64,
101 pub patch_effect: f64,
104 pub normalised_effect: f64,
106}
107
108#[derive(Debug, Clone, Serialize, Deserialize)]
110pub struct PatchingResult {
111 pub clean_prompt: String,
113 pub corrupted_prompt: String,
115 pub target_token: String,
117 pub positions: Vec<PositionPatchResult>,
119 pub top_positions: Vec<usize>,
121}
122
123impl PatchingResult {
124 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 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 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
177pub struct ActivationPatcher {
181 pub config: PatchConfig,
182}
183
184impl ActivationPatcher {
185 pub fn new(config: PatchConfig) -> Self {
186 Self { config }
187 }
188
189 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 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 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 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, }
292 })
293 .collect();
294
295 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
327fn 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#[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}