claude-multiacc 2.0.4 → 2.0.6
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.
- package/bin/claude-accounts +11 -2
- package/bin/codex-accounts +11 -2
- package/lib/__pycache__/audit.cpython-312.pyc +0 -0
- package/lib/__pycache__/keychain.cpython-312.pyc +0 -0
- package/lib/__pycache__/selector_policy.cpython-312.pyc +0 -0
- package/lib/__pycache__/selector_primitives.cpython-312.pyc +0 -0
- package/lib/selector_policy.py +60 -1
- package/lib/selector_primitives.py +20 -1
- package/package.json +1 -1
- package/tests/run-tests.sh +13 -1
- package/tests/test_selector.py +100 -2
package/bin/claude-accounts
CHANGED
|
@@ -2351,6 +2351,15 @@ cmd_self_update() {
|
|
|
2351
2351
|
ulog "self-update: no npm found (looked in $prefix/bin, /opt/homebrew/bin, /usr/local/bin, /usr/bin, PATH); skipping"
|
|
2352
2352
|
return 0
|
|
2353
2353
|
}
|
|
2354
|
+
# Finding npm is not the same as being able to RUN it: npm ships as
|
|
2355
|
+
# `#!/usr/bin/env node`, so an agent PATH without the node that owns it makes
|
|
2356
|
+
# every invocation die with "env: node: No such file or directory" — and npm
|
|
2357
|
+
# exits 0 on that, so the version probe just came back empty and the update
|
|
2358
|
+
# "failed" with nothing to explain it. node lives beside npm, so every npm call
|
|
2359
|
+
# below runs with that directory in front (command-scoped: nothing else in this
|
|
2360
|
+
# process has its PATH changed underneath it).
|
|
2361
|
+
local npm_path
|
|
2362
|
+
npm_path="$(dirname "$npm_bin"):$PATH"
|
|
2354
2363
|
# Update THE COPY THAT IS RUNNING, not whichever one the ambient npm prefix
|
|
2355
2364
|
# happens to point at. my-mini had two global installs — homebrew's on PATH and
|
|
2356
2365
|
# nvm's under `npm root -g` — and self-update kept upgrading the nvm one and
|
|
@@ -2359,13 +2368,13 @@ cmd_self_update() {
|
|
|
2359
2368
|
# A deploy nobody runs is not a deploy, and one that announces success is worse
|
|
2360
2369
|
# than one that fails.
|
|
2361
2370
|
cur="$(pkg_version_at "$REPO_DIR")"
|
|
2362
|
-
lat="$("$npm_bin" view claude-multiacc version 2>/dev/null)"
|
|
2371
|
+
lat="$(PATH="$npm_path" "$npm_bin" view claude-multiacc version 2>/dev/null)"
|
|
2363
2372
|
if [ -n "$lat" ] && [ "$cur" = "$lat" ]; then
|
|
2364
2373
|
ulog "self-update: already latest ($cur)"
|
|
2365
2374
|
return 0
|
|
2366
2375
|
fi
|
|
2367
2376
|
ulog "self-update: npm $cur -> ${lat:-latest} (prefix $prefix, npm $npm_bin)"
|
|
2368
|
-
if "$npm_bin" install -g --prefix "$prefix" claude-multiacc@latest \
|
|
2377
|
+
if PATH="$npm_path" "$npm_bin" install -g --prefix "$prefix" claude-multiacc@latest \
|
|
2369
2378
|
>>"$ACC_ROOT/update.log" 2>&1; then
|
|
2370
2379
|
# Verify by re-reading the file on disk. npm reporting success says nothing
|
|
2371
2380
|
# about which tree it wrote to.
|
package/bin/codex-accounts
CHANGED
|
@@ -1899,14 +1899,23 @@ cmd_self_update() {
|
|
|
1899
1899
|
ulog "self-update: no npm found (looked in $prefix/bin, /opt/homebrew/bin, /usr/local/bin, /usr/bin, PATH); skipping"
|
|
1900
1900
|
return 0
|
|
1901
1901
|
}
|
|
1902
|
+
# Finding npm is not the same as being able to RUN it: npm ships as
|
|
1903
|
+
# `#!/usr/bin/env node`, so an agent PATH without the node that owns it makes
|
|
1904
|
+
# every invocation die with "env: node: No such file or directory" — and npm
|
|
1905
|
+
# exits 0 on that, so the version probe just came back empty and the update
|
|
1906
|
+
# "failed" with nothing to explain it. node lives beside npm, so every npm call
|
|
1907
|
+
# below runs with that directory in front (command-scoped: nothing else in this
|
|
1908
|
+
# process has its PATH changed underneath it).
|
|
1909
|
+
local npm_path
|
|
1910
|
+
npm_path="$(dirname "$npm_bin"):$PATH"
|
|
1902
1911
|
cur="$(pkg_version_at "$REPO_DIR")"
|
|
1903
|
-
lat="$("$npm_bin" view claude-multiacc version 2>/dev/null)"
|
|
1912
|
+
lat="$(PATH="$npm_path" "$npm_bin" view claude-multiacc version 2>/dev/null)"
|
|
1904
1913
|
if [ -n "$lat" ] && [ "$cur" = "$lat" ]; then
|
|
1905
1914
|
ulog "self-update: already latest ($cur)"
|
|
1906
1915
|
return 0
|
|
1907
1916
|
fi
|
|
1908
1917
|
ulog "self-update: npm $cur -> ${lat:-latest} (prefix $prefix, npm $npm_bin)"
|
|
1909
|
-
if "$npm_bin" install -g --prefix "$prefix" claude-multiacc@latest \
|
|
1918
|
+
if PATH="$npm_path" "$npm_bin" install -g --prefix "$prefix" claude-multiacc@latest \
|
|
1910
1919
|
>>"$ACC_ROOT/update.log" 2>&1; then
|
|
1911
1920
|
after="$(pkg_version_at "$REPO_DIR")"
|
|
1912
1921
|
if [ -n "$lat" ] && [ "$after" != "$lat" ]; then
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
package/lib/selector_policy.py
CHANGED
|
@@ -6,6 +6,7 @@ from datetime import datetime
|
|
|
6
6
|
from decimal import Decimal, localcontext
|
|
7
7
|
|
|
8
8
|
from selector_primitives import (
|
|
9
|
+
DEFAULT_HEADROOM_BAND,
|
|
9
10
|
SCHEMA,
|
|
10
11
|
SELECTOR_VERSION,
|
|
11
12
|
canonical_sha256,
|
|
@@ -125,6 +126,47 @@ def _winner_key(row: dict) -> tuple:
|
|
|
125
126
|
row["engine"], row["account_id"], row["runner_id"], row["runner_generation"])
|
|
126
127
|
|
|
127
128
|
|
|
129
|
+
def _band_floor(rows: list[dict], band: Decimal) -> Decimal | None:
|
|
130
|
+
"""The lowest headroom still counted as "as good as the best" this round.
|
|
131
|
+
|
|
132
|
+
Ranking strictly by headroom hands every task to whichever account is on top,
|
|
133
|
+
which is precisely how one account's limit gets burned to zero while three
|
|
134
|
+
others idle: the runner-up only ever wins after the leader has been spent
|
|
135
|
+
below it. Accounts within ``band`` points of the leader are treated as equally
|
|
136
|
+
good, and the tie-break below spreads work across them.
|
|
137
|
+
"""
|
|
138
|
+
known = [Decimal(row["effective_headroom"]) for row in rows
|
|
139
|
+
if row["quota_known"] and row["effective_headroom"] is not None]
|
|
140
|
+
return max(known) - band if known else None
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def _spread(reservation_key: str, row: dict) -> str:
|
|
144
|
+
"""A stable pseudo-random ordinal for this row IN THIS REQUEST.
|
|
145
|
+
|
|
146
|
+
Real randomness cannot live here — the response carries a reproducible
|
|
147
|
+
digest of its own inputs, and two runs of the same request must agree. A
|
|
148
|
+
digest over (reservation key, identity) gives every task its own order over
|
|
149
|
+
the band while staying a pure function of the request, so a burst of
|
|
150
|
+
parallel launches spreads instead of stacking on one account.
|
|
151
|
+
"""
|
|
152
|
+
return canonical_sha256({"reservation_key": reservation_key,
|
|
153
|
+
"identity": list(_row_identity(row))})
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def _band_key(reservation_key: str):
|
|
157
|
+
"""Order INSIDE the band: least-recently-used first, then the spread digest.
|
|
158
|
+
|
|
159
|
+
Headroom deliberately does not appear — inside the band it is what we are
|
|
160
|
+
choosing to ignore. LRU is what makes this rotate rather than merely jitter:
|
|
161
|
+
with three banded accounts, three sequential tasks touch all three.
|
|
162
|
+
"""
|
|
163
|
+
def key(row: dict) -> tuple:
|
|
164
|
+
return (row["last_selected_at"] is not None, row["last_selected_at"] or "",
|
|
165
|
+
_spread(reservation_key, row),
|
|
166
|
+
row["engine"], row["account_id"], row["runner_id"], row["runner_generation"])
|
|
167
|
+
return key
|
|
168
|
+
|
|
169
|
+
|
|
128
170
|
def _snapshot_key(row: dict) -> tuple:
|
|
129
171
|
if row["identity_valid"]:
|
|
130
172
|
return 0, row["runner_id"], row["runner_generation"], row["engine"], row["account_id"]
|
|
@@ -154,7 +196,17 @@ def select(request: object) -> dict:
|
|
|
154
196
|
eligible = [row for row in rows if row["eligible"]]
|
|
155
197
|
if not eligible:
|
|
156
198
|
return _error("no_candidate", {"eligible_count": 0})
|
|
157
|
-
|
|
199
|
+
band, band_text = decimal_value(request.get("headroom_band", DEFAULT_HEADROOM_BAND))
|
|
200
|
+
floor = _band_floor(eligible, band) if band is not None else None
|
|
201
|
+
if floor is None:
|
|
202
|
+
# No usable quota anywhere: nothing to band, so keep the plain ordering
|
|
203
|
+
# (which already ranks unknown-quota rows last and rotates on ties).
|
|
204
|
+
banded = eligible
|
|
205
|
+
winner = min(banded, key=_winner_key)
|
|
206
|
+
else:
|
|
207
|
+
banded = [row for row in eligible if row["quota_known"]
|
|
208
|
+
and Decimal(row["effective_headroom"]) >= floor]
|
|
209
|
+
winner = min(banded, key=_band_key(request["reservation_key"]))
|
|
158
210
|
rows.sort(key=_snapshot_key)
|
|
159
211
|
snapshot = canonical_sha256(rows)
|
|
160
212
|
chosen = dict(zip(("runner_id", "runner_generation", "engine", "account_id"), _row_identity(winner)))
|
|
@@ -162,6 +214,8 @@ def select(request: object) -> dict:
|
|
|
162
214
|
"schema": SCHEMA, "selector_version": SELECTOR_VERSION,
|
|
163
215
|
"database_now": canonical_now, "policy": request["policy"],
|
|
164
216
|
"required_engine": request.get("required_engine"), "reservation_key": request["reservation_key"],
|
|
217
|
+
# The band changes which account wins, so it belongs in the proof.
|
|
218
|
+
"headroom_band": band_text,
|
|
165
219
|
"candidate_snapshot_digest": snapshot, "selected_identity": chosen}
|
|
166
220
|
score_fields = ("quota_known", "weekly_pct", "session_pct", "weekly_remaining",
|
|
167
221
|
"session_remaining", "effective_headroom", "last_selected_at")
|
|
@@ -169,6 +223,11 @@ def select(request: object) -> dict:
|
|
|
169
223
|
"score_basis": {name: winner[name] for name in score_fields},
|
|
170
224
|
"eligible_count": len(eligible),
|
|
171
225
|
"eligible_alternative_count": alternatives,
|
|
226
|
+
# Observability: which accounts were considered interchangeable,
|
|
227
|
+
# so "why did it not pick the emptiest one" has an answer.
|
|
228
|
+
"headroom_band": band_text,
|
|
229
|
+
"band_floor": format(floor, "f") if floor is not None else None,
|
|
230
|
+
"band_count": len(banded),
|
|
172
231
|
"candidate_snapshot_digest": snapshot,
|
|
173
232
|
"selection_digest": canonical_sha256(digest_input)}
|
|
174
233
|
if request["policy"] == "reviewer" and not alternatives and _row_identity(winner) == producer:
|
|
@@ -11,6 +11,13 @@ from datetime import datetime, timezone
|
|
|
11
11
|
from decimal import Decimal, InvalidOperation
|
|
12
12
|
|
|
13
13
|
SCHEMA = "claude-multiacc/pool-selection.v2"
|
|
14
|
+
# NOT bumped for the headroom band. This string is a BUILD FENCE, not a changelog:
|
|
15
|
+
# a queued task records the version that reserved its account, and the claim function
|
|
16
|
+
# (agent-sdk 0005_delivery_claim_functions.sql) only lets a runner advertising the same
|
|
17
|
+
# string claim it — while the runner's value is a hardcoded constant shipped in its
|
|
18
|
+
# bundle. With Macs routinely several builds behind, bumping this stops every lagging
|
|
19
|
+
# Mac from claiming any new work until it updates. The band is additive and optional:
|
|
20
|
+
# the request shape, the response shape and every consumer are unchanged.
|
|
14
21
|
SELECTOR_VERSION = "2.0.1"
|
|
15
22
|
TIME_RE = re.compile(
|
|
16
23
|
r"^[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-5][0-9]"
|
|
@@ -25,6 +32,10 @@ CANDIDATE_KEYS = IDENTITY_KEYS | {
|
|
|
25
32
|
REQUEST_KEYS = {
|
|
26
33
|
"schema", "database_now", "policy", "required_engine", "producer_identity",
|
|
27
34
|
"excluded_identities", "candidates", "reservation_key"}
|
|
35
|
+
# Optional because a caller pinned to an older panel build still sends exactly
|
|
36
|
+
# REQUEST_KEYS; absent, the policy applies its own default band.
|
|
37
|
+
OPTIONAL_REQUEST_KEYS = {"headroom_band"}
|
|
38
|
+
DEFAULT_HEADROOM_BAND = "30"
|
|
28
39
|
POLICIES = {
|
|
29
40
|
"default_claude", "default_codex", "default_both", "explicit", "producer_retry", "reviewer"}
|
|
30
41
|
|
|
@@ -166,8 +177,16 @@ def request_error(request: object) -> tuple[str, dict] | None:
|
|
|
166
177
|
return "invalid_request", {"field": "schema"}
|
|
167
178
|
if request.get("schema") != SCHEMA:
|
|
168
179
|
return "unsupported_schema", {"field": "schema"}
|
|
169
|
-
|
|
180
|
+
keys = set(request)
|
|
181
|
+
if not REQUEST_KEYS <= keys or keys - REQUEST_KEYS - OPTIONAL_REQUEST_KEYS:
|
|
170
182
|
return "invalid_request", {"field": "$"}
|
|
183
|
+
if "headroom_band" in request:
|
|
184
|
+
band, _text = decimal_value(request["headroom_band"])
|
|
185
|
+
# decimal_value clamps to [0,100] and rejects non-numeric shapes; a band
|
|
186
|
+
# the caller cannot express exactly must fail loudly rather than silently
|
|
187
|
+
# widen selection to the whole pool.
|
|
188
|
+
if band is None or isinstance(request["headroom_band"], bool):
|
|
189
|
+
return "invalid_request", {"field": "headroom_band"}
|
|
171
190
|
try:
|
|
172
191
|
timestamp(request["database_now"])
|
|
173
192
|
except ValueError:
|
package/package.json
CHANGED
package/tests/run-tests.sh
CHANGED
|
@@ -2451,8 +2451,17 @@ if command -v node >/dev/null 2>&1; then
|
|
|
2451
2451
|
mkdir -p "$NPMROOT" "$PFX/bin"
|
|
2452
2452
|
cp -R "$REPO_DIR/bin" "$REPO_DIR/lib" "$REPO_DIR/package.json" "$NPMROOT/"
|
|
2453
2453
|
# A fake npm that records how it was called and "installs" by bumping package.json.
|
|
2454
|
-
|
|
2454
|
+
# Its shebang names an interpreter that exists ONLY beside it, mirroring the real
|
|
2455
|
+
# npm's `#!/usr/bin/env node`: resolving npm by absolute path is not enough if the
|
|
2456
|
+
# agent's PATH cannot find npm's own interpreter, which is how the update failed
|
|
2457
|
+
# with an empty version probe and no explanation.
|
|
2458
|
+
cat > "$PFX/bin/multiacc-fake-node" <<'NODEEOF'
|
|
2455
2459
|
#!/usr/bin/env bash
|
|
2460
|
+
exec /bin/bash "$@"
|
|
2461
|
+
NODEEOF
|
|
2462
|
+
chmod +x "$PFX/bin/multiacc-fake-node"
|
|
2463
|
+
cat > "$PFX/bin/npm" <<NPMEOF
|
|
2464
|
+
#!/usr/bin/env multiacc-fake-node
|
|
2456
2465
|
echo "npm \$*" >> "$WORK/npm-calls-$_cli.log"
|
|
2457
2466
|
case "\${1:-}" in
|
|
2458
2467
|
view) echo 9.9.9 ;;
|
|
@@ -2488,6 +2497,9 @@ NPMEOF
|
|
|
2488
2497
|
&& t_fail "$_cli npm choice" "used the npm from PATH instead of the running prefix's" \
|
|
2489
2498
|
|| t_ok "$_cli: a stray npm on PATH never wins over the running prefix's"
|
|
2490
2499
|
check "$_cli: self-update verifies the tree it wrote" "npm update ok (9.9.9)" "$out"
|
|
2500
|
+
grep -q "^npm view " "$WORK/npm-calls-$_cli.log" \
|
|
2501
|
+
&& t_ok "$_cli: npm ran despite its interpreter being off PATH" \
|
|
2502
|
+
|| t_fail "$_cli npm interpreter" "npm never executed — its shebang interpreter was not found"
|
|
2491
2503
|
done
|
|
2492
2504
|
# The agents install.sh writes must carry a PATH for the same reason (a future tool
|
|
2493
2505
|
# that is not resolved by absolute path would hit exactly this again).
|
package/tests/test_selector.py
CHANGED
|
@@ -54,7 +54,7 @@ class SelectorTests(unittest.TestCase):
|
|
|
54
54
|
"e8ef0b4bc7b95e76231ee6da79616d92bd9251c1c991845c8c0524fcbfb6593a")
|
|
55
55
|
self.assertEqual(
|
|
56
56
|
response["selection_digest"],
|
|
57
|
-
"
|
|
57
|
+
"8b68d69b31b2d2252a16f7b83ac46860987647aa360402caa815b2a1bd468def")
|
|
58
58
|
|
|
59
59
|
def test_unicode_and_active_reservation_proof(self):
|
|
60
60
|
busy = {"active_expires_at": "2026-08-25T09:00:00.000001Z", "last_selected_at": None}
|
|
@@ -68,7 +68,7 @@ class SelectorTests(unittest.TestCase):
|
|
|
68
68
|
"29e8e7d1a611f38ee0be43622102b33875e7ff75ff9024aa43d16151575071d4")
|
|
69
69
|
self.assertEqual(
|
|
70
70
|
response["selection_digest"],
|
|
71
|
-
"
|
|
71
|
+
"72e7b31a00496fff0f6281f4dacd656d3a2a1fc438038e2c20c49274e1f61068")
|
|
72
72
|
|
|
73
73
|
def test_both_chooses_each_provider_and_survives_provider_loss(self):
|
|
74
74
|
codex = select(_request([
|
|
@@ -207,8 +207,106 @@ class SelectorTests(unittest.TestCase):
|
|
|
207
207
|
for item in bad))
|
|
208
208
|
self.assertEqual(json.loads(decimal_response.stdout)["account_id"], "z-lower")
|
|
209
209
|
self.assertEqual(version, "2.0.1")
|
|
210
|
+
# npm package versioning, unrelated to SELECTOR_VERSION: auto-version bumps the
|
|
211
|
+
# next patch above what is published.
|
|
210
212
|
self.assertEqual(versions, ["2.0.0", "2.0.1"])
|
|
211
213
|
|
|
212
214
|
|
|
215
|
+
class HeadroomBandTests(unittest.TestCase):
|
|
216
|
+
"""Accounts within `headroom_band` points of the leader are interchangeable.
|
|
217
|
+
|
|
218
|
+
Ranking strictly by headroom sends every task to whichever account is on top
|
|
219
|
+
until it is spent below the runner-up, which is how one account's weekly limit
|
|
220
|
+
was burned to zero while three others sat idle.
|
|
221
|
+
"""
|
|
222
|
+
|
|
223
|
+
def _pool(self, *specs) -> list[dict]:
|
|
224
|
+
# spec: (account_id, weekly_pct, last_selected_at)
|
|
225
|
+
return [_candidate(account_id=name, weekly_pct=pct, session_pct=0,
|
|
226
|
+
reservation_history={"active_expires_at": None,
|
|
227
|
+
"last_selected_at": last})
|
|
228
|
+
for name, pct, last in specs]
|
|
229
|
+
|
|
230
|
+
def test_band_spreads_launches_instead_of_stacking_on_the_leader(self):
|
|
231
|
+
# headroom 100 / 80 / 70 / 60 — the first three are within 30 of the leader.
|
|
232
|
+
pool = self._pool(("a", 0, None), ("b", 20, None), ("c", 30, None), ("d", 40, None))
|
|
233
|
+
picked = {}
|
|
234
|
+
for index in range(120):
|
|
235
|
+
response = select(_request(pool, reservation_key=f"launch-{index}"))
|
|
236
|
+
self.assertTrue(response["ok"])
|
|
237
|
+
picked[response["account_id"]] = picked.get(response["account_id"], 0) + 1
|
|
238
|
+
self.assertEqual(response["band_floor"], "70")
|
|
239
|
+
self.assertEqual(response["band_count"], 3)
|
|
240
|
+
self.assertEqual(set(picked), {"a", "b", "c"},
|
|
241
|
+
"every banded account must take work; 'd' is out of band")
|
|
242
|
+
# No account may take more than half: the point is spreading the burn.
|
|
243
|
+
self.assertLess(max(picked.values()), 60, picked)
|
|
244
|
+
self.assertGreater(min(picked.values()), 20, picked)
|
|
245
|
+
|
|
246
|
+
def test_same_request_always_selects_the_same_account(self):
|
|
247
|
+
pool = self._pool(("a", 0, None), ("b", 20, None), ("c", 30, None))
|
|
248
|
+
first = select(_request(pool, reservation_key="stable"))
|
|
249
|
+
for _ in range(5):
|
|
250
|
+
repeat = select(_request(pool, reservation_key="stable"))
|
|
251
|
+
self.assertEqual(repeat["account_id"], first["account_id"])
|
|
252
|
+
self.assertEqual(repeat["selection_digest"], first["selection_digest"])
|
|
253
|
+
|
|
254
|
+
def test_least_recently_used_wins_inside_the_band(self):
|
|
255
|
+
# The emptiest account (a) was used most recently; the band rotates past it.
|
|
256
|
+
pool = self._pool(("a", 0, "2026-08-25T08:59:00.000000Z"),
|
|
257
|
+
("b", 20, "2026-08-25T08:00:00.000000Z"),
|
|
258
|
+
("c", 30, "2026-08-25T07:00:00.000000Z"))
|
|
259
|
+
self.assertEqual(select(_request(pool))["account_id"], "c")
|
|
260
|
+
# A never-used account outranks every used one.
|
|
261
|
+
pool = self._pool(("a", 0, "2026-08-25T08:59:00.000000Z"), ("b", 20, None))
|
|
262
|
+
self.assertEqual(select(_request(pool))["account_id"], "b")
|
|
263
|
+
|
|
264
|
+
def test_out_of_band_account_never_wins_however_idle(self):
|
|
265
|
+
pool = self._pool(("a", 0, "2026-08-25T08:59:59.000000Z"),
|
|
266
|
+
("d", 40, "2020-01-01T00:00:00.000000Z"))
|
|
267
|
+
response = select(_request(pool))
|
|
268
|
+
self.assertEqual(response["account_id"], "a")
|
|
269
|
+
self.assertEqual(response["band_count"], 1)
|
|
270
|
+
|
|
271
|
+
def test_zero_band_restores_strict_most_headroom(self):
|
|
272
|
+
pool = self._pool(("a", 0, "2026-08-25T08:59:00.000000Z"), ("b", 20, None))
|
|
273
|
+
self.assertEqual(select(_request(pool, headroom_band=0))["account_id"], "a")
|
|
274
|
+
|
|
275
|
+
def test_band_is_configurable_and_validated(self):
|
|
276
|
+
pool = self._pool(("a", 0, None), ("d", 40, None))
|
|
277
|
+
wide = select(_request(pool, headroom_band="45"))
|
|
278
|
+
self.assertEqual(wide["band_floor"], "55")
|
|
279
|
+
self.assertEqual(wide["band_count"], 2)
|
|
280
|
+
for bad in ("abc", True, [], "1e5", None):
|
|
281
|
+
refusal = select(_request(pool, headroom_band=bad))
|
|
282
|
+
self.assertFalse(refusal["ok"], bad)
|
|
283
|
+
self.assertEqual(refusal["error_detail"], {"field": "headroom_band"}, bad)
|
|
284
|
+
|
|
285
|
+
def test_unknown_quota_never_enters_the_band(self):
|
|
286
|
+
# A row with no telemetry has no headroom to compare; it must not become
|
|
287
|
+
# "as good as the leader" just because the band is generous.
|
|
288
|
+
pool = self._pool(("a", 0, None))
|
|
289
|
+
pool.append(_candidate(account_id="blind", weekly_pct=None, session_pct=None,
|
|
290
|
+
reservation_history={"active_expires_at": None,
|
|
291
|
+
"last_selected_at": None}))
|
|
292
|
+
response = select(_request(pool))
|
|
293
|
+
self.assertEqual(response["account_id"], "a")
|
|
294
|
+
self.assertEqual(response["band_count"], 1)
|
|
295
|
+
# ...and with NO usable telemetry anywhere, the band cannot apply at all.
|
|
296
|
+
blind = [_candidate(account_id=name, weekly_pct=None, session_pct=None)
|
|
297
|
+
for name in ("x", "y")]
|
|
298
|
+
response = select(_request(blind))
|
|
299
|
+
self.assertTrue(response["ok"])
|
|
300
|
+
self.assertIsNone(response["band_floor"])
|
|
301
|
+
|
|
302
|
+
def test_band_participates_in_the_selection_proof(self):
|
|
303
|
+
pool = self._pool(("a", 0, None), ("b", 20, None))
|
|
304
|
+
narrow = select(_request(pool, headroom_band="5"))
|
|
305
|
+
wide = select(_request(pool, headroom_band="50"))
|
|
306
|
+
self.assertNotEqual(narrow["selection_digest"], wide["selection_digest"])
|
|
307
|
+
# An omitted band is the documented default, and says so in the response.
|
|
308
|
+
self.assertEqual(select(_request(pool))["headroom_band"], "30")
|
|
309
|
+
|
|
310
|
+
|
|
213
311
|
if __name__ == "__main__":
|
|
214
312
|
unittest.main()
|