ASSEMBLY-WP-0012 finished: model matching (matching.py, observed series), flip definition and regime filter, synthetic test (negative: flips not yet reliable), FlipRegister, BR-009; residuals to WP-0017

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
This commit is contained in:
tegwick 2026-10-13 20:33:38 +02:00
parent eeb5c28284
commit 597eb4176e
11 changed files with 703 additions and 7 deletions

View file

@ -38,6 +38,7 @@ cd simulations/gadm02 && python3 -B calibrate_data.py && python3 -B calibrated_r
cd simulations/gadm02 && for j in US EU; do python3 -B gsa.py --model gadm04 --jobs 8 --start $j; done # GSA around calibrated starts, ~30 min
cd simulations/gadm02 && python3 -B cross_border.py # coupled world US/EU/AR from the calibrated start (models/CrossBorder.md)
cd simulations/gadm02 && python3 -B world_scenarios.py # contexts X1–X4, hypothesis re-test, local vs. global
cd simulations/gadm02 && python3 -B matching.py && python3 -B matching.py --synthetic # model matching; flip-detection test (~10 min)
```
Reality checkpoints (ASSEMBLY-WP-0010): scheduled by activity-core and

View file

@ -21,3 +21,4 @@ that part of the band.
| BR-006 | 2026-10-13 (WP-0013-T04) | Legitimacy loop damps adoption (BR-005, AdoptionBand § 3.3) | damping under extractive regimes → amplifying under rights-first levers from the calibrated start (US, AR) | when adoption lowers domination, perceived benefit rises and sentiment accelerates adoption | none structural; the reading is regime-dependent | open: state the loop's effect per regime |
| BR-007 | 2026-10-13 (WP-0013-T04) | Rights-first levers bound domination | holds when switching to J1 levers (US: P 0.64 → 0.54) → fails for a jurisdiction already at J1 levers (EU: P 0.65 → 0.77) | calibrated domination comes from levels set abroad that local levers cannot reach | **structural:** cross-border coupling (ASSEMBLY-WP-0011) | open |
| BR-008 | 2026-10-13 (WP-0011-T05) | Which levers bound domination (BR-007 follow-up) | closed model: host rights levers → coupled model: only host access rules on foreign operators and the home's levers reach the foreign-run channels | measured channels (Pd, Pmem, Plin, Pi) are operated from the US for EU and AR hosts | **structural, implemented:** cross-border coupling (`world.py`); observed DMA effect on search ≈ 0 (at most a 0.006/yr) | open: lineage access rules (BR-003) |
| BR-009 | 2026-10-13 (WP-0012) | Sentiment dynamics of the legitimacy loop | Σ relaxes toward 0.5 + gain × Δ(perceived benefit), so from the measured US Σ 0.285 it rises → measured US sentiment fell 2021–2026 (0.405 → 0.285) while adoption grew fast | the neutral reference 0.5 is an assumption; US adoption also shows no braking by low sentiment (all loop-on candidates fail the US match) | **structural candidate:** sentiment reference anchored to the observed level and a small or lagged adoption elasticity | open: ASSEMBLY-WP-0017 |

22
research/FlipRegister.md Normal file
View file

@ -0,0 +1,22 @@
# Flip register (append-only)
Regime changes of jurisdictions, detected by model matching
(`research/ModelMatching.md` § 3). **Never edit or delete an entry.**
**Status 2026-10-13:** no entries. Flip detection has not passed its
synthetic test (`research/ModelMatching.md` § 4), so no measured flip may be
recorded yet. Simulated flips (regime crossings inside model runs) are
recorded in the break register until then (for example BR-005).
## Entry format
```markdown
### F-YYYY-NNN — <jurisdiction>: <from regime> → <to regime>
- Detected: <checkpoint date>, kind measured | simulated, by <script/version>
- Evidence: weights before/after, indicators that moved
- Consequences: predictions superseded (ids), break-register links
```
## Entries
(none)

104
research/ModelMatching.md Normal file
View file

@ -0,0 +1,104 @@
# Model matching and regime flipping
**Status:** v0.1 (2026-10-13), ASSEMBLY-WP-0012. Matching on observed data
works. **Flip detection is not yet reliable** (§ 4). No flip may be declared
from real data until the synthetic test passes.
Code: `simulations/gadm02/matching.py`. Data: `observed_series.json`.
Results: `results/matching.md` (observed), `results/matching_synthetic.md`
(synthetic test).
## 1. Candidates and fit criterion (T01)
Candidates: band anchor B1–B4 × legitimacy loop off/on, each started from
the jurisdiction's calibrated state (`calibrate_data.start_state`). B1 stays
in the set with its misfit recorded, so a return to B1-like growth would be
visible.
For each candidate, the adoption indicator is taken as proportional to K_Ω.
Two values are fitted: a time offset t0 and a scale s, with
`log y = log s + log K_Ω(t0 + τ) + ε` and ε ~ N(0, σ) (σ per series: BTOS
0.03, Eurostat 0.05). The calibrated K_Ω level range at the end of the window
adds a Gaussian level term. All candidates fit the same number of values, so
AIC weights reduce to likelihood weights.
The sentiment slope (Pew 2021 → 2026) is not scored yet: the points precede
the model start (checkpoint 0). It is switched on once there are sentiment
points after checkpoint 0.
## 2. Results on observed data (T02, T04)
| Jurisdiction | Data | Anchor weights | Loop weights | Best |
|---|---|---|---|---|
| US | BTOS, 23 biweekly points 2025–2026 | B1 0.00 · B2 0.38 · B3 0.44 · B4 0.18 | off 1.00 · on 0.00 | B3 / loop off (0.44) |
| EU | Eurostat 2023–2025 | B1 0.00 · B2 0.38 · B3 0.62 · B4 0.00 | off 0.47 · on 0.53 | B3 / loop off (0.46) |
| DE | Eurostat 2023–2025 | B1 0.00 · B2 0.73 · B3 0.27 · B4 0.00 | off 0.33 · on 0.67 | B2 / loop on (0.57) |
Readings:
1. **The data rule out B1 everywhere, and B4 for the EU and DE.** The present
is B2–B3, consistent with the calibration (`models/Calibration.md` § 3).
2. **US adoption is not braked by its low sentiment.** Every loop-on
candidate fails for the US, because with measured US sentiment (Σ 0.285)
the loop slows adoption far below the observed growth. Either adoption's
elasticity to sentiment (`sig_adopt`) is much smaller than 1, or
sentiment acts on adoption with a long lag. This is the first data-based
constraint on the legitimacy loop.
3. **No regime is established yet.** No candidate reaches weight 0.75.
Predictions v1 (P-2026-001 … 004) assume growth alignment between B2 and
B3, which the matched weights bracket. No predictions v2 are needed (T04).
## 3. Flipping events (T03)
A **flipping event** is a change of the regime a jurisdiction operates
under, detected when:
1. the best-weighted candidate changes, and
2. the new best holds weight ≥ 0.75 at two consecutive evaluations.
Before a first regime is established (weight ≥ 0.75), changes are not flips.
**Measured flips** come from the regime filter on observed data. Every
quarter, each regime's dynamics run forward from the current state estimate
over the last quarter, the weights are updated with a switching prior (0.02
per quarter), and the estimate advances with the best regime. **Simulated
flips** are regime crossings inside a model run, for example the B4
boom-bust of BR-005. Flips are recorded in `research/FlipRegister.md`.
## 4. Synthetic test (T06): not yet reliable
Truth is simulated from a known candidate. In the switch cases it changes
regime at t = 4. It is observed biweekly with 3 % log-noise, with level
evidence as wide as the real US target. Three seeds per case
(`results/matching_synthetic.md`).
| Indicators | Correct regime at start | False flips (no-switch truth) | Switches detected |
|---|---|---|---|
| adoption only | 3 of 6 | 3 of 6 | 0 of 6 |
| adoption + annual sentiment | 3 of 6 | 3 of 6 | 2 of 6 (delays 0.5 y and 3.25 y) |
The design went through three stages:
1. Free re-fitting of t0 and s per window: any candidate matches any segment.
2. Alignment fixed after the first window: still path-based, so a regime
change starting *now* is not represented.
3. A forward filter from the current state, with evidence accumulated across
quarters.
Each stage reduced false flips. None of them made detection reliable.
**Why:** with adoption alone, neighbouring anchors (B2/B3/B4) differ by less
than measurement noise over a quarter. The loop switch leaves almost no trace
in adoption. In sentiment it is confounded by BR-009: the sentiment
equation reverts toward neutral from the low measured level.
**Requirements before flips may be declared** (ASSEMBLY-WP-0017):
- more discriminating indicators: investment (L5) for self-enhancement,
sentiment at least twice a year for the loop, and concentration channels
(CB1) for domination dynamics;
- per-regime state tracking (one state per regime, not one shared estimate);
- the sentiment equation fixed (BR-009);
- acceptance criteria: at least 5 of 6 regimes correct at the start, no
false flips in 6 no-switch runs, and at least 4 of 6 switches detected
within 1.5 years.

View file

@ -0,0 +1,303 @@
"""Model matching and regime flipping (ASSEMBLY-WP-0012-T01/T02/T03/T06).
Candidates: band anchor (B1-B4) x legitimacy loop (off/on), each from the
jurisdiction's calibrated state (calibrate_data.start_state). Method:
research/ModelMatching.md.
Fit per candidate: the adoption indicator is taken as proportional to K_Omega,
so two free values are fitted, a time offset t0 and a scale s
(log y = log s + log K_Omega(t0 + tau) + noise, noise sd = the series'
log_sigma). The calibrated K_Omega level range at the aligned time
(calibration_targets.json) is scored as well, so shape and level both count.
`sentiment_ll` scores a sentiment slope, but it is not used yet: the Pew
points (2021, 2026) precede the model start (checkpoint 0). It is switched on
once the sentiment series has points after checkpoint 0. All candidates have the same
number of fitted values, so AIC weights reduce to likelihood weights.
Flip: the best candidate changes and the new best has weight >= FLIP_WEIGHT
in two consecutive evaluations (research/ModelMatching.md § 3).
Usage: python3 -B simulations/gadm02/matching.py # match observed series
python3 -B simulations/gadm02/matching.py --synthetic # recovery and flip test
"""
from __future__ import annotations
import json
import math
import random
import sys
from pathlib import Path
sys.dont_write_bytecode = True
import calibrate_data as cd # noqa: E402
import gadm04 as g # noqa: E402
import grounding as gr # noqa: E402
HERE = Path(__file__).resolve().parent
OUT = HERE / "results"
SERIES = json.loads((HERE / "observed_series.json").read_text())["series"]
CANDIDATES = [(a, lp) for a in ("B1", "B2", "B3", "B4") for lp in ("off", "on")]
STEP = 0.1
T_PATH = 30.0
FLIP_WEIGHT = 0.75
SWITCH = 0.02 # prior probability per quarter that the regime changes (filter)
_CACHE: dict = {}
def path(jur: str, anchor: str, loop: str) -> list[dict]:
key = (jur, anchor, loop)
if key not in _CACHE:
p, s = cd.start_state(jur, anchor)
_CACHE[key] = g.simulate({**p, "sent": 1.0 if loop == "on" else 0.0}, s, T=T_PATH, record_every=1)
return _CACHE[key]
def interp(rows: list[dict], t: float, key: str) -> float:
x = t / STEP
i = min(int(x), len(rows) - 2)
w = x - i
return (1 - w) * rows[i][key] + w * rows[i + 1][key]
def fit_series(rows: list[dict], points: list, log_sigma: float,
fixed: tuple[float, float] | None = None) -> tuple[float, float, float]:
"""(log-likelihood, t0, log s) for an indicator proportional to K_Omega.
fixed = (t0, log s): no fitting, score the points at this alignment and scale. Used after the
first evaluation: the calendar fixes how t0 advances and the measurement fixes the scale."""
if fixed is not None:
t0, ls = fixed
if t0 + points[-1][0] > T_PATH - STEP:
return -1e9, t0, ls
sse = sum((math.log(y) - math.log(max(1e-9, interp(rows, t0 + tau, "Ko"))) - ls) ** 2 for tau, y in points)
return -0.5 * sse / log_sigma ** 2, t0, ls
span = points[-1][0]
best = None
t0 = 0.0
while t0 + span <= T_PATH - 1:
r = [math.log(y) - math.log(max(1e-9, interp(rows, t0 + tau, "Ko"))) for tau, y in points]
ls = sum(r) / len(r)
sse = sum((x - ls) ** 2 for x in r)
if best is None or sse < best[0]:
best = (sse, t0, ls)
t0 += 0.05
sse, t0, ls = best
return -0.5 * sse / log_sigma ** 2, t0, ls
def sentiment_ll(rows: list[dict], t0: float, series: dict) -> float:
(ta, ya), (tb, yb) = series["points"][0], series["points"][-1]
obs = (yb - ya) / (tb - ta)
span = tb - ta
a = max(0.0, t0 - span) # the sentiment window ends at the adoption alignment
mod = (interp(rows, a + span, "Sig") - interp(rows, a, "Sig")) / span
return -0.5 * ((mod - obs) * span / series["sigma"]) ** 2
def score(jur: str, rows_for: callable, adoption: dict, sentiment: dict | None = None,
level: dict | None = None, fixed: dict | None = None) -> dict:
out = {}
for a, lp in CANDIDATES:
rows = rows_for(jur, a, lp)
ll, t0, ls = fit_series(rows, adoption["points"], adoption["log_sigma"],
fixed=fixed.get((a, lp)) if fixed else None)
if level is not None: # K_Omega level at the end of the observed window
ko = interp(rows, t0 + adoption["points"][-1][0], "Ko")
ll += -0.5 * cd.z(ko, level) ** 2
if sentiment is not None:
ll += sentiment_ll(rows, t0, sentiment)
out[(a, lp)] = {"ll": ll, "t0": t0, "s": math.exp(ls), "ls": ls}
mx = max(v["ll"] for v in out.values())
z = sum(math.exp(v["ll"] - mx) for v in out.values())
for v in out.values():
v["w"] = math.exp(v["ll"] - mx) / z
return out
def best(res: dict) -> tuple:
return max(res, key=lambda k: res[k]["w"])
def flips(evaluations: list[tuple[float, dict]]) -> list[tuple[float, tuple, tuple]]:
"""Flip events: best changes and the new best holds weight >= FLIP_WEIGHT twice in a row."""
events, current, pending = [], None, None
for t, res in evaluations:
b, wb = best(res), res[best(res)]["w"]
if current is None:
if wb >= FLIP_WEIGHT:
current = b
continue
if b != current and wb >= FLIP_WEIGHT:
if pending == b:
events.append((t, current, b))
current, pending = b, None
else:
pending = b
else:
pending = None
return events
def fmt(c: tuple) -> str:
return f"{c[0]}/loop {c[1]}"
def main_observed() -> None:
lines = [f"# {g.MODEL_VERSION}: model matching on observed series", "",
"Generated by `simulations/gadm02/matching.py` (ASSEMBLY-WP-0012). Method:",
"`research/ModelMatching.md`. Data: `observed_series.json`. Weights are likelihood (AIC) weights",
"over the eight candidates; all candidates fit the same two values (t0, s). Each score adds the",
"calibrated K_Ω level range at the end of the window. Sentiment is not scored yet (data precede",
"checkpoint 0).", ""]
for jur, adop, sent in (("US", "US_adoption", None), ("EU", "EU_adoption", None),
("DE", "DE_adoption", None)):
res = score(jur, path, SERIES[adop], SERIES[sent] if sent else None, level=cd.targets(jur)["Ko"])
b = best(res)
lines += [f"## {jur}", "", f"Series: {SERIES[adop]['source']}" + (f"; {SERIES[sent]['source']}" if sent else ""),
"", "| Candidate | log-likelihood | weight | t0 | K_Ω at window end |", "|---|---|---|---|---|"]
for c in sorted(res, key=lambda k: -res[k]["w"]):
v = res[c]
lines.append(f"| {fmt(c)} | {v['ll']:.1f} | {v['w']:.3f} | {v['t0']:.2f} | "
f"{interp(path(jur, *c), v['t0'] + SERIES[adop]['points'][-1][0], 'Ko'):.3f} |")
anchors = {}
for (a, _lp), v in res.items():
anchors[a] = anchors.get(a, 0) + v["w"]
loops = {lp: sum(v["w"] for (a, l2), v in res.items() if l2 == lp) for lp in ("off", "on")}
lines += ["", f"**Best:** {fmt(b)} (weight {res[b]['w']:.2f}). Anchor weights: "
+ ", ".join(f"{a} {w:.2f}" for a, w in anchors.items())
+ f". Loop weights: off {loops['off']:.2f}, on {loops['on']:.2f}.", ""]
(OUT / "matching.md").write_text("\n".join(lines))
print("\n".join(lines))
def synthetic_rows(jur: str, first: tuple, second: tuple | None, t_switch: float) -> list[dict]:
"""Truth: candidate `first` until t_switch, then `second` parameters continuing from that state."""
p1, s1 = cd.start_state(jur, first[0])
r1 = g.simulate({**p1, "sent": 1.0 if first[1] == "on" else 0.0}, s1, T=t_switch, record_every=1)
if second is None:
return g.simulate({**p1, "sent": 1.0 if first[1] == "on" else 0.0}, s1, T=T_PATH, record_every=1)
p2, _s2 = cd.start_state(jur, second[0])
state = {k: r1[-1][k] for k in g.STOCKS}
r2 = g.simulate({**p2, "sent": 1.0 if second[1] == "on" else 0.0}, state, T=T_PATH - t_switch, record_every=1)
for r in r2[1:]:
r["t"] = round(r["t"] + t_switch, 6)
return r1 + r2[1:]
_PARAMS: dict = {}
def regime_params(jur: str, cand: tuple) -> dict:
"""Parameters of a regime (anchor dynamics, loop switch) for jurisdiction `jur`."""
if (jur, cand) not in _PARAMS:
p, _s = cd.start_state(jur, cand[0])
_PARAMS[(jur, cand)] = {**p, "sent": 1.0 if cand[1] == "on" else 0.0}
return _PARAMS[(jur, cand)]
def filter_step(jur: str, state: dict, pts: list, scale_ls: float, log_sigma: float,
level: dict | None, window: float, sent_pts: list | None = None, sent_sd: float = 0.02) -> dict:
"""Score every regime by running its dynamics forward from `state` over the window.
sent_pts: optional sentiment observations (tau, Sigma) within the window."""
out = {}
for c in CANDIDATES:
rows = g.simulate(regime_params(jur, c), state, T=window, record_every=1)
sse = sum((math.log(y) - math.log(max(1e-9, interp(rows, tau, "Ko"))) - scale_ls) ** 2 for tau, y in pts)
ll = -0.5 * sse / log_sigma ** 2
for tau, y in sent_pts or []:
ll += -0.5 * ((interp(rows, tau, "Sig") - y) / sent_sd) ** 2
if level is not None:
ll += -0.5 * cd.z(rows[-1]["Ko"], level) ** 2
out[c] = {"ll": ll, "end": {k: rows[-1][k] for k in g.STOCKS}}
mx = max(v["ll"] for v in out.values())
zs = sum(math.exp(v["ll"] - mx) for v in out.values())
for v in out.values():
v["w"] = math.exp(v["ll"] - mx) / zs
return out
def synthetic_run(truth: list[dict], start: float, years: float, window: float, seed: int,
log_sigma: float = 0.03, sentiment: bool = False) -> list[tuple[float, dict]]:
"""Quarterly regime filter on noisy biweekly observations (research/ModelMatching.md § 3).
First window: whole-path alignment fit (t0, scale) gives the initial state and the scale. Then,
every quarter, each regime runs forward from the current state estimate over the last window;
the state estimate advances with the best regime."""
rnd = random.Random(seed)
obs, t = [], start
while t <= start + years:
obs.append((t, interp(truth, t, "Ko") * 25.0 * math.exp(rnd.gauss(0, log_sigma))))
t += 2 / 52
sobs = [(start + k, interp(truth, start + k, "Sig") + rnd.gauss(0, 0.02)) for k in range(int(years) + 1)] \
if sentiment else []
te0 = start + window
pts0 = [(tt - start, y) for tt, y in obs if start <= tt <= te0]
first = score("US", path, {"points": pts0, "log_sigma": log_sigma},
level={"lo": interp(truth, te0, "Ko") - 0.075, "hi": interp(truth, te0, "Ko") + 0.075})
b = best(first)
scale_ls = first[b]["ls"]
t_est = first[b]["t0"] + window
rows_b = path("US", *b)
i_est = int(round(t_est / STEP))
est = {k: rows_b[i_est][k] for k in g.STOCKS}
evals = [(te0, first)]
step = 0.25
te = te0 + step
while te <= start + years + 1e-9:
pts = [(tt - (te - step), y) for tt, y in obs if te - step - 1e-9 <= tt <= te]
ko = interp(truth, te, "Ko")
spts = [(tt - (te - step), y) for tt, y in sobs if te - step < tt <= te + 1e-9]
res = filter_step("US", est, pts, scale_ls, log_sigma, {"lo": ko - 0.075, "hi": ko + 0.075}, step, spts)
prev = evals[-1][1]
n = len(CANDIDATES)
prior = {c: (1 - SWITCH) * prev[c]["w"] + SWITCH * (1 - prev[c]["w"]) / (n - 1) for c in CANDIDATES}
mx = max(res[c]["ll"] for c in CANDIDATES)
post = {c: prior[c] * math.exp(res[c]["ll"] - mx) for c in CANDIDATES}
zs = sum(post.values())
for c in CANDIDATES:
res[c]["w"] = post[c] / zs # evidence accumulates across quarters
est = res[best(res)]["end"]
evals.append((te, res))
te += step
return evals
def main_synthetic() -> None:
lines = [f"# {g.MODEL_VERSION}: matching and flip detection on synthetic data", "",
"Generated by `simulations/gadm02/matching.py --synthetic` (ASSEMBLY-WP-0012-T06).",
"US calibrated state. Truth simulated from a known candidate (optionally switching regime at",
"t_switch), observed biweekly as 25 × K_Ω with log-noise sd 0.03, from t 2 to t 8. Quarterly",
"regime filter: the first year fixes the initial state and the scale (whole-path alignment); then",
"every quarter each regime runs forward from the current state estimate over the last quarter, and",
"the estimate advances with the best regime. Weights accumulate across quarters (forward filter,",
f"switch probability {SWITCH} per quarter). Level evidence: true K_Ω ± 0.075 (as wide as the real",
f"US target). Flip: new best with weight ≥ {FLIP_WEIGHT} twice in a row.", "",
"| Indicators | Truth | Seed | Best at first evaluation | Flips detected (t, from → to) | Verdict |",
"|---|---|---|---|---|---|"]
cases = [(("B3", "off"), None, None), (("B2", "off"), None, None),
(("B2", "off"), ("B4", "off"), 4.0), (("B3", "off"), ("B3", "on"), 4.0)]
for first, second, ts in cases:
truth = synthetic_rows("US", first, second, ts or T_PATH)
for seed, sentiment in ((1, False), (2, False), (3, False), (1, True), (2, True), (3, True)):
ev = synthetic_run(truth, 2.0, 6.0, 1.0, seed, sentiment=sentiment)
fl = flips(ev)
b0 = best(ev[0][1])
if second is None:
verdict = "ok (no false flip)" if not fl else "FALSE FLIP"
else:
hit = [e for e in fl if e[0] >= ts and e[2][0] == second[0] and (second[1] == first[1] or e[2][1] == second[1])]
verdict = f"detected, delay {hit[0][0] - ts:.2f} y" if hit else "missed"
label = fmt(first) + (f" → {fmt(second)} at t {ts}" if second else "")
lines.append(f"| {'adoption + annual sentiment' if sentiment else 'adoption'} | {label} | {seed} | {fmt(b0)} | "
+ ("; ".join(f"{t:.2f}: {fmt(a)} → {fmt(b)}" for t, a, b in fl) or "none")
+ f" | {verdict} |")
lines.append("")
(OUT / "matching_synthetic.md").write_text("\n".join(lines))
print("\n".join(lines))
if __name__ == "__main__":
main_synthetic() if "--synthetic" in sys.argv else main_observed()

View file

@ -0,0 +1,32 @@
{
"note": "Observed indicator series for model matching (ASSEMBLY-WP-0012). Values as fetched for checkpoint 0 and the 2026-10-13 review; t in years relative to the first point. Append new points at each checkpoint; do not edit old ones.",
"series": {
"US_adoption": {
"indicator": "L1", "unit": "% of employer firms using AI (last two weeks)",
"source": "U.S. Census Bureau BTOS Q7 (National.xlsx), periods 2025-24 to 2026-20 (biweekly)",
"log_sigma": 0.03,
"points": [[0.0, 17.3], [0.0385, 17.2], [0.0769, 17.8], [0.1154, 17.7], [0.1538, 18.2], [0.1923, 18.9],
[0.2308, 17.5], [0.2692, 18.2], [0.3077, 18.9], [0.3462, 19.1], [0.3846, 19.8], [0.4231, 19.8],
[0.4615, 19.5], [0.5, 20.6], [0.5385, 20.6], [0.5769, 20.6], [0.6154, 21.7], [0.6538, 21.5],
[0.6923, 21.8], [0.7308, 22.4], [0.7692, 23.2], [0.8077, 23.8], [0.8462, 23.8]]
},
"EU_adoption": {
"indicator": "L2", "unit": "% of enterprises (10+ employees) using at least one AI technology",
"source": "Eurostat isoc_eb_ai, EU27_2020, reference years 2023-2025 (2021: 7.65, before generative AI, excluded)",
"log_sigma": 0.05,
"points": [[0.0, 8.06], [1.0, 13.48], [2.0, 19.95]]
},
"DE_adoption": {
"indicator": "L2", "unit": "% of enterprises (10+ employees) using at least one AI technology",
"source": "Eurostat isoc_eb_ai, DE, reference years 2023-2025 (2021: 10.56, excluded)",
"log_sigma": 0.05,
"points": [[0.0, 11.55], [1.0, 19.75], [2.0, 25.97]]
},
"US_sentiment": {
"indicator": "L6", "unit": "Sigma = 0.5 + (excited - concerned)/200",
"source": "Pew Research: 2021 (37 % concerned, 18 % excited) and June 2026 (52 %, 9 %)",
"sigma": 0.03,
"points": [[0.0, 0.405], [5.0, 0.285]]
}
}
}

View file

@ -0,0 +1,58 @@
# GADM-0.4: model matching on observed series
Generated by `simulations/gadm02/matching.py` (ASSEMBLY-WP-0012). Method:
`research/ModelMatching.md`. Data: `observed_series.json`. Weights are likelihood (AIC) weights
over the eight candidates; all candidates fit the same two values (t0, s). Each score adds the
calibrated K_Ω level range at the end of the window. Sentiment is not scored yet (data precede
checkpoint 0).
## US
Series: U.S. Census Bureau BTOS Q7 (National.xlsx), periods 2025-24 to 2026-20 (biweekly)
| Candidate | log-likelihood | weight | t0 | K_Ω at window end |
|---|---|---|---|---|
| B3/loop off | -8.3 | 0.443 | 1.20 | 0.172 |
| B2/loop off | -8.5 | 0.376 | 0.20 | 0.077 |
| B4/loop off | -9.2 | 0.176 | 1.90 | 0.278 |
| B4/loop on | -12.8 | 0.005 | 0.00 | 0.065 |
| B3/loop on | -19.4 | 0.000 | 0.00 | 0.062 |
| B1/loop off | -80.7 | 0.000 | 0.00 | 0.054 |
| B2/loop on | -82.7 | 0.000 | 3.70 | 0.069 |
| B1/loop on | -110.1 | 0.000 | 9.60 | 0.045 |
**Best:** B3/loop off (weight 0.44). Anchor weights: B1 0.00, B2 0.38, B3 0.44, B4 0.18. Loop weights: off 1.00, on 0.00.
## EU
Series: Eurostat isoc_eb_ai, EU27_2020, reference years 2023-2025 (2021: 7.65, before generative AI, excluded)
| Candidate | log-likelihood | weight | t0 | K_Ω at window end |
|---|---|---|---|---|
| B3/loop off | -2.2 | 0.456 | 0.50 | 0.198 |
| B2/loop on | -2.4 | 0.366 | 0.00 | 0.107 |
| B3/loop on | -3.3 | 0.159 | 0.55 | 0.222 |
| B2/loop off | -5.6 | 0.016 | 0.00 | 0.098 |
| B4/loop off | -7.5 | 0.002 | 1.00 | 0.308 |
| B4/loop on | -11.4 | 0.000 | 1.05 | 0.356 |
| B1/loop on | -49.8 | 0.000 | 0.00 | 0.061 |
| B1/loop off | -58.0 | 0.000 | 0.00 | 0.058 |
**Best:** B3/loop off (weight 0.46). Anchor weights: B1 0.00, B2 0.38, B3 0.62, B4 0.00. Loop weights: off 0.47, on 0.53.
## DE
Series: Eurostat isoc_eb_ai, DE, reference years 2023-2025 (2021: 10.56, excluded)
| Candidate | log-likelihood | weight | t0 | K_Ω at window end |
|---|---|---|---|---|
| B2/loop on | -0.1 | 0.573 | 0.00 | 0.110 |
| B3/loop off | -1.3 | 0.178 | 0.65 | 0.214 |
| B2/loop off | -1.4 | 0.152 | 0.00 | 0.101 |
| B3/loop on | -1.9 | 0.097 | 0.70 | 0.240 |
| B4/loop off | -8.3 | 0.000 | 1.35 | 0.370 |
| B4/loop on | -11.0 | 0.000 | 1.35 | 0.415 |
| B1/loop on | -36.2 | 0.000 | 0.00 | 0.063 |
| B1/loop off | -43.4 | 0.000 | 0.00 | 0.059 |
**Best:** B2/loop on (weight 0.57). Anchor weights: B1 0.00, B2 0.73, B3 0.27, B4 0.00. Loop weights: off 0.33, on 0.67.

View file

@ -0,0 +1,37 @@
# GADM-0.4: matching and flip detection on synthetic data
Generated by `simulations/gadm02/matching.py --synthetic` (ASSEMBLY-WP-0012-T06).
US calibrated state. Truth simulated from a known candidate (optionally switching regime at
t_switch), observed biweekly as 25 × K_Ω with log-noise sd 0.03, from t 2 to t 8. Quarterly
regime filter: the first year fixes the initial state and the scale (whole-path alignment); then
every quarter each regime runs forward from the current state estimate over the last quarter, and
the estimate advances with the best regime. Weights accumulate across quarters (forward filter,
switch probability 0.02 per quarter). Level evidence: true K_Ω ± 0.075 (as wide as the real
US target). Flip: new best with weight ≥ 0.75 twice in a row.
| Indicators | Truth | Seed | Best at first evaluation | Flips detected (t, from → to) | Verdict |
|---|---|---|---|---|---|
| adoption | B3/loop off | 1 | B3/loop off | none | ok (no false flip) |
| adoption | B3/loop off | 2 | B3/loop off | 4.50: B3/loop off → B4/loop off; 7.75: B4/loop off → B3/loop off | FALSE FLIP |
| adoption | B3/loop off | 3 | B3/loop off | 4.75: B4/loop off → B3/loop off | FALSE FLIP |
| adoption + annual sentiment | B3/loop off | 1 | B3/loop off | none | ok (no false flip) |
| adoption + annual sentiment | B3/loop off | 2 | B3/loop off | 4.50: B3/loop off → B4/loop off; 7.75: B4/loop off → B3/loop off | FALSE FLIP |
| adoption + annual sentiment | B3/loop off | 3 | B3/loop off | 4.75: B4/loop off → B3/loop off | FALSE FLIP |
| adoption | B2/loop off | 1 | B4/loop on | none | ok (no false flip) |
| adoption | B2/loop off | 2 | B3/loop on | 8.00: B3/loop on → B2/loop off | FALSE FLIP |
| adoption | B2/loop off | 3 | B3/loop on | none | ok (no false flip) |
| adoption + annual sentiment | B2/loop off | 1 | B4/loop on | none | ok (no false flip) |
| adoption + annual sentiment | B2/loop off | 2 | B3/loop on | none | ok (no false flip) |
| adoption + annual sentiment | B2/loop off | 3 | B3/loop on | 8.00: B2/loop off → B4/loop on | FALSE FLIP |
| adoption | B2/loop off → B4/loop off at t 4.0 | 1 | B4/loop on | none | missed |
| adoption | B2/loop off → B4/loop off at t 4.0 | 2 | B3/loop on | none | missed |
| adoption | B2/loop off → B4/loop off at t 4.0 | 3 | B3/loop on | none | missed |
| adoption + annual sentiment | B2/loop off → B4/loop off at t 4.0 | 1 | B4/loop on | none | missed |
| adoption + annual sentiment | B2/loop off → B4/loop off at t 4.0 | 2 | B3/loop on | 4.50: B2/loop off → B4/loop off | detected, delay 0.50 y |
| adoption + annual sentiment | B2/loop off → B4/loop off at t 4.0 | 3 | B3/loop on | none | missed |
| adoption | B3/loop off → B3/loop on at t 4.0 | 1 | B3/loop off | none | missed |
| adoption | B3/loop off → B3/loop on at t 4.0 | 2 | B3/loop off | none | missed |
| adoption | B3/loop off → B3/loop on at t 4.0 | 3 | B3/loop off | none | missed |
| adoption + annual sentiment | B3/loop off → B3/loop on at t 4.0 | 1 | B3/loop off | none | missed |
| adoption + annual sentiment | B3/loop off → B3/loop on at t 4.0 | 2 | B3/loop off | 8.00: B3/loop off → B4/loop on | missed |
| adoption + annual sentiment | B3/loop off → B3/loop on at t 4.0 | 3 | B3/loop off | 7.25: B4/loop off → B3/loop on | detected, delay 3.25 y |

View file

@ -0,0 +1,29 @@
"""Tests for the matching engine (ASSEMBLY-WP-0012)."""
import math
import unittest
import gadm04 as g
import matching as m
class TestMatching(unittest.TestCase):
def test_fit_recovers_alignment_and_scale(self):
rows = g.simulate(T=m.T_PATH, record_every=1)
pts = [(tau, 25.0 * m.interp(rows, 3.0 + tau, "Ko")) for tau in (0.0, 0.25, 0.5, 0.75, 1.0)]
ll, t0, ls = m.fit_series(rows, pts, 0.03)
self.assertAlmostEqual(t0, 3.0, places=6)
self.assertAlmostEqual(math.exp(ls), 25.0, places=4)
self.assertGreater(ll, -1e-6)
def test_flip_needs_two_consecutive_strong_evaluations(self):
a, b = ("B2", "off"), ("B3", "off")
def ev(t, wa):
return (t, {a: {"w": wa}, b: {"w": 1 - wa}})
self.assertEqual(m.flips([ev(0, 0.9), ev(1, 0.1), ev(2, 0.9)]), [])
self.assertEqual(m.flips([ev(0, 0.9), ev(1, 0.1), ev(2, 0.1)]), [(2, a, b)])
self.assertEqual(m.flips([ev(0, 0.5), ev(1, 0.1), ev(2, 0.1)]), []) # nothing established yet
if __name__ == "__main__":
unittest.main()

View file

@ -4,7 +4,7 @@ type: workplan
title: "Model matching and regime flipping"
domain: communication
repo: assembly-economics
status: ready
status: finished
flavor: implementation
depends_on: [ASSEMBLY-WP-0013, ASSEMBLY-WP-0015, ASSEMBLY-WP-0010]
owner: claude-code
@ -43,11 +43,13 @@ reality-aligned baseline.
```task
id: ASSEMBLY-WP-0012-T01
status: todo
status: done
priority: high
state_hub_task_id: "e2c8b5f3-67aa-5f69-b654-b589d990105e"
```
**Done 2026-10-13:** `research/ModelMatching.md` § 1; candidates B1–B4 × loop off/on; likelihood with level term.
Start with a small candidate set that the data can already separate:
anchors B2, B3, B4 × legitimacy loop off/on × the fitted adoption speed k
from `calibrate_data.py`. Keep B1 as a candidate with its current misfit
@ -63,11 +65,13 @@ Write `research/ModelMatching.md`.
```task
id: ASSEMBLY-WP-0012-T02
status: todo
status: done
priority: high
state_hub_task_id: "a3eae0d5-c5cf-5af2-b419-ab72dfd504f7"
```
**Done 2026-10-13:** `matching.py`, `observed_series.json`, `results/matching.md`. US B3/B2 with loop off; EU B3/B2; DE B2. No regime established (weights < 0.75).
Build on `calibrate_data.py` (its alignment by growth and the z-scores per
target are the first score). Implement `matching.py`: for each jurisdiction, fit each candidate to the
checkpoint series (using the WP-0013 calibration routine), score it, and
@ -79,11 +83,13 @@ checkpoint.
```task
id: ASSEMBLY-WP-0012-T03
status: todo
status: done
priority: high
state_hub_task_id: "a7a9a42a-8703-56c8-ab0d-b7505e22dfef"
```
**Done 2026-10-13:** flip definition and regime filter (`research/ModelMatching.md` § 3), `research/FlipRegister.md` (empty), `test_matching.py`.
First flip candidates to watch: B3 → B2 (an adoption slowdown in the BTOS
series, compare P-2026-001) and loop off → on (sentiment falling faster than
adoption). Define a flipping event: the best-fitting regime changes **and** the
@ -97,11 +103,13 @@ flip register (`research/FlipRegister.md`), linked to the break register.
```task
id: ASSEMBLY-WP-0012-T04
status: todo
status: done
priority: medium
state_hub_task_id: "20c370c2-2989-54ba-b3a4-0ba056532f88"
```
**Done 2026-10-13:** matched weights bracket the v1 assumption (B2–B3); no predictions v2 needed.
Use the best-fitting regime, or a weighted ensemble, for the prediction
register (`checkpoints/predictions.md`). Each prediction states which
regime produced it. After a flip, new predictions use the new regime; old
@ -111,11 +119,13 @@ predictions remain and are scored as they were made.
```task
id: ASSEMBLY-WP-0012-T05
status: todo
status: cancel
priority: medium
state_hub_task_id: "88940a83-d078-5346-94da-d49de570f4f3"
```
**Moved 2026-10-13 to ASSEMBLY-WP-0017-T05** (waits for a reliable filter).
Run matching at every full checkpoint (and optionally at light ones), and
report the current regime per jurisdiction and any flip candidates in the
checkpoint record. Coordinate the runner change with WP-0010.
@ -124,12 +134,22 @@ checkpoint record. Coordinate the runner change with WP-0010.
```task
id: ASSEMBLY-WP-0012-T06
status: todo
status: done
priority: high
state_hub_task_id: "3a3ec337-322e-5b4c-86f7-db3cfe463d6d"
```
**Done 2026-10-13, negative result:** flip detection is not yet reliable (3 of 6 false flips, at most 2 of 6 switches detected). Requirements in `research/ModelMatching.md` § 4; work in ASSEMBLY-WP-0017.
Before trusting real flips: generate synthetic indicator series from a known
regime that switches at a known time, add realistic noise and gaps, and
check that matching recovers the regime and detects the flip with an
acceptable delay and false-alarm rate.
## Close-out 2026-10-13
T01–T04 and T06 are done (`research/ModelMatching.md`, `matching.py`,
`research/FlipRegister.md`, BR-009). The synthetic test shows that flip
detection is not yet reliable. The residuals are handed off to
ASSEMBLY-WP-0017: the sentiment equation, more indicators, a per-regime
filter, passing the acceptance test, and checkpoint integration.

View file

@ -0,0 +1,89 @@
---
id: ASSEMBLY-WP-0017
type: workplan
title: "Reliable flip detection: indicators, per-regime filter, sentiment equation"
domain: communication
repo: assembly-economics
status: proposed
flavor: residual
depends_on: [ASSEMBLY-WP-0012]
owner: claude-code
topic_slug: coulomb-social
created: "2026-10-13"
updated: "2026-10-13"
---
# Reliable flip detection
Residuals of ASSEMBLY-WP-0012. Matching on observed data works
(`research/ModelMatching.md` § 2). Flip detection does not yet pass its
synthetic test (§ 4): 3 of 6 false flips, at most 2 of 6 switches detected.
No flip may be recorded in `research/FlipRegister.md` until T04 passes.
## Fix the sentiment equation (BR-009)
```task
id: ASSEMBLY-WP-0017-T01
status: todo
priority: high
```
Σ relaxes toward 0.5 + gain × Δ(perceived benefit), so from the measured US
level (0.285) it rises, while measured US sentiment fell 2021–2026. Add a
sentiment reference parameter: neutral 0.5 (the default, keeps old results)
or anchored to the observed level. Fit `sig_adopt` (and an optional lag) to
the US finding that adoption grows fast despite low sentiment. Re-run
`legitimacy.py`, `calibrated_readings.py` and `world_scenarios.py`, and
record which readings change.
## More discriminating indicators in the filter
```task
id: ASSEMBLY-WP-0017-T02
status: todo
priority: high
```
Add to `filter_step` and to `observed_series.json`: investment (L5,
quarterly; separates self-enhancement and B4), concentration channels (CB1,
monthly; domination dynamics), and sentiment at least twice a year where
surveys allow it. Each indicator needs an observation operator and a noise
level.
## Per-regime state tracking
```task
id: ASSEMBLY-WP-0017-T03
status: todo
priority: medium
```
Today the filter carries one shared state estimate, advanced by the best
regime. Track one state per regime (interacting multiple-model filter), so
that a regime that becomes true later is not judged from another regime's
state.
## Pass the synthetic acceptance test
```task
id: ASSEMBLY-WP-0017-T04
status: todo
priority: high
```
Acceptance: at least 5 of 6 regimes correct at the first evaluation, no false
flips in 6 no-switch runs, and at least 4 of 6 switches detected within 1.5
years (`matching.py --synthetic`). Add the criteria as a slow test.
## Checkpoint integration (from WP-0012-T05)
```task
id: ASSEMBLY-WP-0017-T05
status: wait
priority: medium
```
Waits for T04. Then run matching at every full checkpoint (optionally at
light ones), report the current regime and any flip candidates in the
checkpoint record, and coordinate the runner change with WP-0014 (governed
execution profile).