diff --git a/models/CrossBorder.md b/models/CrossBorder.md index 5ff76c3..b9912c6 100644 --- a/models/CrossBorder.md +++ b/models/CrossBorder.md @@ -325,3 +325,50 @@ Caveats: export demand scales only with Argentina's own K_Ω (no price or competition effects, no partner demand). Adding it at t 0 shifts the calibrated rest point slightly; this is negligible at 1.5 % and noticeable at 10 %. + +## 8. Endogenous foreign shares and the split calibration hook (WP-0016-T02) + +**Endogenous shares.** `world.make(..., endo=...)` lets each foreign share f +relax (speed λ) toward + + f* = f0 · (1 + γ · Δ frontier lead) · (1 − δ · Δ(X · N)) + +that is, toward more foreign use when the home's frontier lead grows and less +when domestic alternatives (commons X, diversity N) grow. At the start f* = f0, +so the measured shares are a rest point. Priors: λ = 0.1 per year, γ = δ = 1. + +**Split calibration hook.** The first hook set the domestic and operator parts +of each channel to the same measured level. That made every share-shifting +lever (portability, endogenous shares) ineffective by construction: moving +users between equally concentrated segments changes nothing. With +`d0 = {channel: domestic-segment concentration}`, the domestic part starts at +d0 with its own counter coefficients, so it is at rest there. The operator +part is solved so that the total keeps its measured level. EU priors: search +0.5 (Ecosia, Qwant: 1–2 % share, one or two firms), cloud 0.3 (EU providers, +about 15 %, several firms), lineages 0.5 (Mistral, open-weight models). +Mobile OS has no EU segment. + +| EU lever (coupled, t 100) | EU P | Δ vs. current | +|---|---|---| +| endogenous shares, current levers | 0.818 | −0.003 | +| commons push (IX × 3), fixed shares | 0.807 | −0.014 | +| split hook + commons push + endogenous shares | **0.793** | −0.027 | +| split hook + lineage portability 0.5 | **0.802** | −0.019 | +| (for comparison) strong access rule on Pd, Pmem | 0.683 | −0.138 | +| (for comparison) US moves to J1 levers | 0.635 | −0.186 | + +Readings: + +1. **Share-shifting levers work once the domestic segment is measured + separately.** Portability and commons-driven substitution lower EU + domination, but by a fifth to a tenth of what access rules or the home's + levers achieve. +2. **The measured foreign shares are close to rest under current levers** + (−0.003 over 100 years): nothing in today's dynamics shifts users toward + domestic alternatives by itself. +3. **The domestic-segment levels d0 are priors.** They need indicator CB3 + (open-weight downloads by developer) and the domestic-provider shares in + CB1 and CB4. + +Calibration note: runs without `d0` keep the original hook, so all earlier +results are unchanged. diff --git a/simulations/gadm02/cross_border.py b/simulations/gadm02/cross_border.py index 5204817..7fd17ea 100644 --- a/simulations/gadm02/cross_border.py +++ b/simulations/gadm02/cross_border.py @@ -36,6 +36,12 @@ STRONG = {"Pd": 0.15, "Pmem": 0.15} # counterfactual strong market-access DMA_EXCESS = -0.017 DMA_EXCESS_RANGE = (-0.034, 0.0) FLOOR = 0.70 +ENDO = {"lam": 0.1, "gamma": 1.0, "delta": 1.0} +# Domestic-segment concentration (priors, WP-0016-T02): EU-own search engines (Ecosia, Qwant; 1-2 % +# share) dominated by one or two firms; EU cloud providers (about 15 %) spread over several; domestic +# and open-weight lineages (Mistral, open models). No EU mobile OS, so Pmem has no domestic segment. +D0_EU = {"Pd": 0.5, "Pi": 0.3, "Plin": 0.5} +IX_EU = 0.02 # gadm02 default commons investment; the push triples it def base() -> dict: @@ -46,7 +52,8 @@ def base() -> dict: def world(cal: dict, coupled: bool = True, us_lev: dict | None = None, eu_access: dict | None = None, - eu_lev: dict | None = None, eu_port: float = 0.0) -> dict: + eu_lev: dict | None = None, eu_port: float = 0.0, eu_endo: dict | None = None, + eu_d0: dict | None = None) -> dict: us_p, us_s = cal["US"] eu_p, eu_s = cal["EU"] ar_p, ar_s = cal["AR"] @@ -54,7 +61,8 @@ def world(cal: dict, coupled: bool = True, us_lev: dict | None = None, eu_access "US": w.make({**us_p, **(us_lev or {})}, us_s), "EU": w.make({**eu_p, **(eu_lev or {})}, eu_s, home="US" if coupled else None, f=F["EU"] if coupled else None, access=eu_access, kappa=KAPPA if coupled else 0.0, - port=eu_port if coupled else 0.0), + port=eu_port if coupled else 0.0, endo=eu_endo if coupled else None, + d0=eu_d0 if coupled else None), "AR": w.make(ar_p, ar_s, home="US" if coupled else None, f=F["AR"] if coupled else None, kappa=KAPPA if coupled else 0.0), } @@ -100,6 +108,15 @@ def cases(a_fit: float) -> list: ("coupled, EU cloud switching (Data Act) a 0.15 on Pi", dict(eu_access={"Pi": 0.15})), ("coupled, EU all host rules (Pd, Pmem, Plin, Pi 0.15; portability 0.5)", dict(eu_access={"Pd": 0.15, "Pmem": 0.15, "Plin": 0.15, "Pi": 0.15}, eu_port=0.5)), + # WP-0016-T02: endogenous foreign shares (priors: speed 0.1/y, weights 1) + ("coupled, endogenous foreign shares, current levers", dict(eu_endo=ENDO)), + ("coupled, EU commons push (IX × 3), fixed shares", dict(eu_lev={"IX": 3 * IX_EU})), + ("coupled, EU commons push (IX × 3), endogenous shares", dict(eu_lev={"IX": 3 * IX_EU}, eu_endo=ENDO)), + ("coupled, split hook (domestic segment measured), current levers", dict(eu_d0=D0_EU)), + ("coupled, split hook, endogenous shares", dict(eu_d0=D0_EU, eu_endo=ENDO)), + ("coupled, split hook, commons push, endogenous shares", + dict(eu_d0=D0_EU, eu_endo=ENDO, eu_lev={"IX": 3 * IX_EU})), + ("coupled, split hook, lineage portability 0.5", dict(eu_d0=D0_EU, eu_port=0.5)), ] diff --git a/simulations/gadm02/results/cross_border.md b/simulations/gadm02/results/cross_border.md index 1589b90..8afab1d 100644 --- a/simulations/gadm02/results/cross_border.md +++ b/simulations/gadm02/results/cross_border.md @@ -53,6 +53,27 @@ at most a ≈ 0.006. Counterfactual strong rule: a 0.15. | coupled, EU all host rules (Pd, Pmem, Plin, Pi 0.15; portability 0.5) | US | 0.68 → 0.79 → 0.82 | 0.88 / 0.63 / 0.90 | 0.36 | 0.253 | 0.36 | 0.32 | broken | | coupled, EU all host rules (Pd, Pmem, Plin, Pi 0.15; portability 0.5) | EU | 0.72 → 0.43 → 0.35 | 0.14 / 0.07 / 0.57 | 0.41 | 0.438 | 0.53 | 0.34 | ok | | coupled, EU all host rules (Pd, Pmem, Plin, Pi 0.15; portability 0.5) | AR | 0.75 → 0.81 → 0.83 | 0.88 / 0.63 / 0.90 | 0.32 | 0.237 | 0.34 | 0.26 | broken | +| coupled, endogenous foreign shares, current levers | US | 0.68 → 0.79 → 0.82 | 0.88 / 0.63 / 0.90 | 0.36 | 0.253 | 0.36 | 0.32 | broken | +| coupled, endogenous foreign shares, current levers | EU | 0.72 → 0.80 → 0.82 | 0.88 / 0.63 / 0.90 | 0.30 | 0.282 | 0.41 | 0.42 | broken | +| coupled, endogenous foreign shares, current levers | AR | 0.75 → 0.81 → 0.83 | 0.88 / 0.63 / 0.90 | 0.32 | 0.241 | 0.35 | 0.27 | broken | +| coupled, EU commons push (IX × 3), fixed shares | US | 0.68 → 0.79 → 0.82 | 0.88 / 0.63 / 0.90 | 0.36 | 0.253 | 0.36 | 0.32 | broken | +| coupled, EU commons push (IX × 3), fixed shares | EU | 0.72 → 0.79 → 0.81 | 0.88 / 0.63 / 0.91 | 0.33 | 0.328 | 0.42 | 0.40 | broken | +| coupled, EU commons push (IX × 3), fixed shares | AR | 0.75 → 0.81 → 0.83 | 0.88 / 0.63 / 0.90 | 0.32 | 0.241 | 0.35 | 0.27 | broken | +| coupled, EU commons push (IX × 3), endogenous shares | US | 0.68 → 0.79 → 0.82 | 0.88 / 0.63 / 0.90 | 0.36 | 0.253 | 0.36 | 0.32 | broken | +| coupled, EU commons push (IX × 3), endogenous shares | EU | 0.72 → 0.79 → 0.81 | 0.88 / 0.63 / 0.90 | 0.33 | 0.327 | 0.42 | 0.40 | broken | +| coupled, EU commons push (IX × 3), endogenous shares | AR | 0.75 → 0.81 → 0.83 | 0.88 / 0.63 / 0.90 | 0.32 | 0.241 | 0.35 | 0.27 | broken | +| coupled, split hook (domestic segment measured), current levers | US | 0.68 → 0.79 → 0.82 | 0.88 / 0.63 / 0.90 | 0.36 | 0.253 | 0.36 | 0.32 | broken | +| coupled, split hook (domestic segment measured), current levers | EU | 0.72 → 0.79 → 0.82 | 0.88 / 0.63 / 0.90 | 0.30 | 0.283 | 0.41 | 0.42 | broken | +| coupled, split hook (domestic segment measured), current levers | AR | 0.75 → 0.81 → 0.83 | 0.88 / 0.63 / 0.90 | 0.32 | 0.241 | 0.35 | 0.27 | broken | +| coupled, split hook, endogenous shares | US | 0.68 → 0.79 → 0.82 | 0.88 / 0.63 / 0.90 | 0.36 | 0.253 | 0.36 | 0.32 | broken | +| coupled, split hook, endogenous shares | EU | 0.72 → 0.79 → 0.82 | 0.88 / 0.63 / 0.90 | 0.30 | 0.282 | 0.41 | 0.42 | broken | +| coupled, split hook, endogenous shares | AR | 0.75 → 0.81 → 0.83 | 0.88 / 0.63 / 0.90 | 0.32 | 0.241 | 0.35 | 0.27 | broken | +| coupled, split hook, commons push, endogenous shares | US | 0.68 → 0.79 → 0.82 | 0.88 / 0.63 / 0.90 | 0.36 | 0.253 | 0.36 | 0.32 | broken | +| coupled, split hook, commons push, endogenous shares | EU | 0.72 → 0.78 → 0.79 | 0.88 / 0.63 / 0.90 | 0.33 | 0.332 | 0.42 | 0.40 | broken | +| coupled, split hook, commons push, endogenous shares | AR | 0.75 → 0.81 → 0.83 | 0.88 / 0.63 / 0.90 | 0.32 | 0.241 | 0.35 | 0.27 | broken | +| coupled, split hook, lineage portability 0.5 | US | 0.68 → 0.79 → 0.82 | 0.88 / 0.63 / 0.90 | 0.36 | 0.253 | 0.36 | 0.32 | broken | +| coupled, split hook, lineage portability 0.5 | EU | 0.68 → 0.77 → 0.80 | 0.88 / 0.63 / 0.86 | 0.31 | 0.293 | 0.41 | 0.41 | broken | +| coupled, split hook, lineage portability 0.5 | AR | 0.75 → 0.81 → 0.83 | 0.88 / 0.63 / 0.90 | 0.32 | 0.241 | 0.35 | 0.27 | broken | ## EU domination at t 100: effect of each lever (coupled model) @@ -70,3 +91,10 @@ at most a ≈ 0.006. Counterfactual strong rule: a 0.15. | coupled, EU lineage access 0.15 + portability 0.5 | 0.757 | -0.064 | | coupled, EU cloud switching (Data Act) a 0.15 on Pi | 0.766 | -0.055 | | coupled, EU all host rules (Pd, Pmem, Plin, Pi 0.15; portability 0.5) | 0.350 | -0.471 | +| coupled, endogenous foreign shares, current levers | 0.818 | -0.003 | +| coupled, EU commons push (IX × 3), fixed shares | 0.807 | -0.014 | +| coupled, EU commons push (IX × 3), endogenous shares | 0.807 | -0.014 | +| coupled, split hook (domestic segment measured), current levers | 0.817 | -0.004 | +| coupled, split hook, endogenous shares | 0.818 | -0.003 | +| coupled, split hook, commons push, endogenous shares | 0.793 | -0.027 | +| coupled, split hook, lineage portability 0.5 | 0.802 | -0.019 | diff --git a/simulations/gadm02/test_world.py b/simulations/gadm02/test_world.py index 0b33e02..808d698 100644 --- a/simulations/gadm02/test_world.py +++ b/simulations/gadm02/test_world.py @@ -74,6 +74,24 @@ class TestWorld(unittest.TestCase): self.assertLess(port["host"][-1]["Plin"], base["host"][-1]["Plin"]) self.assertAlmostEqual(port["home"][-1]["Plin"], base["home"][-1]["Plin"], places=12) + def test_endogenous_shares_rest_at_start_and_fall_with_domestic_alternatives(self): + f = {c: 0.8 for c in w.COUPLED} + endo = {"lam": 0.5, "gamma": 0.0, "delta": 2.0} + still = w.simulate_world({"home": w.make(), "host": w.make(home="home", f=f, endo=endo)}, T=1, record_every=1) + self.assertAlmostEqual(still["host"][1]["f_Pd"], 0.8, places=3) # near rest at start + push = w.simulate_world({"home": w.make(), "host": w.make({"IX": 0.3}, home="home", f=f, endo=endo)}, T=T) + fixed = w.simulate_world({"home": w.make(), "host": w.make({"IX": 0.3}, home="home", f=f)}, T=T) + self.assertLess(push["host"][-1]["f_Pd"], 0.8) + self.assertEqual(fixed["host"][-1]["f_Pd"], 0.8) + + def test_split_hook_keeps_measured_level(self): + f = {c: 0.8 for c in w.COUPLED} + j = w.make(home="home", f=f, d0={"Pd": 0.3}) + self.assertAlmostEqual(w.mixed(j)["Pd"], w.make(home="home", f=f)["s"]["Pd"], places=12) + self.assertEqual(j["s"]["Pd"], 0.3) + d, _a, _sm = w.step_derivatives({"home": w.make(), "host": j})["host"] + self.assertLess(abs(d["Pd"]), 1e-3) # the domestic segment starts at rest + if __name__ == "__main__": unittest.main() diff --git a/simulations/gadm02/world.py b/simulations/gadm02/world.py index 274c4b2..4754709 100644 --- a/simulations/gadm02/world.py +++ b/simulations/gadm02/world.py @@ -34,20 +34,39 @@ OP = {c: "O" + c for c in COUPLED} # operator-part stocks def make(params: dict | None = None, init: dict | None = None, home: str | None = None, - f: dict | None = None, access: dict | None = None, kappa: float = 0.0, port: float = 0.0) -> dict: + f: dict | None = None, access: dict | None = None, kappa: float = 0.0, port: float = 0.0, + endo: dict | None = None, d0: dict | None = None) -> dict: """One jurisdiction: gadm04 params/init plus coupling settings. home: name of the operators' home jurisdiction (None = no foreign operators); f: foreign share per coupled channel; access: host market-access rule per channel; port: lineage portability rule (WP-0016-T01), 0..1: share of imported-lineage use that users can move to domestic or open lineages; lowers the effective lineage import share f_Plin * (1 - port). + endo: endogenous foreign shares (WP-0016-T02), {"lam": speed per year, "gamma": weight of the + home's frontier lead, "delta": weight of domestic alternatives X*N}. f relaxes toward + f* = f0 * (1 + gamma * (lead - lead0)) * (1 - delta * (alt - alt0)), clipped to [0, 1]; at the + start f* = f0, so the measured shares are a rest point. + d0: measured concentration of the domestic segment per channel (WP-0016-T02). Without it, the + calibration hook sets D(0) = O(0) = P(0), which makes every share-shifting lever ineffective. + With it, D(0) = d0 and O(0) is solved so that the mixed channel keeps its measured P(0). """ p = {**g.PARAMS, **(params or {})} s = {**g.INIT, **(init or {})} + fs = {c: 0.0 for c in COUPLED} | (f or {}) for c in COUPLED: s[OP[c]] = s[c] # calibration hook: D(0) = O(0) = P(0) - return {"p": p, "s": s, "home": home, "f": {c: 0.0 for c in COUPLED} | (f or {}), - "access": {c: 0.0 for c in COUPLED} | (access or {}), "kappa": kappa, "port": port} + if d0 and c in d0 and fs[c] > 0: # split: domestic segment measured, operator part keeps P(0) + total = s[c] + fc = fs[c] * (1 - port) if c == "Plin" else fs[c] + s[c] = d0[c] + s[OP[c]] = min(1.0, max(0.0, (total - (1 - fc) * d0[c]) / fc)) + dom = None + if d0: # the domestic segment gets its own channel counters, so that D(0) = d0 is a rest point + coef = g.calibrate(p, s) + dom = {g3.COEF[c]: coef[g3.COEF[c]] for c in COUPLED if c in d0} + return {"p": p, "s": s, "home": home, "f": {c: 0.0 for c in COUPLED} | (f or {}), "dom": dom, + "access": {c: 0.0 for c in COUPLED} | (access or {}), "kappa": kappa, "port": port, + "endo": endo} def context(rows: list[dict], params: dict | None = None) -> dict: @@ -94,7 +113,7 @@ def step_derivatives(world: dict, sigma: dict | None = None) -> dict: sm = mixed(j) d, a = g.derivatives(sm, p) # everything the host sees if any(j["f"][c] for c in COUPLED): # domestic channel parts at their own level - dd, _a = g.derivatives(j["s"], p) + dd, _a = g.derivatives(j["s"], {**p, **(j.get("dom") or {})}) for c in COUPLED: d[c] = dd[c] for c in COUPLED: # global spillover acts on domestic parts too @@ -130,6 +149,11 @@ def simulate_world(world: dict, T: float = 100.0, dt: float = 0.1, record_every: _d, a0 = g.derivatives(mixed(j), j["p"]) j["p"] = {**j["p"], "sig_pb0": g.perceived_benefit(mixed(j), a0, g.legislated(mixed(j), j["p"]))} keys = g.STOCKS + tuple(OP.values()) + for name, j in world.items(): + if j.get("endo") and j["home"] is not None: + h = world[j["home"]]["s"] + j["f"] = dict(j["f"]) + j["_ref"] = {"f0": dict(j["f"]), "lead0": h["Ko"] - j["s"]["Ko"], "alt0": j["s"]["X"] * j["s"]["N"]} rows = {k: [] for k in world} for j in world.values(): if j.get("path") and record_every and len(j["path"]) < int(round(T / dt)) + 1: @@ -143,9 +167,21 @@ def simulate_world(world: dict, T: float = 100.0, dt: float = 0.1, record_every: **{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.update({OP[c]: world[name]["s"][OP[c]] for c in COUPLED}) + row.update({"f_" + c: world[name]["f"][c] for c in COUPLED}) row["VFAC"] = g2.vfac(row) rows[name].append(row) if i < steps: + for name, j in world.items(): # endogenous foreign shares (previous state) + e = j.get("endo") + if not e or j["home"] is None: + continue + ref, h = j["_ref"], world[j["home"]]["s"] + lead, alt = h["Ko"] - j["s"]["Ko"], j["s"]["X"] * j["s"]["N"] + for c in COUPLED: + target = ref["f0"][c] * (1 + e.get("gamma", 1.0) * (lead - ref["lead0"])) \ + * (1 - e.get("delta", 1.0) * (alt - ref["alt0"])) + target = min(1.0, max(0.0, target)) + j["f"][c] = min(1.0, max(0.0, j["f"][c] + dt * e.get("lam", 0.1) * (target - j["f"][c]))) for name, (d, _a, _sm) in der.items(): if world[name].get("path"): row = world[name]["path"][min(i + 1, len(world[name]["path"]) - 1)] diff --git a/workplans/ASSEMBLY-WP-0016-open-economy-refinements.md b/workplans/ASSEMBLY-WP-0016-open-economy-refinements.md index 1502db9..cb6559b 100644 --- a/workplans/ASSEMBLY-WP-0016-open-economy-refinements.md +++ b/workplans/ASSEMBLY-WP-0016-open-economy-refinements.md @@ -42,11 +42,17 @@ after interoperability rules, or the AI Act's GPAI obligations). Re-run ```task id: ASSEMBLY-WP-0016-T02 -status: todo +status: done priority: medium state_hub_task_id: "7a89281d-0cba-5d3f-85c6-6e29cb4f2de8" ``` +**Done 2026-10-13:** endogenous foreign shares (`endo`) and the split +calibration hook (`d0`, domestic segment with its own counters), tests, +`models/CrossBorder.md` § 8. Share-shifting levers work only with the split +hook: EU P −0.019 (portability), −0.027 (commons push with endogenous shares). +Priors λ, γ, δ and d0 still need data (CB1, CB3, CB4). + Make f_c^j a state: it falls when domestic alternatives grow (domestic X, N, open lineages) or access rules bind, and rises with the operator's quality lead (frontier gap). Check that today's measured f (§ 2) is a rest