ASSEMBLY-WP-0017 finished: IMM filter on observed US data (B3 through mid-2026, B3→B4 flip candidate Q3 2026, not confirmed), robustness, checkpoint pointer; residual to WP-0015-T07

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 22:41:08 +02:00
parent 97d0fb71a9
commit 945b28e6ab
8 changed files with 239 additions and 16 deletions

View file

@ -159,6 +159,9 @@ def render(day: date, kind: str, phase: str, obs: list[dict], assess: str | None
"## Follow-ups", "",
"- Band positions against registered predictions (`predictions.md`) are judged at review"
if preds else "- No predictions registered yet; band positions follow once the register has entries.",
*(["- Current regime per jurisdiction and flip candidates: `simulations/gadm02/results/matching.md`"
" (model matching, re-run at the review of this checkpoint; not computed by this runner)"]
if kind == "full" else []),
""]
return "\n".join(lines)

View file

@ -35,6 +35,14 @@ class TestPredictions(unittest.TestCase):
self.assertEqual(rc.band_position(preds, "L9", "US"), "no prediction registered")
class TestRegimePointer(unittest.TestCase):
def test_full_record_points_to_regime_report(self):
full = rc.render(date(2027, 1, 15), "full", "steady", [], None, "x", "d.jsonl")
light = rc.render(date(2027, 1, 15), "light", "steady", [], None, "x", "d.jsonl")
self.assertIn("results/matching.md", full)
self.assertNotIn("results/matching.md", light)
class TestNumberGuard(unittest.TestCase):
def test_numbers_extraction(self):
self.assertEqual(rc._numbers("EU at 19.95 % in 2025, US 23.8."), {"19.95", "2025", "23.8"})

View file

