uremia commited on
Commit
2f9ece5
·
verified ·
1 Parent(s): 94344ca

Add image input with vision-assisted JEV decisions

Browse files

Add JPEG, PNG and WebP upload, preview and removal. Validate and normalize images server-side, then use Qwen2-VL-2B-Instruct to read the image before JEV inference. Display the vision model and image-understanding time separately from JEV decision time.

Files changed (4) hide show
  1. README.md +3 -2
  2. THIRD_PARTY_NOTICES.md +2 -0
  3. app.py +84 -6
  4. play.html +103 -8
README.md CHANGED
@@ -21,11 +21,12 @@ tags:
21
 
22
  Try JEV-27B on your own inputs, or let it choose the next move in two playable Doom scenarios.
23
 
24
- - **Decision lab:** multiple choice, true/false, and 0–5 scoring with option probabilities.
 
25
  - **Doom arena:** target practice and center defense. Play manually, request one JEV move, or run ten consecutive model decisions.
26
  - **Demo reel:** ten recorded demonstrations and six benchmark comparison bar charts.
27
 
28
- Live inference uses the existing AutoTrust JEV-27B deployment. The Space runs the interface and the ViZDoom simulator on CPU. JEV receives structured engine state, and the simulation pauses between decisions. Game sessions expire after ten minutes of inactivity; up to eight sessions and four simultaneous model calls are supported.
29
 
30
  The deployed inference revision is `7ce153d6f7933769527939bcb92ec3f3d8292884`. The recorded reel is the latest ten-demo 5× edit (41 seconds), with the September 27, 2026 benchmark comparison. Benchmark details are in [benchmark-notes.md](benchmark-notes.md).
31
 
 
21
 
22
  Try JEV-27B on your own inputs, or let it choose the next move in two playable Doom scenarios.
23
 
24
+ - **Decision lab:** text or image input for multiple choice, true/false, and 0–5 scoring, with option probabilities and timing.
25
+ Upload one JPEG, PNG, or WebP image up to 5 MB, add a question, and optionally add text context.
26
  - **Doom arena:** target practice and center defense. Play manually, request one JEV move, or run ten consecutive model decisions.
27
  - **Demo reel:** ten recorded demonstrations and six benchmark comparison bar charts.
28
 
