gary-boon Claude Opus 4.7 (1M context) commited on
Commit
f578686
·
1 Parent(s): a1b2ad2

Backend: add mask_positions intervention for What-if "Test this token"

Browse files

The frontend's What-if lens "Test this token" mode posts
intervention_type=mask_positions with a list of arbitrary prompt
positions to mask, but the backend only ever implemented the three
contiguous-region masks (mask_system / mask_user_span /
mask_generated). Requests hit the "Unknown intervention type"
fallthrough.

Adds a mask_positions branch to /analyze/intervention:
- params.positions: list[int] (clamped to valid range, deduped)
- params.selected_token_id: int (optional; defaults to original
step's winner — the token whose logit delta we track)
- params.top_k: int (default 8, clamped 1–50)

Zeroes those positions in the attention mask, re-runs the forward
pass, returns the standard InterventionResponse with a rich details
dict matching the frontend's AblationResult interface: mask_type,
mask_positions_count, ablated_positions, seq_len, prompt_len,
selected_token_id / selected_token, selected_logit_original /
_ablated / _delta, and top_k_after_ablation with per-entry
{token_id, token, logit, probability}.

Verified locally: marking "implementation of" in a quicksort
prompt shifts the score by -0.375 and surfaces the top-6
post-ablation alternatives correctly.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

Files changed (1) hide show
  1. backend/model_service.py +108 -1
backend/model_service.py CHANGED
@@ -4503,7 +4503,7 @@ async def get_attention_row(
4503
  class InterventionRequest(BaseModel):
4504
  request_id: str
4505
  step: int
4506
- intervention_type: str # "mask_system" | "mask_user_span" | "mask_generated" | "greedy" | "temperature_sweep" | "layer_ablation" | "head_ablation" | "expert_mask"
4507
  params: dict = {}
4508
 
4509
  class InterventionResponse(BaseModel):
@@ -4661,6 +4661,113 @@ async def run_intervention(request: InterventionRequest, authenticated: bool = D
4661
  }
4662
  )
4663
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
4664
  elif request.intervention_type == "layer_ablation":
4665
  # Zero out a specific layer's contribution and recompute
4666
  layer_idx = request.params.get("layer_idx", 0)
 
4503
  class InterventionRequest(BaseModel):
4504
  request_id: str
4505
  step: int
4506
+ intervention_type: str # "mask_system" | "mask_user_span" | "mask_generated" | "mask_positions" | "greedy" | "temperature_sweep" | "layer_ablation" | "head_ablation" | "expert_mask"
4507
  params: dict = {}
4508
 
4509
  class InterventionResponse(BaseModel):
 
4661
  }
4662
  )
4663
 
