diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..18985ce
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,13 @@
+__pycache__/
+*.pyc
+.venv/
+venv/
+*.db
+*.db-wal
+*.db-shm
+/var/lib/veriq/
+.env
+keys/
+*.key
+.DS_Store
+node_modules/
\ No newline at end of file
diff --git a/README.md b/README.md
index b488821..89a036c 100644
--- a/README.md
+++ b/README.md
@@ -1,4 +1,46 @@
-veriq
-===============
+# VerIQ
-VerIQ — public IQ test with cryptographically verifiable results bound to your X/Instagram identity. Standalone project.
\ No newline at end of file
+Public IQ test with **cryptographically verifiable results** bound to your identity.
+
+Every existing IQ test gives you a screenshot — forgeable in 30 seconds. VerIQ signs
+your result with Ed25519 and gives anyone a public link to verify it: no login, no trust
+required. Argue about IQ on X/Twitter? Drop the link. Done.
+
+## Status
+
+v1 MVP in active development (2026-10-13). See `docs/SPEC.md` for the full spec.
+
+## Stack
+
+- Backend: FastAPI + SQLite (WAL), single VPS, no external services
+- Signing: Ed25519 (PyCA cryptography)
+- Frontend: vanilla JS, no build step, mobile-first dark theme
+- Deploy: systemd + nginx + certbot (`scripts/deploy.sh`)
+
+## Layout
+
+```
+backend/ FastAPI app (main, scoring, signing, db)
+content/ question bank (questions.json) + calibration.json
+frontend/ static pages (index, test, result, verify)
+scripts/ deploy + question generator
+docs/ SPEC.md
+```
+
+## Run locally
+
+```bash
+pip install -r requirements.txt
+uvicorn backend.main:app --port 8801
+# open http://127.0.0.1:8801
+```
+
+## Verify a result
+
+Result links look like `/v/Ab3xK9mQ`. The verify page shows the score, the bound
+identity (if claimed), and re-checks the Ed25519 signature server-side. The service
+public key is published at `/.well-known/veriq-pubkey.txt`.
+
+## License
+
+Proprietary (c) 2026 VerIQ. All rights reserved.
\ No newline at end of file
diff --git a/backend/db.py b/backend/db.py
new file mode 100644
index 0000000..68883ba
--- /dev/null
+++ b/backend/db.py
@@ -0,0 +1,92 @@
+"""VerIQ — SQLite storage (WAL mode)."""
+import json
+import os
+import secrets
+import sqlite3
+import time
+from pathlib import Path
+
+DB_PATH = Path(os.environ.get("VERIQ_DB_PATH", "/var/lib/veriq/veriq.db"))
+
+_SCHEMA = """
+CREATE TABLE IF NOT EXISTS results (
+ result_id TEXT PRIMARY KEY,
+ score INTEGER NOT NULL,
+ percentile REAL NOT NULL,
+ bank_version TEXT NOT NULL,
+ identity TEXT,
+ completed_at TEXT NOT NULL,
+ payload TEXT NOT NULL,
+ signature TEXT NOT NULL,
+ answers TEXT NOT NULL,
+ flagged INTEGER DEFAULT 0
+);
+CREATE TABLE IF NOT EXISTS events (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ ts REAL NOT NULL,
+ kind TEXT NOT NULL,
+ meta TEXT
+);
+CREATE INDEX IF NOT EXISTS idx_events_kind ON events(kind);
+"""
+
+
+def _conn() -> sqlite3.Connection:
+ DB_PATH.parent.mkdir(parents=True, exist_ok=True)
+ conn = sqlite3.connect(DB_PATH, timeout=10)
+ conn.execute("PRAGMA journal_mode=WAL")
+ conn.execute("PRAGMA foreign_keys=ON")
+ conn.row_factory = sqlite3.Row
+ return conn
+
+
+def init_db() -> None:
+ with _conn() as c:
+ c.executescript(_SCHEMA)
+
+
+def new_result_id() -> str:
+ """8-char base62 URL-safe id with collision check."""
+ alphabet = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789"
+ with _conn() as c:
+ while True:
+ rid = "".join(secrets.choice(alphabet) for _ in range(8))
+ row = c.execute("SELECT 1 FROM results WHERE result_id=?", (rid,)).fetchone()
+ if row is None:
+ return rid
+
+
+def save_result(result_id: str, score: int, percentile: float,
+ bank_version: str, identity: str | None, completed_at: str,
+ payload: str, signature: str, answers: dict,
+ flagged: bool = False) -> None:
+ with _conn() as c:
+ c.execute(
+ "INSERT INTO results (result_id, score, percentile, bank_version,"
+ " identity, completed_at, payload, signature, answers, flagged)"
+ " VALUES (?,?,?,?,?,?,?,?,?,?)",
+ (result_id, score, percentile, bank_version, identity,
+ completed_at, payload, signature, json.dumps(answers),
+ int(flagged)),
+ )
+
+
+def get_result(result_id: str) -> dict | None:
+ with _conn() as c:
+ row = c.execute("SELECT * FROM results WHERE result_id=?", (result_id,)).fetchone()
+ if row is None:
+ return None
+ return dict(row)
+
+
+def log_event(kind: str, meta: dict | None = None) -> None:
+ with _conn() as c:
+ c.execute("INSERT INTO events (ts, kind, meta) VALUES (?,?,?)",
+ (time.time(), kind, json.dumps(meta or {})))
+
+
+def event_stats() -> dict:
+ with _conn() as c:
+ rows = c.execute(
+ "SELECT kind, COUNT(*) AS n FROM events GROUP BY kind").fetchall()
+ return {r["kind"]: r["n"] for r in rows}
\ No newline at end of file
diff --git a/backend/main.py b/backend/main.py
new file mode 100644
index 0000000..77eeded
--- /dev/null
+++ b/backend/main.py
@@ -0,0 +1,218 @@
+"""VerIQ — FastAPI application (v1 MVP).
+
+Standalone public IQ test with signed, verifiable results.
+Run: uvicorn backend.main:app --host 127.0.0.1 --port 8801
+"""
+import json
+import os
+import secrets
+import time
+from pathlib import Path
+
+from fastapi import FastAPI, HTTPException, Request
+from fastapi.responses import FileResponse, JSONResponse, PlainTextResponse
+from pydantic import BaseModel, Field
+
+from . import db, scoring, signing
+
+app = FastAPI(title="VerIQ", docs_url=None, redoc_url=None)
+
+FRONTEND_DIR = Path(__file__).resolve().parent.parent / "frontend"
+
+# --- in-memory test sessions (single-process MVP) ---------------------------
+# session_token -> {task_ids, started_at, ip}
+SESSIONS: dict[str, dict] = {}
+SESSION_TTL_S = 60 * 60 # 1 hour to finish after start
+
+# --- simple per-IP rate limiting --------------------------------------------
+_RATE: dict[str, list[float]] = {}
+RATE_LIMIT_STARTS_PER_HOUR = 10
+
+
+def _rate_ok(ip: str) -> bool:
+ now = time.time()
+ window = _RATE.setdefault(ip, [])
+ window[:] = [t for t in window if now - t < 3600]
+ if len(window) >= RATE_LIMIT_STARTS_PER_HOUR:
+ return False
+ window.append(now)
+ return True
+
+
+def _client_ip(request: Request) -> str:
+ fwd = request.headers.get("x-forwarded-for")
+ if fwd:
+ return fwd.split(",")[0].strip()
+ return request.client.host if request.client else "unknown"
+
+
+# --- models ------------------------------------------------------------------
+class StartResponse(BaseModel):
+ session_token: str
+ tasks: list[dict]
+ time_limit_s: int
+
+
+class FinishRequest(BaseModel):
+ session_token: str
+ answers: dict[str, int] = Field(default_factory=dict)
+ total_time_s: float = 0.0
+
+
+class FinishResponse(BaseModel):
+ result_id: str
+ score: dict
+
+
+# --- static pages -------------------------------------------------------------
+@app.get("/")
+def index():
+ return FileResponse(FRONTEND_DIR / "index.html")
+
+
+@app.get("/test")
+def test_page():
+ return FileResponse(FRONTEND_DIR / "test.html")
+
+
+@app.get("/v/{result_id}")
+def verify_page(result_id: str):
+ return FileResponse(FRONTEND_DIR / "verify.html")
+
+
+@app.get("/assets/{filename}")
+def assets(filename: str):
+ # simple safe-join guard
+ if "/" in filename or ".." in filename:
+ raise HTTPException(404)
+ path = FRONTEND_DIR / "assets" / filename
+ if not path.exists():
+ raise HTTPException(404)
+ return FileResponse(path)
+
+
+@app.get("/.well-known/veriq-pubkey.txt")
+def pubkey():
+ return PlainTextResponse(signing.public_key_b64() + "\n")
+
+
+# --- API ----------------------------------------------------------------------
+@app.post("/api/start", response_model=StartResponse)
+def api_start(request: Request):
+ ip = _client_ip(request)
+ if not _rate_ok(ip):
+ raise HTTPException(429, "Too many attempts, try later")
+
+ bank = scoring.load_bank()
+ all_tasks = bank["tasks"]
+ # v1: use all tasks, shuffled deterministically per session
+ ids = [t["id"] for t in all_tasks]
+ secrets.SystemRandom().shuffle(ids)
+ tasks = [scoring.task_by_id(tid) for tid in ids]
+
+ token = secrets.token_urlsafe(24)
+ SESSIONS[token] = {
+ "task_ids": ids,
+ "started_at": time.time(),
+ "ip": ip,
+ }
+ # prune expired sessions
+ now = time.time()
+ for k in [k for k, v in SESSIONS.items() if now - v["started_at"] > SESSION_TTL_S]:
+ SESSIONS.pop(k, None)
+
+ db.log_event("start", {"ip_hash": secrets.token_hex(4)})
+ # strip answers before sending
+ public_tasks = [
+ {k: t[k] for k in ("id", "category", "difficulty", "time_limit_s",
+ "prompt", "choices")}
+ for t in tasks
+ ]
+ total_limit = sum(t.get("time_limit_s", 60) for t in tasks)
+ return StartResponse(session_token=token, tasks=public_tasks,
+ time_limit_s=total_limit)
+
+
+@app.post("/api/finish", response_model=FinishResponse)
+def api_finish(req: FinishRequest, request: Request):
+ sess = SESSIONS.get(req.session_token)
+ if sess is None:
+ raise HTTPException(404, "Session not found or expired")
+ if time.time() - sess["started_at"] > SESSION_TTL_S:
+ SESSIONS.pop(req.session_token, None)
+ raise HTTPException(410, "Session expired")
+ SESSIONS.pop(req.session_token, None)
+
+ task_ids = sess["task_ids"]
+ # ignore answers for unknown tasks
+ answers = {k: v for k, v in req.answers.items() if scoring.task_by_id(k)}
+
+ flagged = scoring.sanity_check(answers, task_ids, req.total_time_s)
+ score = scoring.score_test(answers, task_ids, req.total_time_s, flagged)
+
+ result_id = db.new_result_id()
+ completed_at = time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime())
+ payload = signing.canonical_payload(
+ result_id=result_id,
+ score=score["iq"],
+ percentile=score["percentile"],
+ bank_version=scoring.bank_version(),
+ identity=None, # phase 2: identity binding
+ completed_at=completed_at,
+ )
+ signature = signing.sign_payload(payload)
+
+ db.save_result(
+ result_id=result_id,
+ score=score["iq"],
+ percentile=score["percentile"],
+ bank_version=scoring.bank_version(),
+ identity=None,
+ completed_at=completed_at,
+ payload=payload,
+ signature=signature,
+ answers=answers,
+ flagged=flagged,
+ )
+ db.log_event("finish", {"flagged": flagged, "iq": score["iq"]})
+ return FinishResponse(result_id=result_id, score=score)
+
+
+@app.get("/api/result/{result_id}")
+def api_result(result_id: str):
+ row = db.get_result(result_id)
+ if row is None:
+ raise HTTPException(404, "Result not found")
+ valid = signing.verify_payload(row["payload"], row["signature"])
+ payload = json.loads(row["payload"])
+ return JSONResponse({
+ "result_id": result_id,
+ "iq": row["score"],
+ "percentile": row["percentile"],
+ "completed_at": row["completed_at"],
+ "identity": row["identity"],
+ "bank_version": row["bank_version"],
+ "signature_valid": valid,
+ "signature": row["signature"],
+ "payload": payload,
+ "flagged": bool(row["flagged"]),
+ })
+
+
+@app.get("/api/stats")
+def api_stats():
+ return db.event_stats()
+
+
+# --- startup --------------------------------------------------------------------
+@app.on_event("startup")
+def startup():
+ db.init_db()
+ # fail fast if bank is missing/broken
+ scoring.load_bank()
+
+
+if __name__ == "__main__":
+ import uvicorn
+ uvicorn.run(app, host="127.0.0.1", port=int(
+ os.environ.get("VERIQ_PORT", "8801")))
\ No newline at end of file
diff --git a/backend/scoring.py b/backend/scoring.py
new file mode 100644
index 0000000..4066a2e
--- /dev/null
+++ b/backend/scoring.py
@@ -0,0 +1,139 @@
+"""VerIQ — scoring: raw points -> IQ score + percentile.
+
+v1 model: fixed weights per difficulty tier, z-score normalization against
+the calibration population stored in calibration.json. Until enough real
+completions accumulate, we fall back to a synthetic prior (see PRIOR below)
+and the result page must show the "preliminary calibration" label.
+"""
+import json
+import math
+import os
+from pathlib import Path
+
+BANK_PATH = Path(os.environ.get(
+ "VERIQ_BANK_PATH",
+ Path(__file__).resolve().parent.parent / "content" / "questions.json"))
+CALIB_PATH = Path(os.environ.get(
+ "VERIQ_CALIB_PATH",
+ Path(__file__).resolve().parent.parent / "content" / "calibration.json"))
+
+# Synthetic prior until real calibration data exists:
+PRIOR_MEAN_RAW = 0.55 # expected fraction of max raw score
+PRIOR_SIGMA_RAW = 0.18 # spread of raw fraction
+MIN_CALIBRATION_N = 500
+
+_bank_cache: dict | None = None
+
+
+def load_bank() -> dict:
+ global _bank_cache
+ if _bank_cache is None:
+ _bank_cache = json.loads(BANK_PATH.read_text(encoding="utf-8"))
+ return _bank_cache
+
+
+def bank_version() -> str:
+ return load_bank()["version"]
+
+
+def task_by_id(task_id: str) -> dict | None:
+ for t in load_bank()["tasks"]:
+ if t["id"] == task_id:
+ return t
+ return None
+
+
+def max_raw_score(task_ids: list[str]) -> int:
+ total = 0
+ for tid in task_ids:
+ t = task_by_id(tid)
+ if t:
+ total += t["weight"]
+ return total
+
+
+def raw_score(answers: dict[str, int], task_ids: list[str]) -> int:
+ """answers: {task_id: chosen_index}. Missing = wrong."""
+ total = 0
+ for tid in task_ids:
+ t = task_by_id(tid)
+ if not t:
+ continue
+ chosen = answers.get(tid)
+ if chosen is not None and chosen == t["answer_index"]:
+ total += t["weight"]
+ return total
+
+
+def _calibration() -> dict:
+ """Real calibration stats if enough data, else synthetic prior."""
+ try:
+ cal = json.loads(CALIB_PATH.read_text(encoding="utf-8"))
+ if cal.get("n", 0) >= MIN_CALIBRATION_N:
+ return cal
+ except Exception:
+ pass
+ return None
+
+
+def score_test(answers: dict[str, int], task_ids: list[str],
+ total_time_s: float, flagged: bool) -> dict:
+ """Compute raw score, IQ, percentile, category breakdown."""
+ bank = load_bank()
+ max_raw = max_raw_score(task_ids)
+ raw = raw_score(answers, task_ids)
+
+ cal = _calibration()
+ if cal:
+ mean, sigma = cal["mean_raw"], cal["sigma_raw"]
+ calibrated = True
+ else:
+ mean = PRIOR_MEAN_RAW * max_raw
+ sigma = PRIOR_SIGMA_RAW * max_raw
+ calibrated = False
+
+ z = (raw - mean) / sigma if sigma > 0 else 0.0
+ iq = round(100 + 15 * max(-4.0, min(4.0, z)))
+ # percentile from normal CDF
+ percentile = round(0.5 * (1 + math.erf(z / math.sqrt(2))) * 100, 1)
+
+ # category breakdown
+ cats: dict[str, dict] = {}
+ for tid in task_ids:
+ t = task_by_id(tid)
+ if not t:
+ continue
+ c = cats.setdefault(t["category"], {"correct": 0, "total": 0})
+ c["total"] += 1
+ chosen = answers.get(tid)
+ if chosen is not None and chosen == t["answer_index"]:
+ c["correct"] += 1
+
+ return {
+ "raw": raw,
+ "max_raw": max_raw,
+ "iq": iq,
+ "percentile": percentile,
+ "calibrated": calibrated,
+ "flagged": flagged,
+ "total_time_s": round(total_time_s, 1),
+ "categories": cats,
+ }
+
+
+def sanity_check(answers: dict[str, int], task_ids: list[str],
+ total_time_s: float) -> bool:
+ """Flag implausible completions: too fast or suspiciously perfect."""
+ n = len(task_ids)
+ if n == 0:
+ return True
+ per_task = total_time_s / n
+ raw = raw_score(answers, task_ids)
+ max_raw = max_raw_score(task_ids)
+ # Under 3 s per task with near-perfect score -> bot/cheat pattern
+ if per_task < 3.0 and raw >= 0.9 * max_raw:
+ return True
+ # Under 1.5 s per task at all -> auto-flag regardless
+ if per_task < 1.5:
+ return True
+ return False
\ No newline at end of file
diff --git a/backend/signing.py b/backend/signing.py
new file mode 100644
index 0000000..9b435cc
--- /dev/null
+++ b/backend/signing.py
@@ -0,0 +1,72 @@
+"""VerIQ — result signing (Ed25519)."""
+import base64
+import json
+import os
+from pathlib import Path
+
+from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
+from cryptography.hazmat.primitives import serialization
+
+KEY_PATH = Path(os.environ.get("VERIQ_KEY_PATH", "/var/lib/veriq/ed25519.key"))
+
+_cached_priv: Ed25519PrivateKey | None = None
+
+
+def _load_or_create_private_key() -> Ed25519PrivateKey:
+ """Load private key from disk; generate and persist on first run."""
+ global _cached_priv
+ if _cached_priv is not None:
+ return _cached_priv
+ if KEY_PATH.exists():
+ raw = KEY_PATH.read_bytes()
+ _cached_priv = Ed25519PrivateKey.from_private_bytes(raw)
+ else:
+ _cached_priv = Ed25519PrivateKey.generate()
+ KEY_PATH.parent.mkdir(parents=True, exist_ok=True)
+ KEY_PATH.write_bytes(_cached_priv.private_bytes(
+ serialization.Encoding.Raw,
+ serialization.PrivateFormat.Raw,
+ serialization.NoEncryption(),
+ ))
+ KEY_PATH.chmod(0o600)
+ return _cached_priv
+
+
+def public_key_b64() -> str:
+ """Base64 of raw 32-byte public key — the value published for verification."""
+ pub = _load_or_create_private_key().public_key()
+ raw = pub.public_bytes(
+ serialization.Encoding.Raw,
+ serialization.PublicFormat.Raw,
+ )
+ return base64.b64encode(raw).decode()
+
+
+def canonical_payload(result_id: str, score: int, percentile: float,
+ bank_version: str, identity: str | None,
+ completed_at: str) -> str:
+ """Deterministic JSON string that gets signed."""
+ obj = {
+ "result_id": result_id,
+ "score": score,
+ "percentile": percentile,
+ "bank_version": bank_version,
+ "identity": identity,
+ "completed_at": completed_at,
+ }
+ return json.dumps(obj, sort_keys=True, separators=(",", ":"))
+
+
+def sign_payload(payload: str) -> str:
+ sig = _load_or_create_private_key().sign(payload.encode("utf-8"))
+ return base64.b64encode(sig).decode()
+
+
+def verify_payload(payload: str, signature_b64: str) -> bool:
+ """Verify with our own public key (server-side re-check on verify page)."""
+ try:
+ pub = _load_or_create_private_key().public_key()
+ pub.verify(base64.b64decode(signature_b64), payload.encode("utf-8"))
+ return True
+ except Exception:
+ return False
\ No newline at end of file
diff --git a/content/calibration.json b/content/calibration.json
new file mode 100644
index 0000000..1a20466
--- /dev/null
+++ b/content/calibration.json
@@ -0,0 +1,6 @@
+{
+ "n": 0,
+ "mean_raw": null,
+ "sigma_raw": null,
+ "updated_at": null
+}
\ No newline at end of file
diff --git a/content/questions.json b/content/questions.json
new file mode 100644
index 0000000..7256ae2
--- /dev/null
+++ b/content/questions.json
@@ -0,0 +1,1245 @@
+{
+ "version": "1.0.0",
+ "tasks": [
+ {
+ "id": "num-001",
+ "category": "numeric",
+ "difficulty": 1,
+ "weight": 1,
+ "time_limit_s": 45,
+ "prompt": {
+ "text": "2, 4, 6, 8, ?",
+ "svg": null
+ },
+ "choices": [
+ {
+ "text": "7",
+ "svg": null
+ },
+ {
+ "text": "9",
+ "svg": null
+ },
+ {
+ "text": "10",
+ "svg": null
+ },
+ {
+ "text": "12",
+ "svg": null
+ }
+ ],
+ "answer_index": 2,
+ "explanation": "Arithmetic progression: +2 each step."
+ },
+ {
+ "id": "num-002",
+ "category": "numeric",
+ "difficulty": 1,
+ "weight": 1,
+ "time_limit_s": 45,
+ "prompt": {
+ "text": "1, 3, 5, 7, ?",
+ "svg": null
+ },
+ "choices": [
+ {
+ "text": "8",
+ "svg": null
+ },
+ {
+ "text": "9",
+ "svg": null
+ },
+ {
+ "text": "10",
+ "svg": null
+ },
+ {
+ "text": "11",
+ "svg": null
+ }
+ ],
+ "answer_index": 1,
+ "explanation": "Odd numbers: +2 each step."
+ },
+ {
+ "id": "num-003",
+ "category": "numeric",
+ "difficulty": 2,
+ "weight": 2,
+ "time_limit_s": 45,
+ "prompt": {
+ "text": "3, 6, 12, 24, ?",
+ "svg": null
+ },
+ "choices": [
+ {
+ "text": "30",
+ "svg": null
+ },
+ {
+ "text": "36",
+ "svg": null
+ },
+ {
+ "text": "48",
+ "svg": null
+ },
+ {
+ "text": "64",
+ "svg": null
+ }
+ ],
+ "answer_index": 2,
+ "explanation": "Each term doubles: ×2."
+ },
+ {
+ "id": "num-004",
+ "category": "numeric",
+ "difficulty": 2,
+ "weight": 2,
+ "time_limit_s": 45,
+ "prompt": {
+ "text": "1, 1, 2, 3, 5, 8, ?",
+ "svg": null
+ },
+ "choices": [
+ {
+ "text": "11",
+ "svg": null
+ },
+ {
+ "text": "12",
+ "svg": null
+ },
+ {
+ "text": "13",
+ "svg": null
+ },
+ {
+ "text": "14",
+ "svg": null
+ }
+ ],
+ "answer_index": 2,
+ "explanation": "Fibonacci: each term is the sum of the two before it (5+8=13)."
+ },
+ {
+ "id": "num-005",
+ "category": "numeric",
+ "difficulty": 2,
+ "weight": 2,
+ "time_limit_s": 45,
+ "prompt": {
+ "text": "81, 64, 49, 36, ?",
+ "svg": null
+ },
+ "choices": [
+ {
+ "text": "16",
+ "svg": null
+ },
+ {
+ "text": "25",
+ "svg": null
+ },
+ {
+ "text": "28",
+ "svg": null
+ },
+ {
+ "text": "30",
+ "svg": null
+ }
+ ],
+ "answer_index": 1,
+ "explanation": "Descending perfect squares: 9², 8², 7², 6², 5²=25."
+ },
+ {
+ "id": "num-006",
+ "category": "numeric",
+ "difficulty": 3,
+ "weight": 3,
+ "time_limit_s": 60,
+ "prompt": {
+ "text": "2, 3, 5, 9, 17, ?",
+ "svg": null
+ },
+ "choices": [
+ {
+ "text": "31",
+ "svg": null
+ },
+ {
+ "text": "32",
+ "svg": null
+ },
+ {
+ "text": "33",
+ "svg": null
+ },
+ {
+ "text": "34",
+ "svg": null
+ }
+ ],
+ "answer_index": 2,
+ "explanation": "Differences double: +1, +2, +4, +8, +16 → 17+16=33."
+ },
+ {
+ "id": "num-007",
+ "category": "numeric",
+ "difficulty": 3,
+ "weight": 3,
+ "time_limit_s": 60,
+ "prompt": {
+ "text": "4, 7, 13, 25, ?",
+ "svg": null
+ },
+ "choices": [
+ {
+ "text": "37",
+ "svg": null
+ },
+ {
+ "text": "43",
+ "svg": null
+ },
+ {
+ "text": "49",
+ "svg": null
+ },
+ {
+ "text": "51",
+ "svg": null
+ }
+ ],
+ "answer_index": 2,
+ "explanation": "Differences double: +3, +6, +12, +24 → 25+24=49."
+ },
+ {
+ "id": "num-008",
+ "category": "numeric",
+ "difficulty": 3,
+ "weight": 3,
+ "time_limit_s": 60,
+ "prompt": {
+ "text": "1, 4, 9, 16, 25, ?",
+ "svg": null
+ },
+ "choices": [
+ {
+ "text": "30",
+ "svg": null
+ },
+ {
+ "text": "32",
+ "svg": null
+ },
+ {
+ "text": "36",
+ "svg": null
+ },
+ {
+ "text": "49",
+ "svg": null
+ }
+ ],
+ "answer_index": 2,
+ "explanation": "Perfect squares: 6²=36."
+ },
+ {
+ "id": "num-009",
+ "category": "numeric",
+ "difficulty": 4,
+ "weight": 4,
+ "time_limit_s": 75,
+ "prompt": {
+ "text": "2, 6, 12, 20, 30, ?",
+ "svg": null
+ },
+ "choices": [
+ {
+ "text": "36",
+ "svg": null
+ },
+ {
+ "text": "40",
+ "svg": null
+ },
+ {
+ "text": "42",
+ "svg": null
+ },
+ {
+ "text": "44",
+ "svg": null
+ }
+ ],
+ "answer_index": 2,
+ "explanation": "n(n+1): 1·2, 2·3, 3·4, 4·5, 5·6, 6·7=42. Also +4,+6,+8,+10,+12."
+ },
+ {
+ "id": "num-010",
+ "category": "numeric",
+ "difficulty": 5,
+ "weight": 5,
+ "time_limit_s": 90,
+ "prompt": {
+ "text": "1, 2, 6, 24, 120, ?",
+ "svg": null
+ },
+ "choices": [
+ {
+ "text": "240",
+ "svg": null
+ },
+ {
+ "text": "360",
+ "svg": null
+ },
+ {
+ "text": "600",
+ "svg": null
+ },
+ {
+ "text": "720",
+ "svg": null
+ }
+ ],
+ "answer_index": 3,
+ "explanation": "Factorials: 1!, 2!, 3!, 4!, 5!, 6!=720."
+ },
+ {
+ "id": "mat-001",
+ "category": "matrix",
+ "difficulty": 1,
+ "weight": 1,
+ "time_limit_s": 45,
+ "prompt": {
+ "text": "How many circles should replace the question mark?",
+ "svg": ""
+ },
+ "choices": [
+ {
+ "text": "4 circles",
+ "svg": ""
+ },
+ {
+ "text": "5 circles",
+ "svg": ""
+ },
+ {
+ "text": "3 circles",
+ "svg": ""
+ },
+ {
+ "text": "2 circles",
+ "svg": ""
+ }
+ ],
+ "answer_index": 0,
+ "explanation": "Count grows by one each step: 1, 2, 3 → 4."
+ },
+ {
+ "id": "mat-002",
+ "category": "matrix",
+ "difficulty": 2,
+ "weight": 2,
+ "time_limit_s": 60,
+ "prompt": {
+ "text": "Which figure continues the sequence?",
+ "svg": ""
+ },
+ "choices": [
+ {
+ "text": "Hexagon",
+ "svg": ""
+ },
+ {
+ "text": "Heptagon",
+ "svg": ""
+ },
+ {
+ "text": "Circle",
+ "svg": ""
+ },
+ {
+ "text": "Square",
+ "svg": ""
+ }
+ ],
+ "answer_index": 0,
+ "explanation": "Number of sides increases by one: 3, 4, 5 → 6."
+ },
+ {
+ "id": "mat-003",
+ "category": "matrix",
+ "difficulty": 2,
+ "weight": 2,
+ "time_limit_s": 60,
+ "prompt": {
+ "text": "Which arrow comes next?",
+ "svg": ""
+ },
+ "choices": [
+ {
+ "text": "Left",
+ "svg": ""
+ },
+ {
+ "text": "Up",
+ "svg": ""
+ },
+ {
+ "text": "Right",
+ "svg": ""
+ },
+ {
+ "text": "Down",
+ "svg": ""
+ }
+ ],
+ "answer_index": 0,
+ "explanation": "The arrow rotates 90° clockwise each step: up, right, down → left."
+ },
+ {
+ "id": "mat-004",
+ "category": "matrix",
+ "difficulty": 3,
+ "weight": 3,
+ "time_limit_s": 60,
+ "prompt": {
+ "text": "Which circle comes next?",
+ "svg": ""
+ },
+ "choices": [
+ {
+ "text": "Half-filled",
+ "svg": ""
+ },
+ {
+ "text": "Fully filled",
+ "svg": ""
+ },
+ {
+ "text": "Empty",
+ "svg": ""
+ },
+ {
+ "text": "Half-filled, other side",
+ "svg": ""
+ }
+ ],
+ "answer_index": 0,
+ "explanation": "Cycle of three: full, half, empty — after the 4th (full) comes half."
+ },
+ {
+ "id": "mat-005",
+ "category": "matrix",
+ "difficulty": 3,
+ "weight": 3,
+ "time_limit_s": 60,
+ "prompt": {
+ "text": "What number replaces the question mark?\n4 9 16\n25 36 49\n64 ? 100",
+ "svg": null
+ },
+ "choices": [
+ {
+ "text": "72",
+ "svg": null
+ },
+ {
+ "text": "81",
+ "svg": null
+ },
+ {
+ "text": "83",
+ "svg": null
+ },
+ {
+ "text": "90",
+ "svg": null
+ }
+ ],
+ "answer_index": 1,
+ "explanation": "Perfect squares in reading order: 2²…10²; missing is 9²=81."
+ },
+ {
+ "id": "mat-006",
+ "category": "matrix",
+ "difficulty": 3,
+ "weight": 3,
+ "time_limit_s": 60,
+ "prompt": {
+ "text": "What letter replaces the question mark?\nB D F\nH J L\nN P ?",
+ "svg": null
+ },
+ "choices": [
+ {
+ "text": "Q",
+ "svg": null
+ },
+ {
+ "text": "R",
+ "svg": null
+ },
+ {
+ "text": "S",
+ "svg": null
+ },
+ {
+ "text": "T",
+ "svg": null
+ }
+ ],
+ "answer_index": 1,
+ "explanation": "Each row skips one letter and continues from the previous row: …N, P, R."
+ },
+ {
+ "id": "mat-007",
+ "category": "matrix",
+ "difficulty": 4,
+ "weight": 4,
+ "time_limit_s": 75,
+ "prompt": {
+ "text": "Which arrow comes next? (Fill matters.)",
+ "svg": ""
+ },
+ "choices": [
+ {
+ "text": "Left, empty",
+ "svg": ""
+ },
+ {
+ "text": "Left, filled",
+ "svg": ""
+ },
+ {
+ "text": "Up, empty",
+ "svg": ""
+ },
+ {
+ "text": "Down, empty",
+ "svg": ""
+ }
+ ],
+ "answer_index": 0,
+ "explanation": "Two combined rules: rotate 90° clockwise; fill alternates filled/empty. 4th arrow: left, empty."
+ },
+ {
+ "id": "mat-008",
+ "category": "matrix",
+ "difficulty": 4,
+ "weight": 4,
+ "time_limit_s": 75,
+ "prompt": {
+ "text": "Which figure completes the matrix?",
+ "svg": ""
+ },
+ "choices": [
+ {
+ "text": "Big circle",
+ "svg": ""
+ },
+ {
+ "text": "Small circle",
+ "svg": ""
+ },
+ {
+ "text": "Big square",
+ "svg": ""
+ },
+ {
+ "text": "Small triangle",
+ "svg": ""
+ }
+ ],
+ "answer_index": 0,
+ "explanation": "Rows: size grows (small → big). Columns: shape stays (squares top, circles bottom). Missing: big circle."
+ },
+ {
+ "id": "mat-009",
+ "category": "matrix",
+ "difficulty": 4,
+ "weight": 4,
+ "time_limit_s": 75,
+ "prompt": {
+ "text": "What number replaces the question mark?\n3 5 8\n4 7 11\n5 9 ?",
+ "svg": null
+ },
+ "choices": [
+ {
+ "text": "12",
+ "svg": null
+ },
+ {
+ "text": "13",
+ "svg": null
+ },
+ {
+ "text": "14",
+ "svg": null
+ },
+ {
+ "text": "15",
+ "svg": null
+ }
+ ],
+ "answer_index": 2,
+ "explanation": "Third column = first + second: 3+5=8, 4+7=11, 5+9=14."
+ },
+ {
+ "id": "mat-010",
+ "category": "matrix",
+ "difficulty": 5,
+ "weight": 5,
+ "time_limit_s": 90,
+ "prompt": {
+ "text": "Panel C must combine panels A and B by XOR: a position gets a dot only if it appears in exactly one of A, B. Which is C?",
+ "svg": ""
+ },
+ "choices": [
+ {
+ "text": "Dots at 1 and 3",
+ "svg": ""
+ },
+ {
+ "text": "Dot at 2 only",
+ "svg": ""
+ },
+ {
+ "text": "Dots at 1, 2 and 3",
+ "svg": ""
+ },
+ {
+ "text": "No dots",
+ "svg": ""
+ }
+ ],
+ "answer_index": 0,
+ "explanation": "A={1,2}, B={2,3}; XOR keeps positions present in exactly one set: {1,3}."
+ },
+ {
+ "id": "ver-001",
+ "category": "verbal",
+ "difficulty": 1,
+ "weight": 1,
+ "time_limit_s": 40,
+ "prompt": {
+ "text": "Which word does not belong?",
+ "svg": null
+ },
+ "choices": [
+ {
+ "text": "apple",
+ "svg": null
+ },
+ {
+ "text": "banana",
+ "svg": null
+ },
+ {
+ "text": "carrot",
+ "svg": null
+ },
+ {
+ "text": "cherry",
+ "svg": null
+ }
+ ],
+ "answer_index": 2,
+ "explanation": "Carrot is a vegetable; the rest are fruits."
+ },
+ {
+ "id": "ver-002",
+ "category": "verbal",
+ "difficulty": 1,
+ "weight": 1,
+ "time_limit_s": 40,
+ "prompt": {
+ "text": "kitten : cat :: puppy : ?",
+ "svg": null
+ },
+ "choices": [
+ {
+ "text": "dog",
+ "svg": null
+ },
+ {
+ "text": "wolf",
+ "svg": null
+ },
+ {
+ "text": "horse",
+ "svg": null
+ },
+ {
+ "text": "kitten",
+ "svg": null
+ }
+ ],
+ "answer_index": 0,
+ "explanation": "A kitten is a young cat; a puppy is a young dog."
+ },
+ {
+ "id": "ver-003",
+ "category": "verbal",
+ "difficulty": 2,
+ "weight": 2,
+ "time_limit_s": 40,
+ "prompt": {
+ "text": "Which word does not belong?",
+ "svg": null
+ },
+ "choices": [
+ {
+ "text": "triangle",
+ "svg": null
+ },
+ {
+ "text": "square",
+ "svg": null
+ },
+ {
+ "text": "circle",
+ "svg": null
+ },
+ {
+ "text": "pentagon",
+ "svg": null
+ }
+ ],
+ "answer_index": 2,
+ "explanation": "A circle has no straight sides or vertices; the rest are polygons."
+ },
+ {
+ "id": "ver-004",
+ "category": "verbal",
+ "difficulty": 2,
+ "weight": 2,
+ "time_limit_s": 40,
+ "prompt": {
+ "text": "author : book :: composer : ?",
+ "svg": null
+ },
+ "choices": [
+ {
+ "text": "symphony",
+ "svg": null
+ },
+ {
+ "text": "orchestra",
+ "svg": null
+ },
+ {
+ "text": "piano",
+ "svg": null
+ },
+ {
+ "text": "conductor",
+ "svg": null
+ }
+ ],
+ "answer_index": 0,
+ "explanation": "An author creates a book; a composer creates a symphony."
+ },
+ {
+ "id": "ver-005",
+ "category": "verbal",
+ "difficulty": 2,
+ "weight": 2,
+ "time_limit_s": 40,
+ "prompt": {
+ "text": "thermometer : temperature :: barometer : ?",
+ "svg": null
+ },
+ "choices": [
+ {
+ "text": "humidity",
+ "svg": null
+ },
+ {
+ "text": "pressure",
+ "svg": null
+ },
+ {
+ "text": "wind",
+ "svg": null
+ },
+ {
+ "text": "rainfall",
+ "svg": null
+ }
+ ],
+ "answer_index": 1,
+ "explanation": "A thermometer measures temperature; a barometer measures pressure."
+ },
+ {
+ "id": "ver-006",
+ "category": "verbal",
+ "difficulty": 3,
+ "weight": 3,
+ "time_limit_s": 50,
+ "prompt": {
+ "text": "Which word does not belong?",
+ "svg": null
+ },
+ "choices": [
+ {
+ "text": "run",
+ "svg": null
+ },
+ {
+ "text": "walk",
+ "svg": null
+ },
+ {
+ "text": "swim",
+ "svg": null
+ },
+ {
+ "text": "think",
+ "svg": null
+ }
+ ],
+ "answer_index": 3,
+ "explanation": "Think is a mental process; the rest are physical activities."
+ },
+ {
+ "id": "ver-007",
+ "category": "verbal",
+ "difficulty": 3,
+ "weight": 3,
+ "time_limit_s": 50,
+ "prompt": {
+ "text": "All roses fade. Some flowers fade. Which conclusion necessarily follows?",
+ "svg": null
+ },
+ "choices": [
+ {
+ "text": "All flowers are roses",
+ "svg": null
+ },
+ {
+ "text": "Some flowers are roses",
+ "svg": null
+ },
+ {
+ "text": "No flower is a rose",
+ "svg": null
+ },
+ {
+ "text": "None of these necessarily follows",
+ "svg": null
+ }
+ ],
+ "answer_index": 3,
+ "explanation": "The premises do not force any overlap between roses and flowers beyond what is stated; none of A–C is entailed."
+ },
+ {
+ "id": "ver-008",
+ "category": "verbal",
+ "difficulty": 4,
+ "weight": 4,
+ "time_limit_s": 60,
+ "prompt": {
+ "text": "A, C, F, J, O, ?",
+ "svg": null
+ },
+ "choices": [
+ {
+ "text": "S",
+ "svg": null
+ },
+ {
+ "text": "T",
+ "svg": null
+ },
+ {
+ "text": "U",
+ "svg": null
+ },
+ {
+ "text": "V",
+ "svg": null
+ }
+ ],
+ "answer_index": 2,
+ "explanation": "Gaps grow: +2, +3, +4, +5, +6 → O(15)+6=U(21)."
+ },
+ {
+ "id": "ver-009",
+ "category": "verbal",
+ "difficulty": 4,
+ "weight": 4,
+ "time_limit_s": 60,
+ "prompt": {
+ "text": "No artists are cheap. Some cheap things are useful. Which conclusion necessarily follows?",
+ "svg": null
+ },
+ "choices": [
+ {
+ "text": "Some useful things are not artists",
+ "svg": null
+ },
+ {
+ "text": "All useful things are artists",
+ "svg": null
+ },
+ {
+ "text": "Some artists are useful",
+ "svg": null
+ },
+ {
+ "text": "None of these necessarily follows",
+ "svg": null
+ }
+ ],
+ "answer_index": 0,
+ "explanation": "Take a useful cheap thing: it is cheap, and no artist is cheap, so it is not an artist."
+ },
+ {
+ "id": "ver-010",
+ "category": "verbal",
+ "difficulty": 5,
+ "weight": 5,
+ "time_limit_s": 75,
+ "prompt": {
+ "text": "In a certain code CAT is written DBU (each letter shifts +1). How is DOG written?",
+ "svg": null
+ },
+ "choices": [
+ {
+ "text": "EPH",
+ "svg": null
+ },
+ {
+ "text": "EPG",
+ "svg": null
+ },
+ {
+ "text": "FPH",
+ "svg": null
+ },
+ {
+ "text": "DPH",
+ "svg": null
+ }
+ ],
+ "answer_index": 0,
+ "explanation": "D→E, O→P, G→H."
+ },
+ {
+ "id": "spa-001",
+ "category": "spatial",
+ "difficulty": 1,
+ "weight": 1,
+ "time_limit_s": 60,
+ "prompt": {
+ "text": "How many triangles are in the figure?",
+ "svg": ""
+ },
+ "choices": [
+ {
+ "text": "4",
+ "svg": null
+ },
+ {
+ "text": "5",
+ "svg": null
+ },
+ {
+ "text": "6",
+ "svg": null
+ },
+ {
+ "text": "8",
+ "svg": null
+ }
+ ],
+ "answer_index": 1,
+ "explanation": "4 small triangles + 1 large outer triangle = 5."
+ },
+ {
+ "id": "spa-002",
+ "category": "spatial",
+ "difficulty": 2,
+ "weight": 2,
+ "time_limit_s": 60,
+ "prompt": {
+ "text": "The figure can be rotated in the plane but never flipped (mirrored). Which option is the same figure?",
+ "svg": ""
+ },
+ "choices": [
+ {
+ "text": "A",
+ "svg": ""
+ },
+ {
+ "text": "B",
+ "svg": ""
+ },
+ {
+ "text": "C",
+ "svg": ""
+ },
+ {
+ "text": "D",
+ "svg": ""
+ }
+ ],
+ "answer_index": 0,
+ "explanation": "Option A is the original rotated 90° clockwise. B is its mirror image; C and D are different shapes."
+ },
+ {
+ "id": "spa-003",
+ "category": "spatial",
+ "difficulty": 1,
+ "weight": 1,
+ "time_limit_s": 60,
+ "prompt": {
+ "text": "How many edges does a triangular prism have?",
+ "svg": null
+ },
+ "choices": [
+ {
+ "text": "6",
+ "svg": null
+ },
+ {
+ "text": "8",
+ "svg": null
+ },
+ {
+ "text": "9",
+ "svg": null
+ },
+ {
+ "text": "12",
+ "svg": null
+ }
+ ],
+ "answer_index": 2,
+ "explanation": "2 triangular bases (3+3 edges) + 3 side edges = 9."
+ },
+ {
+ "id": "spa-004",
+ "category": "spatial",
+ "difficulty": 3,
+ "weight": 3,
+ "time_limit_s": 75,
+ "prompt": {
+ "text": "Which net folds into a cube?",
+ "svg": null
+ },
+ "choices": [
+ {
+ "text": "A",
+ "svg": ""
+ },
+ {
+ "text": "B",
+ "svg": ""
+ },
+ {
+ "text": "C",
+ "svg": ""
+ },
+ {
+ "text": "D",
+ "svg": ""
+ }
+ ],
+ "answer_index": 0,
+ "explanation": "A is the classic cross net. B (2×3 block) and C (1×6 strip) overlap faces; D has two tabs covering the same face."
+ },
+ {
+ "id": "spa-005",
+ "category": "spatial",
+ "difficulty": 3,
+ "weight": 3,
+ "time_limit_s": 75,
+ "prompt": {
+ "text": "A 3×3×3 cube is painted on the outside, then cut into 27 unit cubes. How many unit cubes have exactly two painted faces?",
+ "svg": null
+ },
+ "choices": [
+ {
+ "text": "6",
+ "svg": null
+ },
+ {
+ "text": "12",
+ "svg": null
+ },
+ {
+ "text": "18",
+ "svg": null
+ },
+ {
+ "text": "27",
+ "svg": null
+ }
+ ],
+ "answer_index": 1,
+ "explanation": "Exactly-two-face cubes sit on edges (not corners): 12 edges × 1 middle cube = 12."
+ },
+ {
+ "id": "spa-006",
+ "category": "spatial",
+ "difficulty": 2,
+ "weight": 2,
+ "time_limit_s": 60,
+ "prompt": {
+ "text": "Which flag is the original rotated 180°? (No flipping allowed.)",
+ "svg": ""
+ },
+ "choices": [
+ {
+ "text": "A",
+ "svg": ""
+ },
+ {
+ "text": "B",
+ "svg": ""
+ },
+ {
+ "text": "C",
+ "svg": ""
+ },
+ {
+ "text": "D",
+ "svg": ""
+ }
+ ],
+ "answer_index": 0,
+ "explanation": "A is the 180° rotation: pole on the right, pennant pointing left at the bottom. B and D are mirrors; C is the original."
+ },
+ {
+ "id": "spa-007",
+ "category": "spatial",
+ "difficulty": 2,
+ "weight": 2,
+ "time_limit_s": 60,
+ "prompt": {
+ "text": "How many faces does a square pyramid have?",
+ "svg": null
+ },
+ "choices": [
+ {
+ "text": "4",
+ "svg": null
+ },
+ {
+ "text": "5",
+ "svg": null
+ },
+ {
+ "text": "6",
+ "svg": null
+ },
+ {
+ "text": "8",
+ "svg": null
+ }
+ ],
+ "answer_index": 1,
+ "explanation": "1 square base + 4 triangular sides = 5."
+ },
+ {
+ "id": "spa-008",
+ "category": "spatial",
+ "difficulty": 3,
+ "weight": 3,
+ "time_limit_s": 75,
+ "prompt": {
+ "text": "Each stack is one unit cube deep. How many cubes are shown in total?",
+ "svg": ""
+ },
+ "choices": [
+ {
+ "text": "8",
+ "svg": null
+ },
+ {
+ "text": "9",
+ "svg": null
+ },
+ {
+ "text": "10",
+ "svg": null
+ },
+ {
+ "text": "12",
+ "svg": null
+ }
+ ],
+ "answer_index": 2,
+ "explanation": "Columns of 4, 2, 3, 1: 4+2+3+1=10."
+ },
+ {
+ "id": "spa-009",
+ "category": "spatial",
+ "difficulty": 4,
+ "weight": 4,
+ "time_limit_s": 90,
+ "prompt": {
+ "text": "A square sheet is folded in half along the vertical dashed line (left half over right). A hole is punched at the marked position through all layers. Where are the holes after unfolding?",
+ "svg": ""
+ },
+ "choices": [
+ {
+ "text": "A",
+ "svg": ""
+ },
+ {
+ "text": "B",
+ "svg": ""
+ },
+ {
+ "text": "C",
+ "svg": ""
+ },
+ {
+ "text": "D",
+ "svg": ""
+ }
+ ],
+ "answer_index": 0,
+ "explanation": "Unfolding mirrors the hole across the fold line: two holes at the same height, symmetric left-right."
+ },
+ {
+ "id": "spa-010",
+ "category": "spatial",
+ "difficulty": 5,
+ "weight": 5,
+ "time_limit_s": 120,
+ "prompt": {
+ "text": "A cube is painted: top and bottom red, one pair of opposite sides green, the other pair blue. It is cut into 27 unit cubes. How many unit cubes have exactly one green face?",
+ "svg": null
+ },
+ "choices": [
+ {
+ "text": "2",
+ "svg": null
+ },
+ {
+ "text": "4",
+ "svg": null
+ },
+ {
+ "text": "6",
+ "svg": null
+ },
+ {
+ "text": "8",
+ "svg": null
+ }
+ ],
+ "answer_index": 0,
+ "explanation": "Only the center cube of each green face has exactly one green face (edge-adjacent cubes touch red or blue too): 2 faces × 1 = 2."
+ }
+ ]
+}
\ No newline at end of file
diff --git a/docs/SPEC.md b/docs/SPEC.md
new file mode 100644
index 0000000..9d7e546
--- /dev/null
+++ b/docs/SPEC.md
@@ -0,0 +1,98 @@
+# VerIQ — Product & Tech Specification
+
+Version: 0.1 (2026-10-13)
+Owner: Eugene (architecture decisions) · Navi (implementation)
+
+## 1. Product vision
+
+A public IQ test where the result is a **verifiable credential**, not a screenshot.
+
+Market gap: every existing test (paid or free) outputs an image/PDF that anyone can forge. No mainstream test binds the result to a real identity with cryptographic proof. VerIQ does.
+
+Target audience: people who argue about IQ on X/Twitter, Reddit, Telegram; communities (Mensa-adjacent, dev communities); HR-adjacent curiosity segment.
+
+## 2. Core user flows
+
+### 2.1 Anonymous flow (default)
+1. Land on `/` → "Start test" (no registration required).
+2. Test: 30–40 tasks, ascending difficulty, per-section timer, total ~25 min.
+3. Result page: IQ score, percentile, breakdown by category.
+4. "Claim & verify" → sign-in with X or Instagram → result bound to identity → permanent link `/v/`.
+
+### 2.2 Verification flow (third party)
+1. Someone shares `/v/` link.
+2. Page shows: score, percentile, date, bound identity (avatar + handle), signature status (valid/invalid), service public key fingerprint.
+3. No login required. Page is shareable and indexable (OG tags).
+
+### 2.3 Premium flow (phase 3)
+- Free: score + percentile + basic breakdown.
+- Premium (Telegram Stars / crypto): full report — category analysis, response-time profile, retake history, printable certificate (signed PDF).
+
+## 3. Scoring model
+
+- Raw score: sum of task weights answered correctly (weights by difficulty tier).
+- Normalization: IQ = 100 + 15 × z, where z = (raw − mean) / σ of the current calibration population.
+- Until N ≥ 500 real completions: label "preliminary calibration" on result page.
+- Anti-cheat: one attempt per identity per 30 days for verified results; response-time sanity checks (too fast = flagged); task order randomized from seeded bank.
+
+## 4. Question bank
+
+- 40+ original tasks across 4 categories:
+ - numeric/symbolic sequences
+ - logical matrices (original, NOT Raven copies)
+ - verbal logic
+ - spatial reasoning (SVG figures)
+- Each task: `id`, `category`, `difficulty` (1–5), `weight`, `time_limit_s`, `prompt`, `choices[]`, `answer_index`, `explanation`.
+- Stored as JSON in `content/questions.json`; versioned — result records bank version used.
+
+## 5. Verification & signing
+
+- Service holds Ed25519 keypair. Private key: env var / secret file on VPS (never in repo). Public key: served at `/.well-known/veriq-pubkey.txt` and shown on verify page.
+- Signed payload (canonical JSON): `{result_id, score, percentile, bank_version, identity (optional), completed_at}`.
+- Signature: base64 Ed25519 over the canonical payload; stored with result.
+- Verify page re-checks signature server-side on every view (and shows "signature valid").
+- Result IDs: 8-char base62, URL-safe, collision-checked.
+
+## 6. Identity binding (phase 2)
+
+- **X (Twitter) OAuth 2.0 PKCE**: scope `users.read` + `tweet.read`. We store: `x_id`, `@handle`, `avatar_url`, `name`. No posting permissions.
+- **Instagram**: Facebook Login for Instagram (business/creator accounts) — store `ig_id`, `username`, `avatar`. Fallback if unavailable: "link your Instagram" via manual handle + confirmation post caption code (phase 2.5).
+- Binding is optional: anonymous results get verify links too, but without identity badge.
+- One identity can hold multiple results (history), latest is primary.
+
+## 7. Tech stack & architecture
+
+- **Backend:** FastAPI, Uvicorn, SQLite (WAL mode). Single VPS, single process + systemd. No external DB.
+- **Frontend:** vanilla HTML/CSS/JS, no build step, mobile-first, dark brutalist theme (fits brand).
+- **Signing:** `cryptography` (PyCA) — Ed25519.
+- **Rate limiting:** per-IP token bucket (slowapi or hand-rolled).
+- **Analytics:** self-hosted, minimal — events table (start, finish, share, claim), no third-party trackers.
+- **Deploy:** systemd unit + nginx reverse proxy + certbot. `scripts/deploy.sh`.
+
+## 8. Non-goals (v1)
+
+- No native mobile apps.
+- No gnexus-auth integration (standalone by owner's decision).
+- No multi-language UI (EN first, RU second — phase 2).
+- No adaptive item-response theory (IRT) — fixed weighted scoring for v1.
+
+## 9. Milestones
+
+| # | Milestone | Owner | Status |
+|---|-----------|-------|--------|
+| M1 | Repo + spec + skeleton | Navi | in progress |
+| M2 | Question bank v1 (40 tasks) | Navi | pending |
+| M3 | Test engine + scoring + result page | Navi | pending |
+| M4 | Ed25519 signing + /v/ verify page | Navi | pending |
+| M5 | X OAuth binding | Navi | pending (needs app registration by owner) |
+| M6 | Instagram binding | Navi | pending |
+| M7 | Deploy to VPS + domain | Navi + Eugene | blocked until VPS ready (2026-10-14) |
+| M8 | OG cards + share buttons + analytics | Navi | pending |
+| M9 | Premium report + payments | Navi | phase 3 |
+
+## 10. Risks
+
+- **Calibration validity:** mitigated by honest "preliminary" label + iterative norming.
+- **Legal (test content):** all original items; no Raven/Mensa copies.
+- **OAuth approval delays:** X developer account review can take days — owner registers apps; anonymous flow works without it.
+- **Abuse (bot farming of high scores):** rate limits, response-time sanity, one verified attempt / 30 days / identity.
\ No newline at end of file
diff --git a/frontend/assets/result.js b/frontend/assets/result.js
new file mode 100644
index 0000000..49319df
--- /dev/null
+++ b/frontend/assets/result.js
@@ -0,0 +1,97 @@
+/* VerIQ result page logic */
+(function () {
+ "use strict";
+ var $ = function (id) { return document.getElementById(id); };
+
+ function showError(msg) {
+ $("errorArea").textContent = msg;
+ $("errorArea").style.display = "";
+ $("loading").style.display = "none";
+ }
+
+ var m = window.location.pathname.match(/^\/result\/([A-Za-z0-9]{8})$/);
+ if (!m) { showError("Invalid result link."); return; }
+ var rid = m[1];
+
+ fetch("/api/result/" + rid).then(function (r) {
+ if (!r.ok) return r.json().then(function (e) { throw new Error(e.detail || r.status); });
+ return r.json();
+ }).then(function (d) {
+ $("loading").style.display = "none";
+ $("resultArea").style.display = "";
+
+ $("iqScore").textContent = d.iq;
+ var pct = d.percentile;
+ $("percentileLine").textContent =
+ "higher than " + pct + "% of test takers" +
+ (d.calibrated === false ? " · preliminary calibration" : "");
+
+ var badges = $("badges");
+ badges.innerHTML = "";
+ var b1 = document.createElement("span");
+ b1.className = "badge " + (d.signature_valid ? "ok" : "bad");
+ b1.textContent = d.signature_valid ? "signature valid" : "SIGNATURE INVALID";
+ badges.appendChild(b1);
+ if (d.flagged) {
+ var bf = document.createElement("span");
+ bf.className = "badge warn";
+ bf.textContent = "flagged: implausible timing";
+ badges.appendChild(bf);
+ }
+ var b2 = document.createElement("span");
+ b2.className = "badge";
+ b2.textContent = "bank v" + d.bank_version;
+ badges.appendChild(b2);
+ var b3 = document.createElement("span");
+ b3.className = "badge";
+ b3.textContent = d.completed_at;
+ badges.appendChild(b3);
+
+ var cats = $("cats");
+ cats.innerHTML = "";
+ var order = ["numeric", "matrix", "verbal", "spatial"];
+ order.forEach(function (k) {
+ var c = (d.categories || {})[k];
+ if (!c) return;
+ var row = document.createElement("div");
+ row.className = "catrow";
+ var name = document.createElement("span");
+ name.className = "name";
+ name.textContent = k;
+ var bar = document.createElement("span");
+ bar.className = "bar";
+ var fill = document.createElement("span");
+ fill.style.display = "block";
+ fill.style.height = "100%";
+ fill.style.width = Math.round(100 * c.correct / c.total) + "%";
+ fill.style.background = "var(--accent2)";
+ bar.appendChild(fill);
+ var val = document.createElement("span");
+ val.className = "val";
+ val.textContent = c.correct + "/" + c.total;
+ row.appendChild(name); row.appendChild(bar); row.appendChild(val);
+ cats.appendChild(row);
+ });
+
+ var link = window.location.origin + "/v/" + rid;
+ $("verifyLink").value = link;
+ $("copyBtn").addEventListener("click", function () {
+ var inp = $("verifyLink");
+ inp.select();
+ try { document.execCommand("copy"); } catch (e) {}
+ if (navigator.clipboard) navigator.clipboard.writeText(link);
+ $("copyBtn").textContent = "Copied ✓";
+ setTimeout(function () { $("copyBtn").textContent = "Copy"; }, 1500);
+ });
+
+ var msg = "My VerIQ score: " + d.iq +
+ " (higher than " + pct + "%). Verify it: " + link;
+ $("shareX").href = "https://twitter.com/intent/tweet?text=" +
+ encodeURIComponent(msg);
+ $("shareTg").href = "https://t.me/share/url?url=" +
+ encodeURIComponent(link) + "&text=" +
+ encodeURIComponent("My VerIQ score: " + d.iq + " — verify it yourself");
+ }).catch(function (e) {
+ showError("Could not load result: " + e.message);
+ });
+})();
\ No newline at end of file
diff --git a/frontend/assets/style.css b/frontend/assets/style.css
new file mode 100644
index 0000000..d0530e8
--- /dev/null
+++ b/frontend/assets/style.css
@@ -0,0 +1,147 @@
+/* VerIQ — brutalist dark theme */
+:root {
+ --bg: #0d0d0f;
+ --panel: #16161a;
+ --panel2: #1d1d23;
+ --line: #2a2a33;
+ --text: #e8e8ea;
+ --muted: #8a8a94;
+ --accent: #7aa2f7;
+ --accent2: #bb9af7;
+ --good: #9ece6a;
+ --bad: #f7768e;
+ --warn: #e0af68;
+}
+* { box-sizing: border-box; margin: 0; padding: 0; }
+html, body { height: 100%; }
+body {
+ background: var(--bg);
+ color: var(--text);
+ font-family: "Inter", "Segoe UI", system-ui, -apple-system, sans-serif;
+ line-height: 1.5;
+ -webkit-font-smoothing: antialiased;
+}
+.wrap { max-width: 720px; margin: 0 auto; padding: 24px 16px 64px; }
+header.top {
+ display: flex; align-items: center; justify-content: space-between;
+ padding: 16px 0;
+}
+.logo {
+ font-weight: 800; font-size: 22px; letter-spacing: 0.5px;
+ color: var(--text); text-decoration: none;
+}
+.logo span { color: var(--accent); }
+.tagline { color: var(--muted); font-size: 13px; }
+
+.hero { padding: 40px 0 24px; }
+.hero h1 {
+ font-size: clamp(28px, 6vw, 44px); font-weight: 800; line-height: 1.15;
+ letter-spacing: -0.5px;
+}
+.hero h1 em { font-style: normal; color: var(--accent); }
+.hero p.lead { color: var(--muted); margin: 16px 0 28px; font-size: 17px; max-width: 560px; }
+
+.btn {
+ display: inline-block; border: 2px solid var(--text); background: var(--text);
+ color: var(--bg); font-weight: 700; font-size: 16px;
+ padding: 14px 28px; cursor: pointer; text-decoration: none;
+ transition: transform 0.06s ease, box-shadow 0.06s ease;
+ box-shadow: 4px 4px 0 var(--accent);
+}
+.btn:hover { transform: translate(-1px, -1px); box-shadow: 6px 6px 0 var(--accent); }
+.btn:active { transform: translate(2px, 2px); box-shadow: 1px 1px 0 var(--accent); }
+.btn.ghost {
+ background: transparent; color: var(--text); box-shadow: 4px 4px 0 var(--line);
+}
+.btn.small { padding: 8px 16px; font-size: 14px; }
+.btn[disabled] { opacity: 0.5; cursor: not-allowed; }
+
+.panel {
+ background: var(--panel); border: 1px solid var(--line);
+ padding: 20px; margin: 16px 0;
+}
+.panel h2 { font-size: 18px; margin-bottom: 12px; }
+.panel p { color: var(--muted); font-size: 14px; }
+
+.grid3 { display: grid; grid-template-columns: repeat(3, 1fr); gap: 12px; }
+@media (max-width: 560px) { .grid3 { grid-template-columns: 1fr; } }
+.feat { border: 1px solid var(--line); padding: 14px; background: var(--panel2); }
+.feat b { display: block; margin-bottom: 4px; font-size: 14px; }
+.feat span { color: var(--muted); font-size: 13px; }
+
+/* ---- test page ---- */
+.testbar {
+ display: flex; justify-content: space-between; align-items: center;
+ font-size: 13px; color: var(--muted); margin-bottom: 8px;
+}
+.timer { font-variant-numeric: tabular-nums; font-weight: 700; color: var(--text); }
+.timer.low { color: var(--bad); }
+.progress { height: 6px; background: var(--line); margin-bottom: 20px; }
+.progress > div { height: 100%; background: var(--accent); width: 0%; transition: width 0.2s; }
+
+.qcard { background: var(--panel); border: 1px solid var(--line); padding: 24px; }
+.qnum { color: var(--muted); font-size: 12px; text-transform: uppercase; letter-spacing: 1px; }
+.qtext { font-size: 19px; font-weight: 600; margin: 10px 0 18px; white-space: pre-line; }
+.qsvg { margin: 0 0 18px; }
+.qsvg svg { max-width: 100%; height: auto; }
+.qsvg svg text { font-family: inherit; }
+
+.choices { display: grid; gap: 10px; }
+.choice {
+ display: flex; align-items: center; gap: 14px;
+ border: 2px solid var(--line); background: var(--panel2);
+ padding: 12px 16px; cursor: pointer; font-size: 16px;
+ transition: border-color 0.1s;
+}
+.choice:hover { border-color: var(--muted); }
+.choice.sel { border-color: var(--accent); background: #1a2233; }
+.choice svg { flex: 0 0 auto; max-width: 120px; height: 44px; }
+.choice .letter {
+ flex: 0 0 26px; height: 26px; display: flex; align-items: center; justify-content: center;
+ border: 1px solid var(--line); font-size: 13px; color: var(--muted);
+}
+.choice.sel .letter { border-color: var(--accent); color: var(--accent); }
+
+.testnav { display: flex; justify-content: space-between; margin-top: 18px; gap: 12px; }
+
+/* ---- result / verify ---- */
+.score-big {
+ font-size: clamp(64px, 16vw, 110px); font-weight: 800; line-height: 1;
+ color: var(--accent); letter-spacing: -2px;
+}
+.score-sub { color: var(--muted); font-size: 15px; margin-top: 6px; }
+.badge {
+ display: inline-block; border: 1px solid var(--line); padding: 3px 10px;
+ font-size: 12px; color: var(--muted); margin-right: 6px; margin-top: 10px;
+}
+.badge.ok { border-color: var(--good); color: var(--good); }
+.badge.warn { border-color: var(--warn); color: var(--warn); }
+.badge.bad { border-color: var(--bad); color: var(--bad); }
+.cats { margin-top: 18px; }
+.catrow { display: flex; align-items: center; gap: 10px; margin: 8px 0; font-size: 14px; }
+.catrow .name { flex: 0 0 130px; color: var(--muted); }
+.catrow .bar { flex: 1; height: 10px; background: var(--line); }
+.catrow .bar > div { height: 100%; background: var(--accent2); }
+.catrow .val { flex: 0 0 60px; text-align: right; font-variant-numeric: tabular-nums; }
+
+.share { display: flex; gap: 10px; flex-wrap: wrap; margin-top: 20px; }
+.linkbox {
+ display: flex; gap: 8px; margin-top: 14px;
+}
+.linkbox input {
+ flex: 1; background: var(--panel2); border: 1px solid var(--line);
+ color: var(--text); padding: 10px 12px; font-size: 14px; font-family: monospace;
+}
+.kv { display: grid; grid-template-columns: 160px 1fr; gap: 6px 14px; font-size: 14px; margin: 14px 0; }
+.kv dt { color: var(--muted); }
+.kv dd { font-family: monospace; word-break: break-all; }
+
+footer { margin-top: 48px; color: var(--muted); font-size: 12px; border-top: 1px solid var(--line); padding-top: 16px; }
+footer a { color: var(--muted); }
+.mono { font-family: monospace; }
+.err { color: var(--bad); margin: 12px 0; }
+.spin {
+ width: 28px; height: 28px; border: 3px solid var(--line); border-top-color: var(--accent);
+ border-radius: 50%; animation: spin 0.8s linear infinite; margin: 24px auto;
+}
+@keyframes spin { to { transform: rotate(360deg); } }
\ No newline at end of file
diff --git a/frontend/assets/test.js b/frontend/assets/test.js
new file mode 100644
index 0000000..a6abd9c
--- /dev/null
+++ b/frontend/assets/test.js
@@ -0,0 +1,223 @@
+/* VerIQ test engine (vanilla JS, no deps) */
+(function () {
+ "use strict";
+
+ var state = {
+ token: null,
+ tasks: [],
+ timeLimitS: 0,
+ startedAt: 0,
+ current: 0,
+ answers: {}, // task_id -> choice index
+ timerId: null,
+ finished: false
+ };
+
+ var $ = function (id) { return document.getElementById(id); };
+
+ function show(id) {
+ ["loading", "testArea", "submitArea", "errorArea"].forEach(function (s) {
+ $(s).style.display = (s === id) ? "" : "none";
+ });
+ }
+
+ function showError(msg) {
+ $("errorArea").textContent = msg;
+ show("errorArea");
+ }
+
+ function fmtTime(s) {
+ s = Math.max(0, Math.floor(s));
+ var m = Math.floor(s / 60), r = s % 60;
+ return (m < 10 ? "0" : "") + m + ":" + (r < 10 ? "0" : "") + r;
+ }
+
+ function startTimer() {
+ var tick = function () {
+ var elapsed = (Date.now() - state.startedAt) / 1000;
+ var left = state.timeLimitS - elapsed;
+ var el = $("timer");
+ el.textContent = fmtTime(left);
+ el.className = "timer" + (left < 120 ? " low" : "");
+ if (left <= 0 && !state.finished) {
+ // auto-submit with current answers
+ submit(true);
+ }
+ };
+ state.timerId = setInterval(tick, 500);
+ tick();
+ }
+
+ function renderTask() {
+ var t = state.tasks[state.current];
+ if (!t) return;
+ $("qCounter").textContent = "Task " + (state.current + 1) + " / " + state.tasks.length;
+ $("qCategory").textContent = t.category;
+ $("qText").textContent = t.prompt.text;
+ var svgBox = $("qSvg");
+ svgBox.innerHTML = t.prompt.svg || "";
+ svgBox.style.display = t.prompt.svg ? "" : "none";
+
+ var box = $("choices");
+ box.innerHTML = "";
+ t.choices.forEach(function (c, i) {
+ var div = document.createElement("div");
+ div.className = "choice" + (state.answers[t.id] === i ? " sel" : "");
+ var letter = document.createElement("span");
+ letter.className = "letter";
+ letter.textContent = String.fromCharCode(65 + i);
+ div.appendChild(letter);
+ if (c.svg) {
+ var sv = document.createElement("span");
+ sv.innerHTML = c.svg;
+ div.appendChild(sv);
+ }
+ var txt = document.createElement("span");
+ txt.textContent = c.text || "";
+ div.appendChild(txt);
+ div.addEventListener("click", function () {
+ state.answers[t.id] = i;
+ renderTask();
+ updateAnswered();
+ });
+ box.appendChild(div);
+ });
+
+ $("progressBar").style.width =
+ Math.round((state.current / state.tasks.length) * 100) + "%";
+ $("prevBtn").disabled = state.current === 0;
+ $("nextBtn").textContent =
+ state.current === state.tasks.length - 1 ? "Finish ✓" : "Next →";
+ }
+
+ function updateAnswered() {
+ $("answeredCount").textContent = Object.keys(state.answers).length;
+ }
+
+ function next() {
+ if (state.current < state.tasks.length - 1) {
+ state.current++;
+ renderTask();
+ } else {
+ openSubmit();
+ }
+ }
+
+ function prev() {
+ if (state.current > 0) {
+ state.current--;
+ renderTask();
+ }
+ }
+
+ function openSubmit() {
+ var answered = Object.keys(state.answers).length;
+ var elapsed = Math.round((Date.now() - state.startedAt) / 1000);
+ $("finishSummary").textContent =
+ "Answered " + answered + " of " + state.tasks.length +
+ " tasks in " + fmtTime(elapsed) + ". Unanswered tasks count as wrong. " +
+ "You can go back and review before submitting.";
+ show("submitArea");
+ }
+
+ function submit(auto) {
+ if (state.finished) return;
+ state.finished = true;
+ clearInterval(state.timerId);
+ var elapsed = (Date.now() - state.startedAt) / 1000;
+ var payload = {
+ session_token: state.token,
+ answers: state.answers,
+ total_time_s: Math.round(elapsed * 10) / 10
+ };
+ $("finishBtn").disabled = true;
+ $("finishBtn").textContent = auto ? "Time is up — submitting…" : "Submitting…";
+ fetch("/api/finish", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify(payload)
+ }).then(function (r) {
+ if (!r.ok) return r.json().then(function (e) { throw new Error(e.detail || r.status); });
+ return r.json();
+ }).then(function (data) {
+ try { localStorage.removeItem("veriq_session"); } catch (e) {}
+ window.location.href = "/result/" + data.result_id;
+ }).catch(function (e) {
+ state.finished = false;
+ $("finishBtn").disabled = false;
+ $("finishBtn").textContent = "Submit & get my signed result";
+ showError("Submit failed: " + e.message);
+ });
+ }
+
+ function init() {
+ // resume session if page reloaded mid-test
+ var saved = null;
+ try { saved = JSON.parse(localStorage.getItem("veriq_session") || "null"); } catch (e) {}
+ if (saved && saved.token && saved.tasks && saved.tasks.length &&
+ Date.now() - saved.startedAt < saved.timeLimitS * 1000) {
+ state.token = saved.token;
+ state.tasks = saved.tasks;
+ state.timeLimitS = saved.timeLimitS;
+ state.startedAt = saved.startedAt;
+ state.answers = saved.answers || {};
+ state.current = saved.current || 0;
+ $("sessionInfo").textContent = "session restored";
+ show("testArea");
+ renderTask();
+ updateAnswered();
+ startTimer();
+ return;
+ }
+
+ fetch("/api/start", { method: "POST" }).then(function (r) {
+ if (!r.ok) return r.json().then(function (e) { throw new Error(e.detail || r.status); });
+ return r.json();
+ }).then(function (data) {
+ state.token = data.session_token;
+ state.tasks = data.tasks;
+ state.timeLimitS = data.time_limit_s;
+ state.startedAt = Date.now();
+ state.current = 0;
+ state.answers = {};
+ try {
+ localStorage.setItem("veriq_session", JSON.stringify({
+ token: state.token, tasks: state.tasks,
+ timeLimitS: state.timeLimitS, startedAt: state.startedAt,
+ answers: {}, current: 0
+ }));
+ } catch (e) {}
+ $("sessionInfo").textContent = "40 tasks · one attempt";
+ show("testArea");
+ renderTask();
+ updateAnswered();
+ startTimer();
+ }).catch(function (e) {
+ showError("Could not start the test: " + e.message +
+ (String(e.message).indexOf("429") >= 0 ? " — too many attempts from your network, try again later." : ""));
+ });
+ }
+
+ // persist state on navigation within test
+ window.addEventListener("beforeunload", function () {
+ if (state.token && !state.finished) {
+ try {
+ var s = JSON.parse(localStorage.getItem("veriq_session") || "{}");
+ s.answers = state.answers;
+ s.current = state.current;
+ localStorage.setItem("veriq_session", JSON.stringify(s));
+ } catch (e) {}
+ }
+ });
+
+ $("nextBtn").addEventListener("click", next);
+ $("prevBtn").addEventListener("click", prev);
+ $("finishBtn").addEventListener("click", function () { submit(false); });
+ $("reviewBtn").addEventListener("click", function () {
+ state.current = 0;
+ show("testArea");
+ renderTask();
+ });
+
+ init();
+})();
\ No newline at end of file
diff --git a/frontend/assets/verify.js b/frontend/assets/verify.js
new file mode 100644
index 0000000..15f9d9a
--- /dev/null
+++ b/frontend/assets/verify.js
@@ -0,0 +1,59 @@
+/* VerIQ verify page logic — public verification of a signed result */
+(function () {
+ "use strict";
+ var $ = function (id) { return document.getElementById(id); };
+
+ function showError(msg) {
+ $("errorArea").textContent = msg;
+ $("errorArea").style.display = "";
+ $("loading").style.display = "none";
+ }
+
+ var m = window.location.pathname.match(/^\/v\/([A-Za-z0-9]{8})$/);
+ if (!m) { showError("Invalid verification link."); return; }
+ var rid = m[1];
+
+ fetch("/api/result/" + rid).then(function (r) {
+ if (!r.ok) return r.json().then(function (e) { throw new Error(e.detail || r.status); });
+ return r.json();
+ }).then(function (d) {
+ $("loading").style.display = "none";
+ $("verifyArea").style.display = "";
+
+ $("iqScore").textContent = d.iq;
+ $("percentileLine").textContent =
+ "higher than " + d.percentile + "% of test takers" +
+ (d.flagged ? " · FLAGGED: implausible timing" : "");
+
+ var badges = $("badges");
+ badges.innerHTML = "";
+ var b1 = document.createElement("span");
+ b1.className = "badge " + (d.signature_valid ? "ok" : "bad");
+ b1.textContent = d.signature_valid ? "✓ signature valid" : "✗ SIGNATURE INVALID";
+ badges.appendChild(b1);
+ if (d.identity) {
+ var bi = document.createElement("span");
+ bi.className = "badge ok";
+ bi.textContent = "identity: " + d.identity;
+ badges.appendChild(bi);
+ } else {
+ var bn = document.createElement("span");
+ bn.className = "badge";
+ bn.textContent = "anonymous result";
+ badges.appendChild(bn);
+ }
+
+ $("kvId").textContent = d.result_id;
+ $("kvDate").textContent = d.completed_at;
+ $("kvIdentity").textContent = d.identity || "not claimed (anonymous)";
+ $("kvBank").textContent = "v" + d.bank_version;
+ $("kvSig").textContent = d.signature;
+ $("kvSig").style.color = d.signature_valid ? "var(--good)" : "var(--bad)";
+
+ fetch("/.well-known/veriq-pubkey.txt").then(function (r) { return r.text(); })
+ .then(function (pk) { $("kvPub").textContent = pk.trim(); })
+ .catch(function () { $("kvPub").textContent = "unavailable"; });
+ }).catch(function (e) {
+ showError("Could not load result: " + e.message);
+ });
+})();
\ No newline at end of file
diff --git a/frontend/index.html b/frontend/index.html
new file mode 100644
index 0000000..b70cb55
--- /dev/null
+++ b/frontend/index.html
@@ -0,0 +1,52 @@
+
+
+
+
+
+VerIQ — verifiable IQ test
+
+
+
+
+
+
+
+
+
+ VerIQ
+ signed results · public verification
+
+
+
+
Your IQ score, backed by a signature — not a screenshot.
+
+ Every IQ test on the internet gives you an image anyone can fake.
+ VerIQ signs your result with a cryptographic key and gives you a public
+ link anyone can verify — no login, no trust required.
+
1 · Take the test40 original tasks: sequences, matrices, verbal logic, spatial reasoning.
+
2 · Get a signed resultYour score is signed with Ed25519. Forging it means breaking the key.
+
3 · Share a verify linkAnyone can open your link and see the signature check pass.
+
+
+
+
Why verification matters
+
+ IQ arguments online are endless because scores are unverifiable.
+ A VerIQ link ends the argument: the verify page re-checks the signature
+ server-side on every view and shows the service public key fingerprint.
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/frontend/result.html b/frontend/result.html
new file mode 100644
index 0000000..3d03022
--- /dev/null
+++ b/frontend/result.html
@@ -0,0 +1,61 @@
+
+
+
+
+
+VerIQ — your result
+
+
+
+
+ Soon you'll be able to bind this result to your X or Instagram account —
+ the verify page will then show your handle and avatar next to the score.
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/frontend/test.html b/frontend/test.html
new file mode 100644
index 0000000..dd9b86b
--- /dev/null
+++ b/frontend/test.html
@@ -0,0 +1,55 @@
+
+
+
+
+
+VerIQ — test
+
+
+
+
+ The signature is re-checked server-side on every view of this page.
+ If it shows valid,
+ this result was issued by VerIQ and has not been altered.
+
+
+
+
+
Want your own?
+
Take the test and get your own signed, verifiable result.