1use std::{
2 cmp::Ordering,
3 panic::Location,
4 sync::{
5 Mutex,
6 atomic::{AtomicBool, AtomicUsize, Ordering as AtomicOrdering},
7 },
8};
9
10use serde::Serialize;
11
12use crate::{
13 CascadeDeclaration, CascadeLevel, CascadeOutcome, SpecificityExactnessV0,
14 axis_order::{CascadeKeyAxisV0, first_deciding_cascade_key_axis_v0},
15 model::compare_cascade_axis_prefix,
16};
17
18static CAPTURE_ACTIVE: AtomicBool = AtomicBool::new(false);
19static CAPTURED_ROWS: Mutex<Vec<CascadeRankedSetLossCensusRowV0>> = Mutex::new(Vec::new());
20static CAPTURE_STATE_RECOVERY_COUNT: AtomicUsize = AtomicUsize::new(0);
21static MEASUREMENT_INVOCATION_COUNT: AtomicUsize = AtomicUsize::new(0);
22static RANKED_SET_OUTCOME_COUNT: AtomicUsize = AtomicUsize::new(0);
23static MULTI_CANDIDATE_INEXACT_RANKED_SET_COUNT: AtomicUsize = AtomicUsize::new(0);
24
25#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize)]
26#[serde(rename_all = "camelCase")]
27pub enum CascadeRankedSetFunctionV0 {
28 CascadeProperty,
29 CascadePropertyOpenWorld,
30}
31
32#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize)]
33#[serde(rename_all = "camelCase")]
34pub enum CascadeAxisPrefixV0 {
35 Level,
36 LayerRank,
37 ScopeProximity,
41}
42
43#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
44#[serde(rename_all = "camelCase")]
45pub enum CascadeRankedSetLossClassV0 {
46 RecoverableAxisDominant { axis: CascadeAxisPrefixV0 },
47 AxisWinnerInexact,
48 NoStrictAxisDominance,
49 SingleInexactCandidate,
50}
51
52#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
53#[serde(rename_all = "camelCase")]
54pub struct CascadeRankedSetLossCandidateV0 {
55 pub declaration_id: String,
56 pub level: CascadeLevel,
57 pub layer_rank: i32,
58 pub scope_proximity: u32,
59 pub specificity_exactness: SpecificityExactnessV0,
60}
61
62#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
63#[serde(rename_all = "camelCase")]
64pub struct CascadeRankedSetLossCensusRowV0 {
65 pub function: CascadeRankedSetFunctionV0,
66 pub invocation_site: &'static str,
67 pub source_path: String,
68 pub property: String,
69 pub declaration_ids: Vec<String>,
70 pub candidate_count: usize,
71 pub candidates: Vec<CascadeRankedSetLossCandidateV0>,
72 pub classification: CascadeRankedSetLossClassV0,
73}
74
75#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
76#[serde(rename_all = "camelCase")]
77pub struct CascadeRankedSetLossCaptureV0 {
78 pub schema_version: &'static str,
79 pub product: &'static str,
80 pub capture_state_recovery_count: usize,
81 pub measurement_invocation_count: usize,
82 pub ranked_set_outcome_count: usize,
83 pub multi_candidate_inexact_ranked_set_count: usize,
84 pub rows: Vec<CascadeRankedSetLossCensusRowV0>,
85}
86
87pub fn capture_cascade_ranked_set_losses<R>(
93 operation: impl FnOnce() -> R,
94) -> Result<(R, CascadeRankedSetLossCaptureV0), &'static str> {
95 CAPTURE_ACTIVE
96 .compare_exchange(false, true, AtomicOrdering::AcqRel, AtomicOrdering::Acquire)
97 .map_err(|_| "cascade ranked-set loss capture is already active")?;
98 CAPTURE_STATE_RECOVERY_COUNT.store(0, AtomicOrdering::Release);
99 captured_rows().clear();
100 MEASUREMENT_INVOCATION_COUNT.store(0, AtomicOrdering::Release);
101 RANKED_SET_OUTCOME_COUNT.store(0, AtomicOrdering::Release);
102 MULTI_CANDIDATE_INEXACT_RANKED_SET_COUNT.store(0, AtomicOrdering::Release);
103 let guard = CaptureGuard;
104 let result = operation();
105 let mut rows = std::mem::take(&mut *captured_rows());
106 rows.sort_by(|left, right| {
107 (
108 left.function,
109 left.invocation_site,
110 left.source_path.as_str(),
111 left.property.as_str(),
112 left.declaration_ids.as_slice(),
113 )
114 .cmp(&(
115 right.function,
116 right.invocation_site,
117 right.source_path.as_str(),
118 right.property.as_str(),
119 right.declaration_ids.as_slice(),
120 ))
121 });
122 drop(guard);
123 Ok((
124 result,
125 CascadeRankedSetLossCaptureV0 {
126 schema_version: "0",
127 product: "omena-cascade.ranked-set-loss-capture",
128 capture_state_recovery_count: CAPTURE_STATE_RECOVERY_COUNT
129 .load(AtomicOrdering::Acquire),
130 measurement_invocation_count: MEASUREMENT_INVOCATION_COUNT
131 .load(AtomicOrdering::Acquire),
132 ranked_set_outcome_count: RANKED_SET_OUTCOME_COUNT.load(AtomicOrdering::Acquire),
133 multi_candidate_inexact_ranked_set_count: MULTI_CANDIDATE_INEXACT_RANKED_SET_COUNT
134 .load(AtomicOrdering::Acquire),
135 rows,
136 },
137 ))
138}
139
140pub fn classify_cascade_ranked_set_loss(
141 declarations: &[CascadeDeclaration],
142) -> CascadeRankedSetLossClassV0 {
143 assert!(
144 declarations.iter().any(|declaration| {
145 declaration.specificity_exactness == SpecificityExactnessV0::Inexact
146 }),
147 "ranked-set loss classification requires an inexact declaration",
148 );
149 if declarations.len() == 1 {
150 return CascadeRankedSetLossClassV0::SingleInexactCandidate;
151 }
152
153 let Some((winner_index, deciding_axis)) = strict_axis_prefix_winner(declarations) else {
154 return CascadeRankedSetLossClassV0::NoStrictAxisDominance;
155 };
156 if declarations[winner_index].specificity_exactness == SpecificityExactnessV0::Inexact {
157 CascadeRankedSetLossClassV0::AxisWinnerInexact
158 } else {
159 CascadeRankedSetLossClassV0::RecoverableAxisDominant {
160 axis: deciding_axis,
161 }
162 }
163}
164
165pub(crate) fn observe_cascade_outcome(
166 function: CascadeRankedSetFunctionV0,
167 caller: &'static Location<'static>,
168 outcome: &CascadeOutcome,
169) {
170 if !CAPTURE_ACTIVE.load(AtomicOrdering::Acquire) {
171 return;
172 }
173 MEASUREMENT_INVOCATION_COUNT.fetch_add(1, AtomicOrdering::AcqRel);
174 let CascadeOutcome::RankedSet(declarations) = outcome else {
175 return;
176 };
177 RANKED_SET_OUTCOME_COUNT.fetch_add(1, AtomicOrdering::AcqRel);
178 if !declarations
179 .iter()
180 .any(|declaration| declaration.specificity_exactness == SpecificityExactnessV0::Inexact)
181 {
182 return;
183 }
184 if declarations.len() > 1 {
185 MULTI_CANDIDATE_INEXACT_RANKED_SET_COUNT.fetch_add(1, AtomicOrdering::AcqRel);
186 }
187 let row = CascadeRankedSetLossCensusRowV0 {
188 function,
189 invocation_site: invocation_site(caller.file()),
190 source_path: caller.file().to_string(),
191 property: declarations
192 .first()
193 .map(|declaration| declaration.property.clone())
194 .unwrap_or_default(),
195 declaration_ids: declarations
196 .iter()
197 .map(|declaration| declaration.id.clone())
198 .collect(),
199 candidate_count: declarations.len(),
200 candidates: declarations
201 .iter()
202 .map(|declaration| CascadeRankedSetLossCandidateV0 {
203 declaration_id: declaration.id.clone(),
204 level: declaration.key.level,
205 layer_rank: declaration.key.layer_rank.get(),
206 scope_proximity: declaration.key.scope_proximity,
207 specificity_exactness: declaration.specificity_exactness,
208 })
209 .collect(),
210 classification: classify_cascade_ranked_set_loss(declarations),
211 };
212 captured_rows().push(row);
213}
214
215fn strict_axis_prefix_winner(
216 declarations: &[CascadeDeclaration],
217) -> Option<(usize, CascadeAxisPrefixV0)> {
218 let mut ranked = declarations.iter().enumerate().collect::<Vec<_>>();
219 ranked.sort_by(|(_, left), (_, right)| compare_cascade_axis_prefix(&right.key, &left.key));
220 let [(winner_index, winner), (_, runner_up), ..] = ranked.as_slice() else {
221 return None;
222 };
223 let ordering = compare_cascade_axis_prefix(&winner.key, &runner_up.key);
224 if ordering != Ordering::Greater {
225 return None;
226 }
227 let deciding_axis = deciding_axis(&winner.key, &runner_up.key);
228 Some((*winner_index, deciding_axis))
229}
230
231fn deciding_axis(winner: &crate::CascadeKey, runner_up: &crate::CascadeKey) -> CascadeAxisPrefixV0 {
232 match first_deciding_cascade_key_axis_v0(winner, runner_up) {
233 Some(CascadeKeyAxisV0::Level) => CascadeAxisPrefixV0::Level,
234 Some(CascadeKeyAxisV0::LayerRank) => CascadeAxisPrefixV0::LayerRank,
235 Some(CascadeKeyAxisV0::ScopeProximity) => CascadeAxisPrefixV0::ScopeProximity,
236 _ => unreachable!("a strict cascade axis-prefix winner must differ on one prefix axis"),
237 }
238}
239
240fn invocation_site(source_path: &str) -> &'static str {
241 if source_path.ends_with("omena-query/src/style/cascade_checker/runtime_state.rs") {
242 "queryRuntimeStateScenarioEvaluation"
243 } else if source_path.ends_with("omena-query/src/style/cascade_checker/confidence.rs") {
244 "queryCascadeMarginForEvaluation"
245 } else if source_path.ends_with("omena-query/src/style/cascade_checker/replica_ensemble.rs") {
246 "collectQueryReplicaEnsembleSiteOutcomes"
247 } else if source_path.ends_with("omena-cascade/src/computed_value.rs") {
248 "computeCascadeComputedValue"
249 } else if source_path.ends_with("omena-transform-passes/src/runtime/winner_equality.rs") {
250 "transformWinnerEqualityFromCascadeOutcome"
251 } else {
252 "unclassified"
253 }
254}
255
256fn captured_rows() -> std::sync::MutexGuard<'static, Vec<CascadeRankedSetLossCensusRowV0>> {
257 let (rows, recovered) = recover_captured_rows(CAPTURED_ROWS.lock(), &CAPTURED_ROWS);
258 if recovered {
259 CAPTURE_STATE_RECOVERY_COUNT.fetch_add(1, AtomicOrdering::AcqRel);
260 }
261 rows
262}
263
264fn recover_captured_rows<'a>(
265 lock: std::sync::LockResult<std::sync::MutexGuard<'a, Vec<CascadeRankedSetLossCensusRowV0>>>,
266 mutex: &'a Mutex<Vec<CascadeRankedSetLossCensusRowV0>>,
267) -> (
268 std::sync::MutexGuard<'a, Vec<CascadeRankedSetLossCensusRowV0>>,
269 bool,
270) {
271 match lock {
272 Ok(rows) => (rows, false),
273 Err(poisoned) => {
274 mutex.clear_poison();
275 let mut rows = poisoned.into_inner();
276 rows.clear();
277 (rows, true)
278 }
279 }
280}
281
282struct CaptureGuard;
283
284impl Drop for CaptureGuard {
285 fn drop(&mut self) {
286 CAPTURE_ACTIVE.store(false, AtomicOrdering::Release);
287 }
288}
289
290#[cfg(test)]
291mod tests {
292 use super::{
293 CascadeAxisPrefixV0, CascadeRankedSetLossCensusRowV0, CascadeRankedSetLossClassV0,
294 classify_cascade_ranked_set_loss, recover_captured_rows,
295 };
296 use crate::{
297 CascadeDeclaration, CascadeKey, CascadeLevel, CascadeValue, LayerOrdinal,
298 OpenWorldTieEvidence, Specificity, SpecificityExactnessV0, normalized_layer_rank,
299 };
300
301 fn declaration(
302 id: &str,
303 level: CascadeLevel,
304 layer_ordinal: i32,
305 scope_proximity: u32,
306 specificity: Specificity,
307 exactness: SpecificityExactnessV0,
308 ) -> CascadeDeclaration {
309 CascadeDeclaration {
310 id: id.to_string(),
311 property: "color".to_string(),
312 value: CascadeValue::Literal(id.to_string()),
313 key: CascadeKey::new(
314 level,
315 normalized_layer_rank(false, LayerOrdinal::new(layer_ordinal)),
316 scope_proximity,
317 specificity,
318 0,
319 ),
320 open_world_tie_evidence: OpenWorldTieEvidence::NONE,
321 specificity_exactness: exactness,
322 }
323 }
324
325 #[test]
326 fn axis_winner_exactness_changes_the_recoverability_class() {
327 let lower = declaration(
328 "lower",
329 CascadeLevel::UserNormal,
330 0,
331 0,
332 Specificity::new(9, 9, 9),
333 SpecificityExactnessV0::Inexact,
334 );
335 let exact_winner = declaration(
336 "winner",
337 CascadeLevel::AuthorNormal,
338 0,
339 0,
340 Specificity::ZERO,
341 SpecificityExactnessV0::Exact,
342 );
343 assert_eq!(
344 classify_cascade_ranked_set_loss(&[lower.clone(), exact_winner.clone()]),
345 CascadeRankedSetLossClassV0::RecoverableAxisDominant {
346 axis: CascadeAxisPrefixV0::Level
347 }
348 );
349
350 let mut inexact_winner = exact_winner;
351 inexact_winner.specificity_exactness = SpecificityExactnessV0::Inexact;
352 assert_eq!(
353 classify_cascade_ranked_set_loss(&[lower, inexact_winner]),
354 CascadeRankedSetLossClassV0::AxisWinnerInexact
355 );
356 }
357
358 #[test]
359 fn specificity_only_winner_has_no_strict_axis_dominance() {
360 let weaker = declaration(
361 "weaker",
362 CascadeLevel::AuthorNormal,
363 0,
364 0,
365 Specificity::new(0, 1, 0),
366 SpecificityExactnessV0::Inexact,
367 );
368 let stronger = declaration(
369 "stronger",
370 CascadeLevel::AuthorNormal,
371 0,
372 0,
373 Specificity::new(1, 0, 0),
374 SpecificityExactnessV0::Exact,
375 );
376 assert_eq!(
377 classify_cascade_ranked_set_loss(&[weaker, stronger]),
378 CascadeRankedSetLossClassV0::NoStrictAxisDominance
379 );
380 }
381
382 #[test]
383 fn single_inexact_candidate_is_not_vacuously_recoverable() {
384 let candidate = declaration(
385 "only",
386 CascadeLevel::AuthorNormal,
387 0,
388 0,
389 Specificity::ZERO,
390 SpecificityExactnessV0::Inexact,
391 );
392 assert_eq!(
393 classify_cascade_ranked_set_loss(&[candidate]),
394 CascadeRankedSetLossClassV0::SingleInexactCandidate
395 );
396 }
397
398 #[test]
399 #[should_panic(expected = "requires an inexact declaration")]
400 fn exact_only_input_is_outside_the_loss_classifier_domain() {
401 let candidate = declaration(
402 "exact",
403 CascadeLevel::AuthorNormal,
404 0,
405 0,
406 Specificity::ZERO,
407 SpecificityExactnessV0::Exact,
408 );
409 let _ = classify_cascade_ranked_set_loss(&[candidate]);
410 }
411
412 #[test]
413 fn poisoned_capture_storage_is_cleared_and_reported() {
414 let rows = std::sync::Arc::new(
415 std::sync::Mutex::<Vec<CascadeRankedSetLossCensusRowV0>>::new(Vec::new()),
416 );
417 let poisoned_rows = std::sync::Arc::clone(&rows);
418 let poison_result = std::thread::spawn(move || {
419 let _guard = match poisoned_rows.lock() {
420 Ok(guard) => guard,
421 Err(error) => error.into_inner(),
422 };
423 std::panic::resume_unwind(Box::new("poison capture storage"));
424 })
425 .join();
426 assert!(poison_result.is_err());
427
428 let (recovered_rows, recovered) = recover_captured_rows(rows.lock(), &rows);
429 assert!(recovered);
430 assert!(recovered_rows.is_empty());
431 assert!(!rows.is_poisoned());
432 }
433}