SeaWolf-AI commited on
Commit
3825ff2
·
verified ·
1 Parent(s): 440fa27

Open Materials Challenge season 1 - solid-state electrolyte

Browse files
Files changed (11) hide show
  1. Dockerfile +12 -0
  2. README.md +7 -4
  3. app.py +327 -0
  4. data/anchor_scores.json +226 -0
  5. data/reference_dist.json +1 -0
  6. data/weights.json +8 -0
  7. gates.py +90 -0
  8. index.html +424 -0
  9. requirements.txt +4 -0
  10. seasons.py +75 -0
  11. store.py +187 -0
Dockerfile ADDED
@@ -0,0 +1,12 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # HF Spaces runs the container as a non-root user; write only under paths we own.
2
+ FROM python:3.11-slim
3
+ RUN useradd -m -u 1000 user
4
+ WORKDIR /app
5
+ COPY requirements.txt .
6
+ RUN pip install --no-cache-dir -r requirements.txt
7
+ COPY . .
8
+ RUN mkdir -p /app/data && chown -R user:user /app
9
+ USER user
10
+ ENV OMC_LEDGER=/app/data/ledger.jsonl
11
+ EXPOSE 7860
12
+ CMD ["uvicorn", "app:app", "--host", "0.0.0.0", "--port", "7860"]
README.md CHANGED
@@ -1,10 +1,13 @@
1
  ---
2
  title: Open Materials Challenge
3
- emoji: 📉
4
- colorFrom: gray
5
- colorTo: red
6
  sdk: docker
 
7
  pinned: false
8
  ---
9
 
10
- Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
 
 
 
1
  ---
2
  title: Open Materials Challenge
3
+ emoji: 🔋
4
+ colorFrom: green
5
+ colorTo: gray
6
  sdk: docker
7
+ app_port: 7860
8
  pinned: false
9
  ---
10
 
