{"slug": "ml-powered-adaptive-iq-test", "title": "ML-Powered Adaptive IQ Test", "summary": "A developer built IQ Platform, a free adaptive cognitive assessment tool that calibrates question difficulty based on age, education, and occupation. The tool uses a multi-feature ML regression model for scoring and remembers previously seen questions across sessions, all without a backend API or database.", "body_md": "**Tags:** `nextjs`\n\n`typescript`\n\n`machinelearning`\n\n`webdev`\n\nMost online IQ tests are broken.\n\nThey ask the same 20 questions to everyone — whether you're a 14-year-old high school student or a 35-year-old PhD. They don't adapt. They don't remember what you've already answered. And after two or three retakes, you've memorised the answers. The score stops meaning anything.\n\nI got frustrated enough to build my own. After a few months of work, I launched [IQ Platform](https://iq-platform-plum.vercel.app) — a free, fully adaptive cognitive assessment tool that calibrates question difficulty to your age, education, and occupation, scores you using a multi-feature ML regression model, and remembers which questions you've already seen across sessions.\n\nHere's the complete technical breakdown of how I built it.\n\nNo Auth. No database. No backend API. Everything meaningful happens on the client or at build time.\n\nHere's why standard online IQ tests fail:\n\n```\nStandard test:\n  User A: 14 years old, high school → gets \"What is 30% of 200?\"\n  User B: 26 years old, PhD CS      → gets \"What is 30% of 200?\"\n\nBoth answer correctly. Both score the same. \nBut the question told you nothing useful about User B.\n```\n\nThe solution is **adaptive difficulty** — selecting questions based on who is actually taking the test. This is how real psychometric assessments like the WAIS-IV work. They calibrate to the individual.\n\nThe question bank covers six cognitive domains:\n\n| Domain | What it measures |\n|---|---|\n| Numerical | Arithmetic, algebra, ratios, percentages, logarithms |\n| Verbal | Vocabulary, analogies, anagrams, antonyms |\n| Pattern | Number series, letter sequences, figurate numbers |\n| Logical | Syllogisms, seating arrangements, propositional logic |\n| Memory | Sequence recall, list recognition, position memory |\n| Spatial | 3D geometry, rotations, Euler's formula, clock angles |\n\nEach question has a difficulty rating from 1 (school level) to 5 (postgraduate/doctorate level), plus optional `minAge`\n\n, `maxAge`\n\n, and `tags`\n\n(STEM, arts, language, general):\n\n```\nexport interface Question {\n  id: string\n  category: QuestionCategory\n  difficulty: 1 | 2 | 3 | 4 | 5\n  minAge?: number\n  maxAge?: number\n  tags?: string[]   // 'stem' | 'arts' | 'language' | 'general'\n  text: string\n  options: string[]\n  correctIndex: number\n  explanation: string\n  timeLimit: number  // seconds per question\n}\n```\n\nA difficulty-5 Logical question looks like this:\n\n```\n{\n  id: 'l8', category: 'Logical', difficulty: 5, tags: ['stem'],\n  text: 'Cards A, D, 4, 7. Which to flip to verify \"vowel → even on other side\"?',\n  options: ['A and 4', 'A and 7', 'D and 4', 'A, D and 4'],\n  correctIndex: 1, timeLimit: 40,\n  explanation: 'Flip A (check even) and 7 (check no vowel). Wason selection task.'\n}\n```\n\nWhile a difficulty-1 version looks like:\n\n```\n{\n  id: 'l1', category: 'Logical', difficulty: 1, minAge: 10, maxAge: 16,\n  text: 'All cats are animals. Whiskers is a cat. Therefore:',\n  options: ['Whiskers is an animal', 'All animals are cats', ...],\n  correctIndex: 0, timeLimit: 15,\n  explanation: 'Simple syllogism: cat → animal'\n}\n```\n\nWhen a user fills in their profile, the system computes a **difficulty target** based on three inputs:\n\n``` js\nexport const EDU_DIFFICULTY: Record<string, number> = {\n  school: 1.5,\n  diploma: 2.2,\n  undergraduate: 2.8,\n  postgraduate: 3.5,\n  doctorate: 4.2,\n}\n\nexport function getDifficultyRange(profile: UserProfile) {\n  let target = EDU_DIFFICULTY[profile.education] ?? 2.5\n\n  // Age-based adjustment\n  if (profile.age <= 13) target = Math.min(target, 1.5)\n  else if (profile.age <= 16) target = Math.min(target, 2.2)\n  else if (profile.age <= 19) target = Math.min(target, 3.0)\n  else if (profile.age >= 30 && isStem(profile.occupation)) {\n    target = Math.max(target, 3.2)\n  }\n\n  return {\n    min: Math.max(1, Math.floor(target - 1)),\n    max: Math.min(5, Math.ceil(target + 1.5)),\n    target\n  }\n}\n```\n\nSo a 22-year-old B.Tech CS student gets a difficulty window of **2–4**, while a 14-year-old high school student gets **1–2**. The InfoScreen even shows a live difficulty tier preview as the user fills out the form — \"Advanced tier — challenging questions requiring deeper reasoning\" — updating in real time.\n\nThe `isStem()`\n\nhelper checks the occupation string for keywords:\n\n```\nfunction isStem(occ: string): boolean {\n  return ['engineer', 'developer', 'programmer', 'data', 'scientist',\n    'computer', 'software', 'hardware', 'cs', 'iot', 'electronics',\n    'ai', 'ml', 'cyber', 'network']\n    .some(k => occ.toLowerCase().includes(k))\n}\n```\n\nSTEM occupations get Numerical, Pattern, and Logical questions boosted. Arts/language occupations get Verbal questions prioritised.\n\nThis was the feature that prompted the whole project. After 2–3 retakes, users were memorising answers.\n\nThe solution: track recently seen question IDs in `localStorage`\n\nand always serve unseen questions first.\n\n``` js\nconst SEEN_KEY = 'iq_platform_seen_questions_v2'\nconst MAX_SEEN = 80  // remember last 80 question IDs\n\nfunction getSeenIds(): Set<string> {\n  try {\n    const raw = localStorage.getItem(SEEN_KEY)\n    return raw ? new Set(JSON.parse(raw)) : new Set()\n  } catch { return new Set() }\n}\n\nfunction markAsSeen(ids: string[]): void {\n  const existing = [...getSeenIds()]\n  const updated = [...ids, ...existing].slice(0, MAX_SEEN)\n  localStorage.setItem(SEEN_KEY, JSON.stringify(updated))\n}\n```\n\nDuring selection, unseen questions are sorted to the front:\n\n``` js\nconst unseen = pool\n  .filter(q => !seenIds.has(q.id))\n  .sort(() => Math.random() - 0.5)\n\nconst seen = pool\n  .filter(q => seenIds.has(q.id))\n  .sort(() => Math.random() - 0.5)\n\n// Always prefer unseen\nconst prioritised = [...unseen, ...seen]\nselected.push(...prioritised.slice(0, perCategory))\n```\n\nWith 147 questions and 20 per test, a user can take 7+ tests before any question repeats — and when repeats do happen, they're shuffled into different positions and contexts.\n\nThis is the most interesting part technically. Instead of just calculating a percentage, I built a **7-feature weighted linear regression model** that maps raw performance to an IQ estimate.\n\n``` js\nconst features = {\n  accuracyScore,        // proportion of correct answers\n  speedScore,           // based on average time per correct answer\n  consistencyScore,     // penalises random correct answers (guessing)\n  difficultyWeighted,   // harder correct answers worth more\n  categoryBalance,      // uniform performance > spikey\n  adaptiveScore,        // penalises timed-out questions\n  educationNorm,        // calibrates for education baseline\n  ageNorm,              // peak window (16-35) = 1.0\n}\n```\n\n**Speed scoring** — not just \"was it right\" but \"how quickly\":\n\n``` js\nfunction calcSpeed(answers: TestAnswer[]): number {\n  const correct = answers.filter(a => a.correct && !a.timedOut)\n  const avg = correct.reduce((s, a) => s + a.timeSpent, 0) / correct.length\n  if (avg < 6)  return 1.0\n  if (avg < 10) return 0.9\n  if (avg < 15) return 0.8\n  if (avg < 20) return 0.7\n  if (avg < 25) return 0.55\n  return 0.4\n}\n```\n\n**Consistency scoring** — detects guessing by measuring variance within each domain:\n\n``` js\nfunction calcConsistency(answers: TestAnswer[]): number {\n  const byCategory: Record<string, boolean[]> = {}\n  answers.forEach(a => {\n    if (!byCategory[a.category]) byCategory[a.category] = []\n    byCategory[a.category].push(a.correct)\n  })\n\n  let totalVariance = 0, categories = 0\n  for (const cat in byCategory) {\n    const results = byCategory[cat]\n    if (results.length < 2) continue\n    const mean = results.filter(Boolean).length / results.length\n    const variance = results.reduce(\n      (s, r) => s + Math.pow((r ? 1 : 0) - mean, 2), 0\n    ) / results.length\n    totalVariance += variance\n    categories++\n  }\n  return clamp(1 - (totalVariance / categories) * 1.5, 0.3, 1.0)\n}\n```\n\nIf you get 3 correct and 3 wrong alternating in the same category, that high variance lowers your consistency score — which reduces your final IQ estimate.\n\n``` js\nconst WEIGHTS = {\n  accuracy:      0.42,\n  speed:         0.12,\n  consistency:   0.10,\n  difficulty:    0.22,\n  balance:       0.08,\n  adaptive:      0.06,\n}\n\nconst rawLinear =\n  WEIGHTS.accuracy  * accuracyScore         +\n  WEIGHTS.speed     * speedScore            +\n  WEIGHTS.consistency * consistencyScore    +\n  WEIGHTS.difficulty  * difficultyWeighted  +\n  WEIGHTS.balance   * categoryBalance       +\n  WEIGHTS.adaptive  * adaptiveScore\n```\n\nRaw score (0–1) maps to IQ (70–145) using a z-score approach with the normal distribution:\n\n``` js\n// Map raw score to z-score, scale by education and age norms\nconst zScore = (rawScore - 0.5) * 4.2 * educationNorm * ageNorm\n\n// IQ = mean + z * SD (mean=100, SD=15)\nconst iqEstimate = Math.round(clamp(100 + zScore * 15, 70, 145))\n\n// 90% confidence interval\nconst ciMargin = Math.round(8 + (1 - consistencyScore) * 6)\nconst confidenceInterval = [iqEstimate - ciMargin, iqEstimate + ciMargin]\n```\n\nA raw score of 0.5 maps to exactly IQ 100 (average). A raw score of 1.0 maps to approximately IQ 135. Demographic norms adjust the z-score — a doctorate-level user (educationNorm = 1.07) needs a higher raw performance to achieve the same IQ estimate as an undergraduate (educationNorm = 1.0), reflecting calibration to education-group baselines.\n\nThe percentile is calculated using a proper normal distribution CDF approximated via the Abramowitz and Stegun error function:\n\n``` js\nfunction erf(x: number): number {\n  const sign = x >= 0 ? 1 : -1; x = Math.abs(x)\n  const a1=0.254829592, a2=-0.284496736, a3=1.421413741,\n        a4=-1.453152027, a5=1.061405429, p=0.3275911\n  const t = 1/(1+p*x)\n  const y = 1-(((((a5*t+a4)*t)+a3)*t+a2)*t+a1)*t*Math.exp(-x*x)\n  return sign*y\n}\n\nconst percentile = Math.round(\n  0.5 * (1 + erf((iqEstimate - 100) / (15 * Math.sqrt(2)))) * 100\n)\n```\n\nSession history is stored entirely in `localStorage`\n\n, keyed by a normalised username:\n\n```\nexport function normaliseUserKey(name: string): string {\n  return name.trim().toLowerCase().replace(/\\s+/g, ' ')\n}\n```\n\nThis means \"Om\", \"om\", and \"OM\" all map to the same history bucket — but \"Om\" and \"Rahul\" are completely separate. No login required.\n\n```\nexport function saveSession(profile, result, totalQ, correct, avgTime, diffRange) {\n  const session: TestSession = {\n    id: `session_${Date.now()}_${Math.random().toString(36).slice(2, 7)}`,\n    timestamp: Date.now(),\n    userKey: normaliseUserKey(profile.name),\n    iqEstimate: result.iqEstimate,\n    categoryScores: result.categoryScores,\n    mlFeatures: result.mlFeatures,\n    difficultyRange: diffRange,\n    // ...\n  }\n  const existing = getAllSessions()\n  localStorage.setItem(STORAGE_KEY, JSON.stringify(\n    [session, ...existing].slice(0, 100)\n  ))\n}\n```\n\nThe History page fetches all sessions, groups them by `userKey`\n\n, and renders per-user filter tabs. The IQ trend chart (Recharts LineChart) shows your score trajectory across sessions. Domain averages accumulate lifetime category accuracy.\n\nThe homepage hero is a fully custom animated neural network rendered on a `<canvas>`\n\nelement — no library, pure browser APIs.\n\n38 nodes drift slowly across the canvas. Synaptic connections form between nodes within 160px of each other, with opacity proportional to distance. Signal particles spawn on random connections and travel along them with a glow effect.\n\n``` js\nfunction spawnSignal() {\n  const from = Math.floor(Math.random() * nodes.length)\n  // Find nearest neighbour within 180px\n  let best = -1, bestDist = Infinity\n  nodes.forEach((n, i) => {\n    if (i === from) return\n    const d = Math.hypot(n.x - nodes[from].x, n.y - nodes[from].y)\n    if (d < 180 && d < bestDist) { bestDist = d; best = i }\n  })\n  if (best !== -1) signals.push({ fromIdx: from, toIdx: best, t: 0, ... })\n}\n\n// Signal particle rendering\nsignals.forEach(sig => {\n  sig.t += sig.speed\n  const from = nodes[sig.fromIdx], to = nodes[sig.toIdx]\n  const x = from.x + (to.x - from.x) * sig.t\n  const y = from.y + (to.y - from.y) * sig.t\n\n  // Radial gradient glow\n  const grad = ctx.createRadialGradient(x, y, 0, x, y, 8)\n  grad.addColorStop(0, sig.color + 'dd')\n  grad.addColorStop(1, sig.color + '00')\n  ctx.fillStyle = grad\n  ctx.arc(x, y, 8, 0, Math.PI * 2)\n  ctx.fill()\n})\n```\n\nIt runs at 60fps on modern hardware and falls back gracefully on mobile.\n\n**1. Defining \"adaptive\" is harder than it sounds.** My first version just randomised questions. Then I added difficulty tiers. Then age ranges. Then occupation tags. Each layer felt necessary once I had the previous one. The question is always: what dimensions of the user actually matter for question selection?\n\n**2. localStorage is surprisingly capable for personal data.** No backend, no auth, no GDPR nightmare. For a tool that's genuinely private by design, browser storage is the right call — not a compromise.\n\n**3. Weighted regression beats percentage scoring immediately.** When I switched from `correct/total * 100`\n\nto the 7-feature model, the results suddenly felt more meaningful. A user who answers 12/20 quickly on hard questions scores very differently from one who answers 12/20 slowly on easy questions — as they should.\n\n**4. The canvas animation took longer than the ML engine.** The neural network hero is purely cosmetic but it's what people notice first and share. Don't underestimate the value of a visually distinctive entry point.\n\n**5. People want to retake immediately.** The most common feedback in the first week was \"I want to try again.\" The anti-repeat system was added within 48 hours of launching. Build for the thing people actually do, not the thing you imagined they'd do.\n\n🔗 **Try it:** [https://iq-platform-plum.vercel.app](https://iq-platform-plum.vercel.app)\n\nBuilt during my final year B.Tech in Computer Science at COER University, Roorkee.\n\nThe platform is free, has no login, and stores nothing on a server. Questions are selected fresh for each session based on your profile, and the ML engine shows you a full breakdown of every feature that contributed to your score.\n\nWould love your feedback — especially on the ML model weights and whether the adaptive selection feels right across different education levels. Drop a comment or connect on [LinkedIn](https://linkedin.com/in/omprakash-984540299).\n\n*If you found this useful, consider leaving a reaction — it helps other developers find it.* 🦄", "url": "https://wpnews.pro/news/ml-powered-adaptive-iq-test", "canonical_source": "https://dev.to/om_prakash_50d70268c48f1b/i-built-an-ml-powered-adaptive-iq-test-in-nextjs-14-heres-exactly-how-it-works-iaj", "published_at": "2026-06-28 10:32:45+00:00", "updated_at": "2026-06-28 11:04:16.221524+00:00", "lang": "en", "topics": ["machine-learning", "developer-tools"], "entities": ["IQ Platform", "WAIS-IV"], "alternates": {"html": "https://wpnews.pro/news/ml-powered-adaptive-iq-test", "markdown": "https://wpnews.pro/news/ml-powered-adaptive-iq-test.md", "text": "https://wpnews.pro/news/ml-powered-adaptive-iq-test.txt", "jsonld": "https://wpnews.pro/news/ml-powered-adaptive-iq-test.jsonld"}}