1use schemars::JsonSchema;
8use serde::{Deserialize, Serialize};
9use stack_ids::{
10 CampaignDecisionTraceId, DiscoveryProgramId, ExperimentCampaignId, InformationValueEstimateId,
11 PortfolioPlanId, SurfaceStatus, VerificationLoadBudgetId,
12};
13use std::cmp::Ordering;
14
15pub const DISCOVERY_PROGRAM_V1_SCHEMA: &str = "discovery_program_v1";
16pub const PORTFOLIO_PLAN_V1_SCHEMA: &str = "portfolio_plan_v1";
17pub const EXPERIMENT_CAMPAIGN_V1_SCHEMA: &str = "experiment_campaign_v1";
18pub const CAMPAIGN_DECISION_TRACE_V1_SCHEMA: &str = "campaign_decision_trace_v1";
19pub const INFORMATION_VALUE_ESTIMATE_V1_SCHEMA: &str = "information_value_estimate_v1";
20pub const VERIFICATION_LOAD_BUDGET_V1_SCHEMA: &str = "verification_load_budget_v1";
21pub const PROGRAM_HYPOTHESIS_SET_V1_SCHEMA: &str = "program_hypothesis_set_v1";
22
23#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
24pub struct DiscoveryProgramV1 {
25 pub schema_version: String,
26 pub discovery_program_id: DiscoveryProgramId,
27 pub program_name: String,
28 pub canonical_owner: String,
29 pub publication_status: SurfaceStatus,
30}
31
32#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
33pub struct ProgramHypothesisSetV1 {
34 pub schema_version: String,
35 pub discovery_program_id: DiscoveryProgramId,
36 #[serde(default)]
37 pub hypothesis_refs: Vec<String>,
38 pub horizon_only: bool,
39}
40
41#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
42pub struct InformationValueEstimateV1 {
43 pub schema_version: String,
44 pub information_value_estimate_id: InformationValueEstimateId,
45 pub campaign_id: ExperimentCampaignId,
46 pub expected_information_gain: u32,
47 pub estimated_review_cost: u32,
48}
49
50#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
51pub struct ExperimentCampaignV1 {
52 pub schema_version: String,
53 pub experiment_campaign_id: ExperimentCampaignId,
54 pub campaign_name: String,
55 pub utility_case: String,
56 pub information_value_estimate_id: InformationValueEstimateId,
57 pub required_review_slots: u32,
58 pub advisory_only: bool,
59}
60
61#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
62pub struct PortfolioPlanV1 {
63 pub schema_version: String,
64 pub portfolio_plan_id: PortfolioPlanId,
65 pub discovery_program_id: DiscoveryProgramId,
66 #[serde(default)]
67 pub campaign_ids: Vec<ExperimentCampaignId>,
68 pub utility_rationale: String,
69 pub review_load_rationale: String,
70 pub advisory_only: bool,
71}
72
73#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
74pub struct VerificationLoadBudgetV1 {
75 pub schema_version: String,
76 pub verification_load_budget_id: VerificationLoadBudgetId,
77 pub total_review_slots: u32,
78 pub remaining_review_slots: u32,
79 pub exhausted: bool,
80 pub horizon_only: bool,
81}
82
83#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
84#[serde(rename_all = "snake_case")]
85pub enum CampaignDecision {
86 Launch,
87 Defer,
88 PauseBudgetExhausted,
89}
90
91#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
92pub struct CampaignDecisionLineV1 {
93 pub campaign_id: ExperimentCampaignId,
94 pub decision: CampaignDecision,
95 pub expected_information_gain: u32,
96 pub estimated_review_cost: u32,
97 pub budget_pressure: f64,
98 #[serde(default)]
99 pub hypothesis_refs: Vec<String>,
100 pub rationale: String,
101}
102
103#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
104pub struct CampaignDecisionTraceV1 {
105 pub schema_version: String,
106 pub campaign_decision_trace_id: CampaignDecisionTraceId,
107 pub portfolio_plan_id: PortfolioPlanId,
108 pub discovery_program_id: DiscoveryProgramId,
109 pub verification_load_budget_id: VerificationLoadBudgetId,
110 #[serde(default)]
111 pub decisions: Vec<CampaignDecisionLineV1>,
112 pub remaining_review_slots: u32,
113 pub advisory_only: bool,
114 pub degraded: bool,
115 pub generated_at: String,
116}
117
118pub fn evaluate_portfolio_plan(
119 program: &DiscoveryProgramV1,
120 hypotheses: &ProgramHypothesisSetV1,
121 plan: &PortfolioPlanV1,
122 campaigns: &[ExperimentCampaignV1],
123 value_estimates: &[InformationValueEstimateV1],
124 budget: &VerificationLoadBudgetV1,
125 generated_at: impl Into<String>,
126) -> CampaignDecisionTraceV1 {
127 let mut remaining = budget.remaining_review_slots;
128 let mut degraded = false;
129 let mut decisions = Vec::with_capacity(campaigns.len());
130 let mut candidate_campaigns = campaigns
131 .iter()
132 .filter(|campaign| {
133 plan.campaign_ids.is_empty()
134 || plan.campaign_ids.contains(&campaign.experiment_campaign_id)
135 })
136 .collect::<Vec<_>>();
137
138 let plan_order = plan
139 .campaign_ids
140 .iter()
141 .enumerate()
142 .map(|(index, id)| (id.as_str().to_string(), index))
143 .collect::<std::collections::BTreeMap<_, _>>();
144 let estimate_by_campaign = value_estimates
145 .iter()
146 .map(|estimate| (estimate.campaign_id.as_str().to_string(), estimate))
147 .collect::<std::collections::BTreeMap<_, _>>();
148
149 candidate_campaigns.sort_by(|left, right| {
150 let left_estimate = estimate_by_campaign.get(left.experiment_campaign_id.as_str());
151 let right_estimate = estimate_by_campaign.get(right.experiment_campaign_id.as_str());
152
153 match (left_estimate, right_estimate) {
154 (Some(left_estimate), Some(right_estimate)) => {
155 let left_cost = left_estimate.estimated_review_cost.max(1);
156 let right_cost = right_estimate.estimated_review_cost.max(1);
157 let left_ratio = left_estimate.expected_information_gain * right_cost;
158 let right_ratio = right_estimate.expected_information_gain * left_cost;
159 right_ratio
160 .cmp(&left_ratio)
161 .then_with(|| {
162 right_estimate
163 .expected_information_gain
164 .cmp(&left_estimate.expected_information_gain)
165 })
166 .then_with(|| left.required_review_slots.cmp(&right.required_review_slots))
167 .then_with(|| {
168 let left_order = plan_order
169 .get(left.experiment_campaign_id.as_str())
170 .copied()
171 .unwrap_or(usize::MAX);
172 let right_order = plan_order
173 .get(right.experiment_campaign_id.as_str())
174 .copied()
175 .unwrap_or(usize::MAX);
176 left_order.cmp(&right_order)
177 })
178 }
179 (Some(_), None) => Ordering::Less,
180 (None, Some(_)) => Ordering::Greater,
181 (None, None) => Ordering::Equal,
182 }
183 });
184
185 for campaign in candidate_campaigns {
186 let estimate = estimate_by_campaign.get(campaign.experiment_campaign_id.as_str());
187 let expected_information_gain = estimate
188 .map(|estimate| estimate.expected_information_gain)
189 .unwrap_or_default();
190 let estimated_review_cost = estimate
191 .map(|estimate| estimate.estimated_review_cost)
192 .unwrap_or(campaign.required_review_slots);
193 let budget_pressure = if remaining == 0 {
194 1.0
195 } else {
196 campaign.required_review_slots as f64 / remaining as f64
197 };
198 let hypothesis_context = if hypotheses.hypothesis_refs.is_empty() {
199 "no hypothesis refs were attached".to_string()
200 } else {
201 format!(
202 "hypothesis set remains visible: {}",
203 hypotheses.hypothesis_refs.join(", ")
204 )
205 };
206
207 let (decision, rationale) = if estimate.is_none() {
208 degraded = true;
209 (
210 CampaignDecision::Defer,
211 format!(
212 "campaign is deferred because no typed information-value estimate was supplied; {hypothesis_context}"
213 ),
214 )
215 } else if budget.exhausted || remaining == 0 {
216 degraded = true;
217 (
218 CampaignDecision::PauseBudgetExhausted,
219 format!(
220 "verification budget is exhausted, so the program pauses explicitly even though expected information gain is {expected_information_gain}; {hypothesis_context}"
221 ),
222 )
223 } else if remaining >= campaign.required_review_slots {
224 remaining -= campaign.required_review_slots;
225 (
226 CampaignDecision::Launch,
227 format!(
228 "campaign launches because expected information gain {expected_information_gain} justifies review cost {estimated_review_cost} under budget pressure {:.2}; {hypothesis_context}",
229 budget_pressure
230 ),
231 )
232 } else {
233 degraded = true;
234 (
235 CampaignDecision::Defer,
236 format!(
237 "campaign is deferred because review cost {estimated_review_cost} would exceed remaining review slots {remaining} under budget pressure {:.2}; {hypothesis_context}",
238 budget_pressure
239 ),
240 )
241 };
242
243 decisions.push(CampaignDecisionLineV1 {
244 campaign_id: campaign.experiment_campaign_id.clone(),
245 decision,
246 expected_information_gain,
247 estimated_review_cost,
248 budget_pressure,
249 hypothesis_refs: hypotheses.hypothesis_refs.clone(),
250 rationale,
251 });
252 }
253
254 CampaignDecisionTraceV1 {
255 schema_version: CAMPAIGN_DECISION_TRACE_V1_SCHEMA.into(),
256 campaign_decision_trace_id: CampaignDecisionTraceId::generate(),
257 portfolio_plan_id: plan.portfolio_plan_id.clone(),
258 discovery_program_id: program.discovery_program_id.clone(),
259 verification_load_budget_id: budget.verification_load_budget_id.clone(),
260 decisions,
261 remaining_review_slots: remaining,
262 advisory_only: true,
263 degraded,
264 generated_at: generated_at.into(),
265 }
266}