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
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
162 changes: 122 additions & 40 deletions rust_hft/prediction-markets/crates/ploy-research/src/prediction_mcts.rs
Original file line number Diff line number Diff line change
Expand Up @@ -401,11 +401,14 @@ enum ExpansionAction {
}

fn deterministic_actions(profile: SettlementProbabilityComponentProfile) -> Vec<ExpansionAction> {
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)]
Expand Down Expand Up @@ -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() {
Expand Down Expand Up @@ -580,7 +599,16 @@ impl PredictionMctsCheckpoint {
let ordinal = candidate_id
.strip_prefix(&prefix)
.and_then(|value| value.parse::<usize>().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)
Expand Down Expand Up @@ -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());
}
Expand Down Expand Up @@ -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,
Expand All @@ -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<bool, String> {
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,
Expand Down Expand Up @@ -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;
Expand All @@ -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]
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand All @@ -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]
Expand Down
Loading