Skip to content

Commit 80507d1

Browse files
committed
枠の推移で、返ってこなくなった窓と同名の窓を見分けられるようにする
相手が窓の id を変えると、古い id の系列はそこで止まる。止まった系列を 生きている系列と同じ顔で並べると、最後の点がそのまま現在値に見えるうえ、 並び順が上がり幅の順なので、途中で 0 に落ちた壊れた系列ほど上に来る。 provider ごとの最新の観測より古い系列に印を付け、末尾へ回す。 窓の名前は相手が決めるので、別勘定の窓が同じ名前を名乗ることがある。 名前だけでは見分けられないので、ぶつかった名前には id を添える。 片方だけ素のままにすると、どちらが素かが相手の並べ方次第になるため、 ぶつかったものはすべて添える。 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
1 parent 992f673 commit 80507d1

6 files changed

Lines changed: 149 additions & 14 deletions

File tree

CLAUDE.md

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1294,6 +1294,15 @@ GeoNames 全世界地名辞典 = `geonames`(いずれも 348 言語版・195 か
12941294
使用率が交互に跳ね上がったり 0 に戻ったりして見える。
12951295
**ぶつかったものは全部付け替える** —— 片方に元の名前を残すと、どちらが残るかが
12961296
相手の並べ方次第になり、次に聞いたときに入れ替わる
1297+
- **同じ名前の窓は id を添えて呼び分ける**(`usage.arranged`)。名前は長さから
1298+
作るので、同じ長さの窓が 2 つあると見分けが付かない(実測: codex は 7 日の窓を
1299+
2 つ返す。別勘定で値も違う)。**ぶつかったものは全部添える** ——
1300+
片方だけ素のままにすると、どちらが素かが相手の並べ方次第になる
1301+
- **相手が返さなくなった窓は下へ回す**(`quota_trail``stale`)。名前が変われば
1302+
その線はそこで止まる。**消さずに印を付けて末尾へ** —— 消すと後から確かめ
1303+
ようがなくなるが、生きている線と同じ顔で並ぶと読み違える。
1304+
**混ざっていた頃の線は「上がったぶん」を大きく持つ**ので、直したあとも
1305+
一番上に居座る(実際にそう見えた)
12971306
- **観測点ごとに、そのあいだの依頼も出す**(`calls_between`)。使用率だけでは
12981307
「跳ねた」までしか分からず、どれを止めればよいかが決まらない。
12991308
**区間は前の観測の直後から今の観測まで** —— 始まりを含めると、観測の

app/usage.py

Lines changed: 21 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,7 @@
3333

3434
import asyncio
3535
import logging
36-
from dataclasses import asdict, dataclass, field
36+
from dataclasses import asdict, dataclass, field, replace
3737
from datetime import UTC, datetime, timedelta
3838

3939
import httpx
@@ -353,27 +353,41 @@ async def refresh(provider_id: str) -> Quota:
353353
error=str(e),
354354
windows=_windows_from(stored.get("windows", [])),
355355
)
356-
windows = ordered(windows)
356+
windows = arranged(windows)
357357
usage_store.save_quota(spec.id, [asdict(w) for w in windows])
358358
return Quota(supported=True, fetched_at=_now_iso(), windows=windows)
359359

360360

