///|
/// Probability-of-success and risk reporting primitives for design studies.
pub(all) struct RiskObservation {
scenario : String
outcome : Double
threshold : Double
weight : Double
pass : Bool
} derive(Debug, Eq, ToJson)
///|
pub(all) struct RiskSummary {
observations : Int
weighted_pass_probability : Double
mean_outcome : Double
lower_outcome : Double
upper_outcome : Double
expected_shortfall : Double
worst_scenario : String
risk_level : String
} derive(Debug, Eq, ToJson)
///|
pub(all) struct ReliabilityCurvePoint {
threshold : Double
pass_probability : Double
failure_probability : Double
sample_count : Int
} derive(Debug, Eq, ToJson)
///|
pub fn make_risk_observation(
scenario : String,
outcome : Double,
threshold : Double,
weight : Double,
higher_is_better : Bool,
) -> RiskObservation raise @domain.ExtractionError {
if weight < 0.0 {
raise @domain.ExtractionError::InvalidArgument(
message="risk weight cannot be negative",
)
}
{
scenario,
outcome,
threshold,
weight,
pass: if higher_is_better {
outcome >= threshold
} else {
outcome <= threshold
},
}
}
///|
pub fn summarize_risk(
observations : ArrayView[RiskObservation],
lower_probability : Double,
upper_probability : Double,
) -> RiskSummary raise @domain.ExtractionError {
if observations.length() == 0 ||
lower_probability < 0.0 ||
upper_probability > 1.0 ||
lower_probability > upper_probability {
raise @domain.ExtractionError::InvalidArgument(
message="risk summary inputs are invalid",
)
}
let outcomes = [ for observation in observations => observation.outcome ]
outcomes.sort()
let mut total_weight = 0.0
let mut pass_weight = 0.0
let mut mean = 0.0
let mut worst = observations[0].outcome
let mut worst_scenario = observations[0].scenario
for observation in observations {
total_weight += observation.weight
mean += observation.weight * observation.outcome
if observation.pass {
pass_weight += observation.weight
}
if observation.outcome < worst {
worst = observation.outcome
worst_scenario = observation.scenario
}
}
let mean_outcome = if total_weight == 0.0 { 0.0 } else { mean / total_weight }
let lower_index = (lower_probability * (outcomes.length() - 1).to_double()).to_int()
let upper_index = (upper_probability * (outcomes.length() - 1).to_double()).to_int()
let mut shortfall = 0.0
let mut shortfall_count = 0
for observation in observations {
if !observation.pass {
shortfall += (observation.threshold - observation.outcome).abs()
shortfall_count += 1
}
}
let pass_probability = if total_weight == 0.0 {
0.0
} else {
pass_weight / total_weight
}
{
observations: observations.length(),
weighted_pass_probability: pass_probability,
mean_outcome,
lower_outcome: outcomes[lower_index],
upper_outcome: outcomes[upper_index],
expected_shortfall: if shortfall_count == 0 {
0.0
} else {
shortfall / shortfall_count.to_double()
},
worst_scenario,
risk_level: if pass_probability >= 0.95 {
"low"
} else if pass_probability >= 0.8 {
"moderate"
} else {
"high"
},
}
}
///|
pub fn reliability_curve(
outcomes : ArrayView[Double],
thresholds : ArrayView[Double],
higher_is_better : Bool,
) -> Array[ReliabilityCurvePoint] {
let curve = []
for threshold in thresholds {
let mut pass = 0
for outcome in outcomes {
if (if higher_is_better {
outcome >= threshold
} else {
outcome <= threshold
}) {
pass += 1
}
}
let probability = if outcomes.length() == 0 {
0.0
} else {
pass.to_double() / outcomes.length().to_double()
}
curve.push({
threshold,
pass_probability: probability,
failure_probability: 1.0 - probability,
sample_count: outcomes.length(),
})
}
curve
}
///|
pub fn probability_above(
outcomes : ArrayView[Double],
threshold : Double,
) -> Double {
if outcomes.length() == 0 {
return 0.0
}
let mut count = 0
for outcome in outcomes {
if outcome >= threshold {
count += 1
}
}
count.to_double() / outcomes.length().to_double()
}
///|
pub fn probability_below(
outcomes : ArrayView[Double],
threshold : Double,
) -> Double {
1.0 - probability_above(outcomes, threshold)
}
///|
pub fn conditional_mean(
outcomes : ArrayView[Double],
threshold : Double,
above : Bool,
) -> Double {
let mut total = 0.0
let mut count = 0
for outcome in outcomes {
if (if above { outcome >= threshold } else { outcome < threshold }) {
total += outcome
count += 1
}
}
if count == 0 {
0.0
} else {
total / count.to_double()
}
}
///|
pub fn value_at_risk(
outcomes : ArrayView[Double],
probability : Double,
) -> Double raise @domain.ExtractionError {
if outcomes.length() == 0 || probability < 0.0 || probability > 1.0 {
raise @domain.ExtractionError::InvalidArgument(
message="value-at-risk inputs are invalid",
)
}
let ordered = outcomes.to_owned()
ordered.sort()
ordered[(probability * (ordered.length() - 1).to_double()).to_int()]
}
///|
pub fn conditional_value_at_risk(
outcomes : ArrayView[Double],
probability : Double,
) -> Double raise @domain.ExtractionError {
let threshold = value_at_risk(outcomes, probability)
let mut total = 0.0
let mut count = 0
for outcome in outcomes {
if outcome <= threshold {
total += outcome
count += 1
}
}
if count == 0 {
threshold
} else {
total / count.to_double()
}
}
///|
pub fn risk_summary_markdown(summary : RiskSummary) -> String {
"| Observations | Pass probability | Mean | Lower | Upper | Expected shortfall | Worst scenario | Risk |\n" +
"|---:|---:|---:|---:|---:|---:|---|---|\n" +
"| " +
summary.observations.to_string() +
" | " +
summary.weighted_pass_probability.to_string() +
" | " +
summary.mean_outcome.to_string() +
" | " +
summary.lower_outcome.to_string() +
" | " +
summary.upper_outcome.to_string() +
" | " +
summary.expected_shortfall.to_string() +
" | " +
summary.worst_scenario +
" | " +
summary.risk_level +
" |"
}
///|
pub fn reliability_curve_csv(
curve : ArrayView[ReliabilityCurvePoint],
) -> String {
let lines = ["threshold,pass_probability,failure_probability,sample_count"]
for point in curve {
lines.push(
point.threshold.to_string() +
"," +
point.pass_probability.to_string() +
"," +
point.failure_probability.to_string() +
"," +
point.sample_count.to_string(),
)
}
lines.join("\n")
}
///|
pub fn scenario_risk_order(
observations : ArrayView[RiskObservation],
) -> Array[String] {
let names = []
for observation in observations {
if !observation.pass {
names.push(observation.scenario)
}
}
names
}
///|
pub fn risk_margin(observation : RiskObservation) -> Double {
observation.outcome - observation.threshold
}
///|
pub fn risk_margin_vector(
observations : ArrayView[RiskObservation],
) -> Array[Double] {
[
for observation in observations => risk_margin(observation)
]
}
///|
pub fn risk_is_acceptable(
summary : RiskSummary,
minimum_probability : Double,
) -> Bool {
summary.weighted_pass_probability >= minimum_probability &&
summary.risk_level != "high"
}
///|
pub fn risk_level_from_probability(probability : Double) -> String {
if probability >= 0.95 {
"low"
} else if probability >= 0.8 {
"moderate"
} else {
"high"
}
}