arcus-cli 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
arcus/cli.py ADDED
@@ -0,0 +1,335 @@
1
+ import sys
2
+ import threading
3
+ from uuid import uuid4
4
+
5
+ import typer
6
+ from rich.console import Console
7
+ from rich.table import Table
8
+ from sqlmodel import Session, select
9
+
10
+ from arcus.adapters.arc_adapter import ArcAdapter, ArcModel
11
+ from arcus.cache.semantic_cache import lookup as cache_lookup
12
+ from arcus.cache.semantic_cache import store as cache_store
13
+ from arcus.config import ArcusConfig, BanditAlgorithm, config_path, load_config, save_config
14
+ from arcus.embeddings import get_embedding_model
15
+ from arcus.quality.gate import call_with_quality_gate
16
+ from arcus.routing.bandit import (
17
+ Bandit,
18
+ ContextualBandit,
19
+ EpsilonGreedyBandit,
20
+ RandomBandit,
21
+ ThompsonSamplingBandit,
22
+ UCB1Bandit,
23
+ )
24
+ from arcus.routing.context import Context, classify
25
+ from arcus.routing.warm_start import replay_history
26
+ from arcus.storage.db import RequestLog, get_engine, log_request
27
+ from arcus.storage.stats import aggregate_by_arm_and_mode
28
+
29
+ ARMS = [m.value for m in ArcModel]
30
+
31
+ _ALGORITHM_FACTORIES: dict[BanditAlgorithm, "type[Bandit]"] = {
32
+ "epsilon_greedy": EpsilonGreedyBandit,
33
+ "ucb1": UCB1Bandit,
34
+ "thompson": ThompsonSamplingBandit,
35
+ }
36
+
37
+
38
+ def main(argv: list[str] | None = None) -> None:
39
+ """Entry point for the `arcus` command. Dispatches by hand on argv[0]
40
+ rather than using typer's subcommand machinery. the whole point is for
41
+ `arcus "some question"` to just work with no subcommand at all, and a
42
+ real subcommand parser (click underneath typer) fights that: it wants
43
+ to treat the prompt text itself as an unrecognized command. `stats` is
44
+ the one reserved word, everything else is prompt text.
45
+ """
46
+ argv = list(sys.argv[1:] if argv is None else argv)
47
+
48
+ if argv and argv[0] == "stats":
49
+ run_stats()
50
+ return
51
+
52
+ if argv and argv[0] == "chat":
53
+ random_mode = "--random" in argv
54
+ run_chat(random_mode=random_mode)
55
+ return
56
+
57
+ random_mode = "--random" in argv
58
+ argv = [arg for arg in argv if arg != "--random"]
59
+
60
+ prompt = _build_prompt(argv)
61
+ if not prompt:
62
+ _print_usage()
63
+ return
64
+
65
+ run_ask(prompt, random_mode=random_mode)
66
+
67
+
68
+ def _build_prompt(args: list[str]) -> str:
69
+ # if stdin isn't a tty, something got piped in (a traceback, a test
70
+ # failure, whatever), treat that as the main
71
+ # content and let any positional args add an explicit instruction
72
+ # on top of it, e.g. `python broken.py 2>&1 | arcus "why is this
73
+ # failing"`.
74
+ piped = sys.stdin.read().strip() if not sys.stdin.isatty() else ""
75
+ positional = " ".join(args).strip()
76
+
77
+ if piped and positional:
78
+ return f"{piped}\n\n{positional}"
79
+ return piped or positional
80
+
81
+
82
+ def _print_usage() -> None:
83
+ Console().print('usage: arcus "<question>" or arcus chat or arcus stats')
84
+
85
+
86
+ def _ensure_config() -> ArcusConfig:
87
+ try:
88
+ return load_config()
89
+ except FileNotFoundError:
90
+ return _run_setup_wizard()
91
+
92
+
93
+ def _run_setup_wizard() -> ArcusConfig:
94
+ console = Console()
95
+ console.print("[bold]no ARC key found, let's get you set up.[/bold]")
96
+ console.print(
97
+ "grab one from llm.arc.vt.edu under "
98
+ "User profile > Settings > Account > API keys.\n"
99
+ )
100
+
101
+ api_key = typer.prompt("ARC API key", hide_input=True)
102
+
103
+ console.print("checking that key works...")
104
+ try:
105
+ ArcAdapter(api_key=api_key).chat(
106
+ ArcModel.GPT_OSS_120B,
107
+ [{"role": "user", "content": "reply with just the word ok"}],
108
+ max_tokens=5,
109
+ )
110
+ except Exception as e:
111
+ console.print(f"[red]that key didn't work:[/red] {e}")
112
+ raise SystemExit(1) from e
113
+
114
+ config = ArcusConfig(arc_api_key=api_key)
115
+ save_config(config)
116
+ console.print(f"[green]saved to {config_path()}[/green]\n")
117
+ return config
118
+
119
+
120
+ def run_ask(prompt: str, random_mode: bool = False) -> None:
121
+ console = Console()
122
+
123
+ # the embedding model backs both the context classifier's fallback
124
+ # path and the semantic cache lookup below, and loading it costs a
125
+ # few seconds the first time any process touches it. kick that load
126
+ # off now, in the background, so it's warm (or at least warming) by
127
+ # the time either one actually calls embed(), instead of eating that
128
+ # cost inline and in serial with everything else.
129
+ #
130
+ # this has to start AFTER building the ArcAdapter, not before.
131
+ # constructing the openai client is the first time this process
132
+ # touches httpx internals, and openai does that lazily, on client
133
+ # construction, not on import. starting the embedding thread first
134
+ # let it race that first-time httpx touch against sentence-transformers'
135
+ # own (torch/huggingface_hub) import chain, which also reaches into
136
+ # httpx, and that produced a real, reliably reproducible crash:
137
+ # "partially initialized module 'httpx' ... circular import". building
138
+ # the adapter first means the main thread finishes touching httpx
139
+ # before any second thread gets a chance to.
140
+ config = _ensure_config()
141
+ engine = get_engine()
142
+ adapter = ArcAdapter(api_key=config.arc_api_key)
143
+
144
+ threading.Thread(target=get_embedding_model, daemon=True).start()
145
+
146
+ context = classify(prompt)
147
+ mode = "random" if random_mode else "bandit"
148
+
149
+ cached = cache_lookup(prompt, engine=engine)
150
+ if cached.hit:
151
+ console.print(cached.response)
152
+ log_request(
153
+ prompt=prompt,
154
+ task_type=context.task_type.value,
155
+ length_bucket=context.length_bucket.value,
156
+ model=cached.model,
157
+ cache_hit=True,
158
+ quality_passed=True,
159
+ engine=engine,
160
+ )
161
+ return
162
+
163
+ algorithm_factory = RandomBandit if random_mode else _ALGORITHM_FACTORIES[config.bandit_algorithm]
164
+ bandit = ContextualBandit(lambda: algorithm_factory(ARMS), arms=ARMS)
165
+ # rebuild what this bandit already learned from past requests in this
166
+ # same mode, otherwise every invocation starts back at zero since
167
+ # there's no daemon holding it in memory between runs.
168
+ replay_history(bandit, engine, mode=mode)
169
+
170
+ content, model_used, passed = _route_and_answer(
171
+ adapter, bandit, context, prompt, [{"role": "user", "content": prompt}], mode, engine
172
+ )
173
+
174
+ console.print(content if content else "[red]no usable response from any model.[/red]")
175
+
176
+ if passed and content:
177
+ cache_store(prompt, content, model=model_used, engine=engine)
178
+
179
+
180
+ def _route_and_answer(
181
+ adapter: ArcAdapter,
182
+ bandit: ContextualBandit,
183
+ context: Context,
184
+ prompt: str,
185
+ messages: list[dict],
186
+ mode: str,
187
+ engine,
188
+ conversation_id: str | None = None,
189
+ turn_index: int | None = None,
190
+ ) -> tuple[str | None, str, bool]:
191
+ """Runs one turn through the quality gate, logs every attempt, and
192
+ returns (content, model_used, passed). content can be non-None even
193
+ when passed is False (the last attempt still produced text, it just
194
+ didn't clear the gate); content is None only when every arm errored
195
+ out with nothing to show at all.
196
+ """
197
+ outcome = call_with_quality_gate(adapter, bandit, context.key, messages)
198
+
199
+ for attempt in outcome.attempts:
200
+ log_request(
201
+ prompt=prompt,
202
+ task_type=context.task_type.value,
203
+ length_bucket=context.length_bucket.value,
204
+ model=attempt.model,
205
+ propensity=attempt.propensity,
206
+ latency_ms=attempt.latency_ms,
207
+ mode=mode,
208
+ reward=attempt.reward,
209
+ quality_passed=attempt.passed,
210
+ conversation_id=conversation_id,
211
+ turn_index=turn_index,
212
+ engine=engine,
213
+ )
214
+
215
+ # response is None when every arm errored out at the API level (see
216
+ # call_with_quality_gate), not just returned a bad answer
217
+ content = outcome.response.choices[0].message.content if outcome.response else None
218
+ return content, outcome.model_used, outcome.passed
219
+
220
+
221
+ _MAX_CHAT_MESSAGES = 20 # ~10 exchanges, a first-guess cap like the
222
+ # reward weights, not tuned against anything yet
223
+
224
+
225
+ def _trim_history(messages: list[dict], max_messages: int = _MAX_CHAT_MESSAGES) -> list[dict]:
226
+ if len(messages) <= max_messages:
227
+ return messages
228
+ # drop whole oldest turns (user+assistant pairs), not a single
229
+ # message, so the transcript never gets left with an orphaned
230
+ # question and no answer in front of it
231
+ excess = len(messages) - max_messages
232
+ excess += excess % 2
233
+ return messages[excess:]
234
+
235
+
236
+ def run_chat(random_mode: bool = False) -> None:
237
+ console = Console()
238
+
239
+ config = _ensure_config()
240
+ engine = get_engine()
241
+ adapter = ArcAdapter(api_key=config.arc_api_key) # before the thread, same reason as run_ask
242
+
243
+ threading.Thread(target=get_embedding_model, daemon=True).start()
244
+
245
+ mode = "random" if random_mode else "bandit"
246
+ algorithm_factory = RandomBandit if random_mode else _ALGORITHM_FACTORIES[config.bandit_algorithm]
247
+ bandit = ContextualBandit(lambda: algorithm_factory(ARMS), arms=ARMS)
248
+ replay_history(bandit, engine, mode=mode)
249
+
250
+ conversation_id = str(uuid4())
251
+ messages: list[dict] = []
252
+ turn_index = 0
253
+
254
+ console.print("[bold]chatting with arcus, type 'exit' or ctrl-d to leave.[/bold]\n")
255
+
256
+ while True:
257
+ try:
258
+ user_input = input("you: ").strip()
259
+ except (EOFError, KeyboardInterrupt):
260
+ console.print()
261
+ break
262
+
263
+ if not user_input:
264
+ continue
265
+ if user_input.lower() in ("exit", "quit"):
266
+ break
267
+
268
+ messages.append({"role": "user", "content": user_input})
269
+ context = classify(user_input)
270
+
271
+ content, model_used, passed = _route_and_answer(
272
+ adapter,
273
+ bandit,
274
+ context,
275
+ user_input,
276
+ messages,
277
+ mode,
278
+ engine,
279
+ conversation_id=conversation_id,
280
+ turn_index=turn_index,
281
+ )
282
+
283
+ if content:
284
+ messages.append({"role": "assistant", "content": content})
285
+ console.print(f"[green]arcus ({model_used}):[/green] {content}\n")
286
+ else:
287
+ # nothing usable came back, don't leave an unanswered
288
+ # question sitting in history for the next turn to trip over
289
+ messages.pop()
290
+ console.print("[red]no usable response from any model, try rephrasing.[/red]\n")
291
+
292
+ messages = _trim_history(messages)
293
+ turn_index += 1
294
+
295
+
296
+ def run_stats() -> None:
297
+ console = Console()
298
+ engine = get_engine()
299
+
300
+ with Session(engine) as session:
301
+ rows = session.exec(select(RequestLog)).all()
302
+
303
+ if not rows:
304
+ console.print("no requests logged yet, go ask arcus something.")
305
+ return
306
+
307
+ table = Table(title="arcus stats")
308
+ table.add_column("model")
309
+ table.add_column("mode")
310
+ table.add_column("requests", justify="right")
311
+ table.add_column("avg reward", justify="right")
312
+ table.add_column("avg latency (ms)", justify="right")
313
+ table.add_column("cost score", justify="right")
314
+
315
+ for summary in aggregate_by_arm_and_mode(engine):
316
+ table.add_row(
317
+ summary.model,
318
+ summary.mode,
319
+ str(summary.request_count),
320
+ f"{summary.avg_reward:.3f}" if summary.avg_reward is not None else "-",
321
+ f"{summary.avg_latency_ms:.0f}" if summary.avg_latency_ms is not None else "-",
322
+ f"{summary.cost_score:.2f}" if summary.cost_score is not None else "-",
323
+ )
324
+
325
+ console.print(table)
326
+
327
+ cache_hits = sum(1 for row in rows if row.cache_hit)
328
+ gate_catches = sum(1 for row in rows if not row.quality_passed)
329
+
330
+ console.print(f"\ncache hit rate: {cache_hits / len(rows):.1%} ({cache_hits}/{len(rows)})")
331
+ console.print(f"quality gate catches: {gate_catches} attempt(s) failed and got retried")
332
+
333
+
334
+ if __name__ == "__main__":
335
+ main()
arcus/config.py ADDED
@@ -0,0 +1,46 @@
1
+ import tomllib
2
+ from pathlib import Path
3
+ from typing import Literal
4
+
5
+ from platformdirs import user_config_dir
6
+ from pydantic_settings import BaseSettings
7
+
8
+ BanditAlgorithm = Literal["epsilon_greedy", "ucb1", "thompson"]
9
+
10
+
11
+ class ArcusConfig(BaseSettings):
12
+ arc_api_key: str
13
+ # thompson sampling is the default, it's the one that needs the least
14
+ # hand-tuning (no epsilon to pick) and adapts the fastest early on.
15
+ # the algorithm is swappable here rather than hardcoded, so a user
16
+ # can pick a different one without touching code.
17
+ bandit_algorithm: BanditAlgorithm = "thompson"
18
+
19
+
20
+ def config_path() -> Path:
21
+ # computed inside a function rather than a module constant so tests
22
+ # can point it somewhere disposable the same way storage/db.py's data
23
+ # dir already gets monkeypatched.
24
+ return Path(user_config_dir("arcus")) / "config.toml"
25
+
26
+
27
+ def load_config(path: Path | None = None) -> ArcusConfig:
28
+ path = path or config_path()
29
+ with open(path, "rb") as f:
30
+ data = tomllib.load(f)
31
+ return ArcusConfig(**data)
32
+
33
+
34
+ def save_config(config: ArcusConfig, path: Path | None = None) -> None:
35
+ path = path or config_path()
36
+ path.parent.mkdir(parents=True, exist_ok=True)
37
+
38
+ # only two fields right now, not worth pulling in a TOML-writing
39
+ # dependency just to serialize a couple of key/value lines.
40
+ path.write_text(
41
+ f'arc_api_key = "{config.arc_api_key}"\n'
42
+ f'bandit_algorithm = "{config.bandit_algorithm}"\n'
43
+ )
44
+ # the API key lives on disk in plain text, chmod 600 so it's at least
45
+ # not readable by other users on the same machine.
46
+ path.chmod(0o600)
arcus/embeddings.py ADDED
@@ -0,0 +1,34 @@
1
+ import threading
2
+
3
+ import numpy as np
4
+
5
+ _model_cache = None
6
+ _model_lock = threading.Lock()
7
+
8
+
9
+ def get_embedding_model():
10
+ # imported lazily so importing this module doesn't drag torch in for
11
+ # callers that never actually need embeddings. cached as a singleton
12
+ # once loaded since the load itself (not the import) is the slow
13
+ # part, worth paying once per process, not once per call site.
14
+ #
15
+ # the lock matters once the CLI started warming this up on a
16
+ # background thread (see cli.py): without it, a foreground call
17
+ # landing while the warm-up thread is mid-load would see an empty
18
+ # cache and kick off a second, redundant load instead of just
19
+ # waiting for the first one to finish.
20
+ global _model_cache
21
+ if _model_cache is not None:
22
+ return _model_cache
23
+
24
+ with _model_lock:
25
+ if _model_cache is None:
26
+ from sentence_transformers import SentenceTransformer
27
+
28
+ _model_cache = SentenceTransformer("all-MiniLM-L6-v2")
29
+ return _model_cache
30
+
31
+
32
+ def embed(texts: list[str]) -> np.ndarray:
33
+ model = get_embedding_model()
34
+ return model.encode(texts, normalize_embeddings=True)
arcus/eval/__init__.py ADDED
File without changes
arcus/eval/offline.py ADDED
@@ -0,0 +1,219 @@
1
+ import random
2
+ from dataclasses import dataclass
3
+ from statistics import mean
4
+ from typing import Callable
5
+
6
+ from sqlmodel import Session, select
7
+
8
+ from arcus.storage.db import RequestLog
9
+
10
+ Policy = Callable[[str], str]
11
+
12
+
13
+ @dataclass(frozen=True)
14
+ class LoggedExample:
15
+ context_key: str
16
+ arm: str
17
+ propensity: float
18
+ reward: float
19
+
20
+
21
+ def load_logged_examples(engine, mode: str = "bandit") -> list[LoggedExample]:
22
+ """Pulls rows out of the request log that are actually usable for
23
+ offline evaluation: propensity has to be set (the whole point of
24
+ logging it from day one) and reward has to be set (a row that errored
25
+ out before a reward was computed can't tell an estimator anything).
26
+ """
27
+ with Session(engine) as session:
28
+ rows = session.exec(
29
+ select(RequestLog)
30
+ .where(RequestLog.mode == mode)
31
+ .where(RequestLog.propensity.is_not(None))
32
+ .where(RequestLog.reward.is_not(None))
33
+ ).all()
34
+
35
+ return [
36
+ LoggedExample(
37
+ context_key=f"{row.task_type}:{row.length_bucket}",
38
+ arm=row.model,
39
+ propensity=row.propensity,
40
+ reward=row.reward,
41
+ )
42
+ for row in rows
43
+ ]
44
+
45
+
46
+ def policy_always(arm: str) -> Policy:
47
+ return lambda context_key: arm
48
+
49
+
50
+ def greedy_policy_from_log(examples: list[LoggedExample]) -> Policy:
51
+ """Builds a deterministic policy that, for each context bucket, picks
52
+ whichever arm had the best average logged reward. Fit and evaluated
53
+ on the same data (no held-out split), so its reported value will run
54
+ a little optimistic, this is a known limitation of the simple
55
+ version of this technique, not a bug. A proper cross-fitted version
56
+ would split the log before fitting.
57
+ """
58
+ sums: dict[tuple[str, str], float] = {}
59
+ counts: dict[tuple[str, str], int] = {}
60
+ for ex in examples:
61
+ key = (ex.context_key, ex.arm)
62
+ sums[key] = sums.get(key, 0.0) + ex.reward
63
+ counts[key] = counts.get(key, 0) + 1
64
+
65
+ best_arm_per_context: dict[str, str] = {}
66
+ for (context_key, arm), total in sums.items():
67
+ avg = total / counts[(context_key, arm)]
68
+ current_best = best_arm_per_context.get(context_key)
69
+ if current_best is None or avg > sums[(context_key, current_best)] / counts[(context_key, current_best)]:
70
+ best_arm_per_context[context_key] = arm
71
+
72
+ fallback_arm = max(counts, key=counts.get)[1] if counts else None
73
+
74
+ def policy(context_key: str) -> str:
75
+ return best_arm_per_context.get(context_key, fallback_arm)
76
+
77
+ return policy
78
+
79
+
80
+ def ips_estimate(examples: list[LoggedExample], policy: Policy) -> float:
81
+ """Inverse propensity scoring. For each logged row, if the target
82
+ policy would have picked the same arm the logging policy actually
83
+ picked, that row's reward counts, reweighted by how unlikely the
84
+ logging policy was to pick it (rarer picks get boosted more, that's
85
+ what corrects for the logging policy's own selection bias). Rows
86
+ where the policies disagree contribute nothing, we simply never
87
+ observed what would have happened.
88
+ """
89
+ if not examples:
90
+ return 0.0
91
+
92
+ weighted = [
93
+ ex.reward / ex.propensity if policy(ex.context_key) == ex.arm else 0.0
94
+ for ex in examples
95
+ ]
96
+ return mean(weighted)
97
+
98
+
99
+ def _fit_reward_model(examples: list[LoggedExample]) -> dict[tuple[str, str], float]:
100
+ groups: dict[tuple[str, str], list[float]] = {}
101
+ for ex in examples:
102
+ groups.setdefault((ex.context_key, ex.arm), []).append(ex.reward)
103
+ return {key: mean(rewards) for key, rewards in groups.items()}
104
+
105
+
106
+ def dr_estimate(
107
+ examples: list[LoggedExample],
108
+ policy: Policy,
109
+ reward_model: dict[tuple[str, str], float] | None = None,
110
+ ) -> float:
111
+ """Doubly robust estimator: a direct-method estimate (the reward
112
+ model's guess at what the target policy would earn) plus an IPS
113
+ correction term that only kicks in on rows where the logged arm
114
+ happens to match the target policy. Stays unbiased if either the
115
+ reward model or the propensities are right, not both, that's the
116
+ "doubly" part. Same same-data-fit caveat as greedy_policy_from_log
117
+ applies to the default reward model here.
118
+ """
119
+ if not examples:
120
+ return 0.0
121
+
122
+ reward_model = reward_model if reward_model is not None else _fit_reward_model(examples)
123
+ overall_mean = mean(ex.reward for ex in examples)
124
+
125
+ total = 0.0
126
+ for ex in examples:
127
+ target_arm = policy(ex.context_key)
128
+ q_target = reward_model.get((ex.context_key, target_arm), overall_mean)
129
+
130
+ if target_arm == ex.arm:
131
+ q_logged = reward_model.get((ex.context_key, ex.arm), overall_mean)
132
+ correction = (ex.reward - q_logged) / ex.propensity
133
+ else:
134
+ correction = 0.0
135
+
136
+ total += q_target + correction
137
+
138
+ return total / len(examples)
139
+
140
+
141
+ def bootstrap_ci(
142
+ examples: list[LoggedExample],
143
+ estimator: Callable[[list[LoggedExample]], float],
144
+ n_resamples: int = 1000,
145
+ confidence: float = 0.95,
146
+ seed: int | None = None,
147
+ ) -> tuple[float, float, float]:
148
+ """Percentile bootstrap: resample the log with replacement a bunch of
149
+ times, run the estimator on each resample, and read the confidence
150
+ interval off the sorted results. Plain stdlib random, no scipy, the
151
+ log sizes here don't need anything fancier.
152
+ """
153
+ point = estimator(examples)
154
+ if not examples:
155
+ return point, point, point
156
+
157
+ rng = random.Random(seed)
158
+ n = len(examples)
159
+ resampled = [estimator([examples[rng.randrange(n)] for _ in range(n)]) for _ in range(n_resamples)]
160
+ resampled.sort()
161
+
162
+ lower_idx = int((1 - confidence) / 2 * n_resamples)
163
+ upper_idx = min(int((1 + confidence) / 2 * n_resamples), n_resamples - 1)
164
+
165
+ return point, resampled[lower_idx], resampled[upper_idx]
166
+
167
+
168
+ @dataclass(frozen=True)
169
+ class PolicyEvaluation:
170
+ name: str
171
+ ips_estimate: float
172
+ ips_ci: tuple[float, float]
173
+ dr_estimate: float
174
+ dr_ci: tuple[float, float]
175
+
176
+
177
+ def evaluate_policies(
178
+ engine,
179
+ policies: dict[str, Policy],
180
+ mode: str = "bandit",
181
+ n_resamples: int = 1000,
182
+ seed: int | None = None,
183
+ ) -> list[PolicyEvaluation]:
184
+ """Compares the logged policy against offline-estimated alternatives,
185
+ each with a confidence interval.
186
+ """
187
+ examples = load_logged_examples(engine, mode=mode)
188
+
189
+ logged_point, logged_low, logged_high = bootstrap_ci(
190
+ examples, lambda ex: mean(e.reward for e in ex) if ex else 0.0, n_resamples, seed=seed
191
+ )
192
+ rows = [
193
+ PolicyEvaluation(
194
+ name="logged (as-run)",
195
+ ips_estimate=logged_point,
196
+ ips_ci=(logged_low, logged_high),
197
+ dr_estimate=logged_point,
198
+ dr_ci=(logged_low, logged_high),
199
+ )
200
+ ]
201
+
202
+ for name, policy in policies.items():
203
+ ips_point, ips_low, ips_high = bootstrap_ci(
204
+ examples, lambda ex, p=policy: ips_estimate(ex, p), n_resamples, seed=seed
205
+ )
206
+ dr_point, dr_low, dr_high = bootstrap_ci(
207
+ examples, lambda ex, p=policy: dr_estimate(ex, p), n_resamples, seed=seed
208
+ )
209
+ rows.append(
210
+ PolicyEvaluation(
211
+ name=name,
212
+ ips_estimate=ips_point,
213
+ ips_ci=(ips_low, ips_high),
214
+ dr_estimate=dr_point,
215
+ dr_ci=(dr_low, dr_high),
216
+ )
217
+ )
218
+
219
+ return rows