SeaWolf-AI commited on
Commit
c8b9c87
ยท
verified ยท
1 Parent(s): 16a249d

Remove every field that can reconstruct a private composition from the leaderboard response

Browse files

The masked formula hid the element counts but the composition object beside it carried them verbatim, and the measured energies would have allowed the same reconstruction. Both, along with the atom count, are now stripped after the division is assigned and before the response is returned.

Files changed (1) hide show
  1. app.py +455 -432
app.py CHANGED
@@ -1,432 +1,455 @@
1
- # -*- coding: utf-8 -*-
2
- """Open Materials Challenge โ€” ์ ‘์ˆ˜์™€ ์ˆœ์œ„ํ‘œ.
3
-
4
- **์ด ์„œ๋น„์Šค๊ฐ€ ํ•˜์ง€ ์•Š๋Š” ์ผ**: ์ฑ„์ . ์ŠคํŽ˜์ด์Šค์—๋Š” Materials Project ์ž๋ฃŒ๋„ GPU ๋„ ์—†๋‹ค.
5
- ์ œ์ถœ์„ ๋ฐ›์•„ ์›์žฅ(๋น„๊ณต๊ฐœ ๋ฐ์ดํ„ฐ์…‹)์— ์ ๊ณ , ์›Œ์ปค๊ฐ€ ์ฑ„์ ํ•ด ๋„ฃ์€ ๊ฒฐ๊ณผ๋ฅผ ๋ณด์—ฌ์ค„ ๋ฟ์ด๋‹ค.
6
- ์—ฌ๊ธฐ์„œ ๋ฌผ์„ฑ์„ ํŒ์ •ํ•˜๋Š” ์ฒ™ํ•˜๋ฉด ์ฐธ๊ฐ€์ž๋Š” ๊ทผ๊ฑฐ ์—†๋Š” ๋ฐ˜๋ ค๋ฅผ ๋ฐ›๊ฒŒ ๋œ๋‹ค.
7
-
8
- ์ˆœ์œ„ํ‘œ๋Š” TTL ๋‹น ํ•œ ๋ฒˆ๋งŒ ๋งŒ๋“ค์–ด ๋ฏธ๋ฆฌ ์••์ถ•ํ•ด ๋‘๊ณ , ์บ์‹œ๋Š” ๋‹จ์ผ ๊ฐฑ์‹ ์ž๋กœ ๋Œ๋ฆฐ๋‹ค.
9
- ์ฒซ ํŽ˜์ด์ง€๋Š” async ๋กœ ์„œ๋น™ํ•˜๊ณ , ํ‘œ๋Š” 500 ํ–‰์”ฉ ๋‚˜๋ˆ  ๊ทธ๋ฆฐ๋‹ค.
10
- """
11
- import base64
12
- import gzip
13
- import hashlib
14
- import hmac
15
- import json
16
- import os
17
- import re
18
- import secrets
19
- import time
20
- import urllib.parse
21
- import urllib.request
22
- import uuid
23
-
24
- from fastapi import FastAPI, HTTPException, Request
25
- from fastapi.middleware.gzip import GZipMiddleware
26
- from fastapi.responses import (FileResponse, JSONResponse, RedirectResponse,
27
- Response)
28
- from pydantic import BaseModel
29
-
30
- import gates
31
- import seasons
32
- import store
33
-
34
- HERE = os.path.dirname(os.path.abspath(__file__))
35
- DATA = os.path.join(HERE, "data")
36
- # ํŽ˜์ด์ง€ ํด๋ง ์ฃผ๊ธฐ(20์ดˆ)๋ณด๋‹ค ํฌ๊ฒŒ ์žก๋Š”๋‹ค.
37
- CACHE = store.Cached(ttl=int(os.environ.get("OMC_CACHE_TTL", "60")))
38
- SALT = os.environ.get("OMC_SALT", "omc-season1")
39
-
40
- OAUTH_ID = os.environ.get("OAUTH_CLIENT_ID", "")
41
- OAUTH_SECRET = os.environ.get("OAUTH_CLIENT_SECRET", "")
42
- OAUTH_ISS = os.environ.get("OPENID_PROVIDER_URL", "https://huggingface.co")
43
- SPACE_HOST = os.environ.get("SPACE_HOST", "")
44
- COOKIE = "omc_session"
45
- IN_FRAME = bool(SPACE_HOST)
46
- COOKIE_KW = ({"samesite": "none", "secure": True} if IN_FRAME
47
- else {"samesite": "lax", "secure": False})
48
- SESSION_KEY = os.environ.get("OMC_SESSION_KEY") or secrets.token_hex(16)
49
-
50
- DAILY_CAP = int(os.environ.get("OMC_DAILY_CAP", "30"))
51
- CAP_TZ_OFFSET = 9 * 3600 # ์ฐธ๊ฐ€์ž๊ฐ€ ๊ฐ€์ •ํ•  ํ•˜๋ฃจ ๊ฒฝ๊ณ„ (KST)
52
-
53
- app = FastAPI(title="Open Materials Challenge")
54
- app.add_middleware(GZipMiddleware, minimum_size=1024)
55
-
56
-
57
- class Submission(BaseModel):
58
- formula: str # ์กฐ์„ฑ. ์˜ˆ: Li3YCl6, LiZr2(PO4)3
59
- display_name: str # ์ˆœ์œ„ํ‘œ์— ํ‘œ์‹œ๋  ์ด๋ฆ„
60
- model_name: str = "" # ์–ด๋–ค ๋ชจ๋ธ์ด ์ œ์•ˆํ–ˆ๋Š”์ง€. ์ž์œ  ๊ธฐ์žฌ
61
- rationale: str = "" # ์„ค๊ณ„ ๊ทผ๊ฑฐ (์„ ํƒ)
62
- structure: str = "" # CIF (์„ ํƒ) - ๋‚ด๋ฉด ์ฑ„์ ์ด ๋น ๋ฅด๊ณ  ์ •ํ™•ํ•ด์ง„๋‹ค
63
- season: int = 0
64
- # ์กฐ์„ฑ์„ ๊ณต๊ฐœํ• ์ง€๋Š” ์ œ์ถœ์ž๊ฐ€ ์ •ํ•œ๋‹ค. ๋˜๋Œ๋ฆด ์ˆ˜ ์—†๋Š” ์ชฝ์ด ๊ธฐ๋ณธ๊ฐ’์ด์–ด์•ผ ํ•œ๋‹ค -
65
- # ํ•œ ๋ฒˆ ๊ณต๊ฐœ๋œ ์กฐ์„ฑ์€ ๋˜๊ฐ์„ ์ˆ˜ ์—†๊ณ , ๊ณต๊ฐœ๋Š” ํŠนํ—ˆ์„ฑ์„ ๊นŽ๋Š”๋‹ค.
66
- visibility: str = "private"
67
-
68
-
69
- # ---------------------------------------------------------------- ์›์žฅ ์ ‘๊ทผ
70
- def _submissions(s):
71
- return CACHE.get("ids:%d" % s["number"],
72
- lambda: store.listdir(seasons.path(s, "submissions")))
73
-
74
-
75
- def _board(s):
76
- return CACHE.get("board:%d" % s["number"],
77
- lambda: store.read(seasons.path(s, "leaderboard.json"),
78
- default={}) or {})
79
-
80
-
81
- def public_id(key):
82
- return "OMC-" + hashlib.sha256((SALT + key).encode()).hexdigest()[:10].upper()
83
-
84
-
85
- def mask(rec):
86
- """๋น„๊ณต๊ฐœ ์ œ์ถœ์—์„œ ์กฐ์„ฑ์„ ๊ฐ€๋ฆฐ๋‹ค. ์›์†Œ ์ข…๋ฅ˜๊นŒ์ง€๋Š” ๋ณด์—ฌ์ค€๋‹ค - ๊ทธ๊ฒƒ๋งŒ์œผ๋กœ๋Š”
87
- ์กฐ์„ฑ์„ ๋˜๋Œ๋ฆด ์ˆ˜ ์—†๊ณ , ์–ด๋–ค ํ™”ํ•™๊ณ„๊ฐ€ ์ƒ์œ„์ธ์ง€๋Š” ๋Œ€ํšŒ์˜ ๊ณต๊ฐœ ์ง€์‹์ด์–ด์•ผ ํ•œ๋‹ค."""
88
- out = dict(rec)
89
- if rec.get("visibility") != "public":
90
- comp = rec.get("composition") or {}
91
- out["formula"] = "ยท".join(sorted(comp)) if comp else "๋น„๊ณต๊ฐœ"
92
- out["masked"] = True
93
- out.pop("structure", None)
94
- return out
95
-
96
-
97
- # ---------------------------------------------------------------- ์„ธ์…˜
98
- def _sign(payload):
99
- raw = base64.urlsafe_b64encode(json.dumps(payload).encode()).decode().rstrip("=")
100
- sig = hmac.new(SESSION_KEY.encode(), raw.encode(), hashlib.sha256).hexdigest()[:32]
101
- return raw + "." + sig
102
-
103
-
104
- def _unsign(token):
105
- try:
106
- raw, sig = (token or "").rsplit(".", 1)
107
- except ValueError:
108
- return None
109
- good = hmac.new(SESSION_KEY.encode(), raw.encode(), hashlib.sha256).hexdigest()[:32]
110
- if not hmac.compare_digest(sig, good):
111
- return None
112
- pad = "=" * (-len(raw) % 4)
113
- try:
114
- return json.loads(base64.urlsafe_b64decode(raw + pad))
115
- except Exception:
116
- return None
117
-
118
-
119
- def current_user(request: Request):
120
- return _unsign(request.cookies.get(COOKIE))
121
-
122
-
123
- def _redirect_uri(request: Request):
124
- if SPACE_HOST:
125
- return "https://%s/auth/callback" % SPACE_HOST
126
- return str(request.base_url).rstrip("/") + "/auth/callback"
127
-
128
-
129
- # ---------------------------------------------------------------- ํ™”๋ฉด
130
- @app.get("/")
131
- async def index():
132
- # ์ •์  ํŽ˜์ด์ง€๋Š” ์š”์ฒญ ์Šค๋ ˆ๋“œํ’€์„ ํƒ€์ง€ ์•Š๊ฒŒ async ๋กœ ๋‘”๋‹ค.
133
- return FileResponse(os.path.join(HERE, "index.html"))
134
-
135
-
136
- @app.get("/api/me")
137
- def me(request: Request):
138
- u = current_user(request)
139
- return {"signed_in": bool(u), "user": u,
140
- "oauth_configured": bool(OAUTH_ID and OAUTH_SECRET)}
141
-
142
-
143
- @app.get("/login")
144
- def login(request: Request):
145
- if not (OAUTH_ID and OAUTH_SECRET):
146
- raise HTTPException(503, "OAuth ๊ฐ€ ์„ค์ •๋˜์ง€ ์•Š์•˜์Šต๋‹ˆ๋‹ค.")
147
- state = secrets.token_urlsafe(16)
148
- q = urllib.parse.urlencode({
149
- "client_id": OAUTH_ID, "redirect_uri": _redirect_uri(request),
150
- "response_type": "code", "scope": "openid profile", "state": state})
151
- r = RedirectResponse("%s/oauth/authorize?%s" % (OAUTH_ISS, q))
152
- r.set_cookie("omc_state", state, max_age=600, httponly=True, **COOKIE_KW)
153
- return r
154
-
155
-
156
- @app.get("/auth/callback")
157
- def callback(request: Request, code: str = "", state: str = ""):
158
- if not code or state != request.cookies.get("omc_state"):
159
- return RedirectResponse("/?auth=failed")
160
- body = urllib.parse.urlencode({
161
- "client_id": OAUTH_ID, "client_secret": OAUTH_SECRET,
162
- "grant_type": "authorization_code", "code": code,
163
- "redirect_uri": _redirect_uri(request)}).encode()
164
- try:
165
- req = urllib.request.Request("%s/oauth/token" % OAUTH_ISS, data=body)
166
- with urllib.request.urlopen(req, timeout=30) as x:
167
- tok = json.loads(x.read())
168
- req = urllib.request.Request(
169
- "%s/oauth/userinfo" % OAUTH_ISS,
170
- headers={"Authorization": "Bearer " + tok["access_token"]})
171
- with urllib.request.urlopen(req, timeout=30) as x:
172
- info = json.loads(x.read())
173
- except Exception:
174
- return RedirectResponse("/?auth=failed")
175
- u = {"sub": info.get("sub"), "name": info.get("preferred_username") or info.get("name"),
176
- "picture": info.get("picture")}
177
- r = RedirectResponse("/?auth=ok")
178
- r.set_cookie(COOKIE, _sign(u), max_age=30 * 86400, httponly=True, **COOKIE_KW)
179
- r.delete_cookie("omc_state")
180
- return r
181
-
182
-
183
- @app.get("/logout")
184
- def logout():
185
- r = RedirectResponse("/")
186
- r.delete_cookie(COOKIE)
187
- return r
188
-
189
-
190
- @app.get("/api/seasons")
191
- def season_list():
192
- return {"seasons": seasons.all_public()}
193
-
194
-
195
- @app.get("/api/season")
196
- def season(request: Request):
197
- return seasons.public(seasons.get(request.query_params.get("season")))
198
-
199
-
200
- # ---------------------------------------------------------------- ์ œ์ถœ
201
- @app.post("/api/submit")
202
- def submit(s: Submission, request: Request):
203
- season = seasons.get(s.season or None)
204
- if not season.get("open"):
205
- raise HTTPException(400, "์ด ์‹œ์ฆŒ์€ ์ œ์ถœ์„ ๋ฐ›์ง€ ์•Š์Šต๋‹ˆ๋‹ค.")
206
- user = current_user(request)
207
- if (OAUTH_ID and OAUTH_SECRET) and not user:
208
- raise HTTPException(401, "์ œ์ถœํ•˜๋ ค๋ฉด Hugging Face ๋กœ๊ทธ์ธ์ด ํ•„์š”ํ•ฉ๋‹ˆ๋‹ค.")
209
-
210
- g = seasons.gate(season)
211
- ok, why, info = gates.check(s.formula, cif=s.structure or None,
212
- require_elements=g.get("require_elements", ("Li",)),
213
- max_atoms=g.get("max_atoms", 60))
214
- if not ok:
215
- raise HTTPException(400, why)
216
-
217
- comp = info["composition"]
218
- # ์—ด์‡ ๋Š” **๊ธฐ์•ฝ ์กฐ์„ฑ**์œผ๋กœ ๋งŒ๋“ ๋‹ค. Li3YCl6 ์™€ Li9Y3Cl18 ์€ ๊ฐ™์€ ๋ฌผ์งˆ์ด๋‹ค.
219
- # ์›๋ณธ ์กฐ์„ฑ์œผ๋กœ ๋งŒ๋“ค๋ฉด ๋ฐฐ์ˆ˜๋งŒ ๋ฐ”๊ฟ” ๊ฐ™์€ ๋ฌผ์งˆ์„ ๋ช‡ ๋ฒˆ์ด๊ณ  ์˜ฌ๋ฆด ์ˆ˜ ์žˆ๋‹ค.
220
- red = info.get("reduced") or comp
221
- key = "-".join("%s%g" % (k, red[k]) for k in sorted(red))
222
- cid = public_id(key)
223
-
224
- ids = _submissions(season)
225
- if cid in ids:
226
- raise HTTPException(409, "์ด๋ฏธ ์ œ์ถœ๋œ ์กฐ์„ฑ์ž…๋‹ˆ๋‹ค (%s)." % cid)
227
-
228
- if user:
229
- today = int((time.time() + CAP_TZ_OFFSET) // 86400)
230
- mine = CACHE.get("cap:%s:%d:%d" % (user.get("sub"), season["number"], today),
231
- lambda: 0)
232
- if mine >= DAILY_CAP:
233
- raise HTTPException(429, "ํ•˜๋ฃจ ์ œ์ถœ ์ƒํ•œ(%d๊ฑด)์— ๋„๋‹ฌํ–ˆ์Šต๋‹ˆ๋‹ค." % DAILY_CAP)
234
-
235
- rec = {"candidate_id": cid, "formula": s.formula.strip(),
236
- "composition": comp, "n_atoms": info["n_atoms"],
237
- "display_name": (s.display_name or "").strip()[:60],
238
- "model_name": (s.model_name or "").strip()[:80],
239
- "rationale": (s.rationale or "").strip()[:1000],
240
- "visibility": "public" if s.visibility == "public" else "private",
241
- "has_structure": bool(s.structure),
242
- "hf_user": (user or {}).get("name", ""),
243
- "hf_sub": (user or {}).get("sub", ""),
244
- "season": season["number"],
245
- "submitted_at": int(time.time()),
246
- "uuid": uuid.uuid4().hex}
247
- store.write(seasons.path(season, "submissions/%s.json" % cid), rec,
248
- summary="submit %s" % cid)
249
- if s.structure:
250
- # ๊ตฌ์กฐ๋Š” ๋”ฐ๋กœ ๋‘”๋‹ค. ๋ชฉ๋ก์„ ํ›‘์„ ๋•Œ ๊ฐ™์ด ๋Œ๋ ค์˜ค๋ฉด ์ˆœ์œ„ํ‘œ๊ฐ€ ๋ฌด๊ฑฐ์›Œ์ง„๋‹ค.
251
- store.write(seasons.path(season, "structures/%s.json" % cid),
252
- {"candidate_id": cid, "cif": s.structure[:400_000]},
253
- summary="structure %s" % cid)
254
- CACHE.drop("ids:%d" % season["number"])
255
-
256
- scored = set((_board(season).get("entries") or {}).keys())
257
- ahead = len([i for i in _submissions(season) if i not in scored])
258
- return {"accepted": True, "candidate_id": cid, "queue_position": ahead,
259
- "note": "์ฑ„์ ์€ ๋ณ„๋„ ๊ณ„์‚ฐ ์ž‘์—…์œผ๋กœ ์ฒ˜๋ฆฌ๋˜๋ฉฐ ์™„๋ฃŒ๊นŒ์ง€ ์‹œ๊ฐ„์ด ๊ฑธ๋ฆฝ๋‹ˆ๋‹ค."}
260
-
261
-
262
- # ---------------------------------------------------------------- ์ˆœ์œ„ํ‘œ
263
- def _anchors(season):
264
- p = os.path.join(DATA, season.get("anchors") or "")
265
- if not os.path.exists(p):
266
- return []
267
- out = []
268
- for a in json.load(open(p, encoding="utf-8")):
269
- if not a.get("admitted"):
270
- continue
271
- out.append({"candidate_id": a["label"], "is_anchor": True,
272
- "display_name": "๊ธฐ์ค€๋ฌผ์งˆ", "model_name": "",
273
- "formula": a.get("formula", ""),
274
- "total": a.get("total"), "total_max": a.get("total_max"),
275
- "tier": a.get("tier", 1),
276
- "axes": {k: v["points"] for k, v in (a.get("axes") or {}).items()},
277
- "detail": {k: v["detail"] for k, v in (a.get("axes") or {}).items()},
278
- "note": a.get("note", "")})
279
- return out
280
-
281
-
282
- # ์Œ์ด์˜จ ๊ณจ๊ฒฉ์ด ๋ฌผ์งˆ์˜ ์„ฑ์งˆ์„ ํฌ๊ฒŒ ๊ฐ€๋ฅธ๋‹ค. ๊ฐ™์€ ๊ณ„์—ด๋ผ๋ฆฌ ๊ฒฌ์ฃผ์–ด์•ผ ํ›„๋ณด์˜ ์œ„์น˜๊ฐ€ ์ฝํžŒ๋‹ค.
283
- # ๋ถ€๋ฌธ์„ ๋‚˜๋ˆ„๋˜ **์ ์ˆ˜๋Š” ๊ทธ๋Œ€๋กœ ๋‘”๋‹ค** - ์–ด๋–ค ์ œ์ถœ๋„ ๋ฌดํšจ๊ฐ€ ๋˜์ง€ ์•Š๋Š”๋‹ค.
284
- _ANION_ORDER = ["F", "Cl", "Br", "I", "S", "Se", "O", "N", "P", "H"]
285
- DIVISIONS = {"F": "fluoride", "Cl": "halide", "Br": "halide", "I": "halide",
286
- "S": "sulfide", "Se": "sulfide", "O": "oxide", "N": "nitride",
287
- "P": "oxide", "H": "hydride"}
288
-
289
-
290
- def division_of(comp):
291
- """๊ณจ๊ฒฉ ์Œ์ด์˜จ์œผ๋กœ ๋ถ€๋ฌธ์„ ์ •ํ•œ๋‹ค.
292
-
293
- ์„ž์—ฌ ์žˆ์œผ๋ฉด **๊ฐ€์žฅ ๋งŽ์€ ๊ฒƒ**์„ ๊ณ ๋ฅธ๋‹ค. Li6PS5Cl ์€ S ๊ฐ€ 5, Cl ์ด 1 ์ด๋ฏ€๋กœ ํ™ฉํ™”๋ฌผ์ด๊ณ ,
294
- ์‹ค์ œ๋กœ ์•„์ง€๋กœ๋‹ค์ดํŠธ๋Š” ํ™ฉํ™”๋ฌผ๋กœ ๋ถ„๋ฅ˜๋œ๋‹ค. ๊ฐœ์ˆ˜๊ฐ€ ๊ฐ™์œผ๋ฉด ์œ„ ์ˆœ์„œ์—์„œ ์•ž์„  ๊ฒƒ์„ ์“ด๋‹ค.
295
- """
296
- if not comp:
297
- return "other"
298
- best, bestn = None, -1
299
- for el in _ANION_ORDER:
300
- n = comp.get(el, 0)
301
- if n > bestn:
302
- best, bestn = el, n
303
- if bestn <= 0:
304
- return "other"
305
- return DIVISIONS.get(best, "other")
306
-
307
-
308
- def _leaderboard_body(season):
309
- rows = [mask(r) for r in (_board(season).get("entries") or {}).values()]
310
- anchors = _anchors(season)
311
- merged = rows + anchors
312
- # ์ ์ˆ˜๊ฐ€ ์—†๋Š” ๊ฒƒ์€ ๋งจ ๋’ค๋กœ. ๊ทธ ์•ˆ์—์„œ๋Š” ๋‚ธ ์ˆœ์„œ๋ฅผ ์ง€ํ‚จ๋‹ค.
313
- # ์ „ ์ถ•์ด ์ฑ„์›Œ์ง„ ๊ฒƒ๋ถ€ํ„ฐ, ๊ทธ ์•ˆ์—์„œ ์ด์  ์ˆœ. ์ถ•์ด ๋นˆ ๊ฒƒ์€ ๋’ค๋กœ - ์ด์ ์ด ์žˆ์–ด๋„
314
- # ๋งŒ์ ์ด ๋‹ฌ๋ผ ๊ฐ™์€ ์ค„์— ์„ธ์šธ ์ˆ˜ ์—†๋‹ค.
315
- merged.sort(key=lambda e: (e.get("tier", 1),
316
- 1 if (e.get("total") is None or e.get("held_axes")) else 0,
317
- -(e.get("total") if e.get("total") is not None else 0),
318
- e.get("submitted_at") or 0))
319
- n = 0
320
- div_n = {}
321
- for e in merged:
322
- comp = e.get("composition") or {}
323
- e["division"] = division_of(comp)
324
- # ๋“ฑ์ˆ˜๋Š” **์ „ ์ถ•์ด ์ฑ„์›Œ์ง„ ๊ฒƒ**์—๋งŒ ์ค€๋‹ค. ์ถ•์ด ํ•˜๋‚˜๋ผ๋„ ๋น„๋ฉด ๋งŒ์ ์ด ๋‹ฌ๋ผ ์ด์ ์„
325
- # ๋‚˜๋ž€ํžˆ ์„ธ์šธ ์ˆ˜ ์—†๋‹ค - ์ ์ˆ˜๋Š” ๋‚จ๊ธฐ๋˜ ์ˆœ์œ„์—๋Š” ๋„ฃ์ง€ ์•Š๋Š”๋‹ค.
326
- unscored = e.get("total") is None or bool(e.get("held_axes"))
327
- if e.get("is_anchor") or e.get("tier", 1) != 1 or unscored:
328
- e["rank"] = None # ๊ธฐ์ค€๋ฌผ์งˆ์€ ์ฒ™๋„๋ฅผ ํ‘œ์‹œํ•  ๋ฟ ๋“ฑ์ˆ˜๋ฅผ ๊ฐ–์ง€ ์•Š๋Š”๋‹ค
329
- e["div_rank"] = None
330
- else:
331
- n += 1
332
- e["rank"] = n
333
- # ๋ถ€๋ฌธ ์ˆœ์œ„๋Š” ์ „์ฒด ์ˆœ์œ„์™€ ๋”ฐ๋กœ ๋งค๊ธด๋‹ค. ์ ์ˆ˜๋Š” ์†๋Œ€์ง€ ์•Š๋Š”๋‹ค.
334
- d = e["division"]
335
- div_n[d] = div_n.get(d, 0) + 1
336
- e["div_rank"] = div_n[d]
337
- tally = {}
338
- for e in merged:
339
- if e.get("is_anchor"):
340
- continue
341
- tally[e["division"]] = tally.get(e["division"], 0) + 1
342
- n_held = sum(1 for e in merged
343
- if not e.get("is_anchor")
344
- and (e.get("total") is None or e.get("held_axes")))
345
- return {"season": seasons.public(season), "entries": merged,
346
- "divisions": tally,
347
- "counts": {"scored": len(rows) - n_held, "held": n_held,
348
- "anchors": len(anchors)}}
349
-
350
-
351
- def _leaderboard_bytes(season):
352
- raw = json.dumps(_leaderboard_body(season), ensure_ascii=False).encode("utf-8")
353
- return raw, gzip.compress(raw, 6)
354
-
355
-
356
- @app.get("/api/leaderboard")
357
- def leaderboard(request: Request):
358
- """์‘๋‹ต์€ ์‹œ์ฆŒ์—๋งŒ ์˜์กดํ•˜๋ฏ€๋กœ TTL ๋‹น ํ•œ ๋ฒˆ๋งŒ ๋งŒ๋“ค๊ณ  ํ•œ ๋ฒˆ๋งŒ ์••์ถ•ํ•œ๋‹ค."""
359
- season = seasons.get(request.query_params.get("season"))
360
- raw, gz = CACHE.get("lbresp:%d" % season["number"],
361
- lambda: _leaderboard_bytes(season))
362
- if "gzip" in (request.headers.get("accept-encoding") or ""):
363
- return Response(content=gz, media_type="application/json",
364
- headers={"Content-Encoding": "gzip", "Vary": "Accept-Encoding"})
365
- return Response(content=raw, media_type="application/json",
366
- headers={"Vary": "Accept-Encoding"})
367
-
368
-
369
- _FILLER = {"ai", "model", "llm", "the", "v", "ver", "version", "latest", "preview",
370
- "chat", "instruct", "it", "api", "official", "new"}
371
-
372
-
373
- def canon_model(name):
374
- """ํ‘œ๊ธฐ๊ฐ€ ๊ฐˆ๋ฆฐ ๊ฐ™์€ ๋ชจ๋ธ์„ ํ•˜๋‚˜๋กœ ๋ฌถ๋Š”๋‹ค.
375
-
376
- 'Anthropic Claude Opus 5' ์™€ 'claude-opus-5' ๋Š” ๊ฐ™์€ ๊ฒƒ์ด๊ณ , ๋”ฐ๋กœ ์„ธ๋ฉด ๋‘˜ ๋‹ค
377
- ์‹ค์ œ๋ณด๋‹ค ์ ๊ฒŒ ์“ฐ์ธ ๊ฒƒ์ฒ˜๋Ÿผ ๋ณด์ธ๋‹ค. ๋‹ค๋งŒ ๋ณ€์ข…์„ ๊ตฌ๋ถ„ํ•˜๋Š” ๋‚ฑ๋ง(Pro/Flash ๋“ฑ)์€
378
- ์ง€์šฐ์ง€ ์•Š๋Š”๋‹ค - ๊ทธ๊ฒƒ๊นŒ์ง€ ๋ญ‰์น˜๋ฉด ๋‹ค๋ฅธ ๋ชจ๋ธ์ด ํ•œ ์นธ์— ๋“ค์–ด๊ฐ„๋‹ค.
379
- """
380
- s = re.sub(r"[^a-z0-9]+", " ", (name or "").lower()).strip()
381
- toks = [t for t in s.split() if t and t not in _FILLER]
382
- return " ".join(toks) or (name or "").strip().lower()
383
-
384
-
385
- @app.get("/api/models")
386
- def models(request: Request):
387
- """๋ชจ๋ธ๋ณ„ ์„ฑ์ : ์–ด๋А ๋ชจ๋ธ์ด ๊ฐ€์žฅ ์ข‹์€ ํ›„๋ณด๋ฅผ ๋ƒˆ๋Š”๊ฐ€, ๊ทธ๋ฆฌ๊ณ  ์–ผ๋งˆ๋‚˜ ์“ฐ์ด๋Š”๊ฐ€.
388
-
389
- ๋‘ ์ˆ˜๋ฅผ **๋”ฐ๋กœ** ๋‚ธ๋‹ค. ๋งŽ์ด ์“ฐ์ธ๋‹ค๊ณ  ์ž˜ํ•˜๋Š” ๊ฒƒ์ด ์•„๋‹ˆ๋‹ค - ๋‹ค๋“ค ์ง‘๋Š” ๋ชจ๋ธ์€ ์ ์ˆ˜์™€
390
- ๋ฌด๊ด€ํ•˜๊ฒŒ ์ œ์ถœ ์ˆ˜๊ฐ€ ์Œ“์ด๊ณ , ์„ธ ๋ฒˆ ์“ฐ์ธ ๋ชจ๋ธ์ด 1์œ„ ํ›„๋ณด๋ฅผ ๊ฐ–๊ณ  ์žˆ์„ ์ˆ˜ ์žˆ๋‹ค.
391
- ํ‰๊ท ๋งŒ ๋‚ด๋ฉด ๋‘˜ ๋‹ค ๊ฐ€๋ ค์ง„๋‹ค. ๊ทธ๋ž˜์„œ ์ตœ๊ณ ยทํ‰๊ท ยท๊ฑด์ˆ˜๋ฅผ ๋‚˜๋ž€ํžˆ ๋‘”๋‹ค.
392
- ๊ธฐ์ค€๋ฌผ์งˆ์€ ์ œ์™ธํ•œ๋‹ค - ์ฐธ๊ฐ€์ž์˜ ๋ชจ๋ธ์ด ๋งŒ๋“  ๊ฒƒ์ด ์•„๋‹ˆ๋‹ค.
393
- """
394
- season = seasons.get(request.query_params.get("season"))
395
- agg = {}
396
- for r in (_board(season).get("entries") or {}).values():
397
- if r.get("total") is None:
398
- continue
399
- name = (r.get("model_name") or "").strip() or "๋ฏธ๊ธฐ์žฌ / unspecified"
400
- key = canon_model(name)
401
- a = agg.setdefault(key, {"n": 0, "best": None, "sum": 0.0,
402
- "top_candidate": None, "spellings": {}})
403
- a["spellings"][name] = a["spellings"].get(name, 0) + 1
404
- a["n"] += 1
405
- a["sum"] += r["total"]
406
- if a["best"] is None or r["total"] > a["best"]:
407
- a["best"] = r["total"]
408
- a["top_candidate"] = r.get("candidate_id")
409
- out = []
410
- for k, a in agg.items():
411
- # ์ฐธ๊ฐ€์ž๋“ค์ด ์‹ค์ œ๋กœ ๊ฐ€์žฅ ๋งŽ์ด ์“ด ํ‘œ๊ธฐ๋ฅผ ์ด๋ฆ„์œผ๋กœ ์‚ผ๋Š”๋‹ค
412
- label = max(a["spellings"].items(), key=lambda kv: kv[1])[0]
413
- out.append({"model": label, "n": a["n"], "best": round(a["best"], 2),
414
- "mean": round(a["sum"] / a["n"], 2),
415
- "top_candidate": a["top_candidate"]})
416
- out.sort(key=lambda x: (-x["best"], -x["n"]))
417
- return {"models": out, "counts": {"models": len(out),
418
- "entries": sum(m["n"] for m in out)}}
419
-
420
-
421
- @app.get("/api/queue")
422
- def queue(request: Request):
423
- season = seasons.get(request.query_params.get("season"))
424
- ids = _submissions(season)
425
- scored = set((_board(season).get("entries") or {}).keys())
426
- return {"queued": len([i for i in ids if i not in scored]),
427
- "scored": len(scored), "total": len(ids)}
428
-
429
-
430
- @app.get("/api/health")
431
- def health():
432
- return {"ok": True, "seasons": [s["number"] for s in seasons.SEASONS]}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # -*- coding: utf-8 -*-
2
+ """Open Materials Challenge โ€” ์ ‘์ˆ˜์™€ ์ˆœ์œ„ํ‘œ.
3
+
4
+ **์ด ์„œ๋น„์Šค๊ฐ€ ํ•˜์ง€ ์•Š๋Š” ์ผ**: ์ฑ„์ . ์ŠคํŽ˜์ด์Šค์—๋Š” Materials Project ์ž๋ฃŒ๋„ GPU ๋„ ์—†๋‹ค.
5
+ ์ œ์ถœ์„ ๋ฐ›์•„ ์›์žฅ(๋น„๊ณต๊ฐœ ๋ฐ์ดํ„ฐ์…‹)์— ์ ๊ณ , ์›Œ์ปค๊ฐ€ ์ฑ„์ ํ•ด ๋„ฃ์€ ๊ฒฐ๊ณผ๋ฅผ ๋ณด์—ฌ์ค„ ๋ฟ์ด๋‹ค.
6
+ ์—ฌ๊ธฐ์„œ ๋ฌผ์„ฑ์„ ํŒ์ •ํ•˜๋Š” ์ฒ™ํ•˜๋ฉด ์ฐธ๊ฐ€์ž๋Š” ๊ทผ๊ฑฐ ์—†๋Š” ๋ฐ˜๋ ค๋ฅผ ๋ฐ›๊ฒŒ ๋œ๋‹ค.
7
+
8
+ ์ˆœ์œ„ํ‘œ๋Š” TTL ๋‹น ํ•œ ๋ฒˆ๋งŒ ๋งŒ๋“ค์–ด ๋ฏธ๋ฆฌ ์••์ถ•ํ•ด ๋‘๊ณ , ์บ์‹œ๋Š” ๋‹จ์ผ ๊ฐฑ์‹ ์ž๋กœ ๋Œ๋ฆฐ๋‹ค.
9
+ ์ฒซ ํŽ˜์ด์ง€๋Š” async ๋กœ ์„œ๋น™ํ•˜๊ณ , ํ‘œ๋Š” 500 ํ–‰์”ฉ ๋‚˜๋ˆ  ๊ทธ๋ฆฐ๋‹ค.
10
+ """
11
+ import base64
12
+ import gzip
13
+ import hashlib
14
+ import hmac
15
+ import json
16
+ import os
17
+ import re
18
+ import secrets
19
+ import time
20
+ import urllib.parse
21
+ import urllib.request
22
+ import uuid
23
+
24
+ from fastapi import FastAPI, HTTPException, Request
25
+ from fastapi.middleware.gzip import GZipMiddleware
26
+ from fastapi.responses import (FileResponse, JSONResponse, RedirectResponse,
27
+ Response)
28
+ from pydantic import BaseModel
29
+
30
+ import gates
31
+ import seasons
32
+ import store
33
+
34
+ HERE = os.path.dirname(os.path.abspath(__file__))
35
+ DATA = os.path.join(HERE, "data")
36
+ # ํŽ˜์ด์ง€ ํด๋ง ์ฃผ๊ธฐ(20์ดˆ)๋ณด๋‹ค ํฌ๊ฒŒ ์žก๋Š”๋‹ค.
37
+ CACHE = store.Cached(ttl=int(os.environ.get("OMC_CACHE_TTL", "60")))
38
+ SALT = os.environ.get("OMC_SALT", "omc-season1")
39
+
40
+ OAUTH_ID = os.environ.get("OAUTH_CLIENT_ID", "")
41
+ OAUTH_SECRET = os.environ.get("OAUTH_CLIENT_SECRET", "")
42
+ OAUTH_ISS = os.environ.get("OPENID_PROVIDER_URL", "https://huggingface.co")
43
+ SPACE_HOST = os.environ.get("SPACE_HOST", "")
44
+ COOKIE = "omc_session"
45
+ IN_FRAME = bool(SPACE_HOST)
46
+ COOKIE_KW = ({"samesite": "none", "secure": True} if IN_FRAME
47
+ else {"samesite": "lax", "secure": False})
48
+ SESSION_KEY = os.environ.get("OMC_SESSION_KEY") or secrets.token_hex(16)
49
+
50
+ DAILY_CAP = int(os.environ.get("OMC_DAILY_CAP", "30"))
51
+ CAP_TZ_OFFSET = 9 * 3600 # ์ฐธ๊ฐ€์ž๊ฐ€ ๊ฐ€์ •ํ•  ํ•˜๋ฃจ ๊ฒฝ๊ณ„ (KST)
52
+
53
+ app = FastAPI(title="Open Materials Challenge")
54
+ app.add_middleware(GZipMiddleware, minimum_size=1024)
55
+
56
+
57
+ class Submission(BaseModel):
58
+ formula: str # ์กฐ์„ฑ. ์˜ˆ: Li3YCl6, LiZr2(PO4)3
59
+ display_name: str # ์ˆœ์œ„ํ‘œ์— ํ‘œ์‹œ๋  ์ด๋ฆ„
60
+ model_name: str = "" # ์–ด๋–ค ๋ชจ๋ธ์ด ์ œ์•ˆํ–ˆ๋Š”์ง€. ์ž์œ  ๊ธฐ์žฌ
61
+ rationale: str = "" # ์„ค๊ณ„ ๊ทผ๊ฑฐ (์„ ํƒ)
62
+ structure: str = "" # CIF (์„ ํƒ) - ๋‚ด๋ฉด ์ฑ„์ ์ด ๋น ๋ฅด๊ณ  ์ •ํ™•ํ•ด์ง„๋‹ค
63
+ season: int = 0
64
+ # ์กฐ์„ฑ์„ ๊ณต๊ฐœํ• ์ง€๋Š” ์ œ์ถœ์ž๊ฐ€ ์ •ํ•œ๋‹ค. ๋˜๋Œ๋ฆด ์ˆ˜ ์—†๋Š” ์ชฝ์ด ๊ธฐ๋ณธ๊ฐ’์ด์–ด์•ผ ํ•œ๋‹ค -
65
+ # ํ•œ ๋ฒˆ ๊ณต๊ฐœ๋œ ์กฐ์„ฑ์€ ๋˜๊ฐ์„ ์ˆ˜ ์—†๊ณ , ๊ณต๊ฐœ๋Š” ํŠนํ—ˆ์„ฑ์„ ๊นŽ๋Š”๋‹ค.
66
+ visibility: str = "private"
67
+
68
+
69
+ # ---------------------------------------------------------------- ์›์žฅ ์ ‘๊ทผ
70
+ def _submissions(s):
71
+ return CACHE.get("ids:%d" % s["number"],
72
+ lambda: store.listdir(seasons.path(s, "submissions")))
73
+
74
+
75
+ def _board(s):
76
+ return CACHE.get("board:%d" % s["number"],
77
+ lambda: store.read(seasons.path(s, "leaderboard.json"),
78
+ default={}) or {})
79
+
80
+
81
+ def public_id(key):
82
+ return "OMC-" + hashlib.sha256((SALT + key).encode()).hexdigest()[:10].upper()
83
+
84
+
85
+ def mask(rec):
86
+ """๋น„๊ณต๊ฐœ ์ œ์ถœ์—์„œ ์กฐ์„ฑ์„ ๊ฐ€๋ฆฐ๋‹ค. ์›์†Œ ์ข…๋ฅ˜๊นŒ์ง€๋Š” ๋ณด์—ฌ์ค€๋‹ค - ๊ทธ๊ฒƒ๋งŒ์œผ๋กœ๋Š”
87
+ ์กฐ์„ฑ์„ ๋˜๋Œ๋ฆด ์ˆ˜ ์—†๊ณ , ์–ด๋–ค ํ™”ํ•™๊ณ„๊ฐ€ ์ƒ์œ„์ธ์ง€๋Š” ๋Œ€ํšŒ์˜ ๊ณต๊ฐœ ์ง€์‹์ด์–ด์•ผ ํ•œ๋‹ค.
88
+
89
+ **์—ฌ๊ธฐ์„œ๋Š” ํ‘œ์‹œ์šฉ ๋ฌธ์ž์—ด๋งŒ ๋ฐ”๊พผ๋‹ค.** ์‹ค์ œ ์กฐ์„ฑ์€ ์•„๋ž˜ ๊ตฌ์—ญ ๊ณ„์‚ฐ์— ํ•„์š”ํ•˜๋ฏ€๋กœ
90
+ ํŒŒ์ดํ”„๋ผ์ธ ์•ˆ์—์„œ๋Š” ์‚ด๋ ค ๋‘๊ณ , ์‘๋‹ต์œผ๋กœ ๋‚˜๊ฐ€๊ธฐ ์ง์ „์— strip_masked() ๋กœ ๊ฑท์–ด๋‚ธ๋‹ค.
91
+ ํ•œ์ชฝ๋งŒ ๊ฐ€๋ฆฌ๊ณ  ๋‹ค๋ฅธ ์ชฝ์„ ๋‚จ๊ธฐ๋ฉด ๊ฐ€๋ฆฐ ์˜๋ฏธ๊ฐ€ ์—†๋‹ค.
92
+ """
93
+ out = dict(rec)
94
+ if rec.get("visibility") != "public":
95
+ comp = rec.get("composition") or {}
96
+ out["formula"] = "ยท".join(sorted(comp)) if comp else "๋น„๊ณต๊ฐœ"
97
+ out["masked"] = True
98
+ out.pop("structure", None)
99
+ return out
100
+
101
+
102
+ # ๋น„๊ณต๊ฐœ ํ•ญ๋ชฉ์—์„œ ์‘๋‹ต ์ง์ „์— ์ง€์›Œ์•ผ ํ•˜๋Š” ๊ฒƒ๋“ค.
103
+ # composition ์ •ํ™•ํ•œ ์กฐ์„ฑ๋น„ ๊ทธ ์ž์ฒด
104
+ # measured ์ƒ์„ฑ์—๋„ˆ์ง€ ๋“ฑ ๊ณ„์‚ฐ๊ฐ’. ์›์†Œ ์ง‘ํ•ฉ๊ณผ ํ•จ๊ป˜ ๋‘๋ฉด ํ›„๋ณด ์กฐ์„ฑ์„ ํ›‘์–ด
105
+ # ๋งž์ถฐ๋ณผ ์ˆ˜ ์žˆ์–ด ์‚ฌ์‹ค์ƒ ์กฐ์„ฑ์„ ์•Œ๋ ค์ฃผ๋Š” ๊ฒƒ๊ณผ ๊ฐ™๋‹ค
106
+ # n_atoms ํ™”ํ•™์‹ ๋‹จ์œ„ ์›์ž ์ˆ˜. ์กฐ์„ฑ ํ›„๋ณด๋ฅผ ํฌ๊ฒŒ ์ขํžŒ๋‹ค
107
+ _MASK_DROP = ("composition", "measured", "n_atoms", "structure", "rationale")
108
+
109
+
110
+ def strip_masked(entries):
111
+ """๋งˆ์Šคํ‚น๋œ ํ•ญ๋ชฉ์—์„œ ์กฐ์„ฑ์„ ๋˜๋Œ๋ฆด ์ˆ˜ ์žˆ๋Š” ํ•„๋“œ๋ฅผ ์ „๋ถ€ ์—†์•ค๋‹ค."""
112
+ for e in entries:
113
+ if e.get("masked"):
114
+ for k in _MASK_DROP:
115
+ e.pop(k, None)
116
+ return entries
117
+
118
+
119
+ # ---------------------------------------------------------------- ์„ธ์…˜
120
+ def _sign(payload):
121
+ raw = base64.urlsafe_b64encode(json.dumps(payload).encode()).decode().rstrip("=")
122
+ sig = hmac.new(SESSION_KEY.encode(), raw.encode(), hashlib.sha256).hexdigest()[:32]
123
+ return raw + "." + sig
124
+
125
+
126
+ def _unsign(token):
127
+ try:
128
+ raw, sig = (token or "").rsplit(".", 1)
129
+ except ValueError:
130
+ return None
131
+ good = hmac.new(SESSION_KEY.encode(), raw.encode(), hashlib.sha256).hexdigest()[:32]
132
+ if not hmac.compare_digest(sig, good):
133
+ return None
134
+ pad = "=" * (-len(raw) % 4)
135
+ try:
136
+ return json.loads(base64.urlsafe_b64decode(raw + pad))
137
+ except Exception:
138
+ return None
139
+
140
+
141
+ def current_user(request: Request):
142
+ return _unsign(request.cookies.get(COOKIE))
143
+
144
+
145
+ def _redirect_uri(request: Request):
146
+ if SPACE_HOST:
147
+ return "https://%s/auth/callback" % SPACE_HOST
148
+ return str(request.base_url).rstrip("/") + "/auth/callback"
149
+
150
+
151
+ # ---------------------------------------------------------------- ํ™”๋ฉด
152
+ @app.get("/")
153
+ async def index():
154
+ # ์ •์  ํŽ˜์ด์ง€๋Š” ์š”์ฒญ ์Šค๋ ˆ๋“œํ’€์„ ํƒ€์ง€ ์•Š๊ฒŒ async ๋กœ ๋‘”๋‹ค.
155
+ return FileResponse(os.path.join(HERE, "index.html"))
156
+
157
+
158
+ @app.get("/api/me")
159
+ def me(request: Request):
160
+ u = current_user(request)
161
+ return {"signed_in": bool(u), "user": u,
162
+ "oauth_configured": bool(OAUTH_ID and OAUTH_SECRET)}
163
+
164
+
165
+ @app.get("/login")
166
+ def login(request: Request):
167
+ if not (OAUTH_ID and OAUTH_SECRET):
168
+ raise HTTPException(503, "OAuth ๊ฐ€ ์„ค์ •๋˜์ง€ ์•Š์•˜์Šต๋‹ˆ๋‹ค.")
169
+ state = secrets.token_urlsafe(16)
170
+ q = urllib.parse.urlencode({
171
+ "client_id": OAUTH_ID, "redirect_uri": _redirect_uri(request),
172
+ "response_type": "code", "scope": "openid profile", "state": state})
173
+ r = RedirectResponse("%s/oauth/authorize?%s" % (OAUTH_ISS, q))
174
+ r.set_cookie("omc_state", state, max_age=600, httponly=True, **COOKIE_KW)
175
+ return r
176
+
177
+
178
+ @app.get("/auth/callback")
179
+ def callback(request: Request, code: str = "", state: str = ""):
180
+ if not code or state != request.cookies.get("omc_state"):
181
+ return RedirectResponse("/?auth=failed")
182
+ body = urllib.parse.urlencode({
183
+ "client_id": OAUTH_ID, "client_secret": OAUTH_SECRET,
184
+ "grant_type": "authorization_code", "code": code,
185
+ "redirect_uri": _redirect_uri(request)}).encode()
186
+ try:
187
+ req = urllib.request.Request("%s/oauth/token" % OAUTH_ISS, data=body)
188
+ with urllib.request.urlopen(req, timeout=30) as x:
189
+ tok = json.loads(x.read())
190
+ req = urllib.request.Request(
191
+ "%s/oauth/userinfo" % OAUTH_ISS,
192
+ headers={"Authorization": "Bearer " + tok["access_token"]})
193
+ with urllib.request.urlopen(req, timeout=30) as x:
194
+ info = json.loads(x.read())
195
+ except Exception:
196
+ return RedirectResponse("/?auth=failed")
197
+ u = {"sub": info.get("sub"), "name": info.get("preferred_username") or info.get("name"),
198
+ "picture": info.get("picture")}
199
+ r = RedirectResponse("/?auth=ok")
200
+ r.set_cookie(COOKIE, _sign(u), max_age=30 * 86400, httponly=True, **COOKIE_KW)
201
+ r.delete_cookie("omc_state")
202
+ return r
203
+
204
+
205
+ @app.get("/logout")
206
+ def logout():
207
+ r = RedirectResponse("/")
208
+ r.delete_cookie(COOKIE)
209
+ return r
210
+
211
+
212
+ @app.get("/api/seasons")
213
+ def season_list():
214
+ return {"seasons": seasons.all_public()}
215
+
216
+
217
+ @app.get("/api/season")
218
+ def season(request: Request):
219
+ return seasons.public(seasons.get(request.query_params.get("season")))
220
+
221
+
222
+ # ---------------------------------------------------------------- ์ œ์ถœ
223
+ @app.post("/api/submit")
224
+ def submit(s: Submission, request: Request):
225
+ season = seasons.get(s.season or None)
226
+ if not season.get("open"):
227
+ raise HTTPException(400, "์ด ์‹œ์ฆŒ์€ ์ œ์ถœ์„ ๋ฐ›์ง€ ์•Š์Šต๋‹ˆ๋‹ค.")
228
+ user = current_user(request)
229
+ if (OAUTH_ID and OAUTH_SECRET) and not user:
230
+ raise HTTPException(401, "์ œ์ถœํ•˜๋ ค๋ฉด Hugging Face ๋กœ๊ทธ์ธ์ด ํ•„์š”ํ•ฉ๋‹ˆ๋‹ค.")
231
+
232
+ g = seasons.gate(season)
233
+ ok, why, info = gates.check(s.formula, cif=s.structure or None,
234
+ require_elements=g.get("require_elements", ("Li",)),
235
+ max_atoms=g.get("max_atoms", 60))
236
+ if not ok:
237
+ raise HTTPException(400, why)
238
+
239
+ comp = info["composition"]
240
+ # ์—ด์‡ ๋Š” **๊ธฐ์•ฝ ์กฐ์„ฑ**์œผ๋กœ ๋งŒ๋“ ๋‹ค. Li3YCl6 ์™€ Li9Y3Cl18 ์€ ๊ฐ™์€ ๋ฌผ์งˆ์ด๋‹ค.
241
+ # ์›๋ณธ ์กฐ์„ฑ์œผ๋กœ ๋งŒ๋“ค๋ฉด ๋ฐฐ์ˆ˜๋งŒ ๋ฐ”๊ฟ” ๊ฐ™์€ ๋ฌผ์งˆ์„ ๋ช‡ ๋ฒˆ์ด๊ณ  ์˜ฌ๋ฆด ์ˆ˜ ์žˆ๋‹ค.
242
+ red = info.get("reduced") or comp
243
+ key = "-".join("%s%g" % (k, red[k]) for k in sorted(red))
244
+ cid = public_id(key)
245
+
246
+ ids = _submissions(season)
247
+ if cid in ids:
248
+ raise HTTPException(409, "์ด๋ฏธ ์ œ์ถœ๋œ ์กฐ์„ฑ์ž…๋‹ˆ๋‹ค (%s)." % cid)
249
+
250
+ if user:
251
+ today = int((time.time() + CAP_TZ_OFFSET) // 86400)
252
+ mine = CACHE.get("cap:%s:%d:%d" % (user.get("sub"), season["number"], today),
253
+ lambda: 0)
254
+ if mine >= DAILY_CAP:
255
+ raise HTTPException(429, "ํ•˜๋ฃจ ์ œ์ถœ ์ƒํ•œ(%d๊ฑด)์— ๋„๋‹ฌํ–ˆ์Šต๋‹ˆ๋‹ค." % DAILY_CAP)
256
+
257
+ rec = {"candidate_id": cid, "formula": s.formula.strip(),
258
+ "composition": comp, "n_atoms": info["n_atoms"],
259
+ "display_name": (s.display_name or "").strip()[:60],
260
+ "model_name": (s.model_name or "").strip()[:80],
261
+ "rationale": (s.rationale or "").strip()[:1000],
262
+ "visibility": "public" if s.visibility == "public" else "private",
263
+ "has_structure": bool(s.structure),
264
+ "hf_user": (user or {}).get("name", ""),
265
+ "hf_sub": (user or {}).get("sub", ""),
266
+ "season": season["number"],
267
+ "submitted_at": int(time.time()),
268
+ "uuid": uuid.uuid4().hex}
269
+ store.write(seasons.path(season, "submissions/%s.json" % cid), rec,
270
+ summary="submit %s" % cid)
271
+ if s.structure:
272
+ # ๊ตฌ์กฐ๋Š” ๋”ฐ๋กœ ๋‘”๋‹ค. ๋ชฉ๋ก์„ ํ›‘์„ ๋•Œ ๊ฐ™์ด ๋Œ๋ ค์˜ค๋ฉด ์ˆœ์œ„ํ‘œ๊ฐ€ ๋ฌด๊ฑฐ์›Œ์ง„๋‹ค.
273
+ store.write(seasons.path(season, "structures/%s.json" % cid),
274
+ {"candidate_id": cid, "cif": s.structure[:400_000]},
275
+ summary="structure %s" % cid)
276
+ CACHE.drop("ids:%d" % season["number"])
277
+
278
+ scored = set((_board(season).get("entries") or {}).keys())
279
+ ahead = len([i for i in _submissions(season) if i not in scored])
280
+ return {"accepted": True, "candidate_id": cid, "queue_position": ahead,
281
+ "note": "์ฑ„์ ์€ ๋ณ„๋„ ๊ณ„์‚ฐ ์ž‘์—…์œผ๋กœ ์ฒ˜๋ฆฌ๋˜๋ฉฐ ์™„๋ฃŒ๊นŒ์ง€ ์‹œ๊ฐ„์ด ๊ฑธ๋ฆฝ๋‹ˆ๋‹ค."}
282
+
283
+
284
+ # ---------------------------------------------------------------- ์ˆœ์œ„ํ‘œ
285
+ def _anchors(season):
286
+ p = os.path.join(DATA, season.get("anchors") or "")
287
+ if not os.path.exists(p):
288
+ return []
289
+ out = []
290
+ for a in json.load(open(p, encoding="utf-8")):
291
+ if not a.get("admitted"):
292
+ continue
293
+ out.append({"candidate_id": a["label"], "is_anchor": True,
294
+ "display_name": "๊ธฐ์ค€๋ฌผ์งˆ", "model_name": "",
295
+ "formula": a.get("formula", ""),
296
+ "total": a.get("total"), "total_max": a.get("total_max"),
297
+ "tier": a.get("tier", 1),
298
+ "axes": {k: v["points"] for k, v in (a.get("axes") or {}).items()},
299
+ "detail": {k: v["detail"] for k, v in (a.get("axes") or {}).items()},
300
+ "note": a.get("note", "")})
301
+ return out
302
+
303
+
304
+ # ์Œ์ด์˜จ ๊ณจ๊ฒฉ์ด ๋ฌผ์งˆ์˜ ์„ฑ์งˆ์„ ํฌ๊ฒŒ ๊ฐ€๋ฅธ๋‹ค. ๊ฐ™์€ ๊ณ„์—ด๋ผ๋ฆฌ ๊ฒฌ์ฃผ์–ด์•ผ ํ›„๋ณด์˜ ์œ„์น˜๊ฐ€ ์ฝํžŒ๋‹ค.
305
+ # ๋ถ€๋ฌธ์„ ๋‚˜๋ˆ„๋˜ **์ ์ˆ˜๋Š” ๊ทธ๋Œ€๋กœ ๋‘”๋‹ค** - ์–ด๋–ค ์ œ์ถœ๋„ ๋ฌดํšจ๊ฐ€ ๋˜์ง€ ์•Š๋Š”๋‹ค.
306
+ _ANION_ORDER = ["F", "Cl", "Br", "I", "S", "Se", "O", "N", "P", "H"]
307
+ DIVISIONS = {"F": "fluoride", "Cl": "halide", "Br": "halide", "I": "halide",
308
+ "S": "sulfide", "Se": "sulfide", "O": "oxide", "N": "nitride",
309
+ "P": "oxide", "H": "hydride"}
310
+
311
+
312
+ def division_of(comp):
313
+ """๊ณจ๊ฒฉ ์Œ์ด์˜จ์œผ๋กœ ๋ถ€๋ฌธ์„ ์ •ํ•œ๋‹ค.
314
+
315
+ ์„ž์—ฌ ์žˆ์œผ๋ฉด **๊ฐ€์žฅ ๋งŽ์€ ๊ฒƒ**์„ ๊ณ ๋ฅธ๋‹ค. Li6PS5Cl ์€ S ๊ฐ€ 5, Cl ์ด 1 ์ด๋ฏ€๋กœ ํ™ฉํ™”๋ฌผ์ด๊ณ ,
316
+ ์‹ค์ œ๋กœ ์•„์ง€๋กœ๋‹ค์ดํŠธ๋Š” ํ™ฉํ™”๋ฌผ๋กœ ๋ถ„๋ฅ˜๋œ๋‹ค. ๊ฐœ์ˆ˜๊ฐ€ ๊ฐ™์œผ๋ฉด ์œ„ ์ˆœ์„œ์—์„œ ์•ž์„  ๊ฒƒ์„ ์“ด๋‹ค.
317
+ """
318
+ if not comp:
319
+ return "other"
320
+ best, bestn = None, -1
321
+ for el in _ANION_ORDER:
322
+ n = comp.get(el, 0)
323
+ if n > bestn:
324
+ best, bestn = el, n
325
+ if bestn <= 0:
326
+ return "other"
327
+ return DIVISIONS.get(best, "other")
328
+
329
+
330
+ def _leaderboard_body(season):
331
+ rows = [mask(r) for r in (_board(season).get("entries") or {}).values()]
332
+ anchors = _anchors(season)
333
+ merged = rows + anchors
334
+ # ์ ์ˆ˜๊ฐ€ ์—†๋Š” ๊ฒƒ์€ ๋งจ ๋’ค๋กœ. ๊ทธ ์•ˆ์—์„œ๋Š” ๋‚ธ ์ˆœ์„œ๋ฅผ ์ง€ํ‚จ๋‹ค.
335
+ # ์ „ ์ถ•์ด ์ฑ„์›Œ์ง„ ๊ฒƒ๋ถ€ํ„ฐ, ๊ทธ ์•ˆ์—์„œ ์ด์  ์ˆœ. ์ถ•์ด ๋นˆ ๊ฒƒ์€ ๋’ค๋กœ - ์ด์ ์ด ์žˆ์–ด๋„
336
+ # ๋งŒ์ ์ด ๋‹ฌ๋ผ ๊ฐ™์€ ์ค„์— ์„ธ์šธ ์ˆ˜ ์—†๋‹ค.
337
+ merged.sort(key=lambda e: (e.get("tier", 1),
338
+ 1 if (e.get("total") is None or e.get("held_axes")) else 0,
339
+ -(e.get("total") if e.get("total") is not None else 0),
340
+ e.get("submitted_at") or 0))
341
+ n = 0
342
+ div_n = {}
343
+ for e in merged:
344
+ comp = e.get("composition") or {}
345
+ e["division"] = division_of(comp)
346
+ # ๋“ฑ์ˆ˜๋Š” **์ „ ์ถ•์ด ์ฑ„์›Œ์ง„ ๊ฒƒ**์—๋งŒ ์ค€๋‹ค. ์ถ•์ด ํ•˜๋‚˜๋ผ๋„ ๋น„๋ฉด ๋งŒ์ ์ด ๋‹ฌ๋ผ ์ด์ ์„
347
+ # ๋‚˜๋ž€ํžˆ ์„ธ์šธ ์ˆ˜ ์—†๋‹ค - ์ ์ˆ˜๋Š” ๋‚จ๊ธฐ๋˜ ์ˆœ์œ„์—๋Š” ๋„ฃ์ง€ ์•Š๋Š”๋‹ค.
348
+ unscored = e.get("total") is None or bool(e.get("held_axes"))
349
+ if e.get("is_anchor") or e.get("tier", 1) != 1 or unscored:
350
+ e["rank"] = None # ๊ธฐ์ค€๋ฌผ์งˆ์€ ์ฒ™๋„๋ฅผ ํ‘œ์‹œํ•  ๋ฟ ๋“ฑ์ˆ˜๋ฅผ ๊ฐ–์ง€ ์•Š๋Š”๋‹ค
351
+ e["div_rank"] = None
352
+ else:
353
+ n += 1
354
+ e["rank"] = n
355
+ # ๋ถ€๋ฌธ ์ˆœ์œ„๋Š” ์ „์ฒด ์ˆœ์œ„์™€ ๋”ฐ๋กœ ๋งค๊ธด๋‹ค. ์ ์ˆ˜๋Š” ์†๋Œ€์ง€ ์•Š๋Š”๋‹ค.
356
+ d = e["division"]
357
+ div_n[d] = div_n.get(d, 0) + 1
358
+ e["div_rank"] = div_n[d]
359
+ tally = {}
360
+ for e in merged:
361
+ if e.get("is_anchor"):
362
+ continue
363
+ tally[e["division"]] = tally.get(e["division"], 0) + 1
364
+ n_held = sum(1 for e in merged
365
+ if not e.get("is_anchor")
366
+ and (e.get("total") is None or e.get("held_axes")))
367
+ strip_masked(merged) # ๊ตฌ์—ญ๊นŒ์ง€ ๋งค๊ธด ๋’ค, ์‘๋‹ต์œผ๋กœ ๋‚˜๊ฐ€๊ธฐ ์ง์ „์— ๊ฑท์–ด๋‚ธ๋‹ค
368
+ return {"season": seasons.public(season), "entries": merged,
369
+ "divisions": tally,
370
+ "counts": {"scored": len(rows) - n_held, "held": n_held,
371
+ "anchors": len(anchors)}}
372
+
373
+
374
+ def _leaderboard_bytes(season):
375
+ raw = json.dumps(_leaderboard_body(season), ensure_ascii=False).encode("utf-8")
376
+ return raw, gzip.compress(raw, 6)
377
+
378
+
379
+ @app.get("/api/leaderboard")
380
+ def leaderboard(request: Request):
381
+ """์‘๋‹ต์€ ์‹œ์ฆŒ์—๋งŒ ์˜์กดํ•˜๋ฏ€๋กœ TTL ๋‹น ํ•œ ๋ฒˆ๋งŒ ๋งŒ๋“ค๊ณ  ํ•œ ๋ฒˆ๋งŒ ์••์ถ•ํ•œ๋‹ค."""
382
+ season = seasons.get(request.query_params.get("season"))
383
+ raw, gz = CACHE.get("lbresp:%d" % season["number"],
384
+ lambda: _leaderboard_bytes(season))
385
+ if "gzip" in (request.headers.get("accept-encoding") or ""):
386
+ return Response(content=gz, media_type="application/json",
387
+ headers={"Content-Encoding": "gzip", "Vary": "Accept-Encoding"})
388
+ return Response(content=raw, media_type="application/json",
389
+ headers={"Vary": "Accept-Encoding"})
390
+
391
+
392
+ _FILLER = {"ai", "model", "llm", "the", "v", "ver", "version", "latest", "preview",
393
+ "chat", "instruct", "it", "api", "official", "new"}
394
+
395
+
396
+ def canon_model(name):
397
+ """ํ‘œ๊ธฐ๊ฐ€ ๊ฐˆ๋ฆฐ ๊ฐ™์€ ๋ชจ๋ธ์„ ํ•˜๋‚˜๋กœ ๋ฌถ๋Š”๋‹ค.
398
+
399
+ 'Anthropic Claude Opus 5' ์™€ 'claude-opus-5' ๋Š” ๊ฐ™์€ ๊ฒƒ์ด๊ณ , ๋”ฐ๋กœ ์„ธ๋ฉด ๋‘˜ ๋‹ค
400
+ ์‹ค์ œ๋ณด๋‹ค ์ ๊ฒŒ ์“ฐ์ธ ๊ฒƒ์ฒ˜๋Ÿผ ๋ณด์ธ๋‹ค. ๋‹ค๋งŒ ๋ณ€์ข…์„ ๊ตฌ๋ถ„ํ•˜๋Š” ๋‚ฑ๋ง(Pro/Flash ๋“ฑ)์€
401
+ ์ง€์šฐ์ง€ ์•Š๋Š”๋‹ค - ๊ทธ๊ฒƒ๊นŒ์ง€ ๋ญ‰์น˜๋ฉด ๋‹ค๋ฅธ ๋ชจ๋ธ์ด ํ•œ ์นธ์— ๋“ค์–ด๊ฐ„๋‹ค.
402
+ """
403
+ s = re.sub(r"[^a-z0-9]+", " ", (name or "").lower()).strip()
404
+ toks = [t for t in s.split() if t and t not in _FILLER]
405
+ return " ".join(toks) or (name or "").strip().lower()
406
+
407
+
408
+ @app.get("/api/models")
409
+ def models(request: Request):
410
+ """๋ชจ๋ธ๋ณ„ ์„ฑ์ : ์–ด๋А ๋ชจ๋ธ์ด ๊ฐ€์žฅ ์ข‹์€ ํ›„๋ณด๋ฅผ ๋ƒˆ๋Š”๊ฐ€, ๊ทธ๋ฆฌ๊ณ  ์–ผ๋งˆ๋‚˜ ์“ฐ์ด๋Š”๊ฐ€.
411
+
412
+ ๋‘ ์ˆ˜๋ฅผ **๋”ฐ๋กœ** ๋‚ธ๋‹ค. ๋งŽ์ด ์“ฐ์ธ๋‹ค๊ณ  ์ž˜ํ•˜๋Š” ๊ฒƒ์ด ์•„๋‹ˆ๋‹ค - ๋‹ค๋“ค ์ง‘๋Š” ๋ชจ๋ธ์€ ์ ์ˆ˜์™€
413
+ ๋ฌด๊ด€ํ•˜๊ฒŒ ์ œ์ถœ ์ˆ˜๊ฐ€ ์Œ“์ด๊ณ , ์„ธ ๋ฒˆ ์“ฐ์ธ ๋ชจ๋ธ์ด 1์œ„ ํ›„๋ณด๋ฅผ ๊ฐ–๊ณ  ์žˆ์„ ์ˆ˜ ์žˆ๋‹ค.
414
+ ํ‰๊ท ๋งŒ ๋‚ด๋ฉด ๋‘˜ ๋‹ค ๊ฐ€๋ ค์ง„๋‹ค. ๊ทธ๋ž˜์„œ ์ตœ๊ณ ยทํ‰๊ท ยท๊ฑด์ˆ˜๋ฅผ ๋‚˜๋ž€ํžˆ ๋‘”๋‹ค.
415
+ ๊ธฐ์ค€๋ฌผ์งˆ์€ ์ œ์™ธํ•œ๋‹ค - ์ฐธ๊ฐ€์ž์˜ ๋ชจ๋ธ์ด ๋งŒ๋“  ๊ฒƒ์ด ์•„๋‹ˆ๋‹ค.
416
+ """
417
+ season = seasons.get(request.query_params.get("season"))
418
+ agg = {}
419
+ for r in (_board(season).get("entries") or {}).values():
420
+ if r.get("total") is None:
421
+ continue
422
+ name = (r.get("model_name") or "").strip() or "๋ฏธ๊ธฐ์žฌ / unspecified"
423
+ key = canon_model(name)
424
+ a = agg.setdefault(key, {"n": 0, "best": None, "sum": 0.0,
425
+ "top_candidate": None, "spellings": {}})
426
+ a["spellings"][name] = a["spellings"].get(name, 0) + 1
427
+ a["n"] += 1
428
+ a["sum"] += r["total"]
429
+ if a["best"] is None or r["total"] > a["best"]:
430
+ a["best"] = r["total"]
431
+ a["top_candidate"] = r.get("candidate_id")
432
+ out = []
433
+ for k, a in agg.items():
434
+ # ์ฐธ๊ฐ€์ž๋“ค์ด ์‹ค์ œ๋กœ ๊ฐ€์žฅ ๋งŽ์ด ์“ด ํ‘œ๊ธฐ๋ฅผ ์ด๋ฆ„์œผ๋กœ ์‚ผ๋Š”๋‹ค
435
+ label = max(a["spellings"].items(), key=lambda kv: kv[1])[0]
436
+ out.append({"model": label, "n": a["n"], "best": round(a["best"], 2),
437
+ "mean": round(a["sum"] / a["n"], 2),
438
+ "top_candidate": a["top_candidate"]})
439
+ out.sort(key=lambda x: (-x["best"], -x["n"]))
440
+ return {"models": out, "counts": {"models": len(out),
441
+ "entries": sum(m["n"] for m in out)}}
442
+
443
+
444
+ @app.get("/api/queue")
445
+ def queue(request: Request):
446
+ season = seasons.get(request.query_params.get("season"))
447
+ ids = _submissions(season)
448
+ scored = set((_board(season).get("entries") or {}).keys())
449
+ return {"queued": len([i for i in ids if i not in scored]),
450
+ "scored": len(scored), "total": len(ids)}
451
+
452
+
453
+ @app.get("/api/health")
454
+ def health():
455
+ return {"ok": True, "seasons": [s["number"] for s in seasons.SEASONS]}