"""Figures for Models B and C and the LLM laboratory (Model A figures are produced by scripts/10_model_a.py). Reads results/model_b/*.json, results/model_c/{summary,stress_summary}.csv, results/llm_lab/cells.csv. Writes results/figures/figB_*.pdf, figC_*.pdf, figL_*.pdf and the architecture/timing diagrams. """ from __future__ import annotations import json import sys from pathlib import Path import numpy as np import pandas as pd sys.path.insert(0, str(Path(__file__).resolve().parents[1] / 'src')) import matplotlib.pyplot as plt # noqa: E402 from matplotlib.patches import FancyArrowPatch, FancyBboxPatch # noqa: E402 from age.analysis import plotstyle as ps # noqa: E402 ROOT = Path(__file__).resolve().parents[1] RB, RC, RL = ROOT / 'results' / 'model_b', ROOT / 'results' / 'model_c', ROOT / 'results' / 'llm_lab' FIG = ROOT / 'results' / 'figures' FIG.mkdir(parents=True, exist_ok=True) ps.use() # ------------------------------------------------------------------ architecture and timing diagrams def box(ax, xy, w, h, text, fc='#f0efec', ec=ps.AXIS): ax.add_patch(FancyBboxPatch(xy, w, h, boxstyle='round,pad=0.02,rounding_size=0.02', fc=fc, ec=ec, lw=0.8)) ax.text(xy[0] + w / 2, xy[1] + h / 2, text, ha='center', va='center', fontsize=7.5) def arrow(ax, a, b, text='', color=ps.TEXT2): ax.add_patch(FancyArrowPatch(a, b, arrowstyle='-|>', mutation_scale=9, color=color, lw=0.8)) if text: ax.text((a[0] + b[0]) / 2, (a[1] + b[1]) / 2 + 0.02, text, fontsize=6.5, ha='center', color=color) fig, ax = plt.subplots(figsize=(8.0, 5.4)) ax.set_xlim(0, 1); ax.set_ylim(0, 1.02); ax.axis('off') box(ax, (0.02, 0.66), 0.28, 0.24, 'Households $i\\in[0,1]$\nown capital $a_i$, compute shares $\\omega_i$\nchoose $c,\\ l,\\ a\',\\ d$', fc='#cde2fb') box(ax, (0.36, 0.66), 0.28, 0.24, 'Foundation models $m=1..M$\npolicies $\\pi_m$ (no balance sheet)\nsignal $s_m=\\theta+\\eta_m$', fc='#fde3d7') box(ax, (0.70, 0.66), 0.28, 0.24, 'Compute owners\nstock $K_Q$, rental $p_Q$\n(broad or concentrated)', fc='#d4f2e5') box(ax, (0.02, 0.10), 0.28, 0.24, 'Firm (CRS)\ntasks $x\\leq\\iota$: compute\n$x>\\iota$: labour', fc='#fdf0cc') box(ax, (0.36, 0.10), 0.28, 0.24, 'Compute market\n$Q^P+\\bar q D=K_Q$', fc='#f0efec') box(ax, (0.70, 0.10), 0.28, 0.24, 'Energy sector\n$p_E$ per unit', fc='#f0efec') def arr(a, b, text, dx=0, dy=0.03, color=ps.TEXT2): ax.add_patch(FancyArrowPatch(a, b, arrowstyle='-|>', mutation_scale=9, color=color, lw=0.8)) ax.text((a[0] + b[0]) / 2 + dx, (a[1] + b[1]) / 2 + dy, text, fontsize=6.5, ha='center', color=color) arr((0.30, 0.80), (0.36, 0.80), 'delegation $d_i$, fee $\\phi$', dy=0.025) arr((0.64, 0.80), (0.70, 0.80), 'inference $\\bar q D$', dy=0.025) arr((0.12, 0.66), (0.12, 0.34), 'labour $l$,\ncapital $a\'$', dx=-0.07, dy=0) arr((0.20, 0.34), (0.20, 0.66), 'wage $w$,\nrental $R^K$', dx=0.07, dy=0) arr((0.30, 0.22), (0.36, 0.22), 'production\ncompute $Q^P$', dy=0.04) arr((0.84, 0.66), (0.60, 0.34), '$K_Q$ supply', dx=0.07, dy=0.0) ax.add_patch(FancyArrowPatch((0.84, 0.90), (0.16, 0.90), arrowstyle='-|>', mutation_scale=9, color=ps.CAT[1], lw=0.8, connectionstyle='arc3,rad=0.25')) ax.text(0.50, 0.965, 'rents $p_Q K_Q$ distributed by ownership shares $\\omega_i$', fontsize=6.5, ha='center', color=ps.CAT[1]) arr((0.64, 0.22), (0.70, 0.22), 'energy $\\bar e D$', dy=0.025) ax.set_title('Architecture and ownership flows (Model A): every arrow is a priced flow with a payer and a payee', fontsize=8.5) ps.save(fig, FIG / 'fig_architecture.pdf') fig, ax = plt.subplots(figsize=(7.4, 1.9)) ax.set_xlim(0, 1); ax.set_ylim(0, 1); ax.axis('off') steps = ['$z_t$ realised;\nspot markets clear\n$(w,R^K,p_Q)$', 'delegation $d_{it}$\nchosen', 'signals $s_{it}$\nor $s_{mt}$ arrive', 'savings / allocation\nby human or $\\pi_m$;\nfees paid', '$K_{t+1}=\\int a_{i,t+1}$;\nmodel noise enters', '$\\theta_t=\\varepsilon_{t+1}$\nrevealed at $t+1$'] for k, s in enumerate(steps): box(ax, (0.01 + k * 0.165, 0.25), 0.15, 0.55, s, fc='#f0efec' if k % 2 else '#cde2fb') if k < len(steps) - 1: arrow(ax, (0.16 + k * 0.165, 0.52), (0.175 + k * 0.165, 0.52)) ax.set_title('Within-period timing (Model A)', fontsize=8.5) ps.save(fig, FIG / 'fig_timing.pdf') # ------------------------------------------------------------------ Model B scen = {} for f in sorted(RB.glob('*.json')): scen[f.stem] = json.load(open(f)) if scen: order = [s for s in ['no_ai', 'production_only', 'delegation_only', 'full_broad', 'full_concentrated', 'scarce_broad', 'scarce_concentrated', 'full_broad_tight_credit', 'full_broad_cheap_fee', 'full_broad_no_mistakes'] if s in scen] labels = {'no_ai': 'no AI', 'production_only': 'production only', 'delegation_only': 'delegation only', 'full_broad': 'full, broad', 'full_concentrated': 'full, concentrated', 'scarce_broad': 'scarce, broad', 'scarce_concentrated': 'scarce, concentrated', 'full_broad_tight_credit': 'full, no borrowing', 'full_broad_cheap_fee': 'full, cheap fee', 'full_broad_no_mistakes': 'full, no mistakes'} # Lorenz curves fig, axs = plt.subplots(1, 2, figsize=(7.4, 3.3)) for k, s in enumerate(['no_ai', 'delegation_only', 'full_broad', 'full_concentrated', 'scarce_concentrated']): if s in scen: axs[0].plot(scen[s]['lorenz_x'], scen[s]['lorenz_y'], color=ps.CAT[k], label=f"{labels[s]} (Gini {scen[s]['gini']:.2f})") axs[0].plot([0, 1], [0, 1], color=ps.AXIS, lw=0.6) axs[0].set(xlabel='population share', ylabel='wealth share', title='(a) Lorenz curves') axs[0].legend(fontsize=7) for k, s in enumerate(['delegation_only', 'full_broad', 'full_concentrated', 'scarce_broad']): if s in scen: axs[1].plot(np.arange(1, 11), scen[s]['deleg_by_decile'], marker='o', color=ps.CAT[k], label=labels[s]) axs[1].set(xlabel='wealth decile', ylabel='delegation share within decile', title='(b) Who delegates (H1)') axs[1].legend(fontsize=7) ps.save(fig, FIG / 'figB_lorenz_delegation.pdf') # shares and top shares fig, axs = plt.subplots(1, 2, figsize=(7.4, 3.3)) y = np.arange(len(order)) L = [scen[s]['shares']['labor'] for s in order] Kc = [scen[s]['shares']['capital'] for s in order] Q = [scen[s]['shares']['compute'] for s in order] axs[0].barh(y, L, color=ps.CAT[0], label='labour', height=0.6) axs[0].barh(y, Kc, left=L, color=ps.CAT[1], label='capital', height=0.6) axs[0].barh(y, Q, left=np.array(L) + np.array(Kc), color=ps.CAT[2], label='compute', height=0.6) axs[0].set_yticks(y); axs[0].set_yticklabels([labels[s] for s in order], fontsize=7) axs[0].set(xlabel='income share', title='(a) Factor income shares'); axs[0].legend(fontsize=7, loc='lower right'); axs[0].invert_yaxis() axs[1].plot([scen[s]['top10'] for s in order], y, 'o', color=ps.CAT[0], label='top 10%') axs[1].plot([scen[s]['top1'] for s in order], y, 's', color=ps.CAT[1], label='top 1%') axs[1].plot([scen[s]['gini'] for s in order], y, '^', color=ps.CAT[2], label='Gini') axs[1].set_yticks(y); axs[1].set_yticklabels([]); axs[1].invert_yaxis() axs[1].set(xlabel='share / index', title='(b) Wealth concentration (H3)'); axs[1].legend(fontsize=7) ps.save(fig, FIG / 'figB_shares_concentration.pdf') # welfare by percentile relative to no_ai: consumption-equivalent from value functions (CRRA) if 'no_ai' in scen: gamma = scen['no_ai']['params'].get('gamma', 2.0) fig, ax = plt.subplots(figsize=(5.4, 3.2)) pcts = [10, 25, 50, 75, 90, 99] for k, s in enumerate(['delegation_only', 'full_broad', 'full_concentrated', 'scarce_concentrated']): if s not in scen: continue ce = [] for q in pcts: v1, v0 = scen[s][f'V_p{q}'], scen['no_ai'][f'V_p{q}'] # V = sum beta^t (c^(1-g)-1)/(1-g): CE = ((v1*(1-g)*(1-beta)+1)/(v0*(1-g)*(1-beta)+1))^(1/(1-g)) - 1 b = scen[s]['params'].get('beta', 0.96) ce.append(100 * (((v1 * (1 - gamma) * (1 - b) + 1) / (v0 * (1 - gamma) * (1 - b) + 1)) ** (1 / (1 - gamma)) - 1)) ax.plot(pcts, ce, marker='o', color=ps.CAT[k], label=labels[s]) ax.axhline(0, color=ps.AXIS, lw=0.6) ax.set(xlabel='wealth percentile', ylabel='consumption-equivalent gain vs no AI (%)', title='Welfare by wealth percentile (Model B)') ax.legend(fontsize=7) ps.save(fig, FIG / 'figB_welfare_percentile.pdf') # ------------------------------------------------------------------ Model C if (RC / 'summary.csv').exists(): d = pd.read_csv(RC / 'summary.csv') fig, axs = plt.subplots(1, 3, figsize=(9.6, 3.1)) for k, design in enumerate(['walrasian', 'cda', 'bilateral']): ax = axs[k] for j, M in enumerate([1, 2, 4, 8]): g = d[(d.design == design) & (d.leverage_cap == 0) & ((d.M == M) | (d.D == 0))].sort_values('D') ax.plot(g.D, g.of_corr, color=ps.CAT[j], label=f'M = {M}') ax.fill_between(g.D, g.of_corr_lo, g.of_corr_hi, color=ps.CAT[j], alpha=0.15, lw=0) ax.set(xlabel='delegation share $D$', title=f'{design}', ylim=(0, 1)) if k == 0: ax.set_ylabel('order-flow correlation | public info') ax.legend(fontsize=7) fig.suptitle('Common-model herding (H2): order-flow correlation rises with $D$ and falls with $M$', fontsize=9) ps.save(fig, FIG / 'figC_herding.pdf') fig, axs = plt.subplots(1, 2, figsize=(7.4, 3.2)) for j, M in enumerate([1, 2, 4, 8]): g = d[(d.design == 'cda') & (d.leverage_cap == 0) & ((d.M == M) | (d.D == 0))].sort_values('D') axs[0].plot(g.D, g.vol * 100, color=ps.CAT[j], label=f'M = {M}') axs[0].fill_between(g.D, g.vol_lo * 100, g.vol_hi * 100, color=ps.CAT[j], alpha=0.15, lw=0) axs[1].plot(g.D, g.n_trades, color=ps.CAT[j], label=f'M = {M}') axs[0].set(xlabel='delegation share $D$', ylabel='return volatility (% per period)', title='(a) Volatility (CDA)') axs[1].set(xlabel='delegation share $D$', ylabel='trades per period', title='(b) Trading volume: no-trade with one model') axs[0].legend(fontsize=7) ps.save(fig, FIG / 'figC_volatility_volume.pdf') fig, axs = plt.subplots(1, 3, figsize=(9.6, 3.0)) for k, (key, lab) in enumerate([('of_corr_deleg', 'order-flow corr. among delegators'), ('vol', 'return volatility'), ('defaults', 'defaults per run')]): for j, design in enumerate(['walrasian', 'cda', 'bilateral']): g = d[(d.design == design) & (d.leverage_cap == 0.5) & (d.M == 1) & (d.D > 0)].sort_values('D') axs[k].plot(g.D, g[key], color=ps.CAT[j], label=design) axs[k].fill_between(g.D, g[key + '_lo'], g[key + '_hi'], color=ps.CAT[j], alpha=0.15, lw=0) axs[k].set(xlabel='delegation share $D$', title=lab) axs[0].legend(fontsize=7) fig.suptitle('Leveraged agents (borrowing cap 0.5 × equity, margin 50%), M = 1', fontsize=9) ps.save(fig, FIG / 'figC_designs.pdf') if (RC / 'stress_summary.csv').exists(): s = pd.read_csv(RC / 'stress_summary.csv') s = s[~s.control.astype(bool) & (s.persist == 1)] fig, axs = plt.subplots(1, 2, figsize=(7.6, 3.3)) combos = [(0.5, 0.0, 'unlevered'), (0.5, 0.95, 'levered, margin 50%'), (0.2, 0.95, 'levered, margin 20%')] designs = [('walrasian', 1), ('walrasian', 4), ('cda', 1), ('cda', 4)] w = 0.26 for j, (margin, pre, lab) in enumerate(combos): vals, lo, hi, fs = [], [], [], [] for design, M in designs: g = s[(s.design == design) & (s.M == M) & (s.margin == margin) & (s.preload == pre)] vals.append(100 * g.shock_return.iloc[0]); lo.append(100 * g.shock_return_lo.iloc[0]); hi.append(100 * g.shock_return_hi.iloc[0]) fs.append(g.shock_forced.iloc[0]) x = np.arange(len(designs)) + (j - 1) * w axs[0].bar(x, vals, w, color=ps.CAT[j], label=lab, yerr=[np.array(vals) - np.array(lo), np.array(hi) - np.array(vals)], error_kw=dict(lw=0.6)) axs[1].bar(x, fs, w, color=ps.CAT[j], label=lab) for ax in axs: ax.set_xticks(np.arange(len(designs))); ax.set_xticklabels([f'{d}\nM={M}' for d, M in designs], fontsize=7) axs[0].axhline(0, color=ps.AXIS, lw=0.6) axs[0].set(ylabel='cumulative return over the shock window (%)', title='(a) Price response to a false shared signal') axs[1].set(ylabel='forced sales (shares)', title='(b) Forced sales (H4)') axs[0].legend(fontsize=7) ps.save(fig, FIG / 'figC_stress.pdf') # ------------------------------------------------------------------ LLM lab if (RL / 'cells.csv').exists(): c = pd.read_csv(RL / 'cells.csv') g = c.groupby(['policy', 'D', 'M', 'temperature']).agg(of=('of_corr', 'mean'), of_sd=('of_corr', 'std'), raw=('raw_of_corr', 'mean'), vol=('vol', 'mean'), trades=('n_trades', 'mean'), invalid=('invalid', 'mean'), clipped=('clipped', 'mean')).reset_index() pols = [p for p in ['bayesian', 'surrogate', 'llm_shared', 'llm_diverse', 'llm_shared_varied_prompts', 'llm_shared_memory'] if p in set(g.policy)] names = {'bayesian': 'Bayesian control', 'surrogate': 'surrogate (rule)', 'llm_shared': 'one LLM (Qwen 7B)', 'llm_diverse': 'three LLMs', 'llm_shared_varied_prompts': 'one LLM, varied prompts', 'llm_shared_memory': 'one LLM, memory'} fig, axs = plt.subplots(1, 2, figsize=(7.6, 3.4)) w = 0.25 for j, (D, M) in enumerate([(0.0, 1), (1.0, 1), (1.0, 3)]): vals = [g[(g.policy == p) & (g.D == D) & (g.M == M) & (g.temperature == 0.0)].of.mean() for p in pols] errs = [g[(g.policy == p) & (g.D == D) & (g.M == M) & (g.temperature == 0.0)].of_sd.mean() for p in pols] axs[0].bar(np.arange(len(pols)) + (j - 1) * w, vals, w, yerr=errs, color=ps.CAT[j], label=['private signals', 'shared model signal', '3 model signals'][j], error_kw=dict(lw=0.6)) axs[0].set_xticks(np.arange(len(pols))); axs[0].set_xticklabels([names[p] for p in pols], rotation=25, ha='right', fontsize=7) axs[0].set(ylabel='order-flow correlation | public info', title='(a) Herding by policy and signal structure') axs[0].legend(fontsize=7) for j, (D, M) in enumerate([(0.0, 1), (1.0, 1), (1.0, 3)]): vals = [g[(g.policy == p) & (g.D == D) & (g.M == M) & (g.temperature == 0.0)].trades.mean() for p in pols] axs[1].bar(np.arange(len(pols)) + (j - 1) * w, vals, w, color=ps.CAT[j]) axs[1].set_xticks(np.arange(len(pols))); axs[1].set_xticklabels([names[p] for p in pols], rotation=25, ha='right', fontsize=7) axs[1].set(ylabel='trades per period', title='(b) Trading volume') ps.save(fig, FIG / 'figL_llm_lab.pdf') # inference cost frontier: runtime vs validity per policy fig, ax = plt.subplots(figsize=(5.2, 3.2)) r = c.groupby('policy').agg(rt=('runtime_s', 'mean'), inv=('invalid', 'mean'), cl=('clipped', 'mean')).reset_index() for k, row in r.iterrows(): ax.scatter(row.rt, row.inv, color=ps.CAT[k % 8], s=30) ax.annotate(names.get(row.policy, row.policy), (row.rt, row.inv), xytext=(4, 3), textcoords='offset points', fontsize=7) ax.set(xlabel='wall-clock seconds per run (local 4-bit inference)', ylabel='invalid actions per run', title='Inference cost frontier', xscale='symlog') ps.save(fig, FIG / 'figL_cost_frontier.pdf') print('figures written to', FIG)