Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
53 changes: 48 additions & 5 deletions crucible/keys.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@
__all__ = [
"ACCEPTANCE_REQUIRED_FIELDS",
"ALERTS_ROOT",
"ARM_PREDICTIONS_PREFIX",
"ARM_SEGMENT_SEPARATOR",
"AcceptanceReading",
"BOARD_CURRENT_KEY",
Expand All @@ -40,6 +41,7 @@
"FAULT_INJECTION_ROOT",
"MANIFEST_BASENAME",
"POINTER_KEY",
"PREDICTIONS_PREFIX",
"RELEASES_ROOT",
"REVIEWER_PATTERN",
"RUNS_ROOT",
Expand Down Expand Up @@ -93,6 +95,7 @@
"parse_dispatch_key",
"parse_fault_injection_key",
"parse_manifest_key",
"predictions_key",
"retirement_log_key",
"review_key",
"review_prefix",
Expand Down Expand Up @@ -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 -------------------------------------------------------------
Expand Down
64 changes: 64 additions & 0 deletions tests/test_keys_prefixes.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,11 @@

from crucible import keys as crucible_keys
from crucible.keys import (
ARM_PREDICTIONS_PREFIX,
DRIFT_INPUTS,
PREDICTIONS_PREFIX,
arm_id_from_segment,
arm_predictions_key,
cross_section_key,
cross_section_settled_key,
drift_input_key,
Expand All @@ -42,6 +46,7 @@
heal_key,
manifest_key,
migration_key,
predictions_key,
runs_prefix,
shadow_key,
strategy_arm_key,
Expand Down Expand Up @@ -317,3 +322,62 @@ 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")
5 changes: 4 additions & 1 deletion tests/test_slot_model_stacked_inputs.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down