11
+ # Open Materials Challenge
12
+
13
+ 시즌 1 — 전고체 배터리 전해질. **비공개 준비 중.**
app.py ADDED
@@ -0,0 +1,327 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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 secrets
18
+ import time
19
+ import urllib.parse
20
+ import urllib.request
21
+ import uuid
22
+
23
+ from fastapi import FastAPI, HTTPException, Request
24
+ from fastapi.middleware.gzip import GZipMiddleware
25
+ from fastapi.responses import (FileResponse, JSONResponse, RedirectResponse,
26
+ Response)
27
+ from pydantic import BaseModel
28
+
29
+ import gates
30
+ import seasons
31
+ import store
32
+
33
+ HERE = os.path.dirname(os.path.abspath(__file__))
34
+ DATA = os.path.join(HERE, "data")
35
+ # 페이지 폴링 주기(20초)보다 크게 잡는다.
36
+ CACHE = store.Cached(ttl=int(os.environ.get("OMC_CACHE_TTL", "60")))
37
+ SALT = os.environ.get("OMC_SALT", "omc-season1")
38
+
39
+ OAUTH_ID = os.environ.get("OAUTH_CLIENT_ID", "")
40
+ OAUTH_SECRET = os.environ.get("OAUTH_CLIENT_SECRET", "")
41
+ OAUTH_ISS = os.environ.get("OPENID_PROVIDER_URL", "https://huggingface.co")
42
+ SPACE_HOST = os.environ.get("SPACE_HOST", "")
43
+ COOKIE = "omc_session"
44
+ IN_FRAME = bool(SPACE_HOST)
45
+ COOKIE_KW = ({"samesite": "none", "secure": True} if IN_FRAME
46
+ else {"samesite": "lax", "secure": False})
47
+ SESSION_KEY = os.environ.get("OMC_SESSION_KEY") or secrets.token_hex(16)
48
+
49
+ DAILY_CAP = int(os.environ.get("OMC_DAILY_CAP", "30"))
50
+ CAP_TZ_OFFSET = 9 * 3600 # 참가자가 가정할 하루 경계 (KST)
51
+
52
+ app = FastAPI(title="Open Materials Challenge")
53
+ app.add_middleware(GZipMiddleware, minimum_size=1024)
54
+
55
+
56
+ class Submission(BaseModel):
57
+ formula: str # 조성. 예: Li3YCl6, LiZr2(PO4)3
58
+ display_name: str # 순위표에 표시될 이름
59
+ model_name: str = "" # 어떤 모델이 제안했는지. 자유 기재
60
+ rationale: str = "" # 설계 근거 (선택)
61
+ structure: str = "" # CIF (선택) - 내면 채점이 빠르고 정확해진다
62
+ season: int = 0
63
+ # 조성을 공개할지는 제출자가 정한다. 되돌릴 수 없는 쪽이 기본값이어야 한다 -
64
+ # 한 번 공개된 조성은 되감을 수 없고, 공개는 특허성을 깎는다.
65
+ visibility: str = "private"
66
+
67
+
68
+ # ---------------------------------------------------------------- 원장 접근
69
+ def _submissions(s):
70
+ return CACHE.get("ids:%d" % s["number"],
71
+ lambda: store.listdir(seasons.path(s, "submissions")))
72
+
73
+
74
+ def _board(s):
75
+ return CACHE.get("board:%d" % s["number"],
76
+ lambda: store.read(seasons.path(s, "leaderboard.json"),
77
+ default={}) or {})
78
+
79
+
80
+ def public_id(key):
81
+ return "OMC-" + hashlib.sha256((SALT + key).encode()).hexdigest()[:10].upper()
82
+
83
+
84
+ def mask(rec):
85
+ """비공개 제출에서 조성을 가린다. 원소 종류까지는 보여준다 - 그것만으로는
86
+ 조성을 되돌릴 수 없고, 어떤 화학계가 상위인지는 대회의 공개 지식이어야 한다."""
87
+ out = dict(rec)
88
+ if rec.get("visibility") != "public":
89
+ comp = rec.get("composition") or {}
90
+ out["formula"] = "·".join(sorted(comp)) if comp else "비공개"
91
+ out["masked"] = True
92
+ out.pop("structure", None)
93
+ return out
94
+
95
+
96
+ # ---------------------------------------------------------------- 세션
97
+ def _sign(payload):
98
+ raw = base64.urlsafe_b64encode(json.dumps(payload).encode()).decode().rstrip("=")
99
+ sig = hmac.new(SESSION_KEY.encode(), raw.encode(), hashlib.sha256).hexdigest()[:32]
100
+ return raw + "." + sig
101
+
102
+
103
+ def _unsign(token):
104
+ try:
105
+ raw, sig = (token or "").rsplit(".", 1)
106
+ except ValueError:
107
+ return None
108
+ good = hmac.new(SESSION_KEY.encode(), raw.encode(), hashlib.sha256).hexdigest()[:32]
109
+ if not hmac.compare_digest(sig, good):
110
+ return None
111
+ pad = "=" * (-len(raw) % 4)
112
+ try:
113
+ return json.loads(base64.urlsafe_b64decode(raw + pad))
114
+ except Exception:
115
+ return None
116
+
117
+
118
+ def current_user(request: Request):
119
+ return _unsign(request.cookies.get(COOKIE))
120
+
121
+
122
+ def _redirect_uri(request: Request):
123
+ if SPACE_HOST:
124
+ return "https://%s/auth/callback" % SPACE_HOST
125
+ return str(request.base_url).rstrip("/") + "/auth/callback"
126
+
127
+
128
+ # ---------------------------------------------------------------- 화면
129
+ @app.get("/")
130
+ async def index():
131
+ # async 인 이유: sync 라우트는 요청 스���드풀에서 돈다. 그 풀이 Hub I/O 로 막히면
132
+ # 정적 페이지까지 뒤에 줄을 서서 사이트가 죽은 것처럼 보인다.
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
+ def _leaderboard_body(season):
283
+ rows = [mask(r) for r in (_board(season).get("entries") or {}).values()]
284
+ anchors = _anchors(season)
285
+ merged = rows + anchors
286
+ merged.sort(key=lambda e: (e.get("tier", 1), -(e.get("total") if e.get("total") is not None else -1)))
287
+ n = 0
288
+ for e in merged:
289
+ if e.get("is_anchor") or e.get("tier", 1) != 1:
290
+ e["rank"] = None # 기준물질은 척도를 표시할 뿐 등수를 갖지 않는다
291
+ else:
292
+ n += 1
293
+ e["rank"] = n
294
+ return {"season": seasons.public(season), "entries": merged,
295
+ "counts": {"scored": len(rows), "anchors": len(anchors)}}
296
+
297
+
298
+ def _leaderboard_bytes(season):
299
+ raw = json.dumps(_leaderboard_body(season), ensure_ascii=False).encode("utf-8")
300
+ return raw, gzip.compress(raw, 6)
301
+
302
+
303
+ @app.get("/api/leaderboard")
304
+ def leaderboard(request: Request):
305
+ """응답은 시즌에만 의존하므로 TTL 당 한 번만 만들고 한 번만 압축한다."""
306
+ season = seasons.get(request.query_params.get("season"))
307
+ raw, gz = CACHE.get("lbresp:%d" % season["number"],
308
+ lambda: _leaderboard_bytes(season))
309
+ if "gzip" in (request.headers.get("accept-encoding") or ""):
310
+ return Response(content=gz, media_type="application/json",
311
+ headers={"Content-Encoding": "gzip", "Vary": "Accept-Encoding"})
312
+ return Response(content=raw, media_type="application/json",
313
+ headers={"Vary": "Accept-Encoding"})
314
+
315
+
316
+ @app.get("/api/queue")
317
+ def queue(request: Request):
318
+ season = seasons.get(request.query_params.get("season"))
319
+ ids = _submissions(season)
320
+ scored = set((_board(season).get("entries") or {}).keys())
321
+ return {"queued": len([i for i in ids if i not in scored]),
322
+ "scored": len(scored), "total": len(ids)}
323
+
324
+
325
+ @app.get("/api/health")
326
+ def health():
327
+ return {"ok": True, "seasons": [s["number"] for s in seasons.SEASONS]}
data/anchor_scores.json ADDED
@@ -0,0 +1,226 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ [
2
+ {
3
+ "label": "LGPS",
4
+ "formula": "Li10GeP2S12",
5
+ "admitted": true,
6
+ "tier": 1,
7
+ "axes": {
8
+ "oxidation": {
9
+ "points": 2.73,
10
+ "detail": "산화한계 2.30 V · 상위 92%"
11
+ },
12
+ "li_stability": {
13
+ "points": 13.81,
14
+ "detail": "Li분해 +1.3035 eV/atom · 상위 54%"
15
+ },
16
+ "migration": {
17
+ "points": null,
18
+ "detail": "미평가(G2 대기)"
19
+ },
20
+ "novelty": {
21
+ "points": null,
22
+ "detail": "용도 신규성 미판정"
23
+ }
24
+ },
25
+ "total": 16.54,
26
+ "total_max": 65,
27
+ "note": "황화물. 이온전도도 12 mS/cm 로 최고 수준이나 산화창이 좁고 리튬금속과 반응한다.",
28
+ "measured": {
29
+ "ox": 2.2974683544303796,
30
+ "li": 1.3035463196661523,
31
+ "ea": null
32
+ }
33
+ },
34
+ {
35
+ "label": "아지로다이트",
36
+ "formula": "Li6PS5Cl",
37
+ "admitted": true,
38
+ "tier": 1,
39
+ "axes": {
40
+ "oxidation": {
41
+ "points": 2.04,
42
+ "detail": "산화한계 2.13 V · 상위 94%"
43
+ },
44
+ "li_stability": {
45
+ "points": 19.57,
46
+ "detail": "Li분해 +0.9958 eV/atom · 상위 35%"
47
+ },
48
+ "migration": {
49
+ "points": 14.05,
50
+ "detail": "이동장벽 0.293 eV · 상위 6%"
51
+ },
52
+ "novelty": {
53
+ "points": null,
54
+ "detail": "용도 신규성 미판정"
55
+ }
56
+ },
57
+ "total": 35.66,
58
+ "total_max": 80,
59
+ "note": "황화물. 상용화가 가장 앞선 계열.",
60
+ "measured": {
61
+ "ox": 2.132911392405063,
62
+ "li": 0.9958406943041193,
63
+ "ea": 0.293
64
+ }
65
+ },
66
+ {
67
+ "label": "LPS",
68
+ "formula": "Li3PS4",
69
+ "admitted": true,
70
+ "tier": 1,
71
+ "axes": {
72
+ "oxidation": {
73
+ "points": 7.15,
74
+ "detail": "산화한계 2.71 V · 상위 80%"
75
+ },
76
+ "li_stability": {
77
+ "points": 10.54,
78
+ "detail": "Li분해 +1.4918 eV/atom · 상위 65%"
79
+ },
80
+ "migration": {
81
+ "points": 12.21,
82
+ "detail": "이동장벽 0.420 eV · 상위 19%"
83
+ },
84
+ "novelty": {
85
+ "points": null,
86
+ "detail": "용도 신규성 미판정"
87
+ }
88
+ },
89
+ "total": 29.9,
90
+ "total_max": 80,
91
+ "note": "황화물 기본형.",
92
+ "measured": {
93
+ "ox": 2.7088607594936707,
94
+ "li": 1.4918371727514401,
95
+ "ea": 0.4199
96
+ }
97
+ },
98
+ {
99
+ "label": "LLZO",
100
+ "formula": "Li7La3Zr2O12",
101
+ "admitted": true,
102
+ "tier": 1,
103
+ "axes": {
104
+ "oxidation": {
105
+ "points": 11.89,
106
+ "detail": "산화한계 3.12 V · 상위 66%"
107
+ },
108
+ "li_stability": {
109
+ "points": 29.63,
110
+ "detail": "Li분해 +0.0221 eV/atom · 상위 1% · **불확실**(|값|<σ=0.467)"
111
+ },
112
+ "migration": {
113
+ "points": 11.23,
114
+ "detail": "이동장벽 0.459 eV · 상위 25%"
115
+ },
116
+ "novelty": {
117
+ "points": null,
118
+ "detail": "용도 신규성 미판정"
119
+ }
120
+ },
121
+ "total": 52.75,
122
+ "total_max": 80,
123
+ "note": "산화물 가넷. 리튬금속에 가장 안정한 축에 든다.",
124
+ "measured": {
125
+ "ox": 3.120253164556962,
126
+ "li": 0.022097652991613963,
127
+ "ea": 0.459
128
+ }
129
+ },
130
+ {
131
+ "label": "LIC",
132
+ "formula": "Li3InCl6",
133
+ "admitted": true,
134
+ "tier": 1,
135
+ "axes": {
136
+ "oxidation": {
137
+ "points": 28.74,
138
+ "detail": "산화한계 4.27 V · 상위 18%"
139
+ },
140
+ "li_stability": {
141
+ "points": 22.62,
142
+ "detail": "Li분해 +0.8328 eV/atom · 상위 25%"
143
+ },
144
+ "migration": {
145
+ "points": 11.95,
146
+ "detail": "이동장벽 0.430 eV · 상위 20%"
147
+ },
148
+ "novelty": {
149
+ "points": null,
150
+ "detail": "용도 신규성 미판정"
151
+ }
152
+ },
153
+ "total": 63.31,
154
+ "total_max": 80,
155
+ "note": "할라이드. 4V 급 산화 안정성.",
156
+ "measured": {
157
+ "ox": 4.272151898734177,
158
+ "li": 0.8327522978304368,
159
+ "ea": 0.4297
160
+ }
161
+ },
162
+ {
163
+ "label": "LZP",
164
+ "formula": "LiZr2(PO4)3",
165
+ "admitted": true,
166
+ "tier": 1,
167
+ "axes": {
168
+ "oxidation": {
169
+ "points": 31.36,
170
+ "detail": "산화한계 4.60 V · 상위 10%"
171
+ },
172
+ "li_stability": {
173
+ "points": 10.07,
174
+ "detail": "Li분해 +1.5212 eV/atom · 상위 66%"
175
+ },
176
+ "migration": {
177
+ "points": 14.83,
178
+ "detail": "이동장벽 0.185 eV · 상위 1%"
179
+ },
180
+ "novelty": {
181
+ "points": null,
182
+ "detail": "용도 신규성 미판정"
183
+ }
184
+ },
185
+ "total": 56.26,
186
+ "total_max": 80,
187
+ "note": "NASICON. 산화 안정성이 높다.",
188
+ "measured": {
189
+ "ox": 4.60126582278481,
190
+ "li": 1.521223317414708,
191
+ "ea": 0.1855
192
+ }
193
+ },
194
+ {
195
+ "label": "LATP 모체",
196
+ "formula": "LiTi2(PO4)3",
197
+ "admitted": true,
198
+ "tier": 1,
199
+ "axes": {
200
+ "oxidation": {
201
+ "points": 32.07,
202
+ "detail": "산화한계 4.68 V · 상위 8%"
203
+ },
204
+ "li_stability": {
205
+ "points": 7.25,
206
+ "detail": "Li분해 +1.6956 eV/atom · 상위 76%"
207
+ },
208
+ "migration": {
209
+ "points": 14.3,
210
+ "detail": "이동장벽 0.264 eV · 상위 5%"
211
+ },
212
+ "novelty": {
213
+ "points": null,
214
+ "detail": "용도 신규성 미판정"
215
+ }
216
+ },
217
+ "total": 53.62,
218
+ "total_max": 80,
219
+ "note": "NASICON. Ti(4+) 가 리튬금속에 환원되어 리튬금속 전지에는 그대로 못 쓴다.",
220
+ "measured": {
221
+ "ox": 4.6835443037974684,
222
+ "li": 1.6956100674601606,
223
+ "ea": 0.2637
224
+ }
225
+ }
226
+ ]
data/reference_dist.json ADDED
@@ -0,0 +1 @@
 
 
1
+ {"_meta": {"n_ox": 8749, "n_li": 8749, "n_ea": 1603, "frozen": "2026-08-21", "source": "Materials Project G1 공간 스크리닝", "note": "시즌 중 변경 금지. 바꾸면 이전 제출의 점수가 소급해 달라진다."}, "oxidation": [-0.5, 0.981, 1.2278, 1.6392, 1.8038, 1.9684, 2.1329, 2.2152, 2.2975, 2.2975, 2.2975, 2.2975, 2.3797, 2.3797, 2.3797, 2.462, 2.5443, 2.5443, 2.6266, 2.6266, 2.6266, 2.7089, 2.7089, 2.7911, 2.7911, 2.7911, 2.7911, 2.8734, 2.8734, 2.8734, 2.8734, 2.9557, 3.038, 3.038, 3.1203, 3.1203, 3.1203, 3.2025, 3.2025, 3.2025, 3.2025, 3.2848, 3.2848, 3.2848, 3.2848, 3.3671, 3.3671, 3.3671, 3.3671, 3.4494, 3.4494, 3.4494, 3.5316, 3.5316, 3.5316, 3.6139, 3.6139, 3.6962, 3.6962, 3.7785, 3.7785, 3.7785, 3.7785, 3.7785, 3.7785, 3.8608, 3.8608, 3.8608, 3.8608, 3.943, 3.943, 4.0253, 4.0253, 4.0253, 4.1076, 4.1076, 4.1076, 4.1076, 4.1076, 4.1899, 4.1899, 4.1899, 4.1899, 4.2722, 4.2722, 4.2722, 4.3544, 4.4367, 4.4367, 4.519, 4.6013, 4.6013, 4.6835, 4.7658, 4.8481, 4.9304, 5.1772, 5.8354, 6.0, 6.0, 6.0], "li_stability": [-1.3264, 0.0128, 0.0891, 0.1591, 0.2003, 0.2461, 0.2763, 0.3195, 0.3667, 0.4065, 0.4432, 0.4815, 0.5182, 0.5603, 0.5886, 0.6209, 0.6486, 0.6757, 0.6962, 0.7252, 0.7574, 0.7824, 0.7961, 0.8092, 0.8271, 0.8397, 0.8553, 0.8728, 0.8876, 0.9044, 0.9219, 0.9409, 0.9588, 0.9719, 0.9855, 1.0017, 1.0246, 1.0468, 1.0665, 1.086, 1.1111, 1.1292, 1.1524, 1.1672, 1.1857, 1.198, 1.2137, 1.2238, 1.2329, 1.244, 1.2545, 1.264, 1.2784, 1.2897, 1.3038, 1.323, 1.344, 1.3591, 1.3794, 1.3998, 1.4163, 1.4341, 1.4491, 1.4614, 1.4762, 1.4945, 1.5133, 1.531, 1.5529, 1.5739, 1.5926, 1.6131, 1.6309, 1.6495, 1.6697, 1.6822, 1.6993, 1.7188, 1.744, 1.7772, 1.8053, 1.8165, 1.8329, 1.8594, 1.8874, 1.9164, 1.9534, 1.9768, 2.0106, 2.0504, 2.0878, 2.1101, 2.166, 2.2003, 2.2512, 2.2814, 2.3346, 2.3905, 2.5073, 2.7434, 3.8359], "migration": [0.0781, 0.1758, 0.2051, 0.2344, 0.2441, 0.2637, 0.2832, 0.3027, 0.3125, 0.3223, 0.332, 0.3418, 0.3516, 0.3613, 0.3711, 0.3809, 0.3906, 0.4004, 0.4102, 0.4199, 0.4199, 0.4297, 0.4297, 0.4395, 0.4492, 0.4492, 0.459, 0.459, 0.4688, 0.4785, 0.4883, 0.498, 0.5078, 0.5078, 0.5176, 0.5273, 0.5371, 0.5371, 0.5469, 0.5566, 0.5664, 0.5664, 0.5762, 0.5859, 0.5945, 0.5957, 0.6055, 0.6244, 0.6348, 0.6348, 0.6445, 0.6543, 0.6641, 0.6836, 0.6836, 0.6934, 0.7031, 0.7129, 0.7227, 0.7324, 0.7422, 0.7422, 0.752, 0.7617, 0.7715, 0.7812, 0.791, 0.8008, 0.8203, 0.8398, 0.8496, 0.8691, 0.8789, 0.8984, 0.918, 0.9424, 0.9668, 0.9863, 1.0156, 1.0352, 1.0645, 1.084, 1.1035, 1.123, 1.1426, 1.1816, 1.2012, 1.2207, 1.2574, 1.2793, 1.3281, 1.3947, 1.4551, 1.543, 1.6699, 1.7676, 1.8449, 2.1967, 2.4203, 2.7822, 3.584]}
data/weights.json ADDED
@@ -0,0 +1,8 @@
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "weights": {
3
+ "oxidation": 35,
4
+ "li_stability": 30,
5
+ "novelty": 20,
6
+ "migration": 15
7
+ }
8
+ }
gates.py ADDED
@@ -0,0 +1,90 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # -*- coding: utf-8 -*-
2
+ """접수 단계 검사.
3
+
4
+ 여기서 보는 것은 **형식과 자격**뿐이다. 실제 채점(전기화학창·리튬 이동)은 워커가 한다.
5
+ 스페이스는 MP 데이터도 GPU 도 없으므로, 여기서 물성을 판정하는 척하면 안 된다.
6
+
7
+ 거절은 참가자가 **고칠 수 있는 것**에만 쓴다. 값을 얻지 못한 항목은 거절이 아니라 보류로 둔다.
8
+ """
9
+ import re
10
+
11
+ # 채점 대상 원소.
12
+ SUPPORTED = set("""
13
+ H Li Be B C N O F Na Mg Al Si P S Cl K Ca Sc Ti V Cr Mn Fe Co Ni Cu Zn
14
+ Ga Ge As Se Br Rb Sr Y Zr Nb Mo Tc Ru Rh Pd Ag Cd In Sn Sb Te I Cs Ba
15
+ La Ce Pr Nd Pm Sm Eu Gd Tb Dy Ho Er Tm Yb Lu Hf Ta W Re Os Ir Pt Au Hg
16
+ Tl Pb Bi Ac Th Pa U Np Pu
17
+ """.split())
18
+
19
+ _CIF_HINT = ("data_", "_cell_length_a", "_atom_site")
20
+
21
+
22
+ def parse_formula(text):
23
+ """화학식을 조성으로. **괄호를 반드시 처리한다.**
24
+
25
+ 직접 정규식으로 훑으면 `LiZr2(PO4)3` 가 P1O4 가 되어 조성이 통째로 틀린다.
26
+ Materials Project 자신이 LGPS 를 `Li10Ge(PS6)2` 로 적으므로 이건 예외가 아니라 기본이다.
27
+ """
28
+ from pymatgen.core import Composition
29
+ c = Composition(str(text).strip())
30
+ return {str(k): float(v) for k, v in c.get_el_amt_dict().items()}
31
+
32
+
33
+ def check(text, cif=None, require_elements=("Li",), max_atoms=60,
34
+ supported=None, max_elements=6):
35
+ """접수 판정. (ok, reason, info) 를 돌려준다."""
36
+ supported = supported or SUPPORTED
37
+ raw = (text or "").strip()
38
+ if not raw:
39
+ return False, "화학식을 입력해 주세요.", {}
40
+ if len(raw) > 120:
41
+ return False, "화학식이 너무 깁니다.", {}
42
+ if not re.match(r"^[A-Za-z0-9()\[\]\.\s]+$", raw):
43
+ return False, "화학식에 쓸 수 없는 문자가 있습니다.", {}
44
+
45
+ try:
46
+ comp = parse_formula(raw)
47
+ except Exception:
48
+ return False, "화학식을 해석하지 못했습니다. 예: Li3YCl6, LiZr2(PO4)3", {}
49
+ if not comp:
50
+ return False, "화학식을 해석하지 못했습니다.", {}
51
+
52
+ for el in require_elements:
53
+ if el not in comp:
54
+ return False, "리튬 전해질 시즌입니다. %s 를 포함해야 합니다." % el, {}
55
+
56
+ bad = sorted(e for e in comp if e not in supported)
57
+ if bad:
58
+ return False, ("계산 기준 상태가 없는 원소입니다: %s" % ", ".join(bad)), {}
59
+
60
+ if len(comp) > max_elements:
61
+ return False, "원소가 %d 종을 넘습니다 (현재 %d 종)." % (max_elements, len(comp)), {}
62
+
63
+ # **기약 조성**을 기준으로 삼는다. Li3YCl6 와 Li9Y3Cl18 은 같은 물질이므로 같은 것으로
64
+ # 세야 한다. 원본 조성으로 열쇠를 만들면 배수만 바꿔 같은 물질을 몇 번이고 올릴 수 있다.
65
+ from math import gcd
66
+ from functools import reduce
67
+ integral = all(abs(v - round(v)) < 1e-6 for v in comp.values())
68
+ if integral:
69
+ ints = {k: int(round(v)) for k, v in comp.items()}
70
+ g = reduce(gcd, ints.values()) or 1
71
+ reduced = {k: v // g for k, v in ints.items()}
72
+ else:
73
+ reduced = dict(comp) # 비정수 조성(도핑 등)은 그대로 둔다
74
+ n_atom = sum(reduced.values())
75
+ if n_atom > max_atoms:
76
+ return False, ("기약 조성의 원자 수가 %d 개로 상한 %d 를 넘습니다."
77
+ % (n_atom, max_atoms)), {}
78
+
79
+ info = {"composition": comp, "reduced": reduced,
80
+ "n_atoms": n_atom, "n_elements": len(comp)}
81
+
82
+ if cif:
83
+ c = str(cif)
84
+ if len(c) > 400_000:
85
+ return False, "구조 파일이 너무 큽니다 (400KB 상한).", {}
86
+ if not any(h in c for h in _CIF_HINT):
87
+ return False, "구조 파일이 CIF 형식으로 보이지 않습니다.", {}
88
+ info["has_structure"] = True
89
+
90
+ return True, "", info
index.html ADDED
@@ -0,0 +1,424 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!doctype html>
2
+ <html lang="ko">
3
+ <head>
4
+ <meta charset="utf-8">
5
+ <meta name="viewport" content="width=device-width,initial-scale=1">
6
+ <title>Open Materials Challenge #1 전고체 전해질</title>
7
+ <!-- <head> 는 선택이 아니다: 플랫폼이 자기 태그를 문서에 주입하는데, 받을 head 가 없으면
8
+ 그것들이 doctype 앞에 놓이고 페이지 전체가 쿼크 모드로 떨어진다. -->
9
+ <style>
10
+ :root{
11
+ --bg:#f7f8fa; --card:#fff; --ink:#16181d; --muted:#6b7280; --line:#e5e7eb;
12
+ --accent:#1f6f4a; --accent-soft:#e8f3ed; --warn:#9a6700; --warn-soft:#fff8e1;
13
+ --anchor:#fffbe6; --anchor-line:#f2e3a8;
14
+ }
15
+ :root:not([data-theme="light"]){}
16
+ @media (prefers-color-scheme: dark){
17
+ :root:not([data-theme="light"]){
18
+ --bg:#101215; --card:#171a1f; --ink:#e8eaed; --muted:#9aa1ab; --line:#2a2f37;
19
+ --accent:#4bbb87; --accent-soft:#16281f; --warn:#e0b23c; --warn-soft:#2a2312;
20
+ --anchor:#241f10; --anchor-line:#4a4022;
21
+ }
22
+ }
23
+ :root[data-theme="dark"]{
24
+ --bg:#101215; --card:#171a1f; --ink:#e8eaed; --muted:#9aa1ab; --line:#2a2f37;
25
+ --accent:#4bbb87; --accent-soft:#16281f; --warn:#e0b23c; --warn-soft:#2a2312;
26
+ --anchor:#241f10; --anchor-line:#4a4022;
27
+ }
28
+ *{box-sizing:border-box}
29
+ body{margin:0;background:var(--bg);color:var(--ink);
30
+ font:15px/1.65 -apple-system,BlinkMacSystemFont,"Segoe UI","Noto Sans KR",sans-serif}
31
+ .wrap{max-width:1120px;margin:0 auto;padding:20px 16px 80px}
32
+ header{display:flex;align-items:baseline;gap:12px;flex-wrap:wrap;margin:8px 0 4px}
33
+ h1{font-size:22px;margin:0;letter-spacing:-.3px}
34
+ h2{font-size:17px;margin:0 0 10px;display:flex;align-items:baseline;gap:10px}
35
+ .sub{color:var(--muted);font-size:14px}
36
+ .card{background:var(--card);border:1px solid var(--line);border-radius:14px;
37
+ padding:18px 20px;margin:14px 0}
38
+ .grid{display:grid;grid-template-columns:1fr 360px;gap:14px;align-items:start}
39
+ @media (max-width:900px){.grid{grid-template-columns:1fr}}
40
+ .cnt{color:var(--muted);font-size:13px;font-weight:400;margin-left:auto}
41
+ #langbar,#seasonbar{display:flex;gap:6px;flex-wrap:wrap}
42
+ #langbar{margin-left:auto}
43
+ #langbar button,#seasonbar button{border:1px solid var(--line);background:var(--card);
44
+ color:var(--muted);border-radius:8px;padding:5px 12px;cursor:pointer;font:inherit;font-size:13px}
45
+ #langbar button.on,#seasonbar button.on{background:var(--accent);color:#fff;border-color:var(--accent)}
46
+ .scroll{overflow-x:auto}
47
+ table{width:100%;border-collapse:collapse;font-size:13.5px;min-width:780px}
48
+ th,td{padding:8px 10px;text-align:left;border-bottom:1px solid var(--line);white-space:nowrap}
49
+ th{color:var(--muted);font-weight:600;font-size:12.5px;cursor:pointer;user-select:none}
50
+ th .ar{opacity:.4;margin-left:3px;font-size:10px}
51
+ th.sorted{color:var(--accent)} th.sorted .ar{opacity:1}
52
+ tr.anchor{background:var(--anchor)}
53
+ tr.anchor td{border-bottom-color:var(--anchor-line)}
54
+ td.num{text-align:right;font-variant-numeric:tabular-nums}
55
+ .pill{display:inline-block;padding:1px 7px;border-radius:999px;font-size:11.5px;
56
+ background:var(--accent-soft);color:var(--accent);border:1px solid transparent}
57
+ .note{color:var(--muted);font-size:12.5px;margin-top:12px;line-height:1.7}
58
+ .pager{display:flex;align-items:center;justify-content:center;gap:6px;flex-wrap:wrap;
59
+ padding:14px 4px 2px;font-size:13px}
60
+ .pager button{border:1px solid var(--line);background:var(--card);color:var(--muted);
61
+ border-radius:8px;padding:5px 11px;cursor:pointer;font:inherit;min-width:36px}
62
+ .pager button.on{background:var(--accent);color:#fff;border-color:var(--accent)}
63
+ .pager button:disabled{opacity:.4;cursor:default}
64
+ .pager .pinfo{color:var(--muted);margin:0 8px}
65
+ .pager .gap{color:var(--muted);padding:0 2px}
66
+ label{display:block;font-size:13px;color:var(--muted);margin:12px 0 5px}
67
+ input,textarea,select{width:100%;padding:9px 11px;border:1px solid var(--line);
68
+ border-radius:9px;background:var(--bg);color:var(--ink);font:inherit;font-size:14px}
69
+ textarea{resize:vertical;min-height:64px}
70
+ .radio{display:flex;gap:8px;margin-top:6px}
71
+ .radio label{flex:1;display:flex;align-items:center;gap:7px;margin:0;padding:10px;
72
+ border:1px solid var(--line);border-radius:9px;cursor:pointer;font-size:13px;color:var(--ink)}
73
+ .radio label.on{border-color:var(--accent);background:var(--accent-soft)}
74
+ .go{width:100%;margin-top:16px;padding:12px;border:0;border-radius:10px;
75
+ background:var(--accent);color:#fff;font:inherit;font-size:15px;font-weight:600;cursor:pointer}
76
+ .go:disabled{opacity:.5;cursor:default}
77
+ .msg{margin-top:10px;padding:10px 12px;border-radius:9px;font-size:13px;display:none}
78
+ .msg.ok{display:block;background:var(--accent-soft);color:var(--accent)}
79
+ .msg.bad{display:block;background:var(--warn-soft);color:var(--warn)}
80
+ .callout{background:var(--warn-soft);border:1px solid var(--anchor-line);
81
+ border-radius:10px;padding:12px 14px;font-size:13px;color:var(--ink);margin:12px 0}
82
+ .axes{display:flex;gap:8px;flex-wrap:wrap;margin:10px 0 2px}
83
+ .axes span{font-size:12.5px;color:var(--muted);background:var(--bg);
84
+ border:1px solid var(--line);border-radius:999px;padding:3px 10px}
85
+ .axes b{color:var(--ink)}
86
+ a{color:var(--accent)}
87
+ .badges{display:flex;gap:8px;flex-wrap:wrap;margin-top:6px}
88
+ .badges img{height:22px}
89
+ </style>
90
+ </head>
91
+ <body>
92
+ <div class="wrap">
93
+
94
+ <header>
95
+ <h1 id="t_title">Open Materials Challenge</h1>
96
+ <span class="sub" id="t_sub"></span>
97
+ <div id="langbar">
98
+ <button data-lang="ko" class="on">한국어</button>
99
+ <button data-lang="en">English</button>
100
+ </div>
101
+ </header>
102
+ <div class="badges" id="badges"></div>
103
+
104
+ <div id="seasonbar" style="margin:14px 0 0"></div>
105
+
106
+ <div class="card">
107
+ <h2 id="t_about_h"></h2>
108
+ <div id="t_about"></div>
109
+ <div class="axes" id="wts"></div>
110
+ <div class="callout" id="t_honest"></div>
111
+ </div>
112
+
113
+ <div class="grid">
114
+ <div class="card">
115
+ <h2><span id="t_board_h"></span><span class="cnt" id="counts"></span></h2>
116
+ <div class="scroll">
117
+ <table>
118
+ <thead><tr id="head"></tr></thead>
119
+ <tbody id="rows"></tbody>
120
+ </table>
121
+ </div>
122
+ <div class="pager" id="pager"></div>
123
+ <div class="note" id="t_boardnote"></div>
124
+ </div>
125
+
126
+ <div class="card">
127
+ <h2 id="t_submit_h"></h2>
128
+ <div id="gate" class="callout" style="display:none"></div>
129
+ <label id="l_formula"></label>
130
+ <input id="f_formula" placeholder="Li3YCl6">
131
+ <label id="l_name"></label>
132
+ <input id="f_name" placeholder="">
133
+ <label id="l_model"></label>
134
+ <input id="f_model" placeholder="anthropic/claude-…, Qwen/…">
135
+ <label id="l_cif"></label>
136
+ <textarea id="f_cif" placeholder="# generated using pymatgen&#10;data_…"></textarea>
137
+ <div class="note" id="t_cifnote" style="margin-top:4px"></div>
138
+ <label id="l_why"></label>
139
+ <textarea id="f_why"></textarea>
140
+ <label id="l_vis"></label>
141
+ <div class="radio" id="visbox">
142
+ <label class="on"><input type="radio" name="vis" value="private" checked><span id="t_priv"></span></label>
143
+ <label><input type="radio" name="vis" value="public"><span id="t_pub"></span></label>
144
+ </div>
145
+ <button class="go" id="go"></button>
146
+ <div class="msg" id="msg"></div>
147
+ </div>
148
+ </div>
149
+
150
+ <div class="card">
151
+ <h2 id="t_broker_h"></h2>
152
+ <div id="t_broker"></div>
153
+ </div>
154
+
155
+ </div>
156
+ <script>
157
+ const T = {
158
+ ko:{
159
+ sub:"인류의 난제를 AI 와 함께 — 개인이 참여하는 신소재 탐색",
160
+ about_h:"이 대회는 무엇인가",
161
+ about:`<p>전고체 배터리는 리튬이온 전지의 액체 전해질을 고체로 바꾼 것입니다. 더 안전하고 더 오래 갈 수 있지만,
162
+ <b>고체이면서 리튬만 잘 통과시키는 물질</b>을 찾는 일이 아직 풀리지 않았습니다.</p>
163
+ <p>이 대회는 그 후보 물질을 찾습니다. 참가자는 <b>조성 하나</b>를 냅니다 — 예: <code>Li3YCl6</code>.
164
+ 저희가 계산으로 채점해 순위표에 올립니다. 상금은 없습니다.</p>`,
165
+ honest:`<b>이 시즌이 채점하는 것.</b> <b>전기화학적 강건성</b>입니다 — 높은 전압을 견디는지,
166
+ 리튬 금속에 닿아도 버티는지, 리튬이 지나갈 길이 뚫려 있는지.
167
+ <b>이온전도도는 이번 시즌의 채점 항목이 아닙니다.</b> 따라서 전도도가 높은 계열이라도
168
+ 산화창이 좁거나 리튬 금속과 반응하면 낮게 나옵니다 — 그 두 가지가 이 시즌이 묻는 문제입니다.
169
+ 모든 값은 <b>계산 추정치</b>이며 실제 성능이나 안전성을 뜻하지 않습니다.`,
170
+ board_h:"순위표",
171
+ boardnote:`<b>기준물질(노란 행)</b>은 점수를 받되 등수를 갖지 않습니다. 실제로 쓰이는 전해질이 몇 점인지
172
+ 눈으로 비교하시라고 넣었습니다.<br>
173
+ 비공개 제출은 조성을 가리고 <b>원소 종류만</b> 표시합니다. 원본은 비공개로 보관됩니다.<br>
174
+ 열 제목을 눌러 정렬할 수 있습니다.`,
175
+ submit_h:"제출",
176
+ l_formula:"조성 (화학식)", l_name:"표시 ID (이름·소속·닉네임)", l_model:"사용 모델명 (선택)",
177
+ l_cif:"구조 파일 CIF (선택)", l_why:"설계 근거 (선택)", l_vis:"공개 여부",
178
+ cifnote:`구조를 함께 내면 채점이 더 빠르고 정확합니다. 없으면 저희가 구조를 추정하며,
179
+ 추정 구조를 얻지 못한 조성은 결과가 <b>보류</b>로 표시됩니다.`,
180
+ priv:"🔒 비공개 (기본)", pub:"🔓 조성 공개",
181
+ go:"제출하기",
182
+ gate:"제출하려면 <b>Hugging Face 로그인</b>이 필요합니다. 제출자 계정이 순위표에 함께 표시되며, 이는 다른 사람 이름으로 올리는 것을 막습니다.",
183
+ broker_h:"제출 기록과 제3자 연결",
184
+ broker:`<p>제출은 시각과 함께 저희 원장에 기록됩니다. 조성을 <b>비공개</b>로 두면 저희만 보관하며,
185
+ 공개하지 않는 한 외부에 드러나지 않습니다. 이 기록이 향후 권리 주장에 참고가 될 수 있으나,
186
+ <b>특허 출원을 대신하지 않으며 법적 효력을 보장하지 않습니다.</b> 출원은 전문가와 상의하십시오.</p>
187
+ <p>제3자가 특정 제출에 대해 협의를 요청하는 경우, 운영자가 <b>중개</b>합니다.
188
+ 제출자의 신원이나 연락처는 <b>본인 동의 없이 전달하지 않습니다.</b></p>`,
189
+ cols:{rank:"#",cand:"후보",user:"제출자",who:"모델",date:"제출일",
190
+ oxidation:"산화",li_stability:"Li안정",migration:"이동",novelty:"신규",total:"총점"},
191
+ counts:(a,b)=>`제출 ${a} · 기준물질 ${b}`,
192
+ prange:(a,b,n)=>`${a.toLocaleString()}–${b.toLocaleString()} / 전체 ${n.toLocaleString()}`,
193
+ empty:"아직 채점된 제출이 없습니다.",
194
+ fail:"순위표를 불러오지 못했습니다: ",
195
+ ok:(id,q)=>`접수되었습니다. 후보 ID <b>${id}</b> · 대기 ${q}건`,
196
+ need:"조성과 표시 ID를 입력해 주세요.",
197
+ hold:"보류"
198
+ },
199
+ en:{
200
+ sub:"Open problems, worked on together — materials discovery anyone can join",
201
+ about_h:"What this is",
202
+ about:`<p>A solid-state battery replaces the liquid electrolyte of a lithium-ion cell with a solid.
203
+ It can be safer and last longer, but finding a material that is <b>solid and still lets lithium through</b>
204
+ remains unsolved.</p>
205
+ <p>This challenge looks for candidates. You submit <b>one composition</b> — for example <code>Li3YCl6</code>.
206
+ We score it computationally and place it on the board. There is no prize.</p>`,
207
+ honest:`<b>What this season scores.</b> <b>Electrochemical robustness</b>: whether the material holds up at
208
+ high voltage, survives contact with lithium metal, and has a path for lithium to move through.
209
+ <b>Ionic conductivity is not a scored axis this season.</b> A highly conductive family therefore still
210
+ scores low if its oxidation window is narrow or it reacts with lithium metal — those two are the problem
211
+ this season puts to you.
212
+ Every value is a <b>computational estimate</b> and implies nothing about real performance or safety.`,
213
+ board_h:"Leaderboard",
214
+ boardnote:`<b>Reference materials (amber rows)</b> are scored but hold no rank. They are there so you can see
215
+ where materials in actual use land.<br>
216
+ Private entries show <b>elements only</b>; the composition is held back.<br>
217
+ Click a column header to sort.`,
218
+ submit_h:"Submit",
219
+ l_formula:"Composition (formula)", l_name:"Display ID (name, affiliation or handle)",
220
+ l_model:"Model used (optional)", l_cif:"Structure file, CIF (optional)",
221
+ l_why:"Design rationale (optional)", l_vis:"Visibility",
222
+ cifnote:`Including a structure makes scoring faster and more accurate. Without one we estimate the
223
+ structure; where no estimated structure is obtained, the result is marked <b>held</b>.`,
224
+ priv:"🔒 Private (default)", pub:"🔓 Show composition",
225
+ go:"Submit",
226
+ gate:"Submitting requires a <b>Hugging Face sign-in</b>. Your account appears alongside the entry, which stops anyone posting under another person's name.",
227
+ broker_h:"Record of submission, and third-party contact",
228
+ broker:`<p>Entries are recorded with a timestamp in our ledger. A <b>private</b> composition stays with us and
229
+ is not disclosed unless you choose to publish it. This record may serve as a reference later, but it
230
+ <b>is not a patent filing and carries no guaranteed legal effect.</b> Consult a professional before filing.</p>
231
+ <p>If a third party asks to discuss a specific entry, we pass the request along. We never share a
232
+ submitter's identity or contact details <b>without their consent.</b></p>`,
233
+ cols:{rank:"#",cand:"Entry",user:"Submitter",who:"Model",date:"Date",
234
+ oxidation:"Oxid.",li_stability:"Li-stab.",migration:"Migration",novelty:"Novelty",total:"Total"},
235
+ counts:(a,b)=>`${a} entries · ${b} references`,
236
+ prange:(a,b,n)=>`${a.toLocaleString()}–${b.toLocaleString()} of ${n.toLocaleString()}`,
237
+ empty:"No entries have been scored yet.",
238
+ fail:"Could not load the leaderboard: ",
239
+ ok:(id,q)=>`Accepted. Entry <b>${id}</b> · ${q} in queue`,
240
+ need:"Please enter a composition and a display ID.",
241
+ hold:"held"
242
+ }
243
+ };
244
+
245
+ const $ = s => document.querySelector(s);
246
+ const COLS = ["rank","cand","user","who","date","oxidation","li_stability","migration","novelty","total"];
247
+ // 표가 커지면 한꺼번에 그리지 않고 한 페이지씩 그린다.
248
+ const PAGE_SIZE = 500;
249
+ let LANG = "ko", DATA = null, SEASONS = [], SEASON = 1;
250
+ let sortKey = "total", sortDir = -1, page = 0;
251
+ const t = () => T[LANG];
252
+
253
+ function esc(s){ return String(s ?? "").replace(/[&<>"']/g,c=>({"&":"&amp;","<":"&lt;",">":"&gt;",'"':"&quot;","'":"&#39;"}[c])); }
254
+
255
+ function setLang(l){
256
+ LANG = l;
257
+ document.documentElement.lang = l;
258
+ document.querySelectorAll("#langbar button").forEach(b=>b.classList.toggle("on", b.dataset.lang===l));
259
+ const d = t();
260
+ $("#t_sub").textContent = d.sub;
261
+ $("#t_about_h").textContent = d.about_h;
262
+ $("#t_about").innerHTML = d.about;
263
+ $("#t_honest").innerHTML = d.honest;
264
+ $("#t_board_h").textContent = d.board_h;
265
+ $("#t_boardnote").innerHTML = d.boardnote;
266
+ $("#t_submit_h").textContent = d.submit_h;
267
+ $("#t_broker_h").textContent = d.broker_h;
268
+ $("#t_broker").innerHTML = d.broker;
269
+ $("#t_cifnote").innerHTML = d.cifnote;
270
+ $("#t_priv").textContent = d.priv;
271
+ $("#t_pub").textContent = d.pub;
272
+ $("#go").textContent = d.go;
273
+ for(const k of ["formula","name","model","cif","why","vis"]) $("#l_"+k).textContent = d["l_"+k];
274
+ const g = $("#gate"); if(g.style.display !== "none") g.innerHTML = d.gate;
275
+ renderSeasonBar();
276
+ if(DATA) render();
277
+ }
278
+ document.querySelectorAll("#langbar button").forEach(b=>b.onclick=()=>setLang(b.dataset.lang));
279
+
280
+ function renderSeasonBar(){
281
+ $("#seasonbar").innerHTML = SEASONS.slice().sort((a,b)=>b.number-a.number).map(s=>{
282
+ const nm = LANG==="ko" ? (s.topic_ko||s.topic) : s.topic;
283
+ return `<button data-season="${s.number}" class="${s.number===SEASON?"on":""}">
284
+ #${s.number} ${esc(nm)}</button>`;
285
+ }).join("");
286
+ document.querySelectorAll("#seasonbar button").forEach(b=>
287
+ b.onclick=()=>{ SEASON=parseInt(b.dataset.season,10); page=0; renderSeasonBar(); load(); });
288
+ }
289
+
290
+ function sortVal(e,k){
291
+ if(k==="rank") return e.rank ?? 1e9;
292
+ if(k==="cand") return String(e.candidate_id||"").toLowerCase();
293
+ if(k==="user") return String(e.hf_user||e.display_name||"").toLowerCase();
294
+ if(k==="who") return String(e.model_name||"").toLowerCase();
295
+ if(k==="date") return e.submitted_at ?? 0;
296
+ if(k==="total") return e.total ?? -1;
297
+ return (e.axes||{})[k] ?? -1;
298
+ }
299
+
300
+ function renderPager(total,pages){
301
+ const host = $("#pager"), d = t();
302
+ if(pages<=1){ host.innerHTML=""; return; }
303
+ const from = page*PAGE_SIZE+1, to = Math.min(total,(page+1)*PAGE_SIZE);
304
+ let nums=[];
305
+ if(pages<=9){ for(let i=0;i<pages;i++) nums.push(i); }
306
+ else{
307
+ const near=[page-1,page,page+1].filter(i=>i>0&&i<pages-1);
308
+ nums=[...new Set([0,...near,pages-1])].sort((a,b)=>a-b);
309
+ }
310
+ let out=`<button ${page===0?"disabled":""} data-p="${page-1}">‹</button>`, prev=-1;
311
+ for(const i of nums){
312
+ if(i-prev>1) out+=`<span class="gap">…</span>`;
313
+ out+=`<button class="${i===page?"on":""}" data-p="${i}">${i+1}</button>`; prev=i;
314
+ }
315
+ out+=`<button ${page>=pages-1?"disabled":""} data-p="${page+1}">›</button>`;
316
+ out+=`<span class="pinfo">${d.prange(from,to,total)}</span>`;
317
+ host.innerHTML=out;
318
+ host.querySelectorAll("button[data-p]").forEach(b=>b.onclick=()=>{
319
+ if(b.disabled) return;
320
+ page=parseInt(b.dataset.p,10); render();
321
+ const c=document.querySelector(".card .scroll");
322
+ if(c) window.scrollTo({top:c.getBoundingClientRect().top+window.scrollY-80,behavior:"smooth"});
323
+ });
324
+ }
325
+
326
+ function render(){
327
+ const d = t(), W = (DATA.season && DATA.season.weights) || {};
328
+ $("#wts").innerHTML = Object.entries(W)
329
+ .map(([k,v])=>`<span>${esc(d.cols[k]||k)} <b>${v}</b></span>`).join("");
330
+ $("#counts").textContent = d.counts(DATA.counts.scored, DATA.counts.anchors);
331
+
332
+ $("#head").innerHTML = COLS.map(k=>{
333
+ const on = sortKey===k, ar = on ? (sortDir<0?"▼":"▲") : "▾";
334
+ return `<th data-k="${k}" class="${on?"sorted":""}">${esc(d.cols[k]||k)}<span class="ar">${ar}</span></th>`;
335
+ }).join("");
336
+ document.querySelectorAll("#head th").forEach(th=>th.onclick=()=>{
337
+ const k=th.dataset.k;
338
+ if(sortKey===k) sortDir=-sortDir;
339
+ else { sortKey=k; sortDir=(k==="rank"||k==="cand"||k==="who")?1:-1; }
340
+ page=0; render();
341
+ });
342
+
343
+ const all = DATA.entries.slice().sort((a,b)=>{
344
+ const x=sortVal(a,sortKey), y=sortVal(b,sortKey);
345
+ if(x<y) return -sortDir; if(x>y) return sortDir; return 0;
346
+ });
347
+ const pages = Math.max(1, Math.ceil(all.length/PAGE_SIZE));
348
+ if(page>=pages) page=pages-1;
349
+ if(page<0) page=0;
350
+ const rows = all.slice(page*PAGE_SIZE, page*PAGE_SIZE+PAGE_SIZE);
351
+ renderPager(all.length, pages);
352
+
353
+ $("#rows").innerHTML = rows.length ? rows.map(e=>{
354
+ const a = e.axes||{};
355
+ const cls = e.is_anchor ? "anchor" : "";
356
+ const who = e.is_anchor ? esc(e.display_name) : esc(e.hf_user||e.display_name||"");
357
+ const num = k => {
358
+ const v = a[k];
359
+ return `<td class="num">${v==null?`<span class="pill">${d.hold}</span>`:v.toFixed(1)}</td>`;
360
+ };
361
+ const dt = e.submitted_at ? new Date(e.submitted_at*1000).toISOString().slice(0,10) : "";
362
+ const tot = e.total==null ? `<span class="pill">${d.hold}</span>`
363
+ : `${e.total.toFixed(1)}${e.total_max?`<span class="sub" style="font-size:11px"> /${e.total_max}</span>`:""}`;
364
+ return `<tr class="${cls}">
365
+ <td class="num">${e.rank ?? "—"}</td>
366
+ <td>${esc(e.formula||e.candidate_id||"")}${e.masked?' <span class="pill">🔒</span>':""}</td>
367
+ <td>${who}</td><td>${esc(e.model_name||"")}</td><td>${dt}</td>
368
+ ${num("oxidation")}${num("li_stability")}${num("migration")}${num("novelty")}
369
+ <td class="num"><b>${tot}</b></td></tr>`;
370
+ }).join("") : `<tr><td colspan="${COLS.length}" class="sub">${d.empty}</td></tr>`;
371
+ }
372
+
373
+ async function load(){
374
+ try{
375
+ const r = await fetch(`/api/leaderboard?season=${SEASON}`);
376
+ DATA = await r.json();
377
+ render();
378
+ }catch(e){
379
+ $("#rows").innerHTML = `<tr><td colspan="${COLS.length}" class="sub">${t().fail}${esc(e)}</td></tr>`;
380
+ }
381
+ }
382
+
383
+ document.querySelectorAll('input[name="vis"]').forEach(r=>r.onchange=()=>{
384
+ document.querySelectorAll("#visbox label").forEach(l=>l.classList.toggle("on", l.querySelector("input").checked));
385
+ });
386
+
387
+ $("#go").onclick = async () => {
388
+ const d = t(), m = $("#msg");
389
+ const formula = $("#f_formula").value.trim(), name = $("#f_name").value.trim();
390
+ if(!formula || !name){ m.className="msg bad"; m.textContent=d.need; return; }
391
+ $("#go").disabled = true;
392
+ try{
393
+ const r = await fetch("/api/submit",{method:"POST",headers:{"Content-Type":"application/json"},
394
+ body:JSON.stringify({formula, display_name:name, model_name:$("#f_model").value.trim(),
395
+ rationale:$("#f_why").value.trim(), structure:$("#f_cif").value.trim(),
396
+ visibility:document.querySelector('input[name="vis"]:checked').value, season:SEASON})});
397
+ const j = await r.json();
398
+ if(!r.ok){ m.className="msg bad"; m.textContent = j.detail || ("HTTP "+r.status); }
399
+ else{ m.className="msg ok"; m.innerHTML = d.ok(j.candidate_id, j.queue_position);
400
+ $("#f_formula").value=""; $("#f_cif").value=""; load(); }
401
+ }catch(e){ m.className="msg bad"; m.textContent=String(e); }
402
+ $("#go").disabled = false;
403
+ };
404
+
405
+ (async function boot(){
406
+ try{
407
+ const s = await (await fetch("/api/seasons")).json();
408
+ SEASONS = s.seasons || [];
409
+ const open = SEASONS.filter(x=>x.open);
410
+ SEASON = (open.length?open:SEASONS).slice(-1)[0]?.number || 1;
411
+ }catch(e){}
412
+ try{
413
+ const me = await (await fetch("/api/me")).json();
414
+ if(me.oauth_configured && !me.signed_in){
415
+ const g=$("#gate"); g.style.display="block"; g.innerHTML=t().gate;
416
+ }
417
+ }catch(e){}
418
+ setLang("ko");
419
+ await load();
420
+ setInterval(load, 20000);
421
+ })();
422
+ </script>
423
+ </body>
424
+ </html>
requirements.txt ADDED
@@ -0,0 +1,4 @@
 
 
 
 
 
1
+ fastapi==0.115.0
2
+ uvicorn[standard]==0.30.6
3
+ pydantic==2.9.2
4
+ pymatgen==2024.8.9
seasons.py ADDED
@@ -0,0 +1,75 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # -*- coding: utf-8 -*-
2
+ """시즌 등록부.
3
+
4
+ 시즌마다 대상 물질군·게이트·배점이 다르다. 한 프로세스가 모든 시즌을 서빙하므로
5
+ 설정은 **환경변수가 아니라 인자**로 흐른다 - 환경변수로 두면 시즌 하나의 설정이
6
+ 다른 시즌 요청에 새어 들어간다 (ODC 에서 실제로 그럴 뻔했다).
7
+ """
8
+
9
+ SEASONS = [
10
+ {
11
+ "number": 1,
12
+ "name": "Open Materials Challenge",
13
+ "topic": "Solid-State Battery Electrolyte",
14
+ "topic_ko": "전고체 배터리 전해질",
15
+ "mobile_ion": "Li",
16
+ "opens": "2026-08-21",
17
+ "closes": "2026-11-30",
18
+ "prize": 0,
19
+ "open": True,
20
+ "prefix": "",
21
+ "anchors": "anchor_scores.json",
22
+
23
+ # 게이트는 자격만 본다. 성능은 점수로 반영한다.
24
+ "gate": {
25
+ "gap_min": 1.0, # 전자전도체는 전지를 단락시킨다 - 진짜 요건
26
+ "hull_max": 0.10, # 합성 가능성 상한
27
+ "ea_max": 1.0, # 리튬 관통 경로 존재 여부. 이진 판정
28
+ "max_atoms": 60,
29
+ "require_elements": ("Li",),
30
+ },
31
+ "weights": {
32
+ "oxidation": 35, # 산화 안정성
33
+ "li_stability": 30, # 리튬금속 안정성
34
+ "novelty": 20, # 용도 신규성 (조성 신규성이 아니다)
35
+ "migration": 15, # 리튬 이동 용이성
36
+ },
37
+ },
38
+ ]
39
+
40
+ BY_NUMBER = {s["number"]: s for s in SEASONS}
41
+ DEFAULT = SEASONS[-1]
42
+
43
+ # 공개면에 내보내지 않는 키. 채점기 내부 사정이 새면 표적화된 제출이 들어온다.
44
+ _PRIVATE = ("prefix", "anchors", "gate")
45
+
46
+
47
+ def get(n=None):
48
+ if n in (None, ""):
49
+ return DEFAULT
50
+ try:
51
+ return BY_NUMBER.get(int(n), DEFAULT)
52
+ except (TypeError, ValueError):
53
+ return DEFAULT
54
+
55
+
56
+ def gate(s):
57
+ return dict(s.get("gate") or {})
58
+
59
+
60
+ def path(s, name):
61
+ return (s.get("prefix") or "") + name
62
+
63
+
64
+ def public(s):
65
+ out = {k: v for k, v in s.items() if k not in _PRIVATE}
66
+ g = s.get("gate") or {}
67
+ # 참가자가 알아야 반려를 피할 수 있는 값만 공개한다.
68
+ out["limits"] = {"max_atoms": g.get("max_atoms"),
69
+ "hull_max": g.get("hull_max"),
70
+ "gap_min": g.get("gap_min")}
71
+ return out
72
+
73
+
74
+ def all_public():
75
+ return [public(s) for s in SEASONS]
store.py ADDED
@@ -0,0 +1,187 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # -*- coding: utf-8 -*-
2
+ """The submission ledger, kept in a private Hugging Face dataset.
3
+
4
+ A Space's container filesystem is ephemeral. Every rebuild, restart or sleep wipes it,
5
+ and with it every submission and every score. That is survivable for a demo and not
6
+ survivable for a contest with a prize attached, so the ledger lives outside the container
7
+ in a dataset repo that the entrant's work outlives the service.
8
+
9
+ Layout - one file per record, never appended to:
10
+
11
+ submissions/<id>.json written by the web service when an entry is accepted
12
+ results/<id>.json written by the GPU worker when it finishes scoring
13
+ leaderboard.json rolled up by the worker so the page reads one file
14
+
15
+ One file per record is what makes concurrent writers safe. Two entrants submitting at the
16
+ same moment touch different paths, so neither commit can clobber the other - which an
17
+ append to a shared JSONL absolutely would.
18
+
19
+ The rollup exists because a page load must not fan out into one request per entry. The
20
+ worker already holds every score at the moment it writes one, so it is the natural place
21
+ to rebuild the table.
22
+ """
23
+ import base64
24
+ import json
25
+ import os
26
+ import threading
27
+ import time
28
+ import urllib.error
29
+ import urllib.request
30
+
31
+ REPO = os.environ.get("OMC_DATASET", "FINAL-Bench/omc-submissions")
32
+ TOKEN = os.environ.get("HF_TOKEN", "")
33
+ API = "https://huggingface.co/api/datasets/%s" % REPO
34
+ RESOLVE = "https://huggingface.co/datasets/%s/resolve/main" % REPO
35
+
36
+
37
+ def _hdr(extra=None):
38
+ h = {"User-Agent": "VIDRAFT-OMC/1.0"}
39
+ if TOKEN:
40
+ h["Authorization"] = "Bearer " + TOKEN
41
+ if extra:
42
+ h.update(extra)
43
+ return h
44
+
45
+
46
+ def _get_json(url, timeout=30):
47
+ req = urllib.request.Request(url, headers=_hdr())
48
+ with urllib.request.urlopen(req, timeout=timeout) as r:
49
+ return json.loads(r.read().decode())
50
+
51
+
52
+ def read(path, default=None):
53
+ """Fetch one record. Missing files are a normal state, not an error."""
54
+ try:
55
+ return _get_json("%s/%s" % (RESOLVE, path))
56
+ except urllib.error.HTTPError as e:
57
+ if e.code in (404, 401, 403):
58
+ return default
59
+ raise
60
+ except Exception:
61
+ return default
62
+
63
+
64
+ def write(path, obj, summary=None):
65
+ """Commit one record. NDJSON is the format this endpoint takes - a plain JSON body
66
+ is accepted and then quietly does nothing, which is a long way to debug."""
67
+ blob = base64.b64encode(json.dumps(obj, ensure_ascii=False).encode()).decode()
68
+ lines = [
69
+ json.dumps({"key": "header", "value": {"summary": summary or ("write " + path)}}),
70
+ json.dumps({"key": "file", "value": {"path": path, "content": blob,
71
+ "encoding": "base64"}}),
72
+ ]
73
+ body = ("\n".join(lines) + "\n").encode()
74
+ req = urllib.request.Request(API + "/commit/main", data=body,
75
+ headers=_hdr({"Content-Type": "application/x-ndjson"}))
76
+ with urllib.request.urlopen(req, timeout=60) as r:
77
+ return json.loads(r.read().decode())
78
+
79
+
80
+ MAX_PAGES = int(os.environ.get("OMC_MAX_PAGES", "200"))
81
+
82
+
83
+ def _tree_pages(prefix):
84
+ """Every page of a tree listing, following the Link cursor.
85
+
86
+ The Hub caps a page at 1,000 entries. Reading only the first page was silent while
87
+ the dataset was small and started dropping records the moment it was not."""
88
+ url = "%s/tree/main/%s" % (API, prefix)
89
+ seen = 0
90
+ for _ in range(MAX_PAGES):
91
+ req = urllib.request.Request(url, headers=_hdr())
92
+ with urllib.request.urlopen(req, timeout=60) as r:
93
+ page = json.loads(r.read().decode())
94
+ link = r.headers.get("Link") or ""
95
+ yield page
96
+ seen += len(page)
97
+ nxt = ""
98
+ for part in link.split(","):
99
+ if 'rel="next"' in part and "<" in part:
100
+ nxt = part[part.index("<") + 1:part.index(">")]
101
+ if not nxt:
102
+ return
103
+ url = nxt
104
+ raise RuntimeError("tree listing exceeded %d pages at %s (%d entries)"
105
+ % (MAX_PAGES, prefix, seen))
106
+
107
+
108
+ def listdir(prefix):
109
+ """Record ids under a prefix. Absent directory means nothing has been written yet."""
110
+ out = []
111
+ try:
112
+ for page in _tree_pages(prefix):
113
+ for e in page:
114
+ q = e.get("path", "")
115
+ if q.endswith(".json") and not os.path.basename(q).startswith("_"):
116
+ out.append(os.path.basename(q)[:-5])
117
+ except urllib.error.HTTPError as e:
118
+ if e.code == 404:
119
+ return []
120
+ raise
121
+ except Exception:
122
+ # a partial listing is worse than none: the caller would treat the missing ids as
123
+ # unscored and the rollup would drop them, which is exactly the failure this fixes
124
+ raise
125
+ return out
126
+
127
+
128
+ class Cached:
129
+ """A small TTL cache so a page refresh does not become a round trip to the Hub.
130
+
131
+ Staleness is bounded and harmless here: the worst case is a leaderboard a few seconds
132
+ behind, and the page polls anyway.
133
+
134
+ **Single flight.** The first version had no lock, so every concurrent request that
135
+ arrived after the TTL expired saw a miss and every one of them re-ran produce(). With
136
+ a few thousand entries that is a full paginated tree listing each, and on a 2-vCPU
137
+ container the request threadpool fills with blocked Hub I/O - at which point even the
138
+ static index page queues behind it and the site stops answering. One refresher at a
139
+ time; everyone else is served the value we already have.
140
+
141
+ **Stale beats blocking, and stale beats an error.** A leaderboard a minute old is a
142
+ working page. A timeout is not.
143
+ """
144
+
145
+ def __init__(self, ttl=20):
146
+ self.ttl = ttl
147
+ self._v = {}
148
+ self._locks = {}
149
+ self._guard = threading.Lock()
150
+
151
+ def _lock_for(self, key):
152
+ with self._guard:
153
+ lk = self._locks.get(key)
154
+ if lk is None:
155
+ lk = self._locks[key] = threading.Lock()
156
+ return lk
157
+
158
+ def get(self, key, produce):
159
+ hit = self._v.get(key)
160
+ if hit and time.time() - hit[0] < self.ttl:
161
+ return hit[1]
162
+
163
+ lk = self._lock_for(key)
164
+ # Block only when there is nothing at all to serve. If a refresh is already in
165
+ # flight and we hold a stale value, hand that back instead of joining the queue.
166
+ if not lk.acquire(blocking=(hit is None)):
167
+ return hit[1]
168
+ try:
169
+ hit = self._v.get(key) # the winner may have filled it while we waited
170
+ if hit and time.time() - hit[0] < self.ttl:
171
+ return hit[1]
172
+ try:
173
+ val = produce()
174
+ except Exception:
175
+ if hit:
176
+ return hit[1]
177
+ raise
178
+ self._v[key] = (time.time(), val)
179
+ return val
180
+ finally:
181
+ lk.release()
182
+
183
+ def drop(self, key=None):
184
+ if key is None:
185
+ self._v.clear()
186
+ else:
187
+ self._v.pop(key, None)