{
 "cells": [
  {
   "cell_type": "markdown",
   "id": "bcf3edf3",
   "metadata": {},
   "source": [
    "# Introduction to AI · The arms race you lose\n",
    "\n",
    "Hand-written rules against a spam filter. Two experiments: rules against a\n",
    "world that stays still, and rules against a world that answers back.\n"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": 1,
   "id": "1d3cecb1",
   "metadata": {
    "execution": {
     "iopub.execute_input": "2026-08-17T13:24:30.942394Z",
     "iopub.status.busy": "2026-08-17T13:24:30.942161Z",
     "iopub.status.idle": "2026-08-17T13:24:31.203109Z",
     "shell.execute_reply": "2026-08-17T13:24:31.200431Z"
    }
   },
   "outputs": [],
   "source": [
    "# hide — plumbing so this file runs both in the repo and in Colab.\n",
    "try:\n",
    "    from _figkit import save_fig, record\n",
    "except ImportError:  # Colab — no repo, no problem\n",
    "    def save_fig(name, plot, **kw):\n",
    "        import matplotlib.pyplot as plt\n",
    "        fig, ax = plt.subplots(figsize=kw.get('figsize', (7, 4.2))); plot(ax); plt.show()\n",
    "    def record(key, value):\n",
    "        print(f'{key} = {value}'); return value\n",
    "import random\n"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "720fb1cf",
   "metadata": {},
   "source": [
    "## 1. A mailbox\n",
    "\n",
    "Spam is written from a vocabulary of words that skew spammy; real mail is\n",
    "written from ordinary ones. Neither is pure — plenty of real mail says\n",
    "*free* and plenty of spam says *meeting*.\n"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": 2,
   "id": "a608aaa9",
   "metadata": {
    "execution": {
     "iopub.execute_input": "2026-08-17T13:24:31.208973Z",
     "iopub.status.busy": "2026-08-17T13:24:31.208570Z",
     "iopub.status.idle": "2026-08-17T13:24:31.241333Z",
     "shell.execute_reply": "2026-08-17T13:24:31.241123Z"
    }
   },
   "outputs": [
    {
     "name": "stdout",
     "output_type": "stream",
     "text": [
      "n_mail = 8,000\n",
      "spam_share = 40%\n",
      "vocab_size = 40\n"
     ]
    },
    {
     "data": {
      "text/plain": [
       "40"
      ]
     },
     "execution_count": 2,
     "metadata": {},
     "output_type": "execute_result"
    }
   ],
   "source": [
    "#| caption: Generate a month of mail\n",
    "SPAMMY  = [f'spamword{i}' for i in range(40)]\n",
    "NEUTRAL = [f'word{i}' for i in range(300)]\n",
    "N, SPAM_RATE, ROUNDS = 8000, 0.40, 40\n",
    "\n",
    "def make_mail(rng, spam_vocab):\n",
    "    \"\"\"One mailbox, written from whatever vocabulary the spammer is using\n",
    "    THIS week. Neither side is pure: plenty of real mail says 'free'.\"\"\"\n",
    "    mail = []\n",
    "    for _ in range(N):\n",
    "        spam = rng.random() < SPAM_RATE\n",
    "        pool = spam_vocab if spam else NEUTRAL\n",
    "        toks = {rng.choice(pool) for _ in range(rng.randint(4, 9))}\n",
    "        toks |= {rng.choice(NEUTRAL) for _ in range(rng.randint(3, 7))}\n",
    "        if not spam and rng.random() < .18:\n",
    "            toks.add(rng.choice(spam_vocab))\n",
    "        mail.append((toks, spam))\n",
    "    return mail\n",
    "\n",
    "rng = random.Random(3)\n",
    "mail = make_mail(rng, SPAMMY)\n",
    "record('n_mail', f'{len(mail):,}')\n",
    "record('spam_share', f'{sum(s for _, s in mail)/len(mail):.0%}')\n",
    "record('vocab_size', len(SPAMMY))\n"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "a8f2ef6a",
   "metadata": {},
   "source": [
    "## 2. Rules against a world that stays still\n",
    "\n",
    "Write the best rule you can, measure, write the next best, measure again.\n",
    "Forty times. Each rule is *if this word appears, call it spam*.\n"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": 3,
   "id": "bdb53616",
   "metadata": {
    "execution": {
     "iopub.execute_input": "2026-08-17T13:24:31.242392Z",
     "iopub.status.busy": "2026-08-17T13:24:31.242330Z",
     "iopub.status.idle": "2026-08-17T13:24:38.262820Z",
     "shell.execute_reply": "2026-08-17T13:24:38.262522Z"
    }
   },
   "outputs": [
    {
     "name": "stdout",
     "output_type": "stream",
     "text": [
      "static_rule1 = 65.5%\n",
      "static_rule5 = 81.8%\n",
      "static_peak = 94.2%\n",
      "static_peak_at = 18\n",
      "static_final = 89.8%\n",
      "do_nothing = 60%\n"
     ]
    },
    {
     "data": {
      "text/plain": [
       "'60%'"
      ]
     },
     "execution_count": 3,
     "metadata": {},
     "output_type": "execute_result"
    }
   ],
   "source": [
    "#| caption: Greedy rule-writing, against a world that stays still\n",
    "# BOTH experiments score on mail the rules were NOT written from.\n",
    "def score(mail, rules):\n",
    "    return sum((bool(t & rules) == s) for t, s in mail) / len(mail)\n",
    "\n",
    "def best_next(mail, rules):\n",
    "    vocab = {w for t, _ in mail for w in t}      # includes anything new\n",
    "    gain = {}\n",
    "    for w in vocab:\n",
    "        if w in rules: continue\n",
    "        gain[w] = (sum((w in t) and s for t, s in mail)\n",
    "                   - sum((w in t) and not s for t, s in mail))\n",
    "    return max(gain, key=gain.get)\n",
    "\n",
    "BASE = max(SPAM_RATE, 1 - SPAM_RATE)\n",
    "rng_t = random.Random(99)\n",
    "test_mail = make_mail(rng_t, SPAMMY)             # fresh, never written from\n",
    "\n",
    "rules, static_curve = set(), []\n",
    "for _ in range(ROUNDS):\n",
    "    rules.add(best_next(mail, rules))\n",
    "    static_curve.append(score(test_mail, rules))\n",
    "\n",
    "peak = max(static_curve); peak_at = static_curve.index(peak) + 1\n",
    "record('static_rule1', f'{static_curve[0]:.1%}')\n",
    "record('static_rule5', f'{static_curve[4]:.1%}')\n",
    "record('static_peak', f'{peak:.1%}')\n",
    "record('static_peak_at', peak_at)\n",
    "record('static_final', f'{static_curve[-1]:.1%}')\n",
    "record('do_nothing', f'{BASE:.0%}')\n"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "4f0764f8",
   "metadata": {},
   "source": [
    "## 3. Rules against a world that answers back\n",
    "\n",
    "Same game, one change: after every rule you write, the spammer stops using\n",
    "the word you just banned and picks a fresh one. You are still writing good\n",
    "rules. They are just being written about last week's mail.\n"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": 4,
   "id": "dcd44671",
   "metadata": {
    "execution": {
     "iopub.execute_input": "2026-08-17T13:24:38.264380Z",
     "iopub.status.busy": "2026-08-17T13:24:38.264276Z",
     "iopub.status.idle": "2026-08-17T13:24:47.798672Z",
     "shell.execute_reply": "2026-08-17T13:24:47.798416Z"
    }
   },
   "outputs": [
    {
     "name": "stdout",
     "output_type": "stream",
     "text": [
      "adaptive_rule1 = 65.4%\n",
      "adaptive_peak = 89.5%\n",
      "adaptive_final = 89.2%\n",
      "words_replaced = 31\n",
      "gap_at_end = 1\n",
      "n_rules = 40\n"
     ]
    },
    {
     "data": {
      "text/plain": [
       "40"
      ]
     },
     "execution_count": 4,
     "metadata": {},
     "output_type": "execute_result"
    }
   ],
   "source": [
    "#| caption: The same forty rules, against a spammer who answers back\n",
    "# The spammer holds a LIVE vocabulary. When a rule lands on a word it is\n",
    "# using, it drops that word and invents a brand new one nobody has a rule\n",
    "# for — VIAGRA becomes V1AGRA. Deliberately imperfect: it notices only 75%\n",
    "# of the time, and two rounds late. One that adapts instantly and perfectly\n",
    "# makes the filter never gain a single point, which is a caricature.\n",
    "rng2 = random.Random(3)\n",
    "live = list(SPAMMY); fresh = 0; pending = []\n",
    "rules2, adaptive_curve, replaced = set(), [], 0\n",
    "\n",
    "for r in range(ROUNDS):\n",
    "    train = make_mail(rng2, live)                # this week's mail\n",
    "    w = best_next(train, rules2)\n",
    "    rules2.add(w)\n",
    "    if w in live and rng2.random() < 0.75:\n",
    "        pending.append((r + 2, w))               # noticed, acted on later\n",
    "    for due, word in [x for x in pending if x[0] <= r]:\n",
    "        if word in live:\n",
    "            live.remove(word); fresh += 1\n",
    "            live.append(f'fresh{fresh}'); replaced += 1\n",
    "    pending = [x for x in pending if x[0] > r]\n",
    "    adaptive_curve.append(score(make_mail(rng_t, live), rules2))\n",
    "\n",
    "apeak = max(adaptive_curve)\n",
    "record('adaptive_rule1', f'{adaptive_curve[0]:.1%}')\n",
    "record('adaptive_peak', f'{apeak:.1%}')\n",
    "record('adaptive_final', f'{adaptive_curve[-1]:.1%}')\n",
    "record('words_replaced', replaced)\n",
    "record('gap_at_end', f'{(static_curve[-1]-adaptive_curve[-1])*100:.0f}')\n",
    "record('n_rules', ROUNDS)\n"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": 5,
   "id": "80f339b6",
   "metadata": {
    "execution": {
     "iopub.execute_input": "2026-08-17T13:24:47.799912Z",
     "iopub.status.busy": "2026-08-17T13:24:47.799806Z",
     "iopub.status.idle": "2026-08-17T13:24:47.877903Z",
     "shell.execute_reply": "2026-08-17T13:24:47.877688Z"
    }
   },
   "outputs": [
    {
     "name": "stdout",
     "output_type": "stream",
     "text": [
      "figure two-worlds -> the-arms-race-you-lose.two-worlds.{light,dark}.svg\n"
     ]
    }
   ],
   "source": [
    "#| caption: Both curves, same axes\n",
    "def plot(ax):\n",
    "    xs = range(1, ROUNDS+1)\n",
    "    ax.plot(xs, [100*v for v in static_curve], lw=2.2, label='a world that stays still')\n",
    "    ax.plot(xs, [100*v for v in adaptive_curve], lw=2.2, ls='--',\n",
    "            label='a world that answers back')\n",
    "    ax.axhline(100*max(SPAM_RATE, 1-SPAM_RATE), lw=1.1, ls=':',\n",
    "               label='writing no rules at all')\n",
    "    ax.set_xlabel('rules written')\n",
    "    ax.set_ylabel('correct (%)')\n",
    "    ax.set_ylim(40, 102)\n",
    "    ax.legend(frameon=False, loc='lower right', fontsize=9)\n",
    "save_fig('two-worlds', plot, figsize=(7, 4.2))\n"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": 6,
   "id": "a97c8fd2",
   "metadata": {
    "execution": {
     "iopub.execute_input": "2026-08-17T13:24:47.879051Z",
     "iopub.status.busy": "2026-08-17T13:24:47.878961Z",
     "iopub.status.idle": "2026-08-17T13:24:47.950784Z",
     "shell.execute_reply": "2026-08-17T13:24:47.950541Z"
    }
   },
   "outputs": [
    {
     "name": "stdout",
     "output_type": "stream",
     "text": [
      "figure what-each-rule-bought -> the-arms-race-you-lose.what-each-rule-bought.{light,dark}.svg\n",
      "marginal_1 = 5.5\n",
      "marginal_20 = -0.24\n",
      "marginal_40 = -0.31\n"
     ]
    },
    {
     "data": {
      "text/plain": [
       "'-0.31'"
      ]
     },
     "execution_count": 6,
     "metadata": {},
     "output_type": "execute_result"
    }
   ],
   "source": [
    "#| caption: What each new rule was worth\n",
    "marg = [static_curve[0]-BASE] + \\\n",
    "       [static_curve[i]-static_curve[i-1] for i in range(1, ROUNDS)]\n",
    "def plot(ax):\n",
    "    ax.bar(range(1, ROUNDS+1), [100*m for m in marg])\n",
    "    ax.set_xlabel('rule number')\n",
    "    ax.set_ylabel('percentage points it added')\n",
    "save_fig('what-each-rule-bought', plot, figsize=(7, 3.4))\n",
    "\n",
    "record('marginal_1', f'{100*marg[0]:.1f}')\n",
    "record('marginal_20', f'{100*marg[19]:.2f}')\n",
    "record('marginal_40', f'{100*marg[39]:.2f}')\n"
   ]
  }
 ],
 "metadata": {
  "kernelspec": {
   "display_name": "Python 3",
   "language": "python",
   "name": "python3"
  },
  "language_info": {
   "codemirror_mode": {
    "name": "ipython",
    "version": 3
   },
   "file_extension": ".py",
   "mimetype": "text/x-python",
   "name": "python",
   "nbconvert_exporter": "python",
   "pygments_lexer": "ipython3",
   "version": "3.13.5"
  }
 },
 "nbformat": 4,
 "nbformat_minor": 5
}
