assembly-economics/simulations/gadm02/gadm04.py
tegwick 56649466ac ASSEMBLY-WP-0009-T03: sentiment and legitimacy loop in GADM-0.4 (opt-in); legitimacy.py; AdoptionBand § 3; BR-005
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

Assistant: claude-code
Assistant-Model: opus
Assistant-Process: 14913@bnt-lap001
Assistant-Session: add7ef69-b901-4756-9a67-34cf1125d96f
2026-10-13 14:17:21 +02:00

234 lines
11 KiB
Python

"""GADM-0.4 (in development): durable human capability (ASSEMBLY-WP-0006-T01).
Extends gadm03c (cohorts). Each cohort's capability is split into
D_c durable capability: formed mainly in childhood by education,
depreciates slowly, carried along when people age;
C_c current capability: the GADM-0.3 cohort dynamics, with learning
efficiency scaled by durable capability ("learning begets learning").
Effective cohort capability, which the rest of the model sees, is
H_c = 0.5 * D_c + 0.5 * C_c.
GADM-0.3 had no durable component, so working-age capability forgot childhood
within about 8 years (L3 not supported). gadm03c stays unchanged so the
GADM-0.3 results remain reproducible.
"""
from __future__ import annotations
import gadm02 as g2
import gadm03 as g3
import gadm03c as c
MODEL_VERSION = "GADM-0.4"
TIME_UNIT = c.TIME_UNIT
COHORTS = c.COHORTS
CUR = {k: "C" + k for k in COHORTS} # current capability stocks
DUR = {k: "D" + k for k in COHORTS} # durable capability stocks
STOCKS = tuple(k for k in c.STOCKS if not (k.startswith("H") and k[1:] in COHORTS)) \
+ tuple(CUR.values()) + tuple(DUR.values()) + ("Gw", "Gs", "Sig", "Lg")
INIT = {k: v for k, v in c.INIT.items() if k in STOCKS}
for k in COHORTS:
INIT[CUR[k]] = c.INIT["H" + k] # start with D = C = GADM-0.3 H, so H_c is unchanged
INIT[DUR[k]] = c.INIT["H" + k]
# T04: wealth concentration and cohort attention start at their own equilibria
# (all other stocks fixed at INIT); computed by equilibrium_init() and checked
# by a unit test. GADM-0.3 used W = 0.5, Aw = 0.45, As = 0.65.
INIT.update({"W": 0.1826, "Aw": 0.5406, "As": 0.5216})
# T05: cohort agency, started at its equilibrium (equilibrium_init(("Gw", "Gs")))
INIT.update({"Gw": 0.5373, "Gs": 0.5214})
# WP-0009-T03: sentiment and legislative stance start neutral
INIT.update({"Sig": 0.5, "Lg": 0.5})
PARAMS = dict(c.PARAMS)
PARAMS.update({
"hform": 0.15, # durable formation in childhood (scaled by edu)
"hformX": 0.04, # contribution of commons X to durable formation
"dD": 0.01, # durable depreciation, children and working age (per year)
"dDs": 0.02, # seniors
"dDd": 0.04, # very old
"ll": 0.005, # lifelong durable formation for working-age adults
"lbl": 1.0, # strength of "learning begets learning" (0 = off)
# T02 grounding: organism capability depreciates like software / R&D / AI
# hardware (geometric 0.15-0.35 per year), not 0.06. Investment, learning and
# seeding are rescaled by 3.33 so the baseline organism path stays as before
# (K_Omega ~0.46 at t=25, ~0.37 at t=100): evidence changes the timescale.
"dO": 0.20, "aO": 0.50, "ell": 0.83, "oseed": 0.033,
# T05: effective agency per cohort (working age, seniors)
"cohort_G": 1.0, # 0 = aggregate G as in GADM-0.3 (Gw, Gs inert)
"gentry0": 0.30, # agency of young adults entering working age ...
"gentryD": 0.50, # ... plus this times children's durable capability
"g3s": 0.5, # seniors' restartability weight (relative to working age)
"b2s": 1.5, # seniors' exposure to operator lock-in (digital exclusion)
"gcare": 0.02, # care support (relief from participation claims) raising senior agency
# WP-0009: self-enhancement of organism capability (AI accelerating its own
# development); scales organism investment and learning by (1 + selfenh * K_Omega).
# 0 = off (default); used by the aggressive band anchor B4.
"selfenh": 0.0,
# WP-0009-T03: sentiment and legitimacy loop (research/ScenarioBandMethod.md § 3).
# Sigma (Sig) moves toward 0.5 + sig_gain * (perceived net benefit - its initial
# value); it scales organism adoption. Legislative stance Lg follows Sigma with
# lag leg_lag (years) and sets deployment permissiveness, membrane rights m and
# explainability expl. 0 = off (default): Sig and Lg stay inert.
"sent": 0.0,
"sig_rate": 0.2, # 1/years: Sigma adjusts over about 5 years
"sig_gain": 2.0, # sentiment response to a change in perceived benefit
"sig_disp": 0.5, # weight of displacement K_Omega * (1 - m) in perceived benefit
"sig_adopt": 1.0, # adoption elasticity to sentiment
"leg_lag": 5.0, # years from sentiment to legislation
"leg_perm": 1.0, # permissiveness: adoption scale per unit of stance above neutral
"leg_m": 0.5, # membrane rights tightening per unit of stance below neutral
"leg_expl": 0.5, # explainability tightening per unit of stance below neutral
"sig_pb0": None, # reference perceived benefit; None = value at the initial state
})
def equilibrium_init(keys=("W", "Aw", "As"), steps: int = 20000, h: float = 0.05,
params: dict | None = None) -> dict:
"""Relax `keys` to equilibrium with every other stock frozen at INIT."""
s = dict(INIT)
p = {**PARAMS, **(params or {})}
for _ in range(steps):
d, _a = derivatives(s, p)
for k in keys:
s[k] = min(1.0, max(0.0, s[k] + h * d[k]))
return {k: round(s[k], 4) for k in keys}
def calibrate(params: dict | None = None, init: dict | None = None) -> dict:
"""Channel counter coefficients giving channel equilibrium at GADM-0.4's INIT."""
s0 = {**INIT, **(init or {})}
agg = c.aggregates({**s0, **effective(s0)})
macro = {k: v for k, v in s0.items() if k in g3.STOCKS}
macro.update({"H": agg["H"], "A": agg["A"]})
return g3.calibrate({**PARAMS, **(params or {})}, macro)
def effective(s: dict) -> dict:
return {"H" + k: 0.5 * s[DUR[k]] + 0.5 * s[CUR[k]] for k in COHORTS}
def agency(s: dict) -> float:
"""Adult-weighted effective agency."""
n = c.shares(s)
return (n["w"] * s["Gw"] + n["s"] * s["Gs"]) / (n["w"] + n["s"])
def perceived_benefit(s: dict, a: dict, p: dict) -> float:
"""Perceived net benefit (T03): broad claims, agency, attention and absence of
domination, minus displacement by operator-controlled organisms."""
return 0.3 * a["B"] + 0.3 * agency(s) + 0.2 * a["A"] + 0.2 * (1 - g3.total_p(s)) \
- p["sig_disp"] * s["Ko"] * (1 - p["m"])
def legislated(s: dict, p: dict) -> dict:
"""Parameters with legislation levers shifted by the legislative stance Lg."""
if not p["sent"]:
return p
gap = 0.5 - s["Lg"] # > 0: tightening after negative sentiment
clip = lambda v: min(1.0, max(0.0, v)) # noqa: E731
return {**p, "m": clip(p["m"] + p["leg_m"] * gap), "expl": clip(p["expl"] + p["leg_expl"] * gap)}
def derivatives(s: dict, p: dict) -> tuple[dict, dict]:
p = legislated(s, p)
eff = effective(s)
sc = {k: v for k, v in s.items() if k in c.STOCKS}
sc.update(eff)
if p["cohort_G"]:
sc["G"] = agency(s)
# current-capability dynamics: GADM-0.3 cohort form, evaluated on current capability
cur_state = dict(sc)
cur_state.update({"H" + k: s[CUR[k]] for k in COHORTS})
dc, a = c.derivatives(cur_state, p)
# macro feedback must see effective capability: recompute non-capability flows
d_eff, a = c.derivatives(sc, p)
d = {k: v for k, v in d_eff.items() if not (k.startswith("H") and k[1:] in COHORTS)}
if p["selfenh"]:
Ko = s["Ko"]
grow = (p["aO"] * a["Vsel"] * a["dem"] + p["ell"] * Ko * a["dem"] * s["Q"]) * (1 - Ko)
d["Ko"] += p["selfenh"] * Ko * grow
if p["sent"]:
# sentiment and permissiveness scale the organism inflow, not its depreciation
scale = max(0.0, 1 + p["sig_adopt"] * 2 * (s["Sig"] - 0.5)) \
* max(0.0, 1 + p["leg_perm"] * 2 * (s["Lg"] - 0.5))
inflow = d["Ko"] + p["dO"] * s["Ko"]
d["Ko"] = scale * inflow - p["dO"] * s["Ko"]
pb = perceived_benefit(s, a, p)
pb0 = pb if p["sig_pb0"] is None else p["sig_pb0"]
target = min(1.0, max(0.0, 0.5 + p["sig_gain"] * (pb - pb0)))
d["Sig"] = p["sent"] * p["sig_rate"] * (target - s["Sig"])
d["Lg"] = p["sent"] * (s["Sig"] - s["Lg"]) / p["leg_lag"]
else:
d["Sig"] = d["Lg"] = 0.0
n = c.shares(s)
flow = {"ch": c.AGE_RATE["ch"] * s["Nch"], "w": c.AGE_RATE["w"] * s["Nw"], "s": c.AGE_RATE["s"] * s["Ns"]}
S, B, X = s["S"], a["B"], s["X"]
D0 = 0.5
for k in COHORTS:
# learning-begets-learning: scale the cohort's net current-capability change
# by its durable capability relative to the initial reference level
scale = 1 + p["lbl"] * (s[DUR[k]] - D0)
d[CUR[k]] = dc["H" + k] * (scale if dc["H" + k] > 0 else 1.0)
Dch, Dw, Ds, Dd = (s[DUR[k]] for k in COHORTS)
births = p["birth"] * s["Nw"]
d[DUR["ch"]] = p["edu"] * (p["hform"] * (0.5 * S + 0.5 * B) + p["hformX"] * X) * (1 - Dch) - p["dD"] * Dch \
- births / s["Nch"] * Dch # newborns enter with no durable capability
d[DUR["w"]] = p["ll"] * (1 - Dw) - p["dD"] * Dw + flow["ch"] / s["Nw"] * (Dch - Dw)
d[DUR["s"]] = -p["dDs"] * Ds + flow["w"] / s["Ns"] * (Dw - Ds)
d[DUR["d"]] = -p["dDd"] * Dd + flow["s"] / s["Nd"] * (Ds - Dd)
# T05: cohort agency (GADM-0.2 form with age-specific modifiers)
if p["cohort_G"]:
P, F, Ko, m = g3.total_p(s), s["F"], s["Ko"], p["m"]
Gw, Gs = s["Gw"], s["Gs"]
common = p["g1"] * B + p["g2"] * X * (1 - P) + p["g4"] * Ko * m
relief = min(1.0, p["care_relief"] * p["cp"])
g_entry = p["gentry0"] + p["gentryD"] * Dch
d["Gw"] = (common + p["g3"] * F) * (1 - Gw) \
- (p["dG"] + p["b1"] * P + p["b2"] * Ko * (1 - m)) * Gw \
+ flow["ch"] / s["Nw"] * (g_entry - Gw)
d["Gs"] = (common + p["g3s"] * p["g3"] * F + p["gcare"] * relief) * (1 - Gs) \
- (p["dG"] + p["b1"] * P + p["b2s"] * p["b2"] * Ko * (1 - m)) * Gs \
+ flow["w"] / s["Ns"] * (Gw - Gs)
d["G"] = 0.0
else:
d["Gw"] = d["Gs"] = 0.0
return d, {**a, **eff, "n_ch": n["ch"], "n_w": n["w"], "n_s": n["s"], "n_d": n["d"],
"Gagg": agency(s) if p["cohort_G"] else s["G"]}
def simulate(params: dict | None = None, init: dict | None = None,
T: float = 100.0, dt: float = 0.1, record_every: int = 10) -> list[dict]:
p = {**PARAMS, **(params or {})}
s = {**INIT, **(init or {})}
unknown = set(p) - set(PARAMS)
if unknown:
raise KeyError(f"unknown parameters: {sorted(unknown)}")
if p["sent"] and p["sig_pb0"] is None:
# T03: perceived benefit is judged against the starting situation
_d, a0 = derivatives(s, p)
p = {**p, "sig_pb0": perceived_benefit(s, a0, legislated(s, p))}
rows = []
steps = int(round(T / dt))
for i in range(steps + 1):
d, a = derivatives(s, p)
if i % record_every == 0:
row = {"t": round(i * dt, 6), **s, "P": g3.total_p(s), "G": a["Gagg"],
**{k: a[k] for k in ("H", "A", "n_ch", "n_w", "n_s", "n_d", "care_need",
"Ystar", "B", "V", "Vsel", "Hch", "Hw", "Hs", "Hd")}}
row["VFAC"] = g2.vfac(row)
rows.append(row)
if i < steps:
s = {k: (max(0.0, s[k] + dt * d[k]) if k.startswith("N")
else min(1.0, max(0.0, s[k] + dt * d[k]))) for k in STOCKS}
return rows
# T04: calibrate channel counters at this model's own initial state
PARAMS.update(calibrate())