{
 "cells": [
  {
   "cell_type": "markdown",
   "id": "cell-000",
   "metadata": {},
   "source": [
    "# Lesson 1 — Software Architecture: from Script to Package\n",
    "\n",
    "**Companion notebook** for the Lesson 1 slides. You'll take apart `price_book.py`, a\n",
    "working but monolithic pricing script, and rebuild its pieces the way they appear in a\n",
    "package: validated inputs (pydantic), the rating manual as data, one rating algorithm\n",
    "per coverage (Strategy), algorithms chosen by name (Factory), and a golden-master check\n",
    "that the new code prices the book like the old one."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "cell-001",
   "metadata": {},
   "outputs": [],
   "source": [
    "def check(label, got, expected, tol=1e-6):\n",
    "    \"\"\"Compare your answer with the expected value; prints ✅ or ❌ (never raises).\"\"\"\n",
    "    import math\n",
    "    try:\n",
    "        if isinstance(expected, float) or isinstance(got, float):\n",
    "            ok = math.isclose(float(got), float(expected), rel_tol=tol, abs_tol=tol)\n",
    "        elif isinstance(expected, (list, tuple)) and expected and isinstance(expected[0], str):\n",
    "            ok = list(got) == list(expected)\n",
    "        elif hasattr(expected, \"__len__\") and not isinstance(expected, str):\n",
    "            import numpy as np\n",
    "            ok = np.allclose(np.asarray(got, dtype=float), np.asarray(expected, dtype=float), rtol=tol, atol=tol)\n",
    "        else:\n",
    "            ok = got == expected\n",
    "    except Exception:\n",
    "        ok = False\n",
    "    print((\"✅ \" if ok else \"❌ \") + f\"{label}: got {got!r}, expected {expected!r}\")\n",
    "import subprocess\n",
    "import sys\n",
    "import tomllib\n",
    "from dataclasses import dataclass\n",
    "from pathlib import Path\n",
    "from typing import Annotated, Literal, Protocol, Self\n",
    "\n",
    "import pandas as pd\n",
    "from pydantic import BaseModel, BeforeValidator, ConfigDict, Field, ValidationError, model_validator\n",
    "\n",
    "MOTOR = Path(\"../data/motor\")\n",
    "MONOLITH = Path(\"../starter/monolith/price_book.py\")"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "cell-002",
   "metadata": {},
   "source": [
    "## 1. The monolith\n",
    "\n",
    "Read the script first: `print(MONOLITH.read_text())`. Then run it the way its author\n",
    "does, on the 2025 book."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "cell-003",
   "metadata": {},
   "outputs": [],
   "source": [
    "run = subprocess.run([sys.executable, str(MONOLITH), str(MOTOR / \"book_2025.csv\"), \"mono.csv\"],\n",
    "                     capture_output=True, text=True)\n",
    "print(run.stdout)\n",
    "mono = pd.read_csv(\"mono.csv\", dtype={\"vehicle_id\": str}).set_index(\"vehicle_id\")"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "cell-004",
   "metadata": {},
   "source": [
    "It works, but try it on new-business quote requests:"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "cell-005",
   "metadata": {},
   "outputs": [],
   "source": [
    "run = subprocess.run([sys.executable, str(MONOLITH), str(MOTOR / \"quote_requests.csv\"), \"q.csv\"],\n",
    "                     capture_output=True, text=True)\n",
    "print(\"exit code\", run.returncode, \"·\", run.stderr.strip().splitlines()[-1])"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "cell-006",
   "metadata": {},
   "source": [
    "Pricing, validation, file handling, and the renewal report are welded together: the\n",
    "script can't price a vehicle that has no current premium. The rest of this notebook\n",
    "separates them.\n",
    "\n",
    "---\n",
    "## Your turn 1 — validate the inputs\n",
    "\n",
    "Complete the `Vehicle` model so that a record is accepted only if:\n",
    "\n",
    "| field | rule |\n",
    "|---|---|\n",
    "| `driver_age` | a number from 16 to 110 |\n",
    "| `vehicle_style` | one of `Style` |\n",
    "| `vehicle_value` | greater than 0, at most 500,000 |\n",
    "| `annual_mileage` | greater than 0, at most 150,000 |\n",
    "| `urban` | a boolean |\n",
    "| `coverages` | a set of `Coverage` codes (`_split` turns `\"LIAB|COLL\"` into a list) |\n",
    "| `coll_deductible`, `comp_deductible` | a `Deductible`, or `None` |\n",
    "\n",
    "plus two record-level rules in the `model_validator`: LIAB is compulsory, and each of\n",
    "COLL and COMP has a deductible **if and only if** it is bought."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "cell-007",
   "metadata": {},
   "outputs": [],
   "source": [
    "Coverage = Literal[\"LIAB\", \"COLL\", \"COMP\"]\n",
    "Style = Literal[\"Sedan\", \"SUV\", \"Hatchback\", \"Pickup\", \"Minivan\", \"Sports\"]\n",
    "\n",
    "\n",
    "def _split(value):\n",
    "    return value.split(\"|\") if isinstance(value, str) else value\n",
    "\n",
    "\n",
    "def _whole_number(value):\n",
    "    \"\"\"Files hold text: '500' means the integer 500 (a Literal match needs an int).\"\"\"\n",
    "    if isinstance(value, str) and value.replace(\".\", \"\", 1).isdigit() and float(value).is_integer():\n",
    "        return int(float(value))\n",
    "    return value\n",
    "\n",
    "\n",
    "Deductible = Annotated[Literal[250, 500, 1000], BeforeValidator(_whole_number)]\n",
    "\n",
    "\n",
    "class Vehicle(BaseModel):\n",
    "    model_config = ConfigDict(frozen=True, extra=\"ignore\")\n",
    "    ...  # your code here"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "cell-008",
   "metadata": {},
   "source": [
    "Now validate every row of `quote_requests.csv`. Read it as text\n",
    "(`dtype=str, keep_default_na=False`) and turn empty strings into `None`, so that a\n",
    "missing age is reported as missing instead of becoming `NaN`. Collect the valid vehicles\n",
    "in a dict by `quote_id`, and one row per error (`quote_id`, `field`, `error`) in a\n",
    "DataFrame `errors`. For a record-level error, `loc` is empty: use `\"(record)\"`."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "cell-009",
   "metadata": {},
   "outputs": [],
   "source": [
    "raw = pd.read_csv(MOTOR / \"quote_requests.csv\", dtype=str, keep_default_na=False)\n",
    "valid, errors = None, None\n",
    "...  # your code here\n",
    "check(\"valid requests\", None if valid is None else len(valid), 963)\n",
    "check(\"rejected requests\", None if errors is None else errors[\"quote_id\"].nunique(), 37)\n",
    "check(\"bad vehicle styles\", None if errors is None else int((errors[\"field\"] == \"vehicle_style\").sum()), 7)"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "cell-010",
   "metadata": {},
   "source": [
    "38 errors were planted in the file; one slipped through. Find the row whose `urban` is\n",
    "`\"Y\"`: pydantic's *lax* mode reads `\"Y\"`, `\"yes\"`, and `\"1\"` as `True`. Decide whether\n",
    "that is what your business wants (`ConfigDict(strict=True)` forbids it)."
   ]
  },