@ -3,9 +3,10 @@
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
**Status 2026-10-13:** no entries. The filter passed its synthetic test
(`research/ModelMatching.md` § 4.2). Its observation noise for real data is
not yet calibrated (WP-0015-T07), so a candidate on the watch list needs
confirmation at the next review. Simulated flips (regime crossings inside model runs) are
recorded in the break register until then (for example BR-005).
## Entry format
@ -17,6 +18,16 @@ recorded in the break register until then (for example BR-005).
- Consequences: predictions superseded (ids), break-register links
```
## Watch list (candidates, not entries)
Candidates seen in one evaluation. They become entries only when the rule in
`research/ModelMatching.md` § 3 is met (new anchor ≥ 0.75 in two consecutive
quarters, filter acceptance passed).
| Seen | Jurisdiction | Candidate | Evidence | Check at |
|---|---|---|---|---|
| 2026-10-13 (data to Q3 2026) | US | B3 → B4 | IMM last quarter B4 1.00 (noise × 2: 0.96; × 3: 0.42; without investment 0.69); BTOS 21.8 → 23.8 % in two months; data-center construction +20 % in four months | next review with Q4 2026 data |
## Entries
(none)

View file

@ -1,10 +1,10 @@
# Model matching and regime flipping
**Status:** v0.3 (2026-10-13), ASSEMBLY-WP-0012 and WP-0017. The IMM filter
passes its synthetic acceptance test (§ 4.2) under two caveats: a perfect
model, and an investment indicator that the real data provide only roughly.
Measured flips may be declared only after an AI-specific investment series is
in place (WP-0017-T06).
**Status:** v0.4 (2026-10-13), ASSEMBLY-WP-0012 and WP-0017. The IMM filter
passes its synthetic acceptance test (§ 4.2). It runs on the observed US
series (§ 4.3). There it is overconfident: its observation noise is not yet
calibrated for real data. Its first output is a flip **candidate**
(B3 → B4), not a flip.
Code: `simulations/gadm02/matching.py`. Data: `observed_series.json`.
Results: `results/matching.md` (observed), `results/matching_synthetic.md`
@ -182,3 +182,49 @@ Caveats:
3. **The loop state stays undetectable while sentiment is flat.** Loop flips
are therefore not declared from the filter at all until a sentiment shock
gives evidence.
### 4.3 The filter on observed US data (WP-0017-T05)
`matching.py` now also runs the IMM filter on the observed US series
(`results/matching.md`).
- **Calendar:** t 0 = 2025-08-01.
- **Series:** adoption (BTOS, biweekly, 22 points after the start),
investment (L5b, Census private office construction including data centers,
monthly) and discovery (StatCounter, monthly).
- **Start:** the static matching weights.
| Calendar t (quarter) | B2 | B3 | B4 |
|---|---|---|---|
| 0.32 (late Nov 2025, start) | 0.38 | 0.40 | 0.21 |
| 0.57 | 0.00 | 1.00 | 0.00 |
| 0.82 | 0.00 | 1.00 | 0.00 |
| 1.07 (Q3 2026) | 0.00 | 0.00 | **1.00** |
Robustness of the last quarter: noise × 2: B4 0.96; noise × 3: B3 0.58 /
B4 0.42; without investment: B4 0.69. The search share does not affect the
result.
Readings:
1. **The US was in B3 from early to mid-2026.** B2 drops out after the first
quarter in all variants.
2. **Q3 2026 shows a move toward B4: a flip candidate, not a flip.** Two
things drive it. Adoption rose from 21.8 % to 23.8 % within about two
months, and data-center construction keeps accelerating (L5b +20 % in four
months). The move shows without the investment series too (0.69). It is not
robust to 3× noise. The rule needs a second quarter at ≥ 0.75, so it is
checked at the next review (`research/FlipRegister.md`, watch list).
3. **The filter is overconfident on real data.** Weights jump to 1.00 within
one quarter, which a perfect-model noise level allows but real model error
does not justify. Calibrating the observation noise against real model
residuals is the next step (ASSEMBLY-WP-0015-T07).
4. **Meaning for predictions:** a confirmed B4 regime would move the US
adoption path of P-2026-001 toward the B4 band. That band peaks early and
predicts a plateau or decline by 2030. Predictions are not changed on a
candidate.
**Checkpoint integration.** Full checkpoint records point to
`results/matching.md`, which is re-run at the review of each checkpoint. The
unattended runner does not compute regimes, so the approved execution
declaration stays unchanged.

View file

@ -45,12 +45,21 @@ T_PATH = 30.0
FLIP_WEIGHT = 0.75
SWITCH = 0.02 # prior probability per quarter that the regime changes (filter)
_CACHE: dict = {}
_START: dict = {}
def start(jur: str, anchor: str) -> tuple[dict, dict]:
"""cd.start_state, cached (it is deterministic and slow)."""
if (jur, anchor) not in _START:
_START[(jur, anchor)] = cd.start_state(jur, anchor)
p, s = _START[(jur, anchor)]
return dict(p), dict(s)
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)
p, s = start(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]
@ -183,6 +192,7 @@ def main_observed() -> None:
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}.", ""]
lines += observed_imm_lines()
(OUT / "matching.md").write_text("\n".join(lines))
print("\n".join(lines))
@ -207,7 +217,7 @@ _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])
p, _s = start(jur, cand[0])
_PARAMS[(jur, cand)] = {**p, "sent": 1.0 if cand[1] == "on" else 0.0}
return _PARAMS[(jur, cand)]
@ -337,10 +347,14 @@ NOISE = {"adopt": ("log", 0.03), "invest": ("log", 0.05), "pd": ("abs", 0.01), "
CADENCE = {"adopt": 2 / 52, "invest": 0.25, "pd": 1 / 12, "sig": 0.5}
NOISE_FACTOR = 1.0 # > 1 widens all observation noise (robustness against model error on real data)
def obs_ll(rows: list[dict], obs: list, scales: dict) -> float:
ll = 0.0
for tau, kind, y in obs:
mode, sd = NOISE[kind]
sd *= NOISE_FACTOR
m_ = observe(rows, tau, kind)
if mode == "log":
ll += -0.5 * ((math.log(y) - math.log(max(1e-9, m_)) - scales[kind]) / sd) ** 2
@ -388,18 +402,25 @@ def imm_run(truth: list[dict], start: float, years: float, seed: int, kinds: tup
i = min(len(rc) - 1, int(round((first[c]["t0"] + 1.0) / STEP)))
states[c] = {k: rc[i][k] for k in g.STOCKS}
w = {c: first[c]["w"] for c in CANDIDATES}
evals = [(te0, {c: {"w": w[c]} for c in CANDIDATES})]
return imm_core("US", obs, states, w, scales, te0, start + years, step, switch)
def imm_core(jur: str, obs: list, states: dict, w: dict, scales: dict, t_from: float, t_to: float,
step: float = 0.25, switch: float = None) -> list[tuple[float, dict]]:
"""IMM steps from t_from to t_to: mix regime states, run each regime, update weights."""
switch = IMM_SWITCH if switch is None else switch
evals = [(t_from, {c: {"w": w[c]} for c in CANDIDATES})]
n = len(CANDIDATES)
pi = {(i, c): (1 - switch) if i == c else switch / (n - 1) for i in CANDIDATES for c in CANDIDATES}
te = te0 + step
while te <= start + years + 1e-9:
te = t_from + step
while te <= t_to + 1e-9:
window = [(t - (te - step), k, y) for t, k, y in obs if te - step < t <= te + 1e-9]
cbar = {c: sum(pi[(i, c)] * w[i] for i in CANDIDATES) for c in CANDIDATES}
lls, ends = {}, {}
for c in CANDIDATES:
mu = {i: pi[(i, c)] * w[i] / cbar[c] for i in CANDIDATES}
x0 = {k: sum(mu[i] * states[i][k] for i in CANDIDATES) for k in g.STOCKS}
rows = g.simulate(regime_params("US", c), x0, T=step, record_every=1)
rows = g.simulate(regime_params(jur, c), x0, T=step, record_every=1)
lls[c] = obs_ll(rows, window, scales)
ends[c] = {k: rows[-1][k] for k in g.STOCKS}
mx = max(lls.values())
@ -462,6 +483,79 @@ def main_imm() -> None:
print("\n".join(lines))
# --- WP-0017-T05: IMM filter on the observed US series --------------------------------------------
CAL = {"US_adoption": 0.32, "US_dc_construction": 0.0, "US_discovery": 1 / 6} # calendar offsets, t 0 = 2025-08-01
KIND = {"US_adoption": "adopt", "US_dc_construction": "invest", "US_discovery": "pd"}
def observed_imm(jur: str = "US") -> tuple[list[tuple[float, dict]], dict]:
"""Run the IMM filter on the observed series. Returns (evaluations, info)."""
obs = []
for name, off in CAL.items():
for tau, y in SERIES[name]["points"]:
obs.append((off + tau, KIND[name], y))
adop = SERIES["US_adoption"]
static = score(jur, path, adop, level=cd.targets(jur)["Ko"])
b = best(static)
t_from = CAL["US_adoption"]
def model_t(c, t):
return static[c]["t0"] + (t - t_from)
rb = path(jur, *b)
scales = {"adopt": static[b]["ls"]}
inv = [(t, y) for t, k, y in obs if k == "invest" and model_t(b, t) >= STEP]
if inv:
scales["invest"] = sum(math.log(y) - math.log(observe(rb, model_t(b, t), "invest")) for t, y in inv) / len(inv)
states = {}
for c in CANDIDATES:
rc = path(jur, *c)
i = min(len(rc) - 1, max(0, int(round(model_t(c, t_from) / STEP))))
states[c] = {k: rc[i][k] for k in g.STOCKS}
w = {c: static[c]["w"] for c in CANDIDATES}
t_to = max(t for t, _k, _y in obs)
evals = imm_core(jur, [o for o in obs if o[0] > t_from], states, w, scales, t_from, t_to)
return evals, {"start_best": b, "scales": scales, "t_from": t_from, "t_to": t_to,
"n_obs": {k: sum(1 for o in obs if o[1] == k and o[0] > t_from) for k in ("adopt", "invest", "pd")}}
def observed_imm_lines() -> list[str]:
evals, info = observed_imm("US")
lines = ["## US: IMM regime filter on the observed series (WP-0017-T05)", "",
"Calendar t 0 = 2025-08-01. Indicators: adoption (BTOS, biweekly), investment (L5b, Census private",
"office construction incl. data centers, monthly), discovery channel (StatCounter search share,",
f"monthly). Observations after the start: {info['n_obs']}. Start: static matching weights; quarterly",
f"IMM steps from t {info['t_from']:.2f} to {info['t_to']:.2f}. Flips judged on anchor weights.", "",
"| Calendar t | B1 | B2 | B3 | B4 | loop off | loop on |", "|---|---|---|---|---|---|---|"]
for t, res in evals:
an = marginal(res, 0)
lp = marginal(res, 1)
lines.append(f"| {t:.2f} | " + " | ".join(f"{an[(a,)]['w']:.2f}" for a in ("B1", "B2", "B3", "B4"))
+ f" | {lp[('off',)]['w']:.2f} | {lp[('on',)]['w']:.2f} |")
global NOISE_FACTOR, CAL
rob = []
for label, factor, drop in (("noise × 2", 2.0, None), ("noise × 3", 3.0, None), ("without investment", 1.0, "US_dc_construction")):
keep_f, keep_cal = NOISE_FACTOR, CAL
NOISE_FACTOR = factor
CAL = {k: v for k, v in CAL.items() if k != drop}
ev2, _i = observed_imm("US")
NOISE_FACTOR, CAL = keep_f, keep_cal
an2 = marginal(ev2[-1][1], 0)
rob.append(f"| {label} | " + " | ".join(f"{an2[(a,)]['w']:.2f}" for a in ("B1", "B2", "B3", "B4")) + " |")
lines += ["", "Robustness of the last evaluation (anchor weights):", "", "| Variant | B1 | B2 | B3 | B4 |",
"|---|---|---|---|---|", *rob]
fl = flips(evals, by=0)
an = marginal(evals[-1][1], 0)
top = max(an, key=lambda k: an[k]["w"])
prev = marginal(evals[-2][1], 0)
ptop = max(prev, key=lambda k: prev[k]["w"])
lines += ["", f"**Last evaluation (US):** anchor {top[0]} with weight {an[top]['w']:.2f}; previous quarter "
f"{ptop[0]} {prev[ptop]['w']:.2f}. A flip needs the new anchor at ≥ {FLIP_WEIGHT} in two consecutive "
"quarters; a change seen in one quarter is a **flip candidate** to check at the next review."
+ (" Confirmed flips: " + "; ".join(f"{t:.2f}: {a[0]} → {b_[0]}" for t, a, b_ in fl) if fl else
" Confirmed flips: none."), ""]
return lines
if __name__ == "__main__":
if "--imm" in sys.argv:
main_imm()

View file

@ -56,3 +56,27 @@ Series: Eurostat isoc_eb_ai, DE, reference years 2023-2025 (2021: 10.56, exclude
| B1/loop on | -43.5 | 0.000 | 0.00 | 0.059 |
**Best:** B3/loop on (weight 0.29). Anchor weights: B1 0.00, B2 0.44, B3 0.56, B4 0.00. Loop weights: off 0.49, on 0.51.
## US: IMM regime filter on the observed series (WP-0017-T05)
Calendar t 0 = 2025-08-01. Indicators: adoption (BTOS, biweekly), investment (L5b, Census private
office construction incl. data centers, monthly), discovery channel (StatCounter search share,
monthly). Observations after the start: {'adopt': 22, 'invest': 5, 'pd': 10}. Start: static matching weights; quarterly
IMM steps from t 0.32 to 1.17. Flips judged on anchor weights.
| Calendar t | B1 | B2 | B3 | B4 | loop off | loop on |
|---|---|---|---|---|---|---|
| 0.32 | 0.00 | 0.38 | 0.40 | 0.21 | 0.52 | 0.48 |
| 0.57 | 0.00 | 0.00 | 1.00 | 0.00 | 0.70 | 0.30 |
| 0.82 | 0.00 | 0.00 | 1.00 | 0.00 | 0.77 | 0.23 |
| 1.07 | 0.00 | 0.00 | 0.00 | 1.00 | 0.49 | 0.51 |
Robustness of the last evaluation (anchor weights):
| Variant | B1 | B2 | B3 | B4 |
|---|---|---|---|---|
| noise × 2 | 0.00 | 0.00 | 0.04 | 0.96 |
| noise × 3 | 0.00 | 0.00 | 0.58 | 0.42 |
| without investment | 0.00 | 0.00 | 0.31 | 0.69 |
**Last evaluation (US):** anchor B4 with weight 1.00; previous quarter B3 1.00. A flip needs the new anchor at ≥ 0.75 in two consecutive quarters; a change seen in one quarter is a **flip candidate** to check at the next review. Confirmed flips: none.

View file

@ -156,3 +156,24 @@ Waits for data that does not exist yet as of 2026-10-13.
Then re-calibrate (`models/Calibration.md` § 5) and check whether readings
R1–R4 change. Also verify the secondary-source figures marked in
`checkpoints/2026/2026-10-13-review.md` against their originals.
## Regime filter on real data: noise calibration and the B3 → B4 candidate
```task
id: ASSEMBLY-WP-0015-T07
status: todo
priority: high
```
From ASSEMBLY-WP-0017. The IMM filter is overconfident on observed data:
weights jump to 1.00 within a quarter (`research/ModelMatching.md` § 4.3).
1. Calibrate the observation noise per indicator from real residuals. Fit
the best regime to each series, and set the noise to the residual
standard deviation, including model error, not only sampling error.
2. At the next review with Q4 2026 data, re-run `matching.py`. Check the
watch-list candidate US B3 → B4 (`research/FlipRegister.md`): record a flip
only if the new anchor holds ≥ 0.75 for a second quarter under the
calibrated noise. Otherwise drop the candidate with a note.
3. If a flip is recorded, register predictions v2 from the new regime
(superseding P-2026-001, not editing it).

View file

@ -4,7 +4,7 @@ type: workplan
title: "Reliable flip detection: indicators, per-regime filter, sentiment equation"
domain: communication
repo: assembly-economics
status: active
status: finished
flavor: residual
depends_on: [ASSEMBLY-WP-0012]
owner: claude-code
@ -96,11 +96,19 @@ years (`matching.py --synthetic`). Add the criteria as a slow test.
```task
id: ASSEMBLY-WP-0017-T05
status: todo
status: done
priority: medium
state_hub_task_id: "93477fd0-6d90-51dd-a41a-75a22715bf29"
```
**Done 2026-10-13:** `observed_imm` runs the IMM filter on the observed US
series (`results/matching.md`, `research/ModelMatching.md` § 4.3). Full
checkpoint records point to the report, which is re-run at reviews. The runner
computes nothing new, so the execution declaration stays unchanged. First
output: US in B3 through mid-2026, flip **candidate** B3 → B4 in Q3 2026 (watch
list). The filter is overconfident on real data; noise calibration goes to
WP-0015-T07.
Unblocked 2026-10-13 (T04 passed, T06 provides L5b). First extend
`main_observed` to the IMM filter with the observed series (adoption, L5b,
discovery, sentiment once available), then run matching at every full
@ -130,3 +138,11 @@ closer to organism investment, for example hyperscaler capital expenditure
or a Census AI-spending question if one exists. Add it to `sources.json`
(if machine-readable) and `observed_series.json`. Then re-run
`matching.py --imm` with this series' realistic noise.
## Close-out 2026-10-13
T01–T06 are done: sentiment reference (BR-009), indicators, IMM filter,
acceptance test passed, L5b investment series, filter on observed data with
checkpoint pointer. Residuals go to ASSEMBLY-WP-0015-T07: calibrate the
observation noise for real data, and confirm or drop the B3 → B4 candidate at
the next review.