diff --git a/README.md b/README.md
index ba8ddeb..38b2740 100644
--- a/README.md
+++ b/README.md
@@ -26,9 +26,9 @@ The protocol uses no words or alphabet, but four meaning-free local symbols. For
See [`docs/results-ecp6.md`](docs/results-ecp6.md) for the conclusion, [`docs/protocol-specification-ecp6.md`](docs/protocol-specification-ecp6.md) for the wire format and [`evidence/ecp6/report.md`](evidence/ecp6/report.md) for the compact confirmatory evidence.
ECP-7 development now tests how much of that result depends on the explicit
-one-factor-per-slot architecture. Its first nineteen sealed batches are valid
-development results but none passes the full gate. Batch 15 remains strongest:
-the position-aware design at 30,000 optimization steps reaches 83.46% train
+one-factor-per-slot architecture. Its first twenty sealed batches are valid
+development results but none passes the full gate. Batch 15 established the
+strong position-aware base: at 30,000 optimization steps it reaches 83.46% train
exactness, 82.59% validation and 83.37% translator validation while using
12,585–13,200 hard messages per sender. Validation and translator thresholds pass
together, but the registered train and injectivity thresholds do not. Batch 16
@@ -39,7 +39,9 @@ recovering 80.71% validation and a new-best 84.06% translator score, but train
exactness and injectivity still failed. Batch 19 bounded replay to a pulse and
reached 82.04% validation plus a new-best 80.57% worst-link validation, but
again failed train exactness and injectivity. The ECP-6 positive controls remain
-perfect and the ECP-7 confirmatory test remains sealed. See
+perfect. Batch 20 replayed population-hard training meanings and established
+new-best 83.45% mean and 82.13% worst-link validation, but again failed train
+exactness and injectivity. The ECP-7 confirmatory test remains sealed. See
[`docs/development-log-ecp7.md`](docs/development-log-ecp7.md).
## Build on this work
@@ -99,6 +101,7 @@ Do not translate those artifacts in place. New experiment configurations and all
- [`config/ecp7-b17-development.yaml`](config/ecp7-b17-development.yaml) — late training-only global collision-pair replay on the Batch 15 base.
- [`config/ecp7-b18-development.yaml`](config/ecp7-b18-development.yaml) — the Batch 17 replay mechanism with final weight reduced to 0.1.
- [`config/ecp7-b19-development.yaml`](config/ecp7-b19-development.yaml) — the Batch 18 replay mechanism decayed back to zero after step 20,000.
+- [`config/ecp7-b20-development.yaml`](config/ecp7-b20-development.yaml) — late ordinary task replay on population-hard training meanings.
- [`config/ecp7-positive-control-development.yaml`](config/ecp7-positive-control-development.yaml) — ECP-6 positive control on the ECP-7 split.
- [`config/ecp0.yaml`](config/ecp0.yaml) — machine-readable configuration from step 1.
- [`config/ecp1.yaml`](config/ecp1.yaml) — frozen configuration of the population trial.
diff --git a/config/ecp7-b20-development.yaml b/config/ecp7-b20-development.yaml
new file mode 100644
index 0000000..cdb6391
--- /dev/null
+++ b/config/ecp7-b20-development.yaml
@@ -0,0 +1,19 @@
+extends: ecp7-b15-development.yaml
+
+experiment:
+ title: ECP-7 late global hard-meaning task replay
+ arm: late_global_hard_meaning_task_replay
+ objective: >-
+ Test whether ordinary reconstruction updates concentrated on training
+ meanings failed by any current population link can repair the residual
+ known-meaning bottleneck without a meaning-to-slot binding, factor weight,
+ code-distance surrogate, or validation-driven sampling.
+
+training:
+ global_hard_meaning_replay:
+ enabled: true
+ weight: 0.25
+ start_step: 15000
+ warmup_steps: 5000
+ batch_size: 64
+ refresh_interval: 200
diff --git a/docs/AI_AGENT_START.md b/docs/AI_AGENT_START.md
index 7898b93..8762c36 100644
--- a/docs/AI_AGENT_START.md
+++ b/docs/AI_AGENT_START.md
@@ -41,7 +41,7 @@ python3.12 -m venv .venv
.venv/bin/ecp6 --config config/ecp6.yaml validate
```
-Expected baseline: 105 passing tests and split sizes `14336/1024/1024`.
+Expected baseline: 109 passing tests and split sizes `14336/1024/1024`.
## 4. Reproduce ECP-6
@@ -71,7 +71,7 @@ This reruns a known experiment. It must not be presented as a new independent co
## 5. Continue ECP-7 scientifically
-Do not modify ECP-0 through ECP-6 or the completed ECP-7 Batch 1 through Batch 19
+Do not modify ECP-0 through ECP-6 or the completed ECP-7 Batch 1 through Batch 20
configs. ECP7-B1-I collapsed to 130–139 hard messages. ECP7-B2-I improved its
soft objective but collapsed further to 85–104 hard messages. ECP7-B3-I applied
that loss to straight-through hard messages and improved to 585–972 messages,
@@ -112,18 +112,25 @@ validation and 83.01% translator validation without becoming injective. The
confirmatory ECP-7 test is still sealed.
ECP7-B18-I reduced replay weight to `0.1` and recovered 84.08% train, 80.71%
validation and a new-best 84.06% translator validation. Validation and
-translator gates pass, but train and injectivity do not. Batch 15 retains the
-best validation result, and the confirmatory ECP-7 test remains sealed.
+translator gates pass, but train and injectivity do not. Batch 15 retained the
+best validation result at that stage, and the confirmatory ECP-7 test remains
+sealed.
ECP7-B19-I decayed replay back to zero from steps 20,000–25,000. It reached
83.74% train, 82.04% validation, a new-best 80.57% worst-link validation and
83.50% translator validation. The bounded pulse improved cross-link balance but
-still failed train thresholds and injectivity. Batch 15 retains the best mean
-validation result, and the confirmatory ECP-7 test remains sealed.
+still failed train thresholds and injectivity. Batch 15 retained the best mean
+validation result at that stage, and the confirmatory ECP-7 test remains
+sealed.
+ECP7-B20-I replayed ordinary task updates on training meanings failed by any
+population link. It reached 83.77% train, a new-best 83.45% mean and 82.13%
+worst-link validation, and 83.96% translator validation. The pool shrank but its
+remaining errors became more population-wide, so train thresholds and
+injectivity still failed. The confirmatory ECP-7 test remains sealed.
Continue with this sequence:
1. Read `docs/research-design-ecp7.md` and `docs/development-log-ecp7.md`.
-2. Define exactly one ECP7-B20 intervention and its failure criterion.
+2. Define exactly one ECP7-B21 intervention and its failure criterion.
3. Register the variant and immutable configuration hashes before training.
4. Add tests for every new invariant.
5. Use only `smoke` and `develop`; keep the ECP-7 test split sealed.
@@ -136,27 +143,25 @@ Continue with this sequence:
## 6. Recommended next experiment
-Batches 17–19 show that collision-pair replay changes code use but does not
-stably separate the remaining collisions. Batch 19 improves validation only
-after replay is removed, while known-meaning train exactness remains near 84%.
-The clean next question is whether the residual error is concentrated in a
-sparse set of hard training meanings that uniform task batches do not repair.
-A clean ECP7-B20 progression is:
-
-- keep the same world, bit budget, Batch 15 architecture and 30,000-step horizon;
-- do not enable collision replay or factor reweighting;
-- periodically evaluate only the training codebook and deterministically mine
- meanings reconstructed incorrectly by the current population;
-- add ordinary reconstruction updates on one fixed, registered number of mined
- meanings after step 15,000, using a separate seed-derived sampler;
-- keep the ordinary random task batch and all base objectives unchanged;
+Batch 20 shows that ordinary task replay on any-link errors improves
+generalization and cross-link balance, but mostly removes link-specific errors.
+At its selected checkpoint, 1,937 of 2,848 hard meanings fail for all 16 links,
+up from 1,469 of 3,406 at the Batch 15 checkpoint. The clean next question is
+whether the fixed replay budget should target only those population-shared
+errors. A clean ECP7-B21 progression is:
+
+- inherit the complete Batch 20 implementation and Batch 15 base;
+- change only the mining predicate from at least one failed link to all 16
+ failed links;
+- keep weight `0.25`, the 64-meaning uniform replay batch, 200-step refresh,
+ seed-derived sampler, warmup and horizon unchanged;
+- continue to mine and replay training meanings only;
- measure train exactness, injectivity, validation composition and new-reader
induction, and rerun the frozen ECP-6 positive control.
-Preregister the mining score, refresh interval, replay batch size, coefficient
-and schedule before training. Do not combine hard-meaning replay with collision
-pressure, a new architecture, factor-specific weights, optimizer changes,
-longer training, variable-length messages or negotiation.
+Do not combine the predicate change with another coefficient, graded sampling,
+collision pressure, a new architecture, factor-specific weights, optimizer
+changes, longer training, variable-length messages or negotiation.
## 7. Definition of done for any contribution
diff --git a/docs/development-log-ecp7.md b/docs/development-log-ecp7.md
index 232e13d..2c99fa7 100644
--- a/docs/development-log-ecp7.md
+++ b/docs/development-log-ecp7.md
@@ -27,6 +27,7 @@ this log.
| ECP7-B17-I | Intervention | B15 with late global collision-pair replay | Position-aware MLP | completed; gate failed |
| ECP7-B18-I | Intervention | B17 replay with final weight 0.1 | Position-aware MLP | completed; gate failed |
| ECP7-B19-I | Intervention | B18 with replay decayed to zero after step 20,000 | Position-aware MLP | valid negative; validation 82.04%, train and injectivity fail |
+| ECP7-B20-I | Intervention | Late global replay of population-hard training meanings | Position-aware MLP | valid negative; new-best validation 83.45%, train and injectivity fail |
## Batch 1 preregistration
@@ -1454,3 +1455,114 @@ remaining train error is a sparse hard-example problem without adding a
meaning-to-slot binding or a code-distance surrogate. Collision replay, factor
reweighting, architecture, optimizer, horizon, data, translator and thresholds
must remain unchanged or absent as appropriate.
+
+## Batch 20 preregistration
+
+Preregistered: July 18, 2026 at 15:42:06 UTC
+Seed: `11`
+Maximum population steps: `30,000`
+Minimum selection steps: `15,000`
+Early-stopping patience: `5,000` steps
+Hard-meaning predicate: any incorrect factor from any of 16 current population links
+Replay weight: `0` through step `15,000`, linear to `0.25` at step `20,000`, then hold
+Replay batch size: `64` meanings, uniform with replacement
+Training-only pool refresh interval: `200` steps
+Replay sampler seed offset: `+1211`
+Raw configuration SHA-256: `01afcad913222db6a6ef858919664cc50bfac9269f8405eb8cae0dd9832c7319`
+Effective configuration SHA-256: `44b1dbd8b767e4b3e58e88e7f1645e6dc1578b04fd30fa174777dffaf96c3b75`
+Split-SHA-256: `4947058c75ab07cb43a87eb82776b12cb2a7e2eeba7114de110d3b852cbc64cd`
+Test unsealed: **no**
+
+A selected-checkpoint diagnostic on completed Batch 15 found 3,406 training
+meanings with at least one population-link error. They fail for 11.14 of 16
+links on average. Their ordinary reconstruction loss is `0.20870`, compared
+with `0.06442` on the full training set, so the registered coefficient gives an
+estimated task-scale contribution of `0.0522`.
+
+ECP7-B20-I is identical to Batch 15 through step 15,000. The first pool is then
+mined from hard training predictions only. Each subsequent replay update uses
+the same all-senders/all-receivers factor reconstruction loss and current
+straight-through temperature as the ordinary task batch. The independent
+sampler prevents replay-index draws from changing the base batch sequence. No
+validation meaning participates in mining or replay. The positive control is
+rerun, and no alternative hard predicate, coefficient, sampler or accompanying
+intervention is admitted into this batch.
+
+## Batch 20 results
+
+Positive-control run: `runs/ecp7-batch20-control-development/20260718T154331Z-ecp7-development`
+Intervention run: `runs/ecp7-batch20-intervention-development/20260718T154331Z-ecp7-development`
+Test unsealed: **no**
+
+| Metric | Positive control | ECP7-B20-I |
+|---|---:|---:|
+| Population train, mean | 100% | **83.7721%** |
+| Population train, worst link | 100% | 82.7148% |
+| Population validation, mean | 100% | **83.4473%** |
+| Population validation, worst link | 100% | **82.1289%** |
+| Universal translator, validation | 100% | **83.9600%** |
+| Exact sender-message agreement | 100% | **92.40%** |
+| Unique messages per sender | 15,360 | 12,795–13,304 |
+| Collision meanings per sender | 0 | 2,056–2,565 |
+| Message entropy | 13.91 bits | 13.56–13.62 bits |
+| Development gate | pass | **fail** |
+
+The positive control passed every gate at 100%. ECP7-B20-I establishes the
+strongest ECP-7 mean validation, worst-link validation and sender agreement so
+far. It passes the validation and universal-translator thresholds, but still
+fails both known-meaning train thresholds and sender injectivity. The joint gate
+therefore fails and confirmatory access remains unauthorized.
+
+All 76 logged records through step 15,000 match Batch 15 exactly on every 25
+shared trajectory field. The first registered mining pass found 3,452 hard
+training meanings. Replay weight rose from zero to `0.25` by step 20,000, where
+the pool had fallen to 2,862. Its minimum was 2,705 at step 27,400; it then
+continued to fluctuate rather than converging to zero.
+
+The selected checkpoint was step 29,800 with a 2,848-meaning pool. Task loss was
+`0.06719`, hard-meaning replay loss `0.14823`, replay weight `0.25`, and
+utilization loss `-0.73711`. The weighted replay contribution was approximately
+`0.0371`, below the ordinary task loss. The run reached the registered 30,000
+step horizon. Its final checkpoint had 3,084 hard meanings and 82.84%
+validation, so selection correctly retained step 29,800 using validation only.
+
+Compared with Batch 15, train exactness improved by 0.31 percentage points,
+mean validation by 0.86 points, worst-link validation by 2.34 points, translator
+validation by 0.59 points and sender agreement by 0.07 points. Batch 20 now
+holds the strongest ECP-7 validation result; Batch 18 retains the strongest
+train and translator results.
+
+The hard pool did shrink, but its remaining errors became more population-wide.
+At the selected Batch 15 checkpoint, 3,406 meanings fail for at least one link,
+1,469 fail for all 16 links, and a hard meaning fails for 11.14 links on
+average. At the selected Batch 20 checkpoint, those values are 2,848, 1,937 and
+13.07. Uniform any-link replay therefore repairs many link-specific errors while
+leaving a smaller set of more consistently wrong meanings. Its hard-pool loss
+rose from the diagnostic `0.20870` to `0.25880` despite the smaller pool.
+
+Selected sender codebooks contain 12,795, 12,960, 12,898 and 13,304 unique
+messages, with 2,565, 2,400, 2,462 and 2,056 collision meanings. Population
+validation factor accuracies were `[99.58%, 99.96%, 98.44%, 85.35%]`;
+universal-translator factor accuracies were
+`[100%, 100%, 98.44%, 85.52%]`. The generalization gain comes primarily from
+near-complete population color and shape recovery, while size and especially
+texture remain fixed bottlenecks.
+
+Both sealed analyses report 65 matching artifact hashes, 16,384 validation-only
+episodes, no confirmatory-test keys, valid local alphabets, and exactly 14
+declared bits for every logged message.
+
+## Batch 20 decision
+
+ECP7-B20-I is a valid and informative negative result. Ordinary task replay on
+any-link failures improves cross-link consistency and establishes the strongest
+weak-structure validation result, but it does not materially lift train
+exactness or reach injectivity. The confirmatory split remains sealed.
+
+A future ECP7-B21 may keep the complete Batch 20 mechanism and parameters while
+changing only the mining predicate from “at least one of 16 links fails” to
+“all 16 links fail.” This tests the observed concentration directly by spending
+the same registered replay budget exclusively on population-shared errors. No
+coefficient, replay batch, refresh, schedule, architecture, base loss,
+optimizer, horizon, data, translator or threshold change may accompany that
+predicate test.
diff --git a/docs/research-design-ecp7.md b/docs/research-design-ecp7.md
index c7ef40a..f974381 100644
--- a/docs/research-design-ecp7.md
+++ b/docs/research-design-ecp7.md
@@ -838,3 +838,53 @@ sealed-test requirement fails. No alternative decay, endpoint, mining, pair
sampler, architecture, base loss, optimizer, duration, data, translator or seed
may be tried inside Batch 19. Failure is recorded without confirmatory test
access.
+
+## Batch 20 preregistration — Global hard-meaning task replay
+
+Status: **preregistered for sealed development**
+Preregistered: July 18, 2026 at 15:42:06 UTC
+Configuration: `config/ecp7-b20-development.yaml`
+Raw configuration SHA-256: `01afcad913222db6a6ef858919664cc50bfac9269f8405eb8cae0dd9832c7319`
+Effective configuration SHA-256: `44b1dbd8b767e4b3e58e88e7f1645e6dc1578b04fd30fa174777dffaf96c3b75`
+
+Batches 17–19 show that direct collision replay changes code use but does not
+stably repair the known-meaning error. A training-only diagnostic on the
+selected Batch 15 checkpoint found that 3,406 of 14,336 training meanings fail
+for at least one of the 16 sender-receiver links. Those meanings fail for 11.14
+links on average and have mean ordinary reconstruction loss `0.20870`, versus
+`0.06442` over the complete training set. Batch 20 tests whether this
+concentrated residual error is a hard-example sampling problem.
+
+ECP7-B20-I inherits the complete Batch 15 architecture, random task batch,
+utilization objective, optimizer, data, temperature schedule, 30,000-step
+horizon, selection rule, translator and thresholds. It adds one mechanism:
+
+- after the normal step-15,000 evaluation, and every 200 steps thereafter,
+ evaluate hard predictions over training meanings only;
+- include a meaning in the replay pool when any current sender-receiver link
+ reconstructs at least one factor incorrectly;
+- sample 64 pool meanings uniformly with replacement using a separate
+ seed-derived generator;
+- compute the ordinary all-senders/all-receivers reconstruction loss on that
+ batch with the unchanged straight-through sender and current temperature;
+- keep replay weight `0` through step 15,000, warm it linearly to `0.25` at
+ step 20,000, and hold it through step 30,000.
+
+At the Batch 15 diagnostic scale, weight `0.25` contributes approximately
+`0.0522` for the hard-example loss, comparable to rather than larger than the
+remaining ordinary task loss. The base random batch remains unchanged. Mining
+uses no validation meaning, factor-specific weight, slot assignment,
+code-distance target or collision pair. The complete trajectory through step
+15,000 must match Batch 15 exactly.
+
+The batch contains exactly two seed-11 runs:
+
+1. the unchanged ECP-6 positive control;
+2. ECP7-B20-I with late global hard-meaning task replay.
+
+All existing validity and development gates remain unchanged. The intervention
+fails if any train, validation, translator, injectivity, channel-integrity or
+sealed-test requirement fails. No alternative predicate, score, replay weight,
+batch size, refresh, schedule, architecture, base loss, optimizer, duration,
+data, translator or seed may be tried inside Batch 20. Failure is recorded
+without confirmatory test access.
diff --git a/evidence/ecp7-development/README.md b/evidence/ecp7-development/README.md
index 4b616cf..6c1e422 100644
--- a/evidence/ecp7-development/README.md
+++ b/evidence/ecp7-development/README.md
@@ -104,6 +104,13 @@ validation and 83.50% translator validation. The bounded pulse improved
cross-link balance but still failed train thresholds and injectivity. Exact
identities and hashes are in `batch19-manifest.json`.
+Batch 20 replayed ordinary task updates on training meanings failed by any
+population link. It reached 83.77% train exactness, a new-best 83.45% mean and
+82.13% worst-link validation, and 83.96% translator validation. The hard pool
+shrunk but concentrated into population-wide failures, so train thresholds and
+injectivity still failed. Exact identities and hashes are in
+`batch20-manifest.json`.
+
Reproduce the development arms with:
```bash
diff --git a/evidence/ecp7-development/batch20-manifest.json b/evidence/ecp7-development/batch20-manifest.json
new file mode 100644
index 0000000..32f4b75
--- /dev/null
+++ b/evidence/ecp7-development/batch20-manifest.json
@@ -0,0 +1,54 @@
+{
+ "batch": "ECP7-B20",
+ "confirmatory_test_unsealed": false,
+ "config_effective_sha256": "44b1dbd8b767e4b3e58e88e7f1645e6dc1578b04fd30fa174777dffaf96c3b75",
+ "config_raw_sha256": "01afcad913222db6a6ef858919664cc50bfac9269f8405eb8cae0dd9832c7319",
+ "control": {
+ "artifact_hashes": {
+ "effective_config.yaml": "4bfb976293f117d3ae35f790e4fd6371628be95d91febb334bc275dc5d6b66d4",
+ "posthoc-analysis.json": "cdf890082d98e9c7bba3919fda159ae493bec41923669c17ba3727860b7b8cca",
+ "seed-11/metrics.json": "796388340c029e85ca0f2f8d4675b20abc837d2399aad9018b7ee0e0a6256833",
+ "summary.json": "36056908243ecbd3882252580556d26d5b591b23b073b33b10d228c82d0e3f12"
+ },
+ "development_gate_passed": true,
+ "population_train_mean_exact_match": 1.0,
+ "population_validation_mean_exact_match": 1.0,
+ "run_id": "20260718T154331Z-ecp7-development",
+ "universal_translator_validation_exact_match": 1.0,
+ "unique_messages_per_sender": [15360, 15360, 15360, 15360]
+ },
+ "intervention": {
+ "artifact_hashes": {
+ "effective_config.yaml": "3e87a60609c389058c55490234fc74e5d8c36105eab439e6a1cc5cca4ba4a2ea",
+ "posthoc-analysis.json": "15a4abe3cc84903207a17adbf20258272fb3868b4aa597960bd41cec431ea824",
+ "seed-11/metrics.json": "dad6b8675a42f8ea59416babc25e6902fbc972ca0b457000b7f08bfb93eb491d",
+ "summary.json": "7d1a8aa97528aac32a843096b37c1623a672a7bf9c111dfa42c01e1b08fa3f33"
+ },
+ "best_code_utilization_loss": -0.7371097803115845,
+ "best_global_hard_meaning_replay_loss": 0.14822815358638763,
+ "best_global_hard_meaning_replay_pool_size": 2848,
+ "best_global_hard_meaning_replay_weight": 0.25,
+ "best_step": 29800,
+ "collision_meanings_per_sender": [2565, 2400, 2462, 2056],
+ "development_gate_passed": false,
+ "exact_sender_message_agreement": 0.9239583333333333,
+ "final_hard_meaning_replay_pool_size": 3084,
+ "final_step": 30000,
+ "initial_hard_meaning_replay_pool_size": 3452,
+ "message_entropy_bits_per_sender": [13.55521354477656, 13.57669791977656, 13.562678298177484, 13.621489586443227],
+ "minimum_hard_meaning_replay_pool_size": 2705,
+ "population_train_mean_exact_match": 0.8377205984933036,
+ "population_train_worst_link_exact_match": 0.8271484375,
+ "population_validation_factor_accuracy": [0.99578857421875, 0.99957275390625, 0.984375, 0.85345458984375],
+ "population_validation_mean_exact_match": 0.83447265625,
+ "population_validation_worst_link_exact_match": 0.8212890625,
+ "run_id": "20260718T154331Z-ecp7-development",
+ "selected_all_links_wrong_meaning_count": 1937,
+ "selected_mean_incorrect_links_per_hard_meaning": 13.069873809814453,
+ "universal_translator_validation_exact_match": 0.839599609375,
+ "unique_messages_per_sender": [12795, 12960, 12898, 13304]
+ },
+ "seed": 11,
+ "split_artifact_sha256": "676195997ee0c77d5c9565bae045dbb938b432a2e4e74b666233b0a41f924718",
+ "split_sha256": "4947058c75ab07cb43a87eb82776b12cb2a7e2eeba7114de110d3b852cbc64cd"
+}
diff --git a/src/ai_taal/config.py b/src/ai_taal/config.py
index 70462c7..9fb3d92 100644
--- a/src/ai_taal/config.py
+++ b/src/ai_taal/config.py
@@ -280,6 +280,55 @@ def validate_config(config: dict[str, Any]) -> None:
"Global collision-replay final weight must be non-negative and below its initial weight."
)
+ hard_replay = training.get("global_hard_meaning_replay", {})
+ if hard_replay.get("enabled", False):
+ if hard_replay.get("weight", -1) < 0:
+ raise ConfigError(
+ "Global hard-meaning replay weight cannot be negative."
+ )
+ start_step = hard_replay.get("start_step", -1)
+ warmup_steps = hard_replay.get("warmup_steps", 0)
+ batch_size = hard_replay.get("batch_size", 0)
+ refresh_interval = hard_replay.get("refresh_interval", 0)
+ for value, label in (
+ (start_step, "start"),
+ (warmup_steps, "warmup"),
+ (batch_size, "batch size"),
+ (refresh_interval, "refresh interval"),
+ ):
+ if isinstance(value, bool) or not isinstance(value, int):
+ raise ConfigError(
+ f"Global hard-meaning replay {label} must be an integer."
+ )
+ if start_step < 0:
+ raise ConfigError(
+ "Global hard-meaning replay start cannot be negative."
+ )
+ if warmup_steps < 1:
+ raise ConfigError(
+ "Global hard-meaning replay warmup must be positive."
+ )
+ if start_step + warmup_steps > training["max_steps"]:
+ raise ConfigError(
+ "Global hard-meaning replay warmup must complete within max_steps."
+ )
+ if batch_size < 1:
+ raise ConfigError(
+ "Global hard-meaning replay batch size must be positive."
+ )
+ if refresh_interval < 1:
+ raise ConfigError(
+ "Global hard-meaning replay refresh interval must be positive."
+ )
+ evaluation_interval = training["evaluation_interval"]
+ if (
+ start_step % evaluation_interval != 0
+ or refresh_interval % evaluation_interval != 0
+ ):
+ raise ConfigError(
+ "Global hard-meaning replay must start and refresh on evaluation boundaries."
+ )
+
sender_consensus = training.get("sender_message_consensus", {})
if sender_consensus.get("enabled", False):
if sender_consensus.get("weight", -1) < 0:
diff --git a/src/ai_taal/training.py b/src/ai_taal/training.py
index 911dae5..ba18afa 100644
--- a/src/ai_taal/training.py
+++ b/src/ai_taal/training.py
@@ -177,6 +177,9 @@ def train_population_system(
collision_replay_generator = torch.Generator(device="cpu").manual_seed(
seed + 919
)
+ hard_meaning_replay_generator = torch.Generator(device="cpu").manual_seed(
+ seed + 1211
+ )
consistency_config = training.get("algebraic_consistency", {})
consistency_enabled = bool(consistency_config.get("enabled", False))
consistency_quadruples = None
@@ -221,6 +224,18 @@ def train_population_system(
collision_replay_config.get("weight", 0.0)
) < 0:
raise ValueError("Global collision-replay weight cannot be negative.")
+ hard_meaning_replay_config = training.get(
+ "global_hard_meaning_replay", {}
+ )
+ hard_meaning_replay_enabled = bool(
+ hard_meaning_replay_config.get("enabled", False)
+ )
+ if hard_meaning_replay_enabled and float(
+ hard_meaning_replay_config.get("weight", 0.0)
+ ) < 0:
+ raise ValueError(
+ "Global hard-meaning replay weight cannot be negative."
+ )
sender_consensus_config = training.get("sender_message_consensus", {})
sender_consensus_enabled = bool(sender_consensus_config.get("enabled", False))
if sender_consensus_enabled and float(
@@ -261,6 +276,9 @@ def train_population_system(
torch.empty((0, 2), dtype=torch.long, device=device)
for _ in population.senders
]
+ hard_meaning_replay_indices = torch.empty(
+ 0, dtype=torch.long, device=device
+ )
started = time.perf_counter()
for step in range(1, max_steps + 1):
@@ -435,6 +453,47 @@ def train_population_system(
relaxed_collision_pair_probability(replay_messages)
)
collision_replay_loss = torch.stack(replay_losses).mean()
+ hard_meaning_replay_loss = torch.zeros((), device=device)
+ hard_meaning_replay_weight = 0.0
+ if hard_meaning_replay_enabled:
+ hard_meaning_replay_weight = (
+ _scheduled_hard_meaning_replay_weight(
+ hard_meaning_replay_config, step
+ )
+ )
+ if (
+ hard_meaning_replay_weight > 0
+ and len(hard_meaning_replay_indices) > 0
+ ):
+ replay_batch_size = int(
+ hard_meaning_replay_config["batch_size"]
+ )
+ replay_positions = torch.randint(
+ len(hard_meaning_replay_indices),
+ (replay_batch_size,),
+ generator=hard_meaning_replay_generator,
+ device="cpu",
+ ).to(device)
+ hard_batch = train_values[
+ hard_meaning_replay_indices[replay_positions]
+ ]
+ hard_messages = [
+ sender(
+ hard_batch, temperature=temperature, sample=True
+ )[0]
+ for sender in population.senders
+ ]
+ hard_receiver_logits = [
+ receiver(message)
+ for message in hard_messages
+ for receiver in population.receivers
+ ]
+ hard_meaning_replay_loss = torch.stack(
+ [
+ _factor_loss(logits, hard_batch)
+ for logits in hard_receiver_logits
+ ]
+ ).mean()
sender_consensus_loss = torch.zeros((), device=device)
sender_consensus_weight = 0.0
if sender_consensus_enabled:
@@ -454,6 +513,7 @@ def train_population_system(
+ utilization_weight * utilization_loss
+ joint_collision_weight * joint_collision_loss
+ collision_replay_weight * collision_replay_loss
+ + hard_meaning_replay_weight * hard_meaning_replay_loss
+ sender_consensus_weight * sender_consensus_loss
)
optimizer.zero_grad(set_to_none=True)
@@ -477,6 +537,18 @@ def train_population_system(
mine_sender_collision_pairs(sender, train_values)
for sender in population.senders
]
+ if (
+ hard_meaning_replay_enabled
+ and step >= int(hard_meaning_replay_config["start_step"])
+ and step
+ % int(hard_meaning_replay_config["refresh_interval"])
+ == 0
+ ):
+ hard_meaning_replay_indices = (
+ mine_population_hard_meaning_indices(
+ population, train_values
+ )
+ )
history.append(
{
"step": step,
@@ -511,6 +583,15 @@ def train_population_system(
"global_collision_replay_pair_counts": [
len(pairs) for pairs in collision_replay_pairs
],
+ "global_hard_meaning_replay_loss": float(
+ hard_meaning_replay_loss.detach().cpu()
+ ),
+ "global_hard_meaning_replay_weight": float(
+ hard_meaning_replay_weight
+ ),
+ "global_hard_meaning_replay_pool_size": len(
+ hard_meaning_replay_indices
+ ),
"sender_message_consensus_loss": float(
sender_consensus_loss.detach().cpu()
),
@@ -774,6 +855,25 @@ def mine_sender_collision_pairs(sender: SenderModel, meanings: Tensor) -> Tensor
return collision_pairs_from_messages(encode_meanings(sender, meanings))
+@torch.no_grad()
+def mine_population_hard_meaning_indices(
+ population: PopulationSystem, meanings: Tensor
+) -> Tensor:
+ """Return inputs that any current sender-receiver link gets wrong."""
+ population.eval()
+ hard = torch.zeros(
+ len(meanings), dtype=torch.bool, device=meanings.device
+ )
+ for sender in population.senders:
+ messages = encode_meanings(sender, meanings)
+ for receiver in population.receivers:
+ predictions = torch.stack(
+ [head.argmax(dim=-1) for head in receiver(messages)], dim=1
+ )
+ hard |= ~predictions.eq(meanings).all(dim=1)
+ return hard.nonzero(as_tuple=False).flatten()
+
+
def relaxed_collision_pair_probability(message_pairs: Tensor) -> Tensor:
"""Average relaxed probability that each mined pair keeps one full code."""
if message_pairs.ndim != 4 or message_pairs.shape[1] != 2:
@@ -1208,3 +1308,14 @@ def _scheduled_collision_replay_weight(
return final_weight
decay_progress = (step - decay_start) / (decay_end - decay_start)
return initial_weight + (final_weight - initial_weight) * decay_progress
+
+
+def _scheduled_hard_meaning_replay_weight(
+ hard_replay_config: dict[str, Any], step: int
+) -> float:
+ start_step = int(hard_replay_config["start_step"])
+ if step <= start_step:
+ return 0.0
+ warmup_steps = int(hard_replay_config["warmup_steps"])
+ progress = min((step - start_step) / warmup_steps, 1.0)
+ return float(hard_replay_config["weight"]) * progress
diff --git a/tests/conftest.py b/tests/conftest.py
index 5cea7f0..4063f33 100644
--- a/tests/conftest.py
+++ b/tests/conftest.py
@@ -138,3 +138,8 @@ def ecp7_b18_config():
@pytest.fixture(scope="session")
def ecp7_b19_config():
return load_config(PROJECT_ROOT / "config" / "ecp7-b19-development.yaml")
+
+
+@pytest.fixture(scope="session")
+def ecp7_b20_config():
+ return load_config(PROJECT_ROOT / "config" / "ecp7-b20-development.yaml")
diff --git a/tests/test_config.py b/tests/test_config.py
index fc9102b..4156ec1 100644
--- a/tests/test_config.py
+++ b/tests/test_config.py
@@ -672,3 +672,44 @@ def test_collision_replay_decay_must_follow_warmup_and_fit_horizon(
] = 0.1
with pytest.raises(ConfigError, match="below its initial weight"):
validate_config(invalid_final)
+
+
+def test_ecp7_b20_adds_only_global_hard_meaning_replay(
+ ecp7_b15_config, ecp7_b20_config
+):
+ assert ecp7_b20_config["world"] == ecp7_b15_config["world"]
+ assert ecp7_b20_config["dataset"] == ecp7_b15_config["dataset"]
+ assert ecp7_b20_config["channel"] == ecp7_b15_config["channel"]
+ assert ecp7_b20_config["agents"] == ecp7_b15_config["agents"]
+ b15_training = deepcopy(ecp7_b15_config["training"])
+ b20_training = deepcopy(ecp7_b20_config["training"])
+ assert b20_training.pop("global_hard_meaning_replay") == {
+ "enabled": True,
+ "weight": 0.25,
+ "start_step": 15000,
+ "warmup_steps": 5000,
+ "batch_size": 64,
+ "refresh_interval": 200,
+ }
+ assert b20_training == b15_training
+
+
+def test_global_hard_meaning_replay_parameters_are_bounded(ecp7_b20_config):
+ invalid_batch = deepcopy(ecp7_b20_config)
+ invalid_batch["training"]["global_hard_meaning_replay"]["batch_size"] = 0
+ with pytest.raises(ConfigError, match="batch size must be positive"):
+ validate_config(invalid_batch)
+
+ invalid_warmup = deepcopy(ecp7_b20_config)
+ invalid_warmup["training"]["global_hard_meaning_replay"][
+ "warmup_steps"
+ ] = 15001
+ with pytest.raises(ConfigError, match="complete within max_steps"):
+ validate_config(invalid_warmup)
+
+ invalid_refresh = deepcopy(ecp7_b20_config)
+ invalid_refresh["training"]["global_hard_meaning_replay"][
+ "refresh_interval"
+ ] = 300
+ with pytest.raises(ConfigError, match="evaluation boundaries"):
+ validate_config(invalid_refresh)
diff --git a/tests/test_training.py b/tests/test_training.py
index f8656a9..fd4df53 100644
--- a/tests/test_training.py
+++ b/tests/test_training.py
@@ -14,6 +14,7 @@
_scheduled_code_utilization_weight,
_scheduled_collision_replay_weight,
_scheduled_factor_minimax_weight,
+ _scheduled_hard_meaning_replay_weight,
_scheduled_learning_rate,
_scheduled_temperature,
algebraic_consistency_loss,
@@ -22,6 +23,7 @@
calibrate_receiver_binding,
collision_pairs_from_messages,
factor_agnostic_code_utilization_loss,
+ mine_population_hard_meaning_indices,
mine_sender_collision_pairs,
normalized_factor_minimax_loss,
relaxed_collision_pair_probability,
@@ -323,6 +325,58 @@ def test_collision_pair_mining_uses_a_senders_hard_training_code(
assert len(pairs) == 6
+def test_hard_meaning_mining_returns_union_of_population_link_errors():
+ class IdentitySender(torch.nn.Module):
+ def forward(self, meanings, **_kwargs):
+ return None, meanings.clone()
+
+ class ConditionalReceiver(torch.nn.Module):
+ def __init__(self, *, condition_factor=None, flipped_factor=None):
+ super().__init__()
+ self.condition_factor = condition_factor
+ self.flipped_factor = flipped_factor
+
+ def forward(self, messages):
+ predictions = messages.clone()
+ if self.condition_factor is not None:
+ mask = messages[:, self.condition_factor] == 1
+ predictions[mask, self.flipped_factor] = (
+ 1 - predictions[mask, self.flipped_factor]
+ )
+ return tuple(
+ torch.nn.functional.one_hot(
+ predictions[:, factor_index], num_classes=2
+ ).to(torch.float32)
+ for factor_index in range(predictions.shape[1])
+ )
+
+ class StubPopulation(torch.nn.Module):
+ def __init__(self):
+ super().__init__()
+ self.senders = torch.nn.ModuleList([IdentitySender()])
+ self.receivers = torch.nn.ModuleList(
+ [
+ ConditionalReceiver(),
+ ConditionalReceiver(
+ condition_factor=0, flipped_factor=3
+ ),
+ ConditionalReceiver(
+ condition_factor=1, flipped_factor=2
+ ),
+ ]
+ )
+
+ meanings = torch.tensor(
+ [[0, 0, 0, 0], [1, 0, 1, 0], [0, 1, 0, 1]], dtype=torch.long
+ )
+
+ hard_indices = mine_population_hard_meaning_indices(
+ StubPopulation(), meanings
+ )
+
+ assert hard_indices.tolist() == [1, 2]
+
+
def test_relaxed_collision_replay_measures_full_message_probability():
symbol_pairs = torch.tensor(
[
@@ -471,6 +525,15 @@ def test_collision_replay_pulse_warms_decays_and_stops(ecp7_b19_config):
assert _scheduled_collision_replay_weight(schedule, 30000) == 0.0
+def test_hard_meaning_replay_weight_starts_warms_and_holds(ecp7_b20_config):
+ schedule = ecp7_b20_config["training"]["global_hard_meaning_replay"]
+
+ assert _scheduled_hard_meaning_replay_weight(schedule, 15000) == 0.0
+ assert _scheduled_hard_meaning_replay_weight(schedule, 17500) == 0.125
+ assert _scheduled_hard_meaning_replay_weight(schedule, 20000) == 0.25
+ assert _scheduled_hard_meaning_replay_weight(schedule, 30000) == 0.25
+
+
def test_receiver_binding_calibration_recovers_exact_permutation(ecp4_config):
receiver = FactorizedPermutationSlotReceiver(ModelSpec.from_config(ecp4_config))
meanings = torch.tensor(