Skip to content

Commit 7a2f609

Browse files
authored
Allow sample size of 1 for percentage threshold test (#819)
- Lower `SampleSize::MIN` from `2` to `1` and add per-test minimum enforcement: [percentage](https://bencher.dev/docs/explanation/thresholds/#percentage) test accepts >= 1 (only needs a mean), variance-based tests ([z_score](https://bencher.dev/docs/explanation/thresholds/#z-score), [t_test](https://bencher.dev/docs/explanation/thresholds/#t-test), [log_normal](https://bencher.dev/docs/explanation/thresholds/#log-normal), [iqr](https://bencher.dev/docs/explanation/thresholds/#interquartile-range), [delta_iqr](https://bencher.dev/docs/explanation/thresholds/#delta-interquartile-range)) require >= 2 - Clarify that sample size counts are historical-only, the new Metric being tested is not counted - Update docs across all 9 languages to specify per-test minimums - Update the [static](https://bencher.dev/docs/explanation/thresholds/#static) test callout to recommend max_sample_size: `1`
1 parent 8048391 commit 7a2f609

47 files changed

Lines changed: 533 additions & 107 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

lib/api_projects/tests/thresholds.rs

Lines changed: 158 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -430,3 +430,161 @@ async fn thresholds_get_with_wrong_threshold_model() {
430430
.expect("Request failed");
431431
assert_eq!(resp.status(), StatusCode::NOT_FOUND);
432432
}
433+
434+
async fn create_project_with_branch_testbed_measure(
435+
server: &TestServer,
436+
user: &TestUser,
437+
org_name: &str,
438+
project_name: &str,
439+
) -> String {
440+
let org = server.create_org(user, org_name).await;
441+
let project = server.create_project(user, &org, project_name).await;
442+
let project_slug: String = AsRef::<str>::as_ref(&project.slug).to_owned();
443+
444+
for (path, body) in [
445+
(
446+
"branches",
447+
serde_json::json!({"name": "ssize-branch", "slug": "ssize-branch"}),
448+
),
449+
(
450+
"testbeds",
451+
serde_json::json!({"name": "ssize-testbed", "slug": "ssize-testbed"}),
452+
),
453+
(
454+
"measures",
455+
serde_json::json!({
456+
"name": "latency",
457+
"slug": "latency",
458+
"units": "ns",
459+
}),
460+
),
461+
] {
462+
let resp = server
463+
.client
464+
.post(server.api_url(&format!("/v0/projects/{project_slug}/{path}")))
465+
.header(
466+
bencher_json::AUTHORIZATION,
467+
bencher_json::bearer_header(&user.token),
468+
)
469+
.json(&body)
470+
.send()
471+
.await
472+
.expect("Request failed");
473+
assert_eq!(resp.status(), StatusCode::CREATED);
474+
}
475+
476+
project_slug
477+
}
478+
479+
#[tokio::test]
480+
async fn create_threshold_percentage_max_sample_size_one_succeeds() {
481+
let server = TestServer::new().await;
482+
let user = server
483+
.signup("Test User", "ssize-percentage@example.com")
484+
.await;
485+
let project_slug = create_project_with_branch_testbed_measure(
486+
&server,
487+
&user,
488+
"Sample Size Percentage Org",
489+
"Sample Size Percentage Project",
490+
)
491+
.await;
492+
493+
let body = serde_json::json!({
494+
"branch": "ssize-branch",
495+
"testbed": "ssize-testbed",
496+
"measure": "latency",
497+
"test": "percentage",
498+
"max_sample_size": 1,
499+
"upper_boundary": 0.05,
500+
});
501+
let resp = server
502+
.client
503+
.post(server.api_url(&format!("/v0/projects/{project_slug}/thresholds")))
504+
.header(
505+
bencher_json::AUTHORIZATION,
506+
bencher_json::bearer_header(&user.token),
507+
)
508+
.json(&body)
509+
.send()
510+
.await
511+
.expect("Request failed");
512+
assert_eq!(resp.status(), StatusCode::CREATED);
513+
let _threshold: JsonThreshold = resp.json().await.expect("Failed to parse threshold");
514+
}
515+
516+
#[tokio::test]
517+
async fn create_threshold_t_test_max_sample_size_one_rejected() {
518+
let server = TestServer::new().await;
519+
let user = server.signup("Test User", "ssize-ttest@example.com").await;
520+
let project_slug = create_project_with_branch_testbed_measure(
521+
&server,
522+
&user,
523+
"Sample Size TTest Org",
524+
"Sample Size TTest Project",
525+
)
526+
.await;
527+
528+
let body = serde_json::json!({
529+
"branch": "ssize-branch",
530+
"testbed": "ssize-testbed",
531+
"measure": "latency",
532+
"test": "t_test",
533+
"max_sample_size": 1,
534+
"upper_boundary": 0.99,
535+
});
536+
let resp = server
537+
.client
538+
.post(server.api_url(&format!("/v0/projects/{project_slug}/thresholds")))
539+
.header(
540+
bencher_json::AUTHORIZATION,
541+
bencher_json::bearer_header(&user.token),
542+
)
543+
.json(&body)
544+
.send()
545+
.await
546+
.expect("Request failed");
547+
assert!(
548+
resp.status().is_client_error(),
549+
"Expected 4xx, got: {}",
550+
resp.status()
551+
);
552+
}
553+
554+
#[tokio::test]
555+
async fn create_threshold_iqr_max_sample_size_one_rejected() {
556+
let server = TestServer::new().await;
557+
let user = server.signup("Test User", "ssize-iqr@example.com").await;
558+
let project_slug = create_project_with_branch_testbed_measure(
559+
&server,
560+
&user,
561+
"Sample Size IQR Org",
562+
"Sample Size IQR Project",
563+
)
564+
.await;
565+
566+
let body = serde_json::json!({
567+
"branch": "ssize-branch",
568+
"testbed": "ssize-testbed",
569+
"measure": "latency",
570+
"test": "iqr",
571+
"max_sample_size": 1,
572+
"upper_boundary": 1.5,
573+
});
574+
let resp = server
575+
.client
576+
.post(server.api_url(&format!("/v0/projects/{project_slug}/thresholds")))
577+
.header(
578+
bencher_json::AUTHORIZATION,
579+
bencher_json::bearer_header(&user.token),
580+
)
581+
.json(&body)
582+
.send()
583+
.await
584+
.expect("Request failed");
585+
assert!(
586+
resp.status().is_client_error(),
587+
"Expected 4xx, got: {}",
588+
resp.status()
589+
);
590+
}

lib/bencher_boundary/src/limits/mod.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -957,7 +957,7 @@ mod tests {
957957
);
958958
}
959959

960-
// Edge case: at the minimum allowed sample size (`SampleSize::MIN = 2`), the t-test
960+
// Edge case: at the minimum allowed sample size (`SampleSize::TWO = 2`), the t-test
961961
// uses `freedom = 1` (a Cauchy distribution) and `sqrt(1 + 1/2) = sqrt(1.5)` scaling.
962962
// The interval must still be finite, and it must be strictly wider than the
963963
// z-score interval, since both the t-quantile with 1 dof and the PI scaling factor

lib/bencher_valid/src/error.rs

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
use crate::{Boundary, SampleSize, Window};
1+
use crate::{Boundary, ModelTest, SampleSize, Window};
22

33
pub(crate) const REGEX_ERROR: &str = "Failed to compile regex.";
44

@@ -151,6 +151,14 @@ pub enum ValidError {
151151
"Invalid model, minimum sample size ({min}) is greater than maximum sample size ({max})"
152152
)]
153153
SampleSizes { min: SampleSize, max: SampleSize },
154+
#[error(
155+
"Invalid model, sample size ({sample_size}) is below the minimum ({min}) required for the {test} test"
156+
)]
157+
TestSampleSize {
158+
test: ModelTest,
159+
sample_size: SampleSize,
160+
min: SampleSize,
161+
},
154162
#[error("Invalid model, lower boundary ({lower}) is greater than upper boundary ({upper})")]
155163
Boundaries { lower: Boundary, upper: Boundary },
156164
#[error("Invalid model, no boundary provided")]

0 commit comments

Comments
 (0)