diff --git a/bins/design-challenge/tests/resanitize_backfill.rs b/bins/design-challenge/tests/resanitize_backfill.rs index 8621ce2af..3946f568e 100644 --- a/bins/design-challenge/tests/resanitize_backfill.rs +++ b/bins/design-challenge/tests/resanitize_backfill.rs @@ -40,6 +40,7 @@ async fn seed_run(store: &MemoryDesignStore, id: &str, created_at_ms: u64) { .insert_harness(&HarnessRow { id: format!("h-{id}"), miner_hotkey: "cd".repeat(32), + miner_coldkey: None, agent_py: "def run(task, llm, out):\n pass\n".into(), pyproject_toml: "[project]\nname='x'\nversion='0'\n".into(), extra_files: BTreeMap::new(), diff --git a/crates/chain-live/src/lib.rs b/crates/chain-live/src/lib.rs index dae301c10..3ddf13e69 100644 --- a/crates/chain-live/src/lib.rs +++ b/crates/chain-live/src/lib.rs @@ -25,8 +25,8 @@ pub use storage::{ decode_axon_info, decode_bool, decode_double_map_account_k2, decode_double_map_k2, decode_hotkey, decode_metagraph, decode_u16, decode_u64, decode_vec_u64, decode_vec_vec_u8, storage_double_map_key_u16_account, storage_double_map_key_u16_u16, - storage_double_map_prefix_u16, storage_key, storage_map_key_identity, storage_map_key_twox64, - storage_map_key_u16, ACCOUNT_ID_LEN, + storage_double_map_prefix_u16, storage_key, storage_map_key_account_blake2, + storage_map_key_identity, storage_map_key_twox64, storage_map_key_u16, ACCOUNT_ID_LEN, }; pub use tlock::encrypt_commit; @@ -181,8 +181,42 @@ impl LiveChainClient { Some(block_hash) }; let keys = self.enumerate_hotkeys(netuid, at)?; + let coldkeys = self.fetch_coldkeys_for_hotkeys(&keys, at)?; let owner = self.read_owner_hotkey(netuid, at)?; - Ok(storage::decode_metagraph(keys, owner, netuid)) + Ok(storage::decode_metagraph(keys, coldkeys, owner, netuid)) + } + + /// Bulk-read `SubtensorModule.Owner(hotkey) → coldkey` for every hotkey. + /// + /// Uses batched `state_queryStorageAt` (same path as `Keys`), never + /// per-UID RPCs. Missing / default (all-zero) owners become zero vectors + /// so the UID alignment with `hotkeys` is preserved. + fn fetch_coldkeys_for_hotkeys( + &self, + hotkeys: &[Vec], + at: Option<&[u8; 32]>, + ) -> Result>, ChainError> { + if hotkeys.is_empty() { + return Ok(Vec::new()); + } + let mut storage_keys = Vec::with_capacity(hotkeys.len()); + let mut index_of: HashMap, usize> = HashMap::with_capacity(hotkeys.len()); + for (i, hk) in hotkeys.iter().enumerate() { + let account = account_id(hk)?; + let sk = storage::storage_map_key_account_blake2(PALLET_SUBTENSOR, "Owner", &account); + index_of.insert(sk.clone(), i); + storage_keys.push(sk); + } + let mut coldkeys = vec![vec![0_u8; ACCOUNT_ID_LEN]; hotkeys.len()]; + for chunk in storage_keys.chunks(256) { + for (key, value) in self.rpc.state_query_storage_at(chunk, at)? { + let Some(&i) = index_of.get(&key) else { + continue; + }; + coldkeys[i] = storage::decode_hotkey(&value)?; + } + } + Ok(coldkeys) } /// Connect and load a signing key from a file (32 raw bytes or 64 hex chars). diff --git a/crates/chain-live/src/storage.rs b/crates/chain-live/src/storage.rs index d8f13cdbc..1aacf9819 100644 --- a/crates/chain-live/src/storage.rs +++ b/crates/chain-live/src/storage.rs @@ -218,12 +218,34 @@ pub fn decode_hotkey(bytes: &[u8]) -> Result, ChainError> { } } +/// Map key with the `Blake2_128Concat` hasher over an `AccountId32`. +/// +/// Used by `SubtensorModule.Owner` (hotkey → coldkey). Layout: +/// `Twox128(pallet) ++ Twox128(item) ++ blake2_128(account) ++ account`. +#[must_use] +pub fn storage_map_key_account_blake2( + pallet: &str, + item: &str, + account: &[u8; ACCOUNT_ID_LEN], +) -> Vec { + let mut k = storage_key(pallet, item); + k.extend_from_slice(&blake2_128(account)); + k.extend_from_slice(account); + k +} + /// Build a [`Metagraph`] from decoded storage values. #[must_use] -pub fn decode_metagraph(keys: Vec>, owner: Vec, netuid: u16) -> Metagraph { +pub fn decode_metagraph( + keys: Vec>, + coldkeys: Vec>, + owner: Vec, + netuid: u16, +) -> Metagraph { Metagraph { netuid, hotkeys: keys, + coldkeys, owner_hotkey: owner, } } diff --git a/crates/chain-live/src/tests.rs b/crates/chain-live/src/tests.rs index 686fe59e6..f3bfee488 100644 --- a/crates/chain-live/src/tests.rs +++ b/crates/chain-live/src/tests.rs @@ -132,10 +132,12 @@ fn decode_hotkey_option_some() { #[test] fn decode_metagraph_builds_correctly() { let keys = vec![vec![0xAA; 32], vec![0xBB; 32]]; + let coldkeys = vec![vec![0x11; 32], vec![0x22; 32]]; let owner = vec![0xCC; 32]; - let mg = decode_metagraph(keys.clone(), owner.clone(), 1); + let mg = decode_metagraph(keys.clone(), coldkeys.clone(), owner.clone(), 1); assert_eq!(mg.netuid, 1); assert_eq!(mg.hotkeys, keys); + assert_eq!(mg.coldkeys, coldkeys); assert_eq!(mg.owner_hotkey, owner); } @@ -653,6 +655,8 @@ async fn mock_metagraph_at() { assert_eq!(mg.hotkeys.len(), 2); assert_eq!(mg.hotkeys[0], vec![0xAA; 32]); assert_eq!(mg.hotkeys[1], vec![0xBB; 32]); + // Owner mock returns Keys-shaped changes; unmatched Owner keys stay zero. + assert_eq!(mg.coldkeys.len(), 2); assert_eq!(mg.owner_hotkey, vec![0xCC; 32]); } @@ -673,6 +677,7 @@ async fn mount_metagraph_mocks(server: &MockServer, keys_paged_times: u64) { .mount(server) .await; + // Two batched reads per refresh: Keys values, then Owner(hotkey) coldkeys. Mock::given(method("POST")) .and(body_partial_json(json!({"method": "state_queryStorageAt"}))) .respond_with(ResponseTemplate::new(200).set_body_json(json!({ @@ -685,7 +690,7 @@ async fn mount_metagraph_mocks(server: &MockServer, keys_paged_times: u64) { ] }] }))) - .expect(keys_paged_times) + .expect(keys_paged_times.saturating_mul(2)) .mount(server) .await; diff --git a/crates/chain/src/lib.rs b/crates/chain/src/lib.rs index fb4c0d415..133990cdf 100644 --- a/crates/chain/src/lib.rs +++ b/crates/chain/src/lib.rs @@ -63,6 +63,10 @@ pub struct Metagraph { pub netuid: u16, /// Neuron hotkeys in UID order. pub hotkeys: Vec>, + /// Coldkey owning each hotkey (`SubtensorModule.Owner`), UID-aligned with + /// [`Self::hotkeys`]. Empty when the backend did not resolve owners; an + /// all-zero entry means the chain default (unknown / unset). + pub coldkeys: Vec>, /// Owner hotkey for the subnet (may equal first neuron or a dedicated owner). pub owner_hotkey: Vec, } @@ -404,6 +408,10 @@ pub struct FakeChainConfig { pub owner_hotkey: Vec, /// Neuron hotkeys (UID order). pub hotkeys: Vec>, + /// Optional coldkeys UID-aligned with [`Self::hotkeys`]. Empty → each + /// neuron is treated as self-owned (coldkey == hotkey) so tests that do + /// not care about shared coldkeys keep unique owners. + pub coldkeys: Vec>, /// Published axons as `(hotkey, info)`; hotkeys absent here have never served. pub axons: Vec<(Vec, AxonInfo)>, /// Number of subsequent weight submits that should return [`ChainError::RateLimited`]. @@ -426,6 +434,7 @@ impl Default for FakeChainConfig { blocks_since_last_step: fake_defaults::BLOCKS_SINCE_LAST_STEP, owner_hotkey: vec![0xA1; 32], hotkeys: vec![vec![0xA1; 32], vec![0xB2; 32], vec![0xC3; 32]], + coldkeys: Vec::new(), axons: Vec::new(), rate_limit_fails_remaining: 0, } @@ -550,9 +559,16 @@ impl ChainClient for FakeChain { if !found { return Err(ChainError::UnknownMetagraph); } + let coldkeys = if self.cfg.coldkeys.is_empty() { + // Default: each hotkey owns itself (no shared-coldkey collisions). + self.cfg.hotkeys.clone() + } else { + self.cfg.coldkeys.clone() + }; Ok(Metagraph { netuid: self.cfg.netuid, hotkeys: self.cfg.hotkeys.clone(), + coldkeys, owner_hotkey: self.cfg.owner_hotkey.clone(), }) } diff --git a/crates/challenge-agentic/src/lib.rs b/crates/challenge-agentic/src/lib.rs index ca3c19124..495cccbfb 100644 --- a/crates/challenge-agentic/src/lib.rs +++ b/crates/challenge-agentic/src/lib.rs @@ -36,3 +36,22 @@ pub use types::{ pub fn crate_name() -> &'static str { "challenge-agentic" } + +/// Same economic miner for copy/similarity corpora: matching hotkey, or both +/// coldkeys known and equal (case-insensitive). Used when 1-max gating forces +/// hotkey rotation under one coldkey. +#[must_use] +pub fn same_miner_identity( + hotkey_a: &str, + coldkey_a: Option<&str>, + hotkey_b: &str, + coldkey_b: Option<&str>, +) -> bool { + if hotkey_a.eq_ignore_ascii_case(hotkey_b) { + return true; + } + match (coldkey_a, coldkey_b) { + (Some(a), Some(b)) if !a.is_empty() && !b.is_empty() => a.eq_ignore_ascii_case(b), + _ => false, + } +} diff --git a/crates/challenge-common/src/expected_set.rs b/crates/challenge-common/src/expected_set.rs index 7005b4304..c89569f0b 100644 --- a/crates/challenge-common/src/expected_set.rs +++ b/crates/challenge-common/src/expected_set.rs @@ -236,6 +236,11 @@ mod tests { miner_hk().to_vec(), validator_hk().to_vec(), ], + coldkeys: vec![ + owner_hk().to_vec(), + miner_hk().to_vec(), + validator_hk().to_vec(), + ], owner_hotkey: owner_hk().to_vec(), } } @@ -243,6 +248,7 @@ mod tests { fn meta_after_late_registration() -> Metagraph { let mut m = meta_at_block_b(); m.hotkeys.push(late_miner_hk().to_vec()); + m.coldkeys.push(late_miner_hk().to_vec()); m } diff --git a/crates/db/migrations/0014_miner_coldkey.sql b/crates/db/migrations/0014_miner_coldkey.sql new file mode 100644 index 000000000..68dfe002d --- /dev/null +++ b/crates/db/migrations/0014_miner_coldkey.sql @@ -0,0 +1,26 @@ +-- Persist miner coldkey (SubtensorModule.Owner) at intake so similarity / +-- copy corpora can exclude same-coldkey prior art after a miner iterates via +-- a new hotkey (1-max gating forces hotkey rotation under one coldkey). +-- Nullable for legacy rows; new intakes fill it from the metagraph cache. + +ALTER TABLE design_harness + ADD COLUMN miner_coldkey TEXT; + +ALTER TABLE design_harness + ADD CONSTRAINT design_harness_miner_coldkey_hex + CHECK (miner_coldkey IS NULL OR miner_coldkey ~ '^[0-9a-f]{64}$'); + +CREATE INDEX ix_design_harness_coldkey + ON design_harness (miner_coldkey) + WHERE miner_coldkey IS NOT NULL; + +ALTER TABLE prism_submission + ADD COLUMN miner_coldkey TEXT; + +ALTER TABLE prism_submission + ADD CONSTRAINT prism_submission_miner_coldkey_hex + CHECK (miner_coldkey IS NULL OR miner_coldkey ~ '^[0-9a-f]{64}$'); + +CREATE INDEX ix_prism_submission_coldkey + ON prism_submission (miner_coldkey) + WHERE miner_coldkey IS NOT NULL; diff --git a/crates/db/src/prism_store.rs b/crates/db/src/prism_store.rs index c791c46b1..0d4799436 100644 --- a/crates/db/src/prism_store.rs +++ b/crates/db/src/prism_store.rs @@ -16,6 +16,8 @@ pub struct PrismSubmissionRow { pub id: String, /// Miner hotkey (lowercase 64 hex). pub miner_hotkey: String, + /// Owning coldkey (lowercase 64 hex), when known at intake. + pub miner_coldkey: Option, /// Epoch at acceptance. pub epoch: i64, /// Netuid. @@ -61,6 +63,8 @@ pub struct NewPrismSubmission<'a> { pub id: &'a str, /// miner hotkey. pub miner_hotkey: &'a str, + /// owning coldkey (optional). + pub miner_coldkey: Option<&'a str>, /// epoch. pub epoch: i64, /// netuid. @@ -85,9 +89,9 @@ pub struct NewPrismStageEvent<'a> { } /// Column list shared by all row reads. -const COLS: &str = "id, miner_hotkey, epoch, netuid, status, label, architecture_py, training_py, \ - pod_id, pod_provider, receipt_json, metrics_json, bpb, review_json, similarity_json, kind, \ - score, absence_reason, retry_count, error_detail"; +const COLS: &str = "id, miner_hotkey, miner_coldkey, epoch, netuid, status, label, \ + architecture_py, training_py, pod_id, pod_provider, receipt_json, metrics_json, bpb, \ + review_json, similarity_json, kind, score, absence_reason, retry_count, error_detail"; /// Insert the queued row. /// @@ -98,11 +102,13 @@ pub async fn insert_prism_submission( n: &NewPrismSubmission<'_>, ) -> Result<(), DbError> { sqlx::query( - "INSERT INTO prism_submission (id, miner_hotkey, epoch, netuid, status, label, architecture_py, training_py) \ - VALUES ($1, $2, $3, $4, 'queued', $5, $6, $7)", + "INSERT INTO prism_submission \ + (id, miner_hotkey, miner_coldkey, epoch, netuid, status, label, architecture_py, training_py) \ + VALUES ($1, $2, $3, $4, $5, 'queued', $6, $7, $8)", ) .bind(n.id) .bind(n.miner_hotkey) + .bind(n.miner_coldkey) .bind(n.epoch) .bind(n.netuid) .bind(n.label) diff --git a/crates/design-challenge/src/corpus.rs b/crates/design-challenge/src/corpus.rs index e43426245..b8d209842 100644 --- a/crates/design-challenge/src/corpus.rs +++ b/crates/design-challenge/src/corpus.rs @@ -1,27 +1,25 @@ -//! Shared anti-cheat corpus: other hotkeys' prior art only. -//! -//! Same-hotkey revisions are never comparison material. Review victims must be -//! strictly earlier than the candidate. Gate + review share this module so they -//! cannot drift. Pass the candidate row explicitly (not via recent-list lookup). +//! Anti-cheat corpus: other miners' prior art only (hotkey + coldkey). +//! Gate + review share this module; pass the candidate row explicitly. -use challenge_agentic::{CorpusEntry, GateCorpusEntry}; +use challenge_agentic::{same_miner_identity, CorpusEntry, GateCorpusEntry}; use design_store::HarnessRow; const BASELINE_AGENT: &str = include_str!("../../../docs/external-miner/examples/design-baseline/agent.py"); fn other_miners<'a>( - candidate: &'a HarnessRow, + cand: &'a HarnessRow, recent: &'a [HarnessRow], ) -> impl Iterator { - let miner = candidate.miner_hotkey.to_ascii_lowercase(); - recent - .iter() - .filter(move |h| h.id != candidate.id && h.miner_hotkey.to_ascii_lowercase() != miner) -} - -fn corpus_id(h: &HarnessRow) -> String { - format!("harness:{}", h.id) + recent.iter().filter(move |h| { + h.id != cand.id + && !same_miner_identity( + &cand.miner_hotkey, + cand.miner_coldkey.as_deref(), + &h.miner_hotkey, + h.miner_coldkey.as_deref(), + ) + }) } /// Pre-LLM copy-gate corpus (`created_at_ms` kept for gate ordering). @@ -29,29 +27,27 @@ fn corpus_id(h: &HarnessRow) -> String { pub fn gate_corpus(candidate: &HarnessRow, recent: &[HarnessRow]) -> Vec { other_miners(candidate, recent) .map(|h| GateCorpusEntry { - id: corpus_id(h), + id: format!("harness:{}", h.id), source: h.agent_py.clone(), created_at_ms: h.created_at_ms, }) .collect() } -/// Reviewer corpus: baseline + other hotkeys' earlier harnesses. -/// Untimestamped rows are dropped; a legacy candidate (`created_at_ms == 0`) -/// keeps every timestamped other-hotkey row so the corpus cannot go empty. +/// Reviewer corpus: baseline + other miners' earlier harnesses. #[must_use] pub fn review_corpus(candidate: &HarnessRow, recent: &[HarnessRow]) -> Vec { let mut corpus = vec![CorpusEntry { id: "baseline".into(), source: BASELINE_AGENT.to_owned(), }]; - let cand_ts = candidate.created_at_ms; + let ts = candidate.created_at_ms; corpus.extend(other_miners(candidate, recent).filter_map(|h| { - if h.created_at_ms == 0 || (cand_ts > 0 && h.created_at_ms >= cand_ts) { + if h.created_at_ms == 0 || (ts > 0 && h.created_at_ms >= ts) { return None; } Some(CorpusEntry { - id: corpus_id(h), + id: format!("harness:{}", h.id), source: h.agent_py.clone(), }) })); @@ -62,10 +58,17 @@ pub fn review_corpus(candidate: &HarnessRow, recent: &[HarnessRow]) -> Vec HarnessRow { + fn harness( + id: &str, + miner: &str, + coldkey: Option<&str>, + source: &str, + created_at_ms: u64, + ) -> HarnessRow { HarnessRow { id: id.into(), miner_hotkey: miner.into(), + miner_coldkey: coldkey.map(str::to_owned), agent_py: source.into(), pyproject_toml: "[project]\nname='x'\nversion='0.1.0'\n".into(), extra_files: std::collections::BTreeMap::new(), @@ -77,6 +80,9 @@ mod tests { const AA: &str = "aa"; const BB: &str = "bb"; + const CC: &str = "cc"; + const COLD_X: &str = "11"; + const COLD_Y: &str = "22"; fn ids(entries: &[CorpusEntry]) -> Vec<&str> { entries.iter().map(|e| e.id.as_str()).collect() @@ -88,36 +94,48 @@ mod tests { #[test] fn own_previous_version_is_never_compared_against() { - let v1 = harness("h1", AA, "def run(t):\n pass\n", 1_000); - let v2 = harness("h2", AA, "def run(t):\n pass\n", 2_000); + let v1 = harness("h1", AA, Some(COLD_X), "def run(t):\n pass\n", 1_000); + let v2 = harness("h2", AA, Some(COLD_X), "def run(t):\n pass\n", 2_000); let recent = vec![v2.clone(), v1]; - - assert!( - gate_corpus(&v2, &recent).is_empty(), - "a miner's own v1 must not be a copy victim for their v2" - ); - assert_eq!( - ids(&review_corpus(&v2, &recent)), - vec!["baseline"], - "self-revision must not reach the LLM corpus either" - ); + assert!(gate_corpus(&v2, &recent).is_empty()); + assert_eq!(ids(&review_corpus(&v2, &recent)), vec!["baseline"]); } #[test] fn hotkey_match_is_case_insensitive() { - let mine_old = harness("h1", "AABB", "old\n", 1_000); - let mine_new = harness("h2", "aabb", "new\n", 2_000); + let mine_old = harness("h1", "AABB", Some(COLD_X), "old\n", 1_000); + let mine_new = harness("h2", "aabb", Some(COLD_X), "new\n", 2_000); let recent = vec![mine_new.clone(), mine_old]; assert!(gate_corpus(&mine_new, &recent).is_empty()); assert_eq!(ids(&review_corpus(&mine_new, &recent)), vec!["baseline"]); } #[test] - fn other_miner_prior_art_stays_in_both_corpora() { - let victim = harness("h1", BB, "def run(t):\n pass\n", 1_000); - let copier = harness("h2", AA, "def run(t):\n pass\n", 2_000); + fn same_coldkey_different_hotkey_is_excluded() { + let prior = harness("h1", AA, Some(COLD_X), "prior\n", 1_000); + let next = harness("h2", BB, Some(COLD_X), "next\n", 2_000); + let recent = vec![next.clone(), prior]; + assert!(gate_corpus(&next, &recent).is_empty()); + assert_eq!(ids(&review_corpus(&next, &recent)), vec!["baseline"]); + } + + #[test] + fn different_coldkey_prior_art_stays_in_both_corpora() { + let victim = harness("h1", BB, Some(COLD_Y), "def run(t):\n pass\n", 1_000); + let copier = harness("h2", AA, Some(COLD_X), "def run(t):\n pass\n", 2_000); let recent = vec![copier.clone(), victim]; + assert_eq!(gate_ids(&gate_corpus(&copier, &recent)), vec!["harness:h1"]); + assert_eq!( + ids(&review_corpus(&copier, &recent)), + vec!["baseline", "harness:h1"] + ); + } + #[test] + fn other_miner_prior_art_stays_in_both_corpora() { + let victim = harness("h1", BB, None, "def run(t):\n pass\n", 1_000); + let copier = harness("h2", AA, None, "def run(t):\n pass\n", 2_000); + let recent = vec![copier.clone(), victim]; assert_eq!(gate_ids(&gate_corpus(&copier, &recent)), vec!["harness:h1"]); assert_eq!( ids(&review_corpus(&copier, &recent)), @@ -125,15 +143,24 @@ mod tests { ); } + #[test] + fn missing_coldkey_falls_back_to_hotkey_only() { + let prior = harness("h1", AA, None, "prior\n", 1_000); + let next = harness("h2", BB, Some(COLD_X), "next\n", 2_000); + let foreign = harness("h3", CC, Some(COLD_Y), "foreign\n", 1_500); + let recent = vec![next.clone(), foreign, prior]; + assert_eq!( + gate_ids(&gate_corpus(&next, &recent)), + vec!["harness:h3", "harness:h1"] + ); + } + #[test] fn candidate_outside_the_recent_window_still_excludes_itself() { - // The candidate is deliberately absent from `recent` (aged out): the - // rules must come from the candidate row, not from a lookup. - let mine_old = harness("h1", AA, "old\n", 1_000); - let theirs = harness("h3", BB, "theirs\n", 1_500); - let mine_new = harness("h2", AA, "new\n", 2_000); + let mine_old = harness("h1", AA, Some(COLD_X), "old\n", 1_000); + let theirs = harness("h3", BB, Some(COLD_Y), "theirs\n", 1_500); + let mine_new = harness("h2", AA, Some(COLD_X), "new\n", 2_000); let recent = vec![theirs, mine_old]; - assert_eq!( gate_ids(&gate_corpus(&mine_new, &recent)), vec!["harness:h3"] @@ -146,37 +173,28 @@ mod tests { #[test] fn review_corpus_holds_prior_art_only() { - let candidate = harness("h1", AA, "mine\n", 1_000); - let later = harness("h2", BB, "later\n", 5_000); - let unknown = harness("h3", BB, "legacy\n", 0); + let candidate = harness("h1", AA, Some(COLD_X), "mine\n", 1_000); + let later = harness("h2", BB, Some(COLD_Y), "later\n", 5_000); + let unknown = harness("h3", BB, Some(COLD_Y), "legacy\n", 0); let recent = vec![later, unknown]; - - // A later copycat must never make the original look like the copier. assert_eq!(ids(&review_corpus(&candidate, &recent)), vec!["baseline"]); - // The gate keeps both and orders them itself. assert_eq!(gate_corpus(&candidate, &recent).len(), 2); } #[test] fn legacy_candidate_keeps_timestamped_other_hotkeys() { - let legacy = harness("h0", AA, "legacy\n", 0); - let prior = harness("h1", BB, "prior\n", 1_000); - let recent = vec![prior]; + let legacy = harness("h0", AA, Some(COLD_X), "legacy\n", 0); + let prior = harness("h1", BB, Some(COLD_Y), "prior\n", 1_000); assert_eq!( - ids(&review_corpus(&legacy, &recent)), - vec!["baseline", "harness:h1"], - "unknown candidate timestamp must not empty the review corpus" + ids(&review_corpus(&legacy, &[prior])), + vec!["baseline", "harness:h1"] ); } #[test] fn baseline_is_always_available_to_the_reviewer() { - let candidate = harness("h1", AA, "mine\n", 1_000); - let corpus = review_corpus(&candidate, &[]); + let corpus = review_corpus(&harness("h1", AA, None, "mine\n", 1_000), &[]); assert_eq!(ids(&corpus), vec!["baseline"]); - assert!( - corpus[0].source.contains("def run("), - "baseline agent source" - ); + assert!(corpus[0].source.contains("def run(")); } } diff --git a/crates/design-challenge/src/host_sim.rs b/crates/design-challenge/src/host_sim.rs index c4daf81bc..ca63f131f 100644 --- a/crates/design-challenge/src/host_sim.rs +++ b/crates/design-challenge/src/host_sim.rs @@ -1,16 +1,10 @@ //! Host `SimSandbox` gate — fail-closed outside explicit non-prod CI opt-in. -//! -//! Production / staging droplets must evaluate Design only in Docker. Host -//! Python sim is allowed only when `BASE_ALLOW_HOST_SIM` is truthy **and** the -//! deploy env is not prod (mainnet netuid 100 or `BASE_DEPLOY_ENV=prod`). +//! Prod/staging must use Docker; host sim needs `BASE_ALLOW_HOST_SIM` + non-prod. /// Mainnet netuid / explicit deploy env → prod (host Sim forbidden). #[must_use] pub fn is_prod_env(netuid: u16, deploy_env: Option<&str>) -> bool { - if netuid == 100 { - return true; - } - matches!(deploy_env, Some("prod" | "production")) + netuid == 100 || matches!(deploy_env, Some("prod" | "production")) } /// Whether host `SimSandbox` may be selected. @@ -19,7 +13,7 @@ pub fn host_sim_allowed(netuid: u16, allow_host_sim: bool, deploy_env: Option<&s allow_host_sim && !is_prod_env(netuid, deploy_env) } -/// Error when `DESIGN_FORCE_SIM` / force-sim is requested without host-sim opt-in. +/// Error when force-sim is requested without host-sim opt-in. #[must_use] pub fn force_sim_refusal_reason() -> &'static str { "DESIGN_FORCE_SIM requires BASE_ALLOW_HOST_SIM=1 and non-prod \ diff --git a/crates/design-challenge/src/score.rs b/crates/design-challenge/src/score.rs index fdc3ed61a..19332dd81 100644 --- a/crates/design-challenge/src/score.rs +++ b/crates/design-challenge/src/score.rs @@ -1,10 +1,5 @@ -//! Round + window scoring: admin round wins → rolling 10-round points share. -//! -//! Elo / pairwise annotation is no longer on the on-chain leaf path. -//! `challenge_scoring_version = 3`: miners share `SCORE_MAX` in proportion to -//! their round-win points over the last [`SCORING_WINDOW_ROUNDS`] rounds -//! (rolling window, cheat excluded). Replaces the v2 daily ≥2-wins equal -//! share. +//! Round + window scoring: admin wins → rolling [`SCORING_WINDOW_ROUNDS`] share. +//! `challenge_scoring_version = 3`: proportional `SCORE_MAX` by window points. use std::collections::{BTreeMap, BTreeSet}; diff --git a/crates/design-challenge/tests/cheat_fixtures.rs b/crates/design-challenge/tests/cheat_fixtures.rs index 9d2a447f2..9bf27a84b 100644 --- a/crates/design-challenge/tests/cheat_fixtures.rs +++ b/crates/design-challenge/tests/cheat_fixtures.rs @@ -79,6 +79,7 @@ fn harness_row(id: &str, miner: &str, agent_py: &str, created_at_ms: u64) -> Har HarnessRow { id: id.into(), miner_hotkey: miner.into(), + miner_coldkey: None, agent_py: agent_py.into(), pyproject_toml: BASELINE_PYPROJECT.into(), extra_files: BTreeMap::new(), diff --git a/crates/design-challenge/tests/e2e_sim.rs b/crates/design-challenge/tests/e2e_sim.rs index 17390733b..d02a2c1e7 100644 --- a/crates/design-challenge/tests/e2e_sim.rs +++ b/crates/design-challenge/tests/e2e_sim.rs @@ -35,6 +35,7 @@ async fn sim_pipeline_pages_and_admin_score() { .insert_harness(&HarnessRow { id: hid.clone(), miner_hotkey: bundle.miner_hotkey.clone(), + miner_coldkey: None, agent_py: bundle.agent_py.clone(), pyproject_toml: bundle.pyproject_toml.clone(), extra_files: BTreeMap::new(), diff --git a/crates/design-challenge/tests/orchestrator_retry.rs b/crates/design-challenge/tests/orchestrator_retry.rs index 128df0f03..34238f80a 100644 --- a/crates/design-challenge/tests/orchestrator_retry.rs +++ b/crates/design-challenge/tests/orchestrator_retry.rs @@ -22,6 +22,7 @@ async fn seed(store: &MemoryDesignStore, hid: &str) { .insert_harness(&HarnessRow { id: hid.to_owned(), miner_hotkey: hk(), + miner_coldkey: None, agent_py: "def run(task, llm, out):\n pass\n".into(), pyproject_toml: "[project]\nname='x'\nversion='0'\n".into(), extra_files: BTreeMap::new(), diff --git a/crates/design-challenge/tests/screenshot_backfill.rs b/crates/design-challenge/tests/screenshot_backfill.rs index eba075794..ec3f6cdc9 100644 --- a/crates/design-challenge/tests/screenshot_backfill.rs +++ b/crates/design-challenge/tests/screenshot_backfill.rs @@ -41,6 +41,7 @@ async fn seed_run(store: &MemoryDesignStore, id: &str, created_at_ms: u64) { .insert_harness(&HarnessRow { id: format!("h-{id}"), miner_hotkey: "cd".repeat(32), + miner_coldkey: None, agent_py: "def run(task, llm, out):\n pass\n".into(), pyproject_toml: "[project]\nname='x'\nversion='0'\n".into(), extra_files: BTreeMap::new(), diff --git a/crates/design-db/src/lib.rs b/crates/design-db/src/lib.rs index 87dad3578..23b3deae5 100644 --- a/crates/design-db/src/lib.rs +++ b/crates/design-db/src/lib.rs @@ -14,6 +14,8 @@ pub struct DesignHarnessRow { pub id: String, /// Miner hotkey (lowercase 64 hex). pub miner_hotkey: String, + /// Owning coldkey (lowercase 64 hex), when known at intake. + pub miner_coldkey: Option, /// agent.py source. pub agent_py: String, /// pyproject.toml. @@ -164,6 +166,8 @@ pub struct NewDesignHarness<'a> { pub id: &'a str, /// miner. pub miner_hotkey: &'a str, + /// owning coldkey (optional). + pub miner_coldkey: Option<&'a str>, /// agent.py. pub agent_py: &'a str, /// pyproject. @@ -259,7 +263,8 @@ pub struct NewDesignStageEvent<'a> { } const HARNESS_COLS: &str = - "id, miner_hotkey, agent_py, pyproject_toml, extra_files, active, eliminated_until_round, \ + "id, miner_hotkey, miner_coldkey, agent_py, pyproject_toml, extra_files, active, \ + eliminated_until_round, \ (FLOOR(EXTRACT(EPOCH FROM created_at) * 1000))::BIGINT AS created_at_ms"; const ROUND_COLS: &str = "round_id, epoch, netuid, prompt_set_digest, status"; const RUN_COLS: &str = "id, round_id, harness_id, prompt_id, status, artifact_digest, \ @@ -276,11 +281,13 @@ const RATING_COLS: &str = /// SQL error. pub async fn insert_design_harness(pool: &PgPool, n: &NewDesignHarness<'_>) -> Result<(), DbError> { sqlx::query( - "INSERT INTO design_harness (id, miner_hotkey, agent_py, pyproject_toml, extra_files) \ - VALUES ($1, $2, $3, $4, $5)", + "INSERT INTO design_harness \ + (id, miner_hotkey, miner_coldkey, agent_py, pyproject_toml, extra_files) \ + VALUES ($1, $2, $3, $4, $5, $6)", ) .bind(n.id) .bind(n.miner_hotkey) + .bind(n.miner_coldkey) .bind(n.agent_py) .bind(n.pyproject_toml) .bind(&n.extra_files) diff --git a/crates/design-http/src/api.rs b/crates/design-http/src/api.rs index 4ad24a150..5ff13cf9d 100644 --- a/crates/design-http/src/api.rs +++ b/crates/design-http/src/api.rs @@ -361,9 +361,15 @@ async fn post_harness( let mut extras = req.extra_files; encode_env_into_extras(&mut extras, &req.env_vars); + let miner_coldkey = st + .metagraph + .as_ref() + .and_then(|c| c.snapshot()) + .and_then(|v| v.coldkey_hex_of(&hotkey)); let row = HarnessRow { id: id.clone(), miner_hotkey: hotkey.clone(), + miner_coldkey, agent_py: req.agent_py, pyproject_toml: req.pyproject_toml, extra_files: extras, @@ -1161,7 +1167,8 @@ mod tests { let gating = Arc::new(MemoryGatingStore::new()); let metagraph = metagraph_hotkeys.map(|keys| { let cache = Arc::new(MetagraphCache::new()); - cache.update(541, &keys.iter().map(|h| h.to_vec()).collect::>()); + let raw: Vec> = keys.iter().map(|h| h.to_vec()).collect(); + cache.update(541, &raw, &raw); cache }); ( @@ -1225,6 +1232,7 @@ mod tests { HarnessRow { id: format!("harness-{marker}"), miner_hotkey: hotkey.to_owned(), + miner_coldkey: None, agent_py: format!("def run(task, llm, out):\n pass # {marker}\n"), pyproject_toml: "[project]\nname='x'\nversion='0.1.0'\n".into(), extra_files: BTreeMap::new(), diff --git a/crates/design-http/tests/admin_winners.rs b/crates/design-http/tests/admin_winners.rs index 71ba08149..b2aa1df51 100644 --- a/crates/design-http/tests/admin_winners.rs +++ b/crates/design-http/tests/admin_winners.rs @@ -44,6 +44,7 @@ async fn setup() -> (Arc, Arc) { .insert_harness(&HarnessRow { id: hid.into(), miner_hotkey: miner.repeat(32), + miner_coldkey: None, agent_py: "print(1)".into(), pyproject_toml: "[project]\nname='x'\nversion='0'\n".into(), extra_files: BTreeMap::default(), diff --git a/crates/design-store/src/dbstore.rs b/crates/design-store/src/dbstore.rs index c9b69f900..44e7d93cd 100644 --- a/crates/design-store/src/dbstore.rs +++ b/crates/design-store/src/dbstore.rs @@ -48,6 +48,7 @@ fn harness_from(r: dbs::DesignHarnessRow) -> HarnessRow { HarnessRow { id: r.id, miner_hotkey: r.miner_hotkey, + miner_coldkey: r.miner_coldkey, agent_py: r.agent_py, pyproject_toml: r.pyproject_toml, extra_files: extras_from_value(&r.extra_files), @@ -134,6 +135,7 @@ impl DesignStore for DbDesignStore { &dbs::NewDesignHarness { id: &row.id, miner_hotkey: &row.miner_hotkey, + miner_coldkey: row.miner_coldkey.as_deref(), agent_py: &row.agent_py, pyproject_toml: &row.pyproject_toml, extra_files: extras, diff --git a/crates/design-store/src/store.rs b/crates/design-store/src/store.rs index bc5a64e71..93ac83d04 100644 --- a/crates/design-store/src/store.rs +++ b/crates/design-store/src/store.rs @@ -143,6 +143,8 @@ pub struct HarnessRow { pub id: String, /// Miner hotkey. pub miner_hotkey: String, + /// Owning coldkey (lowercase 64 hex), when known at intake. + pub miner_coldkey: Option, /// agent.py. pub agent_py: String, /// pyproject.toml. diff --git a/crates/prism-challenge/src/agentic.rs b/crates/prism-challenge/src/agentic.rs index 8402cab08..ca9d6e74f 100644 --- a/crates/prism-challenge/src/agentic.rs +++ b/crates/prism-challenge/src/agentic.rs @@ -3,11 +3,24 @@ use std::fs; use std::path::Path; -use challenge_agentic::{CorpusEntry, ReviewRequest, PRISM_DOMAIN_RULES}; +use challenge_agentic::{ + same_miner_identity, CorpusEntry, GateCorpusEntry, ReviewRequest, PRISM_DOMAIN_RULES, +}; use prism_lium::{EvalReceipt, RemoteExecResult}; use prism_recipe::BASELINE_ARCHITECTURE_PY; use prism_store::SubmissionState; +/// True when `other` is the same economic miner as `candidate` (hotkey or coldkey). +#[must_use] +pub fn same_miner(candidate: &SubmissionState, other: &SubmissionState) -> bool { + same_miner_identity( + &candidate.miner_hotkey, + candidate.miner_coldkey.as_deref(), + &other.miner_hotkey, + other.miner_coldkey.as_deref(), + ) +} + /// Build a temp workdir + [`ReviewRequest`] for one Prism submission. /// /// # Errors @@ -47,6 +60,23 @@ pub fn build_review_request( }) } +/// Pre-LLM copy-gate corpus: other miners' prior art only (hotkey + coldkey). +#[must_use] +pub fn gate_corpus_from_rows( + candidate: &SubmissionState, + recent: &[SubmissionState], +) -> Vec { + recent + .iter() + .filter(|r| r.id != candidate.id && !same_miner(candidate, r)) + .map(|r| GateCorpusEntry { + id: format!("subm:{}", r.id), + source: r.architecture_py.clone(), + created_at_ms: r.created_at_ms, + }) + .collect() +} + /// Baseline + recent terminated submissions as agentic corpus entries. /// /// Corpus entries are **architecture.py only** (similarity v2): `training.py` @@ -54,10 +84,11 @@ pub fn build_review_request( /// script on two different architectures is legitimate competition behavior. /// `exempt_arch` drops entries byte-equal to that source (training-only /// submissions on a registry architecture: the identity is by design). +/// Same-hotkey and same-coldkey prior art are excluded. #[must_use] pub fn corpus_from_rows( - current_id: &str, - recent: &[prism_store::SubmissionState], + candidate: &SubmissionState, + recent: &[SubmissionState], exempt_arch: Option<&str>, ) -> Vec { let mut v = vec![CorpusEntry { @@ -65,7 +96,10 @@ pub fn corpus_from_rows( source: BASELINE_ARCHITECTURE_PY.into(), }]; for r in recent { - if r.id == current_id || Some(r.architecture_py.as_str()) == exempt_arch { + if r.id == candidate.id || same_miner(candidate, r) { + continue; + } + if Some(r.architecture_py.as_str()) == exempt_arch { continue; } let label = if r.id.len() >= 8 { @@ -80,3 +114,93 @@ pub fn corpus_from_rows( } v } + +#[cfg(test)] +mod tests { + use super::*; + use prism_store::{FinalScore, Stage}; + + fn row( + id: &str, + hotkey: &str, + coldkey: Option<&str>, + arch: &str, + created_at_ms: u64, + ) -> SubmissionState { + SubmissionState { + id: id.into(), + miner_hotkey: hotkey.into(), + miner_coldkey: coldkey.map(str::to_owned), + epoch: 1, + netuid: 1, + status: Stage::Terminated, + architecture_py: arch.into(), + training_py: "train".into(), + label: None, + pod_id: None, + pod_provider: None, + receipt: None, + metrics_json: None, + bpb: Some(1.0), + arch_id: None, + review: None, + similarity: None, + final_score: Some(FinalScore::Score(1)), + retry_count: 0, + error_detail: None, + created_at_ms, + updated_at_ms: created_at_ms, + } + } + + #[test] + fn same_hotkey_prior_art_excluded() { + let prior = row("aaaaaaaa", "aa", Some("11"), "arch_a", 1_000); + let next = row("bbbbbbbb", "aa", Some("11"), "arch_b", 2_000); + let recent = vec![prior, next.clone()]; + assert!(gate_corpus_from_rows(&next, &recent).is_empty()); + assert_eq!( + corpus_from_rows(&next, &recent, None) + .iter() + .map(|e| e.id.as_str()) + .collect::>(), + vec!["baseline"] + ); + } + + #[test] + fn same_coldkey_different_hotkey_excluded() { + let prior = row("aaaaaaaa", "aa", Some("11"), "arch_a", 1_000); + let next = row("bbbbbbbb", "bb", Some("11"), "arch_b", 2_000); + let recent = vec![prior, next.clone()]; + assert!(gate_corpus_from_rows(&next, &recent).is_empty()); + assert_eq!( + corpus_from_rows(&next, &recent, None) + .iter() + .map(|e| e.id.as_str()) + .collect::>(), + vec!["baseline"] + ); + } + + #[test] + fn different_coldkey_stays_in_corpus() { + let victim = row("aaaaaaaa", "aa", Some("11"), "arch_a", 1_000); + let copier = row("bbbbbbbb", "bb", Some("22"), "arch_b", 2_000); + let recent = vec![victim, copier.clone()]; + assert_eq!( + gate_corpus_from_rows(&copier, &recent) + .iter() + .map(|e| e.id.as_str()) + .collect::>(), + vec!["subm:aaaaaaaa"] + ); + assert_eq!( + corpus_from_rows(&copier, &recent, None) + .iter() + .map(|e| e.id.as_str()) + .collect::>(), + vec!["baseline", "subm:aaaaaaaa"] + ); + } +} diff --git a/crates/prism-challenge/src/api.rs b/crates/prism-challenge/src/api.rs index 7b4910a9f..04a9f09ce 100644 --- a/crates/prism-challenge/src/api.rs +++ b/crates/prism-challenge/src/api.rs @@ -259,9 +259,16 @@ async fn post_submission( } }; let epoch = st.epoch.load(std::sync::atomic::Ordering::Relaxed); + let miner_hotkey = req.miner_hotkey.trim().to_owned(); + let miner_coldkey = st + .metagraph + .as_ref() + .and_then(|c| c.snapshot()) + .and_then(|v| v.coldkey_hex_of(&miner_hotkey)); let row = SubmissionState { id: id.clone(), - miner_hotkey: req.miner_hotkey.trim().to_owned(), + miner_hotkey, + miner_coldkey, epoch, netuid: st.netuid, status: Stage::Queued, @@ -600,13 +607,9 @@ mod tests { ) -> (Arc, Arc) { let gating = Arc::new(submission_gating::MemoryGatingStore::new()); let cache = Arc::new(MetagraphCache::new()); - cache.update( - 541, - &metagraph_hotkeys - .iter() - .map(|h| h.to_vec()) - .collect::>(), - ); + let keys: Vec> = metagraph_hotkeys.iter().map(|h| h.to_vec()).collect(); + // Tests that do not care about shared coldkeys: each hotkey owns itself. + cache.update(541, &keys, &keys); ( Arc::new(AppState { store: Arc::new(MemoryPrismStore::new()), diff --git a/crates/prism-challenge/src/orchestrator.rs b/crates/prism-challenge/src/orchestrator.rs index 08095e0ac..cce1c9689 100644 --- a/crates/prism-challenge/src/orchestrator.rs +++ b/crates/prism-challenge/src/orchestrator.rs @@ -17,7 +17,7 @@ use std::time::Duration; use bundle::NoScoreReasonCode; use chain::ChainClient; -use challenge_agentic::{copy_gate, AgenticBackend, AgenticVerdict, GateCorpusEntry, VerdictKind}; +use challenge_agentic::{copy_gate, AgenticBackend, AgenticVerdict, VerdictKind}; use challenge_common::{expected_set_at_chain, PinnedBlockHash}; use crypto::KEY_LEN; use prism_emit::EpochEmitter; @@ -29,7 +29,7 @@ use submission_gating::{GatingState, GatingStore}; use tokio::time::sleep; use tracing::{info, warn}; -use crate::agentic::{build_review_request, corpus_from_rows}; +use crate::agentic::{build_review_request, corpus_from_rows, gate_corpus_from_rows, same_miner}; use crate::score::{combine_final, FinalOutcome}; use crate::submit::GatewayClient; use prism_store::{FinalScore, PrismStore, Stage, StageEvent, StatePatch, SubmissionState}; @@ -452,15 +452,7 @@ impl Orchestrator { return false; } let recent = self.store.list(None, None, 64).await.unwrap_or_default(); - let corpus: Vec = recent - .into_iter() - .filter(|r| r.id != row.id) - .map(|r| GateCorpusEntry { - id: format!("subm:{}", r.id), - source: r.architecture_py, - created_at_ms: r.created_at_ms, - }) - .collect(); + let corpus = gate_corpus_from_rows(row, &recent); let Some(hit) = copy_gate(&row.architecture_py, row.created_at_ms, &corpus) else { return false; }; @@ -653,7 +645,7 @@ impl Orchestrator { prompt_version: prism_review::SIMILARITY_PROMPT_VERSION, }); } - let corpus = self.similarity_corpus(id).await; + let corpus = self.similarity_corpus(row).await; self.reviewer .similarity(&row.architecture_py, &corpus) .await @@ -686,7 +678,7 @@ impl Orchestrator { // Training-only rows: drop the referenced registry arch from the // corpus (byte-identity with it is by design, not a copy). let corpus = corpus_from_rows( - id, + row, &recent, row.arch_id .is_some() @@ -744,7 +736,7 @@ impl Orchestrator { .await; } - async fn similarity_corpus(&self, current_id: &str) -> Vec { + async fn similarity_corpus(&self, candidate: &SubmissionState) -> Vec { let recent = self .store .list(Some("terminated"), None, self.cfg.similarity_corpus_limit) @@ -756,11 +748,16 @@ impl Orchestrator { training_py: BASELINE_TRAINING_PY.into(), }]; for r in recent { - if r.id == current_id { + if r.id == candidate.id || same_miner(candidate, &r) { continue; } + let label = if r.id.len() >= 8 { + format!("subm:{}", &r.id[..8]) + } else { + format!("subm:{}", r.id) + }; v.push(SourceSnippet { - label: format!("subm:{}", &r.id[..8]), + label, architecture_py: r.architecture_py.clone(), training_py: r.training_py.clone(), }); diff --git a/crates/prism-challenge/tests/arch_competition.rs b/crates/prism-challenge/tests/arch_competition.rs index 1923efae9..8d7183d84 100644 --- a/crates/prism-challenge/tests/arch_competition.rs +++ b/crates/prism-challenge/tests/arch_competition.rs @@ -108,6 +108,7 @@ fn row( SubmissionState { id: id.into(), miner_hotkey: hotkey.into(), + miner_coldkey: None, epoch: 7, netuid: 541, status: Stage::Queued, diff --git a/crates/prism-challenge/tests/cheat_arch_copy.rs b/crates/prism-challenge/tests/cheat_arch_copy.rs index f6a5c62f8..8102fe51b 100644 --- a/crates/prism-challenge/tests/cheat_arch_copy.rs +++ b/crates/prism-challenge/tests/cheat_arch_copy.rs @@ -109,6 +109,7 @@ async fn baseline_arch_train_copy_scores_zero() { .insert_queued(&SubmissionState { id: id.clone(), miner_hotkey: "22".repeat(32), + miner_coldkey: None, epoch: 7, netuid: 541, status: Stage::Queued, diff --git a/crates/prism-challenge/tests/cheat_metrics.rs b/crates/prism-challenge/tests/cheat_metrics.rs index ebdb29cf3..517783fcc 100644 --- a/crates/prism-challenge/tests/cheat_metrics.rs +++ b/crates/prism-challenge/tests/cheat_metrics.rs @@ -115,6 +115,7 @@ def train(model, ctx): .insert_queued(&SubmissionState { id: id.clone(), miner_hotkey: "11".repeat(32), + miner_coldkey: None, epoch: 7, netuid: 541, status: Stage::Queued, diff --git a/crates/prism-challenge/tests/copy_gate.rs b/crates/prism-challenge/tests/copy_gate.rs index a13524b68..0b4f9fbe9 100644 --- a/crates/prism-challenge/tests/copy_gate.rs +++ b/crates/prism-challenge/tests/copy_gate.rs @@ -97,6 +97,7 @@ fn row( SubmissionState { id: id.into(), miner_hotkey: hotkey.into(), + miner_coldkey: None, epoch: 7, netuid: 541, status, diff --git a/crates/prism-challenge/tests/e2e_orchestrate_sim.rs b/crates/prism-challenge/tests/e2e_orchestrate_sim.rs index f9ebb4f5f..efa7dd6bc 100644 --- a/crates/prism-challenge/tests/e2e_orchestrate_sim.rs +++ b/crates/prism-challenge/tests/e2e_orchestrate_sim.rs @@ -127,6 +127,7 @@ async fn orchestrator_completes_one_submission_and_emits() { .insert_queued(&SubmissionState { id: id.clone(), miner_hotkey: req.miner_hotkey.clone(), + miner_coldkey: None, epoch: 7, netuid: 541, status: Stage::Queued, @@ -189,6 +190,7 @@ async fn submission_detail_exposes_metrics_over_http() { .insert_queued(&SubmissionState { id: id.clone(), miner_hotkey: req.miner_hotkey.clone(), + miner_coldkey: None, epoch: 7, netuid: 541, status: Stage::Queued, @@ -270,6 +272,7 @@ async fn emit_and_submit_covers_expected_set() { .insert_queued(&SubmissionState { id: "seed".into(), miner_hotkey: hex::encode(hk), + miner_coldkey: None, epoch: 3, netuid: 541, status: Stage::Terminated, diff --git a/crates/prism-emit/tests/epoch_semantics.rs b/crates/prism-emit/tests/epoch_semantics.rs index 8249afef4..5770db5a0 100644 --- a/crates/prism-emit/tests/epoch_semantics.rs +++ b/crates/prism-emit/tests/epoch_semantics.rs @@ -64,6 +64,7 @@ fn scored_row(id: &str, hotkey: &str, accept_epoch: u64, score: FinalScore) -> S SubmissionState { id: id.into(), miner_hotkey: hotkey.into(), + miner_coldkey: None, epoch: accept_epoch, netuid: 541, status: Stage::Terminated, diff --git a/crates/prism-store/src/dbprism.rs b/crates/prism-store/src/dbprism.rs index 805b8ddb4..ef975874d 100644 --- a/crates/prism-store/src/dbprism.rs +++ b/crates/prism-store/src/dbprism.rs @@ -58,6 +58,7 @@ fn row_to_state(r: dbs::PrismSubmissionRow) -> SubmissionState { SubmissionState { id: r.id, miner_hotkey: r.miner_hotkey, + miner_coldkey: r.miner_coldkey, epoch: r.epoch.cast_unsigned(), netuid: u16::try_from(r.netuid).unwrap_or(0), status, @@ -174,6 +175,7 @@ impl PrismStore for DbPrismStore { &dbs::NewPrismSubmission { id: &row.id, miner_hotkey: &row.miner_hotkey, + miner_coldkey: row.miner_coldkey.as_deref(), epoch: i64::try_from(row.epoch).unwrap_or(i64::MAX), netuid: i32::from(row.netuid), label: row.label.as_deref(), diff --git a/crates/prism-store/src/store.rs b/crates/prism-store/src/store.rs index 3d433fa1a..2ac82d171 100644 --- a/crates/prism-store/src/store.rs +++ b/crates/prism-store/src/store.rs @@ -31,6 +31,8 @@ pub struct SubmissionState { pub id: SubmissionId, /// Miner hotkey hex (64). pub miner_hotkey: String, + /// Owning coldkey (lowercase 64 hex), when known at intake. + pub miner_coldkey: Option, /// Chain epoch at acceptance. pub epoch: u64, /// Netuid. @@ -813,6 +815,7 @@ mod tests { SubmissionState { id: id.into(), miner_hotkey: hotkey.into(), + miner_coldkey: None, epoch: 7, netuid: 541, status: Stage::Queued, diff --git a/crates/submission-gating/src/lib.rs b/crates/submission-gating/src/lib.rs index 4450a135e..5e1b2d435 100644 --- a/crates/submission-gating/src/lib.rs +++ b/crates/submission-gating/src/lib.rs @@ -420,6 +420,9 @@ pub struct MetagraphView { pub fetched_at_secs: u64, /// Hotkeys in UID order. pub hotkeys: Vec<[u8; 32]>, + /// Coldkeys UID-aligned with [`Self::hotkeys`] (`SubtensorModule.Owner`). + /// All-zero entries mean unknown / chain default. + pub coldkeys: Vec<[u8; 32]>, } impl MetagraphView { @@ -439,6 +442,17 @@ impl MetagraphView { pub fn contains_hex(&self, hotkey_hex: &str) -> bool { self.uid_of_hex(hotkey_hex).is_some() } + + /// Lowercase-hex coldkey for a hotkey, when the Owner entry is non-zero. + #[must_use] + pub fn coldkey_hex_of(&self, hotkey_hex: &str) -> Option { + let uid = self.uid_of_hex(hotkey_hex)? as usize; + let ck = self.coldkeys.get(uid)?; + if ck.iter().all(|&b| b == 0) { + return None; + } + Some(hex::encode(ck)) + } } /// Cached metagraph snapshot refreshed by the watcher; intake reads only this @@ -456,17 +470,27 @@ impl MetagraphCache { } /// Install a fresh snapshot from raw (possibly non-32-byte) hotkeys. - pub fn update(&self, netuid: u16, hotkeys: &[Vec]) { + /// + /// `coldkeys` should be UID-aligned with `hotkeys` when provided; shorter + /// / empty slices leave unknown (zero) coldkeys for missing UIDs. + pub fn update(&self, netuid: u16, hotkeys: &[Vec], coldkeys: &[Vec]) { let keys: Vec<[u8; 32]> = hotkeys .iter() .filter_map(|h| <[u8; 32]>::try_from(h.as_slice()).ok()) .collect(); + let mut cks: Vec<[u8; 32]> = coldkeys + .iter() + .take(keys.len()) + .map(|c| <[u8; 32]>::try_from(c.as_slice()).unwrap_or([0; 32])) + .collect(); + cks.resize(keys.len(), [0; 32]); let view = MetagraphView { netuid, fetched_at_secs: std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) .map_or(0, |d| d.as_secs()), hotkeys: keys, + coldkeys: cks, }; if let Ok(mut g) = self.inner.write() { *g = Some(view); @@ -520,7 +544,7 @@ pub async fn watch_once( let mg = chain .metagraph_at(&hash) .map_err(|e| GatingError::Backend(format!("metagraph: {e}")))?; - cache.update(netuid, &mg.hotkeys); + cache.update(netuid, &mg.hotkeys, &mg.coldkeys); let Some(view) = cache.snapshot() else { return Ok(0); }; @@ -576,9 +600,11 @@ mod tests { netuid: 541, fetched_at_secs: 0, hotkeys: vec![[0xAA; 32], [0xBB; 32]], + coldkeys: vec![[0x11; 32], [0x22; 32]], }; assert_eq!(view.uid_of_hex(&hk(0xAA)), Some(0)); assert_eq!(view.uid_of_hex(&hk(0xBB)), Some(1)); + assert_eq!(view.coldkey_hex_of(&hk(0xAA)), Some(hk(0x11))); assert!(!view.contains_hex(&hk(0xCC))); assert!(!view.contains_hex("not-hex")); } @@ -596,6 +622,7 @@ mod tests { netuid: 541, fetched_at_secs: 0, hotkeys: vec![[0xAA; 32]], // 0xBB deregistered / replaced + coldkeys: vec![[0xAA; 32]], }; let reset = reconcile_metagraph(&s, "design", &view).await.unwrap(); assert_eq!(reset, vec![hk(0xBB)]); diff --git a/docs/DESIGN_CHALLENGE.md b/docs/DESIGN_CHALLENGE.md index 8565e7af3..8e2321596 100644 --- a/docs/DESIGN_CHALLENGE.md +++ b/docs/DESIGN_CHALLENGE.md @@ -378,10 +378,11 @@ through to the LLM. Starting from the published miner **baseline** is never a cheat signal (baseline-zeroing fix); copying another *miner's* harness is. **Corpus rule (both the gate and the LLM review):** the comparison corpus is -**other hotkeys' prior art only** — entries owned by the candidate's own -`miner_hotkey` are excluded, and so is anything created at or after the -candidate. A miner iterating on their own harness is therefore never scored -against their own previous version. Selection lives in one place, +**other hotkeys' and same-coldkey prior art only** — entries owned by the +candidate's own `miner_hotkey` **or** `miner_coldkey` are excluded, and so is +anything created at or after the candidate. After 1-max gating a miner iterates +via a new hotkey under the same coldkey; those revisions must not be treated as +cross-miner copies. Selection lives in one place, [`crates/design-challenge/src/corpus.rs`](../crates/design-challenge/src/corpus.rs), so the gate and the review can never disagree. diff --git a/docs/DESIGN_CHALLENGE_CHECKLIST.md b/docs/DESIGN_CHALLENGE_CHECKLIST.md index f3dae9a33..5ed30557e 100644 --- a/docs/DESIGN_CHALLENGE_CHECKLIST.md +++ b/docs/DESIGN_CHALLENGE_CHECKLIST.md @@ -46,7 +46,7 @@ pins without bumping `challenge_scoring_version`. | scoring_window | `SCORING_WINDOW_ROUNDS = 10` | | daily_quota | `MANUAL_DAILY_RUN_QUOTA = 10` | | scheduled_quota | `DESIGN_SCHEDULED_DAILY_RUN_CAP` | -| selfsim_excluded | `other hotkeys' prior art only` | +| selfsim_excluded | `other hotkeys' and same-coldkey prior art only` | | prompts_per_round | `3 prompts` | | bank_v1 | `bank_v1.json` | | agent_py | `agent.py` | diff --git a/docs/PRISM.md b/docs/PRISM.md index 2eac0b701..10c4db12f 100644 --- a/docs/PRISM.md +++ b/docs/PRISM.md @@ -172,16 +172,18 @@ absent/empty → publishing is a graceful no-op, scoring is unaffected. ## Agentic anti-cheat + AST + metrics gate Before any pod or LLM spend, the **pre-LLM copy gate** compares the -candidate `architecture.py` against recent submissions (byte hash + -`challenge-ast` fingerprints): a byte/AST copy of a **strictly-earlier** -submission (`created_at` ordered) is terminal `rejected` with `Score(0)` — -no pod, no LLM. Ties / unknown timestamps fall through to the LLM path; the -published baseline is exempt (miners start from it). After measure and the -cheap `prism-review` arch-only similarity/quality filters, the shared -`challenge-agentic` loop inspects miner sources with read-only tools +candidate `architecture.py` against recent submissions from **other miners** +(byte hash + `challenge-ast` fingerprints; same-`miner_hotkey` and +same-`miner_coldkey` prior art excluded): a byte/AST copy of a +**strictly-earlier** submission (`created_at` ordered) is terminal `rejected` +with `Score(0)` — no pod, no LLM. Ties / unknown timestamps fall through to +the LLM path; the published baseline is exempt (miners start from it). After +measure and the cheap `prism-review` arch-only similarity/quality filters, the +shared `challenge-agentic` loop inspects miner sources with read-only tools (`list_dir`, `read_file`, `ast_summary`, `ast_diff_nearest`, `read_metrics`) -against an **architecture-only** corpus of baseline + recent submissions. -Final judge is the mandatory `submit_verdict` function-call. +against an **architecture-only** corpus of baseline + other miners' recent +submissions (same hotkey/coldkey exclusion as the gate). Final judge is the +mandatory `submit_verdict` function-call. | Verdict | Leaf effect | |---------|-------------| diff --git a/docs/PRISM_RECIPE.md b/docs/PRISM_RECIPE.md index 0e1e22951..ea163b18a 100644 --- a/docs/PRISM_RECIPE.md +++ b/docs/PRISM_RECIPE.md @@ -108,18 +108,19 @@ review still gates eligibility: ## Anti-copy review A **pre-LLM copy gate** first compares the candidate `architecture.py` -against recent submissions (byte hash + AST fingerprints, `created_at` -ordered): a byte/AST copy of a strictly-earlier architecture is terminal -`rejected` with zero score — no pod time, no LLM spend. The baseline is -exempt (everyone may start from it); created_at ties fall through to the LLM -path below. +against recent submissions from **other miners** (byte hash + AST +fingerprints, `created_at` ordered; same-`miner_hotkey` and +same-`miner_coldkey` prior art excluded): a byte/AST copy of a +strictly-earlier architecture is terminal `rejected` with zero score — no pod +time, no LLM spend. The baseline is exempt (everyone may start from it); +created_at ties fall through to the LLM path below. Each remaining submission then faces an LLM review on the master (`OpenRouter` when the key file `/run/base/openrouter/api_key` exists, else the deterministic `SimReviewer`) over its **architecture only** vs. the -recipe **baseline plus every earlier submission** (`prism_submission` -history, capped at the 6 most recent records). Since similarity v2, -`training.py` is exempt from both candidate and corpus: the same training -script on two different architectures is legitimate. Verdicts: `Original` / -`Suspicious` / `Copied`, with a similarity score and evidence line — all -stored append-only in `prism_stage_event`. +recipe **baseline plus earlier other-miner submissions** (`prism_submission` +history, capped at the 6 most recent records; same hotkey/coldkey exclusion). +Since similarity v2, `training.py` is exempt from both candidate and corpus: +the same training script on two different architectures is legitimate. +Verdicts: `Original` / `Suspicious` / `Copied`, with a similarity score and +evidence line — all stored append-only in `prism_stage_event`. diff --git a/docs/external-miner/design.md b/docs/external-miner/design.md index 44e5d0515..21c8df66e 100644 --- a/docs/external-miner/design.md +++ b/docs/external-miner/design.md @@ -148,9 +148,10 @@ reviewer. A pre-LLM **copy gate** rejects a byte/AST copy of an *earlier* harness outright (`rejected`, `Score(0)`, no LLM call); `cheat` / `suspicious` from the LLM review → `Score(0)`. Starting from the published **baseline** is fine — copying another *miner's* harness is not. Both the copy gate and the LLM -review compare you against **other hotkeys' earlier harnesses only**: your own -previous versions are excluded from the corpus, so iterating on your own -harness is never read as self-copying. +review compare you against **other miners' earlier harnesses only**: your own +previous versions (same hotkey **or** same coldkey) are excluded from the +corpus, so iterating via a new hotkey under the same coldkey is never read as +self-copying. Clean runs await **admin winners** (1 or 2 harnesses per round); each round win is one **point**. Rewards are **not** winner-take-all on a single round: the diff --git a/docs/external-miner/prism.md b/docs/external-miner/prism.md index d6c37ed8b..d13384931 100644 --- a/docs/external-miner/prism.md +++ b/docs/external-miner/prism.md @@ -96,8 +96,11 @@ allowed. Final leaf score is pure bits-per-byte (bpb) on the lattice `[0, SCORE_MAX]`. Cheap similarity plus the shared **agentic** gate (AST + metrics/receipt) force -hard-zero on `cheat` / `suspicious` (and cheap `Copied` / `Suspicious`); -LLM quality is coherence-only, not a grader. **Competition:** per epoch you are +hard-zero on `cheat` / `suspicious` (and cheap `Copied` / `Suspicious`). +Copy/similarity corpora exclude your own prior art (same hotkey **or** same +coldkey), so iterating via a new hotkey under the same coldkey is not treated +as a cross-miner copy. LLM quality is coherence-only, not a grader. +**Competition:** per epoch you are credited the max of (a) your own best training result and (b) for each arch you own, that arch's best result by *any* trainer — architecture owners are rewarded for architectures people win with. Scores first land in the leaf set emitted at the diff --git a/xtask/src/design_check.rs b/xtask/src/design_check.rs index 689594a50..ec8089a61 100644 --- a/xtask/src/design_check.rs +++ b/xtask/src/design_check.rs @@ -39,7 +39,10 @@ const CONTENT_PINS: &[(&str, &str)] = &[ ("scoring_window", "SCORING_WINDOW_ROUNDS = 10"), ("daily_quota", "MANUAL_DAILY_RUN_QUOTA = 10"), ("scheduled_quota", "DESIGN_SCHEDULED_DAILY_RUN_CAP"), - ("selfsim_excluded", "other hotkeys' prior art only"), + ( + "selfsim_excluded", + "other hotkeys' and same-coldkey prior art only", + ), ("prompts_per_round", "3 prompts"), ("bank_v1", "bank_v1.json"), ("agent_py", "agent.py"),