#!/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"<svg viewBox='0 0 {w} {h}' xmlns='http://www.w3.org/2000/svg'>"
f"{inner}</svg>")
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"<circle cx='{cx}' cy='{cy}' r='{r}' fill='{fill}' "
f"stroke='{stroke}' stroke-width='{sw}'/>")
pts = poly_points(n_sides, cx, cy, r)
return (f"<polygon points='{pts}' fill='{fill}' stroke='{stroke}' "
f"stroke-width='{sw}'/>")
def circles_row(k: int, w: int = None) -> str:
w = w or (k * 40 + 10)
inner = "".join(
f"<circle cx='{15 + i * 40}' cy='20' r='11' fill='#111'/>"
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"<g transform='rotate({deg} 20 20)'><path d='{ARROW_PATH}' "
f"fill='{fill}' stroke='{stroke}' stroke-width='2'/></g>")
def half_circle(right: bool = True) -> str:
sweep = 1 if right else 0
return svg_wrap(
"<circle cx='20' cy='20' r='14' fill='none' stroke='#111' "
"stroke-width='2'/>"
f"<path d='M20 6 A14 14 0 0 {sweep} 20 34 Z' fill='#111'/>")
def dots_panel(positions: list, label: str = None, w: int = 60) -> str:
xs = [10, 30, 50]
inner = ""
if label:
inner += (f"<text x='4' y='12' font-size='11' fill='#666'>"
f"{label}</text>")
for p in positions:
inner += f"<circle cx='{xs[p]}' cy='26' r='6' fill='#111'/>"
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"<rect x='2' y='2' width='{n * cell}' height='{n * cell}' "
f"fill='none' stroke='#bbb' stroke-width='1'/>")
for (c, r) in cells:
inner += (f"<rect x='{2 + c * cell}' y='{2 + r * cell}' "
f"width='{cell}' height='{cell}' fill='#111'/>")
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 = "<line x1='10' y1='5' x2='10' y2='35' stroke='#111' stroke-width='2'/>"
tri = "<polygon points='10,5 30,12 10,19' fill='#111'/>"
return svg_wrap(pole + tri)
if variant == "rot180":
pole = "<line x1='30' y1='5' x2='30' y2='35' stroke='#111' stroke-width='2'/>"
tri = "<polygon points='30,35 10,28 30,21' fill='#111'/>"
return svg_wrap(pole + tri)
if variant == "mirror_top":
pole = "<line x1='30' y1='5' x2='30' y2='35' stroke='#111' stroke-width='2'/>"
tri = "<polygon points='30,5 10,12 30,19' fill='#111'/>"
return svg_wrap(pole + tri)
if variant == "mirror_bottom":
pole = "<line x1='10' y1='5' x2='10' y2='35' stroke='#111' stroke-width='2'/>"
tri = "<polygon points='10,35 30,28 10,21' fill='#111'/>"
return svg_wrap(pole + tri)
raise ValueError(variant)
def tri_subdivided() -> str:
return svg_wrap(
"<polygon points='20,4 36,34 4,34' fill='none' stroke='#111' stroke-width='2'/>"
"<line x1='12' y1='19' x2='28' y2='19' stroke='#111' stroke-width='1.5'/>"
"<line x1='28' y1='19' x2='20' y2='34' stroke='#111' stroke-width='1.5'/>"
"<line x1='12' y1='19' x2='20' y2='34' stroke='#111' stroke-width='1.5'/>",
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"<rect x='{x}' y='{y}' width='{cell}' height='{cell}' "
f"fill='none' stroke='#111' stroke-width='1.5'/>")
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"<rect x='{2 + c * cell}' y='{2 + r * cell}' "
f"width='{cell}' height='{cell}' "
f"fill='#e8e8e8' stroke='#111' stroke-width='1.5'/>")
return svg_wrap(inner, w, h)
def square_holes(holes: list, fold_line: bool = False) -> str:
inner = ("<rect x='4' y='4' width='52' height='52' fill='none' "
"stroke='#111' stroke-width='2'/>")
if fold_line:
inner += ("<line x1='30' y1='4' x2='30' y2='56' stroke='#999' "
"stroke-width='1.5' stroke-dasharray='4 3'/>")
for (x, y) in holes:
inner += f"<circle cx='{x}' cy='{y}' r='4' fill='#111'/>"
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 = ("<text x='75' y='82' font-size='24' fill='#111' "
"text-anchor='middle'>?</text>")
grid = ("<rect x='0' y='0' width='100' height='100' fill='none' "
"stroke='#bbb'/>"
"<line x1='50' y1='0' x2='50' y2='100' stroke='#bbb'/>"
"<line x1='0' y1='50' x2='100' y2='50' stroke='#bbb'/>")
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)
+ "<text x='150' y='26' font-size='18' fill='#111'>?</text>", 170, 44)
if False else
svg_wrap(
"<circle cx='25' cy='22' r='11' fill='#111'/>"
"<circle cx='65' cy='22' r='11' fill='#111'/><circle cx='90' cy='22' r='11' fill='#111'/>"
"<circle cx='130' cy='22' r='11' fill='#111'/><circle cx='155' cy='22' r='11' fill='#111'/>"
"<circle cx='180' cy='22' r='11' fill='#111'/>"
"<text x='215' y='28' font-size='18' fill='#111'>?</text>", 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("<svg", "<g").replace("</svg>", "</g>") if False else
svg_wrap(
f"<g transform='translate(0,0)'>{arrow(0)[5:-6]}</g>"
f"<g transform='translate(50,0)'>{arrow(90, False)[5:-6]}</g>"
f"<g transform='translate(100,0)'>{arrow(180)[5:-6]}</g>", 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("<circle cx='20' cy='20' r='14' fill='#111'/>", 40, 40)[5:-6]
+ f"<g transform='translate(50,0)'>{half_circle(True)[5:-6]}</g>"
+ f"<g transform='translate(100,0)'>{svg_wrap('<circle cx=20 cy=20 r=14 fill=none stroke=%23111 stroke-width=2/>'.replace('%23','#'),40,40)[5:-6]}</g>"
+ f"<g transform='translate(150,0)'>{svg_wrap('<circle cx=20 cy=20 r=14 fill=%23111/>'.replace('%23','#'),40,40)[5:-6]}</g>"
+ "<text x='205' y='26' font-size='18' fill='#111'>?</text>", 220, 40),
choice_svgs=[half_circle(True),
svg_wrap("<circle cx='20' cy='20' r='14' fill='#111'/>"),
svg_wrap("<circle cx='20' cy='20' r='14' fill='none' stroke='#111' stroke-width='2'/>"),
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"<g transform='translate(0,0)'>{arrow(0)[5:-6]}</g>"
f"<g transform='translate(50,0)'>{arrow(90, False)[5:-6]}</g>"
f"<g transform='translate(100,0)'>{arrow(180)[5:-6]}</g>"
"<text x='155' y='26' font-size='18' fill='#111'>?</text>", 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"<g transform='translate(0,0)'>{dots_panel([0,1], 'A')[5:-6]}</g>"
f"<g transform='translate(70,0)'>{dots_panel([1,2], 'B')[5:-6]}</g>"
f"<g transform='translate(140,0)'>{dots_panel([], 'C')[5:-6]}</g>"
"<text x='215' y='30' font-size='18' fill='#111'>?</text>", 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())