diff --git a/checkpoints/run_checkpoint.py b/checkpoints/run_checkpoint.py index c382b27..a7bdf0b 100644 --- a/checkpoints/run_checkpoint.py +++ b/checkpoints/run_checkpoint.py @@ -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) diff --git a/checkpoints/test_run_checkpoint.py b/checkpoints/test_run_checkpoint.py index db087d9..901adc0 100644 --- a/checkpoints/test_run_checkpoint.py +++ b/checkpoints/test_run_checkpoint.py @@ -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"}) diff --git a/research/FlipRegister.md b/research/FlipRegister.md index c92e9b6..9fd7a96 100644 --- a/research/FlipRegister.md +++ b/research/FlipRegister.md @@ -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) diff --git a/research/ModelMatching.md b/research/ModelMatching.md index 9443302..35dce0c 100644 --- a/research/ModelMatching.md +++ b/research/ModelMatching.md @@ -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. diff --git a/simulations/gadm02/matching.py b/simulations/gadm02/matching.py index 36eee95..b50c623 100644 --- a/simulations/gadm02/matching.py +++ b/simulations/gadm02/matching.py @@ -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() diff --git a/simulations/gadm02/results/matching.md b/simulations/gadm02/results/matching.md index 3c3c39b..4c23e5e 100644 --- a/simulations/gadm02/results/matching.md +++ b/simulations/gadm02/results/matching.md @@ -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. diff --git a/workplans/ASSEMBLY-WP-0015-first-checkpoint-review.md b/workplans/ASSEMBLY-WP-0015-first-checkpoint-review.md index 8457160..b90e3ae 100644 --- a/workplans/ASSEMBLY-WP-0015-first-checkpoint-review.md +++ b/workplans/ASSEMBLY-WP-0015-first-checkpoint-review.md @@ -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). diff --git a/workplans/ASSEMBLY-WP-0017-reliable-flip-detection.md b/workplans/ASSEMBLY-WP-0017-reliable-flip-detection.md index 9c4f289..d71076c 100644 --- a/workplans/ASSEMBLY-WP-0017-reliable-flip-detection.md +++ b/workplans/ASSEMBLY-WP-0017-reliable-flip-detection.md @@ -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.