Skip to main content

omena_cascade/
ranked_set_loss_census.rs

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