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": "viewBox='0 0 40 40' xmlns='http://www.w3.org/2000/svg'>viewBox='0 0 40 40' xmlns='http://www.w3.org/2000/svg'>viewBox='0 0 40 40' xmlns='http://www.w3.org/2000/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": "viewBox='0 0 40 40' xmlns='http://www.w3.org/2000/svg'>viewBox='0 0 40 40' xmlns='http://www.w3.org/2000/svg'>viewBox='0 0 40 40' xmlns='http://www.w3.org/2000/svg'>viewBox='0 0 40 40' xmlns='http://www.w3.org/2000/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": "viewBox='0 0 40 40' xmlns='http://www.w3.org/2000/svg'>viewBox='0 0 40 40' xmlns='http://www.w3.org/2000/svg'>viewBox='0 0 40 40' xmlns='http://www.w3.org/2000/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": "viewBox='0 0 60 40' xmlns='http://www.w3.org/2000/svg'>AviewBox='0 0 60 40' xmlns='http://www.w3.org/2000/svg'>BviewBox='0 0 60 40' xmlns='http://www.w3.org/2000/svg'>C?" + }, + "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 + + + + + + + +
+
+ + 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. +

+ Start the test → +

40 tasks · ~25 minutes · free · no registration

+
+ +
+
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. +

+
+ +
+ VerIQ · results are preliminary-calibrated until 500 completions · + service public key +
+
+ + \ 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 + + + +
+
+ + your signed result +
+ +
+ + + + + +
+ VerIQ · service public key · + results are preliminary-calibrated until 500 completions +
+
+ + + \ 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 + + + +
+
+ + +
+ +

Preparing your test…

