///|
/// Exact-backed quantile summary for small to medium samples.
///
/// It uses the same reporting interface that a future approximate quantile
/// sketch can keep, so examples and CI workflows do not need to change.
pub(all) struct QuantileSummary {
values : Array[Int]
sorted : Bool
} derive(Eq, Debug)
///|
pub(all) struct NumericSummary {
count : Int
min : Int?
max : Int?
sum : Int
mean : Double?
p50 : Int?
p90 : Int?
p99 : Int?
} derive(Eq, Debug)
///|
pub fn quantile_new() -> QuantileSummary {
{ values: [], sorted: true }
}
///|
pub fn quantile_add(summary : QuantileSummary, value : Int) -> QuantileSummary {
let values = summary.values
values.push(value)
{ values, sorted: false }
}
///|
pub fn quantile_from_values(values : Array[Int]) -> QuantileSummary {
let mut summary = quantile_new()
for value in values {
summary = quantile_add(summary, value)
}
quantile_sorted(summary)
}
///|
pub fn quantile_from_text(input : String) -> QuantileSummary {
let values : Array[Int] = Array::new()
for token in parse_events(input) {
match quantile_parse_int(token) {
Some(value) => values.push(value)
None => ()
}
}
quantile_from_values(values)
}
///|
pub fn quantile_sorted(summary : QuantileSummary) -> QuantileSummary {
if summary.sorted {
return summary
}
let values = summary.values
for i in 0.. Int? {
let sorted = quantile_sorted(summary)
if sorted.values.length() == 0 {
return None
}
let pct = if percentile < 0 {
0
} else if percentile > 100 {
100
} else {
percentile
}
let index = ((sorted.values.length() - 1) * pct + 50) / 100
Some(sorted.values[index])
}
///|
pub fn numeric_summary(summary : QuantileSummary) -> NumericSummary {
let sorted = quantile_sorted(summary)
let count = sorted.values.length()
if count == 0 {
return {
count,
min: None,
max: None,
sum: 0,
mean: None,
p50: None,
p90: None,
p99: None,
}
}
let mut sum = 0
for value in sorted.values {
sum += value
}
{
count,
min: Some(sorted.values[0]),
max: Some(sorted.values[count - 1]),
sum,
mean: Some(sum.to_double() / count.to_double()),
p50: quantile_value(sorted, 50),
p90: quantile_value(sorted, 90),
p99: quantile_value(sorted, 99),
}
}
///|
pub fn numeric_summary_markdown(summary : QuantileSummary) -> String {
let stats = numeric_summary(summary)
let out = StringBuilder()
out.write_string("# Numeric Summary\n\n")
out.write_string("| metric | value |\n| --- | ---: |\n")
out.write_string("| count | " + stats.count.to_string() + " |\n")
out.write_string("| min | " + quantile_optional_int(stats.min) + " |\n")
out.write_string("| max | " + quantile_optional_int(stats.max) + " |\n")
out.write_string("| sum | " + stats.sum.to_string() + " |\n")
out.write_string("| mean | " + quantile_optional_double(stats.mean) + " |\n")
out.write_string("| p50 | " + quantile_optional_int(stats.p50) + " |\n")
out.write_string("| p90 | " + quantile_optional_int(stats.p90) + " |\n")
out.write_string("| p99 | " + quantile_optional_int(stats.p99) + " |\n")
out.to_string()
}
///|
pub fn numeric_summary_json(summary : QuantileSummary) -> String {
let stats = numeric_summary(summary)
let out = StringBuilder()
out.write_string("{")
out.write_string("\"count\":" + stats.count.to_string())
out.write_string(",\"min\":" + quantile_json_int(stats.min))
out.write_string(",\"max\":" + quantile_json_int(stats.max))
out.write_string(",\"sum\":" + stats.sum.to_string())
out.write_string(",\"mean\":" + quantile_json_double(stats.mean))
out.write_string(",\"p50\":" + quantile_json_int(stats.p50))
out.write_string(",\"p90\":" + quantile_json_int(stats.p90))
out.write_string(",\"p99\":" + quantile_json_int(stats.p99))
out.write_string("}")
out.to_string()
}
///|
pub fn quantile_compare_markdown(
baseline : QuantileSummary,
candidate : QuantileSummary,
) -> String {
let base = numeric_summary(baseline)
let cand = numeric_summary(candidate)
let out = StringBuilder()
out.write_string("# Quantile Drift Report\n\n")
out.write_string(
"| metric | baseline | candidate | delta |\n| --- | ---: | ---: | ---: |\n",
)
quantile_compare_row(out, "count", base.count, cand.count)
quantile_compare_option_row(out, "min", base.min, cand.min)
quantile_compare_option_row(out, "max", base.max, cand.max)
quantile_compare_option_row(out, "p50", base.p50, cand.p50)
quantile_compare_option_row(out, "p90", base.p90, cand.p90)
quantile_compare_option_row(out, "p99", base.p99, cand.p99)
out.to_string()
}
///|
fn quantile_compare_row(
out : StringBuilder,
label : String,
baseline : Int,
candidate : Int,
) -> Unit {
out.write_string(
"| " +
label +
" | " +
baseline.to_string() +
" | " +
candidate.to_string() +
" | " +
(candidate - baseline).to_string() +
" |\n",
)
}
///|
fn quantile_compare_option_row(
out : StringBuilder,
label : String,
baseline : Int?,
candidate : Int?,
) -> Unit {
match (baseline, candidate) {
(Some(b), Some(c)) => quantile_compare_row(out, label, b, c)
_ =>
out.write_string(
"| " +
label +
" | " +
quantile_optional_int(baseline) +
" | " +
quantile_optional_int(candidate) +
" | |\n",
)
}
}
///|
fn quantile_optional_int(value : Int?) -> String {
match value {
Some(v) => v.to_string()
None => ""
}
}
///|
fn quantile_optional_double(value : Double?) -> String {
match value {
Some(v) => sketch_double_text(v)
None => ""
}
}
///|
fn quantile_json_int(value : Int?) -> String {
match value {
Some(v) => v.to_string()
None => "null"
}
}
///|
fn quantile_json_double(value : Double?) -> String {
match value {
Some(v) => sketch_double_text(v)
None => "null"
}
}
///|
fn quantile_parse_int(token : String) -> Int? {
try @string.parse_int(token) catch {
_ => None
} noraise {
value => Some(value)
}
}