4664
+ elif request.intervention_type == "mask_positions":
4665
+ # Per-position mask: the caller hands us an explicit list of
4666
+ # absolute token positions to zero out in the attention mask.
4667
+ # Used by the What-if lens's "Test this token" mode where the
4668
+ # user marks individual prompt tokens (not contiguous spans).
4669
+ #
4670
+ # Returns a richer details dict than the contiguous-region
4671
+ # mask handlers: top-K alternatives post-ablation, plus the
4672
+ # selected token's logit delta if the caller passed
4673
+ # selected_token_id. The frontend's AblationResult interface
4674
+ # expects all of these.
4675
+ positions = request.params.get("positions", []) or []
4676
+ if not isinstance(positions, list) or not all(isinstance(p, int) for p in positions):
4677
+ raise HTTPException(status_code=400, detail="mask_positions requires params.positions: list[int]")
4678
+ top_k = int(request.params.get("top_k", 8))
4679
+ top_k = max(1, min(top_k, 50))
4680
+ selected_token_id_param = request.params.get("selected_token_id")
4681
+
4682
+ cached_current_ids = hidden_state_cache.get_current_ids(request.request_id, request.step)
4683
+ input_ids_prompt = hidden_state_cache.get_input_ids(request.request_id)
4684
+ if cached_current_ids is None and input_ids_prompt is None:
4685
+ raise HTTPException(status_code=404, detail="Sequence data not available for this step. Please re-generate.")
4686
+
4687
+ if cached_current_ids is not None:
4688
+ full_ids = cached_current_ids.to(manager.device)
4689
+ else:
4690
+ full_ids = input_ids_prompt.to(manager.device)
4691
+
4692
+ seq_len = full_ids.shape[-1]
4693
+ prompt_len = input_ids_prompt.shape[-1] if input_ids_prompt is not None else seq_len
4694
+
4695
+ # Clamp to valid range and dedupe. Out-of-range positions are
4696
+ # silently dropped rather than raising — the UI may send a
4697
+ # stale set if the user navigated between steps with marks
4698
+ # still active.
4699
+ valid_positions = sorted({p for p in positions if 0 <= p < seq_len})
4700
+
4701
+ attention_mask = torch.ones(1, seq_len, dtype=torch.long, device=manager.device)
4702
+ for p in valid_positions:
4703
+ attention_mask[0, p] = 0
4704
+
4705
+ with torch.no_grad():
4706
+ masked_outputs = manager.model(
4707
+ full_ids,
4708
+ attention_mask=attention_mask,
4709
+ output_hidden_states=False,
4710
+ output_attentions=False,
4711
+ )
4712
+ recomputed_logits = masked_outputs.logits[0, -1, :]
4713
+
4714
+ top2_new, top2_new_ids = torch.topk(recomputed_logits, k=2)
4715
+ top2_new_list = top2_new.cpu().tolist()
4716
+ top2_new_ids_list = top2_new_ids.cpu().tolist()
4717
+ recomputed_margin = top2_new_list[0] - top2_new_list[1] if len(top2_new_list) >= 2 else 0.0
4718
+ recomputed_winner = manager.tokenizer.decode([top2_new_ids_list[0]], skip_special_tokens=False)
4719
+
4720
+ # The "selected token" is the one the user is studying — by
4721
+ # default the original winner at this step, but the caller
4722
+ # can override (e.g. to track the runner-up's behaviour).
4723
+ if isinstance(selected_token_id_param, int):
4724
+ selected_token_id = selected_token_id_param
4725
+ else:
4726
+ selected_token_id = top2_orig_ids_list[0]
4727
+ selected_token_text = manager.tokenizer.decode([selected_token_id], skip_special_tokens=False)
4728
+ selected_logit_original = float(raw_logits[selected_token_id].item())
4729
+ selected_logit_ablated = float(recomputed_logits[selected_token_id].item())
4730
+ selected_logit_delta = selected_logit_ablated - selected_logit_original
4731
+
4732
+ # Top-K alternatives after ablation, with both logits and
4733
+ # softmax probabilities so the frontend can render either.
4734
+ recomputed_probs = torch.softmax(recomputed_logits, dim=0)
4735
+ topk_vals, topk_ids = torch.topk(recomputed_logits, k=top_k)
4736
+ topk_ids_list = topk_ids.cpu().tolist()
4737
+ topk_vals_list = topk_vals.cpu().tolist()
4738
+ top_k_after_ablation = []
4739
+ for tid, logit_val in zip(topk_ids_list, topk_vals_list):
4740
+ top_k_after_ablation.append({
4741
+ "token_id": int(tid),
4742
+ "token": manager.tokenizer.decode([tid], skip_special_tokens=False),
4743
+ "logit": float(logit_val),
4744
+ "probability": float(recomputed_probs[tid].item()),
4745
+ })
4746
+
4747
+ return InterventionResponse(
4748
+ original_margin=original_margin,
4749
+ recomputed_margin=recomputed_margin,
4750
+ margin_shift=recomputed_margin - original_margin,
4751
+ original_stability=_classify_stability(original_margin),
4752
+ recomputed_stability=_classify_stability(recomputed_margin),
4753
+ original_winner=original_winner,
4754
+ recomputed_winner=recomputed_winner,
4755
+ winner_changed=top2_new_ids_list[0] != top2_orig_ids_list[0],
4756
+ details={
4757
+ "mask_type": "mask_positions",
4758
+ "mask_positions_count": len(valid_positions),
4759
+ "ablated_positions": valid_positions,
4760
+ "seq_len": seq_len,
4761
+ "prompt_len": prompt_len,
4762
+ "selected_token_id": int(selected_token_id),
4763
+ "selected_token": selected_token_text,
4764
+ "selected_logit_original": selected_logit_original,
4765
+ "selected_logit_ablated": selected_logit_ablated,
4766
+ "selected_logit_delta": selected_logit_delta,
4767
+ "top_k_after_ablation": top_k_after_ablation,
4768
+ }
4769
+ )
4770
+
4771
  elif request.intervention_type == "layer_ablation":
4772
  # Zero out a specific layer's contribution and recompute
4773
  layer_idx = request.params.get("layer_idx", 0)