361-
def ordered(windows: list[Window]) -> list[Window]:
362-
"""短い窓から並べる。**相手ごとに返す順が違う** —— 実測で codex は 5 時間が先、
363-
antigravity は週が先だった。詰まりやすいのは短いほうなので、そこを上に出す。
361+
def arranged(windows: list[Window]) -> list[Window]:
362+
"""画面に出す形に整える —— 短い窓から並べ、**同じ名前の窓を呼び分ける**。
364363
364+
並びは短い順。**相手ごとに返す順が違う** —— 実測で codex は 5 時間が先、
365+
antigravity は週が先だった。詰まりやすいのは短いほうなので、そこを上に出す。
365366
**長さの分からない窓は末尾に、元の並びのまま置く**(claude は文面から読むので
366367
長さを持たない)。安定な並べ替えなので、同じ長さの窓どうしの順も動かない。
368+
369+
**名前は長さから作るので、同じ長さの窓が 2 つあると見分けが付かない** ——
370+
実測で codex は 7 日の窓を 2 つ返す(片方は別勘定で、値もまったく違う)。
371+
**ぶつかったものは全部、窓の id を添えて呼び分ける** —— 片方だけ素のままに
372+
すると、どちらが素のままかが相手の並べ方次第になる(ブリッジ側で id を
373+
呼び分けているのと同じ判断)。
367374
"""
368-
return sorted(
375+
out = sorted(
369376
windows,
370377
key=lambda w: (1, 0.0) if not w.window_minutes else (0, float(w.window_minutes)),
371378
)
379+
seen: dict[str, int] = {}
380+
for w in out:
381+
seen[w.label] = seen.get(w.label, 0) + 1
382+
return [
383+
replace(w, label=f"{w.label}({w.id})") if seen.get(w.label, 0) > 1 else w
384+
for w in out
385+
]
372386

373387

374388
def _windows_from(raw: list) -> list[Window]:
375389
out = [_window_from(w) for w in raw if isinstance(w, dict)]
376-
return ordered([w for w in out if w is not None])
390+
return arranged([w for w in out if w is not None])
377391

378392

379393
def _now_iso() -> str:

app/usage_store.py

Lines changed: 17 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -459,7 +459,8 @@ def quota_trail(since: datetime) -> list[dict]:
459459
key = (r["provider"], r["window_id"])
460460
trail = trails.setdefault(key, {
461461
"provider": r["provider"], "window_id": r["window_id"],
462-
"label": r["label"] or r["window_id"], "points": [], "climbed": 0.0, "resets_at": "",
462+
"label": r["label"] or r["window_id"], "points": [], "climbed": 0.0,
463+
"resets_at": "", "stale": False,
463464
})
464465
percent = float(r["used_percent"])
465466
if trail["points"] and percent > trail["points"][-1]["used_percent"]:
@@ -468,7 +469,21 @@ def quota_trail(since: datetime) -> list[dict]:
468469
# 見出しと明ける時刻は新しいほうを採る(窓が明けると次の時刻に変わる)
469470
trail["label"] = r["label"] or trail["label"]
470471
trail["resets_at"] = r["resets_at"] or trail["resets_at"]
471-
return sorted(trails.values(), key=lambda t: (-t["climbed"], t["provider"]))
472+
473+
# **相手が返さなくなった窓は下へ回す**(`stale`)。窓の名前が変わったり、枠の
474+
# 出し方が変わったりすると、その名前の線はそこで伸びなくなる —— 混ざったまま
475+
# 伸びていた頃の線が「上がったぶん」を大きく持っていると、**直したあとも
476+
# 壊れた線が一番上に居座る**(実際にそう見えた)。
477+
newest: dict[str, str] = {}
478+
for trail in trails.values():
479+
last = trail["points"][-1]["at"]
480+
newest[trail["provider"]] = max(newest.get(trail["provider"], ""), last)
481+
for trail in trails.values():
482+
trail["stale"] = trail["points"][-1]["at"] < newest[trail["provider"]]
483+
return sorted(
484+
trails.values(),
485+
key=lambda t: (t["stale"], -t["climbed"], t["provider"]),
486+
)
472487

473488

474489
def calls_since(provider: str, at: str) -> int:

app/views/ai_usage.py

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -286,6 +286,18 @@ def _trail_points_html(trail: dict) -> str:
286286
)
287287

288288

289+
def _stale_note(trail: dict) -> str:
290+
"""相手が返さなくなった窓の印。**消さずに、伸びていないことを書く。**
291+
292+
窓の名前が変わると、その名前の線はそこで止まる —— 消すと「あの数字は何だったのか」
293+
を確かめようがなくなるが、生きている線と同じ顔で並んでいると読み違える。
294+
"""
295+
if not trail.get("stale"):
296+
return ""
297+
return ('<br><span class="muted">いまは返ってこない窓'
298+
"(名前が変わったか、相手が出さなくなった)</span>")
299+
300+
289301
def trail_html(request: Request | None = None) -> str:
290302
"""枠の推移 —— いつ跳ねたかを読むところ。
291303
@@ -303,9 +315,9 @@ def trail_html(request: Request | None = None) -> str:
303315
return (f'{head}\n<p class="muted">この窓の観測はありません'
304316
"(枠を聞ける相手を呼ぶと、定時に控えはじめます)。</p>")
305317
body = "\n".join(
306-
"<tr>"
318+
f'<tr{" class=\"off\"" if t["stale"] else ""}>'
307319
f'<td>{esc(usage.label_of(t["provider"]))}<br>'
308-
f'<span class="muted">{esc(t["label"])}</span></td>'
320+
f'<span class="muted">{esc(t["label"])}</span>{_stale_note(t)}</td>'
309321
f'<td>{esc(_percent(t["points"][-1]["used_percent"]))}</td>'
310322
f'<td>{esc(_climb(t["climbed"]))}</td>'
311323
f"<td>{_trail_points_html(t)}</td>"

docs/ai.md

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -922,6 +922,18 @@ Antigravity は週が先でした。詰まりやすいのは短いほうなの
922922
**長さの分からない窓は末尾に、元の並びのまま**置きます(claude は文面から読むので
923923
長さを持ちません——ただしあちらは元から session → week の順で返ってきます)。
924924

925+
**同じ名前の窓が 2 つ並ぶことがあります。** 名前は窓の長さから作るので、実測で
926+
codex が返す 2 つの 7 日の窓は見分けが付きませんでした(別勘定で、値もまったく違います)。
927+
ぶつかったものは**全部、窓の id を添えて呼び分けます**`直近 7 日(secondary)` /
928+
`直近 7 日(primary-10080m)`)——片方だけ素のままにすると、どちらが素のままかが
929+
相手の並べ方次第になります。
930+
931+
**相手が返さなくなった窓は下へ回します。** 窓の名前が変わると、その名前の線はそこで
932+
止まります——**消さずに「いまは返ってこない窓」と書いて末尾へ**。消すと後から
933+
「あの数字は何だったのか」を確かめようがなくなりますが、生きている線と同じ顔で
934+
並んでいると読み違えます(**混ざっていた頃の線が「上がったぶん」を大きく持っていると、
935+
直したあとも一番上に居座ります**——実際にそう見えました)。
936+
925937
**窓の名前がぶつかることがあります。** 実測で codex は、長さの違う 2 つの窓を
926938
どちらも `primary` と名乗ってきました。名前は推移を束ねる鍵なので、そのままだと
927939
**別々の窓が 1 本の線に混ざり**、使用率が交互に跳ね上がったり 0 に戻ったりして

