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.
@@ -0,0 +1,218 @@
1
+ import re
2
+ from dataclasses import dataclass
3
+ from enum import Enum
4
+
5
+
6
+ class TaskType(str, Enum):
7
+ CODE = "code"
8
+ REASONING_MATH = "reasoning_math"
9
+ WRITING = "writing"
10
+ LONG_DOCUMENT = "long_document"
11
+ GENERAL = "general"
12
+
13
+
14
+ class LengthBucket(str, Enum):
15
+ SHORT = "short"
16
+ MEDIUM = "medium"
17
+ LONG = "long"
18
+ VERY_LONG = "very_long"
19
+
20
+
21
+ _CODE_MARKERS = re.compile(
22
+ r"```"
23
+ r"|traceback \(most recent call last\)"
24
+ r"|\b(syntax|type|name|index|key|attribute|value|indentation)error\b"
25
+ r"|\bexception in thread\b"
26
+ r"|\bnpm err!"
27
+ r"|^\s*(def|class|import|from|function|public|private|const|let)\s",
28
+ re.IGNORECASE | re.MULTILINE,
29
+ )
30
+
31
+ _MATH_MARKERS = re.compile(
32
+ r"\b(solve|prove|derivative|integral|equation|theorem|factorize|simplify)\b"
33
+ r"|\btime complexity\b|\bbig[- ]o\b"
34
+ r"|solve for [a-z]\b",
35
+ re.IGNORECASE,
36
+ )
37
+
38
+ _DOC_MARKERS = re.compile(
39
+ r"\bsummariz(e|ing)\b"
40
+ r"|\btl;?dr\b"
41
+ r"|\bthe following (document|article|text|paper)\b"
42
+ r"|\bgiven the (text|document) below\b",
43
+ re.IGNORECASE,
44
+ )
45
+
46
+ _WRITING_MARKERS = re.compile(
47
+ r"\b(write|draft|compose)\s+(a|an|me a)\b"
48
+ r"|\b(essay|blog post|cover letter|short story|poem)\b",
49
+ re.IGNORECASE,
50
+ )
51
+
52
+ # anchor prompts for the embedding fallback below. GENERAL gets its own
53
+ # examples too, not just the other four, otherwise the fallback would
54
+ # always pick one of code/math/writing/doc even for plain small talk
55
+ # since there'd be nothing else in the running.
56
+ _SIMILARITY_THRESHOLD = 0.35
57
+
58
+ _TASK_TYPE_EXAMPLES: dict[TaskType, list[str]] = {
59
+ TaskType.CODE: [
60
+ "why is my for loop not terminating",
61
+ "getting a null pointer exception in this function",
62
+ "how do I fix this compiler error",
63
+ "review this pull request for bugs",
64
+ "my api call keeps returning a 500",
65
+ ],
66
+ TaskType.REASONING_MATH: [
67
+ "walk me through the proof of this theorem",
68
+ "what's the expected value of this dice roll",
69
+ "help me figure out the recurrence relation for this algorithm",
70
+ "is this argument logically valid",
71
+ "explain why this inequality holds",
72
+ ],
73
+ TaskType.WRITING: [
74
+ "help me phrase this paragraph better",
75
+ "can you make this sound more persuasive",
76
+ "give me a catchy title for this post",
77
+ "rewrite this in a more formal tone",
78
+ "I need a few opening lines for a speech",
79
+ ],
80
+ TaskType.LONG_DOCUMENT: [
81
+ "here's a long report, pull out the key takeaways",
82
+ "go through this contract and flag anything unusual",
83
+ "condense this research paper into a few bullet points",
84
+ "what are the main arguments made across these pages",
85
+ ],
86
+ TaskType.GENERAL: [
87
+ "what's a good movie to watch tonight",
88
+ "how's the weather looking this weekend",
89
+ "tell me something interesting",
90
+ "what time zone is Tokyo in",
91
+ "hey, how are you",
92
+ ],
93
+ }
94
+
95
+ _embedding_classifier_cache = None
96
+
97
+
98
+ def _get_embedding_classifier():
99
+ # unlike the db engine, this one is worth caching as a real
100
+ # singleton. loading the model is the expensive part (a few hundred
101
+ # ms to a couple seconds), so paying it once per process is the
102
+ # whole point. the model itself comes from arcus.embeddings, shared
103
+ # with the semantic cache, so a request that needs both this fallback
104
+ # and a cache lookup only pays that load cost once, not twice.
105
+ global _embedding_classifier_cache
106
+ if _embedding_classifier_cache is not None:
107
+ return _embedding_classifier_cache
108
+
109
+ from arcus.embeddings import embed
110
+
111
+ labels: list[TaskType] = []
112
+ examples: list[str] = []
113
+ for task_type, prompts in _TASK_TYPE_EXAMPLES.items():
114
+ for prompt in prompts:
115
+ labels.append(task_type)
116
+ examples.append(prompt)
117
+
118
+ anchor_embeddings = embed(examples)
119
+
120
+ _embedding_classifier_cache = (labels, anchor_embeddings)
121
+ return _embedding_classifier_cache
122
+
123
+
124
+ def _classify_by_embedding(text: str) -> TaskType:
125
+ try:
126
+ labels, anchor_embeddings = _get_embedding_classifier()
127
+ from arcus.embeddings import embed
128
+
129
+ query_embedding = embed([text])[0]
130
+ except Exception:
131
+ # no internet on first run, weights not cached yet, whatever the
132
+ # reason, the regex rules already did their best. don't let a
133
+ # missing model take classification down entirely.
134
+ return TaskType.GENERAL
135
+
136
+ similarities = anchor_embeddings @ query_embedding
137
+ best_idx = int(similarities.argmax())
138
+
139
+ if similarities[best_idx] < _SIMILARITY_THRESHOLD:
140
+ return TaskType.GENERAL
141
+
142
+ return labels[best_idx]
143
+
144
+
145
+ def _looks_like_code(text: str) -> bool:
146
+ return bool(_CODE_MARKERS.search(text))
147
+
148
+
149
+ def _looks_like_math(text: str) -> bool:
150
+ return bool(_MATH_MARKERS.search(text))
151
+
152
+
153
+ def _looks_like_document_task(text: str) -> bool:
154
+ return bool(_DOC_MARKERS.search(text))
155
+
156
+
157
+ def _looks_like_writing_request(text: str) -> bool:
158
+ return bool(_WRITING_MARKERS.search(text))
159
+
160
+
161
+ def classify_task_type(text: str) -> TaskType:
162
+ # order matters here. code and error output are the most confident
163
+ # signal we ever get (and piping errors in is the single most common
164
+ # thing this tool will see), so that gets checked first no matter
165
+ # what else is in the prompt.
166
+ if _looks_like_code(text):
167
+ return TaskType.CODE
168
+ if _looks_like_math(text):
169
+ return TaskType.REASONING_MATH
170
+ if _looks_like_document_task(text):
171
+ return TaskType.LONG_DOCUMENT
172
+ if _looks_like_writing_request(text):
173
+ return TaskType.WRITING
174
+
175
+ # nothing hit a keyword. rather than giving up and calling it
176
+ # GENERAL, check how close it reads to labeled examples of each
177
+ # category, catches phrasing the regex rules just don't cover.
178
+ result = _classify_by_embedding(text)
179
+
180
+ # a long wall of text with no other signal is still a document task
181
+ # even if the embedding read came back wishy-washy about it, raw
182
+ # length beats a low-confidence embedding guess here.
183
+ if result == TaskType.GENERAL and bucket_length(text) in (LengthBucket.LONG, LengthBucket.VERY_LONG):
184
+ return TaskType.LONG_DOCUMENT
185
+
186
+ return result
187
+
188
+
189
+ def bucket_length(text: str) -> LengthBucket:
190
+ # rough chars-per-token estimate (~4), not a real tokenizer. none of
191
+ # ARC's four models use OpenAI's tokenizer anyway so an exact count
192
+ # would just be fake precision. these thresholds are a first guess,
193
+ # worth retuning once we have real query lengths to look at.
194
+ approx_tokens = len(text) // 4
195
+
196
+ if approx_tokens < 100:
197
+ return LengthBucket.SHORT
198
+ if approx_tokens < 500:
199
+ return LengthBucket.MEDIUM
200
+ if approx_tokens < 2000:
201
+ return LengthBucket.LONG
202
+ return LengthBucket.VERY_LONG
203
+
204
+
205
+ @dataclass(frozen=True)
206
+ class Context:
207
+ task_type: TaskType
208
+ length_bucket: LengthBucket
209
+
210
+ @property
211
+ def key(self) -> str:
212
+ # this is the dict key the per-context bandits key off of, one
213
+ # bandit instance per bucket.
214
+ return f"{self.task_type.value}:{self.length_bucket.value}"
215
+
216
+
217
+ def classify(text: str) -> Context:
218
+ return Context(task_type=classify_task_type(text), length_bucket=bucket_length(text))
@@ -0,0 +1,91 @@
1
+ from dataclasses import dataclass
2
+
3
+ # published commercial hosting rates for these open-weight models, one
4
+ # primary source per model, as of August 2026. this is a SIMULATED cost
5
+ # signal, ARC itself is free to the user, nobody's actually being
6
+ # billed these numbers. rates are dollars per 1M tokens.
7
+ #
8
+ # gpt-oss-120b: Groq
9
+ # GLM-5.3: Zhipu/Z.ai official pricing
10
+ # Kimi-K3: Moonshot AI official pricing (cache-miss rate, ignoring the
11
+ # cache-hit discount, more precision than a simulated signal needs)
12
+ # DeepSeek-V4-Flash: DeepSeek official pricing (off-peak rate, ignoring
13
+ # the peak-hours surcharge for the same reason)
14
+ MODEL_HOSTING_RATES: dict[str, tuple[float, float]] = {
15
+ "gpt-oss-120b": (0.15, 0.60),
16
+ "GLM-5.3": (1.40, 4.40),
17
+ "Kimi-K3": (3.00, 15.00),
18
+ "DeepSeek-V4-Flash": (0.22, 0.66),
19
+ }
20
+
21
+
22
+ def _blended_rate(model: str) -> float:
23
+ input_rate, output_rate = MODEL_HOSTING_RATES[model]
24
+ return (input_rate + output_rate) / 2
25
+
26
+
27
+ # computed from the table above rather than hardcoded, so the raw rates
28
+ # stay the actual source of truth. cheapest model scores 1.0, priciest
29
+ # scores 0.0, everything else falls in between.
30
+ def _compute_cost_scores() -> dict[str, float]:
31
+ blended = {model: _blended_rate(model) for model in MODEL_HOSTING_RATES}
32
+ cheapest = min(blended.values())
33
+ priciest = max(blended.values())
34
+ spread = priciest - cheapest
35
+
36
+ return {model: 1 - (rate - cheapest) / spread for model, rate in blended.items()}
37
+
38
+
39
+ COST_SCORES = _compute_cost_scores()
40
+
41
+
42
+ def normalize_latency(latency_ms: float, ceiling_ms: float = 20_000) -> float:
43
+ # past the ceiling, slower is just uniformly bad, no reason to keep
44
+ # penalizing harder. 20s is a reasonable worst-case wait for an
45
+ # interactive CLI tool.
46
+ return max(0.0, min(1.0, 1 - latency_ms / ceiling_ms))
47
+
48
+
49
+ def basic_quality_score(finish_reason: str | None, content: str | None) -> float:
50
+ """A minimal quality signal: finished cleanly (finish_reason == "stop")
51
+ and non-empty content. The real request pipeline uses the fuller
52
+ checks in quality/gate.py instead.
53
+ """
54
+ if finish_reason != "stop":
55
+ return 0.0
56
+ if not content or not content.strip():
57
+ return 0.0
58
+ return 1.0
59
+
60
+
61
+ @dataclass(frozen=True)
62
+ class RewardWeights:
63
+ quality: float
64
+ latency: float
65
+ cost: float
66
+
67
+
68
+ # quality weighted highest: a fast, cheap, wrong answer is worth about
69
+ # nothing. latency next: it's the most immediately felt cost on shared,
70
+ # sometimes-contended infra, where quality can degrade during peak load.
71
+ # cost lowest: ARC is free to the user, this dimension exists to
72
+ # demonstrate routing that accounts for cost as an engineering practice,
73
+ # not because real budget pressure exists here.
74
+ DEFAULT_WEIGHTS = RewardWeights(quality=0.5, latency=0.3, cost=0.2)
75
+
76
+
77
+ def compute_reward(
78
+ latency_ms: float,
79
+ model: str,
80
+ quality_score: float,
81
+ weights: RewardWeights = DEFAULT_WEIGHTS,
82
+ ) -> float:
83
+ weight_sum = weights.quality + weights.latency + weights.cost
84
+ if abs(weight_sum - 1.0) > 1e-6:
85
+ raise ValueError(f"reward weights must sum to 1.0, got {weight_sum}")
86
+
87
+ return (
88
+ weights.quality * quality_score
89
+ + weights.latency * normalize_latency(latency_ms)
90
+ + weights.cost * COST_SCORES[model]
91
+ )
@@ -0,0 +1,35 @@
1
+ from sqlmodel import Session, select
2
+
3
+ from arcus.routing.bandit import ContextualBandit
4
+ from arcus.storage.db import RequestLog
5
+
6
+
7
+ def replay_history(bandit: ContextualBandit, engine, mode: str) -> None:
8
+ """Rebuilds a bandit's learned state from past requests in the log.
9
+
10
+ Every `arcus` invocation is a fresh process, there's no daemon keeping
11
+ the bandit alive in memory between runs. Without this, each run would
12
+ start from a blank slate and the router would never actually learn
13
+ anything. It works because update() on all four algorithms is just an
14
+ associative accumulation of pull counts and reward sums, so replaying
15
+ the log in chronological order and feeding each row back through
16
+ update() lands on the same state as if the process had been running
17
+ continuously the whole time.
18
+ """
19
+ with Session(engine) as session:
20
+ rows = session.exec(
21
+ select(RequestLog)
22
+ .where(RequestLog.mode == mode)
23
+ .where(RequestLog.reward.is_not(None))
24
+ .order_by(RequestLog.created_at)
25
+ ).all()
26
+
27
+ for row in rows:
28
+ if row.model not in bandit.arms:
29
+ # a model that was live when this row was logged but has
30
+ # since been retired or renamed by ARC. no arm to credit the
31
+ # reward to anymore, skip it rather than crash the whole
32
+ # replay over one stale row.
33
+ continue
34
+ context_key = f"{row.task_type}:{row.length_bucket}"
35
+ bandit.update(context_key, row.model, row.reward)
File without changes
arcus/storage/db.py ADDED
@@ -0,0 +1,117 @@
1
+ import os
2
+ from datetime import UTC, datetime
3
+ from pathlib import Path
4
+
5
+ from platformdirs import user_data_dir
6
+ from sqlmodel import Field, Session, SQLModel, create_engine
7
+
8
+
9
+ class RequestLog(SQLModel, table=True):
10
+ id: int | None = Field(default=None, primary_key=True)
11
+ created_at: datetime = Field(default_factory=lambda: datetime.now(UTC))
12
+
13
+ prompt: str
14
+ task_type: str
15
+ length_bucket: str
16
+ model: str
17
+
18
+ # nullable since a cache hit skips the bandit and quality gate
19
+ # entirely, but propensity in particular has to be a column from day
20
+ # one: there's no way to go back and add propensity logging to rows
21
+ # that already happened.
22
+ propensity: float | None = Field(default=None)
23
+ latency_ms: float | None = Field(default=None)
24
+ finish_reason: str | None = Field(default=None)
25
+ error: str | None = Field(default=None)
26
+
27
+ # "bandit" or "random", whichever ContextualBandit actually drove
28
+ # this request. mode plus model is the whole point of the A/B stats
29
+ # comparison.
30
+ mode: str | None = Field(default=None)
31
+ # the actual compute_reward() output at request time, stored rather
32
+ # than recomputed later. if reward.py's weights or cost table change
33
+ # down the line, historical stats should still reflect what the
34
+ # bandit was actually updated with, not whatever the weights happen
35
+ # to be when someone runs `arcus stats`.
36
+ reward: float | None = Field(default=None)
37
+
38
+ # was this response served from the semantic cache instead of an
39
+ # actual ARC call. needed for `arcus stats` to report a cache hit
40
+ # rate, the current schema has no other way to tell a cache hit apart
41
+ # from a normal request.
42
+ cache_hit: bool = Field(default=False)
43
+ # did this specific attempt pass the quality gate. combined with
44
+ # `model`, this is what makes a per-model catch rate possible instead
45
+ # of only ever seeing which model eventually succeeded.
46
+ quality_passed: bool = Field(default=True)
47
+
48
+ # groups every turn of one `arcus chat` session together. null for
49
+ # one-shot `arcus "..."` calls, there's no session to group those into.
50
+ conversation_id: str | None = Field(default=None, index=True)
51
+ # position of this turn within its conversation, 0-indexed. null
52
+ # alongside conversation_id for one-shot calls.
53
+ turn_index: int | None = Field(default=None)
54
+
55
+
56
+ def _database_url() -> str:
57
+ override = os.environ.get("ARCUS_DATABASE_URL")
58
+ if override:
59
+ return override
60
+
61
+ data_dir = Path(user_data_dir("arcus"))
62
+ data_dir.mkdir(parents=True, exist_ok=True)
63
+ return f"sqlite:///{data_dir / 'arcus.db'}"
64
+
65
+
66
+ def get_engine():
67
+ # deliberately not cached as a module-level singleton. sqlite engine
68
+ # creation is cheap, and skipping the singleton means there's no
69
+ # stale global state to worry about resetting between tests (or
70
+ # between one CLI run and the next, if ARCUS_DATABASE_URL changes).
71
+ engine = create_engine(_database_url())
72
+ SQLModel.metadata.create_all(engine)
73
+ return engine
74
+
75
+
76
+ def log_request(
77
+ prompt: str,
78
+ task_type: str,
79
+ length_bucket: str,
80
+ model: str,
81
+ propensity: float | None = None,
82
+ latency_ms: float | None = None,
83
+ finish_reason: str | None = None,
84
+ error: str | None = None,
85
+ mode: str | None = None,
86
+ reward: float | None = None,
87
+ cache_hit: bool = False,
88
+ quality_passed: bool = True,
89
+ conversation_id: str | None = None,
90
+ turn_index: int | None = None,
91
+ engine=None,
92
+ ) -> RequestLog:
93
+ engine = engine or get_engine()
94
+
95
+ entry = RequestLog(
96
+ prompt=prompt,
97
+ task_type=task_type,
98
+ length_bucket=length_bucket,
99
+ model=model,
100
+ propensity=propensity,
101
+ latency_ms=latency_ms,
102
+ finish_reason=finish_reason,
103
+ error=error,
104
+ mode=mode,
105
+ reward=reward,
106
+ cache_hit=cache_hit,
107
+ quality_passed=quality_passed,
108
+ conversation_id=conversation_id,
109
+ turn_index=turn_index,
110
+ )
111
+
112
+ with Session(engine) as session:
113
+ session.add(entry)
114
+ session.commit()
115
+ session.refresh(entry)
116
+
117
+ return entry
arcus/storage/stats.py ADDED
@@ -0,0 +1,51 @@
1
+ from dataclasses import dataclass
2
+ from statistics import mean
3
+
4
+ from sqlmodel import Session, select
5
+
6
+ from arcus.routing.reward import COST_SCORES
7
+ from arcus.storage.db import RequestLog
8
+
9
+
10
+ @dataclass(frozen=True)
11
+ class ArmModeSummary:
12
+ model: str
13
+ mode: str
14
+ request_count: int
15
+ avg_reward: float | None
16
+ avg_latency_ms: float | None
17
+ cost_score: float | None
18
+
19
+
20
+ def aggregate_by_arm_and_mode(engine) -> list[ArmModeSummary]:
21
+ """Groups logged requests by (model, mode) and averages reward/latency
22
+ per group. Done in plain Python rather than a SQL GROUP BY/AVG, this
23
+ is a personal local CLI's log, realistically thousands of rows at
24
+ most, not a scale where hand-rolled SQL aggregation earns its
25
+ complexity over just reading the rows.
26
+ """
27
+ with Session(engine) as session:
28
+ rows = session.exec(select(RequestLog)).all()
29
+
30
+ groups: dict[tuple[str, str], list[RequestLog]] = {}
31
+ for row in rows:
32
+ key = (row.model, row.mode or "unknown")
33
+ groups.setdefault(key, []).append(row)
34
+
35
+ summaries = []
36
+ for (model, mode), entries in groups.items():
37
+ rewards = [e.reward for e in entries if e.reward is not None]
38
+ latencies = [e.latency_ms for e in entries if e.latency_ms is not None]
39
+
40
+ summaries.append(
41
+ ArmModeSummary(
42
+ model=model,
43
+ mode=mode,
44
+ request_count=len(entries),
45
+ avg_reward=mean(rewards) if rewards else None,
46
+ avg_latency_ms=mean(latencies) if latencies else None,
47
+ cost_score=COST_SCORES.get(model),
48
+ )
49
+ )
50
+
51
+ return sorted(summaries, key=lambda s: (s.model, s.mode))