1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
|
//! Rule-based policy triggers. They feed the LLM as candidates and are the fallback answer
//! when no API key is set or the LLM output fails validation.
use crate::facts::Facts;
use crate::policies;
use serde::Serialize;
#[derive(Serialize, Clone)]
pub struct Trigger {
pub policy_id: &'static str,
pub score: f64,
pub reasons: Vec<String>,
pub evidence: Vec<String>,
}
fn fmt(v: f64) -> String {
if v.fract() == 0.0 || v.abs() >= 100.0 {
let s = format!("{:.0}", v);
// thousands separator
let (sign, d) = s.split_at(if s.starts_with('-') { 1 } else { 0 });
let mut o = String::new();
for (i, c) in d.chars().enumerate() {
if i > 0 && (d.len() - i) % 3 == 0 {
o.push(',');
}
o.push(c);
}
format!("{sign}{o}")
} else {
format!("{v:.1}")
}
}
/// " in a 1-in-100-year flood" for ids like "flood.rp100.x" / "region.rp100.x".
fn scen(id: &str) -> String {
match id.split('.').nth(1).filter(|k| k.starts_with("rp") || *k == "observed") {
Some(k) => format!(" in a {} flood", crate::facts::scenario_label(k)),
None => String::new(),
}
}
pub fn run(f: &Facts) -> Vec<Trigger> {
let mut t: Vec<Trigger> = Vec::new();
let mut add = |id: &'static str, score: f64, reason: String, ev: &str| {
if let Some(x) = t.iter_mut().find(|x| x.policy_id == id) {
x.score += score;
x.reasons.push(reason);
x.evidence.push(ev.to_string());
} else {
t.push(Trigger { policy_id: id, score, reasons: vec![reason], evidence: vec![ev.to_string()] });
}
};
if let Some((id, v)) = f.max_suffix(".people_flooded_or_cut_off").filter(|x| x.1 > 0.0) {
add("ST-ALERT", 3.0, format!("{} people would be flooded or cut off{}", fmt(v), scen(&id)), &id);
add("ST-COMMS", 2.0, format!("{} people need to know what to do", fmt(v)), &id);
}
if let Some((id, v)) = f.max_suffix(".people_leave_now").filter(|x| x.1 > 0.0) {
add("ST-EVACAPP", 3.0, format!("{} people must leave now{}", fmt(v), scen(&id)), &id);
add("ST-ALERT", 2.0, format!("{} people must leave now, so warnings must arrive early", fmt(v)), &id);
}
for suf in [".people_boat_or_air", "evacuation.max_boat_or_air", "timeline.peak_people_air"] {
if let Some((id, v)) = f.max_suffix(suf).filter(|x| x.1 > 0.0) {
add("ST-PREP", 3.0, format!("{} people reachable only by boat or air{}: pre-position rescue means", fmt(v), scen(&id)), &id);
add("ST-CRISIS", 2.0, format!("{} people need boat or air rescue{}: coordinate reinforcements", fmt(v), scen(&id)), &id);
break; // the first source (flood scenarios) is enough
}
}
if let Some(v) = f.get("evacuation.max_need_transport").filter(|v| *v > 0.0) {
add("ST-INCLUSIVE", 3.0, format!("{} evacuees need transport", fmt(v)), "evacuation.max_need_transport");
}
if let Some(v) = f.get("evacuation.max_time_to_safety_p95_h").filter(|v| *v > 0.0) {
let s = if v >= 2.0 { 3.0 } else { 1.0 };
add("ST-EVACAPP", s, format!("95% of evacuees safe only after {} h", fmt(v)), "evacuation.max_time_to_safety_p95_h");
if v >= 2.0 {
add("ST-ALERT", 2.0, format!("evacuation takes {} h, longer than a flash-flood warning window", fmt(v)), "evacuation.max_time_to_safety_p95_h");
}
}
for suf in [".roads_closed_km", "evacuation.max_roads_congested_km"] {
if let Some((id, v)) = f.max_suffix(suf).filter(|x| x.1 > 0.0) {
add("ST-EVACAPP", 1.5, format!("{} km of road closed or jammed{}: route around it", fmt(v), scen(&id)), &id);
}
}
if let Some(v) = f.get("evacuation.buildings_cut_off").filter(|v| *v > 0.0) {
add("ST-INCLUSIVE", 1.5, format!("{} buildings cut off: people there depend on others", fmt(v)), "evacuation.buildings_cut_off");
}
// care facilities / critical infrastructure in the area
for fact in &f.0 {
let Some(k) = fact.id.strip_prefix("region.facilities.") else { continue };
let Some(n) = fact.value.as_f64().filter(|n| *n > 0.0) else { continue };
let k = k.to_lowercase();
if k.contains("hospital") || k.contains("nursing") || k.contains("care") || k.contains("elder") {
add("ST-CAREHOMES", 3.0, format!("{}: {} in the area", k.replace('_', " "), fmt(n)), &fact.id);
add("ST-PREP", 1.5, format!("{}: {} to keep running", k.replace('_', " "), fmt(n)), &fact.id);
} else if k.contains("fire") || k.contains("police") || k.contains("power") || k.contains("water") {
add("ST-PREP", 1.0, format!("{}: {} in the area", k.replace('_', " "), fmt(n)), &fact.id);
} else if k.contains("school") || k.contains("shelter") || k.contains("stadium") || k.contains("sport") {
add("ST-EVACAPP", 0.5, format!("{}: {} usable as shelters", k.replace('_', " "), fmt(n)), &fact.id);
}
}
if let Some(v) = f.get("region.people_in_high_risk").filter(|v| *v > 0.0) {
add("ST-ALERT", 1.5, format!("~{} people live in high-risk cells", fmt(v)), "region.people_in_high_risk");
add("LT-ZONING", 2.0, format!("~{} people live in high-risk cells", fmt(v)), "region.people_in_high_risk");
}
if let Some(v) = f.get("region.high_risk_km2").filter(|v| *v > 0.0) {
add("ST-MITIG", 1.5, format!("{} km² at high risk should shape land-use plans", fmt(v)), "region.high_risk_km2");
add("LT-RETENTION", 1.0, format!("{} km² at high risk", fmt(v)), "region.high_risk_km2");
}
if let Some((id, v)) = f.max_suffix(".buildings_flooded").filter(|x| x.1 > 0.0) {
add("LT-RECOVERY", 2.0, format!("{} buildings would flood{}", fmt(v), scen(&id)), &id);
add("ST-MITIG", 1.5, format!("{} buildings exposed{}", fmt(v), scen(&id)), &id);
add("LT-ZONING", 1.0, format!("{} buildings exposed{}", fmt(v), scen(&id)), &id);
}
if let Some(v) = f.get("impact.loss_m_eur_2021").filter(|v| *v > 0.0) {
add("LT-RECOVERY", 1.5, format!("~€{}M building loss in July 2021 (all EMS map areas)", fmt(v)), "impact.loss_m_eur_2021");
add("ST-MITIG", 1.0, format!("~€{}M building loss in July 2021 (all EMS map areas)", fmt(v)), "impact.loss_m_eur_2021");
}
if let Some(v) = f.get("impact.displaced_2021").filter(|v| *v > 0.0) {
add("LT-ZONING", 1.0, format!("~{} people displaced in July 2021 (all EMS map areas)", fmt(v)), "impact.displaced_2021");
}
// weak model fit → more monitoring, less reliance on the map alone
let pct = f.get("region.cells_outside_training_pct").unwrap_or(0.0);
let auc = f.get("region.nearest_training_aoi_auc").unwrap_or(1.0);
if pct > 10.0 || auc < 0.7 {
let (id, r) = if pct > 10.0 {
("region.cells_outside_training_pct", format!("{}% of cells are outside the model's training range: monitor on the ground", fmt(pct)))
} else {
("region.nearest_training_aoi_auc", format!("nearest training area held-out AUC is only {}: do not rely on the map alone", fmt(auc)))
};
add("ST-AI", 2.0, r, id);
}
if f.has("models.eval0") {
add("ST-AI", 0.5, "model checks are available and should be shown with every decision".into(), "models.eval0");
}
if let Some(ev) = t.first().map(|x| x.evidence[0].clone()) {
t.push(Trigger {
policy_id: "LT-AWARE",
score: 0.5,
reasons: vec!["people in exposed areas need to know the risk before the next event".into()],
evidence: vec![ev],
});
}
t.retain(|x| policies::get(x.policy_id).is_some());
t.sort_by(|a, b| b.score.total_cmp(&a.score));
t
}
|