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/eval/regret.py ADDED
@@ -0,0 +1,96 @@
1
+ import random
2
+ from dataclasses import dataclass
3
+ from typing import Callable
4
+
5
+ import numpy as np
6
+
7
+ from arcus.adapters.arc_adapter import ArcModel
8
+ from arcus.routing.bandit import Bandit, EpsilonGreedyBandit, RandomBandit, ThompsonSamplingBandit, UCB1Bandit
9
+
10
+ ARMS = [m.value for m in ArcModel]
11
+
12
+
13
+ @dataclass(frozen=True)
14
+ class ArmDistribution:
15
+ mean: float
16
+ std: float
17
+
18
+
19
+ # illustrative synthetic reward distributions, not measured. regret
20
+ # benchmarking needs a known ground-truth mean per arm to compute regret
21
+ # against, and that's exactly the thing live traffic can never give us:
22
+ # a real request only ever explores one arm per round, so we'd have no
23
+ # way to know what the *other* three would have scored that round. this
24
+ # is the standard way to study a bandit algorithm's exploration behavior
25
+ # in isolation from actual model quality, not a stand-in for missing
26
+ # data.
27
+ DEFAULT_ARM_DISTRIBUTIONS: dict[str, ArmDistribution] = {
28
+ "gpt-oss-120b": ArmDistribution(mean=0.72, std=0.10),
29
+ "GLM-5.3": ArmDistribution(mean=0.68, std=0.12),
30
+ "Kimi-K3": ArmDistribution(mean=0.75, std=0.08),
31
+ "DeepSeek-V4-Flash": ArmDistribution(mean=0.70, std=0.15),
32
+ }
33
+
34
+
35
+ def _sample_reward(dist: ArmDistribution, rng: random.Random) -> float:
36
+ # clipped to [0, 1], real compute_reward() output is bounded there too
37
+ return min(1.0, max(0.0, rng.gauss(dist.mean, dist.std)))
38
+
39
+
40
+ def simulate_regret(
41
+ bandit_factory: Callable[[], Bandit],
42
+ arm_distributions: dict[str, ArmDistribution],
43
+ n_rounds: int,
44
+ seed: int,
45
+ ) -> list[float]:
46
+ """Runs one bandit for n_rounds against a known synthetic reward
47
+ environment and returns its cumulative regret curve. Regret per
48
+ round is the gap between the best arm's true mean and the pulled
49
+ arm's true mean, not the noisy realized reward, so the curve tracks
50
+ the algorithm's actual exploration cost instead of being dominated
51
+ by sampling noise.
52
+ """
53
+ # Thompson sampling draws from numpy's global RNG (see bandit.py),
54
+ # the other algorithms only touch the stdlib random module, seeding
55
+ # both keeps every algorithm's run reproducible regardless of which
56
+ # one is being simulated.
57
+ rng = random.Random(seed)
58
+ np.random.seed(seed)
59
+
60
+ bandit = bandit_factory()
61
+ best_mean = max(dist.mean for dist in arm_distributions.values())
62
+
63
+ cumulative_regret = 0.0
64
+ curve = []
65
+ for _ in range(n_rounds):
66
+ arm = bandit.select_arm()
67
+ reward = _sample_reward(arm_distributions[arm], rng)
68
+ bandit.update(arm, reward)
69
+
70
+ cumulative_regret += best_mean - arm_distributions[arm].mean
71
+ curve.append(cumulative_regret)
72
+
73
+ return curve
74
+
75
+
76
+ def run_regret_benchmark(
77
+ arm_distributions: dict[str, ArmDistribution] = DEFAULT_ARM_DISTRIBUTIONS,
78
+ n_rounds: int = 2000,
79
+ seed: int = 42,
80
+ ) -> dict[str, list[float]]:
81
+ """Cumulative regret curves for all three real algorithms plus the
82
+ random baseline, demonstrating this is a real algorithm family and
83
+ not just "a bandit works."
84
+ """
85
+ arms = list(arm_distributions.keys())
86
+ factories: dict[str, Callable[[], Bandit]] = {
87
+ "epsilon_greedy": lambda: EpsilonGreedyBandit(arms),
88
+ "ucb1": lambda: UCB1Bandit(arms),
89
+ "thompson": lambda: ThompsonSamplingBandit(arms),
90
+ "random": lambda: RandomBandit(arms),
91
+ }
92
+
93
+ return {
94
+ name: simulate_regret(factory, arm_distributions, n_rounds, seed)
95
+ for name, factory in factories.items()
96
+ }
File without changes
arcus/quality/gate.py ADDED
@@ -0,0 +1,225 @@
1
+ import re
2
+ import time
3
+ from dataclasses import dataclass
4
+
5
+ from openai import APIError
6
+ from openai.types.chat import ChatCompletion
7
+ from pydantic import BaseModel, ValidationError
8
+
9
+ from arcus.adapters.arc_adapter import ArcAdapter
10
+ from arcus.routing.bandit import ContextualBandit
11
+ from arcus.routing.reward import compute_reward
12
+
13
+ _REFUSAL_MARKERS = re.compile(
14
+ r"i cannot assist|i can't assist"
15
+ r"|i cannot help|i can't help"
16
+ r"|as an ai language model"
17
+ r"|i'm sorry,? but i can'?t"
18
+ r"|i am not able to|i'm not able to"
19
+ r"|i won'?t be able to"
20
+ r"|i must decline"
21
+ r"|against my guidelines",
22
+ re.IGNORECASE,
23
+ )
24
+
25
+ _REPETITION_THRESHOLD = 0.5
26
+ # below this many words there aren't enough trigrams for the duplication
27
+ # ratio to mean anything, a two-sentence answer isn't "looping" just
28
+ # because it reuses a word.
29
+ _MIN_WORDS_FOR_REPETITION_CHECK = 9
30
+
31
+
32
+ @dataclass(frozen=True)
33
+ class QualityIssue:
34
+ kind: str
35
+ detail: str
36
+
37
+
38
+ @dataclass(frozen=True)
39
+ class QualityCheckResult:
40
+ passed: bool
41
+ issues: list[QualityIssue]
42
+
43
+
44
+ def check_truncation(finish_reason: str | None) -> QualityIssue | None:
45
+ if finish_reason == "length":
46
+ return QualityIssue("truncated", "response was cut off before finishing (finish_reason=length)")
47
+ return None
48
+
49
+
50
+ def check_empty(content: str | None) -> QualityIssue | None:
51
+ # deliberately not a fuzzy "too short" length check, a correct answer
52
+ # can legitimately be one word. this only catches actually-empty output.
53
+ if not content or not content.strip():
54
+ return QualityIssue("empty", "response was empty or whitespace only")
55
+ return None
56
+
57
+
58
+ def check_repetition(content: str | None, threshold: float = _REPETITION_THRESHOLD) -> QualityIssue | None:
59
+ if not content:
60
+ return None
61
+
62
+ words = content.split()
63
+ if len(words) < _MIN_WORDS_FOR_REPETITION_CHECK:
64
+ return None
65
+
66
+ trigrams = [tuple(words[i : i + 3]) for i in range(len(words) - 2)]
67
+ duplication_ratio = 1 - len(set(trigrams)) / len(trigrams)
68
+
69
+ if duplication_ratio > threshold:
70
+ return QualityIssue("repetitive", f"trigram duplication ratio {duplication_ratio:.2f} exceeds {threshold}")
71
+ return None
72
+
73
+
74
+ def check_refusal(content: str | None) -> QualityIssue | None:
75
+ if not content:
76
+ return None
77
+ if _REFUSAL_MARKERS.search(content):
78
+ return QualityIssue("refusal", "response matched a known refusal phrase")
79
+ return None
80
+
81
+
82
+ def check_schema(content: str | None, schema: type[BaseModel] | None) -> QualityIssue | None:
83
+ if schema is None:
84
+ return None
85
+ if not content:
86
+ return QualityIssue("schema_invalid", "no content to validate against schema")
87
+ try:
88
+ schema.model_validate_json(content)
89
+ except ValidationError as e:
90
+ return QualityIssue("schema_invalid", str(e))
91
+ except ValueError as e:
92
+ # model_validate_json raises a plain ValueError (from the underlying
93
+ # json parse) when content isn't valid JSON at all, not a
94
+ # ValidationError, both count as a schema failure here
95
+ return QualityIssue("schema_invalid", str(e))
96
+ return None
97
+
98
+
99
+ def check_response(
100
+ content: str | None,
101
+ finish_reason: str | None,
102
+ schema: type[BaseModel] | None = None,
103
+ ) -> QualityCheckResult:
104
+ checks = [
105
+ check_truncation(finish_reason),
106
+ check_empty(content),
107
+ check_repetition(content),
108
+ check_refusal(content),
109
+ check_schema(content, schema),
110
+ ]
111
+ issues = [issue for issue in checks if issue is not None]
112
+ return QualityCheckResult(passed=len(issues) == 0, issues=issues)
113
+
114
+
115
+ @dataclass(frozen=True)
116
+ class AttemptDetail:
117
+ model: str
118
+ passed: bool
119
+ reward: float
120
+ latency_ms: float
121
+ propensity: float
122
+ issues: list[QualityIssue]
123
+
124
+
125
+ @dataclass(frozen=True)
126
+ class QualityGateOutcome:
127
+ # None only when every arm errored out at the API level rather than
128
+ # returning a bad answer, there was nothing to hand back at all
129
+ response: ChatCompletion | None
130
+ model_used: str
131
+ passed: bool
132
+ attempts: list[AttemptDetail]
133
+ issues: list[QualityIssue]
134
+
135
+
136
+ def call_with_quality_gate(
137
+ adapter: ArcAdapter,
138
+ bandit: ContextualBandit,
139
+ context_key: str,
140
+ messages: list[dict],
141
+ schema: type[BaseModel] | None = None,
142
+ max_attempts: int | None = None,
143
+ ) -> QualityGateOutcome:
144
+ """Calls ARC, runs the response through the quality gate, and retries
145
+ with a different arm on failure, feeding a real (not hardcoded)
146
+ negative reward back to the bandit each time so it actually learns
147
+ from the failure instead of just being told "not this one." Every
148
+ attempt gets logged, not just the winning one, so a caller can later
149
+ tell how often each model got caught by the gate, not only which
150
+ model ended up serving the response.
151
+ """
152
+ max_attempts = min(max_attempts or len(bandit.arms), len(bandit.arms))
153
+
154
+ already_tried: set[str] = set()
155
+ attempts: list[AttemptDetail] = []
156
+ completion = None
157
+ arm = None
158
+ last_issues: list[QualityIssue] = []
159
+
160
+ for _ in range(max_attempts):
161
+ arm = bandit.select_arm(context_key, exclude=already_tried)
162
+ # propensity has to be read off the bandit's state right here,
163
+ # before update() below changes the counts it's computed from
164
+ propensity = bandit.propensity(context_key, arm, exclude=already_tried)
165
+
166
+ start = time.monotonic()
167
+ try:
168
+ completion = adapter.chat(arm, messages)
169
+ except APIError as e:
170
+ # a network blip, rate limit, or ARC-side outage on this one
171
+ # model isn't a reason to give up on the whole request, treat
172
+ # it exactly like a failed quality check: log it, penalize
173
+ # this arm for this context, and let the loop try the next
174
+ # one instead of crashing the CLI with a raw traceback.
175
+ latency_ms = (time.monotonic() - start) * 1000
176
+ issue = QualityIssue("api_error", str(e))
177
+ reward = compute_reward(latency_ms=latency_ms, model=arm, quality_score=0.0)
178
+ bandit.update(context_key, arm, reward)
179
+
180
+ attempts.append(
181
+ AttemptDetail(
182
+ model=arm, passed=False, reward=reward, latency_ms=latency_ms,
183
+ propensity=propensity, issues=[issue],
184
+ )
185
+ )
186
+ last_issues = [issue]
187
+ completion = None
188
+ already_tried.add(arm)
189
+ continue
190
+
191
+ latency_ms = (time.monotonic() - start) * 1000
192
+
193
+ choice = completion.choices[0]
194
+ result = check_response(choice.message.content, choice.finish_reason, schema)
195
+ last_issues = result.issues
196
+
197
+ quality_score = 1.0 if result.passed else 0.0
198
+ reward = compute_reward(latency_ms=latency_ms, model=arm, quality_score=quality_score)
199
+ bandit.update(context_key, arm, reward)
200
+
201
+ attempts.append(
202
+ AttemptDetail(
203
+ model=arm,
204
+ passed=result.passed,
205
+ reward=reward,
206
+ latency_ms=latency_ms,
207
+ propensity=propensity,
208
+ issues=result.issues,
209
+ )
210
+ )
211
+
212
+ if result.passed:
213
+ return QualityGateOutcome(
214
+ response=completion, model_used=arm, passed=True, attempts=attempts, issues=[]
215
+ )
216
+
217
+ already_tried.add(arm)
218
+
219
+ # every arm got tried and none passed (or errored out), hand back the
220
+ # last attempt and let the caller decide what to do. response may be
221
+ # None here if the very last arm failed with an API error rather than
222
+ # a bad answer, callers need to handle that.
223
+ return QualityGateOutcome(
224
+ response=completion, model_used=arm, passed=False, attempts=attempts, issues=last_issues
225
+ )
File without changes
@@ -0,0 +1,226 @@
1
+ import math
2
+ import random
3
+ from typing import Callable, Protocol
4
+
5
+ import numpy as np
6
+
7
+
8
+ class Bandit(Protocol):
9
+ def select_arm(self, exclude: set[str] | None = None) -> str: ...
10
+ def update(self, arm: str, reward: float) -> None: ...
11
+ def propensity(self, arm: str, exclude: set[str] | None = None) -> float: ...
12
+
13
+
14
+ class _CountBasedBandit:
15
+ """Shared bookkeeping for epsilon-greedy and UCB1. Both track a running
16
+ per-arm pull count and reward sum, and both need every arm tried once
17
+ before their selection formula is even defined (UCB1's confidence bound
18
+ divides by pull count, epsilon-greedy has nothing to compare averages
19
+ against on arm zero).
20
+ """
21
+
22
+ def __init__(self, arms: list[str]) -> None:
23
+ self.arms = list(arms)
24
+ self._pulls = {arm: 0 for arm in self.arms}
25
+ self._reward_sums = {arm: 0.0 for arm in self.arms}
26
+
27
+ def _candidates(self, exclude: set[str] | None) -> list[str]:
28
+ if not exclude:
29
+ return self.arms
30
+ return [arm for arm in self.arms if arm not in exclude]
31
+
32
+ def _untried_arm(self, exclude: set[str] | None) -> str | None:
33
+ for arm in self._candidates(exclude):
34
+ if self._pulls[arm] == 0:
35
+ return arm
36
+ return None
37
+
38
+ def _mean_reward(self, arm: str) -> float:
39
+ return self._reward_sums[arm] / self._pulls[arm]
40
+
41
+ def update(self, arm: str, reward: float) -> None:
42
+ self._pulls[arm] += 1
43
+ self._reward_sums[arm] += reward
44
+
45
+ def _cold_start_propensity(self, arm: str, exclude: set[str] | None) -> float | None:
46
+ # returns None once every candidate arm has at least one pull, so
47
+ # the caller knows to fall through to its own formula. while an
48
+ # arm is still untried, select_arm() always returns the first
49
+ # untried one it finds, so the propensity for that specific arm
50
+ # is 1.0 and everything else is 0, not a probability distribution
51
+ # spread across the untried arms.
52
+ untried = self._untried_arm(exclude)
53
+ if untried is None:
54
+ return None
55
+ return 1.0 if arm == untried else 0.0
56
+
57
+
58
+ class EpsilonGreedyBandit(_CountBasedBandit):
59
+ def __init__(self, arms: list[str], epsilon: float = 0.1) -> None:
60
+ super().__init__(arms)
61
+ self.epsilon = epsilon
62
+
63
+ def select_arm(self, exclude: set[str] | None = None) -> str:
64
+ untried = self._untried_arm(exclude)
65
+ if untried is not None:
66
+ return untried
67
+
68
+ candidates = self._candidates(exclude)
69
+
70
+ if random.random() < self.epsilon:
71
+ return random.choice(candidates)
72
+
73
+ best_mean = max(self._mean_reward(arm) for arm in candidates)
74
+ best_arms = [arm for arm in candidates if self._mean_reward(arm) == best_mean]
75
+ return random.choice(best_arms)
76
+
77
+ def propensity(self, arm: str, exclude: set[str] | None = None) -> float:
78
+ cold_start = self._cold_start_propensity(arm, exclude)
79
+ if cold_start is not None:
80
+ return cold_start
81
+
82
+ candidates = self._candidates(exclude)
83
+ if arm not in candidates:
84
+ return 0.0
85
+
86
+ best_mean = max(self._mean_reward(a) for a in candidates)
87
+ best_arms = [a for a in candidates if self._mean_reward(a) == best_mean]
88
+
89
+ # epsilon/n chance of landing here through the random branch, plus
90
+ # a share of the (1 - epsilon) greedy branch if this arm is one of
91
+ # the (possibly tied) best ones.
92
+ base = self.epsilon / len(candidates)
93
+ if arm in best_arms:
94
+ return base + (1 - self.epsilon) / len(best_arms)
95
+ return base
96
+
97
+
98
+ class UCB1Bandit(_CountBasedBandit):
99
+ def select_arm(self, exclude: set[str] | None = None) -> str:
100
+ untried = self._untried_arm(exclude)
101
+ if untried is not None:
102
+ return untried
103
+
104
+ candidates = self._candidates(exclude)
105
+
106
+ # textbook UCB1, no tunable exploration constant on purpose, that's
107
+ # the whole point of this algorithm over epsilon-greedy: provable
108
+ # logarithmic regret without anything to hand-tune.
109
+ total_pulls = sum(self._pulls.values())
110
+
111
+ def ucb_score(arm: str) -> float:
112
+ return self._mean_reward(arm) + math.sqrt(2 * math.log(total_pulls) / self._pulls[arm])
113
+
114
+ best_score = max(ucb_score(arm) for arm in candidates)
115
+ best_arms = [arm for arm in candidates if ucb_score(arm) == best_score]
116
+ return random.choice(best_arms)
117
+
118
+ def propensity(self, arm: str, exclude: set[str] | None = None) -> float:
119
+ cold_start = self._cold_start_propensity(arm, exclude)
120
+ if cold_start is not None:
121
+ return cold_start
122
+
123
+ candidates = self._candidates(exclude)
124
+ if arm not in candidates:
125
+ return 0.0
126
+
127
+ total_pulls = sum(self._pulls.values())
128
+
129
+ def ucb_score(a: str) -> float:
130
+ return self._mean_reward(a) + math.sqrt(2 * math.log(total_pulls) / self._pulls[a])
131
+
132
+ best_score = max(ucb_score(a) for a in candidates)
133
+ best_arms = [a for a in candidates if ucb_score(a) == best_score]
134
+ # UCB1 is deterministic except for ties, so the propensity is just
135
+ # 1 over however many arms are tied for the top score.
136
+ return 1 / len(best_arms) if arm in best_arms else 0.0
137
+
138
+
139
+ _THOMPSON_PROPENSITY_SAMPLES = 2000
140
+
141
+
142
+ class ThompsonSamplingBandit:
143
+ def __init__(self, arms: list[str]) -> None:
144
+ self.arms = list(arms)
145
+ # Beta(1, 1) is a uniform prior. no cold-start branch needed here
146
+ # like the other two, a fresh arm just has wide uncertainty and
147
+ # naturally gets sampled a lot until its posterior narrows, that's
148
+ # Thompson sampling's actual advantage over the count-based ones.
149
+ self._alpha = {arm: 1.0 for arm in self.arms}
150
+ self._beta = {arm: 1.0 for arm in self.arms}
151
+
152
+ def select_arm(self, exclude: set[str] | None = None) -> str:
153
+ candidates = [arm for arm in self.arms if not exclude or arm not in exclude]
154
+ samples = {arm: np.random.beta(self._alpha[arm], self._beta[arm]) for arm in candidates}
155
+ return max(samples, key=samples.get)
156
+
157
+ def update(self, arm: str, reward: float) -> None:
158
+ # standard Beta-Bernoulli Thompson sampling assumes 0/1 rewards.
159
+ # ours are continuous in [0, 1], so the reward itself gets treated
160
+ # as a fractional pseudo-observation rather than a hard success or
161
+ # failure. this is the usual way to stretch Beta-Bernoulli to
162
+ # continuous rewards in that range.
163
+ self._alpha[arm] += reward
164
+ self._beta[arm] += 1 - reward
165
+
166
+ def propensity(self, arm: str, exclude: set[str] | None = None) -> float:
167
+ candidates = [a for a in self.arms if not exclude or a not in exclude]
168
+ if arm not in candidates:
169
+ return 0.0
170
+
171
+ # there's no closed form for the probability that one independent
172
+ # Beta draw beats a handful of others, so this estimates it the
173
+ # same way select_arm() actually decides: draw a batch of samples
174
+ # per arm and see how often this arm comes out on top. this is a
175
+ # Monte Carlo estimate, not an exact value, that's expected and
176
+ # documented, not a bug.
177
+ draws = np.stack(
178
+ [np.random.beta(self._alpha[a], self._beta[a], size=_THOMPSON_PROPENSITY_SAMPLES) for a in candidates]
179
+ )
180
+ winners = draws.argmax(axis=0)
181
+ return float((winners == candidates.index(arm)).mean())
182
+
183
+
184
+ class RandomBandit(_CountBasedBandit):
185
+ """Uniform random arm selection, ignores everything it's learned. This
186
+ is the 'B' side of the A/B mode flag: hand this to a
187
+ ContextualBandit instead of one of the real algorithms and you get a
188
+ random-routing baseline to compare the real bandits against. update()
189
+ still records pulls and reward sums like the other count-based
190
+ bandits do, not because selection uses them, but so stats aggregation
191
+ can report what random selection would have earned per arm.
192
+ """
193
+
194
+ def select_arm(self, exclude: set[str] | None = None) -> str:
195
+ return random.choice(self._candidates(exclude))
196
+
197
+ def propensity(self, arm: str, exclude: set[str] | None = None) -> float:
198
+ candidates = self._candidates(exclude)
199
+ return 1 / len(candidates) if arm in candidates else 0.0
200
+
201
+
202
+ class ContextualBandit:
203
+ """One independent bandit instance per context bucket, the 'disjoint'
204
+ contextual bandit approach, rather than one bandit shared across
205
+ every kind of request. Instances are created lazily the first time a
206
+ given context key shows up.
207
+ """
208
+
209
+ def __init__(self, algorithm_factory: Callable[[], Bandit], arms: list[str]) -> None:
210
+ self._algorithm_factory = algorithm_factory
211
+ self.arms = list(arms)
212
+ self._bandits: dict[str, Bandit] = {}
213
+
214
+ def _get_bandit(self, context_key: str) -> Bandit:
215
+ if context_key not in self._bandits:
216
+ self._bandits[context_key] = self._algorithm_factory()
217
+ return self._bandits[context_key]
218
+
219
+ def select_arm(self, context_key: str, exclude: set[str] | None = None) -> str:
220
+ return self._get_bandit(context_key).select_arm(exclude=exclude)
221
+
222
+ def update(self, context_key: str, arm: str, reward: float) -> None:
223
+ self._get_bandit(context_key).update(arm, reward)
224
+
225
+ def propensity(self, context_key: str, arm: str, exclude: set[str] | None = None) -> float:
226
+ return self._get_bandit(context_key).propensity(arm, exclude=exclude)