1use std::{
2 panic::Location,
3 sync::{
4 Mutex,
5 atomic::{AtomicBool, AtomicUsize, Ordering as AtomicOrdering},
6 },
7};
8
9use omena_syntax::ident::AuthoredPropertyTextV0;
10use serde::Serialize;
11
12use crate::{
13 CascadeDeclaration, CascadeLevel, CascadeOutcome, SpecificityExactnessV0,
14 axis_order::CascadeKeyAxisV0,
15 ranking::{InexactSpecificityAdjudicationV0, adjudicate_inexact_specificity_v0},
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 RECOVERED_DEFINITE_OUTCOME_COUNT: AtomicUsize = AtomicUsize::new(0);
24static MULTI_CANDIDATE_INEXACT_RANKED_SET_COUNT: AtomicUsize = AtomicUsize::new(0);
25
26#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize)]
27#[serde(rename_all = "camelCase")]
28pub enum CascadeRankedSetFunctionV0 {
29 CascadeProperty,
30 CascadePropertyOpenWorld,
31}
32
33#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize)]
34#[serde(rename_all = "camelCase")]
35pub enum CascadeAxisPrefixV0 {
36 Level,
37 LayerRank,
38 ScopeProximity,
42}
43
44#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
45#[serde(rename_all = "camelCase")]
46pub enum CascadeRankedSetLossClassV0 {
47 RecoverableAxisDominant { axis: CascadeAxisPrefixV0 },
48 AxisWinnerInexact,
49 NoStrictAxisDominance,
50 SingleInexactCandidate,
51}
52
53#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize)]
54#[serde(rename_all = "camelCase")]
55pub enum CascadeRankedSetFinalOutcomeV0 {
56 RankedSet,
57 Definite,
58}
59
60#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
61#[serde(rename_all = "camelCase")]
62pub struct CascadeRankedSetLossCandidateV0 {
63 pub declaration_id: String,
64 pub level: CascadeLevel,
65 pub layer_rank: i32,
66 pub scope_proximity: u32,
67 pub specificity_exactness: SpecificityExactnessV0,
68}
69
70#[derive(Debug, Clone, Serialize)]
71#[serde(rename_all = "camelCase")]
72pub struct CascadeRankedSetLossCensusRowV0 {
73 pub function: CascadeRankedSetFunctionV0,
74 pub invocation_site: &'static str,
75 pub source_path: String,
76 pub property: AuthoredPropertyTextV0,
77 pub declaration_ids: Vec<String>,
78 pub candidate_count: usize,
79 pub candidates: Vec<CascadeRankedSetLossCandidateV0>,
80 pub classification: CascadeRankedSetLossClassV0,
81 pub final_outcome: CascadeRankedSetFinalOutcomeV0,
82 pub definite_winner_declaration_id: Option<String>,
83}
84
85impl PartialEq for CascadeRankedSetLossCensusRowV0 {
86 fn eq(&self, other: &Self) -> bool {
87 self.function == other.function
88 && self.invocation_site == other.invocation_site
89 && self.source_path == other.source_path
90 && self
91 .property
92 .to_property_name()
93 .same_as(&other.property.to_property_name())
94 && self.declaration_ids == other.declaration_ids
95 && self.candidate_count == other.candidate_count
96 && self.candidates == other.candidates
97 && self.classification == other.classification
98 && self.final_outcome == other.final_outcome
99 && self.definite_winner_declaration_id == other.definite_winner_declaration_id
100 }
101}
102
103impl Eq for CascadeRankedSetLossCensusRowV0 {}
104
105#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
106#[serde(rename_all = "camelCase")]
107pub struct CascadeRankedSetLossCaptureV0 {
108 pub schema_version: &'static str,
109 pub product: &'static str,
110 pub capture_state_recovery_count: usize,
111 pub measurement_invocation_count: usize,
112 pub ranked_set_outcome_count: usize,
113 pub recovered_definite_outcome_count: usize,
114 pub multi_candidate_inexact_ranked_set_count: usize,
115 pub rows: Vec<CascadeRankedSetLossCensusRowV0>,
116}
117
118pub fn capture_cascade_ranked_set_losses<R>(
124 operation: impl FnOnce() -> R,
125) -> Result<(R, CascadeRankedSetLossCaptureV0), &'static str> {
126 CAPTURE_ACTIVE
127 .compare_exchange(false, true, AtomicOrdering::AcqRel, AtomicOrdering::Acquire)
128 .map_err(|_| "cascade ranked-set loss capture is already active")?;
129 CAPTURE_STATE_RECOVERY_COUNT.store(0, AtomicOrdering::Release);
130 captured_rows().clear();
131 MEASUREMENT_INVOCATION_COUNT.store(0, AtomicOrdering::Release);
132 RANKED_SET_OUTCOME_COUNT.store(0, AtomicOrdering::Release);
133 RECOVERED_DEFINITE_OUTCOME_COUNT.store(0, AtomicOrdering::Release);
134 MULTI_CANDIDATE_INEXACT_RANKED_SET_COUNT.store(0, AtomicOrdering::Release);
135 let guard = CaptureGuard;
136 let result = operation();
137 let mut rows = std::mem::take(&mut *captured_rows());
138 rows.sort_by(|left, right| {
139 let left_property_key = left.property.to_property_name().canonical_key();
140 let right_property_key = right.property.to_property_name().canonical_key();
141 (
142 left.function,
143 left.invocation_site,
144 left.source_path.as_str(),
145 left_property_key,
146 left.declaration_ids.as_slice(),
147 )
148 .cmp(&(
149 right.function,
150 right.invocation_site,
151 right.source_path.as_str(),
152 right_property_key,
153 right.declaration_ids.as_slice(),
154 ))
155 });
156 drop(guard);
157 Ok((
158 result,
159 CascadeRankedSetLossCaptureV0 {
160 schema_version: "0",
161 product: "omena-cascade.ranked-set-loss-capture",
162 capture_state_recovery_count: CAPTURE_STATE_RECOVERY_COUNT
163 .load(AtomicOrdering::Acquire),
164 measurement_invocation_count: MEASUREMENT_INVOCATION_COUNT
165 .load(AtomicOrdering::Acquire),
166 ranked_set_outcome_count: RANKED_SET_OUTCOME_COUNT.load(AtomicOrdering::Acquire),
167 recovered_definite_outcome_count: RECOVERED_DEFINITE_OUTCOME_COUNT
168 .load(AtomicOrdering::Acquire),
169 multi_candidate_inexact_ranked_set_count: MULTI_CANDIDATE_INEXACT_RANKED_SET_COUNT
170 .load(AtomicOrdering::Acquire),
171 rows,
172 },
173 ))
174}
175
176pub fn classify_cascade_ranked_set_loss(
177 declarations: &[CascadeDeclaration],
178) -> CascadeRankedSetLossClassV0 {
179 match adjudicate_inexact_specificity_v0(declarations) {
180 InexactSpecificityAdjudicationV0::Recoverable { deciding_axis, .. } => {
181 CascadeRankedSetLossClassV0::RecoverableAxisDominant {
182 axis: match deciding_axis {
183 CascadeKeyAxisV0::Level => CascadeAxisPrefixV0::Level,
184 CascadeKeyAxisV0::LayerRank => CascadeAxisPrefixV0::LayerRank,
185 CascadeKeyAxisV0::ScopeProximity
186 | CascadeKeyAxisV0::SpecificityIds
187 | CascadeKeyAxisV0::SpecificityClasses
188 | CascadeKeyAxisV0::SpecificityElements
189 | CascadeKeyAxisV0::SourceOrder => unreachable!(
190 "an exact winner cannot cross inexact specificity to recover on a later axis"
191 ),
192 },
193 }
194 }
195 InexactSpecificityAdjudicationV0::AxisWinnerInexact => {
196 CascadeRankedSetLossClassV0::AxisWinnerInexact
197 }
198 InexactSpecificityAdjudicationV0::NoStrictAxisDominance => {
199 CascadeRankedSetLossClassV0::NoStrictAxisDominance
200 }
201 InexactSpecificityAdjudicationV0::SingleInexactCandidate => {
202 CascadeRankedSetLossClassV0::SingleInexactCandidate
203 }
204 }
205}
206
207pub(crate) fn observe_cascade_outcome(
208 function: CascadeRankedSetFunctionV0,
209 caller: &'static Location<'static>,
210 outcome: &CascadeOutcome,
211) {
212 if !CAPTURE_ACTIVE.load(AtomicOrdering::Acquire) {
213 return;
214 }
215 MEASUREMENT_INVOCATION_COUNT.fetch_add(1, AtomicOrdering::AcqRel);
216 let (declarations, final_outcome, definite_winner_declaration_id) = match outcome {
217 CascadeOutcome::RankedSet(declarations) => {
218 RANKED_SET_OUTCOME_COUNT.fetch_add(1, AtomicOrdering::AcqRel);
219 (
220 declarations.clone(),
221 CascadeRankedSetFinalOutcomeV0::RankedSet,
222 None,
223 )
224 }
225 CascadeOutcome::Definite {
226 winner,
227 also_considered,
228 ..
229 } => {
230 let mut declarations = Vec::with_capacity(also_considered.len().saturating_add(1));
231 declarations.push(winner.clone());
232 declarations.extend(also_considered.iter().cloned());
233 if !declarations.iter().any(|declaration| {
234 declaration.specificity_exactness == SpecificityExactnessV0::Inexact
235 }) {
236 return;
237 }
238 assert_definite_inexact_outcome_is_recoverable(&declarations);
239 RECOVERED_DEFINITE_OUTCOME_COUNT.fetch_add(1, AtomicOrdering::AcqRel);
240 (
241 declarations,
242 CascadeRankedSetFinalOutcomeV0::Definite,
243 Some(winner.id.clone()),
244 )
245 }
246 CascadeOutcome::Inherit | CascadeOutcome::Top => return,
247 };
248 if !declarations
249 .iter()
250 .any(|declaration| declaration.specificity_exactness == SpecificityExactnessV0::Inexact)
251 {
252 return;
253 }
254 if final_outcome == CascadeRankedSetFinalOutcomeV0::RankedSet && declarations.len() > 1 {
255 MULTI_CANDIDATE_INEXACT_RANKED_SET_COUNT.fetch_add(1, AtomicOrdering::AcqRel);
256 }
257 let row = CascadeRankedSetLossCensusRowV0 {
258 function,
259 invocation_site: invocation_site(caller.file()),
260 source_path: caller.file().to_string(),
261 property: declarations
262 .first()
263 .map(|declaration| declaration.property.clone())
264 .unwrap_or_else(|| AuthoredPropertyTextV0::new("")),
265 declaration_ids: declarations
266 .iter()
267 .map(|declaration| declaration.id.clone())
268 .collect(),
269 candidate_count: declarations.len(),
270 candidates: declarations
271 .iter()
272 .map(|declaration| CascadeRankedSetLossCandidateV0 {
273 declaration_id: declaration.id.clone(),
274 level: declaration.key.level,
275 layer_rank: declaration.key.layer_rank.get(),
276 scope_proximity: declaration.key.scope_proximity,
277 specificity_exactness: declaration.specificity_exactness,
278 })
279 .collect(),
280 classification: classify_cascade_ranked_set_loss(&declarations),
281 final_outcome,
282 definite_winner_declaration_id,
283 };
284 captured_rows().push(row);
285}
286
287fn assert_definite_inexact_outcome_is_recoverable(declarations: &[CascadeDeclaration]) {
288 assert!(
289 matches!(
290 classify_cascade_ranked_set_loss(declarations),
291 CascadeRankedSetLossClassV0::RecoverableAxisDominant { .. }
292 ),
293 "a definite inexact outcome must be justified by an earlier exact axis"
294 );
295}
296
297fn invocation_site(source_path: &str) -> &'static str {
298 if source_path.ends_with("omena-query/src/style/cascade_checker/runtime_state.rs") {
299 "queryRuntimeStateScenarioEvaluation"
300 } else if source_path.ends_with("omena-query/src/style/cascade_checker/confidence.rs") {
301 "queryCascadeMarginForEvaluation"
302 } else if source_path.ends_with("omena-query/src/style/cascade_checker/replica_ensemble.rs") {
303 "collectQueryReplicaEnsembleSiteOutcomes"
304 } else if source_path.ends_with("omena-cascade/src/computed_value.rs") {
305 "computeCascadeComputedValue"
306 } else if source_path.ends_with("omena-transform-passes/src/runtime/winner_equality.rs") {
307 "transformWinnerEqualityFromCascadeOutcome"
308 } else {
309 "unclassified"
310 }
311}
312
313fn captured_rows() -> std::sync::MutexGuard<'static, Vec<CascadeRankedSetLossCensusRowV0>> {
314 let (rows, recovered) = recover_captured_rows(CAPTURED_ROWS.lock(), &CAPTURED_ROWS);
315 if recovered {
316 CAPTURE_STATE_RECOVERY_COUNT.fetch_add(1, AtomicOrdering::AcqRel);
317 }
318 rows
319}
320
321fn recover_captured_rows<'a>(
322 lock: std::sync::LockResult<std::sync::MutexGuard<'a, Vec<CascadeRankedSetLossCensusRowV0>>>,
323 mutex: &'a Mutex<Vec<CascadeRankedSetLossCensusRowV0>>,
324) -> (
325 std::sync::MutexGuard<'a, Vec<CascadeRankedSetLossCensusRowV0>>,
326 bool,
327) {
328 match lock {
329 Ok(rows) => (rows, false),
330 Err(poisoned) => {
331 mutex.clear_poison();
332 let mut rows = poisoned.into_inner();
333 rows.clear();
334 (rows, true)
335 }
336 }
337}
338
339struct CaptureGuard;
340
341impl Drop for CaptureGuard {
342 fn drop(&mut self) {
343 CAPTURE_ACTIVE.store(false, AtomicOrdering::Release);
344 }
345}
346
347#[cfg(test)]
348mod tests {
349 use super::{
350 CascadeAxisPrefixV0, CascadeRankedSetFinalOutcomeV0, CascadeRankedSetLossCensusRowV0,
351 CascadeRankedSetLossClassV0, assert_definite_inexact_outcome_is_recoverable,
352 capture_cascade_ranked_set_losses, classify_cascade_ranked_set_loss, recover_captured_rows,
353 };
354 use crate::{
355 CascadeDeclaration, CascadeKey, CascadeLevel, CascadeOutcome, CascadeValue, LayerOrdinal,
356 OpenWorldTieEvidence, Specificity, SpecificityExactnessV0, cascade_property,
357 normalized_layer_rank,
358 };
359
360 fn declaration(
361 id: &str,
362 level: CascadeLevel,
363 layer_ordinal: i32,
364 scope_proximity: u32,
365 specificity: Specificity,
366 exactness: SpecificityExactnessV0,
367 ) -> CascadeDeclaration {
368 CascadeDeclaration {
369 id: id.to_string(),
370 property: omena_syntax::ident::AuthoredPropertyTextV0::new("color"),
371 property_key: omena_syntax::ident::PropertyNameV0::standard("color").canonical_key(),
372 value: CascadeValue::Literal(id.to_string()),
373 key: CascadeKey::new(
374 level,
375 normalized_layer_rank(false, LayerOrdinal::new(layer_ordinal)),
376 scope_proximity,
377 specificity,
378 0,
379 ),
380 open_world_tie_evidence: OpenWorldTieEvidence::NONE,
381 specificity_exactness: exactness,
382 }
383 }
384
385 #[test]
386 fn axis_winner_exactness_changes_the_recoverability_class() {
387 let lower = declaration(
388 "lower",
389 CascadeLevel::UserNormal,
390 0,
391 0,
392 Specificity::new(9, 9, 9),
393 SpecificityExactnessV0::Inexact,
394 );
395 let exact_winner = declaration(
396 "winner",
397 CascadeLevel::AuthorNormal,
398 0,
399 0,
400 Specificity::ZERO,
401 SpecificityExactnessV0::Exact,
402 );
403 assert_eq!(
404 classify_cascade_ranked_set_loss(&[lower.clone(), exact_winner.clone()]),
405 CascadeRankedSetLossClassV0::RecoverableAxisDominant {
406 axis: CascadeAxisPrefixV0::Level
407 }
408 );
409
410 let mut inexact_winner = exact_winner;
411 inexact_winner.specificity_exactness = SpecificityExactnessV0::Inexact;
412 assert_eq!(
413 classify_cascade_ranked_set_loss(&[lower, inexact_winner]),
414 CascadeRankedSetLossClassV0::AxisWinnerInexact
415 );
416 }
417
418 #[test]
419 fn specificity_only_winner_has_no_strict_axis_dominance() {
420 let weaker = declaration(
421 "weaker",
422 CascadeLevel::AuthorNormal,
423 0,
424 0,
425 Specificity::new(0, 1, 0),
426 SpecificityExactnessV0::Inexact,
427 );
428 let stronger = declaration(
429 "stronger",
430 CascadeLevel::AuthorNormal,
431 0,
432 0,
433 Specificity::new(1, 0, 0),
434 SpecificityExactnessV0::Exact,
435 );
436 assert_eq!(
437 classify_cascade_ranked_set_loss(&[weaker, stronger]),
438 CascadeRankedSetLossClassV0::NoStrictAxisDominance
439 );
440 }
441
442 #[test]
443 fn single_inexact_candidate_is_not_vacuously_recoverable() {
444 let candidate = declaration(
445 "only",
446 CascadeLevel::AuthorNormal,
447 0,
448 0,
449 Specificity::ZERO,
450 SpecificityExactnessV0::Inexact,
451 );
452 assert_eq!(
453 classify_cascade_ranked_set_loss(&[candidate]),
454 CascadeRankedSetLossClassV0::SingleInexactCandidate
455 );
456 }
457
458 #[test]
459 #[should_panic(expected = "requires an inexact declaration")]
460 fn exact_only_input_is_outside_the_loss_classifier_domain() {
461 let candidate = declaration(
462 "exact",
463 CascadeLevel::AuthorNormal,
464 0,
465 0,
466 Specificity::ZERO,
467 SpecificityExactnessV0::Exact,
468 );
469 let _ = classify_cascade_ranked_set_loss(&[candidate]);
470 }
471
472 #[test]
473 fn capture_retains_the_row_that_product_ranking_recovers_to_definite() {
474 let inexact_lower = declaration(
475 "inexact-lower",
476 CascadeLevel::UserNormal,
477 0,
478 0,
479 Specificity::new(9, 9, 9),
480 SpecificityExactnessV0::Inexact,
481 );
482 let exact_winner = declaration(
483 "exact-winner",
484 CascadeLevel::AuthorNormal,
485 0,
486 0,
487 Specificity::ZERO,
488 SpecificityExactnessV0::Exact,
489 );
490 let captured = capture_cascade_ranked_set_losses(|| {
491 cascade_property([inexact_lower, exact_winner], "color")
492 });
493 assert!(captured.is_ok(), "capture should be exclusive");
494 let Ok((outcome, capture)) = captured else {
495 return;
496 };
497
498 assert!(matches!(outcome, CascadeOutcome::Definite { .. }));
499 assert_eq!(capture.ranked_set_outcome_count, 0);
500 assert_eq!(capture.recovered_definite_outcome_count, 1);
501 assert_eq!(capture.multi_candidate_inexact_ranked_set_count, 0);
502 assert_eq!(capture.rows.len(), 1);
503 assert_eq!(
504 capture.rows[0].classification,
505 CascadeRankedSetLossClassV0::RecoverableAxisDominant {
506 axis: CascadeAxisPrefixV0::Level
507 }
508 );
509 assert_eq!(
510 capture.rows[0].final_outcome,
511 CascadeRankedSetFinalOutcomeV0::Definite
512 );
513 assert_eq!(
514 capture.rows[0].definite_winner_declaration_id.as_deref(),
515 Some("exact-winner")
516 );
517 }
518
519 #[test]
520 #[should_panic(expected = "a definite inexact outcome must be justified")]
521 fn release_build_rejects_an_unjustified_definite_outcome() {
522 let inexact = declaration(
523 "inexact",
524 CascadeLevel::AuthorNormal,
525 0,
526 0,
527 Specificity::new(0, 1, 0),
528 SpecificityExactnessV0::Inexact,
529 );
530 let exact = declaration(
531 "exact",
532 CascadeLevel::AuthorNormal,
533 0,
534 0,
535 Specificity::new(1, 0, 0),
536 SpecificityExactnessV0::Exact,
537 );
538 assert_definite_inexact_outcome_is_recoverable(&[exact, inexact]);
539 }
540
541 #[test]
542 fn poisoned_capture_storage_is_cleared_and_reported() {
543 let rows = std::sync::Arc::new(
544 std::sync::Mutex::<Vec<CascadeRankedSetLossCensusRowV0>>::new(Vec::new()),
545 );
546 let poisoned_rows = std::sync::Arc::clone(&rows);
547 let poison_result = std::thread::spawn(move || {
548 let _guard = match poisoned_rows.lock() {
549 Ok(guard) => guard,
550 Err(error) => error.into_inner(),
551 };
552 std::panic::resume_unwind(Box::new("poison capture storage"));
553 })
554 .join();
555 assert!(poison_result.is_err());
556
557 let (recovered_rows, recovered) = recover_captured_rows(rows.lock(), &rows);
558 assert!(recovered);
559 assert!(recovered_rows.is_empty());
560 assert!(!rows.is_poisoned());
561 }
562}