Coverage for transformer_lens/model_bridge/sources/inspect/driver.py: 94%

134 statements  

« prev     ^ index     » next       coverage.py v7.10.1, created at 2026-10-10 05:10 +0000

1"""InspectDriver — torch-free consumer of an ``inspect_ai`` provider's output. 

2 

3Talks to a provider through the inspect_ai ``ModelOutput`` envelope. Everything 

4provider-specific — the request schema, which hooks are served, full vs last-token 

5logits, intervention translation — lives in a :mod:`profiles` Profile; the driver just 

6drives it. Stays numpy-only (``to_torch`` runs at the bridge boundary), so this file 

7imports zero torch symbols (enforced by a unit test). ``inspect_ai`` is imported lazily. 

8""" 

9from __future__ import annotations 

10 

11import asyncio 

12import logging 

13import os 

14import threading 

15import warnings 

16from concurrent.futures import TimeoutError as FutureTimeout 

17from typing import Any, Mapping 

18 

19import numpy as np 

20 

21from transformer_lens.model_bridge.driver_protocol import ( 

22 ForwardResult, 

23 Intervention, 

24 TensorLike, 

25) 

26from transformer_lens.model_bridge.sources._driver_base import DriverBase 

27 

28from . import hooks, wire 

29from .masks import normalize_attention_mask 

30from .profiles import TLBridgeProfile 

31 

32# Cap a single provider call so a hung remote/provider forward unblocks the sync caller 

33# instead of stalling it forever. Generous by default; override for slow remote backends. 

34_PROVIDER_TIMEOUT_S = float(os.environ.get("TL_INSPECT_TIMEOUT_S", "300")) 

35 

36 

37class InspectDriver(DriverBase): 

38 """Driver wrapping an ``inspect_ai`` model; capture + interventions via a Profile.""" 

39 

40 # Remote provider — no torch weight/grad surface. 

41 _supported_features = frozenset() 

42 

43 def __init__(self, model: Any, adapter: Any, tokenizer: Any, profile: Any = None) -> None: 

44 super().__init__(adapter.cfg, tokenizer) 

45 self._model = model 

46 self._profile = profile if profile is not None else TLBridgeProfile() 

47 self._n_layers = int(self.bridge_config.n_layers) 

48 self._d_vocab = int(self.bridge_config.d_vocab) 

49 # Provider-specific: loss/both allowed only if the provider returns full logits. 

50 self.provides_sequence_logits = self._profile.provides_sequence_logits 

51 self.supported_hook_points = self._profile.supported_hooks(self._n_layers) 

52 # Everything the registry could serve (boundaries + head-split) that this 

53 # provider/model doesn't, plus the never-fireable set (embed, ln_final, scores). 

54 universe = hooks.all_hook_points(self._n_layers) 

55 self.non_fireable_hook_points = hooks.nonfireable_hook_points(self._n_layers) | ( 

56 universe - self.supported_hook_points 

57 ) 

58 # Background event loop, created lazily on first forward. 

59 self._loop: asyncio.AbstractEventLoop | None = None 

60 self._loop_thread: threading.Thread | None = None 

61 self._loop_lock = threading.Lock() # guards lazy creation / abandonment 

62 self._warned_missing: set[str] = set() # hooks we've already warned were absent 

63 

64 def forward( 

65 self, 

66 input_ids: TensorLike | None = None, 

67 *, 

68 capture: tuple[str, ...] = (), 

69 intervene: Mapping[str, Intervention] | None = None, 

70 max_new_tokens: int = 1, 

71 return_logits: bool = True, 

72 attention_mask: TensorLike | None = None, 

73 **kwargs: Any, 

74 ) -> ForwardResult: 

75 if self._model is None: 

76 raise RuntimeError("InspectDriver is closed.") 

77 if input_ids is None: 77 ↛ 78line 77 didn't jump to line 78 because the condition on line 77 was never true

78 raise ValueError("InspectDriver requires input_ids") 

