diff --git a/rust_hft/prediction-markets/config/research_missions/polymarket-btc-5m.example.json b/rust_hft/prediction-markets/config/research_missions/polymarket-btc-5m.example.json index fa4d741dd..2496d6e62 100644 --- a/rust_hft/prediction-markets/config/research_missions/polymarket-btc-5m.example.json +++ b/rust_hft/prediction-markets/config/research_missions/polymarket-btc-5m.example.json @@ -15,7 +15,7 @@ "horizon": "5m", "time_cohort_boundary_ms": 0, "prompt_snapshot_id": "sha256:2b55ba0e724dfc9f5a040911e397058a09c6229e42064e4998739d617b368dcb", - "search_policy_snapshot_id": "sha256:f8d80d3125cba1d7c2e4e23e68f3ab63314533ec8df2912a5cf8bff3a60dc352", + "search_policy_snapshot_id": "sha256:3375b087cdaf642916b8a4a99a0816a96200c2860fa042046a12b6741f59bbd3", "search_budget": { "max_candidates": 6, "max_llm_calls": 2, diff --git a/rust_hft/prediction-markets/config/research_missions/polymarket-sol-5m.example.json b/rust_hft/prediction-markets/config/research_missions/polymarket-sol-5m.example.json index 2c5556d57..18786cf8e 100644 --- a/rust_hft/prediction-markets/config/research_missions/polymarket-sol-5m.example.json +++ b/rust_hft/prediction-markets/config/research_missions/polymarket-sol-5m.example.json @@ -15,7 +15,7 @@ "horizon": "5m", "time_cohort_boundary_ms": 0, "prompt_snapshot_id": "sha256:0816ebccf4c75ee6bdcfe315b253c84ddf1808a38687ebe3b924b87bd72a52a9", - "search_policy_snapshot_id": "sha256:f8d80d3125cba1d7c2e4e23e68f3ab63314533ec8df2912a5cf8bff3a60dc352", + "search_policy_snapshot_id": "sha256:3375b087cdaf642916b8a4a99a0816a96200c2860fa042046a12b6741f59bbd3", "search_budget": { "max_candidates": 6, "max_llm_calls": 2, diff --git a/rust_hft/prediction-markets/crates/ploy-research/src/prediction_mcts.rs b/rust_hft/prediction-markets/crates/ploy-research/src/prediction_mcts.rs index 0ba00abc6..53dca10ec 100644 --- a/rust_hft/prediction-markets/crates/ploy-research/src/prediction_mcts.rs +++ b/rust_hft/prediction-markets/crates/ploy-research/src/prediction_mcts.rs @@ -401,11 +401,14 @@ enum ExpansionAction { } fn deterministic_actions(profile: SettlementProbabilityComponentProfile) -> Vec { - eligible_components(profile) - .iter() - .copied() - .map(|component| ExpansionAction::Increase { component }) - .collect() + match profile { + SettlementProbabilityComponentProfile::FullSurface => eligible_components(profile) + .iter() + .copied() + .map(|component| ExpansionAction::Increase { component }) + .collect(), + SettlementProbabilityComponentProfile::MarketMidpointOnly => Vec::new(), + } } #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] @@ -518,6 +521,22 @@ impl PredictionMctsCheckpoint { }) { return Err("prediction MCTS baseline identity mismatch".to_string()); } + let market_midpoint_only = self.config.component_profile + == SettlementProbabilityComponentProfile::MarketMidpointOnly; + if market_midpoint_only && !self.config.llm_advisor_sha256.is_empty() { + return Err("market-midpoint-only search does not accept LLM advice".to_string()); + } + if market_midpoint_only + && (self.proposed > 1 + || self.nodes.len() != 1 + || self.seen_blends.len() != 1 + || !self.nodes[0].children.is_empty() + || !self.nodes[0].unexpanded_actions.is_empty()) + { + return Err( + "market-midpoint-only search evaluates only its canonical baseline".to_string(), + ); + } validate_tree(&self.nodes, 0, self.config.max_depth) .map_err(|error| format!("invalid prediction MCTS tree: {error}"))?; for (node_id, node) in self.nodes.iter().enumerate() { @@ -580,7 +599,16 @@ impl PredictionMctsCheckpoint { let ordinal = candidate_id .strip_prefix(&prefix) .and_then(|value| value.parse::().ok()); - if *node_id == 0 + let canonical_baseline_pending = *node_id == 0 + && self.config.component_profile + == SettlementProbabilityComponentProfile::MarketMidpointOnly + && self.pending.len() == 1 + && self.proposed == 1 + && ordinal == Some(0) + && node.visits == 0 + && node.total_reward == 0.0 + && node.best_reward.is_none(); + if (*node_id == 0 && !canonical_baseline_pending) || !pending_nodes.insert(*node_id) || ordinal.is_none_or(|ordinal| { ordinal >= self.proposed || !pending_ordinals.insert(ordinal) @@ -768,6 +796,11 @@ impl PredictionMctsEngine { if mission.search_budget.max_candidates == 0 { return Err("prediction MCTS requires a non-zero candidate budget".to_string()); } + if component_profile == SettlementProbabilityComponentProfile::MarketMidpointOnly + && !llm_advice.is_empty() + { + return Err("market-midpoint-only search does not accept LLM advice".to_string()); + } if !llm_advice.is_empty() && mission.search_budget.max_llm_calls == 0 { return Err("LLM advice requires a non-zero governed LLM-call budget".to_string()); } @@ -843,6 +876,16 @@ impl PredictionMctsEngine { if self.checkpoint.proposed >= self.checkpoint.config.max_candidates { return Err("prediction MCTS candidate budget is exhausted".to_string()); } + if self.checkpoint.config.component_profile + == SettlementProbabilityComponentProfile::MarketMidpointOnly + { + if self.checkpoint.proposed == 0 { + return Ok(self.mark_candidate_pending(0)); + } + return Err( + "market-midpoint-only search evaluates only its canonical baseline".to_string(), + ); + } for _ in 0..256 { let parent = select_expandable( &self.checkpoint.nodes, @@ -856,31 +899,40 @@ impl PredictionMctsEngine { if !self.checkpoint.seen_blends.insert(blend_key(&blend)) { continue; } - let probability_blend_sha256 = blend_digest(&blend); - let candidate_id = format!( - "{}{}", - candidate_id_prefix(&self.checkpoint.config, &blend), - self.checkpoint.proposed - ); - self.checkpoint.proposed += 1; - self.checkpoint - .pending - .insert(candidate_id.clone(), node_id); - return Ok(PredictionMctsCandidate { - candidate_id, - identity: self.checkpoint.config.identity.clone(), - probability_blend_sha256, - source: self.checkpoint.nodes[node_id].source, - probability_blend: blend, - }); + return Ok(self.mark_candidate_pending(node_id)); } Err("prediction MCTS could not produce a novel candidate".to_string()) } + fn mark_candidate_pending(&mut self, node_id: usize) -> PredictionMctsCandidate { + let blend = self.checkpoint.nodes[node_id].blend.clone(); + let candidate_id = format!( + "{}{}", + candidate_id_prefix(&self.checkpoint.config, &blend), + self.checkpoint.proposed + ); + self.checkpoint.proposed += 1; + self.checkpoint + .pending + .insert(candidate_id.clone(), node_id); + PredictionMctsCandidate { + candidate_id, + identity: self.checkpoint.config.identity.clone(), + probability_blend_sha256: blend_digest(&blend), + source: self.checkpoint.nodes[node_id].source, + probability_blend: blend, + } + } + pub(crate) fn has_expandable_candidate(&self) -> Result { if self.checkpoint.proposed >= self.checkpoint.config.max_candidates { return Ok(false); } + if self.checkpoint.config.component_profile + == SettlementProbabilityComponentProfile::MarketMidpointOnly + { + return Ok(self.checkpoint.proposed == 0); + } Ok(select_expandable( &self.checkpoint.nodes, 0, @@ -1170,7 +1222,7 @@ mod tests { } #[test] - fn market_midpoint_profile_never_expands_an_unsupported_component() { + fn market_midpoint_profile_evaluates_only_its_canonical_baseline() { let mut mission = mission(); mission.search_budget.max_candidates = 3; mission.search_budget.max_llm_calls = 1; @@ -1186,32 +1238,62 @@ mod tests { ) .expect("market-midpoint-only engine"); - for _ in 0..3 { - let candidate = engine.propose().expect("eligible deterministic candidate"); - assert_eq!(candidate.probability_blend.chainlink_digital_weight, 0.0); - assert_eq!(candidate.probability_blend.distance_lob_vol_weight, 0.0); - assert_eq!(candidate.probability_blend.event_surface_weight, 0.0); - assert_eq!(candidate.probability_blend.existing_model_weight, 0.0); - let evaluation = FakeEvaluator - .evaluate_training(&candidate) - .expect("training evidence"); - engine - .observe(&candidate.candidate_id, &evaluation) - .expect("observe eligible candidate"); - } + let candidate = engine.propose().expect("canonical baseline candidate"); + assert_eq!(candidate.source, PredictionExpansionSource::Baseline); + assert_eq!( + candidate.probability_blend, + market_midpoint_blend("baseline").into() + ); + engine.checkpoint().expect("durable pending baseline"); + let evaluation = FakeEvaluator + .evaluate_training(&candidate) + .expect("training evidence"); + engine + .observe(&candidate.candidate_id, &evaluation) + .expect("observe canonical baseline"); + assert!(!engine.has_expandable_candidate().unwrap()); + let before = engine.checkpoint().expect("observed baseline checkpoint"); + let error = engine + .propose() + .expect_err("market-midpoint-only search must stop after its baseline"); + assert!(error.contains("canonical baseline"), "{error}"); + assert_eq!(engine.checkpoint().unwrap(), before); + + let mut forged = before.clone(); + forged.proposed = 2; + let error = engine + .restore_checkpoint(forged) + .expect_err("expanded midpoint-only checkpoint must fail closed"); + assert!(error.contains("canonical baseline"), "{error}"); + assert_eq!(engine.checkpoint().unwrap(), before); + + let advisor = validate_single_proposal(market_midpoint_blend("advisor")).unwrap(); + let mut forged = before.clone(); + forged + .config + .llm_advisor_sha256 + .push(blend_digest(&advisor)); + forged.nodes[0] + .unexpanded_actions + .push(ExpansionAction::LlmAdvisor { blend: advisor }); + let error = engine + .restore_checkpoint(forged) + .expect_err("midpoint-only checkpoint must reject LLM authority"); + assert!(error.contains("does not accept LLM advice"), "{error}"); + assert_eq!(engine.checkpoint().unwrap(), before); let error = PredictionMctsEngine::new_with_component_profile( &mission, market_midpoint_blend("baseline"), - vec![blend("invalid_advisor")], + vec![market_midpoint_blend("advisor")], 7, 1.4, 3, SettlementProbabilityComponentProfile::MarketMidpointOnly, ) .err() - .expect("unsupported advisor must fail"); - assert!(error.contains("ineligible")); + .expect("market-midpoint-only profile must reject LLM advice"); + assert!(error.contains("does not accept LLM advice")); } #[test] diff --git a/rust_hft/prediction-markets/crates/ploy-research/src/prediction_mcts_run.rs b/rust_hft/prediction-markets/crates/ploy-research/src/prediction_mcts_run.rs index d812fa7a6..ad91dcefb 100644 --- a/rust_hft/prediction-markets/crates/ploy-research/src/prediction_mcts_run.rs +++ b/rust_hft/prediction-markets/crates/ploy-research/src/prediction_mcts_run.rs @@ -1377,7 +1377,7 @@ mod tests { } #[test] - fn reduced_profile_selects_when_the_tree_exhausts_before_the_budget() { + fn reduced_profile_selects_its_only_canonical_candidate() { let output = temp_dir("reduced-profile-tree-exhaustion"); let mut client = FakeClient { calls: 0 }; let mut evaluator = FakeEvaluator::default(); @@ -1393,7 +1393,8 @@ mod tests { .expect("tree exhaustion selects the best evaluated candidate"); assert_eq!(evaluator.calls.last().map(String::as_str), Some("held_out")); - assert_eq!(evaluator.candidate_ids.len(), 4); + assert_eq!(evaluator.candidate_ids.len(), 2); + assert_eq!(evaluator.candidate_ids[0], evaluator.candidate_ids[1]); } #[test]