tests/test_ai_usage.py

Lines changed: 76 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -955,7 +955,7 @@ def test_the_boundary_is_not_counted_twice(self, env):
955955

956956

957957
class TestWindowOrder:
958-
"""窓の並び —— 相手ごとに返す順が違うので、受け取る側で揃える。"""
958+
"""窓の並びと名前 —— 相手ごとに返す順が違い、名前もぶつかりうる。"""
959959

960960
def _window(self, name: str, minutes: float | None) -> "object":
961961
from app.usage import Window
@@ -969,7 +969,7 @@ def test_the_short_window_comes_first(self, env):
969969
weekly = self._window("週", 7 * 24 * 60)
970970
session = self._window("5 時間", 5 * 60)
971971

972-
assert [w.label for w in usage.ordered([weekly, session])] == ["5 時間", "週"]
972+
assert [w.label for w in usage.arranged([weekly, session])] == ["5 時間", "週"]
973973

974974
def test_windows_without_a_length_keep_their_place_at_the_end(self, env):
975975
"""claude は文面から読むので長さを持たない。元の並びのまま末尾へ。"""
@@ -979,7 +979,7 @@ def test_windows_without_a_length_keep_their_place_at_the_end(self, env):
979979
second = self._window("week", None)
980980
short = self._window("5 時間", 5 * 60)
981981

