systemprompt_runtime/optimization/
holdout.rs1use super::diagnostics::DiagnosticContext;
6use super::{OptimizationError, SkillOptimizationOrchestrator};
7use serde::{Deserialize, Serialize};
8use systemprompt_evaluation::campaigns::diagnostics::{DiagnosticCode, DiagnosticStage};
9use systemprompt_evaluation::campaigns::holdout::HoldoutProposal;
10use systemprompt_evaluation::experiments::ExperimentSpec;
11use systemprompt_evaluation::experiments::records::ExperimentStatus;
12use systemprompt_evaluation::repository::experiments::{CampaignAvailability, CampaignExperiment};
13use systemprompt_identifiers::{
14 EvalCampaignId, EvalExperimentId, EvalHoldoutProposalId, EvalRevisionId, UserId,
15};
16
17#[derive(Debug, Clone, Deserialize, schemars::JsonSchema)]
20#[serde(deny_unknown_fields)]
21pub struct PrepareHoldout {
22 pub development_experiment_id: EvalExperimentId,
23 pub holdout_dataset_id: EvalRevisionId,
24 pub idempotency_key: String,
25}
26#[derive(Debug, Clone, Deserialize, schemars::JsonSchema)]
28#[serde(deny_unknown_fields)]
29pub struct ConfirmHoldout {
30 pub spec_digest: String,
31 pub confirm_independent_holdout: bool,
32}
33#[derive(Debug, Clone, Copy)]
35pub struct HoldoutConfirmationTarget<'a> {
36 pub actor: &'a UserId,
37 pub campaign: &'a EvalCampaignId,
38 pub id: &'a EvalHoldoutProposalId,
39}
40#[derive(Debug, Clone, Serialize, schemars::JsonSchema)]
43pub struct HoldoutReview {
44 pub proposal: HoldoutProposal,
45 pub execution_availability: CampaignAvailability,
46}
47impl SkillOptimizationOrchestrator {
48 pub async fn prepare_holdout(
49 &self,
50 owner: &UserId,
51 actor: &UserId,
52 campaign: &EvalCampaignId,
53 input: &PrepareHoldout,
54 ) -> Result<HoldoutReview, OptimizationError> {
55 let result = self.prepare_holdout_inner(owner, campaign, input).await;
56 let ctx = DiagnosticContext {
57 owner,
58 actor,
59 campaign,
60 key: &input.idempotency_key,
61 stage: DiagnosticStage::Holdout,
62 };
63 match &result {
64 Err(error) => self.retain_failure(&ctx, error).await?,
65 Ok(review) if !review.execution_availability.admitted => {
66 self.blocked(&ctx, DiagnosticCode::UnsupportedCapability)
67 .await?;
68 },
69 Ok(_) => {},
70 }
71 result
72 }
73 async fn prepare_holdout_inner(
74 &self,
75 owner: &UserId,
76 campaign: &EvalCampaignId,
77 input: &PrepareHoldout,
78 ) -> Result<HoldoutReview, OptimizationError> {
79 let report = self
80 .report(owner, campaign, &input.development_experiment_id)
81 .await?;
82 if !report.development.eligible {
83 return Err(OptimizationError::Source("Complete a qualifying development comparison before independent holdout preparation".to_owned()));
84 }
85 let mut spec = self
86 .completed_development_spec(owner, &input.development_experiment_id)
87 .await?;
88 let cases = self
89 .partition_cases(owner, &spec.cases, &input.holdout_dataset_id)
90 .await?;
91 let counts = (
92 i32::try_from(cases.development.len()).unwrap_or(i32::MAX),
93 i32::try_from(cases.holdout.len()).unwrap_or(i32::MAX),
94 );
95 let minimum = i32::try_from(report.campaign.policy.minimum_pairs).unwrap_or(i32::MAX);
96 if counts.0 < minimum || counts.1 < minimum {
97 return Err(OptimizationError::Source(
98 "Both partitions must meet the unchanged campaign minimum paired-case threshold"
99 .to_owned(),
100 ));
101 }
102 self.freeze_holdout_spec(owner, &mut spec, cases).await?;
103 let proposal = self
104 .evaluations
105 .campaigns
106 .propose_holdout(
107 owner,
108 systemprompt_evaluation::campaigns::holdout::HoldoutProposalRequest {
109 campaign,
110 development: &input.development_experiment_id,
111 key: &input.idempotency_key,
112 spec: &spec,
113 counts,
114 },
115 )
116 .await?;
117 Ok(HoldoutReview {
118 execution_availability: self
119 .evaluations
120 .experiments
121 .execution_availability(&proposal.spec),
122 proposal,
123 })
124 }
125 async fn completed_development_spec(
126 &self,
127 owner: &UserId,
128 experiment: &EvalExperimentId,
129 ) -> Result<ExperimentSpec, OptimizationError> {
130 let detail = self.evaluations.experiments.get(owner, experiment).await?;
131 if detail.experiment.status != ExperimentStatus::Completed
132 || detail.experiment.accounting.reserved != 0
133 || detail.experiment.accounting.frozen
134 {
135 return Err(OptimizationError::Source(
136 "Development execution and accounting must be complete".to_owned(),
137 ));
138 }
139 Ok(detail.experiment.spec)
140 }
141 pub async fn confirm_holdout(
142 &self,
143 owner: &UserId,
144 target: &HoldoutConfirmationTarget<'_>,
145 input: &ConfirmHoldout,
146 ) -> Result<HoldoutProposal, OptimizationError> {
147 let ctx = DiagnosticContext {
148 owner,
149 actor: target.actor,
150 campaign: target.campaign,
151 key: target.id.as_str(),
152 stage: DiagnosticStage::Holdout,
153 };
154 let result = self.confirm_holdout_inner(&ctx, target.id, input).await;
155 if let Err(error) = &result {
156 self.retain_failure(&ctx, error).await?;
157 }
158 result
159 }
160 async fn confirm_holdout_inner(
161 &self,
162 ctx: &DiagnosticContext<'_>,
163 id: &EvalHoldoutProposalId,
164 input: &ConfirmHoldout,
165 ) -> Result<HoldoutProposal, OptimizationError> {
166 if !input.confirm_independent_holdout {
167 self.blocked(ctx, DiagnosticCode::InvalidInput).await?;
168 return Err(OptimizationError::Source(
169 "Explicit independent holdout confirmation is required".to_owned(),
170 ));
171 }
172 let proposal = self
173 .evaluations
174 .campaigns
175 .confirm_holdout(
176 ctx.owner,
177 systemprompt_evaluation::campaigns::holdout::HoldoutConfirmation {
178 actor: ctx.actor,
179 campaign: ctx.campaign,
180 id,
181 digest: &input.spec_digest,
182 },
183 )
184 .await?;
185 if proposal.experiment_id.is_some() {
186 return Ok(proposal);
187 }
188 let experiment = self
189 .launch(
190 ctx.owner,
191 ctx.actor,
192 &CampaignExperiment {
193 campaign_id: ctx.campaign.clone(),
194 idempotency_key: format!("holdout:{}", proposal.id),
195 spec: proposal.spec,
196 },
197 )
198 .await?;
199 Ok(self
200 .evaluations
201 .campaigns
202 .attach_holdout_run(ctx.owner, ctx.campaign, id, &experiment)
203 .await?)
204 }
205}