Skip to main content

omena_cascade/
ranked_set_loss_census.rs

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    /// Retained for 0.x wire compatibility. The current specification order
38    /// places specificity before scope proximity, so the pre-specificity
39    /// classifier cannot emit this variant.
40    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
87/// Captures inexactness-bail `RankedSet` outcomes produced while `operation` runs.
88///
89/// Capture is process-wide so worker threads participate in the same bounded
90/// measurement. Nested or concurrent captures are rejected instead of merging
91/// unrelated populations.
92pub 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}