79 if int(max_new_tokens) != 1: 

80 raise NotImplementedError( 

81 "InspectDriver supports max_new_tokens=1 only (single-forward capture)." 

82 ) 

83 ids = self._normalize_input_ids(input_ids) 

84 mask = None 

85 if attention_mask is not None: 

86 if not getattr(self._profile, "supports_attention_mask", False): 

87 raise NotImplementedError("This Inspect provider does not support attention_mask.") 

88 mask = normalize_attention_mask(attention_mask, len(ids)) 

89 # capture is authoritative: the bridge passes exactly the hooks with handlers, 

90 # so () means "capture nothing" (logits only), not "capture everything". 

91 names = list(capture) 

92 wire_keys = self._wire_keys(names) 

93 interventions = self._profile.translate_interventions( 

94 intervene or {}, self.supported_hook_points 

95 ) 

96 prompt, extra_args = self._profile.build_request( 

97 ids, wire_keys, interventions, return_logits, self.tokenizer 

98 ) 

99 if mask is not None: 

100 extra_args["attention_mask"] = mask 

101 

102 output = self._run_coro(self._generate(prompt, extra_args)) 

103 

104 captured = self._assemble_captures(output, names) 

105 logits = ( 

106 self._profile.decode_logits(output, len(ids), self._d_vocab, self.tokenizer) 

107 if return_logits 

108 else None 

109 ) 

110 return ForwardResult(logits=logits, captured=captured, raw_output=output) 

111 

112 async def _generate(self, prompt: Any, extra_args: dict[str, Any]) -> Any: 

113 from inspect_ai.model import GenerateConfig 

114 

115 config = GenerateConfig( 

116 temperature=0.0, max_tokens=1, extra_body={"extra_args": extra_args} 

117 ) 

118 return await self._model.generate(prompt, config=config) 

119 

120 def _assemble_captures(self, output: Any, names: list[str]) -> dict[str, np.ndarray]: 

121 """Decode the requested hooks → ``{hook_name: (1, ...)}`` (batch dim added onto the 

122 provider's batchless array — rank 2 for boundaries, 3 for head-split/pattern); 

123 names the provider didn't return are skipped (and warned once).""" 

124 metadata = getattr(output, "metadata", None) or {} 

125 decoded = wire.decode_activations(metadata, self._wire_keys(names)) 

126 captured: dict[str, np.ndarray] = {} 

127 missing: list[str] = [] 

128 for name in names: 

129 resolved = hooks.resolve(name) 

130 if resolved is None: 130 ↛ 131line 130 didn't jump to line 131 because the condition on line 130 was never true

131 continue 

132 arr = decoded.get(hooks.wire_key(*resolved)) 

133 if arr is None: 

134 missing.append(name) 

135 continue 

136 batchless = hooks.WIRE_BATCHLESS_NDIM.get(resolved[1], 2) 

137 captured[name] = arr[np.newaxis, ...] if arr.ndim == batchless else arr 

138 self._warn_missing(missing) 

139 return captured 

140 

141 def _warn_missing(self, missing: list[str]) -> None: 

142 """Warn once per hook the provider was asked for but didn't return — else its 

143 cache entry is silently absent and surfaces only as a later KeyError.""" 

144 new = [name for name in missing if name not in self._warned_missing] 

145 if new: 

146 self._warned_missing.update(new) 

147 warnings.warn( 

148 f"InspectDriver: provider returned no activation for {sorted(new)} " 

149 "(requested and in supported_hook_points); those cache keys will be absent.", 

150 UserWarning, 

151 stacklevel=2, 

152 ) 

153 

154 def close(self) -> None: 

155 log = logging.getLogger("transformer_lens.inspect") 

156 with self._loop_lock: 

157 loop, self._loop = self._loop, None 

158 thread, self._loop_thread = self._loop_thread, None 

159 if loop is not None: 

160 try: 

161 loop.call_soon_threadsafe(loop.stop) 

