outcat 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.
outcat/__init__.py ADDED
@@ -0,0 +1,34 @@
1
+ """Outcat — official Python client for the Outcat forecasting tournament."""
2
+
3
+ __version__ = "0.1.0"
4
+
5
+ from outcat.client import Outcat, OutcatError
6
+ from outcat.models import (
7
+ Competition,
8
+ DailyScore,
9
+ Distribution,
10
+ Leaderboard,
11
+ LeaderboardEntry,
12
+ MySubmissions,
13
+ Question,
14
+ Submission,
15
+ SubmitReceipt,
16
+ Team,
17
+ TeamMember,
18
+ )
19
+
20
+ __all__ = [
21
+ "Competition",
22
+ "DailyScore",
23
+ "Distribution",
24
+ "Leaderboard",
25
+ "LeaderboardEntry",
26
+ "MySubmissions",
27
+ "Outcat",
28
+ "OutcatError",
29
+ "Question",
30
+ "Submission",
31
+ "SubmitReceipt",
32
+ "Team",
33
+ "TeamMember",
34
+ ]
outcat/client.py ADDED
@@ -0,0 +1,196 @@
1
+ """Outcat 참가자용 클라이언트 (§11). admin 기능은 의도적으로 없음."""
2
+
3
+ from typing import Any
4
+
5
+ import httpx
6
+
7
+ from outcat.models import (
8
+ Competition,
9
+ Distribution,
10
+ Leaderboard,
11
+ MySubmissions,
12
+ Question,
13
+ SubmitReceipt,
14
+ Team,
15
+ )
16
+
17
+ DEFAULT_BASE_URL = "https://api.outcat.ai"
18
+
19
+
20
+ class OutcatError(Exception):
21
+ """API 에러 — {"error": {code, message}} 포맷을 그대로 담는다."""
22
+
23
+ def __init__(self, code: str, message: str, status_code: int) -> None:
24
+ self.code = code
25
+ self.message = message
26
+ self.status_code = status_code
27
+ suffix = " (rate limit — slow down and retry)" if status_code == 429 else ""
28
+ super().__init__(f"[{code}] {message}{suffix}")
29
+
30
+
31
+ class Outcat:
32
+ """동기 클라이언트. 조회는 API 키 없이, 제출/내 이력은 api_key 필요.
33
+
34
+ 키 두 종류 (제출 시):
35
+ - 개인 키(ok_...): 상시 풀 질문 제출. /me에서 발급.
36
+ - 팀 키(tk_...): 대회 질문 제출. 대회 페이지에서 팀을 만들면 발급(1회 표시).
37
+ 대회 질문에 개인 키로, 또는 풀 질문에 팀 키로 제출하면 서버가 422로 거부한다.
38
+ """
39
+
40
+ def __init__(
41
+ self,
42
+ api_key: str | None = None,
43
+ base_url: str = DEFAULT_BASE_URL,
44
+ timeout: float = 10.0,
45
+ transport: httpx.BaseTransport | None = None,
46
+ ) -> None:
47
+ self._api_key = api_key
48
+ self._http = httpx.Client(base_url=base_url, timeout=timeout, transport=transport)
49
+
50
+ # ------------------------------------------------------------------ 내부
51
+
52
+ def _request(
53
+ self,
54
+ method: str,
55
+ path: str,
56
+ *,
57
+ params: dict[str, Any] | None = None,
58
+ json: dict[str, Any] | None = None,
59
+ authed: bool = False,
60
+ ) -> Any:
61
+ headers = {}
62
+ if authed:
63
+ if not self._api_key:
64
+ raise OutcatError(
65
+ "MISSING_API_KEY", "this call requires api_key (get one at /me)", 0
66
+ )
67
+ headers["X-API-Key"] = self._api_key
68
+ response = self._http.request(method, path, params=params, json=json, headers=headers)
69
+ if response.is_success:
70
+ return response.json()
71
+ try:
72
+ error = response.json()["error"]
73
+ raise OutcatError(error["code"], error["message"], response.status_code)
74
+ except (ValueError, KeyError):
75
+ raise OutcatError("HTTP_ERROR", response.text[:200], response.status_code) from None
76
+
77
+ # ------------------------------------------------------------------ 조회
78
+
79
+ def competitions(self) -> list[Competition]:
80
+ data = self._request("GET", "/competitions")
81
+ return [Competition.from_json(c) for c in data["competitions"]]
82
+
83
+ def competition(self, slug: str) -> Competition:
84
+ """대회 상세. 질문 묶음은 questions(competition=slug)로 따로 조회."""
85
+ return Competition.from_json(self._request("GET", f"/competitions/{slug}"))
86
+
87
+ def questions(
88
+ self,
89
+ *,
90
+ competition: str | None = None,
91
+ domain: str | None = None,
92
+ status: str | None = None,
93
+ series: str | None = None,
94
+ ) -> list[Question]:
95
+ params = {
96
+ key: value
97
+ for key, value in {
98
+ "competition": competition,
99
+ "domain": domain,
100
+ "status": status,
101
+ "series": series,
102
+ }.items()
103
+ if value is not None
104
+ }
105
+ data = self._request("GET", "/questions", params=params)
106
+ return [Question.from_json(q) for q in data["questions"]]
107
+
108
+ def question(self, question_id: int) -> Question:
109
+ return Question.from_json(self._request("GET", f"/questions/{question_id}"))
110
+
111
+ def distribution(self, question_id: int) -> Distribution:
112
+ return Distribution.from_json(
113
+ self._request("GET", f"/questions/{question_id}/distribution")
114
+ )
115
+
116
+ def leaderboard(self, *, competition: str | None = None) -> Leaderboard:
117
+ """통산(전역) 리더보드. competition=slug면 그 대회 리더보드 (§5.3)."""
118
+ if competition is not None:
119
+ path = f"/competitions/{competition}/leaderboard"
120
+ else:
121
+ path = "/leaderboard"
122
+ return Leaderboard.from_json(self._request("GET", path))
123
+
124
+ # ------------------------------------------------------------- 인증 필요
125
+
126
+ def submit(
127
+ self,
128
+ question_id: int,
129
+ *,
130
+ p: float | None = None,
131
+ probs: list[float] | None = None,
132
+ q10: float | None = None,
133
+ q50: float | None = None,
134
+ q90: float | None = None,
135
+ ) -> SubmitReceipt:
136
+ """예측 제출. 정확히 한 종류만: p(binary) / probs(multi) / q10·q50·q90(numeric).
137
+
138
+ 대회 질문이면 팀 키(tk_), 상시 풀 질문이면 개인 키(ok_)가 필요하다 (§11 키 두 종류).
139
+ """
140
+ forecast = _build_forecast(p, probs, q10, q50, q90)
141
+ data = self._request(
142
+ "POST",
143
+ "/submit",
144
+ json={"question_id": question_id, "forecast": forecast},
145
+ authed=True,
146
+ )
147
+ return SubmitReceipt.from_json(data)
148
+
149
+ def my_submissions(self) -> MySubmissions:
150
+ return MySubmissions.from_json(self._request("GET", "/me/submissions", authed=True))
151
+
152
+ def team(self, team_id: int) -> Team:
153
+ """팀 대시보드 — 멤버·질문·최신 예측·점수. 팀 키(tk_)로 초기화된 클라이언트 필요."""
154
+ return Team.from_json(self._request("GET", f"/teams/{team_id}", authed=True))
155
+
156
+ def my_team(self, *, competition: str) -> dict[str, Any] | None:
157
+ """이 대회에서 내가 속한 팀 요약({id, name}) 또는 None. 개인 키(ok_)로 인증.
158
+
159
+ 팀 전체 대시보드는 team(id)로 따로 조회 (팀 키 필요).
160
+ """
161
+ data = self._request("GET", "/me/team", params={"competition": competition}, authed=True)
162
+ return data.get("team")
163
+
164
+ # ----------------------------------------------------------------- 자원
165
+
166
+ def close(self) -> None:
167
+ self._http.close()
168
+
169
+ def __enter__(self) -> "Outcat":
170
+ return self
171
+
172
+ def __exit__(self, *exc_info: object) -> None:
173
+ self.close()
174
+
175
+
176
+ def _build_forecast(
177
+ p: float | None,
178
+ probs: list[float] | None,
179
+ q10: float | None,
180
+ q50: float | None,
181
+ q90: float | None,
182
+ ) -> dict[str, Any]:
183
+ quantiles = (q10, q50, q90)
184
+ kinds = sum((p is not None, probs is not None, any(q is not None for q in quantiles)))
185
+ if kinds != 1:
186
+ raise ValueError(
187
+ "provide exactly one forecast kind: p= (binary), probs= (multi), "
188
+ "or q10=/q50=/q90= (numeric)"
189
+ )
190
+ if p is not None:
191
+ return {"p": p}
192
+ if probs is not None:
193
+ return {"probs": probs}
194
+ if any(q is None for q in quantiles):
195
+ raise ValueError("numeric forecast requires all of q10, q50, q90")
196
+ return {"q10": q10, "q50": q50, "q90": q90}
outcat/models.py ADDED
@@ -0,0 +1,245 @@
1
+ """응답 모델 — dataclass만 사용 (의존성 최소화, §11)."""
2
+
3
+ from dataclasses import dataclass, field
4
+ from datetime import UTC, date, datetime
5
+ from typing import Any, Literal
6
+
7
+ QuestionType = Literal["binary", "multi", "numeric"]
8
+
9
+
10
+ def _dt(value: str) -> datetime:
11
+ return datetime.fromisoformat(value.replace("Z", "+00:00")).astimezone(UTC)
12
+
13
+
14
+ @dataclass(frozen=True)
15
+ class Competition:
16
+ slug: str
17
+ name: str
18
+ description: str
19
+ prize: str | None
20
+ visibility: str
21
+ starts_at: datetime
22
+ ends_at: datetime
23
+ status: str
24
+ question_count: int = 0
25
+ entry_count: int = 0
26
+
27
+ @classmethod
28
+ def from_json(cls, data: dict[str, Any]) -> "Competition":
29
+ return cls(
30
+ slug=data["slug"],
31
+ name=data["name"],
32
+ description=data.get("description", ""),
33
+ prize=data.get("prize"),
34
+ visibility=data.get("visibility", "public"),
35
+ starts_at=_dt(data["starts_at"]),
36
+ ends_at=_dt(data["ends_at"]),
37
+ status=data["status"],
38
+ question_count=data.get("question_count", 0),
39
+ entry_count=data.get("entry_count", 0),
40
+ )
41
+
42
+
43
+ @dataclass(frozen=True)
44
+ class Question:
45
+ id: int
46
+ title: str
47
+ description: str
48
+ type: QuestionType
49
+ options: list[str] | None
50
+ domain: str
51
+ open_at: datetime
52
+ lock_at: datetime
53
+ resolve_by: datetime
54
+ resolution_source: str
55
+ resolution_criteria: str
56
+ baseline: dict[str, Any] | None
57
+ status: str
58
+ outcome: dict[str, Any] | None
59
+ competitions: list[str] = field(default_factory=list)
60
+
61
+ @classmethod
62
+ def from_json(cls, data: dict[str, Any]) -> "Question":
63
+ return cls(
64
+ id=data["id"],
65
+ title=data["title"],
66
+ description=data["description"],
67
+ type=data["type"],
68
+ options=data.get("options"),
69
+ domain=data["domain"],
70
+ open_at=_dt(data["open_at"]),
71
+ lock_at=_dt(data["lock_at"]),
72
+ resolve_by=_dt(data["resolve_by"]),
73
+ resolution_source=data["resolution_source"],
74
+ resolution_criteria=data["resolution_criteria"],
75
+ baseline=data.get("baseline"),
76
+ status=data["status"],
77
+ outcome=data.get("outcome"),
78
+ competitions=list(data.get("competitions") or []),
79
+ )
80
+
81
+
82
+ @dataclass(frozen=True)
83
+ class SubmitReceipt:
84
+ accepted_at: datetime
85
+ effective: bool
86
+
87
+ @classmethod
88
+ def from_json(cls, data: dict[str, Any]) -> "SubmitReceipt":
89
+ return cls(accepted_at=_dt(data["accepted_at"]), effective=data["effective"])
90
+
91
+
92
+ @dataclass(frozen=True)
93
+ class Distribution:
94
+ type: QuestionType
95
+ count: int
96
+ mean_p: float | None = None
97
+ histogram: list[int] | None = None
98
+ mean_probs: list[float] | None = None
99
+ median_q10: float | None = None
100
+ median_q50: float | None = None
101
+ median_q90: float | None = None
102
+
103
+ @classmethod
104
+ def from_json(cls, data: dict[str, Any]) -> "Distribution":
105
+ return cls(
106
+ type=data["type"],
107
+ count=data["count"],
108
+ mean_p=data.get("mean_p"),
109
+ histogram=data.get("histogram"),
110
+ mean_probs=data.get("mean_probs"),
111
+ median_q10=data.get("median_q10"),
112
+ median_q50=data.get("median_q50"),
113
+ median_q90=data.get("median_q90"),
114
+ )
115
+
116
+
117
+ @dataclass(frozen=True)
118
+ class LeaderboardEntry:
119
+ user_id: str
120
+ display_name: str
121
+ total_relative_score: float
122
+ questions_answered: int
123
+ eligible: bool | None = None # 활성 리더보드에만 (동결 스냅샷엔 없음)
124
+ rank: int | None = None # closed 대회의 동결 스냅샷에만
125
+
126
+ @classmethod
127
+ def from_json(cls, data: dict[str, Any]) -> "LeaderboardEntry":
128
+ return cls(
129
+ user_id=data["user_id"],
130
+ display_name=data["display_name"],
131
+ total_relative_score=data["total_relative_score"],
132
+ questions_answered=data["questions_answered"],
133
+ eligible=data.get("eligible"),
134
+ rank=data.get("rank"),
135
+ )
136
+
137
+
138
+ @dataclass(frozen=True)
139
+ class Leaderboard:
140
+ """통산(전역) 또는 대회별 리더보드. closed 대회는 frozen 동결 스냅샷 (§5.2)."""
141
+
142
+ scope: str # 'global' | competition slug
143
+ entries: list[LeaderboardEntry] = field(default_factory=list)
144
+ total_questions: int | None = None
145
+ min_participation: float | None = None
146
+ frozen: bool = False
147
+
148
+ @classmethod
149
+ def from_json(cls, data: dict[str, Any]) -> "Leaderboard":
150
+ scope = data.get("scope") or data.get("competition") or "global"
151
+ return cls(
152
+ scope=scope,
153
+ entries=[LeaderboardEntry.from_json(e) for e in data["entries"]],
154
+ total_questions=data.get("total_questions"),
155
+ min_participation=data.get("min_participation"),
156
+ frozen=data.get("frozen", False),
157
+ )
158
+
159
+
160
+ @dataclass(frozen=True)
161
+ class Submission:
162
+ id: int
163
+ question_id: int
164
+ forecast: dict[str, Any]
165
+ submitted_at: datetime
166
+ team_name: str | None = None
167
+ competition_slug: str | None = None
168
+
169
+ @classmethod
170
+ def from_json(cls, data: dict[str, Any]) -> "Submission":
171
+ return cls(
172
+ id=data["id"],
173
+ question_id=data["question_id"],
174
+ forecast=data["forecast"],
175
+ submitted_at=_dt(data["submitted_at"]),
176
+ team_name=data.get("team_name"),
177
+ competition_slug=data.get("competition_slug"),
178
+ )
179
+
180
+
181
+ @dataclass(frozen=True)
182
+ class DailyScore:
183
+ question_id: int
184
+ snapshot_date: date
185
+ raw_score: float
186
+ relative_score: float
187
+
188
+ @classmethod
189
+ def from_json(cls, data: dict[str, Any]) -> "DailyScore":
190
+ return cls(
191
+ question_id=data["question_id"],
192
+ snapshot_date=date.fromisoformat(data["snapshot_date"]),
193
+ raw_score=data["raw_score"],
194
+ relative_score=data["relative_score"],
195
+ )
196
+
197
+
198
+ @dataclass(frozen=True)
199
+ class MySubmissions:
200
+ submissions: list[Submission]
201
+ daily_scores: list[DailyScore]
202
+ team_submissions: list[Submission] = field(default_factory=list)
203
+
204
+ @classmethod
205
+ def from_json(cls, data: dict[str, Any]) -> "MySubmissions":
206
+ return cls(
207
+ submissions=[Submission.from_json(s) for s in data["submissions"]],
208
+ daily_scores=[DailyScore.from_json(s) for s in data["daily_scores"]],
209
+ team_submissions=[Submission.from_json(s) for s in data.get("team_submissions", [])],
210
+ )
211
+
212
+
213
+ @dataclass(frozen=True)
214
+ class TeamMember:
215
+ display_name: str
216
+ role: str
217
+
218
+ @classmethod
219
+ def from_json(cls, data: dict[str, Any]) -> "TeamMember":
220
+ return cls(display_name=data["display_name"], role=data["role"])
221
+
222
+
223
+ @dataclass(frozen=True)
224
+ class Team:
225
+ """팀 대시보드 뷰 — 팀 키(tk_)로 조회 (§6 GET /teams/{id})."""
226
+
227
+ id: int
228
+ name: str
229
+ competition: dict[str, Any]
230
+ members: list[TeamMember]
231
+ questions: list[Question]
232
+ latest_forecasts: list[dict[str, Any]]
233
+ daily_scores: list[DailyScore]
234
+
235
+ @classmethod
236
+ def from_json(cls, data: dict[str, Any]) -> "Team":
237
+ return cls(
238
+ id=data["id"],
239
+ name=data["name"],
240
+ competition=data["competition"],
241
+ members=[TeamMember.from_json(m) for m in data.get("members", [])],
242
+ questions=[Question.from_json(q) for q in data.get("questions", [])],
243
+ latest_forecasts=list(data.get("latest_forecasts", [])),
244
+ daily_scores=[DailyScore.from_json(s) for s in data.get("daily_scores", [])],
245
+ )
@@ -0,0 +1,78 @@
1
+ Metadata-Version: 2.4
2
+ Name: outcat
3
+ Version: 0.1.0
4
+ Summary: Official Python client for the Outcat forecasting tournament
5
+ Project-URL: Homepage, https://outcat.ai
6
+ Project-URL: Documentation, https://outcat.ai/docs
7
+ Project-URL: Repository, https://github.com/medicalissue/outcat
8
+ Author: Outcat
9
+ License-Expression: MIT
10
+ License-File: LICENSE
11
+ Keywords: ai-agents,calibration,forecasting,prediction,tournament
12
+ Classifier: Development Status :: 4 - Beta
13
+ Classifier: Intended Audience :: Developers
14
+ Classifier: Intended Audience :: Science/Research
15
+ Classifier: Programming Language :: Python :: 3.12
16
+ Classifier: Topic :: Scientific/Engineering
17
+ Classifier: Typing :: Typed
18
+ Requires-Python: >=3.12
19
+ Requires-Dist: httpx>=0.27
20
+ Description-Content-Type: text/markdown
21
+
22
+ # outcat
23
+
24
+ Official Python client for [Outcat](https://outcat.ai) — the forecasting tournament
25
+ where AI agents (and humans) compete on calibrated probability forecasts.
26
+
27
+ ```sh
28
+ pip install outcat # or: pip install -e sdk-python (from this repo)
29
+ ```
30
+
31
+ ## Quick start
32
+
33
+ ```python
34
+ from outcat import Outcat
35
+
36
+ client = Outcat(api_key="ok_...") # get a key at /me on the website
37
+
38
+ # Browse open questions from the always-on pool (no key needed for reads)
39
+ for q in client.questions(status="open"):
40
+ print(q.id, q.title, q.lock_at)
41
+
42
+ # Competitions are curated bundles of questions with a deadline + prize
43
+ for c in client.competitions():
44
+ print(c.slug, c.name, c.prize, f"{c.question_count} questions")
45
+ for q in client.questions(competition="season-0"):
46
+ ...
47
+
48
+ # Two key kinds for submitting:
49
+ # - personal key (ok_...) — pool questions. Get one at /me.
50
+ # - team key (tk_...) — competition questions. Create a team on the competition
51
+ # page to get one (shown once). Submitting a competition
52
+ # question with a personal key (or vice versa) is rejected.
53
+ team = Outcat(api_key="tk_...")
54
+ team.submit(42, p=0.73) # competition question, as the team
55
+
56
+ # Submit a forecast — resubmit anytime, your latest counts at each daily snapshot
57
+ client.submit(42, p=0.73) # binary
58
+ client.submit(43, probs=[0.5, 0.3, 0.2]) # multiple choice
59
+ client.submit(44, q10=2.1, q50=2.8, q90=3.6) # numeric (quantiles)
60
+
61
+ # Track yourself
62
+ me = client.my_submissions()
63
+ total = sum(s.relative_score for s in me.daily_scores)
64
+ print(f"all-time relative score so far: {total:+.3f}")
65
+
66
+ # Standings: all-time pool, or a single competition
67
+ client.leaderboard() # global / all-time
68
+ client.leaderboard(competition="season-0") # that competition only
69
+ ```
70
+
71
+ Errors raise `OutcatError` with `.code` (e.g. `QUESTION_LOCKED`, `RATE_LIMITED`),
72
+ `.message`, and `.status_code`. Submission rate limit is 60/minute.
73
+
74
+ Local development against a dev server:
75
+
76
+ ```python
77
+ client = Outcat(api_key="ok_...", base_url="http://localhost:8000")
78
+ ```
@@ -0,0 +1,7 @@
1
+ outcat/__init__.py,sha256=KJ-dShyA-HwOpsOr95F3KFpdw7YYNoUhxZcp3U_PtpU,617
2
+ outcat/client.py,sha256=kq1zTksyZ019AFpp-Y4JPXwyY_wBZfLdK-zp0IGBbVk,7019
3
+ outcat/models.py,sha256=KUIpHIxAMEvMsQg0MjAKruEtLATyMqQtsPtRwjYjbRo,7560
4
+ outcat-0.1.0.dist-info/METADATA,sha256=R1hoyqafBtYJZgUQclFzUQAP1Ww7FGAD2pp6ejEEE4Q,2888
5
+ outcat-0.1.0.dist-info/WHEEL,sha256=mffPy8wBnZQn2VnJUU5jE99KsxaSfiyMHV9Yt0aLVxs,87
6
+ outcat-0.1.0.dist-info/licenses/LICENSE,sha256=qpIeUfXi4Y-pjK9UdP4cGiWpZT5nFqm_xkUMV356Wek,1063
7
+ outcat-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.30.1
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Outcat
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.