+ + + + + + +
+ + + \ No newline at end of file diff --git a/frontend/verify.html b/frontend/verify.html new file mode 100644 index 0000000..292960e --- /dev/null +++ b/frontend/verify.html @@ -0,0 +1,57 @@ + + + + + +VerIQ — verify result + + + +
+
+ + public verification +
+ +
+ + + + + + +
+ + + \ No newline at end of file diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..23e13e0 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,4 @@ +fastapi>=0.110 +uvicorn[standard]>=0.29 +pydantic>=2.6 +cryptography>=42.0 \ No newline at end of file diff --git a/scripts/generate_questions.py b/scripts/generate_questions.py new file mode 100644 index 0000000..6b59ecb --- /dev/null +++ b/scripts/generate_questions.py @@ -0,0 +1,474 @@ +#!/usr/bin/env python3 +"""VerIQ question bank generator. + +Generates content/questions.json with 40 ORIGINAL tasks across 4 categories. +All items are authored for VerIQ — no Raven/Mensa copies. +Run: python3 scripts/generate_questions.py +""" +import json +import math +import sys +from pathlib import Path + +OUT = Path(__file__).resolve().parent.parent / "content" / "questions.json" + +# ---------------------------------------------------------------- SVG helpers +def svg_wrap(inner: str, w: int = 40, h: int = 40) -> str: + return (f"" + f"{inner}") + + +def poly_points(n: int, cx: float, cy: float, r: float, + rot_deg: float = -90.0) -> str: + pts = [] + for i in range(n): + a = math.radians(rot_deg + i * 360.0 / n) + pts.append(f"{cx + r * math.cos(a):.1f},{cy + r * math.sin(a):.1f}") + return " ".join(pts) + + +def shape(n_sides: int, cx: float = 20, cy: float = 20, r: float = 14, + fill: str = "none", stroke: str = "#111", sw: float = 2) -> str: + if n_sides == 0: # circle + return (f"") + pts = poly_points(n_sides, cx, cy, r) + return (f"") + + +def circles_row(k: int, w: int = None) -> str: + w = w or (k * 40 + 10) + inner = "".join( + f"" + for i in range(k)) + return svg_wrap(inner, w, 40) + + +def shapes_row(specs: list, w: int = None) -> str: + """specs: list of (n_sides, fill).""" + w = w or (len(specs) * 50 + 10) + inner = "".join( + shape(n, cx=25 + i * 50, fill=f) + for i, (n, f) in enumerate(specs)) + return svg_wrap(inner, w, 44) + + +ARROW_PATH = "M20 5 L31 23 L24.5 23 L24.5 35 L15.5 35 L15.5 23 L9 23 Z" + + +def arrow(deg: int, filled: bool = True) -> str: + fill = "#111" if filled else "white" + stroke = "none" if filled else "#111" + return svg_wrap( + f"") + + +def half_circle(right: bool = True) -> str: + sweep = 1 if right else 0 + return svg_wrap( + "" + f"") + + +def dots_panel(positions: list, label: str = None, w: int = 60) -> str: + xs = [10, 30, 50] + inner = "" + if label: + inner += (f"" + f"{label}") + for p in positions: + inner += f"" + return svg_wrap(inner, w, 40) + + +def cells_svg(cells: list, n: int = 3, cell: int = 14) -> str: + """cells: list of (col,row). Filled unit squares on n x n grid.""" + size = n * cell + 4 + inner = (f"") + for (c, r) in cells: + inner += (f"") + return svg_wrap(inner, size, size) + + +def flag(deg: int = 0, mirror: bool = False, variant: str = "rot") -> str: + """Flag: pole + triangular pennant. variant: rot|mirror_top|mirror_bottom.""" + if variant == "orig": + pole = "" + tri = "" + return svg_wrap(pole + tri) + if variant == "rot180": + pole = "" + tri = "" + return svg_wrap(pole + tri) + if variant == "mirror_top": + pole = "" + tri = "" + return svg_wrap(pole + tri) + if variant == "mirror_bottom": + pole = "" + tri = "" + return svg_wrap(pole + tri) + raise ValueError(variant) + + +def tri_subdivided() -> str: + return svg_wrap( + "" + "" + "" + "", + 40, 40) + + +def columns_svg(heights: list) -> str: + cell = 12 + w = len(heights) * cell + 8 + h = max(heights) * cell + 8 + inner = "" + for i, hgt in enumerate(heights): + for k in range(hgt): + x = 4 + i * cell + y = h - 4 - (k + 1) * cell + inner += (f"") + return svg_wrap(inner, w, h) + + +def net_svg(cells: list, cols: int, rows: int, cell: int = 16) -> str: + w, h = cols * cell + 4, rows * cell + 4 + inner = "" + for (c, r) in cells: + inner += (f"") + return svg_wrap(inner, w, h) + + +def square_holes(holes: list, fold_line: bool = False) -> str: + inner = ("") + if fold_line: + inner += ("") + for (x, y) in holes: + inner += f"" + return svg_wrap(inner, 60, 60) + + +def matrix_2x2() -> str: + tl = shape(4, cx=25, cy=25, r=7) + tr = shape(4, cx=75, cy=25, r=13) + bl = shape(0, cx=25, cy=75, r=7) + q = ("?") + grid = ("" + "" + "") + return svg_wrap(grid + tl + tr + bl + q, 100, 100) + + +def choice_big_circle() -> str: + return svg_wrap(shape(0, r=14)) +def choice_small_circle() -> str: + return svg_wrap(shape(0, r=6)) +def choice_big_square() -> str: + return svg_wrap(shape(4, r=14)) +def choice_small_triangle() -> str: + return svg_wrap(shape(3, r=10)) + +# ---------------------------------------------------------------- task data +T = [] + +def add(tid, cat, diff, tlim, prompt_text, choices, answer, expl, + prompt_svg=None, choice_svgs=None): + chs = [] + for i, c in enumerate(choices): + chs.append({"text": c, + "svg": choice_svgs[i] if choice_svgs else None}) + T.append({ + "id": tid, "category": cat, "difficulty": diff, "weight": diff, + "time_limit_s": tlim, + "prompt": {"text": prompt_text, "svg": prompt_svg}, + "choices": chs, "answer_index": answer, "explanation": expl, + }) + +# ===== NUMERIC (10) =========================================================== +add("num-001", "numeric", 1, 45, "2, 4, 6, 8, ?", + ["7", "9", "10", "12"], 2, "Arithmetic progression: +2 each step.") +add("num-002", "numeric", 1, 45, "1, 3, 5, 7, ?", + ["8", "9", "10", "11"], 1, "Odd numbers: +2 each step.") +add("num-003", "numeric", 2, 45, "3, 6, 12, 24, ?", + ["30", "36", "48", "64"], 2, "Each term doubles: ×2.") +add("num-004", "numeric", 2, 45, "1, 1, 2, 3, 5, 8, ?", + ["11", "12", "13", "14"], 2, "Fibonacci: each term is the sum of the two before it (5+8=13).") +add("num-005", "numeric", 2, 45, "81, 64, 49, 36, ?", + ["16", "25", "28", "30"], 1, "Descending perfect squares: 9², 8², 7², 6², 5²=25.") +add("num-006", "numeric", 3, 60, "2, 3, 5, 9, 17, ?", + ["31", "32", "33", "34"], 2, "Differences double: +1, +2, +4, +8, +16 → 17+16=33.") +add("num-007", "numeric", 3, 60, "4, 7, 13, 25, ?", + ["37", "43", "49", "51"], 2, "Differences double: +3, +6, +12, +24 → 25+24=49.") +add("num-008", "numeric", 3, 60, "1, 4, 9, 16, 25, ?", + ["30", "32", "36", "49"], 2, "Perfect squares: 6²=36.") +add("num-009", "numeric", 4, 75, "2, 6, 12, 20, 30, ?", + ["36", "40", "42", "44"], 2, "n(n+1): 1·2, 2·3, 3·4, 4·5, 5·6, 6·7=42. Also +4,+6,+8,+10,+12.") +add("num-010", "numeric", 5, 90, "1, 2, 6, 24, 120, ?", + ["240", "360", "600", "720"], 3, "Factorials: 1!, 2!, 3!, 4!, 5!, 6!=720.") + +# ===== LOGICAL MATRICES (10) ================================================== +add("mat-001", "matrix", 1, 45, "How many circles should replace the question mark?", + ["4 circles", "5 circles", "3 circles", "2 circles"], 0, + "Count grows by one each step: 1, 2, 3 → 4.", + prompt_svg=svg_wrap( + circles_row(1, 50) + circles_row(2, 90) + circles_row(3, 130) + + "?", 170, 44) + if False else + svg_wrap( + "" + "" + "" + "" + "?", 230, 44), + choice_svgs=[circles_row(4), circles_row(5), circles_row(3), circles_row(2)]) + +add("mat-002", "matrix", 2, 60, "Which figure continues the sequence?", + ["Hexagon", "Heptagon", "Circle", "Square"], 0, + "Number of sides increases by one: 3, 4, 5 → 6.", + prompt_svg=shapes_row([(3, "none"), (4, "none"), (5, "none")], 160), + choice_svgs=[svg_wrap(shape(6)), svg_wrap(shape(7)), + svg_wrap(shape(0)), svg_wrap(shape(4))]) + +add("mat-003", "matrix", 2, 60, "Which arrow comes next?", + ["Left", "Up", "Right", "Down"], 0, + "The arrow rotates 90° clockwise each step: up, right, down → left.", + prompt_svg=svg_wrap(arrow(0) + arrow(90, False) + arrow(180), 140, 44) + .replace("", "") if False else + svg_wrap( + f"{arrow(0)[5:-6]}" + f"{arrow(90, False)[5:-6]}" + f"{arrow(180)[5:-6]}", 140, 40), + choice_svgs=[arrow(270), arrow(0), arrow(90), arrow(180)]) + +add("mat-004", "matrix", 3, 60, "Which circle comes next?", + ["Half-filled", "Fully filled", "Empty", "Half-filled, other side"], 0, + "Cycle of three: full, half, empty — after the 4th (full) comes half.", + prompt_svg=svg_wrap( + svg_wrap("", 40, 40)[5:-6] + + f"{half_circle(True)[5:-6]}" + + f"{svg_wrap(''.replace('%23','#'),40,40)[5:-6]}" + + f"{svg_wrap(''.replace('%23','#'),40,40)[5:-6]}" + + "?", 220, 40), + choice_svgs=[half_circle(True), + svg_wrap(""), + svg_wrap(""), + half_circle(False)]) + +add("mat-005", "matrix", 3, 60, + "What number replaces the question mark?\n4 9 16\n25 36 49\n64 ? 100", + ["72", "81", "83", "90"], 1, + "Perfect squares in reading order: 2²…10²; missing is 9²=81.") + +add("mat-006", "matrix", 3, 60, + "What letter replaces the question mark?\nB D F\nH J L\nN P ?", + ["Q", "R", "S", "T"], 1, + "Each row skips one letter and continues from the previous row: …N, P, R.") + +add("mat-007", "matrix", 4, 75, "Which arrow comes next? (Fill matters.)", + ["Left, empty", "Left, filled", "Up, empty", "Down, empty"], 0, + "Two combined rules: rotate 90° clockwise; fill alternates filled/empty. " + "4th arrow: left, empty.", + prompt_svg=svg_wrap( + f"{arrow(0)[5:-6]}" + f"{arrow(90, False)[5:-6]}" + f"{arrow(180)[5:-6]}" + "?", 170, 40), + choice_svgs=[arrow(270, False), arrow(270), arrow(0, False), arrow(180, False)]) + +add("mat-008", "matrix", 4, 75, + "Which figure completes the matrix?", + ["Big circle", "Small circle", "Big square", "Small triangle"], 0, + "Rows: size grows (small → big). Columns: shape stays (squares top, " + "circles bottom). Missing: big circle.", + prompt_svg=matrix_2x2(), + choice_svgs=[choice_big_circle(), choice_small_circle(), + choice_big_square(), choice_small_triangle()]) + +add("mat-009", "matrix", 4, 75, + "What number replaces the question mark?\n3 5 8\n4 7 11\n5 9 ?", + ["12", "13", "14", "15"], 2, + "Third column = first + second: 3+5=8, 4+7=11, 5+9=14.") + +add("mat-010", "matrix", 5, 90, + "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?", + ["Dots at 1 and 3", "Dot at 2 only", "Dots at 1, 2 and 3", "No dots"], 0, + "A={1,2}, B={2,3}; XOR keeps positions present in exactly one set: {1,3}.", + prompt_svg=svg_wrap( + f"{dots_panel([0,1], 'A')[5:-6]}" + f"{dots_panel([1,2], 'B')[5:-6]}" + f"{dots_panel([], 'C')[5:-6]}" + "?", 230, 40), + choice_svgs=[dots_panel([0, 2]), dots_panel([1]), + dots_panel([0, 1, 2]), dots_panel([])]) + +# ===== VERBAL (10) ============================================================ +add("ver-001", "verbal", 1, 40, "Which word does not belong?", + ["apple", "banana", "carrot", "cherry"], 2, + "Carrot is a vegetable; the rest are fruits.") +add("ver-002", "verbal", 1, 40, "kitten : cat :: puppy : ?", + ["dog", "wolf", "horse", "kitten"], 0, + "A kitten is a young cat; a puppy is a young dog.") +add("ver-003", "verbal", 2, 40, "Which word does not belong?", + ["triangle", "square", "circle", "pentagon"], 2, + "A circle has no straight sides or vertices; the rest are polygons.") +add("ver-004", "verbal", 2, 40, "author : book :: composer : ?", + ["symphony", "orchestra", "piano", "conductor"], 0, + "An author creates a book; a composer creates a symphony.") +add("ver-005", "verbal", 2, 40, "thermometer : temperature :: barometer : ?", + ["humidity", "pressure", "wind", "rainfall"], 1, + "A thermometer measures temperature; a barometer measures pressure.") +add("ver-006", "verbal", 3, 50, "Which word does not belong?", + ["run", "walk", "swim", "think"], 3, + "Think is a mental process; the rest are physical activities.") +add("ver-007", "verbal", 3, 50, + "All roses fade. Some flowers fade. Which conclusion necessarily follows?", + ["All flowers are roses", + "Some flowers are roses", + "No flower is a rose", + "None of these necessarily follows"], 3, + "The premises do not force any overlap between roses and flowers beyond " + "what is stated; none of A–C is entailed.") +add("ver-008", "verbal", 4, 60, "A, C, F, J, O, ?", + ["S", "T", "U", "V"], 2, + "Gaps grow: +2, +3, +4, +5, +6 → O(15)+6=U(21).") +add("ver-009", "verbal", 4, 60, + "No artists are cheap. Some cheap things are useful. Which conclusion " + "necessarily follows?", + ["Some useful things are not artists", + "All useful things are artists", + "Some artists are useful", + "None of these necessarily follows"], 0, + "Take a useful cheap thing: it is cheap, and no artist is cheap, so it " + "is not an artist.") +add("ver-010", "verbal", 5, 75, + "In a certain code CAT is written DBU (each letter shifts +1). " + "How is DOG written?", + ["EPH", "EPG", "FPH", "DPH"], 0, + "D→E, O→P, G→H.") + +# ===== SPATIAL (10) =========================================================== +add("spa-001", "spatial", 1, 60, "How many triangles are in the figure?", + ["4", "5", "6", "8"], 1, + "4 small triangles + 1 large outer triangle = 5.", + prompt_svg=tri_subdivided()) + +add("spa-002", "spatial", 2, 60, + "The figure can be rotated in the plane but never flipped (mirrored). " + "Which option is the same figure?", + ["A", "B", "C", "D"], 0, + "Option A is the original rotated 90° clockwise. B is its mirror image; " + "C and D are different shapes.", + prompt_svg=cells_svg([(0, 0), (0, 1), (0, 2), (1, 2)]), + choice_svgs=[cells_svg([(2, 0), (1, 0), (0, 0), (0, 1)]), + cells_svg([(1, 0), (1, 1), (1, 2), (0, 2)]), + cells_svg([(0, 0), (1, 0), (2, 0), (1, 1)]), + cells_svg([(1, 0), (2, 0), (0, 1), (1, 1)])]) + +add("spa-003", "spatial", 1, 60, "How many edges does a triangular prism have?", + ["6", "8", "9", "12"], 2, + "2 triangular bases (3+3 edges) + 3 side edges = 9.") + +add("spa-004", "spatial", 3, 75, "Which net folds into a cube?", + ["A", "B", "C", "D"], 0, + "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.", + prompt_svg=None, + choice_svgs=[ + net_svg([(0, 1), (1, 1), (2, 1), (3, 1), (1, 0), (1, 2)], 4, 3), + net_svg([(0, 0), (1, 0), (2, 0), (0, 1), (1, 1), (2, 1)], 3, 2), + net_svg([(i, 0) for i in range(6)], 6, 1), + net_svg([(0, 1), (1, 1), (2, 1), (3, 1), (1, 0), (2, 0)], 4, 2)]) + +add("spa-005", "spatial", 3, 75, + "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?", + ["6", "12", "18", "27"], 1, + "Exactly-two-face cubes sit on edges (not corners): 12 edges × 1 middle " + "cube = 12.") + +add("spa-006", "spatial", 2, 60, + "Which flag is the original rotated 180°? (No flipping allowed.)", + ["A", "B", "C", "D"], 0, + "A is the 180° rotation: pole on the right, pennant pointing left at the " + "bottom. B and D are mirrors; C is the original.", + prompt_svg=flag(variant="orig"), + choice_svgs=[flag(variant="rot180"), flag(variant="mirror_top"), + flag(variant="orig"), flag(variant="mirror_bottom")]) + +add("spa-007", "spatial", 2, 60, "How many faces does a square pyramid have?", + ["4", "5", "6", "8"], 1, + "1 square base + 4 triangular sides = 5.") + +add("spa-008", "spatial", 3, 75, + "Each stack is one unit cube deep. How many cubes are shown in total?", + ["8", "9", "10", "12"], 2, + "Columns of 4, 2, 3, 1: 4+2+3+1=10.", + prompt_svg=columns_svg([4, 2, 3, 1])) + +add("spa-009", "spatial", 4, 90, + "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?", + ["A", "B", "C", "D"], 0, + "Unfolding mirrors the hole across the fold line: two holes at the same " + "height, symmetric left-right.", + prompt_svg=square_holes([(15, 15)], fold_line=True), + choice_svgs=[square_holes([(15, 15), (45, 15)]), + square_holes([(15, 15), (15, 45)]), + square_holes([(15, 15), (45, 45)]), + square_holes([(15, 15)])]) + +add("spa-010", "spatial", 5, 120, + "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?", + ["2", "4", "6", "8"], 0, + "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.") + +# ---------------------------------------------------------------- validate+write +def main(): + assert len(T) == 40, f"expected 40 tasks, got {len(T)}" + ids = [t["id"] for t in T] + assert len(ids) == len(set(ids)), "duplicate task ids" + max_raw = 0 + per_cat = {} + for t in T: + assert t["answer_index"] < len(t["choices"]), f"bad answer idx {t['id']}" + texts = [c["text"] for c in t["choices"]] + assert len(texts) == len(set(texts)), f"duplicate choices {t['id']}" + assert t["prompt"]["text"], f"empty prompt {t['id']}" + max_raw += t["weight"] + per_cat[t["category"]] = per_cat.get(t["category"], 0) + 1 + bank = {"version": "1.0.0", "tasks": T} + OUT.parent.mkdir(parents=True, exist_ok=True) + OUT.write_text(json.dumps(bank, ensure_ascii=False, indent=1), + encoding="utf-8") + print(f"OK: {len(T)} tasks, categories={per_cat}, max_raw={max_raw}") + print(f"written: {OUT}") + + calib = OUT.parent / "calibration.json" + if not calib.exists(): + calib.write_text(json.dumps( + {"n": 0, "mean_raw": None, "sigma_raw": None, "updated_at": None}, + indent=1), encoding="utf-8") + print(f"written: {calib}") + + +if __name__ == "__main__": + sys.exit(main()) \ No newline at end of file