{
 "cells": [
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "# 02 — Conviction Voting Analysis\n",
    "\n",
    "Analyzing the TEC's Disputable Conviction Voting system:\n",
    "- Proposal outcomes and funding distribution\n",
    "- Participation dynamics and voter concentration\n",
    "- Conviction accumulation patterns\n",
    "- Effective governance throughput"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": 1,
   "metadata": {
    "execution": {
     "iopub.execute_input": "2026-04-03T21:39:43.619838Z",
     "iopub.status.busy": "2026-04-03T21:39:43.619413Z",
     "iopub.status.idle": "2026-04-03T21:39:44.292452Z",
     "shell.execute_reply": "2026-04-03T21:39:44.290969Z"
    }
   },
   "outputs": [
    {
     "name": "stdout",
     "output_type": "stream",
     "text": [
      "Proposals: 47\n",
      "Stake events: 891\n",
      "Support updates: 873\n",
      "\n",
      "Proposal status breakdown:\n",
      "status\n",
      "executed     36\n",
      "cancelled    10\n",
      "open          1\n",
      "Name: count, dtype: int64\n"
     ]
    }
   ],
   "source": [
    "import pandas as pd\n",
    "import numpy as np\n",
    "import matplotlib.pyplot as plt\n",
    "import matplotlib.dates as mdates\n",
    "import seaborn as sns\n",
    "\n",
    "sns.set_theme(style='whitegrid', palette='deep')\n",
    "plt.rcParams['figure.figsize'] = (14, 6)\n",
    "plt.rcParams['figure.dpi'] = 100\n",
    "\n",
    "DATA = '../data/onchain'\n",
    "\n",
    "proposals = pd.read_csv(f'{DATA}/cv_proposals.csv')\n",
    "stakes = pd.read_csv(f'{DATA}/cv_stakes.csv')\n",
    "supports = pd.read_csv(f'{DATA}/cv_support_updates.csv')\n",
    "\n",
    "print(f'Proposals: {len(proposals)}')\n",
    "print(f'Stake events: {len(stakes)}')\n",
    "print(f'Support updates: {len(supports)}')\n",
    "print(f'\\nProposal status breakdown:')\n",
    "print(proposals['status'].value_counts())"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "## 1. Proposal Funding Distribution\n",
    "\n",
    "How was the common pool allocated? Who were the biggest recipients?"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": 2,
   "metadata": {
    "execution": {
     "iopub.execute_input": "2026-04-03T21:39:44.320001Z",
     "iopub.status.busy": "2026-04-03T21:39:44.319777Z",
     "iopub.status.idle": "2026-04-03T21:39:45.268690Z",
     "shell.execute_reply": "2026-04-03T21:39:45.267240Z"
    }
   },
   "outputs": [
    {
     "name": "stderr",
     "output_type": "stream",
     "text": [
      "/tmp/ipykernel_82345/1426465025.py:28: UserWarning: Glyph 129304 (\\N{SIGN OF THE HORNS}) missing from font(s) DejaVu Sans.\n",
      "  plt.tight_layout()\n",
      "/tmp/ipykernel_82345/1426465025.py:29: UserWarning: Glyph 129304 (\\N{SIGN OF THE HORNS}) missing from font(s) DejaVu Sans.\n",
      "  plt.savefig(f'{DATA}/../snapshots/cv_funding_distribution.png', dpi=150, bbox_inches='tight')\n"
     ]
    },
    {
     "name": "stderr",
     "output_type": "stream",
     "text": [
      "/home/jeffe/Github/TEC-analysis/.venv/lib/python3.12/site-packages/IPython/core/pylabtools.py:170: UserWarning: Glyph 129304 (\\N{SIGN OF THE HORNS}) missing from font(s) DejaVu Sans.\n",
      "  fig.canvas.print_figure(bytes_io, **kw)\n"
     ]
    },
    {
     "data": {
      "image/png": "[encoded data, 260620 characters]",
      "text/plain": [
       "<Figure size 1800x800 with 2 Axes>"
      ]
     },
     "metadata": {},
     "output_type": "display_data"
    },
    {
     "name": "stdout",
     "output_type": "stream",
     "text": [
      "\n",
      "Top 5 beneficiaries by total funding:\n",
      "  0x3b067af83f540c... :     50,000 TEC (1 proposals)\n",
      "    - TE Fundamentals / TE Academy\n",
      "  0x5041a1c1dcc760... :     54,000 TEC (1 proposals)\n",
      "    - cadCAD Community Funding\n",
      "  0x0fe03a31c0ab49... :     59,559 TEC (2 proposals)\n",
      "    - Communitas WG - Second term funding proposal\n",
      "    - Communitas WG 3-month funding\n",
      "  0xa26d6aeb775a95... :     64,000 TEC (2 proposals)\n",
      "    - Support TE Academy\n",
      "    - Support TE Academy (Part 2)\n",
      "  0xffbd35255008f8... :     76,801 TEC (2 proposals)\n",
      "    - Gravity DAO Second funding proposal from the TEC \n",
      "    - Gravity 6 month funding from the TEC\n"
     ]
    }
   ],
   "source": [
    "funded = proposals[proposals['status'] == 'executed'].copy()\n",
    "funded = funded.sort_values('amount_requested', ascending=True)\n",
    "\n",
    "# Extract short proposal names from link field\n",
    "funded['name'] = funded['link'].str[:50]\n",
    "\n",
    "fig, axes = plt.subplots(1, 2, figsize=(18, 8))\n",
    "\n",
    "# Bar chart of funded amounts\n",
    "ax = axes[0]\n",
    "bars = ax.barh(range(len(funded)), funded['amount_requested'], color='steelblue')\n",
    "ax.set_yticks(range(len(funded)))\n",
    "ax.set_yticklabels(funded['name'], fontsize=7)\n",
    "ax.set_xlabel('TEC Tokens Requested')\n",
    "ax.set_title(f'Funded Proposals ({len(funded)} total, {funded[\"amount_requested\"].sum():,.0f} TEC)')\n",
    "\n",
    "# Top beneficiary addresses (group by beneficiary)\n",
    "ax = axes[1]\n",
    "by_beneficiary = funded.groupby('beneficiary')['amount_requested'].sum().sort_values(ascending=True)\n",
    "# Show short addresses\n",
    "labels = [f'{addr[:8]}...{addr[-4:]}' for addr in by_beneficiary.index]\n",
    "ax.barh(range(len(by_beneficiary)), by_beneficiary.values, color='teal')\n",
    "ax.set_yticks(range(len(by_beneficiary)))\n",
    "ax.set_yticklabels(labels, fontsize=8)\n",
    "ax.set_xlabel('Total TEC Received')\n",
    "ax.set_title(f'Funding by Beneficiary Address ({len(by_beneficiary)} unique)')\n",
    "\n",
    "plt.tight_layout()\n",
    "plt.savefig(f'{DATA}/../snapshots/cv_funding_distribution.png', dpi=150, bbox_inches='tight')\n",
    "plt.show()\n",
    "\n",
    "print(f'\\nTop 5 beneficiaries by total funding:')\n",
    "for addr, amt in by_beneficiary.tail(5).items():\n",
    "    props = funded[funded['beneficiary'] == addr]['name'].tolist()\n",
    "    print(f'  {addr[:16]}... : {amt:>10,.0f} TEC ({len(props)} proposals)')\n",
    "    for p in props:\n",
    "        print(f'    - {p}')"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "## 2. Proposal Success/Failure Patterns"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": 3,
   "metadata": {
    "execution": {
     "iopub.execute_input": "2026-04-03T21:39:45.270730Z",
     "iopub.status.busy": "2026-04-03T21:39:45.270560Z",
     "iopub.status.idle": "2026-04-03T21:39:45.652352Z",
     "shell.execute_reply": "2026-04-03T21:39:45.651097Z"
    }
   },
   "outputs": [
    {
     "data": {
      "image/png": "[encoded data, 85960 characters]",
      "text/plain": [
       "<Figure size 1800x500 with 3 Axes>"
      ]
     },
     "metadata": {},
     "output_type": "display_data"
    }
   ],
   "source": [
    "# Exclude the Abstain proposal (#1) and zero-amount proposals\n",
    "real_proposals = proposals[(proposals['id'] > 1) & (proposals['amount_requested'] > 0)].copy()\n",
    "\n",
    "fig, axes = plt.subplots(1, 3, figsize=(18, 5))\n",
    "\n",
    "# Status breakdown\n",
    "ax = axes[0]\n",
    "status_counts = real_proposals['status'].value_counts()\n",
    "colors = {'executed': '#2ecc71', 'cancelled': '#e74c3c', 'open': '#3498db'}\n",
    "ax.pie(status_counts.values, labels=status_counts.index, autopct='%1.0f%%',\n",
    "       colors=[colors.get(s, 'gray') for s in status_counts.index])\n",
    "ax.set_title(f'Proposal Outcomes (n={len(real_proposals)})')\n",
    "\n",
    "# Amount requested: funded vs cancelled\n",
    "ax = axes[1]\n",
    "for status, color in [('executed', '#2ecc71'), ('cancelled', '#e74c3c')]:\n",
    "    subset = real_proposals[real_proposals['status'] == status]\n",
    "    ax.hist(subset['amount_requested'], bins=15, alpha=0.6, label=status, color=color)\n",
    "ax.set_xlabel('Amount Requested (TEC)')\n",
    "ax.set_ylabel('Count')\n",
    "ax.set_title('Distribution of Request Sizes')\n",
    "ax.legend()\n",
    "\n",
    "# Cancelled proposals - were they resubmitted?\n",
    "ax = axes[2]\n",
    "cancelled = real_proposals[real_proposals['status'] == 'cancelled']\n",
    "resubmitted = []\n",
    "for _, row in cancelled.iterrows():\n",
    "    # Check if same beneficiary has a funded proposal\n",
    "    same_benef = funded[funded['beneficiary'] == row['beneficiary']]\n",
    "    resubmitted.append(len(same_benef) > 0)\n",
    "resub_counts = pd.Series(resubmitted).value_counts()\n",
    "labels = ['Resubmitted & Funded' if k else 'Not Resubmitted' for k in resub_counts.index]\n",
    "ax.pie(resub_counts.values, labels=labels, autopct='%1.0f%%',\n",
    "       colors=['#2ecc71', '#95a5a6'])\n",
    "ax.set_title(f'Cancelled Proposals: Resubmission')\n",
    "\n",
    "plt.tight_layout()\n",
    "plt.savefig(f'{DATA}/../snapshots/cv_proposal_outcomes.png', dpi=150, bbox_inches='tight')\n",
    "plt.show()"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "## 3. Voter Participation & Concentration\n",
    "\n",
    "How concentrated was governance power? Did a small number of whales dominate?"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": 4,
   "metadata": {
    "execution": {
     "iopub.execute_input": "2026-04-03T21:39:45.654270Z",
     "iopub.status.busy": "2026-04-03T21:39:45.654111Z",
     "iopub.status.idle": "2026-04-03T21:39:47.056259Z",
     "shell.execute_reply": "2026-04-03T21:39:47.054827Z"
    }
   },
   "outputs": [
    {
     "data": {
      "image/png": "[encoded data, 168008 characters]",
      "text/plain": [
       "<Figure size 1600x1000 with 4 Axes>"
      ]
     },
     "metadata": {},
     "output_type": "display_data"
    },
    {
     "name": "stdout",
     "output_type": "stream",
     "text": [
      "\n",
      "Participation stats:\n",
      "  Total unique stakers: 159\n",
      "  Median proposals per staker: 2\n",
      "  Mean proposals per staker: 4.5\n",
      "  Stakers on 1 proposal only: 69\n",
      "  Stakers on 5+ proposals: 49\n",
      "  Gini coefficient: 0.843\n",
      "  Top 10 stakers hold 65.1% of max stake\n"
     ]
    }
   ],
   "source": [
    "# Analyze staker participation\n",
    "staker_stats = stakes.groupby('staker').agg(\n",
    "    num_stakes=('proposal_id', 'count'),\n",
    "    unique_proposals=('proposal_id', 'nunique'),\n",
    "    max_tokens_staked=('tokens_staked', 'max'),\n",
    "    total_conviction=('conviction', 'sum'),\n",
    ").sort_values('max_tokens_staked', ascending=False)\n",
    "\n",
    "fig, axes = plt.subplots(2, 2, figsize=(16, 10))\n",
    "\n",
    "# Staker activity distribution\n",
    "ax = axes[0, 0]\n",
    "ax.hist(staker_stats['unique_proposals'], bins=range(0, staker_stats['unique_proposals'].max()+2),\n",
    "        color='steelblue', edgecolor='white')\n",
    "ax.set_xlabel('Number of Proposals Staked On')\n",
    "ax.set_ylabel('Number of Stakers')\n",
    "ax.set_title(f'Staker Engagement Distribution (n={len(staker_stats)})')\n",
    "\n",
    "# Stake size distribution (log scale)\n",
    "ax = axes[0, 1]\n",
    "max_stakes = staker_stats['max_tokens_staked'].sort_values(ascending=False)\n",
    "ax.bar(range(len(max_stakes)), max_stakes.values, color='teal', width=1.0)\n",
    "ax.set_yscale('log')\n",
    "ax.set_xlabel('Staker Rank')\n",
    "ax.set_ylabel('Max Tokens Staked (log scale)')\n",
    "ax.set_title('Stake Size Distribution (Whale Analysis)')\n",
    "\n",
    "# Cumulative stake concentration (Lorenz-like curve)\n",
    "ax = axes[1, 0]\n",
    "sorted_stakes = max_stakes.sort_values().values\n",
    "cumulative = np.cumsum(sorted_stakes) / sorted_stakes.sum()\n",
    "x = np.arange(len(cumulative)) / len(cumulative)\n",
    "ax.plot(x, cumulative, 'b-', linewidth=2, label='Actual')\n",
    "ax.plot([0, 1], [0, 1], 'k--', alpha=0.3, label='Perfect equality')\n",
    "ax.set_xlabel('Fraction of Stakers')\n",
    "ax.set_ylabel('Fraction of Total Stake')\n",
    "ax.set_title('Stake Concentration (Lorenz Curve)')\n",
    "ax.legend()\n",
    "\n",
    "# Gini coefficient\n",
    "n = len(sorted_stakes)\n",
    "gini = (2 * np.sum((np.arange(1, n+1)) * sorted_stakes) / (n * np.sum(sorted_stakes))) - (n+1)/n\n",
    "ax.text(0.05, 0.9, f'Gini: {gini:.3f}', transform=ax.transAxes, fontsize=12,\n",
    "        bbox=dict(boxstyle='round', facecolor='wheat', alpha=0.8))\n",
    "\n",
    "# Top 10 stakers - what % of total conviction\n",
    "ax = axes[1, 1]\n",
    "top10 = staker_stats.head(10)\n",
    "top10_share = top10['max_tokens_staked'].sum() / staker_stats['max_tokens_staked'].sum()\n",
    "labels_short = [f'{addr[:6]}...{addr[-4:]}' for addr in top10.index]\n",
    "ax.barh(range(len(top10)), top10['max_tokens_staked'], color='coral')\n",
    "ax.set_yticks(range(len(top10)))\n",
    "ax.set_yticklabels(labels_short, fontsize=8)\n",
    "ax.set_xlabel('Max Tokens Staked')\n",
    "ax.set_title(f'Top 10 Stakers ({top10_share:.1%} of total stake)')\n",
    "\n",
    "plt.tight_layout()\n",
    "plt.savefig(f'{DATA}/../snapshots/cv_participation.png', dpi=150, bbox_inches='tight')\n",
    "plt.show()\n",
    "\n",
    "print(f'\\nParticipation stats:')\n",
    "print(f'  Total unique stakers: {len(staker_stats)}')\n",
    "print(f'  Median proposals per staker: {staker_stats[\"unique_proposals\"].median():.0f}')\n",
    "print(f'  Mean proposals per staker: {staker_stats[\"unique_proposals\"].mean():.1f}')\n",
    "print(f'  Stakers on 1 proposal only: {(staker_stats[\"unique_proposals\"]==1).sum()}')\n",
    "print(f'  Stakers on 5+ proposals: {(staker_stats[\"unique_proposals\"]>=5).sum()}')\n",
    "print(f'  Gini coefficient: {gini:.3f}')\n",
    "print(f'  Top 10 stakers hold {top10_share:.1%} of max stake')"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "## 4. Conviction Dynamics Over Time\n",
    "\n",
    "How did conviction accumulate? Were proposals funded quickly or did they require sustained support?"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": 5,
   "metadata": {
    "execution": {
     "iopub.execute_input": "2026-04-03T21:39:47.059304Z",
     "iopub.status.busy": "2026-04-03T21:39:47.059116Z",
     "iopub.status.idle": "2026-04-03T21:39:47.620562Z",
     "shell.execute_reply": "2026-04-03T21:39:47.619436Z"
    }
   },
   "outputs": [
    {
     "data": {
      "image/png": "[encoded data, 86492 characters]",
      "text/plain": [
       "<Figure size 1600x600 with 2 Axes>"
      ]
     },
     "metadata": {},
     "output_type": "display_data"
    },
    {
     "name": "stdout",
     "output_type": "stream",
     "text": [
      "\n",
      "Conviction dynamics:\n",
      "  Funded proposals - median days active: 198.0\n",
      "  Cancelled proposals - median days active: 50.2\n"
     ]
    }
   ],
   "source": [
    "# Track conviction over time for each proposal\n",
    "# Use support updates as the timeline\n",
    "support_by_prop = supports.groupby('proposal_id').agg(\n",
    "    num_updates=('block', 'count'),\n",
    "    first_block=('block', 'min'),\n",
    "    last_block=('block', 'max'),\n",
    ")\n",
    "support_by_prop['block_span'] = support_by_prop['last_block'] - support_by_prop['first_block']\n",
    "# Approximate time: Gnosis Chain ~5 sec blocks\n",
    "support_by_prop['days_active'] = support_by_prop['block_span'] * 5 / 86400\n",
    "\n",
    "# Merge with proposal data\n",
    "merged = proposals.merge(support_by_prop, left_on='id', right_index=True, how='left')\n",
    "merged = merged[merged['id'] > 1]  # exclude abstain\n",
    "\n",
    "fig, axes = plt.subplots(1, 2, figsize=(16, 6))\n",
    "\n",
    "# Days active by status\n",
    "ax = axes[0]\n",
    "for status, color in [('executed', '#2ecc71'), ('cancelled', '#e74c3c')]:\n",
    "    subset = merged[merged['status'] == status]\n",
    "    ax.scatter(subset['amount_requested'], subset['days_active'],\n",
    "              c=color, s=60, alpha=0.7, label=status, edgecolors='white')\n",
    "ax.set_xlabel('Amount Requested (TEC)')\n",
    "ax.set_ylabel('Days Active (approx)')\n",
    "ax.set_title('Proposal Lifetime vs Request Size')\n",
    "ax.legend()\n",
    "\n",
    "# Number of support updates per proposal\n",
    "ax = axes[1]\n",
    "for status, color in [('executed', '#2ecc71'), ('cancelled', '#e74c3c')]:\n",
    "    subset = merged[merged['status'] == status]\n",
    "    ax.scatter(subset['amount_requested'], subset['num_updates'],\n",
    "              c=color, s=60, alpha=0.7, label=status, edgecolors='white')\n",
    "ax.set_xlabel('Amount Requested (TEC)')\n",
    "ax.set_ylabel('Support Update Events')\n",
    "ax.set_title('Governance Activity per Proposal')\n",
    "ax.legend()\n",
    "\n",
    "plt.tight_layout()\n",
    "plt.savefig(f'{DATA}/../snapshots/cv_dynamics.png', dpi=150, bbox_inches='tight')\n",
    "plt.show()\n",
    "\n",
    "print(f'\\nConviction dynamics:')\n",
    "print(f'  Funded proposals - median days active: {merged[merged[\"status\"]==\"executed\"][\"days_active\"].median():.1f}')\n",
    "print(f'  Cancelled proposals - median days active: {merged[merged[\"status\"]==\"cancelled\"][\"days_active\"].median():.1f}')"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "## 5. Governance Throughput & Spending Rate"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": 6,
   "metadata": {
    "execution": {
     "iopub.execute_input": "2026-04-03T21:39:47.623894Z",
     "iopub.status.busy": "2026-04-03T21:39:47.623491Z",
     "iopub.status.idle": "2026-04-03T21:39:48.219922Z",
     "shell.execute_reply": "2026-04-03T21:39:48.219016Z"
    }
   },
   "outputs": [
    {
     "data": {
      "image/png": "[encoded data, 113136 characters]",
      "text/plain": [
       "<Figure size 1600x1000 with 2 Axes>"
      ]
     },
     "metadata": {},
     "output_type": "display_data"
    },
    {
     "name": "stdout",
     "output_type": "stream",
     "text": [
      "\n",
      "Spending analysis:\n",
      "  Active period: Jan 2022 to Mar 2023 (404 days)\n",
      "  Total funded: 680,295 TEC\n",
      "  Monthly burn rate: ~50,517 TEC/month\n",
      "  Avg proposal size: 18,897 TEC\n",
      "  Median proposal size: 14,074 TEC\n"
     ]
    }
   ],
   "source": [
    "# Timeline of funded proposals\n",
    "funded_timeline = proposals[(proposals['status'] == 'executed') & (proposals['amount_requested'] > 0)].copy()\n",
    "funded_timeline = funded_timeline.sort_values('block')\n",
    "funded_timeline['cumulative_funded'] = funded_timeline['amount_requested'].cumsum()\n",
    "\n",
    "# Approximate dates from blocks (block 20086944 ≈ Jan 19, 2022)\n",
    "# Gnosis chain: ~5 sec blocks\n",
    "import datetime\n",
    "genesis_block = 20086944\n",
    "genesis_date = datetime.datetime(2022, 1, 19)\n",
    "funded_timeline['date'] = funded_timeline['block'].apply(\n",
    "    lambda b: genesis_date + datetime.timedelta(seconds=(b - genesis_block) * 5)\n",
    ")\n",
    "\n",
    "fig, axes = plt.subplots(2, 1, figsize=(16, 10))\n",
    "\n",
    "# Cumulative funding\n",
    "ax = axes[0]\n",
    "ax.step(funded_timeline['date'], funded_timeline['cumulative_funded'],\n",
    "        where='post', color='steelblue', linewidth=2)\n",
    "ax.fill_between(funded_timeline['date'], funded_timeline['cumulative_funded'],\n",
    "                step='post', alpha=0.2, color='steelblue')\n",
    "ax.set_ylabel('Cumulative TEC Funded')\n",
    "ax.set_title('Conviction Voting: Cumulative Funding Over Time')\n",
    "ax.xaxis.set_major_formatter(mdates.DateFormatter('%b %Y'))\n",
    "ax.xaxis.set_major_locator(mdates.MonthLocator(interval=3))\n",
    "plt.setp(ax.xaxis.get_majorticklabels(), rotation=45)\n",
    "\n",
    "# Individual proposal amounts over time\n",
    "ax = axes[1]\n",
    "colors_map = {'executed': '#2ecc71', 'cancelled': '#e74c3c', 'open': '#3498db'}\n",
    "all_props = proposals[proposals['id'] > 1].copy()\n",
    "all_props['date'] = all_props['block'].apply(\n",
    "    lambda b: genesis_date + datetime.timedelta(seconds=(b - genesis_block) * 5)\n",
    ")\n",
    "for status in ['executed', 'cancelled']:\n",
    "    subset = all_props[all_props['status'] == status]\n",
    "    ax.bar(subset['date'], subset['amount_requested'],\n",
    "           width=5, color=colors_map[status], alpha=0.7, label=status)\n",
    "ax.set_ylabel('TEC Requested')\n",
    "ax.set_xlabel('Date')\n",
    "ax.set_title('Individual Proposals Over Time')\n",
    "ax.legend()\n",
    "ax.xaxis.set_major_formatter(mdates.DateFormatter('%b %Y'))\n",
    "ax.xaxis.set_major_locator(mdates.MonthLocator(interval=3))\n",
    "plt.setp(ax.xaxis.get_majorticklabels(), rotation=45)\n",
    "\n",
    "plt.tight_layout()\n",
    "plt.savefig(f'{DATA}/../snapshots/cv_throughput.png', dpi=150, bbox_inches='tight')\n",
    "plt.show()\n",
    "\n",
    "# Calculate spending rate\n",
    "date_range = (funded_timeline['date'].max() - funded_timeline['date'].min()).days\n",
    "monthly_rate = funded_timeline['amount_requested'].sum() / (date_range / 30)\n",
    "print(f'\\nSpending analysis:')\n",
    "print(f'  Active period: {funded_timeline[\"date\"].min().strftime(\"%b %Y\")} to {funded_timeline[\"date\"].max().strftime(\"%b %Y\")} ({date_range} days)')\n",
    "print(f'  Total funded: {funded_timeline[\"amount_requested\"].sum():,.0f} TEC')\n",
    "print(f'  Monthly burn rate: ~{monthly_rate:,.0f} TEC/month')\n",
    "print(f'  Avg proposal size: {funded_timeline[\"amount_requested\"].mean():,.0f} TEC')\n",
    "print(f'  Median proposal size: {funded_timeline[\"amount_requested\"].median():,.0f} TEC')"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "## 6. Key Findings Summary"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": 7,
   "metadata": {
    "execution": {
     "iopub.execute_input": "2026-04-03T21:39:48.221720Z",
     "iopub.status.busy": "2026-04-03T21:39:48.221577Z",
     "iopub.status.idle": "2026-04-03T21:39:48.226763Z",
     "shell.execute_reply": "2026-04-03T21:39:48.225989Z"
    }
   },
   "outputs": [
    {
     "name": "stdout",
     "output_type": "stream",
     "text": [
      "============================================================\n",
      "CONVICTION VOTING — KEY FINDINGS\n",
      "============================================================\n",
      "\n",
      "SCALE:\n",
      "  47 total proposals, 36 funded\n",
      "  680,295 TEC disbursed\n",
      "  159 unique governance participants\n",
      "\n",
      "CONCENTRATION:\n",
      "  Gini coefficient: 0.843\n",
      "  Top 10 stakers: 65.1% of stake\n",
      "  69/159 stakers only voted once\n",
      "\n",
      "OUTCOMES:\n",
      "  36/43 proposals funded (84% success rate)\n",
      "  7/43 cancelled (16%)\n",
      "\n",
      "QUESTIONS FOR DEEPER ANALYSIS:\n",
      "  1. Did conviction voting favor incumbents/repeat proposers?\n",
      "  2. How did the 11% spending limit affect large proposals?\n",
      "  3. Were cancelled proposals victims of insufficient participation or active opposition?\n",
      "  4. Did the Abstain proposal (#1) serve its intended signal function?\n",
      "  5. How did staking for CV affect circulating supply and ABC price?\n",
      "\n"
     ]
    }
   ],
   "source": [
    "print('=' * 60)\n",
    "print('CONVICTION VOTING — KEY FINDINGS')\n",
    "print('=' * 60)\n",
    "print(f'''\n",
    "SCALE:\n",
    "  {len(proposals)} total proposals, {len(funded_timeline)} funded\n",
    "  {funded_timeline[\"amount_requested\"].sum():,.0f} TEC disbursed\n",
    "  {len(staker_stats)} unique governance participants\n",
    "\n",
    "CONCENTRATION:\n",
    "  Gini coefficient: {gini:.3f}\n",
    "  Top 10 stakers: {top10_share:.1%} of stake\n",
    "  {(staker_stats[\"unique_proposals\"]==1).sum()}/{len(staker_stats)} stakers only voted once\n",
    "\n",
    "OUTCOMES:\n",
    "  {len(funded_timeline)}/{len(real_proposals)} proposals funded ({len(funded_timeline)/len(real_proposals):.0%} success rate)\n",
    "  {len(cancelled)}/{len(real_proposals)} cancelled ({len(cancelled)/len(real_proposals):.0%})\n",
    "\n",
    "QUESTIONS FOR DEEPER ANALYSIS:\n",
    "  1. Did conviction voting favor incumbents/repeat proposers?\n",
    "  2. How did the 11% spending limit affect large proposals?\n",
    "  3. Were cancelled proposals victims of insufficient participation or active opposition?\n",
    "  4. Did the Abstain proposal (#1) serve its intended signal function?\n",
    "  5. How did staking for CV affect circulating supply and ABC price?\n",
    "''')"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "## 7. Conviction Growth & Decay per Proposal\n",
    "\n",
    "Reconstruct per-proposal conviction and staking timelines to see how support built up, peaked, and decayed over each proposal's lifecycle."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": 8,
   "metadata": {
    "execution": {
     "iopub.execute_input": "2026-04-03T21:39:48.228779Z",
     "iopub.status.busy": "2026-04-03T21:39:48.228631Z",
     "iopub.status.idle": "2026-04-03T21:39:48.281812Z",
     "shell.execute_reply": "2026-04-03T21:39:48.281023Z"
    }
   },
   "outputs": [
    {
     "name": "stdout",
     "output_type": "stream",
     "text": [
      "Built timeline: 891 data points across 43 proposals\n",
      "Date range: Jan 27 2022 to May 04 2023\n",
      "\n",
      "Abstain proposal (#1): 90 data points\n",
      "  Peak stake: 729,141 TEC\n",
      "  Final stake: 706,785 TEC\n",
      "  Peak conviction: 514,968\n"
     ]
    }
   ],
   "source": [
    "# Reconstruct per-proposal total stake by tracking each staker's position over time\n",
    "import datetime\n",
    "\n",
    "genesis_block = 20086944\n",
    "genesis_date = datetime.datetime(2022, 1, 19)\n",
    "\n",
    "def block_to_date(block):\n",
    "    return genesis_date + datetime.timedelta(seconds=int(block - genesis_block) * 5)\n",
    "\n",
    "# Sort all stake events chronologically\n",
    "all_stakes = stakes.sort_values('block').copy()\n",
    "all_stakes['date'] = all_stakes['block'].apply(block_to_date)\n",
    "\n",
    "# Track each staker's current position on each proposal\n",
    "staker_positions = {}  # (staker, proposal_id) -> tokens_staked\n",
    "timeline_records = []\n",
    "\n",
    "for _, row in all_stakes.iterrows():\n",
    "    pid = int(row['proposal_id'])\n",
    "    staker = row['staker']\n",
    "    staker_positions[(staker, pid)] = row['tokens_staked']\n",
    "    \n",
    "    # Compute true proposal total = sum of all stakers on this proposal\n",
    "    prop_total = sum(v for (s, p), v in staker_positions.items() if p == pid)\n",
    "    \n",
    "    timeline_records.append({\n",
    "        'block': int(row['block']),\n",
    "        'date': row['date'],\n",
    "        'proposal_id': pid,\n",
    "        'conviction': row['conviction'],\n",
    "        'proposal_total_stake': prop_total,\n",
    "        'staker': staker,\n",
    "    })\n",
    "\n",
    "timeline = pd.DataFrame(timeline_records)\n",
    "\n",
    "# Get last state per proposal per block\n",
    "prop_timeline = timeline.groupby(['proposal_id', 'block']).agg(\n",
    "    conviction=('conviction', 'last'),\n",
    "    total_stake=('proposal_total_stake', 'last'),\n",
    "    date=('date', 'first'),\n",
    ").reset_index().sort_values(['proposal_id', 'block'])\n",
    "\n",
    "# Proposal metadata lookup\n",
    "prop_meta = proposals.set_index('id').to_dict('index')\n",
    "\n",
    "# Color scheme\n",
    "status_colors = {'executed': '#2ecc71', 'cancelled': '#e74c3c', 'open': '#f39c12'}\n",
    "\n",
    "print(f'Built timeline: {len(prop_timeline)} data points across {prop_timeline[\"proposal_id\"].nunique()} proposals')\n",
    "print(f'Date range: {prop_timeline[\"date\"].min().strftime(\"%b %d %Y\")} to {prop_timeline[\"date\"].max().strftime(\"%b %d %Y\")}')\n",
    "\n",
    "# Show Abstain proposal (#1) stake trajectory\n",
    "abstain = prop_timeline[prop_timeline['proposal_id'] == 1]\n",
    "print(f'\\nAbstain proposal (#1): {len(abstain)} data points')\n",
    "print(f'  Peak stake: {abstain[\"total_stake\"].max():,.0f} TEC')\n",
    "print(f'  Final stake: {abstain[\"total_stake\"].iloc[-1]:,.0f} TEC')\n",
    "print(f'  Peak conviction: {abstain[\"conviction\"].max():,.0f}')"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": 9,
   "metadata": {
    "execution": {
     "iopub.execute_input": "2026-04-03T21:39:48.283586Z",
     "iopub.status.busy": "2026-04-03T21:39:48.283438Z",
     "iopub.status.idle": "2026-04-03T21:39:49.136243Z",
     "shell.execute_reply": "2026-04-03T21:39:49.134815Z"
    }
   },
   "outputs": [
    {
     "data": {
      "image/png": "[encoded data, 309428 characters]",
      "text/plain": [
       "<Figure size 1800x1400 with 2 Axes>"
      ]
     },
     "metadata": {},
     "output_type": "display_data"
    }
   ],
   "source": [
    "# Static conviction timeplots: conviction growth/decay per proposal\n",
    "from matplotlib.lines import Line2D\n",
    "\n",
    "def safe_exec_block(meta):\n",
    "    \"\"\"Safely extract executed_block as int, or return None.\"\"\"\n",
    "    eb = meta.get('executed_block', '')\n",
    "    try:\n",
    "        return int(float(eb))\n",
    "    except (ValueError, TypeError):\n",
    "        return None\n",
    "\n",
    "fig, axes = plt.subplots(2, 1, figsize=(18, 14))\n",
    "\n",
    "# --- Panel 1: Conviction score per proposal ---\n",
    "ax = axes[0]\n",
    "for pid in sorted(prop_timeline['proposal_id'].unique()):\n",
    "    subset = prop_timeline[prop_timeline['proposal_id'] == pid].sort_values('date')\n",
    "    meta = prop_meta.get(pid, {})\n",
    "    status = meta.get('status', 'unknown')\n",
    "    color = status_colors.get(status, 'gray')\n",
    "    lw = 2.5 if pid == 1 else 1.2\n",
    "    alpha_val = 1.0 if pid == 1 else (0.8 if status == 'executed' else 0.5)\n",
    "    label = 'Abstain (#1)' if pid == 1 else ''\n",
    "    ax.plot(subset['date'], subset['conviction'], color=color, alpha=alpha_val,\n",
    "            linewidth=lw, label=label)\n",
    "    \n",
    "    eb = safe_exec_block(meta)\n",
    "    if eb is not None:\n",
    "        exec_date = block_to_date(eb)\n",
    "        exec_conv = subset['conviction'].iloc[-1]\n",
    "        ax.scatter([exec_date], [exec_conv], color=color, s=40, zorder=5,\n",
    "                   marker='v', edgecolors='black', linewidth=0.5)\n",
    "\n",
    "ax.set_ylabel('Conviction Score', fontsize=12)\n",
    "ax.set_title('Conviction Growth & Decay per Proposal', fontsize=14)\n",
    "legend_elements = [\n",
    "    Line2D([0], [0], color='#f39c12', lw=2.5, label='Abstain (#1)'),\n",
    "    Line2D([0], [0], color='#2ecc71', lw=1.5, label='Executed'),\n",
    "    Line2D([0], [0], color='#e74c3c', lw=1.5, label='Cancelled'),\n",
    "    Line2D([0], [0], marker='v', color='gray', lw=0, markersize=8, label='Execution point'),\n",
    "]\n",
    "ax.legend(handles=legend_elements, loc='upper right', fontsize=10)\n",
    "ax.xaxis.set_major_formatter(mdates.DateFormatter('%b %Y'))\n",
    "ax.xaxis.set_major_locator(mdates.MonthLocator(interval=3))\n",
    "\n",
    "# --- Panel 2: Total tokens staked per proposal ---\n",
    "ax = axes[1]\n",
    "for pid in sorted(prop_timeline['proposal_id'].unique()):\n",
    "    subset = prop_timeline[prop_timeline['proposal_id'] == pid].sort_values('date')\n",
    "    meta = prop_meta.get(pid, {})\n",
    "    status = meta.get('status', 'unknown')\n",
    "    color = status_colors.get(status, 'gray')\n",
    "    lw = 2.5 if pid == 1 else 1.2\n",
    "    alpha_val = 1.0 if pid == 1 else (0.8 if status == 'executed' else 0.5)\n",
    "    ax.plot(subset['date'], subset['total_stake'], color=color, alpha=alpha_val,\n",
    "            linewidth=lw)\n",
    "\n",
    "ax.set_ylabel('Total Tokens Staked', fontsize=12)\n",
    "ax.set_xlabel('Date', fontsize=12)\n",
    "ax.set_title('Staking Support per Proposal Over Time', fontsize=14)\n",
    "ax.legend(handles=legend_elements[:3], loc='upper right', fontsize=10)\n",
    "ax.xaxis.set_major_formatter(mdates.DateFormatter('%b %Y'))\n",
    "ax.xaxis.set_major_locator(mdates.MonthLocator(interval=3))\n",
    "\n",
    "plt.setp(axes[-1].xaxis.get_majorticklabels(), rotation=45)\n",
    "plt.tight_layout()\n",
    "plt.savefig(f'{DATA}/../snapshots/cv_conviction_timelines.png', dpi=150, bbox_inches='tight')\n",
    "plt.show()"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "## 8. The Abstain Proposal as Inertial Force\n",
    "\n",
    "In conviction voting, the Abstain proposal (#1) acts as an **inertial brake** on all other proposals. Tokens staked on Abstain don't contribute conviction toward any funding request, effectively increasing the threshold for all other proposals. When Abstain stake is high relative to active proposals, it becomes harder to pass funding — this is by design, allowing token holders to signal \"slow down\" without opposing specific proposals.\n",
    "\n",
    "How did this inertial force evolve over time?"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": 10,
   "metadata": {
    "execution": {
     "iopub.execute_input": "2026-04-03T21:39:49.138481Z",
     "iopub.status.busy": "2026-04-03T21:39:49.138316Z",
     "iopub.status.idle": "2026-04-03T21:39:50.429984Z",
     "shell.execute_reply": "2026-04-03T21:39:50.427889Z"
    }
   },
   "outputs": [
    {
     "data": {
      "image/png": "[encoded data, 208652 characters]",
      "text/plain": [
       "<Figure size 1800x1600 with 3 Axes>"
      ]
     },
     "metadata": {},
     "output_type": "display_data"
    },
    {
     "name": "stdout",
     "output_type": "stream",
     "text": [
      "\n",
      "Abstain as Inertial Force:\n",
      "  Peak Abstain stake: 729,141 TEC\n",
      "  Peak Active stake: 7,668,191 TEC\n",
      "  Avg Abstain share: 15.7%\n",
      "  Max Abstain share: 100.0%\n",
      "  Min Abstain share: 7.7%\n",
      "  Periods where Abstain > Active: 31/890 events (3%)\n"
     ]
    }
   ],
   "source": [
    "# Build daily time series of Abstain stake vs total active proposal stakes\n",
    "# Use chronological event data to reconstruct state at each event\n",
    "\n",
    "# Get abstain and non-abstain timelines\n",
    "abstain_tl = prop_timeline[prop_timeline['proposal_id'] == 1][['date', 'total_stake']].copy()\n",
    "abstain_tl.columns = ['date', 'abstain_stake']\n",
    "\n",
    "# For non-abstain, sum total stake across all active proposals at each event\n",
    "# We need a global timeline: at each event block, compute total staked on non-abstain proposals\n",
    "event_blocks = sorted(prop_timeline['block'].unique())\n",
    "\n",
    "# Build state tracker: last known total_stake for each proposal\n",
    "last_stake = {}\n",
    "global_timeline = []\n",
    "\n",
    "for block in event_blocks:\n",
    "    events_at_block = prop_timeline[prop_timeline['block'] == block]\n",
    "    for _, row in events_at_block.iterrows():\n",
    "        last_stake[row['proposal_id']] = row['total_stake']\n",
    "    \n",
    "    abstain_s = last_stake.get(1, 0)\n",
    "    active_s = sum(v for k, v in last_stake.items() if k != 1)\n",
    "    total_s = abstain_s + active_s\n",
    "    \n",
    "    global_timeline.append({\n",
    "        'block': block,\n",
    "        'date': block_to_date(block),\n",
    "        'abstain_stake': abstain_s,\n",
    "        'active_stake': active_s,\n",
    "        'total_staked': total_s,\n",
    "        'abstain_share': abstain_s / total_s if total_s > 0 else 0,\n",
    "    })\n",
    "\n",
    "gtl = pd.DataFrame(global_timeline)\n",
    "\n",
    "fig, axes = plt.subplots(3, 1, figsize=(18, 16))\n",
    "\n",
    "# Panel 1: Abstain vs Active proposals stacked area\n",
    "ax = axes[0]\n",
    "ax.fill_between(gtl['date'], 0, gtl['abstain_stake'], alpha=0.6, color='#f39c12', label='Abstain (#1)')\n",
    "ax.fill_between(gtl['date'], gtl['abstain_stake'], gtl['abstain_stake'] + gtl['active_stake'],\n",
    "                alpha=0.5, color='#2ecc71', label='Active Proposals')\n",
    "ax.set_ylabel('Tokens Staked', fontsize=12)\n",
    "ax.set_title('Abstain vs Active Proposal Stakes Over Time', fontsize=14)\n",
    "ax.legend(fontsize=11, loc='upper right')\n",
    "ax.xaxis.set_major_formatter(mdates.DateFormatter('%b %Y'))\n",
    "ax.xaxis.set_major_locator(mdates.MonthLocator(interval=3))\n",
    "\n",
    "# Panel 2: Abstain share as \"brake strength\"\n",
    "ax = axes[1]\n",
    "ax.fill_between(gtl['date'], gtl['abstain_share'], alpha=0.4, color='#f39c12')\n",
    "ax.plot(gtl['date'], gtl['abstain_share'], color='#e67e22', linewidth=2)\n",
    "ax.axhline(y=0.5, color='red', linestyle='--', alpha=0.3, label='50% threshold')\n",
    "ax.set_ylabel('Abstain Share of Total Staked', fontsize=12)\n",
    "ax.set_title('Abstain \"Brake Strength\" Over Time (higher = harder to pass proposals)', fontsize=14)\n",
    "ax.set_ylim(0, 1)\n",
    "ax.legend(fontsize=10)\n",
    "ax.xaxis.set_major_formatter(mdates.DateFormatter('%b %Y'))\n",
    "ax.xaxis.set_major_locator(mdates.MonthLocator(interval=3))\n",
    "\n",
    "# Panel 3: Abstain stake with funded proposals marked\n",
    "ax = axes[2]\n",
    "ax.plot(gtl['date'], gtl['abstain_stake'], color='#f39c12', linewidth=2, label='Abstain stake')\n",
    "# Mark when proposals got funded\n",
    "for _, row in funded_timeline.iterrows():\n",
    "    eb = safe_exec_block({'executed_block': row.get('executed_block', '')})\n",
    "    if eb is not None:\n",
    "        exec_date = block_to_date(eb)\n",
    "        ax.axvline(x=exec_date, color='#2ecc71', alpha=0.3, linewidth=0.8)\n",
    "ax.set_ylabel('Abstain Tokens Staked', fontsize=12)\n",
    "ax.set_xlabel('Date', fontsize=12)\n",
    "ax.set_title('Abstain Stake Level with Funded Proposals (green lines)', fontsize=14)\n",
    "ax.legend(fontsize=10)\n",
    "ax.xaxis.set_major_formatter(mdates.DateFormatter('%b %Y'))\n",
    "ax.xaxis.set_major_locator(mdates.MonthLocator(interval=3))\n",
    "\n",
    "plt.setp(axes[-1].xaxis.get_majorticklabels(), rotation=45)\n",
    "plt.tight_layout()\n",
    "plt.savefig(f'{DATA}/../snapshots/cv_abstain_inertia.png', dpi=150, bbox_inches='tight')\n",
    "plt.show()\n",
    "\n",
    "# Key metrics\n",
    "print(f'\\nAbstain as Inertial Force:')\n",
    "print(f'  Peak Abstain stake: {gtl[\"abstain_stake\"].max():,.0f} TEC')\n",
    "print(f'  Peak Active stake: {gtl[\"active_stake\"].max():,.0f} TEC')\n",
    "print(f'  Avg Abstain share: {gtl[\"abstain_share\"].mean():.1%}')\n",
    "print(f'  Max Abstain share: {gtl[\"abstain_share\"].max():.1%}')\n",
    "print(f'  Min Abstain share: {gtl[\"abstain_share\"].min():.1%}')\n",
    "print(f'  Periods where Abstain > Active: {(gtl[\"abstain_share\"] > 0.5).sum()}/{len(gtl)} events ({(gtl[\"abstain_share\"] > 0.5).mean():.0%})')"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "## 9. Animated Conviction Dynamics\n",
    "\n",
    "Animated GIFs showing conviction building up over time for each proposal, and the Abstain proposal's inertial effect on governance throughput."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": 11,
   "metadata": {
    "execution": {
     "iopub.execute_input": "2026-04-03T21:39:50.432918Z",
     "iopub.status.busy": "2026-04-03T21:39:50.432566Z",
     "iopub.status.idle": "2026-04-03T21:40:33.113548Z",
     "shell.execute_reply": "2026-04-03T21:40:33.111553Z"
    }
   },
   "outputs": [
    {
     "name": "stdout",
     "output_type": "stream",
     "text": [
      "Generating conviction dynamics GIF (120 frames)...\n"
     ]
    },
    {
     "name": "stdout",
     "output_type": "stream",
     "text": [
      "Saved: ../data/onchain/../snapshots/cv_conviction_animated.gif\n"
     ]
    },
    {
     "data": {
      "image/gif": "[encoded data, 2709508 characters]",
      "text/plain": [
       "<IPython.core.display.Image object>"
      ]
     },
     "metadata": {},
     "output_type": "display_data"
    }
   ],
   "source": [
    "# GIF 1: Conviction curves building over time with Abstain as backdrop\n",
    "from matplotlib.animation import FuncAnimation, PillowWriter\n",
    "import matplotlib.patches as mpatches\n",
    "\n",
    "# Create date grid for animation frames (weekly steps)\n",
    "date_min = prop_timeline['date'].min()\n",
    "date_max = prop_timeline['date'].max()\n",
    "n_frames = 120  # ~2 years at weekly resolution\n",
    "frame_dates = pd.date_range(date_min, date_max, periods=n_frames)\n",
    "\n",
    "# Pre-compute: for each proposal, get sorted conviction timeline\n",
    "prop_data = {}\n",
    "for pid in prop_timeline['proposal_id'].unique():\n",
    "    subset = prop_timeline[prop_timeline['proposal_id'] == pid].sort_values('date')\n",
    "    prop_data[pid] = subset[['date', 'conviction', 'total_stake']].copy()\n",
    "\n",
    "# Get top proposals by peak conviction (for labeling)\n",
    "peak_conv = {pid: df['conviction'].max() for pid, df in prop_data.items()}\n",
    "top_props = sorted(peak_conv, key=peak_conv.get, reverse=True)[:12]\n",
    "\n",
    "# Pre-compute execution dates for each proposal\n",
    "exec_dates = {}\n",
    "for pid in top_props:\n",
    "    meta = prop_meta.get(pid, {})\n",
    "    eb = safe_exec_block(meta)\n",
    "    if eb is not None:\n",
    "        exec_dates[pid] = block_to_date(eb)\n",
    "\n",
    "# Pre-compute global timeline for abstain backdrop\n",
    "gtl_sorted = gtl.sort_values('date')\n",
    "\n",
    "# Color map: give top proposals distinct colors, rest are gray\n",
    "prop_colors = {}\n",
    "cmap = plt.get_cmap('tab20', len(top_props))\n",
    "for i, pid in enumerate(top_props):\n",
    "    if pid == 1:\n",
    "        prop_colors[pid] = '#f39c12'  # gold for abstain\n",
    "    else:\n",
    "        meta = prop_meta.get(pid, {})\n",
    "        status = meta.get('status', 'unknown')\n",
    "        prop_colors[pid] = status_colors.get(status, cmap(i))\n",
    "\n",
    "fig, (ax_main, ax_abstain) = plt.subplots(2, 1, figsize=(16, 10),\n",
    "                                           gridspec_kw={'height_ratios': [3, 1]})\n",
    "\n",
    "def animate(frame_idx):\n",
    "    ax_main.clear()\n",
    "    ax_abstain.clear()\n",
    "    \n",
    "    current_date = frame_dates[frame_idx]\n",
    "    \n",
    "    ax_main.set_xlim(date_min, date_max)\n",
    "    ax_main.set_ylabel('Conviction Score', fontsize=11)\n",
    "    ax_main.set_title(f'Conviction Voting Dynamics — {current_date.strftime(\"%b %d, %Y\")}', fontsize=14)\n",
    "    ax_main.xaxis.set_major_formatter(mdates.DateFormatter('%b %Y'))\n",
    "    ax_main.xaxis.set_major_locator(mdates.MonthLocator(interval=3))\n",
    "    ax_main.grid(True, alpha=0.3)\n",
    "    \n",
    "    # Draw conviction curves up to current date\n",
    "    max_y = 1000  # minimum y scale\n",
    "    for pid in top_props:\n",
    "        if pid == 1:\n",
    "            continue  # abstain shown below\n",
    "        df = prop_data[pid]\n",
    "        visible = df[df['date'] <= current_date]\n",
    "        if len(visible) == 0:\n",
    "            continue\n",
    "        color = prop_colors.get(pid, 'gray')\n",
    "        ax_main.plot(visible['date'], visible['conviction'], color=color,\n",
    "                    linewidth=1.8, alpha=0.85)\n",
    "        # Label at tip\n",
    "        ax_main.annotate(f'#{pid}', xy=(visible['date'].iloc[-1], visible['conviction'].iloc[-1]),\n",
    "                        fontsize=7, color=color, fontweight='bold', alpha=0.9)\n",
    "        max_y = max(max_y, visible['conviction'].max())\n",
    "        \n",
    "        # Mark execution\n",
    "        if pid in exec_dates and exec_dates[pid] <= current_date:\n",
    "            exec_vis = visible[visible['date'] <= exec_dates[pid]]\n",
    "            if len(exec_vis) > 0:\n",
    "                ax_main.scatter([exec_dates[pid]], [exec_vis['conviction'].iloc[-1]],\n",
    "                              color=color, s=60, zorder=5, marker='*',\n",
    "                              edgecolors='black', linewidth=0.5)\n",
    "    \n",
    "    ax_main.set_ylim(0, max_y * 1.1)\n",
    "    \n",
    "    # Vertical \"now\" line\n",
    "    ax_main.axvline(x=current_date, color='black', alpha=0.3, linewidth=1, linestyle='--')\n",
    "    \n",
    "    # Legend\n",
    "    legend_els = [\n",
    "        mpatches.Patch(color='#2ecc71', alpha=0.7, label='Executed'),\n",
    "        mpatches.Patch(color='#e74c3c', alpha=0.7, label='Cancelled'),\n",
    "        Line2D([0], [0], marker='*', color='gray', lw=0, markersize=10, label='Funded'),\n",
    "    ]\n",
    "    ax_main.legend(handles=legend_els, loc='upper left', fontsize=9)\n",
    "    \n",
    "    # Abstain panel\n",
    "    gtl_visible = gtl_sorted[gtl_sorted['date'] <= current_date]\n",
    "    if len(gtl_visible) > 0:\n",
    "        ax_abstain.fill_between(gtl_visible['date'], 0, gtl_visible['abstain_stake'],\n",
    "                               alpha=0.5, color='#f39c12', label='Abstain')\n",
    "        ax_abstain.fill_between(gtl_visible['date'], gtl_visible['abstain_stake'],\n",
    "                               gtl_visible['abstain_stake'] + gtl_visible['active_stake'],\n",
    "                               alpha=0.4, color='#2ecc71', label='Active')\n",
    "        ax_abstain.set_ylim(0, max(gtl_sorted['total_staked'].max() * 1.05, 1))\n",
    "    \n",
    "    ax_abstain.axvline(x=current_date, color='black', alpha=0.3, linewidth=1, linestyle='--')\n",
    "    ax_abstain.set_xlim(date_min, date_max)\n",
    "    ax_abstain.set_ylabel('Tokens Staked', fontsize=11)\n",
    "    ax_abstain.set_xlabel('Date', fontsize=11)\n",
    "    ax_abstain.xaxis.set_major_formatter(mdates.DateFormatter('%b %Y'))\n",
    "    ax_abstain.xaxis.set_major_locator(mdates.MonthLocator(interval=3))\n",
    "    ax_abstain.legend(loc='upper left', fontsize=9)\n",
    "    ax_abstain.grid(True, alpha=0.3)\n",
    "    \n",
    "    plt.tight_layout()\n",
    "    return []\n",
    "\n",
    "print('Generating conviction dynamics GIF (120 frames)...')\n",
    "anim = FuncAnimation(fig, animate, frames=n_frames, interval=100, blit=False)\n",
    "gif_path = f'{DATA}/../snapshots/cv_conviction_animated.gif'\n",
    "anim.save(gif_path, writer=PillowWriter(fps=8))\n",
    "plt.close(fig)\n",
    "print(f'Saved: {gif_path}')\n",
    "\n",
    "# Display the GIF inline\n",
    "from IPython.display import Image, display\n",
    "display(Image(filename=gif_path))"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": 12,
   "metadata": {
    "execution": {
     "iopub.execute_input": "2026-04-03T21:40:33.143407Z",
     "iopub.status.busy": "2026-04-03T21:40:33.142284Z",
     "iopub.status.idle": "2026-04-03T21:41:39.687226Z",
     "shell.execute_reply": "2026-04-03T21:41:39.685695Z"
    }
   },
   "outputs": [
    {
     "name": "stdout",
     "output_type": "stream",
     "text": [
      "Generating Abstain inertia GIF (120 frames)...\n"
     ]
    },
    {
     "name": "stdout",
     "output_type": "stream",
     "text": [
      "Saved: ../data/onchain/../snapshots/cv_abstain_animated.gif\n"
     ]
    },
    {
     "data": {
      "image/gif": "[encoded data, 3432972 characters]",
      "text/plain": [
       "<IPython.core.display.Image object>"
      ]
     },
     "metadata": {},
     "output_type": "display_data"
    }
   ],
   "source": [
    "# GIF 2: Abstain inertial force — staking balance + brake strength over time\n",
    "# with funded proposals appearing as they pass\n",
    "\n",
    "fig2, (ax_stake, ax_brake, ax_funded) = plt.subplots(3, 1, figsize=(16, 12),\n",
    "                                                       gridspec_kw={'height_ratios': [2, 1, 1]})\n",
    "\n",
    "# Pre-compute funded proposal timeline\n",
    "funded_events = []\n",
    "for _, row in funded_timeline.iterrows():\n",
    "    eb = safe_exec_block({'executed_block': row.get('executed_block', '')})\n",
    "    if eb is not None:\n",
    "        funded_events.append({\n",
    "            'date': block_to_date(eb),\n",
    "            'name': str(row.get('link', '')).encode('ascii', 'ignore').decode()[:35],\n",
    "            'amount': row['amount_requested'],\n",
    "            'id': row['id'],\n",
    "        })\n",
    "funded_events = sorted(funded_events, key=lambda x: x['date'])\n",
    "cum_funded = np.cumsum([e['amount'] for e in funded_events])\n",
    "\n",
    "def animate2(frame_idx):\n",
    "    ax_stake.clear()\n",
    "    ax_brake.clear()\n",
    "    ax_funded.clear()\n",
    "    \n",
    "    current_date = frame_dates[frame_idx]\n",
    "    \n",
    "    # Panel 1: Stacked area — Abstain vs Active\n",
    "    gtl_vis = gtl_sorted[gtl_sorted['date'] <= current_date]\n",
    "    if len(gtl_vis) > 0:\n",
    "        ax_stake.fill_between(gtl_vis['date'], 0, gtl_vis['abstain_stake'],\n",
    "                             alpha=0.6, color='#f39c12', label='Abstain (Inertia)')\n",
    "        ax_stake.fill_between(gtl_vis['date'], gtl_vis['abstain_stake'],\n",
    "                             gtl_vis['abstain_stake'] + gtl_vis['active_stake'],\n",
    "                             alpha=0.5, color='#2ecc71', label='Active Proposals')\n",
    "    \n",
    "    ax_stake.set_xlim(date_min, date_max)\n",
    "    ax_stake.set_ylim(0, gtl_sorted['total_staked'].max() * 1.1)\n",
    "    ax_stake.set_ylabel('Tokens Staked', fontsize=11)\n",
    "    ax_stake.set_title(f'Conviction Voting: Abstain as Inertial Force — {current_date.strftime(\"%b %Y\")}',\n",
    "                       fontsize=14)\n",
    "    ax_stake.legend(loc='upper right', fontsize=10)\n",
    "    ax_stake.axvline(x=current_date, color='black', alpha=0.2, linewidth=1, linestyle='--')\n",
    "    ax_stake.xaxis.set_major_formatter(mdates.DateFormatter('%b %Y'))\n",
    "    ax_stake.xaxis.set_major_locator(mdates.MonthLocator(interval=3))\n",
    "    ax_stake.grid(True, alpha=0.3)\n",
    "    \n",
    "    # Add current stats as text box\n",
    "    if len(gtl_vis) > 0:\n",
    "        last = gtl_vis.iloc[-1]\n",
    "        stats_text = (f'Abstain: {last[\"abstain_stake\"]:,.0f} TEC ({last[\"abstain_share\"]:.0%})\\n'\n",
    "                     f'Active: {last[\"active_stake\"]:,.0f} TEC ({1-last[\"abstain_share\"]:.0%})')\n",
    "        ax_stake.text(0.02, 0.95, stats_text, transform=ax_stake.transAxes,\n",
    "                     fontsize=10, verticalalignment='top',\n",
    "                     bbox=dict(boxstyle='round', facecolor='white', alpha=0.8))\n",
    "    \n",
    "    # Panel 2: Brake strength (abstain share)\n",
    "    if len(gtl_vis) > 0:\n",
    "        ax_brake.fill_between(gtl_vis['date'], gtl_vis['abstain_share'],\n",
    "                             alpha=0.4, color='#f39c12')\n",
    "        ax_brake.plot(gtl_vis['date'], gtl_vis['abstain_share'], color='#e67e22', linewidth=2)\n",
    "    ax_brake.axhline(y=0.5, color='red', linestyle='--', alpha=0.4, linewidth=1)\n",
    "    ax_brake.set_xlim(date_min, date_max)\n",
    "    ax_brake.set_ylim(0, 1)\n",
    "    ax_brake.set_ylabel('Brake Strength', fontsize=11)\n",
    "    ax_brake.axvline(x=current_date, color='black', alpha=0.2, linewidth=1, linestyle='--')\n",
    "    ax_brake.xaxis.set_major_formatter(mdates.DateFormatter('%b %Y'))\n",
    "    ax_brake.xaxis.set_major_locator(mdates.MonthLocator(interval=3))\n",
    "    ax_brake.grid(True, alpha=0.3)\n",
    "    ax_brake.text(0.98, 0.95, 'Higher = harder to pass proposals',\n",
    "                 transform=ax_brake.transAxes, fontsize=9, ha='right', va='top',\n",
    "                 style='italic', alpha=0.6)\n",
    "    \n",
    "    # Panel 3: Cumulative funding — proposals funded so far\n",
    "    visible_funded = [(e, c) for e, c in zip(funded_events, cum_funded) if e['date'] <= current_date]\n",
    "    if visible_funded:\n",
    "        dates_f = [e['date'] for e, _ in visible_funded]\n",
    "        cum_f = [c for _, c in visible_funded]\n",
    "        ax_funded.step(dates_f, cum_f, where='post', color='steelblue', linewidth=2)\n",
    "        ax_funded.fill_between(dates_f, cum_f, step='post', alpha=0.2, color='steelblue')\n",
    "        # Label latest funded\n",
    "        last_e, last_c = visible_funded[-1]\n",
    "        ax_funded.annotate(f'#{last_e[\"id\"]}: {last_e[\"name\"]}',\n",
    "                          xy=(last_e['date'], last_c),\n",
    "                          xytext=(10, 5), textcoords='offset points',\n",
    "                          fontsize=7, color='steelblue', fontweight='bold')\n",
    "    \n",
    "    ax_funded.set_xlim(date_min, date_max)\n",
    "    ax_funded.set_ylim(0, max(cum_funded) * 1.1 if len(cum_funded) > 0 else 1)\n",
    "    ax_funded.set_ylabel('Cumulative TEC Funded', fontsize=11)\n",
    "    ax_funded.set_xlabel('Date', fontsize=11)\n",
    "    ax_funded.axvline(x=current_date, color='black', alpha=0.2, linewidth=1, linestyle='--')\n",
    "    ax_funded.xaxis.set_major_formatter(mdates.DateFormatter('%b %Y'))\n",
    "    ax_funded.xaxis.set_major_locator(mdates.MonthLocator(interval=3))\n",
    "    ax_funded.grid(True, alpha=0.3)\n",
    "    \n",
    "    plt.tight_layout()\n",
    "    return []\n",
    "\n",
    "print('Generating Abstain inertia GIF (120 frames)...')\n",
    "anim2 = FuncAnimation(fig2, animate2, frames=n_frames, interval=100, blit=False)\n",
    "gif_path2 = f'{DATA}/../snapshots/cv_abstain_animated.gif'\n",
    "anim2.save(gif_path2, writer=PillowWriter(fps=8))\n",
    "plt.close(fig2)\n",
    "print(f'Saved: {gif_path2}')\n",
    "\n",
    "display(Image(filename=gif_path2))"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "## 10. Proposal Spending by Category\n",
    "\n",
    "Bucketing the 36 funded conviction voting proposals into rough spending categories to understand where the common pool was allocated."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": 13,
   "metadata": {
    "execution": {
     "iopub.execute_input": "2026-04-03T21:41:39.707939Z",
     "iopub.status.busy": "2026-04-03T21:41:39.707565Z",
     "iopub.status.idle": "2026-04-03T21:41:40.402578Z",
     "shell.execute_reply": "2026-04-03T21:41:40.401474Z"
    }
   },
   "outputs": [
    {
     "data": {
      "image/png": "[encoded data, 185280 characters]",
      "text/plain": [
       "<Figure size 1800x800 with 2 Axes>"
      ]
     },
     "metadata": {},
     "output_type": "display_data"
    },
    {
     "name": "stdout",
     "output_type": "stream",
     "text": [
      "\n",
      "Spending Category Breakdown:\n",
      "Category                            TEC    Pct  #Props   Avg Size\n",
      "-----------------------------------------------------------------\n",
      "Education & Research            259,515  38.1%       8     32,439\n",
      "Working Groups                  143,233  21.1%       5     28,647\n",
      "Governance Infrastructure       110,801  16.3%       4     27,700\n",
      "Communications                   64,410   9.5%       6     10,735\n",
      "Rewards & Recognition            48,000   7.1%       3     16,000\n",
      "Transparency & Oversight         39,006   5.7%       5      7,801\n",
      "Retroactive Funding               9,830   1.4%       4      2,458\n",
      "Other                             5,500   0.8%       1      5,500\n"
     ]
    }
   ],
   "source": [
    "import json\n",
    "\n",
    "# Load spending categories\n",
    "with open(f'{DATA}/cv_proposal_categories.json') as f:\n",
    "    categories = json.load(f)\n",
    "\n",
    "cat_df = pd.DataFrame(categories)\n",
    "\n",
    "# Aggregate by category\n",
    "cat_summary = cat_df.groupby('category').agg(\n",
    "    total_tec=('amount_requested', 'sum'),\n",
    "    num_proposals=('id', 'count'),\n",
    "    avg_size=('amount_requested', 'mean'),\n",
    ").sort_values('total_tec', ascending=False)\n",
    "\n",
    "cat_summary['pct'] = cat_summary['total_tec'] / cat_summary['total_tec'].sum() * 100\n",
    "\n",
    "fig, axes = plt.subplots(1, 2, figsize=(18, 8))\n",
    "\n",
    "# Pie chart\n",
    "ax = axes[0]\n",
    "colors = ['#2ecc71', '#3498db', '#e74c3c', '#f39c12', '#9b59b6', '#1abc9c', '#e67e22', '#95a5a6']\n",
    "wedges, texts, autotexts = ax.pie(\n",
    "    cat_summary['total_tec'], labels=cat_summary.index,\n",
    "    autopct='%1.1f%%', colors=colors[:len(cat_summary)],\n",
    "    pctdistance=0.85, startangle=140\n",
    ")\n",
    "for t in texts:\n",
    "    t.set_fontsize(9)\n",
    "for t in autotexts:\n",
    "    t.set_fontsize(8)\n",
    "ax.set_title(f'Conviction Voting Spending by Category\\n({cat_summary[\"total_tec\"].sum():,.0f} TEC total)', fontsize=13)\n",
    "\n",
    "# Horizontal bar chart with proposal count annotations\n",
    "ax = axes[1]\n",
    "y_pos = range(len(cat_summary))\n",
    "bars = ax.barh(y_pos, cat_summary['total_tec'], color=colors[:len(cat_summary)], alpha=0.8)\n",
    "ax.set_yticks(y_pos)\n",
    "ax.set_yticklabels(cat_summary.index, fontsize=10)\n",
    "ax.set_xlabel('Total TEC Funded', fontsize=11)\n",
    "ax.set_title('Spending by Category (with proposal counts)', fontsize=13)\n",
    "\n",
    "# Annotate with count and average\n",
    "for i, (idx_name, row) in enumerate(cat_summary.iterrows()):\n",
    "    ax.text(row['total_tec'] + 2000, i,\n",
    "            f'{row[\"num_proposals\"]:.0f} proposals, avg {row[\"avg_size\"]:,.0f}',\n",
    "            va='center', fontsize=9, color='gray')\n",
    "\n",
    "ax.set_xlim(0, cat_summary['total_tec'].max() * 1.5)\n",
    "\n",
    "plt.tight_layout()\n",
    "plt.savefig(f'{DATA}/../snapshots/cv_spending_categories.png', dpi=150, bbox_inches='tight')\n",
    "plt.show()\n",
    "\n",
    "# Summary table\n",
    "print('\\nSpending Category Breakdown:')\n",
    "print(f'{\"Category\":<28} {\"TEC\":>10} {\"Pct\":>6} {\"#Props\":>7} {\"Avg Size\":>10}')\n",
    "print('-' * 65)\n",
    "for cat, row in cat_summary.iterrows():\n",
    "    print(f'{cat:<28} {row[\"total_tec\"]:>10,.0f} {row[\"pct\"]:>5.1f}% {row[\"num_proposals\"]:>7.0f} {row[\"avg_size\"]:>10,.0f}')\n",
    "\n",
    "# Cross-reference: which beneficiaries span multiple categories?\n",
    "multi_cat = cat_df.groupby('beneficiary')['category'].nunique()\n",
    "multi = multi_cat[multi_cat > 1]\n",
    "if len(multi) > 0:\n",
    "    print(f'\\n{len(multi)} beneficiaries received funding in multiple categories:')\n",
    "    for addr in multi.index:\n",
    "        cats = cat_df[cat_df['beneficiary'] == addr][['link', 'category', 'amount_requested']]\n",
    "        total = cats['amount_requested'].sum()\n",
    "        print(f'  {addr[:16]}... ({total:,.0f} TEC total)')\n",
    "        for _, r in cats.iterrows():\n",
    "            print(f'    - {r[\"link\"][:45]} [{r[\"category\"]}] {r[\"amount_requested\"]:,.0f}')"
   ]
  }
 ],
 "metadata": {
  "kernelspec": {
   "display_name": "TEC Analysis",
   "language": "python",
   "name": "tec-analysis"
  },
  "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.12.3"
  }
 },
 "nbformat": 4,
 "nbformat_minor": 4
}
