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

126 statements  

« prev     ^ index     » next       coverage.py v7.10.1, created at 2026-09-21 19:27 +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 .profiles import TLBridgeProfile 

30 

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

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

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

34 

35 

36class InspectDriver(DriverBase): 

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

38 

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

40 _supported_features = frozenset() 

41 

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

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

44 self._model = model 

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

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

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

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

49 self.provides_sequence_logits = self._profile.provides_sequence_logits 

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

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

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

53 universe = hooks.all_hook_points(self._n_layers) 

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

55 universe - self.supported_hook_points 

56 ) 

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

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

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

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

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

62 

63 def forward( 

64 self, 

65 input_ids: TensorLike | None = None, 

66 *, 

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

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

69 max_new_tokens: int = 1, 

70 return_logits: bool = True, 

71 **kwargs: Any, 

72 ) -> ForwardResult: 

73 if self._model is None: 

74 raise RuntimeError("InspectDriver is closed.") 

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

76 raise ValueError("InspectDriver requires input_ids") 

77 if int(max_new_tokens) != 1: 

78 raise NotImplementedError( 

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

80 ) 

81 ids = self._normalize_input_ids(input_ids) 

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

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

84 names = list(capture) 

85 wire_keys = self._wire_keys(names) 

86 interventions = self._profile.translate_interventions( 

87 intervene or {}, self.supported_hook_points 

88 ) 

89 prompt, extra_args = self._profile.build_request( 

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

91 ) 

92 

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

94 

95 captured = self._assemble_captures(output, names) 

96 logits = ( 

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

98 if return_logits 

99 else None 

100 ) 

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

102 

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

104 from inspect_ai.model import GenerateConfig 

105 

106 config = GenerateConfig( 

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

108 ) 

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

110 

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

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

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

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

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

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

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

118 missing: list[str] = [] 

119 for name in names: 

120 resolved = hooks.resolve(name) 

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

122 continue 

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

124 if arr is None: 

125 missing.append(name) 

126 continue 

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

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

129 self._warn_missing(missing) 

130 return captured 

131 

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

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

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

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

136 if new: 

137 self._warned_missing.update(new) 

138 warnings.warn( 

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

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

141 UserWarning, 

142 stacklevel=2, 

143 ) 

144 

145 def close(self) -> None: 

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

147 with self._loop_lock: 

148 loop, self._loop = self._loop, None 

149 thread, self._loop_thread = self._loop_thread, None 

150 if loop is not None: 

151 try: 

152 loop.call_soon_threadsafe(loop.stop) 

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

154 thread.join(timeout=5) 

155 loop.close() 

156 except Exception as e: 

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

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

159 

160 # ---- helpers ---- 

161 

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

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

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

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

166 loop = self._loop 

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

168 return loop 

169 with self._loop_lock: 

170 if self._loop is None: 

171 self._loop = asyncio.new_event_loop() 

172 self._loop_thread = threading.Thread( 

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

174 ) 

175 self._loop_thread.start() 

176 return self._loop 

177 

178 def _abandon_loop(self) -> None: 

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

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

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

182 lazily on the next forward.""" 

183 with self._loop_lock: 

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

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

186 try: 

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

188 except Exception: 

189 pass 

190 warnings.warn( 

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

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

193 UserWarning, 

194 stacklevel=3, 

195 ) 

196 

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

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

199 try: 

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

201 except FutureTimeout: 

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

203 self._abandon_loop() 

204 raise TimeoutError( 

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

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

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

208 ) from None 

209 

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

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

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

213 return sorted(keys) 

214 

215 @staticmethod 

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

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

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

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

220 if hasattr(input_ids, "detach") and hasattr(input_ids, "cpu"): 220 ↛ 222line 220 didn't jump to line 222 because the condition on line 220 was always true

221 input_ids = input_ids.detach().cpu() 

222 arr = np.asarray(input_ids) 

223 if arr.ndim == 2: 

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

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

226 arr = arr[0] 

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