From 1faa339df67ea8d0fe039b7421d235da9ef54833 Mon Sep 17 00:00:00 2001 From: Brian McMahon Date: Fri, 11 Sep 2026 10:44:54 -0700 Subject: [PATCH 1/2] fix(keys): the per-arm predictions artifact leaves the trader's prefix (alpha-engine-config-I9822) `predictions/` held two artifact shapes: the trader's champion serving feed (`predictions/{trading_day}.json`, the contract named in `slots/__init__.py` and AGENTS.md) and the per-arm artifact (`predictions/{arm~seg}/{trading_day}.json`). A consumer doing `store.list_keys("predictions/")` saw both and had to discriminate by path depth. Nothing did, which is what made it a design risk rather than a live defect. The per-arm shape moves to `arm_predictions/`, and `predictions_key` is declared so the trader contract has a single source for its key rather than living only in prose. MOVED WHILE THE PREFIX WAS EMPTY. Measured 2026-09-11: zero objects under `predictions/` in the production store, so this orphaned nothing. The same change after either shape had been written would have stranded those objects at an address no code resolves - which is alpha-engine-config-I10498, filed the same day for a feature layer stranded exactly that way by a content-hash move. The cheapest moment to separate two artifact shapes is before either exists, and that moment is now. Deliverable 1 of the issue was already done: `arm_predictions_key` lives in `crucible/keys.py` and `slots/inputs.py` imports it from there. Only deliverable 2 remained. The new guard is proven red, not assumed: reverting ARM_PREDICTIONS_PREFIX to "predictions/" fails test_the_two_shapes_live_under_different_prefixes and test_a_listing_of_the_feed_prefix_cannot_return_an_arm_artifact. Two things found on the way: - `__all__` was out of alphabetical order before this change; the repo's own TestAllIsSortedAndDeduplicated caught my addition and the pre-existing disorder together. Re-sorted and de-duplicated, 79 entries. - The tests went into `tests/test_keys_prefixes.py`, the file that already owns this class of assertion, rather than the `tests/test_keys.py` the issue named - which does not exist, and creating it would have made a second home for one question. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01WARhC81oatq9czVeyaM66L --- crucible/keys.py | 53 +++++++++++++++++++-- tests/test_keys_prefixes.py | 63 +++++++++++++++++++++++++ tests/test_slot_model_stacked_inputs.py | 5 +- 3 files changed, 115 insertions(+), 6 deletions(-) diff --git a/crucible/keys.py b/crucible/keys.py index 312f12f..7f003d5 100644 --- a/crucible/keys.py +++ b/crucible/keys.py @@ -29,6 +29,7 @@ __all__ = [ "ACCEPTANCE_REQUIRED_FIELDS", "ALERTS_ROOT", + "ARM_PREDICTIONS_PREFIX", "ARM_SEGMENT_SEPARATOR", "AcceptanceReading", "BOARD_CURRENT_KEY", @@ -40,6 +41,7 @@ "FAULT_INJECTION_ROOT", "MANIFEST_BASENAME", "POINTER_KEY", + "PREDICTIONS_PREFIX", "RELEASES_ROOT", "REVIEWER_PATTERN", "RUNS_ROOT", @@ -93,6 +95,7 @@ "parse_dispatch_key", "parse_fault_injection_key", "parse_manifest_key", + "predictions_key", "retirement_log_key", "review_key", "review_prefix", @@ -314,15 +317,55 @@ def arm_id_from_segment(segment: str) -> str: return segment.replace(ARM_SEGMENT_SEPARATOR, ":") +#: The trader's champion serving feed — the contract named in +#: `crucible.slots.__init__` and in this repo's `AGENTS.md`: *"the trader reads +#: one contract — `champions/{slot}/current.json` plus +#: `predictions/{trading_day}.json`"*. ONE object per trading day, at the root +#: of its own prefix. +PREDICTIONS_PREFIX = "predictions/" + +#: What one ARM predicted, which is a different artifact answering a different +#: question, and therefore lives under a different prefix +#: (`alpha-engine-config-I9822`). It shared `predictions/` until 2026-09-11 and +#: was distinguishable from the serving feed only by counting path segments — +#: so any consumer doing `store.list_keys("predictions/")` saw both shapes and +#: had to discriminate by depth. Nothing did. +#: +#: **Moved while the prefix was EMPTY.** Measured 2026-09-11: zero objects +#: existed under `predictions/` in the production store, so this rename +#: orphaned nothing. The same change made after either shape had been written +#: would have stranded those objects at an address no code resolves — which is +#: `alpha-engine-config-I10498`, filed the same day for a feature layer that +#: was stranded exactly that way by a content-hash move. The cheapest moment to +#: separate two artifact shapes is before either exists. +ARM_PREDICTIONS_PREFIX = "arm_predictions/" + + +def predictions_key(trading_day: str) -> str: + """The champion's serving feed for ONE trading day — what the trader reads. + + The key builder only. Writing this artifact is the M slot's job and is + tracked separately (`alpha-engine-config-I10129`: the feed is declared in + the trader contract and written by nothing). It is declared here because a + contract with no single source for its key is how the prose and the code + drift apart, and because :func:`arm_predictions_key` cannot be tested + against a collision with a key that does not exist. + """ + return f"{PREDICTIONS_PREFIX}{trading_day}.json" + + def arm_predictions_key(arm_id: str, trading_day: str) -> str: """What ONE arm predicted on ONE trading day. - Per-arm, not per-slot: `predictions/{trading_day}.json` is the *champion's* - serving feed, and a stacked arm reading that would depend on whichever arm - holds the pointer — a base model that silently changes identity between - two cycles, and a self-reference the moment the stacked arm won the slot. + Per-arm, not per-slot: :func:`predictions_key` is the *champion's* serving + feed, and a stacked arm reading that would depend on whichever arm holds + the pointer — a base model that silently changes identity between two + cycles, and a self-reference the moment the stacked arm won the slot. + + Under `arm_predictions/`, never `predictions/`: see + :data:`ARM_PREDICTIONS_PREFIX`. """ - return f"predictions/{arm_key_segment(arm_id)}/{trading_day}.json" + return f"{ARM_PREDICTIONS_PREFIX}{arm_key_segment(arm_id)}/{trading_day}.json" # -- data layer ------------------------------------------------------------- diff --git a/tests/test_keys_prefixes.py b/tests/test_keys_prefixes.py index fff2bc1..b6ae1fd 100644 --- a/tests/test_keys_prefixes.py +++ b/tests/test_keys_prefixes.py @@ -29,7 +29,9 @@ from crucible import keys as crucible_keys from crucible.keys import ( + ARM_PREDICTIONS_PREFIX, DRIFT_INPUTS, + PREDICTIONS_PREFIX, cross_section_key, cross_section_settled_key, drift_input_key, @@ -45,6 +47,9 @@ runs_prefix, shadow_key, strategy_arm_key, + arm_id_from_segment, + arm_predictions_key, + predictions_key, strategy_arms_prefix, verdict_key, ) @@ -317,3 +322,61 @@ def test_an_absent_feature_layer_fails_the_manifest_rather_than_scoring_from_not manifest = json.loads(store.get_bytes(manifest_key("drift", day)).decode("utf-8")) assert manifest["status"] == "failed" assert features_key(DEFAULT_FEATURE_VERSION, day) in manifest["reason"] + +class TestPredictionsPrefixesCannotCollide: + """`alpha-engine-config-I9822`: the trader's serving feed and the per-arm + artifact shared `predictions/` and were distinguishable only by counting + path segments. + + A consumer listing `predictions/` saw both shapes. Nothing discriminated, + which made this a design risk rather than a live defect — and the moment + something did list that prefix, the cheapest reading (`the one object for + this trading day`) would have matched an arm's artifact for an arm whose id + happened to look like a date. + """ + + def test_the_two_shapes_live_under_different_prefixes(self) -> None: + day = "2026-08-28" + feed = predictions_key(day) + arm = arm_predictions_key("m:base:abc123", day) + + assert feed.startswith(PREDICTIONS_PREFIX) + assert arm.startswith(ARM_PREDICTIONS_PREFIX) + assert not arm.startswith(PREDICTIONS_PREFIX), ( + "the per-arm artifact is under `predictions/` again; a listing of " + "that prefix now returns two shapes and every consumer must " + "discriminate by path depth" + ) + + def test_a_listing_of_the_feed_prefix_cannot_return_an_arm_artifact(self) -> None: + """The property that matters, stated as a listing rather than as a + string comparison: this is how a consumer actually meets the two.""" + day = "2026-08-28" + keys = [predictions_key(day)] + [ + arm_predictions_key(arm, day) + for arm in ("m:base:abc123", "r:llm:deadbeef", "s:momentum:0001") + ] + under_feed = [k for k in keys if k.startswith(PREDICTIONS_PREFIX)] + assert under_feed == [predictions_key(day)], ( + f"listing {PREDICTIONS_PREFIX!r} returned {under_feed}; it must " + "return the champion's serving feed and nothing else" + ) + + def test_no_arm_id_can_make_the_two_builders_collide(self) -> None: + """Including the adversarial case the shared prefix allowed: an arm + whose id looks like a trading day.""" + day = "2026-08-28" + for arm_id in ("m:base:abc123", "2026-08-28", "m:2026-08-28:x", "a:b:c"): + assert arm_predictions_key(arm_id, day) != predictions_key(day) + + def test_the_arm_segment_stays_invertible(self) -> None: + """`arm_key_segment` refuses an `arm_id` already containing the + separator, which is what makes the segment invertible. The prefix move + must not have routed around it.""" + arm_id = "m:base:abc123" + key = arm_predictions_key(arm_id, "2026-08-28") + segment = key.removeprefix(ARM_PREDICTIONS_PREFIX).split("/")[0] + assert arm_id_from_segment(segment) == arm_id + + with pytest.raises(ValueError): + arm_predictions_key("m~base~abc123", "2026-08-28") diff --git a/tests/test_slot_model_stacked_inputs.py b/tests/test_slot_model_stacked_inputs.py index 6599399..984d22c 100644 --- a/tests/test_slot_model_stacked_inputs.py +++ b/tests/test_slot_model_stacked_inputs.py @@ -218,8 +218,11 @@ class TestThePredictionsArtifactContract: """Deliverable 5: a versioned schema with both halves tested at birth.""" def test_the_key_is_per_arm_and_carries_the_arms_spec_hash(self) -> None: + """Under `arm_predictions/`, not `predictions/` — the latter is the + trader's champion serving feed and the two shared a prefix until + `alpha-engine-config-I9822`.""" key = arm_predictions_key("m:base:abc123", "2026-08-28") - assert key == "predictions/m~base~abc123/2026-08-28.json" + assert key == "arm_predictions/m~base~abc123/2026-08-28.json" def test_the_producer_output_round_trips_through_the_consumer(self, tmp_path) -> None: store = LocalStore(tmp_path) From 318dae554965182962e9e3fdfb35310545c120d0 Mon Sep 17 00:00:00 2001 From: Brian McMahon Date: Fri, 11 Sep 2026 10:52:13 -0700 Subject: [PATCH 2/2] style: ruff import order in the new prefix-collision tests Prepared by: Claude Opus 5 (1M context) via Claude Code Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01WARhC81oatq9czVeyaM66L --- tests/test_keys_prefixes.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/tests/test_keys_prefixes.py b/tests/test_keys_prefixes.py index b6ae1fd..cc06b37 100644 --- a/tests/test_keys_prefixes.py +++ b/tests/test_keys_prefixes.py @@ -32,6 +32,8 @@ ARM_PREDICTIONS_PREFIX, DRIFT_INPUTS, PREDICTIONS_PREFIX, + arm_id_from_segment, + arm_predictions_key, cross_section_key, cross_section_settled_key, drift_input_key, @@ -44,12 +46,10 @@ heal_key, manifest_key, migration_key, + predictions_key, runs_prefix, shadow_key, strategy_arm_key, - arm_id_from_segment, - arm_predictions_key, - predictions_key, strategy_arms_prefix, verdict_key, ) @@ -323,6 +323,7 @@ def test_an_absent_feature_layer_fails_the_manifest_rather_than_scoring_from_not assert manifest["status"] == "failed" assert features_key(DEFAULT_FEATURE_VERSION, day) in manifest["reason"] + class TestPredictionsPrefixesCannotCollide: """`alpha-engine-config-I9822`: the trader's serving feed and the per-arm artifact shared `predictions/` and were distinguishable only by counting