29
+ Live decisions use the existing AutoTrust JEV-27B deployment. Image input uses [Qwen/Qwen2-VL-2B-Instruct](https://huggingface.co/Qwen/Qwen2-VL-2B-Instruct), pinned to revision `895c3a49bc3fa70a340399125c650a463535e71c`, to describe the image before JEV makes the decision. This is a vision-assisted pipeline, since the deployed JEV checkpoint is text-only. The interface names the vision model and shows image-understanding time separately from JEV decision time. Images are processed in memory, normalized to at most 1024 pixels per side, and are not saved by this app. The Space runs the interface and the ViZDoom simulator on CPU. JEV receives structured engine state, and the simulation pauses between decisions. Game sessions expire after ten minutes of inactivity; up to eight sessions and four simultaneous model calls are supported.
30
 
31
  The deployed inference revision is `7ce153d6f7933769527939bcb92ec3f3d8292884`. The recorded reel is the latest ten-demo 5× edit (41 seconds), with the September 27, 2026 benchmark comparison. Benchmark details are in [benchmark-notes.md](benchmark-notes.md).
32
 
THIRD_PARTY_NOTICES.md CHANGED
@@ -14,6 +14,8 @@ Only the freely distributed Freedoom assets supplied with ViZDoom are used. No c
14
 
15
  JEV-27B is provided by AutoTrust at https://huggingface.co/autotrust/JEV-27B. Its model card identifies the model license as Apache-2.0. This repository connects to a separately hosted deployment and contains no model weights.
16
 
 
 
17
  ## Recorded media
18
 
19
  Demo video, AutoTrust brand assets, and benchmark graphics were supplied by the AutoTrust demo project.
 
14
 
15
  JEV-27B is provided by AutoTrust at https://huggingface.co/autotrust/JEV-27B. Its model card identifies the model license as Apache-2.0. This repository connects to a separately hosted deployment and contains no model weights.
16
 
17
+ Image understanding uses Qwen/Qwen2-VL-2B-Instruct, provided by the Qwen team at https://huggingface.co/Qwen/Qwen2-VL-2B-Instruct, under Apache-2.0. The separately hosted image reader uses revision `895c3a49bc3fa70a340399125c650a463535e71c`. Its observations are passed to JEV-27B for the final decision; this is not native vision in the JEV checkpoint. No Qwen model weights are included in this repository.
18
+
19
  ## Recorded media
20
 
21
  Demo video, AutoTrust brand assets, and benchmark graphics were supplied by the AutoTrust demo project.
app.py CHANGED
@@ -2,12 +2,16 @@
2
  from __future__ import annotations
3
 
4
  import asyncio
 
 
5
  import contextlib
 
6
  import math
7
  import os
8
  import re
9
  import secrets
10
  import time
 
11
  from concurrent.futures import ThreadPoolExecutor
12
  from contextlib import asynccontextmanager
13
  from pathlib import Path
@@ -16,7 +20,9 @@ from urllib.parse import urlsplit
16
 
17
  import httpx
18
  from fastapi import FastAPI, HTTPException, Request
 
19
  from fastapi.responses import FileResponse, JSONResponse
 
20
  from pydantic import BaseModel, ConfigDict, Field, StringConstraints, model_validator
21
 
22
  from doom_engine import DoomEpisode, SCENARIOS
@@ -27,6 +33,10 @@ MODEL = "autotrust/JEV-27B"
27
  SESSION_LIMIT = 8
28
  SESSION_TTL = 600
29
  QUEUE_WAIT_SECONDS = 5
 
 
 
 
30
  SAFE_ASSETS = {".css", ".js", ".svg", ".png", ".jpg", ".json", ".mp4", ".md"}
31
  Option = Annotated[str, StringConstraints(strip_whitespace=True, min_length=1, max_length=180)]
32
 
@@ -34,9 +44,10 @@ Option = Annotated[str, StringConstraints(strip_whitespace=True, min_length=1, m
34
  class DecisionRequest(BaseModel):
35
  model_config = ConfigDict(extra="forbid")
36
  kind: Literal["choice", "noul", "score"] = "choice"
37
- state: Annotated[str, StringConstraints(max_length=6000)]
38
  question: Annotated[str, StringConstraints(strip_whitespace=True, min_length=1, max_length=1000)]
39
  options: list[Option] | None = Field(default=None, min_length=2, max_length=16)
 
40
 
41
  @model_validator(mode="after")
42
  def native_options(self):
@@ -148,13 +159,23 @@ async def lifespan(app: FastAPI):
148
  app = FastAPI(title="JEV-27B Live Demo", lifespan=lifespan, docs_url=None, redoc_url=None)
149
 
150
 
 
 
 
 
 
 
 
 
 
151
  @app.middleware("http")
152
  async def basic_limits(request: Request, call_next):
153
  if request.method in {"POST", "PUT", "PATCH"}:
154
  body = bytearray()
155
  async for chunk in request.stream():
156
  body.extend(chunk)
157
- if len(body) > 40_000:
 
158
  return JSONResponse({"detail": "Request body is too large."}, status_code=413)
159
  # Starlette replays the cached body when call_next constructs its Request.
160
  request._body = bytes(body)
@@ -193,6 +214,9 @@ def clean_decision(data: dict, payload: dict) -> dict:
193
  options = payload["options"]
194
  if data.get("model") != MODEL or data.get("options") != options:
195
  raise ValueError("Unexpected model or options")
 
 
 
196
  probabilities = data.get("probabilities")
197
  if not isinstance(probabilities, list) or len(probabilities) != len(options):
198
  raise ValueError("Invalid distribution")
@@ -210,9 +234,60 @@ def clean_decision(data: dict, payload: dict) -> dict:
210
  revision = data.get("revision", "")
211
  if not isinstance(revision, str) or not re.fullmatch(r"[a-fA-F0-9]{7,64}", revision):
212
  revision = "unavailable"
213
- return {"kind": payload["kind"], "options": options, "probabilities": probabilities,
214
- "choice_index": index, "choice": options[index], "model": MODEL,
215
- "revision": revision, "elapsed_seconds": elapsed}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
216
 
217
 
218
  async def infer(payload: dict) -> dict:
@@ -243,7 +318,10 @@ async def infer(payload: dict) -> dict:
243
 
244
  @app.post("/api/decide")
245
  async def decide(payload: DecisionRequest):
246
- return await infer(payload.model_dump())
 
 
 
247
 
248
 
249
  @app.post("/api/games/new")
 
2
  from __future__ import annotations
3
 
4
  import asyncio
5
+ import base64
6
+ import binascii
7
  import contextlib
8
+ import io
9
  import math
10
  import os
11
  import re
12
  import secrets
13
  import time
14
+ import warnings
15
  from concurrent.futures import ThreadPoolExecutor
16
  from contextlib import asynccontextmanager
17
  from pathlib import Path
 
20
 
21
  import httpx
22
  from fastapi import FastAPI, HTTPException, Request
23
+ from fastapi.exceptions import RequestValidationError
24
  from fastapi.responses import FileResponse, JSONResponse
25
+ from PIL import Image, ImageOps, UnidentifiedImageError
26
  from pydantic import BaseModel, ConfigDict, Field, StringConstraints, model_validator
27
 
28
  from doom_engine import DoomEpisode, SCENARIOS
 
33
  SESSION_LIMIT = 8
34
  SESSION_TTL = 600
35
  QUEUE_WAIT_SECONDS = 5
36
+ MAX_IMAGE_BYTES = 5 * 1024 * 1024
37
+ MAX_IMAGE_BASE64 = 4 * ((MAX_IMAGE_BYTES + 2) // 3)
38
+ MAX_IMAGE_PIXELS = 32_000_000
39
+ MAX_IMAGE_EDGE = 1024
40
  SAFE_ASSETS = {".css", ".js", ".svg", ".png", ".jpg", ".json", ".mp4", ".md"}
41
  Option = Annotated[str, StringConstraints(strip_whitespace=True, min_length=1, max_length=180)]
42
 
 
44
  class DecisionRequest(BaseModel):
45
  model_config = ConfigDict(extra="forbid")
46
  kind: Literal["choice", "noul", "score"] = "choice"
47
+ state: Annotated[str, StringConstraints(max_length=6000)] = ""
48
  question: Annotated[str, StringConstraints(strip_whitespace=True, min_length=1, max_length=1000)]
49
  options: list[Option] | None = Field(default=None, min_length=2, max_length=16)
50
+ image: Annotated[str, StringConstraints(max_length=MAX_IMAGE_BASE64 + 32)] | None = None
51
 
52
  @model_validator(mode="after")
53
  def native_options(self):
 
159
  app = FastAPI(title="JEV-27B Live Demo", lifespan=lifespan, docs_url=None, redoc_url=None)
160
 
161
 
162
+ @app.exception_handler(RequestValidationError)
163
+ async def invalid_request(request: Request, error: RequestValidationError):
164
+ # Do not echo uploaded image data back in validation errors.
165
+ return JSONResponse({"detail": [
166
+ {"loc": item["loc"], "msg": item["msg"], "type": item["type"]}
167
+ for item in error.errors()
168
+ ]}, status_code=422)
169
+
170
+
171
  @app.middleware("http")
172
  async def basic_limits(request: Request, call_next):
173
  if request.method in {"POST", "PUT", "PATCH"}:
174
  body = bytearray()
175
  async for chunk in request.stream():
176
  body.extend(chunk)
177
+ limit = MAX_IMAGE_BASE64 + 40_000 if request.url.path == "/api/decide" else 40_000
178
+ if len(body) > limit:
179
  return JSONResponse({"detail": "Request body is too large."}, status_code=413)
180
  # Starlette replays the cached body when call_next constructs its Request.
181
  request._body = bytes(body)
 
214
  options = payload["options"]
215
  if data.get("model") != MODEL or data.get("options") != options:
216
  raise ValueError("Unexpected model or options")
217
+ image_used = bool(payload.get("image"))
218
+ if image_used and data.get("image_used") is not True:
219
+ raise ValueError("The backend did not confirm image inference")
220
  probabilities = data.get("probabilities")
221
  if not isinstance(probabilities, list) or len(probabilities) != len(options):
222
  raise ValueError("Invalid distribution")
 
234
  revision = data.get("revision", "")
235
  if not isinstance(revision, str) or not re.fullmatch(r"[a-fA-F0-9]{7,64}", revision):
236
  revision = "unavailable"
237
+ result = {"kind": payload["kind"], "options": options, "probabilities": probabilities,
238
+ "choice_index": index, "choice": options[index], "model": MODEL,
239
+ "revision": revision, "elapsed_seconds": elapsed, "image_used": image_used}
240
+ if image_used:
241
+ vision = data.get("vision")
242
+ if not isinstance(vision, dict):
243
+ raise ValueError("Missing image understanding provenance")
244
+ vision_model = vision.get("model")
245
+ vision_elapsed = vision.get("elapsed_seconds")
246
+ if (not isinstance(vision_model, str) or not re.fullmatch(r"[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+", vision_model)
247
+ or len(vision_model) > 160 or type(vision_elapsed) not in (int, float)
248
+ or not math.isfinite(vision_elapsed) or vision_elapsed < 0):
249
+ raise ValueError("Invalid image understanding provenance")
250
+ result["vision"] = {"model": vision_model, "elapsed_seconds": vision_elapsed}
251
+ return result
252
+
253
+
254
+ def prepare_image(data_url: str) -> str:
255
+ """Decode and normalize a user image without fetching URLs or storing files."""
256
+ header, separator, encoded = data_url.partition(",")
257
+ formats = {"data:image/jpeg;base64": "JPEG", "data:image/png;base64": "PNG",
258
+ "data:image/webp;base64": "WEBP"}
259
+ if not separator or header not in formats:
260
+ raise HTTPException(422, "Upload a JPEG, PNG, or WebP image.")
261
+ try:
262
+ raw = base64.b64decode(encoded, validate=True)
263
+ except (ValueError, binascii.Error):
264
+ raise HTTPException(422, "The image could not be read. Choose another file.") from None
265
+ if not raw or len(raw) > MAX_IMAGE_BYTES:
266
+ raise HTTPException(413, "Choose an image no larger than 5 MB.")
267
+ try:
268
+ with warnings.catch_warnings():
269
+ warnings.simplefilter("error", Image.DecompressionBombWarning)
270
+ with Image.open(io.BytesIO(raw)) as probe:
271
+ if probe.format != formats[header] or getattr(probe, "is_animated", False):
272
+ raise ValueError("Unsupported image format")
273
+ if probe.width * probe.height > MAX_IMAGE_PIXELS:
274
+ raise ValueError("Image dimensions are too large")
275
+ probe.verify()
276
+ with Image.open(io.BytesIO(raw)) as source:
277
+ upright = ImageOps.exif_transpose(source)
278
+ upright.thumbnail((MAX_IMAGE_EDGE, MAX_IMAGE_EDGE), Image.Resampling.LANCZOS)
279
+ if "A" in upright.getbands() or "transparency" in upright.info:
280
+ rgba = upright.convert("RGBA")
281
+ normalized = Image.new("RGB", rgba.size, "white")
282
+ normalized.paste(rgba, mask=rgba.getchannel("A"))
283
+ else:
284
+ normalized = upright.convert("RGB")
285
+ output = io.BytesIO()
286
+ normalized.save(output, format="JPEG", quality=90)
287
+ return "data:image/jpeg;base64," + base64.b64encode(output.getvalue()).decode("ascii")
288
+ except (UnidentifiedImageError, OSError, ValueError, SyntaxError,
289
+ Image.DecompressionBombError, Image.DecompressionBombWarning):
290
+ raise HTTPException(422, "Use a valid, still JPEG, PNG, or WebP image up to 32 megapixels.") from None
291
 
292
 
293
  async def infer(payload: dict) -> dict:
 
318
 
319
  @app.post("/api/decide")
320
  async def decide(payload: DecisionRequest):
321
+ data = payload.model_dump(exclude_none=True)
322
+ if payload.image is not None:
323
+ data["image"] = await asyncio.to_thread(prepare_image, payload.image)
324
+ return await infer(data)
325
 
326
 
327
  @app.post("/api/games/new")
play.html CHANGED
@@ -9,6 +9,9 @@
9
  <link rel="icon" href="/autotrust-mark.svg" type="image/svg+xml">
10
  <style>
11
  :root{color-scheme:dark;--bg:#122E27;--surface:#18362F;--deep:#0E251F;--ivory:#FAF6EC;--gold:#E4C96A;--muted:#A9B9AF;--line:rgba(250,246,236,.14);--soft:rgba(250,246,236,.045);--red:#F3B4A1}*{box-sizing:border-box}body{margin:0;background:var(--bg);color:var(--ivory);font-family:Inter,-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif;font-size:14px;line-height:1.55}button,input,textarea,select{font:inherit}button,a,input,textarea,select{-webkit-tap-highlight-color:transparent}a{color:inherit}button{cursor:pointer}button:disabled{cursor:default;opacity:.42}button:focus-visible,a:focus-visible,input:focus-visible,textarea:focus-visible,select:focus-visible{outline:2px solid var(--gold);outline-offset:4px}button:disabled:hover{transform:none}button{transition:background .15s,border-color .15s}main,.top,footer{max-width:1224px;width:calc(100% - 80px);margin:auto}[hidden]{display:none!important}.top{height:86px;display:flex;align-items:center;justify-content:space-between;border-bottom:1px solid var(--line);gap:25px}.brand{display:flex;gap:10px;align-items:center;text-decoration:none;flex:none}.brand img{height:33px;width:33px}.brand-name{font-family:Georgia,serif;font-size:21px;letter-spacing:-.3px}.nav{display:flex;align-items:center;gap:26px;font-size:12px;color:var(--muted)}.nav a{text-decoration:none}.nav a:hover{color:var(--gold)}.nav .nav-current{color:var(--ivory)}.nav-divider{height:18px;border-left:1px solid var(--line)}.connection{display:flex;align-items:center;gap:8px;font-size:11px;color:var(--muted);white-space:nowrap}.dot{height:6px;width:6px;border-radius:50%;background:#90A197;flex:none}.connection[data-status=ok] .dot{background:var(--gold);box-shadow:0 0 0 4px rgba(228,201,106,.07)}.connection[data-status=unavailable] .dot{background:var(--red)}.connection[data-status=unavailable]{color:var(--red)}.hero{padding:48px 0 32px;display:flex;align-items:flex-end;justify-content:space-between;gap:42px}.eyebrow{font-size:10px;text-transform:uppercase;letter-spacing:.2em;color:var(--gold);margin:0 0 15px;display:flex;gap:12px;align-items:center}.eyebrow:before{content:"";height:1px;width:23px;background:var(--gold)}h1,h2,h3,p{margin-top:0}h1{font-family:Georgia,"Times New Roman",serif;font-weight:400;letter-spacing:-2.2px;line-height:1.05;font-size:clamp(38px,4.5vw,60px);margin:0}h1 em{color:var(--gold);font-weight:400}.hero-copy{max-width:320px;font-size:13px;color:var(--muted);margin:0 0 3px;line-height:1.8}.hero-copy strong{font-weight:500;color:var(--ivory)}.tabbar{display:flex;border-bottom:1px solid var(--line);gap:34px;margin:0 0 26px}.tab{display:flex;gap:10px;align-items:center;border:0;background:none;color:var(--muted);padding:17px 0;position:relative;font-size:13px}.tab .number{font-size:10px;letter-spacing:.04em;opacity:.65}.tab[aria-selected=true]{color:var(--gold)}.tab[aria-selected=true]:after{content:"";height:2px;background:var(--gold);position:absolute;bottom:-1px;left:0;right:0}.tab svg{width:16px;height:16px}.tab-tail{margin:auto 0 auto auto;color:var(--muted);font-size:10px;letter-spacing:.1em;text-transform:uppercase}.lab-grid{display:grid;grid-template-columns:1.07fr 1fr;gap:20px}.panel{border:1px solid var(--line);border-radius:12px;background:var(--surface);overflow:hidden}.panel-head{padding:21px 25px;border-bottom:1px solid var(--line);display:flex;justify-content:space-between;align-items:center;gap:12px}.section-label{font-size:10px;letter-spacing:.14em;text-transform:uppercase;color:var(--muted);display:flex;align-items:center;gap:9px}.section-label span{color:var(--gold);font-family:ui-monospace,monospace}.panel-head .small{font-size:10px;color:var(--muted)}.input-body{padding:22px 25px 24px}.preset-label{font-size:11px;color:var(--muted);margin-bottom:9px}.presets{display:flex;flex-wrap:wrap;gap:7px;margin-bottom:24px}.preset{background:var(--soft);border:1px solid var(--line);border-radius:6px;color:var(--muted);padding:7px 10px;font-size:11px}.preset:hover,.preset[aria-pressed=true]{color:var(--gold);border-color:rgba(228,201,106,.45);background:rgba(228,201,106,.06)}label{display:block;font-size:11px;color:var(--ivory);margin:0 0 8px}.label-line{display:flex;align-items:baseline;justify-content:space-between;gap:10px}.label-line span{color:var(--muted);font-size:10px}.field{margin:0 0 19px}.field:last-of-type{margin-bottom:21px}input,textarea,select{width:100%;border:1px solid rgba(250,246,236,.2);background:var(--deep);color:var(--ivory);border-radius:6px;padding:11px 12px;font-size:12px;line-height:1.65}textarea{resize:vertical;min-height:85px}textarea[readonly]{color:var(--muted)}input::placeholder,textarea::placeholder{color:#91A298}select{padding:10px 12px;appearance:auto}.form-row{display:grid;grid-template-columns:1fr 1.16fr;gap:16px;align-items:start}.helper{font-size:10px;color:var(--muted);line-height:1.6;margin:7px 0 0}.primary,.secondary,.action-button{border-radius:6px;padding:11px 15px;font-size:12px;border:1px solid var(--line);background:var(--soft);color:var(--ivory);display:inline-flex;align-items:center;justify-content:center;gap:8px}.primary{background:var(--gold);color:var(--bg);border-color:var(--gold);font-weight:650}.primary:hover:not(:disabled){background:#EFDA8C}.secondary:hover:not(:disabled),.action-button:hover:not(:disabled){border-color:rgba(228,201,106,.55);background:rgba(228,201,106,.06);color:var(--gold)}.primary svg,.secondary svg{width:14px;height:14px}.run-row{display:flex;align-items:center;justify-content:space-between;gap:16px}.run-row .primary{min-width:146px}.run-caption{font-size:10px;color:var(--muted);text-align:right;line-height:1.6;max-width:150px}.error{padding:12px 14px;background:rgba(243,180,161,.08);border:1px solid rgba(243,180,161,.25);border-radius:6px;color:var(--red);font-size:12px;margin:15px 0 0}.output-panel{display:flex;flex-direction:column;min-width:0}.output-body{padding:28px 25px;flex:1;display:flex;flex-direction:column}.empty-output{display:flex;align-items:center;justify-content:center;flex:1;min-height:280px;flex-direction:column;text-align:center}.signal-mark{height:78px;width:78px;position:relative;display:flex;align-items:center;justify-content:center;margin-bottom:27px}.signal-mark:before,.signal-mark:after{position:absolute;content:"";inset:0;border:1px solid rgba(228,201,106,.25);transform:rotate(45deg);border-radius:12px}.signal-mark:after{inset:14px;border-color:rgba(228,201,106,.5);border-radius:6px}.signal-mark svg{height:25px;width:25px;color:var(--gold)}.empty-output h2{font-family:Georgia,serif;font-size:26px;font-weight:400;letter-spacing:-.5px;margin-bottom:10px}.empty-output p{max-width:275px;font-size:12px;color:var(--muted);line-height:1.8;margin:0}.output-foot{border-top:1px solid var(--line);padding:15px 25px;display:flex;align-items:center;justify-content:space-between;gap:10px;flex-wrap:wrap;font-size:10px;color:var(--muted)}.mono{font-family:ui-monospace,SFMono-Regular,Consolas,monospace}.result-label{font-size:10px;letter-spacing:.12em;text-transform:uppercase;color:var(--muted);margin-bottom:10px}.picked{font-family:Georgia,serif;color:var(--gold);font-size:31px;font-weight:400;letter-spacing:-.6px;line-height:1.3;overflow-wrap:anywhere;margin-bottom:28px}.bar-list{display:flex;flex-direction:column;gap:19px}.bar-row{min-width:0}.bar-top{display:flex;justify-content:space-between;gap:16px;font-size:11px;margin-bottom:7px}.bar-name{overflow-wrap:anywhere;color:var(--muted)}.bar-row.selected .bar-name{color:var(--ivory)}.bar-value{font-family:ui-monospace,monospace;flex:none;color:var(--muted)}.bar-row.selected .bar-value{color:var(--gold)}.bar-track{height:5px;background:rgba(250,246,236,.08);border-radius:3px;overflow:hidden}.bar-fill{height:100%;width:0;background:#718D7F;border-radius:3px;transition:width .35s ease}.bar-row.selected .bar-fill{background:var(--gold)}.result-note{font-size:10px;color:var(--muted);line-height:1.7;margin:25px 0 0}.loading .signal-mark{animation:signal-breathe 1.4s ease-in-out infinite}.loading .signal-mark:after{background:rgba(228,201,106,.08)}.below-lab{padding:23px 3px 0;display:grid;grid-template-columns:repeat(3,1fr);gap:35px}.how-item{font-size:11px;color:var(--muted);line-height:1.75;display:flex;gap:12px}.how-item>span{font-family:Georgia,serif;color:var(--gold);font-size:17px;margin-top:-2px}.how-item p{margin:0}.how-item strong{color:var(--ivory);display:block;font-weight:500;margin-bottom:3px}.arena-toolbar{display:flex;justify-content:space-between;align-items:center;gap:20px;margin:0 0 17px}.scenario-wrap{display:flex;align-items:center;gap:13px}.scenario-wrap label{margin:0;font-size:11px;color:var(--muted)}.scenario-wrap select{min-width:180px;padding:8px 10px}.arena-toolbar .primary{padding:10px 17px}.arena-grid{display:grid;grid-template-columns:minmax(0,1.8fr) minmax(265px,1fr);gap:20px}.game-screen{position:relative;aspect-ratio:4/3;background:#0A1915;display:flex;align-items:center;justify-content:center;border-bottom:1px solid var(--line);overflow:hidden}.game-screen img{width:100%;height:100%;object-fit:contain;image-rendering:pixelated;display:block}.game-placeholder{position:absolute;inset:0;display:flex;align-items:center;justify-content:center;flex-direction:column;text-align:center;padding:32px;background-image:linear-gradient(rgba(228,201,106,.035) 1px,transparent 1px),linear-gradient(90deg,rgba(228,201,106,.035) 1px,transparent 1px);background-size:36px 36px}.game-placeholder svg{width:43px;height:43px;color:var(--gold);opacity:.7;margin-bottom:21px}.game-placeholder h2{font-family:Georgia,serif;font-weight:400;font-size:27px;letter-spacing:-.5px;margin:0 0 9px}.game-placeholder p{font-size:12px;color:var(--muted);max-width:235px;margin:0;line-height:1.8}.frame-badge{position:absolute;top:14px;left:14px;border:1px solid rgba(250,246,236,.2);color:var(--ivory);background:rgba(10,25,21,.85);padding:5px 8px;border-radius:4px;font-size:9px;text-transform:uppercase;letter-spacing:.09em}.metrics{display:grid;grid-template-columns:repeat(5,1fr);padding:17px 10px}.metric{text-align:center;border-right:1px solid var(--line)}.metric:last-child{border:0}.metric span{display:block;font-size:9px;text-transform:uppercase;letter-spacing:.08em;color:var(--muted);margin-bottom:5px}.metric b{font-weight:500;font-size:18px;font-family:ui-monospace,monospace}.arena-sidebar{display:flex;flex-direction:column;gap:17px}.controls-body{padding:19px 20px}.controls-label{font-size:10px;text-transform:uppercase;letter-spacing:.1em;color:var(--muted);margin:0 0 10px}.manual-actions{display:grid;grid-template-columns:1fr 1fr;gap:7px}.action-button{font-size:11px;padding:10px 8px;min-height:39px}.no-actions{font-size:11px;color:var(--muted);margin:0;padding:5px 0 13px}.divider{border:0;border-top:1px solid var(--line);margin:21px 0}.agent-actions{display:grid;grid-template-columns:1fr 1fr;gap:8px}.agent-actions .primary,.agent-actions .secondary{padding:10px 8px;font-size:11px}.stop-button{width:100%;margin-top:9px;border-color:rgba(243,180,161,.4);color:var(--red)}.arena-note{font-size:10px;color:var(--muted);line-height:1.8;margin:13px 0 0}.game-status{font-size:11px;color:var(--muted);margin:16px 0 0;min-height:18px}.game-result{padding:19px 20px}.game-result .picked{font-size:23px;margin-bottom:19px}.game-result .bar-list{gap:12px}.game-result .bar-top{font-size:10px}.game-result .bar-track{height:4px}.game-decision-empty{font-size:12px;line-height:1.8;color:var(--muted);margin:0}.inference-timing{display:flex;align-items:center;justify-content:space-between;gap:16px;padding:14px 16px;margin:0 0 24px;border:1px solid rgba(228,201,106,.3);border-radius:6px;background:rgba(228,201,106,.055)}.inference-timing span{font-size:10px;letter-spacing:.06em;text-transform:uppercase;color:var(--muted)}.inference-timing strong{font-family:ui-monospace,SFMono-Regular,Consolas,monospace;font-size:23px;font-weight:500;white-space:nowrap;color:var(--gold)}.game-result .inference-timing{padding:12px;margin-bottom:20px;gap:10px}.game-result .inference-timing strong{font-size:21px}.game-decision-meta{font-size:10px;color:var(--muted);margin:19px 0 0}.arena-explainer{margin:16px 0 0;color:var(--muted);font-size:11px}.arena-explainer strong{font-weight:500;color:var(--ivory)}footer{display:flex;align-items:center;justify-content:space-between;gap:16px;border-top:1px solid var(--line);margin-top:43px;padding:22px 0 31px;color:var(--muted);font-size:10px}footer a{text-decoration:none;color:var(--gold)}.footer-links{display:flex;gap:20px}.sr-only{position:absolute;width:1px;height:1px;padding:0;margin:-1px;overflow:hidden;clip:rect(0,0,0,0);white-space:nowrap;border:0}@keyframes signal-breathe{50%{opacity:.45;transform:scale(.93)}}@media(min-width:1400px){.hero{padding-top:55px}}@media(max-width:980px){main,.top,footer{width:calc(100% - 48px)}.hero{gap:24px}.hero-copy{max-width:260px;font-size:12px}.nav{gap:19px}.form-row{grid-template-columns:1fr}.lab-grid{grid-template-columns:1.05fr 1fr}.input-body,.output-body{padding:22px 20px}.panel-head{padding:20px}.output-foot{padding:15px 20px}.arena-grid{grid-template-columns:minmax(0,1.6fr) minmax(245px,1fr)}.game-screen{aspect-ratio:1/1}.agent-actions{grid-template-columns:1fr}.below-lab{gap:22px}.nav-divider{display:none}.top>.connection{display:none}}@media(max-width:720px){.hero{display:block;padding-top:36px}.hero-copy{max-width:430px;margin:19px 0 0;font-size:13px}.lab-grid,.arena-grid{grid-template-columns:1fr}.form-row{grid-template-columns:1fr 1.16fr}.empty-output{min-height:245px}.output-panel{min-height:350px}.arena-sidebar{display:grid;grid-template-columns:1fr 1fr;align-items:start}.game-screen{aspect-ratio:4/3}.agent-actions{grid-template-columns:1fr}.below-lab{gap:17px}.how-item{display:block}.how-item>span{display:block;margin:0 0 6px}.tab-tail{font-size:9px}.nav{gap:18px}}@media(max-width:480px){main,.top,footer{width:calc(100% - 32px)}.top{height:73px;gap:12px}.brand-name{font-size:19px}.brand img{width:29px;height:29px}.nav{font-size:11px;gap:15px}.nav .nav-current{display:none}h1{font-size:41px;letter-spacing:-1.7px}.hero{padding:31px 0 22px}.hero-copy{font-size:12px}.tabbar{gap:24px;margin-bottom:20px}.tab-tail{display:none}.tab{font-size:12px}.panel-head{padding:17px 18px}.input-body,.output-body{padding:20px 18px}.form-row{grid-template-columns:1fr}.presets{gap:6px}.preset{padding:7px 9px;font-size:10px}.below-lab{grid-template-columns:1fr;gap:16px;padding-top:22px}.how-item{display:flex;gap:12px}.how-item>span{flex:0 0 15px}.how-item strong{display:inline;margin-right:5px}.scenario-wrap{display:block}.scenario-wrap label{margin-bottom:5px;font-size:10px}.scenario-wrap select{min-width:0;font-size:11px;max-width:185px}.arena-toolbar{gap:10px;align-items:flex-end}.arena-toolbar .primary{font-size:11px;padding:11px 13px;white-space:nowrap}.arena-sidebar{display:flex}.agent-actions{grid-template-columns:1fr 1fr}.metrics{padding:14px 4px}.metric b{font-size:15px}.metric span{font-size:8px;letter-spacing:.03em}footer{align-items:flex-start;flex-direction:column;gap:10px;margin-top:32px}.run-caption{font-size:9px}.output-foot{padding:15px 18px}.picked{font-size:28px}}@media(prefers-reduced-motion:reduce){*{animation:none!important;transition:none!important}}
 
 
 
12
  </style>
13
  </head>
14
  <body>
@@ -18,7 +21,7 @@
18
  <div class="connection" id="connection" data-status="checking" role="status"><span class="dot" aria-hidden="true"></span><span id="connection-text">Checking model…</span></div>
19
  </header>
20
  <main>
21
- <section class="hero" aria-labelledby="page-title"><div><p class="eyebrow">JEV-27B · Live playground</p><h1 id="page-title">A decision. Then <em>action.</em></h1></div><p class="hero-copy">Give JEV a situation and a set of choices.<br><strong>See what it picks. Watch what happens.</strong><br>Real inference, one decision at a time.</p></section>
22
  <div class="tabbar" role="tablist" aria-label="Playground mode">
23
  <button class="tab" id="lab-tab" type="button" role="tab" aria-selected="true" aria-controls="lab" tabindex="0"><span class="number">01</span><svg viewBox="0 0 20 20" fill="none" stroke="currentColor" stroke-width="1.4" aria-hidden="true"><path d="M4 4h12v12H4zM7 7h6M7 10h6M7 13h3"/></svg>Decision lab</button>
24
  <button class="tab" id="arena-tab" type="button" role="tab" aria-selected="false" aria-controls="arena" tabindex="-1"><span class="number">02</span><svg viewBox="0 0 20 20" fill="none" stroke="currentColor" stroke-width="1.4" aria-hidden="true"><path d="M7 4h6l4 4v7l-3 1-3-3H9l-3 3-3-1V8zM7 7v5M4.5 9.5h5M13 8.5h.01M15 11h.01"/></svg>Doom arena</button>
@@ -29,14 +32,16 @@
29
  <div class="panel"><div class="panel-head"><div class="section-label"><span>01</span>Set the situation</div><span class="small">Your input</span></div>
30
  <form id="decision-form" class="input-body">
31
  <div class="preset-label">Start with an example</div><div class="presets" aria-label="Example situations"><button class="preset" type="button" data-preset="support" aria-pressed="true">Support ticket</button><button class="preset" type="button" data-preset="route" aria-pressed="false">Choose a route</button><button class="preset" type="button" data-preset="fact" aria-pressed="false">Factual check</button></div>
32
- <div class="field"><div class="label-line"><label for="state">Situation</label><span>Editable context</span></div><textarea id="state" rows="4" maxlength="6000" required spellcheck="false"></textarea></div>
 
 
33
  <div class="field"><label for="question">What should JEV decide?</label><input id="question" maxlength="1000" required autocomplete="off"></div>
34
  <div class="form-row"><div class="field"><label for="kind">Decision type</label><select id="kind"><option value="choice">Choose an option</option><option value="noul">True or false</option><option value="score">Score from 0 to 5</option></select><p class="helper" id="kind-help">Compare the choices you provide.</p></div><div class="field"><div class="label-line"><label for="options">Options</label><span id="options-label">One per line</span></div><textarea id="options" rows="3" maxlength="8000" required spellcheck="false"></textarea></div></div>
35
  <div class="run-row"><button class="primary" id="run-decision" type="submit">Run JEV <svg viewBox="0 0 18 18" fill="none" stroke="currentColor" stroke-width="1.5" aria-hidden="true"><path d="M3 9h11M10 5l4 4-4 4"/></svg></button><span class="run-caption">Each run calls the<br>connected JEV-27B model.</span></div><p class="error" id="decision-error" role="alert" hidden></p>
36
  </form></div>
37
- <div class="panel output-panel"><div class="panel-head"><div class="section-label"><span>02</span>Read the decision</div><span class="small" id="result-status">Ready when you are</span></div><div class="output-body"><div class="empty-output" id="empty-output"><div class="signal-mark" aria-hidden="true"><svg viewBox="0 0 28 28" fill="none" stroke="currentColor" stroke-width="1.4"><path d="M7 14h14M14 7v14"/><circle cx="14" cy="14" r="10"/></svg></div><h2 id="empty-title">Give a choice some context.</h2><p id="empty-copy">Run an example or write your own situation. JEV’s answer and option probabilities will appear here.</p></div><div id="decision-result" aria-live="polite" hidden><p class="result-label">JEV’s choice</p><h2 class="picked" id="picked-answer"></h2><div class="inference-timing"><span>Model inference time</span><strong id="result-latency">—</strong></div><div class="bar-list" id="probabilities" aria-label="Option probabilities"></div><p class="result-note">Probabilities compare the provided options. They are not a guarantee that an answer is correct.</p></div></div><div class="output-foot"><span class="mono" id="result-model">autotrust/JEV-27B</span><span>Timing reported by the model server</span></div></div>
38
  </div>
39
- <div class="below-lab"><div class="how-item"><span>1</span><p><strong>Describe the situation.</strong>A ticket, a route, a claim — add the details that matter.</p></div><div class="how-item"><span>2</span><p><strong>Define the decision.</strong>Choose an option, check a statement, or assign a score.</p></div><div class="how-item"><span>3</span><p><strong>Inspect the result.</strong>Compare option probabilities. Edit the context and try again.</p></div></div>
40
  </section>
41
  <section id="arena" role="tabpanel" aria-labelledby="arena-tab" tabindex="0" hidden>
42
  <div class="arena-toolbar"><div class="scenario-wrap"><label for="scenario">Choose an arena</label><select id="scenario"><option value="basic">Target practice</option><option value="defend_the_center">Center defense</option></select></div><button class="primary" type="button" id="new-game">Start arena <svg viewBox="0 0 18 18" fill="none" stroke="currentColor" stroke-width="1.5" aria-hidden="true"><path d="M5 3l9 6-9 6z"/></svg></button></div>
@@ -51,6 +56,8 @@
51
  const $=id=>document.getElementById(id);
52
  const presets={support:{kind:'choice',state:'A customer was charged twice for a single subscription renewal. They included two transaction IDs for the same date and amount. Their account is active, and they need help correcting the duplicate charge.',question:'Which team should handle this ticket?',options:['Billing support','Technical support','Sales']},route:{kind:'choice',state:'A delivery must arrive within 25 minutes. Route A takes 18 minutes but crosses a bridge closed for repairs. Route B takes 23 minutes and is open. Route C takes 32 minutes and is open.',question:'Which route should the driver take?',options:['Route A','Route B','Route C']},fact:{kind:'noul',state:'The museum is open Tuesday through Sunday, from 10:00 to 18:00. It is closed every Monday. Today is Monday.',question:'Is the museum open today at 14:00?',options:['false','true']}};
53
  let decisionBusy=false,customOptions='',modelName='autotrust/JEV-27B';
 
 
54
  let sessionId=null,gameBusy=false,gameDone=false,autoPlaying=false,stopRequested=false,actions=[],gameModel=modelName;
55
  const tabs=[$('lab-tab'),$('arena-tab')];
56
  function setTab(index){tabs.forEach((tab,i)=>{tab.setAttribute('aria-selected',String(i===index));tab.tabIndex=i===index?0:-1;$(tab.getAttribute('aria-controls')).hidden=i!==index});if(index!==1&&autoPlaying){stopRequested=true;$('game-status').textContent='Stopping after the current move…'}}
@@ -63,13 +70,101 @@ async function checkHealth(){try{const data=await api('/api/health');const onlin
63
  function formatTime(seconds){const n=seconds;return typeof n==='number'&&Number.isFinite(n)&&n>=0?n<1?`${Math.round(n*1000)} ms`:`${n.toFixed(2)} s`:'—'}
64
  function decisionData(data,fallbackOptions=[]){const optionList=Array.isArray(data.options)?data.options:fallbackOptions;const labels=optionList.map(option=>typeof option==='object'?String(option.label??option.id??''):String(option));let probabilities=data.probabilities;if(!Array.isArray(probabilities)&&probabilities&&typeof probabilities==='object')probabilities=optionList.map(option=>probabilities[typeof option==='object'?option.id:option]);if(!Array.isArray(probabilities))probabilities=[];const rawChoice=data.choice??data.answer??data.selected;let selectedIndex=-1;if(typeof rawChoice==='number'&&Number.isInteger(rawChoice)&&rawChoice>=0&&rawChoice<labels.length)selectedIndex=rawChoice;else selectedIndex=optionList.findIndex((option,index)=>String(typeof option==='object'?option.id:option)===String(rawChoice)||labels[index]===String(rawChoice));if(selectedIndex<0&&Number.isInteger(data.choice_index))selectedIndex=data.choice_index;const choiceLabel=selectedIndex>=0&&selectedIndex<labels.length?labels[selectedIndex]:rawChoice==null?'No decision returned':String(rawChoice);return {labels,probabilities,selectedIndex,choiceLabel}}
65
  function renderBars(container,result){container.replaceChildren();result.labels.forEach((label,index)=>{const row=document.createElement('div');row.className='bar-row'+(index===result.selectedIndex?' selected':'');const top=document.createElement('div');top.className='bar-top';const name=document.createElement('span');name.className='bar-name';name.textContent=label;const value=document.createElement('span');value.className='bar-value';const p=Number(result.probabilities[index]),valid=Number.isFinite(p)&&p>=0&&p<=1;value.textContent=valid?`${(p*100).toFixed(1)}%`:'—';const track=document.createElement('div');track.className='bar-track';const fill=document.createElement('div');fill.className='bar-fill';fill.style.width=valid?`${p*100}%`:'0%';track.append(fill);top.append(name,value);row.append(top,track);container.append(row)})}
66
- function resetDecision(){showError('decision-error','');$('decision-result').hidden=true;$('empty-output').hidden=false;$('empty-output').classList.remove('loading');$('empty-title').textContent='Give a choice some context.';$('empty-copy').textContent='Run an example or write your own situation. JEV’s answer and option probabilities will appear here.';$('result-status').textContent='Ready when you are';$('result-latency').textContent='—'}
 
67
  function updateKind(){const kind=$('kind').value;$('options').readOnly=kind!=='choice';$('options-label').textContent=kind==='choice'?'One per line':'Fixed choices';$('kind-help').textContent=kind==='choice'?'Compare the choices you provide.':kind==='noul'?'Judge whether the statement is true.':'Use your question to define what the score means.';if(kind==='noul')$('options').value='false\ntrue';else if(kind==='score')$('options').value='0\n1\n2\n3\n4\n5';else $('options').value=customOptions}
68
- function applyPreset(name){if(decisionBusy)return;const p=presets[name];$('state').value=p.state;$('question').value=p.question;$('kind').value=p.kind;if(p.kind==='choice')customOptions=p.options.join('\n');updateKind();document.querySelectorAll('.preset').forEach(button=>button.setAttribute('aria-pressed',String(button.dataset.preset===name)));resetDecision()}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
69
  document.querySelectorAll('.preset').forEach(button=>button.addEventListener('click',()=>applyPreset(button.dataset.preset)));
70
  $('kind').addEventListener('change',()=>{updateKind();resetDecision()});$('options').addEventListener('input',()=>{if($('kind').value==='choice')customOptions=$('options').value});['state','question','options','kind'].forEach(id=>$(id).addEventListener('input',()=>document.querySelectorAll('.preset').forEach(button=>button.setAttribute('aria-pressed','false'))));
71
- function setDecisionBusy(busy){decisionBusy=busy;document.querySelectorAll('#decision-form button,#decision-form input,#decision-form textarea,#decision-form select').forEach(element=>element.disabled=busy);$('run-decision').textContent=busy?'JEV is deciding…':'Run JEV →';$('decision-form').setAttribute('aria-busy',String(busy))}
72
- $('decision-form').addEventListener('submit',async event=>{event.preventDefault();if(decisionBusy)return;const kind=$('kind').value,state=$('state').value.trim(),question=$('question').value.trim(),options=$('options').value.split('\n').map(option=>option.trim()).filter(Boolean);if(!state||!question){showError('decision-error','Add a situation and a question first.');return}if(options.length<2||options.length>16){showError('decision-error','Provide between 2 and 16 options, one per line.');return}if(options.some(option=>option.length>180)){showError('decision-error','Keep each option to 180 characters or fewer.');return}if(new Set(options).size!==options.length){showError('decision-error','Give each option a different name.');return}showError('decision-error','');setDecisionBusy(true);$('decision-result').hidden=true;$('empty-output').hidden=false;$('empty-output').classList.add('loading');$('empty-title').textContent='Weighing the options.';$('empty-copy').textContent='JEV is reading your situation and comparing the available choices.';$('result-status').textContent='Inference in progress';$('result-latency').textContent='—';try{const data=await api('/api/decide',{kind,state,question,options});const result=decisionData(data,options);if(!result.labels.length||result.choiceLabel==='No decision returned')throw new Error('The model did not return a valid decision. Please try again.');connected(data.model);$('picked-answer').textContent=result.choiceLabel;renderBars($('probabilities'),result);$('empty-output').hidden=true;$('decision-result').hidden=false;$('result-status').textContent='Decision received';$('result-latency').textContent=formatTime(data.elapsed_seconds);$('result-model').textContent=typeof data.model==='string'?data.model:modelName}catch(error){showError('decision-error',error.message);$('empty-title').textContent='The decision did not arrive.';$('empty-copy').textContent='Your inputs are still here. Try again when the service is ready.';$('result-status').textContent='Request unsuccessful';checkHealth()}finally{$('empty-output').classList.remove('loading');setDecisionBusy(false)}});
73
  function updateGameControls(){const disabled=gameBusy||autoPlaying||!sessionId||gameDone;$('new-game').disabled=gameBusy||autoPlaying;$('scenario').disabled=gameBusy||autoPlaying;$('jev-move').disabled=disabled;$('auto-play').disabled=disabled;document.querySelectorAll('#manual-actions button').forEach(button=>button.disabled=disabled);$('stop-auto').hidden=!autoPlaying;$('stop-auto').disabled=stopRequested;$('auto-play').textContent=autoPlaying?'Auto-play running…':'Auto-play · 10 steps';$('new-game').textContent=sessionId?'Reset arena ↻':'Start arena ▷'}
74
  function renderActions(nextActions){if(!Array.isArray(nextActions))return;actions=nextActions;$('manual-actions').replaceChildren();for(const action of actions){if(!action||typeof action.id!=='string')continue;const button=document.createElement('button');button.type='button';button.className='action-button';button.textContent=String(action.label||action.id);button.title=String(action.description||action.label||action.id);button.addEventListener('click',()=>runGameStep('human',action.id));$('manual-actions').append(button)}$('no-actions').hidden=actions.length>0;updateGameControls()}
75
  function updateSnapshot(data){if(typeof data.session_id==='string')sessionId=data.session_id;gameDone=Boolean(data.done);if(typeof data.frame==='string'&&data.frame){const frame=data.frame.startsWith('data:image/png;base64,')?data.frame:`data:image/png;base64,${data.frame}`;$('game-frame').src=frame;$('game-frame').hidden=false;$('game-placeholder').hidden=true;$('game-frame').alt=`Doom ${data.scenario==='defend_the_center'?'center defense':'target practice'}; step ${data.metrics?.steps??'unknown'}`}if(data.actions)renderActions(data.actions);for(const key of ['health','ammo','kills','reward','steps']){const value=data.metrics?.[key];$('metric-'+key).textContent=typeof value==='number'&&Number.isFinite(value)?Number.isInteger(value)?String(value):value.toFixed(1):'—'}$('frame-badge').hidden=!sessionId;$('frame-badge').textContent=gameDone?'Episode complete':'Simulation paused';if(data.decision){const decision=data.decision;connected(decision.model);const results=decisionData(decision,actions.map(action=>action.id));const labels=results.labels.map(label=>actions.find(action=>action.id===label||action.description===label)?.label||label);const selectedId=results.choiceLabel;results.labels=labels;results.choiceLabel=actions.find(action=>action.id===selectedId||action.description===selectedId)?.label||selectedId;$('game-picked').textContent=results.choiceLabel;renderBars($('game-probabilities'),results);$('game-decision-empty').hidden=true;$('game-decision').hidden=false;gameModel=typeof decision.model==='string'?decision.model:typeof data.model==='string'?data.model:modelName;$('game-latency').textContent=formatTime(decision.elapsed_seconds);$('game-decision-meta').textContent=`${gameModel} · Timing reported by the model server`;}updateGameControls()}
 
9
  <link rel="icon" href="/autotrust-mark.svg" type="image/svg+xml">
10
  <style>
11
  :root{color-scheme:dark;--bg:#122E27;--surface:#18362F;--deep:#0E251F;--ivory:#FAF6EC;--gold:#E4C96A;--muted:#A9B9AF;--line:rgba(250,246,236,.14);--soft:rgba(250,246,236,.045);--red:#F3B4A1}*{box-sizing:border-box}body{margin:0;background:var(--bg);color:var(--ivory);font-family:Inter,-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif;font-size:14px;line-height:1.55}button,input,textarea,select{font:inherit}button,a,input,textarea,select{-webkit-tap-highlight-color:transparent}a{color:inherit}button{cursor:pointer}button:disabled{cursor:default;opacity:.42}button:focus-visible,a:focus-visible,input:focus-visible,textarea:focus-visible,select:focus-visible{outline:2px solid var(--gold);outline-offset:4px}button:disabled:hover{transform:none}button{transition:background .15s,border-color .15s}main,.top,footer{max-width:1224px;width:calc(100% - 80px);margin:auto}[hidden]{display:none!important}.top{height:86px;display:flex;align-items:center;justify-content:space-between;border-bottom:1px solid var(--line);gap:25px}.brand{display:flex;gap:10px;align-items:center;text-decoration:none;flex:none}.brand img{height:33px;width:33px}.brand-name{font-family:Georgia,serif;font-size:21px;letter-spacing:-.3px}.nav{display:flex;align-items:center;gap:26px;font-size:12px;color:var(--muted)}.nav a{text-decoration:none}.nav a:hover{color:var(--gold)}.nav .nav-current{color:var(--ivory)}.nav-divider{height:18px;border-left:1px solid var(--line)}.connection{display:flex;align-items:center;gap:8px;font-size:11px;color:var(--muted);white-space:nowrap}.dot{height:6px;width:6px;border-radius:50%;background:#90A197;flex:none}.connection[data-status=ok] .dot{background:var(--gold);box-shadow:0 0 0 4px rgba(228,201,106,.07)}.connection[data-status=unavailable] .dot{background:var(--red)}.connection[data-status=unavailable]{color:var(--red)}.hero{padding:48px 0 32px;display:flex;align-items:flex-end;justify-content:space-between;gap:42px}.eyebrow{font-size:10px;text-transform:uppercase;letter-spacing:.2em;color:var(--gold);margin:0 0 15px;display:flex;gap:12px;align-items:center}.eyebrow:before{content:"";height:1px;width:23px;background:var(--gold)}h1,h2,h3,p{margin-top:0}h1{font-family:Georgia,"Times New Roman",serif;font-weight:400;letter-spacing:-2.2px;line-height:1.05;font-size:clamp(38px,4.5vw,60px);margin:0}h1 em{color:var(--gold);font-weight:400}.hero-copy{max-width:320px;font-size:13px;color:var(--muted);margin:0 0 3px;line-height:1.8}.hero-copy strong{font-weight:500;color:var(--ivory)}.tabbar{display:flex;border-bottom:1px solid var(--line);gap:34px;margin:0 0 26px}.tab{display:flex;gap:10px;align-items:center;border:0;background:none;color:var(--muted);padding:17px 0;position:relative;font-size:13px}.tab .number{font-size:10px;letter-spacing:.04em;opacity:.65}.tab[aria-selected=true]{color:var(--gold)}.tab[aria-selected=true]:after{content:"";height:2px;background:var(--gold);position:absolute;bottom:-1px;left:0;right:0}.tab svg{width:16px;height:16px}.tab-tail{margin:auto 0 auto auto;color:var(--muted);font-size:10px;letter-spacing:.1em;text-transform:uppercase}.lab-grid{display:grid;grid-template-columns:1.07fr 1fr;gap:20px}.panel{border:1px solid var(--line);border-radius:12px;background:var(--surface);overflow:hidden}.panel-head{padding:21px 25px;border-bottom:1px solid var(--line);display:flex;justify-content:space-between;align-items:center;gap:12px}.section-label{font-size:10px;letter-spacing:.14em;text-transform:uppercase;color:var(--muted);display:flex;align-items:center;gap:9px}.section-label span{color:var(--gold);font-family:ui-monospace,monospace}.panel-head .small{font-size:10px;color:var(--muted)}.input-body{padding:22px 25px 24px}.preset-label{font-size:11px;color:var(--muted);margin-bottom:9px}.presets{display:flex;flex-wrap:wrap;gap:7px;margin-bottom:24px}.preset{background:var(--soft);border:1px solid var(--line);border-radius:6px;color:var(--muted);padding:7px 10px;font-size:11px}.preset:hover,.preset[aria-pressed=true]{color:var(--gold);border-color:rgba(228,201,106,.45);background:rgba(228,201,106,.06)}label{display:block;font-size:11px;color:var(--ivory);margin:0 0 8px}.label-line{display:flex;align-items:baseline;justify-content:space-between;gap:10px}.label-line span{color:var(--muted);font-size:10px}.field{margin:0 0 19px}.field:last-of-type{margin-bottom:21px}input,textarea,select{width:100%;border:1px solid rgba(250,246,236,.2);background:var(--deep);color:var(--ivory);border-radius:6px;padding:11px 12px;font-size:12px;line-height:1.65}textarea{resize:vertical;min-height:85px}textarea[readonly]{color:var(--muted)}input::placeholder,textarea::placeholder{color:#91A298}select{padding:10px 12px;appearance:auto}.form-row{display:grid;grid-template-columns:1fr 1.16fr;gap:16px;align-items:start}.helper{font-size:10px;color:var(--muted);line-height:1.6;margin:7px 0 0}.primary,.secondary,.action-button{border-radius:6px;padding:11px 15px;font-size:12px;border:1px solid var(--line);background:var(--soft);color:var(--ivory);display:inline-flex;align-items:center;justify-content:center;gap:8px}.primary{background:var(--gold);color:var(--bg);border-color:var(--gold);font-weight:650}.primary:hover:not(:disabled){background:#EFDA8C}.secondary:hover:not(:disabled),.action-button:hover:not(:disabled){border-color:rgba(228,201,106,.55);background:rgba(228,201,106,.06);color:var(--gold)}.primary svg,.secondary svg{width:14px;height:14px}.run-row{display:flex;align-items:center;justify-content:space-between;gap:16px}.run-row .primary{min-width:146px}.run-caption{font-size:10px;color:var(--muted);text-align:right;line-height:1.6;max-width:150px}.error{padding:12px 14px;background:rgba(243,180,161,.08);border:1px solid rgba(243,180,161,.25);border-radius:6px;color:var(--red);font-size:12px;margin:15px 0 0}.output-panel{display:flex;flex-direction:column;min-width:0}.output-body{padding:28px 25px;flex:1;display:flex;flex-direction:column}.empty-output{display:flex;align-items:center;justify-content:center;flex:1;min-height:280px;flex-direction:column;text-align:center}.signal-mark{height:78px;width:78px;position:relative;display:flex;align-items:center;justify-content:center;margin-bottom:27px}.signal-mark:before,.signal-mark:after{position:absolute;content:"";inset:0;border:1px solid rgba(228,201,106,.25);transform:rotate(45deg);border-radius:12px}.signal-mark:after{inset:14px;border-color:rgba(228,201,106,.5);border-radius:6px}.signal-mark svg{height:25px;width:25px;color:var(--gold)}.empty-output h2{font-family:Georgia,serif;font-size:26px;font-weight:400;letter-spacing:-.5px;margin-bottom:10px}.empty-output p{max-width:275px;font-size:12px;color:var(--muted);line-height:1.8;margin:0}.output-foot{border-top:1px solid var(--line);padding:15px 25px;display:flex;align-items:center;justify-content:space-between;gap:10px;flex-wrap:wrap;font-size:10px;color:var(--muted)}.mono{font-family:ui-monospace,SFMono-Regular,Consolas,monospace}.result-label{font-size:10px;letter-spacing:.12em;text-transform:uppercase;color:var(--muted);margin-bottom:10px}.picked{font-family:Georgia,serif;color:var(--gold);font-size:31px;font-weight:400;letter-spacing:-.6px;line-height:1.3;overflow-wrap:anywhere;margin-bottom:28px}.bar-list{display:flex;flex-direction:column;gap:19px}.bar-row{min-width:0}.bar-top{display:flex;justify-content:space-between;gap:16px;font-size:11px;margin-bottom:7px}.bar-name{overflow-wrap:anywhere;color:var(--muted)}.bar-row.selected .bar-name{color:var(--ivory)}.bar-value{font-family:ui-monospace,monospace;flex:none;color:var(--muted)}.bar-row.selected .bar-value{color:var(--gold)}.bar-track{height:5px;background:rgba(250,246,236,.08);border-radius:3px;overflow:hidden}.bar-fill{height:100%;width:0;background:#718D7F;border-radius:3px;transition:width .35s ease}.bar-row.selected .bar-fill{background:var(--gold)}.result-note{font-size:10px;color:var(--muted);line-height:1.7;margin:25px 0 0}.loading .signal-mark{animation:signal-breathe 1.4s ease-in-out infinite}.loading .signal-mark:after{background:rgba(228,201,106,.08)}.below-lab{padding:23px 3px 0;display:grid;grid-template-columns:repeat(3,1fr);gap:35px}.how-item{font-size:11px;color:var(--muted);line-height:1.75;display:flex;gap:12px}.how-item>span{font-family:Georgia,serif;color:var(--gold);font-size:17px;margin-top:-2px}.how-item p{margin:0}.how-item strong{color:var(--ivory);display:block;font-weight:500;margin-bottom:3px}.arena-toolbar{display:flex;justify-content:space-between;align-items:center;gap:20px;margin:0 0 17px}.scenario-wrap{display:flex;align-items:center;gap:13px}.scenario-wrap label{margin:0;font-size:11px;color:var(--muted)}.scenario-wrap select{min-width:180px;padding:8px 10px}.arena-toolbar .primary{padding:10px 17px}.arena-grid{display:grid;grid-template-columns:minmax(0,1.8fr) minmax(265px,1fr);gap:20px}.game-screen{position:relative;aspect-ratio:4/3;background:#0A1915;display:flex;align-items:center;justify-content:center;border-bottom:1px solid var(--line);overflow:hidden}.game-screen img{width:100%;height:100%;object-fit:contain;image-rendering:pixelated;display:block}.game-placeholder{position:absolute;inset:0;display:flex;align-items:center;justify-content:center;flex-direction:column;text-align:center;padding:32px;background-image:linear-gradient(rgba(228,201,106,.035) 1px,transparent 1px),linear-gradient(90deg,rgba(228,201,106,.035) 1px,transparent 1px);background-size:36px 36px}.game-placeholder svg{width:43px;height:43px;color:var(--gold);opacity:.7;margin-bottom:21px}.game-placeholder h2{font-family:Georgia,serif;font-weight:400;font-size:27px;letter-spacing:-.5px;margin:0 0 9px}.game-placeholder p{font-size:12px;color:var(--muted);max-width:235px;margin:0;line-height:1.8}.frame-badge{position:absolute;top:14px;left:14px;border:1px solid rgba(250,246,236,.2);color:var(--ivory);background:rgba(10,25,21,.85);padding:5px 8px;border-radius:4px;font-size:9px;text-transform:uppercase;letter-spacing:.09em}.metrics{display:grid;grid-template-columns:repeat(5,1fr);padding:17px 10px}.metric{text-align:center;border-right:1px solid var(--line)}.metric:last-child{border:0}.metric span{display:block;font-size:9px;text-transform:uppercase;letter-spacing:.08em;color:var(--muted);margin-bottom:5px}.metric b{font-weight:500;font-size:18px;font-family:ui-monospace,monospace}.arena-sidebar{display:flex;flex-direction:column;gap:17px}.controls-body{padding:19px 20px}.controls-label{font-size:10px;text-transform:uppercase;letter-spacing:.1em;color:var(--muted);margin:0 0 10px}.manual-actions{display:grid;grid-template-columns:1fr 1fr;gap:7px}.action-button{font-size:11px;padding:10px 8px;min-height:39px}.no-actions{font-size:11px;color:var(--muted);margin:0;padding:5px 0 13px}.divider{border:0;border-top:1px solid var(--line);margin:21px 0}.agent-actions{display:grid;grid-template-columns:1fr 1fr;gap:8px}.agent-actions .primary,.agent-actions .secondary{padding:10px 8px;font-size:11px}.stop-button{width:100%;margin-top:9px;border-color:rgba(243,180,161,.4);color:var(--red)}.arena-note{font-size:10px;color:var(--muted);line-height:1.8;margin:13px 0 0}.game-status{font-size:11px;color:var(--muted);margin:16px 0 0;min-height:18px}.game-result{padding:19px 20px}.game-result .picked{font-size:23px;margin-bottom:19px}.game-result .bar-list{gap:12px}.game-result .bar-top{font-size:10px}.game-result .bar-track{height:4px}.game-decision-empty{font-size:12px;line-height:1.8;color:var(--muted);margin:0}.inference-timing{display:flex;align-items:center;justify-content:space-between;gap:16px;padding:14px 16px;margin:0 0 24px;border:1px solid rgba(228,201,106,.3);border-radius:6px;background:rgba(228,201,106,.055)}.inference-timing span{font-size:10px;letter-spacing:.06em;text-transform:uppercase;color:var(--muted)}.inference-timing strong{font-family:ui-monospace,SFMono-Regular,Consolas,monospace;font-size:23px;font-weight:500;white-space:nowrap;color:var(--gold)}.game-result .inference-timing{padding:12px;margin-bottom:20px;gap:10px}.game-result .inference-timing strong{font-size:21px}.game-decision-meta{font-size:10px;color:var(--muted);margin:19px 0 0}.arena-explainer{margin:16px 0 0;color:var(--muted);font-size:11px}.arena-explainer strong{font-weight:500;color:var(--ivory)}footer{display:flex;align-items:center;justify-content:space-between;gap:16px;border-top:1px solid var(--line);margin-top:43px;padding:22px 0 31px;color:var(--muted);font-size:10px}footer a{text-decoration:none;color:var(--gold)}.footer-links{display:flex;gap:20px}.sr-only{position:absolute;width:1px;height:1px;padding:0;margin:-1px;overflow:hidden;clip:rect(0,0,0,0);white-space:nowrap;border:0}@keyframes signal-breathe{50%{opacity:.45;transform:scale(.93)}}@media(min-width:1400px){.hero{padding-top:55px}}@media(max-width:980px){main,.top,footer{width:calc(100% - 48px)}.hero{gap:24px}.hero-copy{max-width:260px;font-size:12px}.nav{gap:19px}.form-row{grid-template-columns:1fr}.lab-grid{grid-template-columns:1.05fr 1fr}.input-body,.output-body{padding:22px 20px}.panel-head{padding:20px}.output-foot{padding:15px 20px}.arena-grid{grid-template-columns:minmax(0,1.6fr) minmax(245px,1fr)}.game-screen{aspect-ratio:1/1}.agent-actions{grid-template-columns:1fr}.below-lab{gap:22px}.nav-divider{display:none}.top>.connection{display:none}}@media(max-width:720px){.hero{display:block;padding-top:36px}.hero-copy{max-width:430px;margin:19px 0 0;font-size:13px}.lab-grid,.arena-grid{grid-template-columns:1fr}.form-row{grid-template-columns:1fr 1.16fr}.empty-output{min-height:245px}.output-panel{min-height:350px}.arena-sidebar{display:grid;grid-template-columns:1fr 1fr;align-items:start}.game-screen{aspect-ratio:4/3}.agent-actions{grid-template-columns:1fr}.below-lab{gap:17px}.how-item{display:block}.how-item>span{display:block;margin:0 0 6px}.tab-tail{font-size:9px}.nav{gap:18px}}@media(max-width:480px){main,.top,footer{width:calc(100% - 32px)}.top{height:73px;gap:12px}.brand-name{font-size:19px}.brand img{width:29px;height:29px}.nav{font-size:11px;gap:15px}.nav .nav-current{display:none}h1{font-size:41px;letter-spacing:-1.7px}.hero{padding:31px 0 22px}.hero-copy{font-size:12px}.tabbar{gap:24px;margin-bottom:20px}.tab-tail{display:none}.tab{font-size:12px}.panel-head{padding:17px 18px}.input-body,.output-body{padding:20px 18px}.form-row{grid-template-columns:1fr}.presets{gap:6px}.preset{padding:7px 9px;font-size:10px}.below-lab{grid-template-columns:1fr;gap:16px;padding-top:22px}.how-item{display:flex;gap:12px}.how-item>span{flex:0 0 15px}.how-item strong{display:inline;margin-right:5px}.scenario-wrap{display:block}.scenario-wrap label{margin-bottom:5px;font-size:10px}.scenario-wrap select{min-width:0;font-size:11px;max-width:185px}.arena-toolbar{gap:10px;align-items:flex-end}.arena-toolbar .primary{font-size:11px;padding:11px 13px;white-space:nowrap}.arena-sidebar{display:flex}.agent-actions{grid-template-columns:1fr 1fr}.metrics{padding:14px 4px}.metric b{font-size:15px}.metric span{font-size:8px;letter-spacing:.03em}footer{align-items:flex-start;flex-direction:column;gap:10px;margin-top:32px}.run-caption{font-size:9px}.output-foot{padding:15px 18px}.picked{font-size:28px}}@media(prefers-reduced-motion:reduce){*{animation:none!important;transition:none!important}}
12
+
13
+ /* One optional image, kept in page memory until a decision is submitted. */
14
+ .vision-details{margin:-12px 0 24px;padding:0 2px 14px;border-bottom:1px solid var(--line)}.vision-timing{display:flex;align-items:center;justify-content:space-between;gap:15px;font-size:11px;color:var(--muted)}.vision-timing strong{font-family:ui-monospace,SFMono-Regular,Consolas,monospace;font-size:12px;font-weight:500;color:var(--ivory);white-space:nowrap}.vision-model{font-size:9px;color:var(--muted);margin:5px 0 0;overflow-wrap:anywhere}.image-field{margin-top:15px}.image-drop{position:relative;border:1px dashed rgba(250,246,236,.28);border-radius:7px;background:var(--deep);transition:border-color .15s,background .15s}.image-drop:focus-within{outline:2px solid var(--gold);outline-offset:4px}.image-drop.is-dragging{border-color:var(--gold);background:rgba(228,201,106,.08)}.image-drop[aria-disabled=true]{opacity:.5}.image-picker{display:flex;align-items:center;justify-content:center;gap:11px;min-height:83px;padding:15px 17px;margin:0;cursor:pointer;color:var(--muted);font-size:11px;line-height:1.6;text-align:left}.image-picker svg{width:24px;height:24px;flex:none;color:var(--gold)}.image-picker strong{font-weight:500;color:var(--gold)}.image-picker small{display:block;font-size:10px;color:var(--muted);margin-top:2px}.image-drop[aria-disabled=true] .image-picker{cursor:default}.image-preview{display:flex;align-items:center;gap:13px;padding:13px;min-width:0}.image-thumbnail{display:block;flex:0 0 74px;width:74px;height:74px;object-fit:contain;border-radius:4px;background:rgba(250,246,236,.04);border:1px solid var(--line)}.image-details{flex:1;min-width:0}.image-name{color:var(--ivory);font-size:11px;line-height:1.5;overflow-wrap:anywhere;margin:0 0 5px}.image-meta{font-size:10px;color:var(--muted);line-height:1.5;margin:0}.image-remove{border:1px solid var(--line);border-radius:5px;background:transparent;color:var(--muted);font-size:10px;padding:6px 9px;margin-top:9px}.image-remove:hover:not(:disabled){color:var(--ivory);border-color:var(--ivory)}.image-status{min-height:0;font-size:10px;color:var(--gold);margin:7px 0 0;line-height:1.6}.image-status:empty{display:none}.image-error{margin-top:9px;padding:10px 12px;font-size:11px}@media(max-width:480px){.image-picker{padding:14px 12px;gap:10px}.image-preview{padding:11px;gap:11px}.image-thumbnail{width:64px;height:64px;flex-basis:64px}}
15
  </style>
16
  </head>
17
  <body>
 
21
  <div class="connection" id="connection" data-status="checking" role="status"><span class="dot" aria-hidden="true"></span><span id="connection-text">Checking model…</span></div>
22
  </header>
23
  <main>
24
+ <section class="hero" aria-labelledby="page-title"><div><p class="eyebrow">JEV-27B · Live playground</p><h1 id="page-title">A decision. Then <em>action.</em></h1></div><p class="hero-copy">Give JEV text or an image, and a set of choices.<br><strong>See what it picks. Watch what happens.</strong><br>Real inference, one decision at a time.</p></section>
25
  <div class="tabbar" role="tablist" aria-label="Playground mode">
26
  <button class="tab" id="lab-tab" type="button" role="tab" aria-selected="true" aria-controls="lab" tabindex="0"><span class="number">01</span><svg viewBox="0 0 20 20" fill="none" stroke="currentColor" stroke-width="1.4" aria-hidden="true"><path d="M4 4h12v12H4zM7 7h6M7 10h6M7 13h3"/></svg>Decision lab</button>
27
  <button class="tab" id="arena-tab" type="button" role="tab" aria-selected="false" aria-controls="arena" tabindex="-1"><span class="number">02</span><svg viewBox="0 0 20 20" fill="none" stroke="currentColor" stroke-width="1.4" aria-hidden="true"><path d="M7 4h6l4 4v7l-3 1-3-3H9l-3 3-3-1V8zM7 7v5M4.5 9.5h5M13 8.5h.01M15 11h.01"/></svg>Doom arena</button>
 
32
  <div class="panel"><div class="panel-head"><div class="section-label"><span>01</span>Set the situation</div><span class="small">Your input</span></div>
33
  <form id="decision-form" class="input-body">
34
  <div class="preset-label">Start with an example</div><div class="presets" aria-label="Example situations"><button class="preset" type="button" data-preset="support" aria-pressed="true">Support ticket</button><button class="preset" type="button" data-preset="route" aria-pressed="false">Choose a route</button><button class="preset" type="button" data-preset="fact" aria-pressed="false">Factual check</button></div>
35
+ <div class="field"><div class="label-line"><label for="state">Situation</label><span id="state-hint">Text or image context</span></div><textarea id="state" rows="4" maxlength="6000" required spellcheck="false" placeholder="Describe the situation, or attach an image below." aria-describedby="state-hint"></textarea>
36
+ <div class="image-field"><div class="label-line"><label for="image-input">Image</label><span>Optional · one image</span></div><div class="image-drop" id="image-drop" role="group" aria-label="Image attachment" aria-disabled="false"><input id="image-input" class="sr-only" type="file" accept="image/jpeg,image/png,image/webp" aria-label="Choose an image" aria-describedby="image-help image-status image-error"><label class="image-picker" id="image-picker" for="image-input"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.4" aria-hidden="true"><rect x="3" y="3" width="18" height="18" rx="3"/><circle cx="8.5" cy="8.5" r="1.5"/><path d="m4 17 5-5 4 4 3-3 5 5"/></svg><span><strong>Choose an image</strong> or drop it here<small>Add a photo, screenshot, or visual question.</small></span></label><div class="image-preview" id="image-preview" hidden><img class="image-thumbnail" id="image-thumbnail" alt="Preview of the attached image"><div class="image-details"><p class="image-name" id="image-name"></p><p class="image-meta" id="image-meta"></p><button class="image-remove" id="remove-image" type="button" aria-label="Remove attached image">Remove image</button></div></div></div><p class="helper" id="image-help">JPEG, PNG or WebP · up to 5 MiB. Text is optional with an image. Images are read by a vision model, then JEV makes the decision.</p><p class="image-status" id="image-status" role="status" aria-live="polite"></p><p class="error image-error" id="image-error" role="alert" hidden></p></div>
37
+ </div>
38
  <div class="field"><label for="question">What should JEV decide?</label><input id="question" maxlength="1000" required autocomplete="off"></div>
39
  <div class="form-row"><div class="field"><label for="kind">Decision type</label><select id="kind"><option value="choice">Choose an option</option><option value="noul">True or false</option><option value="score">Score from 0 to 5</option></select><p class="helper" id="kind-help">Compare the choices you provide.</p></div><div class="field"><div class="label-line"><label for="options">Options</label><span id="options-label">One per line</span></div><textarea id="options" rows="3" maxlength="8000" required spellcheck="false"></textarea></div></div>
40
  <div class="run-row"><button class="primary" id="run-decision" type="submit">Run JEV <svg viewBox="0 0 18 18" fill="none" stroke="currentColor" stroke-width="1.5" aria-hidden="true"><path d="M3 9h11M10 5l4 4-4 4"/></svg></button><span class="run-caption">Each run calls the<br>connected JEV-27B model.</span></div><p class="error" id="decision-error" role="alert" hidden></p>
41
  </form></div>
42
+ <div class="panel output-panel"><div class="panel-head"><div class="section-label"><span>02</span>Read the decision</div><span class="small" id="result-status">Ready when you are</span></div><div class="output-body"><div class="empty-output" id="empty-output"><div class="signal-mark" aria-hidden="true"><svg viewBox="0 0 28 28" fill="none" stroke="currentColor" stroke-width="1.4"><path d="M7 14h14M14 7v14"/><circle cx="14" cy="14" r="10"/></svg></div><h2 id="empty-title">Give a choice some context.</h2><p id="empty-copy">Run an example or write your own situation. JEV’s answer and option probabilities will appear here.</p></div><div id="decision-result" aria-live="polite" hidden><p class="result-label">JEV’s choice</p><h2 class="picked" id="picked-answer"></h2><div class="inference-timing"><span>JEV inference time</span><strong id="result-latency">—</strong></div><div class="vision-details" id="vision-details" hidden><div class="vision-timing"><span>Image understanding</span><strong id="vision-latency">—</strong></div><p class="vision-model mono" id="vision-model"></p></div><div class="bar-list" id="probabilities" aria-label="Option probabilities"></div><p class="result-note">Probabilities compare the provided options. They are not a guarantee that an answer is correct.</p></div></div><div class="output-foot"><span class="mono" id="result-model">autotrust/JEV-27B</span><span>Timing reported by the model server</span></div></div>
43
  </div>
44
+ <div class="below-lab"><div class="how-item"><span>1</span><p><strong>Describe the situation.</strong>Write the context or attach an image. Add the details that matter.</p></div><div class="how-item"><span>2</span><p><strong>Define the decision.</strong>Choose an option, check a statement, or assign a score.</p></div><div class="how-item"><span>3</span><p><strong>Inspect the result.</strong>Compare option probabilities. Edit the context and try again.</p></div></div>
45
  </section>
46
  <section id="arena" role="tabpanel" aria-labelledby="arena-tab" tabindex="0" hidden>
47
  <div class="arena-toolbar"><div class="scenario-wrap"><label for="scenario">Choose an arena</label><select id="scenario"><option value="basic">Target practice</option><option value="defend_the_center">Center defense</option></select></div><button class="primary" type="button" id="new-game">Start arena <svg viewBox="0 0 18 18" fill="none" stroke="currentColor" stroke-width="1.5" aria-hidden="true"><path d="M5 3l9 6-9 6z"/></svg></button></div>
 
56
  const $=id=>document.getElementById(id);
57
  const presets={support:{kind:'choice',state:'A customer was charged twice for a single subscription renewal. They included two transaction IDs for the same date and amount. Their account is active, and they need help correcting the duplicate charge.',question:'Which team should handle this ticket?',options:['Billing support','Technical support','Sales']},route:{kind:'choice',state:'A delivery must arrive within 25 minutes. Route A takes 18 minutes but crosses a bridge closed for repairs. Route B takes 23 minutes and is open. Route C takes 32 minutes and is open.',question:'Which route should the driver take?',options:['Route A','Route B','Route C']},fact:{kind:'noul',state:'The museum is open Tuesday through Sunday, from 10:00 to 18:00. It is closed every Monday. Today is Monday.',question:'Is the museum open today at 14:00?',options:['false','true']}};
58
  let decisionBusy=false,customOptions='',modelName='autotrust/JEV-27B';
59
+ const MAX_IMAGE_BYTES=5*1024*1024,IMAGE_TYPES=new Set(['image/jpeg','image/png','image/webp']);
60
+ let attachedImage=null,imageLoading=false,imageLoadVersion=0,imageReader=null,imageDragDepth=0;
61
  let sessionId=null,gameBusy=false,gameDone=false,autoPlaying=false,stopRequested=false,actions=[],gameModel=modelName;
62
  const tabs=[$('lab-tab'),$('arena-tab')];
63
  function setTab(index){tabs.forEach((tab,i)=>{tab.setAttribute('aria-selected',String(i===index));tab.tabIndex=i===index?0:-1;$(tab.getAttribute('aria-controls')).hidden=i!==index});if(index!==1&&autoPlaying){stopRequested=true;$('game-status').textContent='Stopping after the current move…'}}
 
70
  function formatTime(seconds){const n=seconds;return typeof n==='number'&&Number.isFinite(n)&&n>=0?n<1?`${Math.round(n*1000)} ms`:`${n.toFixed(2)} s`:'—'}
71
  function decisionData(data,fallbackOptions=[]){const optionList=Array.isArray(data.options)?data.options:fallbackOptions;const labels=optionList.map(option=>typeof option==='object'?String(option.label??option.id??''):String(option));let probabilities=data.probabilities;if(!Array.isArray(probabilities)&&probabilities&&typeof probabilities==='object')probabilities=optionList.map(option=>probabilities[typeof option==='object'?option.id:option]);if(!Array.isArray(probabilities))probabilities=[];const rawChoice=data.choice??data.answer??data.selected;let selectedIndex=-1;if(typeof rawChoice==='number'&&Number.isInteger(rawChoice)&&rawChoice>=0&&rawChoice<labels.length)selectedIndex=rawChoice;else selectedIndex=optionList.findIndex((option,index)=>String(typeof option==='object'?option.id:option)===String(rawChoice)||labels[index]===String(rawChoice));if(selectedIndex<0&&Number.isInteger(data.choice_index))selectedIndex=data.choice_index;const choiceLabel=selectedIndex>=0&&selectedIndex<labels.length?labels[selectedIndex]:rawChoice==null?'No decision returned':String(rawChoice);return {labels,probabilities,selectedIndex,choiceLabel}}
72
  function renderBars(container,result){container.replaceChildren();result.labels.forEach((label,index)=>{const row=document.createElement('div');row.className='bar-row'+(index===result.selectedIndex?' selected':'');const top=document.createElement('div');top.className='bar-top';const name=document.createElement('span');name.className='bar-name';name.textContent=label;const value=document.createElement('span');value.className='bar-value';const p=Number(result.probabilities[index]),valid=Number.isFinite(p)&&p>=0&&p<=1;value.textContent=valid?`${(p*100).toFixed(1)}%`:'—';const track=document.createElement('div');track.className='bar-track';const fill=document.createElement('div');fill.className='bar-fill';fill.style.width=valid?`${p*100}%`:'0%';track.append(fill);top.append(name,value);row.append(top,track);container.append(row)})}
73
+ function renderVision(vision){const present=vision&&typeof vision==='object'&&!Array.isArray(vision);$('vision-details').hidden=!present;$('vision-latency').textContent=present?formatTime(vision.elapsed_seconds):'—';$('vision-model').textContent=present&&typeof vision.model==='string'?vision.model:''}
74
+ function resetDecision(){renderVision(null);showError('decision-error','');$('decision-result').hidden=true;$('empty-output').hidden=false;$('empty-output').classList.remove('loading');$('empty-title').textContent='Give a choice some context.';$('empty-copy').textContent='Run an example or write your own situation. JEV’s answer and option probabilities will appear here.';$('result-status').textContent='Ready when you are';$('result-latency').textContent='—'}
75
  function updateKind(){const kind=$('kind').value;$('options').readOnly=kind!=='choice';$('options-label').textContent=kind==='choice'?'One per line':'Fixed choices';$('kind-help').textContent=kind==='choice'?'Compare the choices you provide.':kind==='noul'?'Judge whether the statement is true.':'Use your question to define what the score means.';if(kind==='noul')$('options').value='false\ntrue';else if(kind==='score')$('options').value='0\n1\n2\n3\n4\n5';else $('options').value=customOptions}
76
+
77
+ function syncImageControls(){
78
+ $('image-drop').setAttribute('aria-disabled',String(decisionBusy));
79
+ $('image-input').disabled=decisionBusy;
80
+ $('image-input').hidden=Boolean(attachedImage);
81
+ $('remove-image').disabled=decisionBusy;
82
+ $('run-decision').disabled=decisionBusy||imageLoading;
83
+ $('state').required=!attachedImage;
84
+ $('state-hint').textContent=attachedImage?'Text context is optional':'Text or image context';
85
+ $('decision-form').setAttribute('aria-busy',String(decisionBusy||imageLoading));
86
+ }
87
+ function clearImage(){
88
+ imageLoadVersion++;
89
+ if(imageReader?.readyState===1)imageReader.abort();
90
+ imageReader=null;attachedImage=null;imageLoading=false;imageDragDepth=0;
91
+ $('image-input').value='';
92
+ $('image-thumbnail').removeAttribute('src');
93
+ $('image-preview').hidden=true;$('image-picker').hidden=false;
94
+ $('image-name').textContent='';$('image-meta').textContent='';$('image-status').textContent='';
95
+ $('image-drop').classList.remove('is-dragging');showError('image-error','');syncImageControls();
96
+ }
97
+ function imageType(bytes){
98
+ if(bytes.length>=8&&[137,80,78,71,13,10,26,10].every((n,i)=>bytes[i]===n))return 'image/png';
99
+ if(bytes.length>=3&&bytes[0]===255&&bytes[1]===216&&bytes[2]===255)return 'image/jpeg';
100
+ if(bytes.length>=12&&[82,73,70,70].every((n,i)=>bytes[i]===n)&&[87,69,66,80].every((n,i)=>bytes[i+8]===n))return 'image/webp';
101
+ return '';
102
+ }
103
+ function readImageData(file){
104
+ return new Promise((resolve,reject)=>{
105
+ const reader=new FileReader();imageReader=reader;
106
+ reader.onload=()=>typeof reader.result==='string'?resolve(reader.result):reject(new Error('This image could not be read. Choose the file again.'));
107
+ reader.onerror=()=>reject(new Error('This image could not be read. Choose the file again.'));
108
+ reader.onabort=()=>reject(new Error('Image selection was cancelled.'));
109
+ reader.readAsDataURL(file);
110
+ });
111
+ }
112
+ function decodeImage(dataUrl){
113
+ return new Promise((resolve,reject)=>{
114
+ const image=new Image();
115
+ image.onload=()=>{
116
+ if(image.naturalWidth>0&&image.naturalHeight>0)resolve({width:image.naturalWidth,height:image.naturalHeight});
117
+ else reject(new Error('This image could not be decoded. Choose a valid JPEG, PNG, or WebP image.'));
118
+ };
119
+ image.onerror=()=>reject(new Error('This image could not be decoded. Choose a valid JPEG, PNG, or WebP image.'));
120
+ image.src=dataUrl;
121
+ });
122
+ }
123
+ async function selectImage(files){
124
+ if(decisionBusy||!files||!files.length)return;
125
+ clearImage();resetDecision();
126
+ document.querySelectorAll('.preset').forEach(button=>button.setAttribute('aria-pressed','false'));
127
+ const version=imageLoadVersion;
128
+ imageLoading=true;$('image-status').textContent='Checking image…';syncImageControls();
129
+ try{
130
+ if(files.length!==1)throw new Error('Choose one image at a time. No image is attached.');
131
+ const file=files[0];
132
+ if(file.size>MAX_IMAGE_BYTES)throw new Error('This image is larger than 5 MiB. Choose a smaller image.');
133
+ if(!file.size)throw new Error('This file is empty. Choose a valid JPEG, PNG, or WebP image.');
134
+ if(file.type&&!IMAGE_TYPES.has(file.type))throw new Error('Unsupported image type. Choose a JPEG, PNG, or WebP image.');
135
+ const mime=imageType(new Uint8Array(await file.slice(0,12).arrayBuffer()));
136
+ if(version!==imageLoadVersion)return;
137
+ if(!mime||(file.type&&file.type!==mime))throw new Error('The file content is not a matching JPEG, PNG, or WebP image. Choose another file.');
138
+ const raw=await readImageData(file);
139
+ if(version!==imageLoadVersion)return;
140
+ const dataUrl=`data:${mime};base64,${raw.slice(raw.indexOf(',')+1)}`;
141
+ const dimensions=await decodeImage(dataUrl);
142
+ if(version!==imageLoadVersion)return;
143
+ attachedImage={dataUrl,name:file.name,size:file.size,...dimensions};
144
+ $('image-thumbnail').src=dataUrl;
145
+ $('image-name').textContent=file.name;
146
+ $('image-meta').textContent=`${dimensions.width} × ${dimensions.height} · ${file.size>=1024*1024?(file.size/(1024*1024)).toFixed(1)+' MiB':Math.max(1,Math.ceil(file.size/1024))+' KiB'}`;
147
+ $('image-preview').hidden=false;$('image-picker').hidden=true;
148
+ $('image-status').textContent='Image attached. You can add text for more context.';
149
+ }catch(error){
150
+ if(version!==imageLoadVersion)return;
151
+ $('image-status').textContent='No image attached.';
152
+ showError('image-error',error.message);
153
+ }finally{
154
+ if(version===imageLoadVersion){imageReader=null;imageLoading=false;syncImageControls()}
155
+ }
156
+ }
157
+ $('image-input').addEventListener('change',event=>selectImage(Array.from(event.target.files||[])));
158
+ $('remove-image').addEventListener('click',()=>{if(decisionBusy)return;clearImage();resetDecision();$('image-status').textContent='Image removed.';$('image-input').focus()});
159
+ $('image-drop').addEventListener('dragenter',event=>{event.preventDefault();if(decisionBusy)return;imageDragDepth++;$('image-drop').classList.add('is-dragging')});
160
+ $('image-drop').addEventListener('dragover',event=>{event.preventDefault();if(event.dataTransfer)event.dataTransfer.dropEffect=decisionBusy?'none':'copy'});
161
+ $('image-drop').addEventListener('dragleave',event=>{event.preventDefault();imageDragDepth=Math.max(0,imageDragDepth-1);if(!imageDragDepth)$('image-drop').classList.remove('is-dragging')});
162
+ $('image-drop').addEventListener('drop',event=>{event.preventDefault();imageDragDepth=0;$('image-drop').classList.remove('is-dragging');if(decisionBusy)return;const files=Array.from(event.dataTransfer?.files||[]);if(files.length)selectImage(files);else{clearImage();resetDecision();$('image-status').textContent='No image attached.';showError('image-error','Drop an image file from your device, or use Choose an image.')}});
163
+ function applyPreset(name){if(decisionBusy)return;clearImage();const p=presets[name];$('state').value=p.state;$('question').value=p.question;$('kind').value=p.kind;if(p.kind==='choice')customOptions=p.options.join('\n');updateKind();document.querySelectorAll('.preset').forEach(button=>button.setAttribute('aria-pressed',String(button.dataset.preset===name)));resetDecision()}
164
  document.querySelectorAll('.preset').forEach(button=>button.addEventListener('click',()=>applyPreset(button.dataset.preset)));
165
  $('kind').addEventListener('change',()=>{updateKind();resetDecision()});$('options').addEventListener('input',()=>{if($('kind').value==='choice')customOptions=$('options').value});['state','question','options','kind'].forEach(id=>$(id).addEventListener('input',()=>document.querySelectorAll('.preset').forEach(button=>button.setAttribute('aria-pressed','false'))));
166
+ function setDecisionBusy(busy){decisionBusy=busy;document.querySelectorAll('#decision-form button,#decision-form input,#decision-form textarea,#decision-form select').forEach(element=>element.disabled=busy);$('run-decision').textContent=busy?'JEV is deciding…':'Run JEV →';syncImageControls()}
167
+ $('decision-form').addEventListener('submit',async event=>{event.preventDefault();if(decisionBusy||imageLoading)return;const kind=$('kind').value,state=$('state').value.trim(),question=$('question').value.trim(),options=$('options').value.split('\n').map(option=>option.trim()).filter(Boolean);if(!question){showError('decision-error','Add a question for JEV first.');return}if(!state&&!attachedImage){showError('decision-error','Describe a situation or attach an image first.');return}if(options.length<2||options.length>16){showError('decision-error','Provide between 2 and 16 options, one per line.');return}if(options.some(option=>option.length>180)){showError('decision-error','Keep each option to 180 characters or fewer.');return}if(new Set(options).size!==options.length){showError('decision-error','Give each option a different name.');return}showError('decision-error','');setDecisionBusy(true);$('decision-result').hidden=true;$('empty-output').hidden=false;$('empty-output').classList.add('loading');$('empty-title').textContent='Weighing the options.';$('empty-copy').textContent=attachedImage?'Reading your image, then JEV will compare the available choices.':'JEV is reading your situation and comparing the available choices.';$('result-status').textContent='Inference in progress';$('result-latency').textContent='—';try{const payload={kind,state,question,options};if(attachedImage)payload.image=attachedImage.dataUrl;const data=await api('/api/decide',payload);const result=decisionData(data,options);if(!result.labels.length||result.choiceLabel==='No decision returned')throw new Error('The model did not return a valid decision. Please try again.');connected(data.model);renderVision(data.vision);$('picked-answer').textContent=result.choiceLabel;renderBars($('probabilities'),result);$('empty-output').hidden=true;$('decision-result').hidden=false;$('result-status').textContent='Decision received';$('result-latency').textContent=formatTime(data.elapsed_seconds);$('result-model').textContent=typeof data.model==='string'?data.model:modelName}catch(error){showError('decision-error',error.message);$('empty-title').textContent='The decision did not arrive.';$('empty-copy').textContent='Your inputs are still here. Try again when the service is ready.';$('result-status').textContent='Request unsuccessful';checkHealth()}finally{$('empty-output').classList.remove('loading');setDecisionBusy(false)}});
168
  function updateGameControls(){const disabled=gameBusy||autoPlaying||!sessionId||gameDone;$('new-game').disabled=gameBusy||autoPlaying;$('scenario').disabled=gameBusy||autoPlaying;$('jev-move').disabled=disabled;$('auto-play').disabled=disabled;document.querySelectorAll('#manual-actions button').forEach(button=>button.disabled=disabled);$('stop-auto').hidden=!autoPlaying;$('stop-auto').disabled=stopRequested;$('auto-play').textContent=autoPlaying?'Auto-play running…':'Auto-play · 10 steps';$('new-game').textContent=sessionId?'Reset arena ↻':'Start arena ▷'}
169
  function renderActions(nextActions){if(!Array.isArray(nextActions))return;actions=nextActions;$('manual-actions').replaceChildren();for(const action of actions){if(!action||typeof action.id!=='string')continue;const button=document.createElement('button');button.type='button';button.className='action-button';button.textContent=String(action.label||action.id);button.title=String(action.description||action.label||action.id);button.addEventListener('click',()=>runGameStep('human',action.id));$('manual-actions').append(button)}$('no-actions').hidden=actions.length>0;updateGameControls()}
170
  function updateSnapshot(data){if(typeof data.session_id==='string')sessionId=data.session_id;gameDone=Boolean(data.done);if(typeof data.frame==='string'&&data.frame){const frame=data.frame.startsWith('data:image/png;base64,')?data.frame:`data:image/png;base64,${data.frame}`;$('game-frame').src=frame;$('game-frame').hidden=false;$('game-placeholder').hidden=true;$('game-frame').alt=`Doom ${data.scenario==='defend_the_center'?'center defense':'target practice'}; step ${data.metrics?.steps??'unknown'}`}if(data.actions)renderActions(data.actions);for(const key of ['health','ammo','kills','reward','steps']){const value=data.metrics?.[key];$('metric-'+key).textContent=typeof value==='number'&&Number.isFinite(value)?Number.isInteger(value)?String(value):value.toFixed(1):'—'}$('frame-badge').hidden=!sessionId;$('frame-badge').textContent=gameDone?'Episode complete':'Simulation paused';if(data.decision){const decision=data.decision;connected(decision.model);const results=decisionData(decision,actions.map(action=>action.id));const labels=results.labels.map(label=>actions.find(action=>action.id===label||action.description===label)?.label||label);const selectedId=results.choiceLabel;results.labels=labels;results.choiceLabel=actions.find(action=>action.id===selectedId||action.description===selectedId)?.label||selectedId;$('game-picked').textContent=results.choiceLabel;renderBars($('game-probabilities'),results);$('game-decision-empty').hidden=true;$('game-decision').hidden=false;gameModel=typeof decision.model==='string'?decision.model:typeof data.model==='string'?data.model:modelName;$('game-latency').textContent=formatTime(decision.elapsed_seconds);$('game-decision-meta').textContent=`${gameModel} · Timing reported by the model server`;}updateGameControls()}