Spaces:
Running
Running
Download devui/index.html from polter-dev/societysim: direct link, hf CLI and curl.
- Browser
- Download file 17.2 kB
-
https://huggingface.co/spaces/polter-dev/societysim/resolve/main/devui/index.html
- Command line
-
hf download hf://spaces/polter-dev/societysim/devui/index.html
-
curl -L -o index.html https://huggingface.co/spaces/polter-dev/societysim/resolve/main/devui/index.html
17.2 kB
| <html lang="en"> | |
| <head> | |
| <meta charset="utf-8"> | |
| <meta name="viewport" content="width=device-width, initial-scale=1"> | |
| <title>SocietySim dev UI</title> | |
| <style> | |
| body { font: 14px/1.4 system-ui, sans-serif; margin: 16px; color: #111; background: #fff; } | |
| h1 { font-size: 18px; margin: 0 0 8px; } | |
| h2 { font-size: 15px; margin: 20px 0 6px; } | |
| fieldset { margin: 8px 0; } | |
| input[type=text], input[type=password] { width: 22em; max-width: 90%; } | |
| table { border-collapse: collapse; margin: 4px 0; } | |
| th, td { border: 1px solid #ccc; padding: 2px 6px; text-align: left; vertical-align: top; } | |
| th { background: #f3f3f3; } | |
| th.sortable { cursor: pointer; } | |
| .ok { color: #070; } | |
| .bad { color: #b00; } | |
| .priv { background: #fff7e0; } | |
| .label { background: #fde; border-radius: 3px; padding: 0 3px; margin-right: 3px; } | |
| .scroll { overflow-x: auto; } | |
| .muted { color: #666; } | |
| pre { margin: 0; white-space: pre-wrap; } | |
| </style> | |
| </head> | |
| <body> | |
| <h1>SocietySim dev UI <span class="muted">(throwaway test tool; not the viewer)</span></h1> | |
| <fieldset> | |
| <legend>Source</legend> | |
| <label><input type="radio" name="mode" value="static" checked> Static files</label> | |
| <label>base <input type="text" id="base" value="../data/"></label> | |
| <label>schemas <input type="text" id="schemas" value="schema/"></label> | |
| <br> | |
| <label><input type="radio" name="mode" value="neon"> Neon Data API</label> | |
| <label>url <input type="text" id="neon" placeholder="https://.../rest/v1"></label> | |
| <label>auth url <input type="text" id="auth" placeholder="https://.../neondb/auth"></label> | |
| <label>token override (optional) <input type="password" id="key" autocomplete="off"></label> | |
| <button id="load">Load</button> | |
| <div class="muted">URL params: ?base=, ?schemas=, ?neon=, ?auth=, ?key=. Keys are never stored.</div> | |
| </fieldset> | |
| <div id="status" class="muted"></div> | |
| <div id="out"></div> | |
| <h2>Contract check</h2> | |
| <table id="contract"><thead><tr><th>file</th><th>result</th></tr></thead><tbody></tbody></table> | |
| <script> | |
| ; | |
| const $ = (id) => document.getElementById(id); | |
| const qs = new URLSearchParams(location.search); | |
| if (qs.get("base")) $("base").value = qs.get("base"); | |
| if (qs.get("schemas")) $("schemas").value = qs.get("schemas"); | |
| if (qs.get("neon")) { $("neon").value = qs.get("neon"); document.querySelector("input[value=neon]").checked = true; } | |
| if (qs.get("auth")) $("auth").value = qs.get("auth"); | |
| if (qs.get("key")) $("key").value = qs.get("key"); | |
| function el(tag, props, ...kids) { | |
| const e = document.createElement(tag); | |
| Object.assign(e, props || {}); | |
| for (const k of kids) e.append(k); | |
| return e; | |
| } | |
| const fmt = (v) => v === null || v === undefined ? "" : typeof v === "object" ? JSON.stringify(v) : String(v); | |
| const dirUrl = (s) => new URL(s.endsWith("/") ? s : s + "/", location.href).href; | |
| // ---- tiny JSON Schema check: $ref anyOf const enum type required properties | |
| // additionalProperties patternProperties propertyNames items prefixItems | |
| // min/maxItems minimum maximum pattern. Returns a list of problems. | |
| function check(schema, root, v, path, errs) { | |
| if (errs.length >= 20) return; | |
| if (schema.$ref) { | |
| let t = root; | |
| for (const p of schema.$ref.replace(/^#\//, "").split("/")) t = t[p]; | |
| return check(t, root, v, path, errs); | |
| } | |
| if (schema.anyOf) { | |
| const subs = schema.anyOf.map((s) => { const e = []; check(s, root, v, path, e); return e; }); | |
| if (!subs.some((e) => e.length === 0)) errs.push(`${path}: matches none of the allowed shapes (${subs[0][0] || "?"})`); | |
| return; | |
| } | |
| if ("const" in schema && v !== schema.const) errs.push(`${path}: expected ${JSON.stringify(schema.const)}`); | |
| if (schema.enum && !schema.enum.includes(v)) errs.push(`${path}: ${JSON.stringify(v)} not in ${JSON.stringify(schema.enum)}`); | |
| if (schema.type) { | |
| const t = Array.isArray(v) ? "array" : v === null ? "null" : typeof v; | |
| const okType = schema.type === t || (schema.type === "integer" && Number.isInteger(v)) || | |
| (schema.type === "number" && t === "number"); | |
| if (!okType) { errs.push(`${path}: expected ${schema.type}, got ${t}`); return; } | |
| } | |
| if (typeof v === "string" && schema.pattern && !new RegExp(schema.pattern).test(v)) errs.push(`${path}: does not match ${schema.pattern}`); | |
| if (typeof v === "number") { | |
| if ("minimum" in schema && v < schema.minimum) errs.push(`${path}: below ${schema.minimum}`); | |
| if ("maximum" in schema && v > schema.maximum) errs.push(`${path}: above ${schema.maximum}`); | |
| } | |
| if (Array.isArray(v)) { | |
| if ("minItems" in schema && v.length < schema.minItems) errs.push(`${path}: fewer than ${schema.minItems} items`); | |
| if ("maxItems" in schema && v.length > schema.maxItems) errs.push(`${path}: more than ${schema.maxItems} items`); | |
| v.forEach((x, i) => { | |
| const s = schema.prefixItems && i < schema.prefixItems.length ? schema.prefixItems[i] : schema.items; | |
| if (s) check(s, root, x, `${path}[${i}]`, errs); | |
| }); | |
| } else if (v && typeof v === "object") { | |
| for (const r of schema.required || []) if (!(r in v)) errs.push(`${path}: missing required "${r}"`); | |
| const props = schema.properties || {}, pats = Object.entries(schema.patternProperties || {}); | |
| for (const [k, x] of Object.entries(v)) { | |
| const p = `${path}.${k}`; | |
| if (schema.propertyNames) check(schema.propertyNames, root, k, `${p} (key)`, errs); | |
| const matched = pats.filter(([re]) => new RegExp(re).test(k)); | |
| if (k in props) check(props[k], root, x, p, errs); | |
| else if (matched.length) matched.forEach(([, s]) => check(s, root, x, p, errs)); | |
| else if (schema.additionalProperties === false) errs.push(`${p}: unknown field`); | |
| else if (schema.additionalProperties && typeof schema.additionalProperties === "object") check(schema.additionalProperties, root, x, p, errs); | |
| } | |
| } | |
| } | |
| const schemaCache = {}; | |
| async function getSchema(name) { | |
| if (!schemaCache[name]) { | |
| schemaCache[name] = fetch(new URL(`${name}.schema.json`, dirUrl($("schemas").value))).then((r) => { | |
| if (!r.ok) throw new Error(`schema ${name}: HTTP ${r.status}`); | |
| return r.json(); | |
| }); | |
| } | |
| return schemaCache[name]; | |
| } | |
| const contractRows = {}; | |
| function report(file, errs) { | |
| let row = contractRows[file]; | |
| if (!row) { | |
| row = contractRows[file] = el("tr", {}, el("td"), el("td")); | |
| $("contract").tBodies[0].append(row); | |
| } | |
| row.cells[0].textContent = file; | |
| row.cells[1].replaceChildren(errs.length === 0 ? el("span", { className: "ok", textContent: "OK" }) | |
| : el("span", { className: "bad", textContent: errs.join("\n") + (errs.length >= 20 ? "\n..." : "") })); | |
| if (errs.length) row.cells[1].firstChild.style.whiteSpace = "pre-wrap"; | |
| } | |
| async function validate(file, schemaName, data) { | |
| try { | |
| const s = await getSchema(schemaName); | |
| const errs = []; | |
| check(s, s, data, "$", errs); | |
| report(file, errs); | |
| } catch (e) { report(file, [String(e.message || e)]); } | |
| } | |
| async function validateLines(file, schemaName, rows, def) { | |
| try { | |
| const s = await getSchema(schemaName); | |
| const sub = def ? { $ref: `#/$defs/${def}` } : s; | |
| const errs = []; | |
| rows.forEach((r, i) => check(sub, s, r, `line ${i}`, errs)); | |
| report(file, errs); | |
| } catch (e) { report(file, [String(e.message || e)]); } | |
| } | |
| // ---- tables | |
| function table(rows, cols, opts = {}) { | |
| cols = cols || [...new Set(rows.flatMap((r) => Object.keys(r)))]; | |
| const t = el("table"); | |
| const body = el("tbody"); | |
| let sort = { col: null, dir: 1 }; | |
| const draw = () => { | |
| const rs = rows.slice(); | |
| if (sort.col !== null) { | |
| const get = (r) => { const v = opts.value ? opts.value(r, sort.col) : r[sort.col]; return v === "" ? null : v; }; | |
| rs.sort((a, b) => { | |
| const x = get(a), y = get(b); | |
| if (x === null || x === undefined) return y === null || y === undefined ? 0 : 1; | |
| if (y === null || y === undefined) return -1; | |
| return (x < y ? -1 : x > y ? 1 : 0) * sort.dir; | |
| }); | |
| } | |
| body.replaceChildren(...rs.map((r) => { | |
| const tr = el("tr"); | |
| for (const c of cols) { | |
| const td = el("td"); | |
| const v = opts.cell ? opts.cell(r, c) : null; | |
| td.append(v instanceof Node ? v : fmt(v !== null && v !== undefined ? v : r[c])); | |
| tr.append(td); | |
| } | |
| return tr; | |
| })); | |
| }; | |
| const head = el("tr", {}, ...cols.map((c) => el("th", { | |
| className: "sortable", textContent: c, title: "click to sort", | |
| onclick: () => { sort = { col: c, dir: sort.col === c ? -sort.dir : 1 }; draw(); }, | |
| }))); | |
| t.append(el("thead", {}, head), body); | |
| draw(); | |
| return el("div", { className: "scroll" }, t); | |
| } | |
| function section(title, ...kids) { | |
| const s = el("section", {}, el("h2", { textContent: title }), ...kids); | |
| $("out").append(s); | |
| return s; | |
| } | |
| // ---- static mode | |
| async function getJson(url) { | |
| const r = await fetch(url); | |
| if (!r.ok) throw new Error(`${url}: HTTP ${r.status}`); | |
| return r.json(); | |
| } | |
| async function getLines(url, optional) { | |
| const r = await fetch(url); | |
| if (!r.ok) { if (optional) return null; throw new Error(`${url}: HTTP ${r.status}`); } | |
| return (await r.text()).split("\n").filter((l) => l.trim()).map((l) => JSON.parse(l)); | |
| } | |
| const flat = (o, pre = "") => Object.entries(o || {}).flatMap(([k, v]) => | |
| v && typeof v === "object" && !Array.isArray(v) ? flat(v, `${pre}${k}.`) : [[pre + k, v]]); | |
| async function loadStatic() { | |
| const base = dirUrl($("base").value); | |
| const rel = (p) => new URL(p, base).href; | |
| const files = [ | |
| ["report/index.json", "index"], ["report/leaderboard.json", "leaderboard"], | |
| ["report/metrics.json", "metrics"], ["highlights.json", "highlights"], | |
| ]; | |
| const data = {}; | |
| await Promise.all(files.map(async ([f, s]) => { | |
| try { data[f] = await getJson(rel(f)); validate(f, s, data[f]); } | |
| catch (e) { report(f, [String(e.message)]); } | |
| })); | |
| const lb = data["report/leaderboard.json"]; | |
| if (lb) { | |
| section(`Leaderboard (${lb.games} games)`, table(lb.models, null, { | |
| cell: (r, c) => c === "openskill" ? fmt(r.openskill && r.openskill.ordinal) : c === "lie_rate" ? fmt(r.lie_rate) : null, | |
| value: (r, c) => c === "openskill" ? r.openskill && r.openskill.ordinal : r[c], | |
| })); | |
| } | |
| const m = data["report/metrics.json"]; | |
| if (m) { | |
| const kv = (obj) => table(flat(obj).map(([k, v]) => ({ key: k, value: v })), ["key", "value"]); | |
| section("Metrics summary", kv(m.summary)); | |
| section("Metrics speed", table(Object.entries(m.speed || {}).map(([model, s]) => ({ model, ...s })))); | |
| } | |
| const hl = data["highlights.json"]; | |
| if (hl) { | |
| section(`Highlights (${hl.length})`, table(hl, ["type", "score", "game_id", "day", "description", "link"], { | |
| cell: (r, c) => c === "link" ? el("a", { href: `../${r.link}`, textContent: r.link, title: "opens the real viewer" }) : null, | |
| })); | |
| } | |
| const idx = data["report/index.json"]; | |
| if (idx) { | |
| const detail = el("div"); | |
| const games = table(idx.map((g) => ({ ...g, seats: undefined, models: [...new Set(Object.values(g.seats).map((s) => s.model))].join(", ") })), | |
| ["game_id", "seed", "days", "winner", "models", "open"], { | |
| cell: (r, c) => c === "open" ? el("button", { textContent: "open", onclick: () => openGame(idx.find((g) => g.game_id === r.game_id), base) }) : null, | |
| value: (r, c) => c === "open" ? null : r[c], | |
| }); | |
| section(`Games (${idx.length})`, games); | |
| section("Game", detail); | |
| detail.id = "game"; | |
| if (qs.get("game") && idx.some((g) => g.game_id === qs.get("game"))) openGame(idx.find((g) => g.game_id === qs.get("game")), base); | |
| } | |
| } | |
| async function openGame(entry, base) { | |
| const box = $("game"); | |
| box.replaceChildren(`loading ${entry.game_id}...`); | |
| try { | |
| // "file" is relative to the batch dir, one level above report/index.json | |
| const logUrl = new URL(entry.file, base).href; | |
| const events = await getLines(logUrl, false); | |
| const labels = (await getLines(logUrl.replace(/\.jsonl$/, ".labels.jsonl"), true)) || []; | |
| validateLines(`${entry.file}`, "events", events); | |
| if (labels.length) validateLines(entry.file.replace(/\.jsonl$/, ".labels.jsonl"), "events", labels, "Label"); | |
| else report(entry.file.replace(/\.jsonl$/, ".labels.jsonl"), []); | |
| const bySeq = {}; | |
| for (const l of labels) (bySeq[l.source_seq] = bySeq[l.source_seq] || []).push(l); | |
| const toggle = el("input", { type: "checkbox" }); | |
| const wrap = el("div", { className: "scroll" }); | |
| const seatLine = el("div", { className: "muted" }); | |
| const draw = () => { | |
| const priv = toggle.checked; | |
| seatLine.textContent = priv | |
| ? Object.entries(entry.seats).map(([s, v]) => `${s} ${v.name} (${v.model}, ${v.role})`).join("; ") | |
| : Object.entries(entry.seats).map(([s, v]) => `${s} ${v.name}`).join("; "); | |
| const cols = ["seq", "day/phase", "actor", "type", "target", "public_text", "labels"].concat(priv ? ["visibility", "private_reasoning", "suspicion"] : []); | |
| wrap.replaceChildren(table(events.filter((e) => priv || e.visibility === "public"), cols, { | |
| cell: (e, c) => { | |
| if (c === "day/phase") return `${e.day} ${e.phase}`; | |
| if (c === "labels") return el("span", {}, ...(bySeq[e.seq] || []).map((l) => el("span", { className: "label", textContent: `${l.label} [${l.source}]`, title: l.detail }))); | |
| if (c === "private_reasoning" || c === "visibility" || c === "suspicion") { | |
| const td = el("pre", { className: "priv", textContent: fmt(e[c]) }); | |
| return td; | |
| } | |
| return null; | |
| }, | |
| value: (e, c) => c === "day/phase" ? e.day : c === "labels" ? (bySeq[e.seq] || []).length : e[c], | |
| })); | |
| }; | |
| toggle.onchange = draw; | |
| box.replaceChildren(el("h3", { textContent: `${entry.game_id} winner: ${entry.winner}` }), | |
| el("label", {}, toggle, " narrator view (private reasoning, suspicion, wolf channel, roles)"), seatLine, wrap); | |
| draw(); | |
| } catch (e) { box.replaceChildren(el("span", { className: "bad", textContent: String(e.message) })); } | |
| } | |
| // ---- Neon mode | |
| // Anonymous JWT from Neon Auth: GET <auth>/token/anonymous -> {token, expires_at}. Cached in memory, | |
| // refreshed 60 s before expiry, or on a 400/401. The token is never logged or stored. | |
| let anon = null; | |
| async function anonToken(force) { | |
| const auth = $("auth").value.replace(/\/+$/, ""); | |
| if (!auth) return null; | |
| if (!force && anon && anon.auth === auth && Date.parse(anon.exp) - 60000 > Date.now()) return anon.token; | |
| const r = await fetch(`${auth}/token/anonymous`); | |
| if (!r.ok) throw new Error(`anonymous token: HTTP ${r.status}`); | |
| const j = await r.json(); | |
| anon = { auth, token: j.token, exp: j.expires_at }; | |
| return anon.token; | |
| } | |
| async function neonFetch(url, force) { | |
| const tok = $("key").value || (await anonToken(force)); | |
| const r = await fetch(url, { headers: tok ? { Authorization: `Bearer ${tok}` } : {} }); | |
| if ((r.status === 400 || r.status === 401) && !force && !$("key").value && $("auth").value) return neonFetch(url, true); | |
| return r; | |
| } | |
| async function loadNeon() { | |
| const base = $("neon").value.replace(/\/+$/, ""); | |
| const q = async (name, query) => { | |
| const r = await neonFetch(`${base}/${name}?${query}`); | |
| if (!r.ok) throw new Error(`${name}: HTTP ${r.status}`); | |
| const rows = await r.json(); | |
| section(`${name} (${rows.length})`, rows.length ? table(rows) : el("div", { className: "muted", textContent: "no rows" })); | |
| return rows; | |
| }; | |
| const jobs = [ | |
| ["games", "select=game_id,batch_id,status,winner,models,agents&status=eq.finished&or=(agents.is.null,agents.neq.scripted)&order=started_at.desc.nullslast"], | |
| ["leaderboard", "select=model,seat_games,wins,win_rate&order=win_rate.desc.nullslast"], | |
| ["leaderboard_by_batch", "select=*"], | |
| ["lies_by_model", "select=*"], | |
| ]; | |
| for (const [name, query] of jobs) { | |
| try { await q(name, query); } catch (e) { section(name, el("span", { className: "bad", textContent: String(e.message) })); } | |
| } | |
| } | |
| async function load() { | |
| $("out").replaceChildren(); | |
| $("contract").tBodies[0].replaceChildren(); | |
| for (const k of Object.keys(contractRows)) delete contractRows[k]; | |
| for (const k of Object.keys(schemaCache)) delete schemaCache[k]; | |
| $("status").textContent = "loading..."; | |
| try { | |
| if (document.querySelector("input[name=mode]:checked").value === "neon") { | |
| if (!$("neon").value) throw new Error("enter a Data API url"); | |
| await loadNeon(); | |
| report("(Neon rows)", []); | |
| $("contract").tBodies[0].rows[0].cells[1].textContent = "not checked: schemas cover the static files only"; | |
| } else await loadStatic(); | |
| $("status").textContent = "done"; | |
| } catch (e) { $("status").textContent = ""; $("out").append(el("div", { className: "bad", textContent: String(e.message) })); } | |
| } | |
| $("load").onclick = load; | |
| // config.json (written by scripts/deploy-space.sh when the Neon build vars are set) fills the Neon fields. | |
| fetch("config.json").then((r) => (r.ok ? r.json() : {})).catch(() => ({})).then((c) => { | |
| if (c.neon_url && !$("neon").value) $("neon").value = c.neon_url; | |
| if (c.auth_url && !$("auth").value) $("auth").value = c.auth_url; | |
| if (c.neon_anon_key && !$("key").value) $("key").value = c.neon_anon_key; | |
| load(); | |
| }); | |
| </script> | |
| </body> | |
| </html> | |