shuyi_cli/contract/playtest/
summary.rs1use std::collections::BTreeMap;
6
7use serde::{Deserialize, Serialize};
8use shuyi_core::bots::playtest::Tried;
9
10#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
14#[serde(deny_unknown_fields)]
15#[non_exhaustive]
16pub enum Summary {
17 #[non_exhaustive]
19 Sum {
20 measure: String,
22 #[serde(default)]
24 label: Option<String>,
25 },
26 #[non_exhaustive]
29 Spread {
30 measure: String,
32 up_to: u32,
34 #[serde(default)]
36 label: Option<String>,
37 },
38 #[non_exhaustive]
42 Most {
43 prefix: String,
45 #[serde(default)]
47 skip_suffix: Option<String>,
48 #[serde(default)]
50 label: Option<String>,
51 },
52 #[non_exhaustive]
54 Median {
55 measure: String,
57 #[serde(default)]
59 label: Option<String>,
60 },
61 #[non_exhaustive]
63 Max {
64 measure: String,
66 #[serde(default)]
68 label: Option<String>,
69 },
70}
71
72impl Summary {
73 pub fn sum(measure: impl Into<String>) -> Self {
75 Summary::Sum {
76 measure: measure.into(),
77 label: None,
78 }
79 }
80
81 pub fn spread(measure: impl Into<String>, up_to: u32) -> Self {
84 Summary::Spread {
85 measure: measure.into(),
86 up_to,
87 label: None,
88 }
89 }
90
91 pub fn most(prefix: impl Into<String>) -> Self {
95 Summary::Most {
96 prefix: prefix.into(),
97 skip_suffix: None,
98 label: None,
99 }
100 }
101
102 pub fn median(measure: impl Into<String>) -> Self {
104 Summary::Median {
105 measure: measure.into(),
106 label: None,
107 }
108 }
109
110 pub fn max(measure: impl Into<String>) -> Self {
112 Summary::Max {
113 measure: measure.into(),
114 label: None,
115 }
116 }
117
118 #[must_use]
120 pub fn with_label(mut self, label: Option<String>) -> Self {
121 match &mut self {
122 Summary::Sum { label: l, .. }
123 | Summary::Spread { label: l, .. }
124 | Summary::Most { label: l, .. }
125 | Summary::Median { label: l, .. }
126 | Summary::Max { label: l, .. } => *l = label,
127 }
128 self
129 }
130
131 #[must_use]
134 pub fn with_skip_suffix(mut self, skip_suffix: Option<String>) -> Self {
135 if let Summary::Most { skip_suffix: s, .. } = &mut self {
136 *s = skip_suffix;
137 }
138 self
139 }
140
141 pub fn text(&self, tries: &[Tried]) -> String {
143 let named = |label: &Option<String>, measure: &str| -> String {
144 label.clone().unwrap_or_else(|| measure.to_owned())
145 };
146 match self {
147 Summary::Sum { measure, label } => {
148 let sum = values(tries, measure).fold(0i64, i64::saturating_add);
149 format!("{} {sum}/{}", named(label, measure), tries.len())
150 }
151 Summary::Spread {
152 measure,
153 up_to,
154 label,
155 } => {
156 let mut buckets: BTreeMap<u32, usize> = (0..=*up_to).map(|b| (b, 0)).collect();
157 for value in values(tries, measure) {
158 let bucket = u32::try_from(value.max(0)).unwrap_or(u32::MAX).min(*up_to);
159 *buckets.entry(bucket).or_default() += 1;
160 }
161 let spread: Vec<String> = buckets
162 .iter()
163 .map(|(&b, n)| {
164 let open = if b == *up_to { "+" } else { "" };
165 format!("{b}{open}: {n}")
166 })
167 .collect();
168 format!("{} per try {}", named(label, measure), spread.join(" "))
169 }
170 Summary::Most {
171 prefix,
172 skip_suffix,
173 label,
174 } => {
175 let mut set: BTreeMap<&str, u32> = BTreeMap::new();
176 for t in tries {
177 let keys = t
178 .measures
179 .iter()
180 .filter(|&(_, &value)| value >= 1)
181 .filter_map(|(key, _)| key.strip_prefix(prefix.as_str()))
182 .filter(|key| skip_suffix.as_ref().is_none_or(|s| !key.ends_with(s)));
183 for key in keys {
184 *set.entry(key).or_default() += 1;
185 }
186 }
187 let most = set
189 .iter()
190 .max_by(|a, b| a.1.cmp(b.1).then_with(|| b.0.cmp(a.0)))
191 .map_or_else(|| "nobody".to_owned(), |(key, n)| format!("{key} {n}×"));
192 format!("{}: {most}", named(label, "most"))
193 }
194 Summary::Median { measure, label } => {
195 let mut sorted: Vec<i64> = values(tries, measure).collect();
196 sorted.sort_unstable();
197 let median = sorted
198 .get(sorted.len().saturating_sub(1) / 2)
199 .copied()
200 .unwrap_or(0);
201 format!("{} median {median}", named(label, measure))
202 }
203 Summary::Max { measure, label } => {
204 let max = values(tries, measure).max().unwrap_or(0);
205 format!("{} max {max}", named(label, measure))
206 }
207 }
208 }
209}
210
211fn values<'a>(tries: &'a [Tried], measure: &'a str) -> impl Iterator<Item = i64> + 'a {
213 tries
214 .iter()
215 .map(move |t| t.measures.get(measure).copied().unwrap_or(0))
216}