162 if thread is not None: 162 ↛ 164line 162 didn't jump to line 164 because the condition on line 162 was always true

163 thread.join(timeout=5) 

164 loop.close() 

165 except Exception as e: 

166 log.debug("event-loop teardown failed during close(): %s", e) 

167 self._model = None # drop the provider reference; the server owns its own lifecycle 

168 

169 # ---- helpers ---- 

170 

171 def _ensure_loop(self) -> asyncio.AbstractEventLoop: 

172 """A private loop on a daemon thread — works even when the caller is already 

173 inside a running loop (Jupyter), unlike asyncio.run(). Locked: unlocked 

174 check-then-create would leak loops under concurrent first calls.""" 

175 loop = self._loop 

176 if loop is not None: # GIL-safe fast path; the lock only guards create/abandon 

177 return loop 

178 with self._loop_lock: 

179 if self._loop is None: 

180 self._loop = asyncio.new_event_loop() 

181 self._loop_thread = threading.Thread( 

182 target=self._loop.run_forever, daemon=True, name="inspect-driver-loop" 

183 ) 

184 self._loop_thread.start() 

185 return self._loop 

186 

187 def _abandon_loop(self) -> None: 

188 """After a timeout the loop thread is still occupied by the hung forward (cancel 

189 can't interrupt sync work), so every later call would queue behind it and time out. 

190 Abandon it — the daemon thread stops once the hung call returns — and rebuild 

191 lazily on the next forward.""" 

192 with self._loop_lock: 

193 loop, self._loop, self._loop_thread = self._loop, None, None 

194 if loop is not None: 194 ↛ 199line 194 didn't jump to line 199 because the condition on line 194 was always true

195 try: 

196 loop.call_soon_threadsafe(loop.stop) # takes effect when the hung call ends 

197 except Exception: 

198 pass 

199 warnings.warn( 

200 "InspectDriver: abandoning the wedged event-loop thread after a provider " 

201 "timeout; a fresh loop will be created on the next forward.", 

202 UserWarning, 

203 stacklevel=3, 

204 ) 

205 

206 def _run_coro(self, coro: Any) -> Any: 

207 future = asyncio.run_coroutine_threadsafe(coro, self._ensure_loop()) 

208 try: 

209 return future.result(timeout=_PROVIDER_TIMEOUT_S) # re-raises provider errors 

210 except FutureTimeout: 

211 future.cancel() # best-effort; can't interrupt a sync forward on the loop thread 

212 self._abandon_loop() 

213 raise TimeoutError( 

214 f"Inspect provider call exceeded {_PROVIDER_TIMEOUT_S:.0f}s " 

215 "(set TL_INSPECT_TIMEOUT_S to change) — the remote/provider forward looks " 

216 "hung; unblocking the caller rather than stalling indefinitely." 

217 ) from None 

218 

219 def _wire_keys(self, names: list[str]) -> list[str]: 

220 """Unique ``<layer>:<kind>`` keys for the requested hook names (aliases collapse).""" 

221 keys = {hooks.wire_key(*r) for r in (hooks.resolve(n) for n in names) if r is not None} 

222 return sorted(keys) 

223 

224 @staticmethod 

225 def _normalize_input_ids(input_ids: Any) -> list[int]: 

226 """Coerce to a flat list[int] (batch_size=1 only); numpy/list/tensor, no torch import.""" 

227 # Duck-type a torch tensor onto CPU first — np.asarray can't read CUDA memory, 

228 # and a torch-free driver can't import torch to .cpu() it. 

229 if hasattr(input_ids, "detach") and hasattr(input_ids, "cpu"): 

230 input_ids = input_ids.detach().cpu() 

231 arr = np.asarray(input_ids) 

232 if arr.ndim == 2: 

233 if arr.shape[0] != 1: 

234 raise NotImplementedError("InspectDriver supports batch_size=1 only.") 

235 arr = arr[0] 

236 return [int(x) for x in arr.tolist()]