982-
assert [w.label for w in usage.ordered([first, second, short])] == [
982+
assert [w.label for w in usage.arranged([first, second, short])] == [
983983
"5 時間", "session", "week",
984984
]
985985

@@ -1007,3 +1007,76 @@ def test_it_can_be_enabled_without_one(self, env):
10071007

10081008
assert row["has_credential"] is False
10091009
assert row["can_enable"] is True
1010+
1011+
def test_windows_with_the_same_name_are_told_apart(self, env):
1012+
"""名前は長さから作るので、同じ長さの窓が 2 つあると見分けが付かない
1013+
—— 実測で codex は 7 日の窓を 2 つ返す(別勘定で、値もまったく違う)。
1014+
"""
1015+
from app import usage
1016+
from app.usage import Window
1017+
1018+
pair = [
1019+
Window(id="secondary", label="直近 7 日", used_percent=33.0,
1020+
window_minutes=7 * 24 * 60),
1021+
Window(id="primary-10080m", label="直近 7 日", used_percent=0.0,
1022+
window_minutes=7 * 24 * 60),
1023+
]
1024+
1025+
assert [w.label for w in usage.arranged(pair)] == [
1026+
"直近 7 日(secondary)", "直近 7 日(primary-10080m)",
1027+
]
1028+
1029+
def test_a_name_that_does_not_clash_is_left_alone(self, env):
1030+
from app import usage
1031+
from app.usage import Window
1032+
1033+
one = [Window(id="primary", label="直近 5 時間", used_percent=1.0,
1034+
window_minutes=300)]
1035+
1036+
assert [w.label for w in usage.arranged(one)] == ["直近 5 時間"]
1037+
1038+
1039+
class TestAWindowThatStoppedComing:
1040+
"""相手が返さなくなった窓は下へ回す。**消さずに、伸びていないことを示す。**"""
1041+
1042+
def test_it_sinks_below_the_live_ones(self, env):
1043+
import sqlite3
1044+
from datetime import UTC, datetime, timedelta
1045+
1046+
from app import usage_store
1047+
1048+
with make_client(env, ReplyLLM()):
1049+
# 混ざって大きく上がった古い線(もう伸びない)
1050+
usage_store.save_quota("codex", [{"id": "primary", "label": "直近 7 日",
1051+
"used_percent": 5.0}])
1052+
usage_store.save_quota("codex", [{"id": "primary", "label": "直近 7 日",
1053+
"used_percent": 90.0}])
1054+
old = (datetime.now(UTC) - timedelta(hours=2)).isoformat(timespec="seconds")
1055+
with sqlite3.connect(usage_store.db_path()) as conn:
1056+
conn.execute("UPDATE quota_samples SET at = ?", (old,))
1057+
# いま伸びている線
1058+
usage_store.save_quota("codex", [{"id": "primary-300m", "label": "直近 5 時間",
1059+
"used_percent": 3.0}])
1060+
trails = usage_store.quota_trail(datetime.now(UTC) - timedelta(days=1))
1061+
1062+
assert [t["window_id"] for t in trails] == ["primary-300m", "primary"]
1063+
assert trails[0]["stale"] is False
1064+
assert trails[1]["stale"] is True
1065+
1066+
def test_the_screen_says_it_is_no_longer_reported(self, env):
1067+
import sqlite3
1068+
from datetime import UTC, datetime, timedelta
1069+
1070+
from app import usage_store
1071+
1072+
with make_client(env, ReplyLLM()) as client:
1073+
usage_store.save_quota("codex", [{"id": "primary", "label": "直近 7 日",
1074+
"used_percent": 90.0}])
1075+
old = (datetime.now(UTC) - timedelta(hours=2)).isoformat(timespec="seconds")
1076+
with sqlite3.connect(usage_store.db_path()) as conn:
1077+
conn.execute("UPDATE quota_samples SET at = ?", (old,))
1078+
usage_store.save_quota("codex", [{"id": "primary-300m", "label": "直近 5 時間",
1079+
"used_percent": 3.0}])
1080+
html = client.get("/admin/ai").text
1081+
1082+
assert "いまは返ってこない窓" in html

0 commit comments

Comments
 (0)