
  {
   "cell_type": "markdown",
   "id": "cell-011",
   "metadata": {},
   "source": [
    "## 2. The manual is data, not code\n",
    "\n",
    "Every number an actuary might change lives in `rating_manual.toml`."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "cell-012",
   "metadata": {},
   "outputs": [],
   "source": [
    "print((MOTOR / \"rating_manual.toml\").read_text())"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "cell-013",
   "metadata": {},
   "source": [
    "## Your turn 2 — band tables\n",
    "\n",
    "Bands are `[lower, upper)`: a value belongs to the first band whose `upper` it is\n",
    "**below**. Complete `BandTable.lookup`, then load the manual."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "cell-014",
   "metadata": {},
   "outputs": [],
   "source": [
    "class BandTable(BaseModel):\n",
    "    model_config = ConfigDict(frozen=True)\n",
    "    upper: tuple[float, ...]\n",
    "    factor: tuple[float, ...]\n",
    "\n",
    "    def lookup(self, value: float) -> float:\n",
    "        ...  # your code here\n",
    "\n",
    "\n",
    "class RatingManual(BaseModel):\n",
    "    model_config = ConfigDict(frozen=True)\n",
    "    base_rate: dict[Coverage, float]\n",
    "    style: dict[Style, float]\n",
    "    driver_age: BandTable\n",
    "    annual_mileage: BandTable\n",
    "    value_per: float\n",
    "    deductible: dict[int, float]\n",
    "\n",
    "\n",
    "toml = tomllib.loads((MOTOR / \"rating_manual.toml\").read_text())\n",
    "toml[\"value_per\"] = toml.pop(\"vehicle_value\")[\"per\"]\n",
    "toml[\"deductible\"] = {int(k): v for k, v in toml[\"deductible\"].items()}\n",
    "manual = RatingManual.model_validate(toml)\n",
    "ages = [24.99, 25.0, 29.99, 30.0, 65.0, 75.0]\n",
    "check(\"age factors at the boundaries\", [manual.driver_age.lookup(a) for a in ages],\n",
    "      [1.60, 1.15, 1.15, 1.00, 1.10, 1.35])\n",
    "check(\"mileage factor at 15,000\", manual.annual_mileage.lookup(15_000), 1.20)"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "cell-015",
   "metadata": {},
   "source": [
    "## 3. One algorithm per coverage (Strategy)\n",
    "\n",
    "Every rater has the same shape: `premium(vehicle) -> float`. Two are written for you."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "cell-016",
   "metadata": {},
   "outputs": [],
   "source": [
    "class CoverageRater(Protocol):\n",
    "    def premium(self, vehicle: Vehicle) -> float: ...\n",
    "\n",
    "\n",
    "@dataclass(frozen=True)\n",
    "class ManualLiability:\n",
    "    manual: RatingManual\n",
    "\n",
    "    def premium(self, v: Vehicle) -> float:\n",
    "        m = self.manual\n",
    "        return round(m.base_rate[\"LIAB\"] * m.style[v.vehicle_style] * m.driver_age.lookup(v.driver_age)\n",
    "                     * m.annual_mileage.lookup(v.annual_mileage), 2)\n",
    "\n",
    "\n",
    "@dataclass(frozen=True)\n",
    "class ManualComprehensive:\n",
    "    manual: RatingManual\n",
    "\n",
    "    def premium(self, v: Vehicle) -> float:\n",
    "        m = self.manual\n",
    "        return round(m.base_rate[\"COMP\"] * m.annual_mileage.lookup(v.annual_mileage)\n",
    "                     * v.vehicle_value / m.value_per * m.deductible[v.comp_deductible], 2)"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "cell-017",
   "metadata": {},
   "source": [
    "## Your turn 3 — collision, and the engine\n",
    "\n",
    "COLL = base rate × style × driver age × mileage × value ÷ 30,000 × deductible factor\n",
    "(multiply in that order, then round to cents). Then write `RatingEngine.quote`, which\n",
    "returns `{coverage: premium}` for the coverages the vehicle bought, in the order LIAB,\n",
    "COLL, COMP."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "cell-018",
   "metadata": {},
   "outputs": [],
   "source": [
    "@dataclass(frozen=True)\n",
    "class ManualCollision:\n",
    "    manual: RatingManual\n",
    "\n",
    "    def premium(self, v: Vehicle) -> float:\n",
    "        ...  # your code here\n",
    "\n",
    "\n",
    "class RatingEngine:\n",
    "    def __init__(self, raters: dict[str, CoverageRater]):\n",
    "        self.raters = raters\n",
    "\n",
    "    def quote(self, v: Vehicle) -> dict[str, float]:\n",
    "        ...  # your code here\n",
    "\n",
    "\n",
    "engine = RatingEngine({\"LIAB\": ManualLiability(manual), \"COLL\": ManualCollision(manual),\n",
    "                       \"COMP\": ManualComprehensive(manual)})\n",
    "example = Vehicle(driver_age=22, vehicle_style=\"Sports\", vehicle_value=45_000, annual_mileage=16_000,\n",
    "                  urban=True, coverages=\"LIAB|COLL|COMP\", coll_deductible=1000, comp_deductible=250)\n",
    "q = engine.quote(example)\n",
    "print(q)\n",
    "check(\"example quote\", None if q is None else list(q.values()), [2521.59, 2046.76, 313.53])"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "cell-019",
   "metadata": {},
   "source": [
    "## Your turn 4 — a second liability algorithm, chosen by name (Factory)\n",
    "\n",
    "The Course 3 rate review proposed liability × 1.25 for urban vehicles and × 0.857 for\n",
    "the rest. Write `UrbanAmendedLiability` (wrap `ManualLiability` and round again), register\n",
    "it under `(\"LIAB\", \"urban_amendment\")`, and complete `build_engine(choices)`, which starts\n",
    "from `\"manual\"` for every coverage and applies the overrides in `choices`. An unknown\n",
    "name must raise `ValueError`.\n",
    "\n",
    "Then price the 2025 book with both engines and store the **total LIAB premium change**."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "cell-020",
   "metadata": {},
   "outputs": [],
   "source": [
    "ALGORITHMS = {(\"LIAB\", \"manual\"): ManualLiability, (\"COLL\", \"manual\"): ManualCollision,\n",
    "              (\"COMP\", \"manual\"): ManualComprehensive}\n",
    "liab_change = None\n",
    "...  # your code here\n",
    "check(\"LIAB premium change, urban amendment\", None if liab_change is None else round(liab_change, 4), 0.0918)\n",
    "try:\n",
    "    build_engine({\"LIAB\": \"glm\"})\n",
    "    print(\"❌ an unknown algorithm should raise ValueError\")\n",
    "except ValueError as e:\n",
    "    print(\"✅ unknown algorithm rejected:\", e)\n",
    "except NameError:\n",
    "    print(\"❌ build_engine is not defined yet\")"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "cell-021",
   "metadata": {},
   "source": [
    "## Your turn 5 — the golden master\n",
    "\n",
    "A refactor must price the book exactly as before, or explain every difference. Price the\n",
    "book with `build_engine()` and compare each vehicle's total with the monolith's\n",
    "`manual_premium` (`mono`). Count the vehicles that differ by more than half a cent, then\n",
    "find how many of them are drivers aged 25 to 26 and how many have exactly 15,000 miles.\n",
    "Look back at the monolith's `if` statements: who is right?"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "cell-022",
   "metadata": {},
   "outputs": [],
   "source": [
    "n_diff = n_age25 = n_15k = None\n",
    "...  # your code here\n",
    "check(\"vehicles priced differently\", n_diff, 324)\n",
    "check(\"... of which drivers aged 25.x\", n_age25, 254)\n",
    "check(\"... of which exactly 15,000 miles\", n_15k, 72)"
   ]
  }
 ],
 "metadata": {
  "kernelspec": {
   "display_name": "Python 3",
   "language": "python",
   "name": "python3"
  },
  "language_info": {
   "name": "python"
  }
 },
 "nbformat": 4,
 "nbformat_minor": 5
}
