Skip to main content

hanzo_client/apis/
experiment_api.rs

1/*
2 * Hanzo Cloud API
3 *
4 * The Hanzo Cloud API as a customer calls it: every operation under /v1/ except the operator's admin product, relay routes, legacy spellings and capabilities still reached by flag. Tagged by product: the first path segment after /v1/.
5 *
6 * The version of the OpenAPI document: v1
7 * 
8 * Generated by: https://openapi-generator.tech
9 */
10
11
12use reqwest;
13use serde::{Deserialize, Serialize, de::Error as _};
14use crate::{apis::ResponseContent, models};
15use super::{Error, configuration, ContentType};
16
17
18/// struct for typed errors of method [`get_experiment`]
19#[derive(Debug, Clone, Serialize, Deserialize)]
20#[serde(untagged)]
21pub enum GetExperimentError {
22    UnknownValue(serde_json::Value),
23}
24
25/// struct for typed errors of method [`get_experiment_by_id`]
26#[derive(Debug, Clone, Serialize, Deserialize)]
27#[serde(untagged)]
28pub enum GetExperimentByIdError {
29    UnknownValue(serde_json::Value),
30}
31
32/// struct for typed errors of method [`get_experiment_by_id_assign`]
33#[derive(Debug, Clone, Serialize, Deserialize)]
34#[serde(untagged)]
35pub enum GetExperimentByIdAssignError {
36    UnknownValue(serde_json::Value),
37}
38
39/// struct for typed errors of method [`get_experiment_health`]
40#[derive(Debug, Clone, Serialize, Deserialize)]
41#[serde(untagged)]
42pub enum GetExperimentHealthError {
43    UnknownValue(serde_json::Value),
44}
45
46/// struct for typed errors of method [`post_experiment`]
47#[derive(Debug, Clone, Serialize, Deserialize)]
48#[serde(untagged)]
49pub enum PostExperimentError {
50    UnknownValue(serde_json::Value),
51}
52
53/// struct for typed errors of method [`post_experiment_by_id_analyze`]
54#[derive(Debug, Clone, Serialize, Deserialize)]
55#[serde(untagged)]
56pub enum PostExperimentByIdAnalyzeError {
57    UnknownValue(serde_json::Value),
58}
59
60/// struct for typed errors of method [`post_experiment_by_id_decide`]
61#[derive(Debug, Clone, Serialize, Deserialize)]
62#[serde(untagged)]
63pub enum PostExperimentByIdDecideError {
64    UnknownValue(serde_json::Value),
65}
66
67
68/// Is every experiment in the caller's org, with its variants, status and decision, ordered by project then id.  Scoped to the org resolved from the validated principal — a distinct org is a distinct physical store, so no query here can reach another tenant's rows — and further narrowed to the caller's project scope when the credential carries one. A principal with NO project scope sees the org's experiments across all of its projects, which is the answer a reader most often expects to be filtered and is not.  Requires a validated principal; refuses without one rather than answering an empty list.
69pub async fn get_experiment(configuration: &configuration::Configuration, ) -> Result<models::ExperimentList, Error<GetExperimentError>> {
70
71    let uri_str = format!("{}/v1/experiment", configuration.base_path);
72    let mut req_builder = configuration.client.request(reqwest::Method::GET, &uri_str);
73
74    if let Some(ref user_agent) = configuration.user_agent {
75        req_builder = req_builder.header(reqwest::header::USER_AGENT, user_agent.clone());
76    }
77    if let Some(ref token) = configuration.bearer_access_token {
78        req_builder = req_builder.bearer_auth(token.to_owned());
79    };
80
81    let req = req_builder.build()?;
82    let resp = configuration.client.execute(req).await?;
83
84    let status = resp.status();
85    let content_type = resp
86        .headers()
87        .get("content-type")
88        .and_then(|v| v.to_str().ok())
89        .unwrap_or("application/octet-stream");
90    let content_type = super::ContentType::from(content_type);
91
92    if !status.is_client_error() && !status.is_server_error() {
93        let content = resp.text().await?;
94        match content_type {
95            ContentType::Json => serde_json::from_str(&content).map_err(Error::from),
96            ContentType::Text => return Err(Error::from(serde_json::Error::custom("Received `text/plain` content type response that cannot be converted to `models::ExperimentList`"))),
97            ContentType::Unsupported(unknown_type) => return Err(Error::from(serde_json::Error::custom(format!("Received `{unknown_type}` content type response that cannot be converted to `models::ExperimentList`")))),
98        }
99    } else {
100        let content = resp.text().await?;
101        let entity: Option<GetExperimentError> = serde_json::from_str(&content).ok();
102        Err(Error::ResponseError(ResponseContent { status, content, entity }))
103    }
104}
105
106/// Is one experiment's definition and lifecycle: variants, weights, control arm, status and winner.  It reads the registry row only — the definition and the decision, never live measurements. Assignment lives in the flags plane and outcomes in analytics; this is the value that names both.  Scoped to the caller's org and project from the validated principal, so another tenant's experiment of the same id is simply not found. An id that is not a legal slug is answered the same way, without a store read — the shape check and the existence check are one answer, so neither leaks the other.
107pub async fn get_experiment_by_id(configuration: &configuration::Configuration, id: &str) -> Result<models::Trial, Error<GetExperimentByIdError>> {
108    // add a prefix to parameters to efficiently prevent name collisions
109    let p_id = id;
110
111    let uri_str = format!("{}/v1/experiment/{id}", configuration.base_path, id=crate::apis::urlencode(p_id));
112    let mut req_builder = configuration.client.request(reqwest::Method::GET, &uri_str);
113
114    if let Some(ref user_agent) = configuration.user_agent {
115        req_builder = req_builder.header(reqwest::header::USER_AGENT, user_agent.clone());
116    }
117    if let Some(ref token) = configuration.bearer_access_token {
118        req_builder = req_builder.bearer_auth(token.to_owned());
119    };
120
121    let req = req_builder.build()?;
122    let resp = configuration.client.execute(req).await?;
123
124    let status = resp.status();
125    let content_type = resp
126        .headers()
127        .get("content-type")
128        .and_then(|v| v.to_str().ok())
129        .unwrap_or("application/octet-stream");
130    let content_type = super::ContentType::from(content_type);
131
132    if !status.is_client_error() && !status.is_server_error() {
133        let content = resp.text().await?;
134        match content_type {
135            ContentType::Json => serde_json::from_str(&content).map_err(Error::from),
136            ContentType::Text => return Err(Error::from(serde_json::Error::custom("Received `text/plain` content type response that cannot be converted to `models::Trial`"))),
137            ContentType::Unsupported(unknown_type) => return Err(Error::from(serde_json::Error::custom(format!("Received `{unknown_type}` content type response that cannot be converted to `models::Trial`")))),
138        }
139    } else {
140        let content = resp.text().await?;
141        let entity: Option<GetExperimentByIdError> = serde_json::from_str(&content).ok();
142        Err(Error::ResponseError(ResponseContent { status, content, entity }))
143    }
144}
145
146/// Is the variant one subject is bucketed into, and the payload that variant carries.  The bucketing is a deterministic hash of the subject, so the same subject gets the same arm on every call for as long as the flag definition is unchanged — and this is a pure READ: it records nothing. In particular it does NOT record an exposure. The caller's SDK must emit the experiment's exposure event itself, or the analysis has an empty denominator and every arm measures zero.  An empty variant with on false is not an error — it means the flag returned nothing for this subject, so the subject is not enrolled. A flags engine that is unavailable refuses rather than defaulting to an arm. Requires a validated principal, and the experiment must exist in the caller's org and project.
147pub async fn get_experiment_by_id_assign(configuration: &configuration::Configuration, id: &str, subject: &str, props: Option<&str>) -> Result<models::Assignment, Error<GetExperimentByIdAssignError>> {
148    // add a prefix to parameters to efficiently prevent name collisions
149    let p_id = id;
150    let p_subject = subject;
151    let p_props = props;
152
153    let uri_str = format!("{}/v1/experiment/{id}/assign", configuration.base_path, id=crate::apis::urlencode(p_id));
154    let mut req_builder = configuration.client.request(reqwest::Method::GET, &uri_str);
155
156    req_builder = req_builder.query(&[("subject", &p_subject.to_string())]);
157    if let Some(ref param_value) = p_props {
158        req_builder = req_builder.query(&[("props", &param_value.to_string())]);
159    }
160    if let Some(ref user_agent) = configuration.user_agent {
161        req_builder = req_builder.header(reqwest::header::USER_AGENT, user_agent.clone());
162    }
163    if let Some(ref token) = configuration.bearer_access_token {
164        req_builder = req_builder.bearer_auth(token.to_owned());
165    };
166
167    let req = req_builder.build()?;
168    let resp = configuration.client.execute(req).await?;
169
170    let status = resp.status();
171    let content_type = resp
172        .headers()
173        .get("content-type")
174        .and_then(|v| v.to_str().ok())
175        .unwrap_or("application/octet-stream");
176    let content_type = super::ContentType::from(content_type);
177
178    if !status.is_client_error() && !status.is_server_error() {
179        let content = resp.text().await?;
180        match content_type {
181            ContentType::Json => serde_json::from_str(&content).map_err(Error::from),
182            ContentType::Text => return Err(Error::from(serde_json::Error::custom("Received `text/plain` content type response that cannot be converted to `models::Assignment`"))),
183            ContentType::Unsupported(unknown_type) => return Err(Error::from(serde_json::Error::custom(format!("Received `{unknown_type}` content type response that cannot be converted to `models::Assignment`")))),
184        }
185    } else {
186        let content = resp.text().await?;
187        let entity: Option<GetExperimentByIdAssignError> = serde_json::from_str(&content).ok();
188        Err(Error::ResponseError(ResponseContent { status, content, entity }))
189    }
190}
191
192/// Is whether the experiments subsystem is mounted and serving in this process.  It answers unconditionally. It proves exactly one thing — that this binary registered the experiments routes and is dispatching them — and deliberately no more: it reads no principal, opens no per-org registry, and touches neither the flags engine nor the analytics plane, so a 200 here says nothing about whether a given tenant's store will open or whether an analysis can run. It is the only route on this surface that needs no org.  The static path is registered ahead of the /:id read, so it always wins the first-match scan. \"health\" is a legal experiment id, which means an experiment created under that id can never be fetched by id — pick another.
193pub async fn get_experiment_health(configuration: &configuration::Configuration, ) -> Result<models::Health, Error<GetExperimentHealthError>> {
194
195    let uri_str = format!("{}/v1/experiment/health", configuration.base_path);
196    let mut req_builder = configuration.client.request(reqwest::Method::GET, &uri_str);
197
198    if let Some(ref user_agent) = configuration.user_agent {
199        req_builder = req_builder.header(reqwest::header::USER_AGENT, user_agent.clone());
200    }
201    if let Some(ref token) = configuration.bearer_access_token {
202        req_builder = req_builder.bearer_auth(token.to_owned());
203    };
204
205    let req = req_builder.build()?;
206    let resp = configuration.client.execute(req).await?;
207
208    let status = resp.status();
209    let content_type = resp
210        .headers()
211        .get("content-type")
212        .and_then(|v| v.to_str().ok())
213        .unwrap_or("application/octet-stream");
214    let content_type = super::ContentType::from(content_type);
215
216    if !status.is_client_error() && !status.is_server_error() {
217        let content = resp.text().await?;
218        match content_type {
219            ContentType::Json => serde_json::from_str(&content).map_err(Error::from),
220            ContentType::Text => return Err(Error::from(serde_json::Error::custom("Received `text/plain` content type response that cannot be converted to `models::Health`"))),
221            ContentType::Unsupported(unknown_type) => return Err(Error::from(serde_json::Error::custom(format!("Received `{unknown_type}` content type response that cannot be converted to `models::Health`")))),
222        }
223    } else {
224        let content = resp.text().await?;
225        let entity: Option<GetExperimentHealthError> = serde_json::from_str(&content).ok();
226        Err(Error::ResponseError(ResponseContent { status, content, entity }))
227    }
228}
229
230/// Registers a controlled experiment AND puts its assignment flag live, in that order, so the arms start bucketing subjects the moment this returns 201 — the flag is created active at 100% rollout, with each variant weighted as declared. There is no separate start call; creating IS starting.  A variant carries an opaque payload this primitive never interprets: a feature config, an ad-creative id, a subject line, a model id.  Requires a validated principal, and refuses without one. The org and project are taken from that principal and the creator is stamped from the credential — none of the three is a body field, so an experiment cannot be filed against another tenant. An id already used in this project is a conflict, never a silent overwrite: re-creating would stomp the assignment flag of a run in progress.  It fails closed on the flag write. An experiment whose assignment flag does not exist would assign nobody, so if that write fails nothing is registered.
231pub async fn post_experiment(configuration: &configuration::Configuration, create_body: models::CreateBody) -> Result<models::Trial, Error<PostExperimentError>> {
232    // add a prefix to parameters to efficiently prevent name collisions
233    let p_create_body = create_body;
234
235    let uri_str = format!("{}/v1/experiment", configuration.base_path);
236    let mut req_builder = configuration.client.request(reqwest::Method::POST, &uri_str);
237
238    if let Some(ref user_agent) = configuration.user_agent {
239        req_builder = req_builder.header(reqwest::header::USER_AGENT, user_agent.clone());
240    }
241    if let Some(ref token) = configuration.bearer_access_token {
242        req_builder = req_builder.bearer_auth(token.to_owned());
243    };
244    req_builder = req_builder.json(&p_create_body);
245
246    let req = req_builder.build()?;
247    let resp = configuration.client.execute(req).await?;
248
249    let status = resp.status();
250    let content_type = resp
251        .headers()
252        .get("content-type")
253        .and_then(|v| v.to_str().ok())
254        .unwrap_or("application/octet-stream");
255    let content_type = super::ContentType::from(content_type);
256
257    if !status.is_client_error() && !status.is_server_error() {
258        let content = resp.text().await?;
259        match content_type {
260            ContentType::Json => serde_json::from_str(&content).map_err(Error::from),
261            ContentType::Text => return Err(Error::from(serde_json::Error::custom("Received `text/plain` content type response that cannot be converted to `models::Trial`"))),
262            ContentType::Unsupported(unknown_type) => return Err(Error::from(serde_json::Error::custom(format!("Received `{unknown_type}` content type response that cannot be converted to `models::Trial`")))),
263        }
264    } else {
265        let content = resp.text().await?;
266        let entity: Option<PostExperimentError> = serde_json::from_str(&content).ok();
267        Err(Error::ResponseError(ResponseContent { status, content, entity }))
268    }
269}
270
271/// Is per-variant conversion, lift and statistical significance against the control arm.  It reads per-subject outcomes from the analytics plane over a window, folds them into per-variant samples, and returns each arm's exposed count, conversions, rate, lift versus control, two-proportion z, two-tailed p-value and whether it clears alpha. Arms with no data still appear with zero exposed, so the read is complete over the experiment's declared arms; the control arm sorts first. The pooled-variance estimator is used and the p-value is exact; a degenerate comparison (an empty arm, no variance) answers z 0 and p 1 — not significant, never an error.  Only EXPOSED subjects are counted, and each is joined to its arm by re-evaluating the assignment flag AT ANALYSIS TIME — not from what was in force during the window. That is the one rule to get right: analyzing an experiment after its winner has been promoted re-buckets every subject into the promoted arm, collapsing the control to zero exposed and making the result meaningless. Read the analysis before deciding. A subject the flag cannot place is dropped rather than allowed to poison the fold.  The winner in the response is ADVISORY — the significant, control-beating arm with the highest rate, or empty when inconclusive. It promotes nothing; the decision is a separate, explicit act.  Every plane read is scoped to the caller's org. Per-variant samples are also written to the research evidence plane as immutable ab rows, best-effort: the analysis is still returned if that write fails, because the samples are recomputable, and the failure is logged rather than swallowed.
272pub async fn post_experiment_by_id_analyze(configuration: &configuration::Configuration, id: &str, analyze_query: models::AnalyzeQuery) -> Result<models::Analysis, Error<PostExperimentByIdAnalyzeError>> {
273    // add a prefix to parameters to efficiently prevent name collisions
274    let p_id = id;
275    let p_analyze_query = analyze_query;
276
277    let uri_str = format!("{}/v1/experiment/{id}/analyze", configuration.base_path, id=crate::apis::urlencode(p_id));
278    let mut req_builder = configuration.client.request(reqwest::Method::POST, &uri_str);
279
280    if let Some(ref user_agent) = configuration.user_agent {
281        req_builder = req_builder.header(reqwest::header::USER_AGENT, user_agent.clone());
282    }
283    if let Some(ref token) = configuration.bearer_access_token {
284        req_builder = req_builder.bearer_auth(token.to_owned());
285    };
286    req_builder = req_builder.json(&p_analyze_query);
287
288    let req = req_builder.build()?;
289    let resp = configuration.client.execute(req).await?;
290
291    let status = resp.status();
292    let content_type = resp
293        .headers()
294        .get("content-type")
295        .and_then(|v| v.to_str().ok())
296        .unwrap_or("application/octet-stream");
297    let content_type = super::ContentType::from(content_type);
298
299    if !status.is_client_error() && !status.is_server_error() {
300        let content = resp.text().await?;
301        match content_type {
302            ContentType::Json => serde_json::from_str(&content).map_err(Error::from),
303            ContentType::Text => return Err(Error::from(serde_json::Error::custom("Received `text/plain` content type response that cannot be converted to `models::Analysis`"))),
304            ContentType::Unsupported(unknown_type) => return Err(Error::from(serde_json::Error::custom(format!("Received `{unknown_type}` content type response that cannot be converted to `models::Analysis`")))),
305        }
306    } else {
307        let content = resp.text().await?;
308        let entity: Option<PostExperimentByIdAnalyzeError> = serde_json::from_str(&content).ok();
309        Err(Error::ResponseError(ResponseContent { status, content, entity }))
310    }
311}
312
313/// Promotes one variant to the whole rollout and records who decided.  It rewrites the assignment flag so the named winner serves 100% of the rollout and every other arm 0%, preserving the flag's targeting groups and payloads, then stamps the experiment decided with the winner, the deciding credential and the time. This is a production behaviour change that takes effect immediately for every subject the flag evaluates.  It requires an ORG ADMIN of the caller's own org — a stricter gate than the rest of this surface, matching the flags write plane, because promoting is a flag write. The admin check runs AFTER the experiment is found, so a caller from another tenant is answered not-found rather than forbidden and learns nothing about what exists.  An experiment whose assignment flag has gone missing is a conflict rather than a silent no-op — there is nothing to promote.  Deciding is NOT terminal. A second call re-promotes a different variant and re-stamps the row; the status stays decided and the previous winner is overwritten with no record that it was ever chosen. Nothing here reverts the flag to its original weights either, so an experiment cannot be un-decided through this route — restoring a split means writing the flag definition back through the flags plane.
314pub async fn post_experiment_by_id_decide(configuration: &configuration::Configuration, id: &str, decide_body: models::DecideBody) -> Result<models::Trial, Error<PostExperimentByIdDecideError>> {
315    // add a prefix to parameters to efficiently prevent name collisions
316    let p_id = id;
317    let p_decide_body = decide_body;
318
319    let uri_str = format!("{}/v1/experiment/{id}/decide", configuration.base_path, id=crate::apis::urlencode(p_id));
320    let mut req_builder = configuration.client.request(reqwest::Method::POST, &uri_str);
321
322    if let Some(ref user_agent) = configuration.user_agent {
323        req_builder = req_builder.header(reqwest::header::USER_AGENT, user_agent.clone());
324    }
325    if let Some(ref token) = configuration.bearer_access_token {
326        req_builder = req_builder.bearer_auth(token.to_owned());
327    };
328    req_builder = req_builder.json(&p_decide_body);
329
330    let req = req_builder.build()?;
331    let resp = configuration.client.execute(req).await?;
332
333    let status = resp.status();
334    let content_type = resp
335        .headers()
336        .get("content-type")
337        .and_then(|v| v.to_str().ok())
338        .unwrap_or("application/octet-stream");
339    let content_type = super::ContentType::from(content_type);
340
341    if !status.is_client_error() && !status.is_server_error() {
342        let content = resp.text().await?;
343        match content_type {
344            ContentType::Json => serde_json::from_str(&content).map_err(Error::from),
345            ContentType::Text => return Err(Error::from(serde_json::Error::custom("Received `text/plain` content type response that cannot be converted to `models::Trial`"))),
346            ContentType::Unsupported(unknown_type) => return Err(Error::from(serde_json::Error::custom(format!("Received `{unknown_type}` content type response that cannot be converted to `models::Trial`")))),
347        }
348    } else {
349        let content = resp.text().await?;
350        let entity: Option<PostExperimentByIdDecideError> = serde_json::from_str(&content).ok();
351        Err(Error::ResponseError(ResponseContent { status, content, entity }))
352    }
353}
354