Skip to main content

discovery_portfolio/
lib.rs

1//! Typed discovery portfolio surface crate for program, campaign, and budget artifacts.
2//!
3//! The crate keeps the compatibility name, but it only publishes typed
4//! portfolio surfaces plus bounded prioritization helpers. It does not
5//! implement a general experiment scheduler or promotion runtime.
6
7use 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}