Skip to main content

omena_cascade/
origin.rs

1//! Cascade-origin inputs and their mapping onto the existing priority ladder.
2
3use serde::{Deserialize, Serialize};
4use std::collections::BTreeSet;
5use std::sync::OnceLock;
6
7use crate::{
8    CascadeLevel,
9    axis_order::{CascadeKeyAxisV0, cascade_key_axis_order_v0},
10};
11
12#[derive(
13    Debug, Clone, Copy, Default, PartialEq, Eq, PartialOrd, Ord, Hash, Deserialize, Serialize,
14)]
15#[serde(rename_all = "camelCase")]
16pub enum CascadeOriginV0 {
17    UserAgent,
18    User,
19    #[default]
20    Author,
21    Inline,
22}
23
24impl CascadeOriginV0 {
25    pub const fn is_author(&self) -> bool {
26        matches!(self, Self::Author)
27    }
28}
29
30#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
31#[serde(rename_all = "camelCase")]
32pub struct CascadeOriginDriverV0 {
33    pub origin: CascadeOriginV0,
34    pub important: bool,
35    pub level: CascadeLevel,
36}
37
38#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Deserialize, Serialize)]
39#[serde(rename_all = "camelCase")]
40pub enum CascadeWinnerAxisV0 {
41    CascadeLevel,
42    LayerRank,
43    ScopeProximity,
44    Specificity,
45    SourceOrder,
46}
47
48#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize)]
49#[serde(rename_all = "camelCase")]
50#[non_exhaustive]
51/// A named legacy source that can supply evidence for a cascade axis.
52pub enum CascadeAxisNamedDriverV0 {
53    /// Selector-context rank used by the design-token plane; this is not CSS `@scope`.
54    LegacySelectorContextFallback,
55}
56
57#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize)]
58#[serde(rename_all = "camelCase")]
59#[non_exhaustive]
60/// Why an axis is outside the currently modeled cascade fragment.
61pub enum CascadeAxisOutOfFragmentReasonV0 {
62    ShadowTreeEncapsulationContextUnmodeled,
63}
64
65#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize)]
66#[serde(tag = "status", rename_all = "camelCase")]
67#[non_exhaustive]
68/// Reach status for one specification cascade axis.
69pub enum CascadeAxisReachStatusV0 {
70    Modeled,
71    NotReachedByProduct {
72        #[serde(rename = "namedDriver")]
73        named_driver: CascadeAxisNamedDriverV0,
74    },
75    OutOfFragment {
76        reason: CascadeAxisOutOfFragmentReasonV0,
77    },
78}
79
80#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize)]
81#[serde(rename_all = "camelCase")]
82#[non_exhaustive]
83/// Reach disclosure derived from level/axis authorities, the producer census,
84/// and the declared encapsulation fragment boundary.
85pub struct CascadeAxisReachDisclosureV0 {
86    pub origin_and_importance: CascadeAxisReachStatusV0,
87    pub encapsulation_context: CascadeAxisReachStatusV0,
88    pub style_attribute: CascadeAxisReachStatusV0,
89    pub layers: CascadeAxisReachStatusV0,
90    pub specificity: CascadeAxisReachStatusV0,
91    pub scope_proximity: CascadeAxisReachStatusV0,
92    pub order_of_appearance: CascadeAxisReachStatusV0,
93}
94
95#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
96#[serde(rename_all = "camelCase")]
97struct CascadeDriverCensusV0 {
98    schema_version: String,
99    product: String,
100    levels: Vec<CascadeDriverLevelV0>,
101    winner_axes: Vec<CascadeDriverAxisV0>,
102    cascade_key_producers: Vec<CascadeKeyProducerV0>,
103    spec_axis_reach: CascadeAxisReachDisclosureV0,
104}
105
106#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
107#[serde(rename_all = "camelCase")]
108struct CascadeDriverLevelV0 {
109    level: String,
110    status: String,
111    driver_inputs: Vec<String>,
112    #[serde(default)]
113    follow_up: Option<String>,
114}
115
116#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
117#[serde(rename_all = "camelCase")]
118struct CascadeDriverAxisV0 {
119    axis: CascadeWinnerAxisV0,
120    status: CascadeDriverAxisStatusV0,
121    #[serde(default)]
122    named_driver: Option<CascadeAxisNamedDriverV0>,
123}
124
125#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)]
126#[serde(rename_all = "camelCase")]
127enum CascadeDriverAxisStatusV0 {
128    Driven,
129    AutomaticProductDriver,
130}
131
132impl CascadeDriverAxisStatusV0 {
133    const fn is_driven(self) -> bool {
134        matches!(self, Self::Driven | Self::AutomaticProductDriver)
135    }
136}
137
138#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
139#[serde(rename_all = "camelCase")]
140struct CascadeKeyProducerV0 {
141    path: String,
142    symbol: String,
143    occurrence: u32,
144    disposition: CascadeKeyProducerDispositionV0,
145    scope_proximity_source: CascadeScopeProximitySourceV0,
146}
147
148#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)]
149#[serde(rename_all = "camelCase")]
150enum CascadeKeyProducerDispositionV0 {
151    AutomaticProductDerived,
152    CallerSuppliedBoundary,
153    Conformance,
154    Generated,
155    Fixture,
156}
157
158#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)]
159#[serde(rename_all = "camelCase")]
160enum CascadeScopeProximitySourceV0 {
161    ConstantZero,
162    LegacySelectorContextFallback,
163    CallerSupplied,
164    GeneratedValue,
165}
166
167const CASCADE_DRIVER_CENSUS_JSON: &str = include_str!("../data/cascade-driver-census.json");
168static CASCADE_DRIVER_CENSUS: OnceLock<Result<CascadeDriverCensusV0, String>> = OnceLock::new();
169
170fn cascade_driver_census_v0() -> Option<&'static CascadeDriverCensusV0> {
171    CASCADE_DRIVER_CENSUS
172        .get_or_init(|| {
173            serde_json::from_str(CASCADE_DRIVER_CENSUS_JSON).map_err(|error| error.to_string())
174        })
175        .as_ref()
176        .ok()
177}
178
179pub fn cascade_driven_levels_v0() -> Vec<CascadeLevel> {
180    cascade_driver_census_v0()
181        .into_iter()
182        .flat_map(|census| census.levels.iter())
183        .filter(|entry| entry.status == "driven")
184        .filter_map(|entry| cascade_level_from_name_v0(entry.level.as_str()))
185        .collect()
186}
187
188pub fn cascade_driven_winner_axes_v0() -> Vec<CascadeWinnerAxisV0> {
189    cascade_driver_census_v0()
190        .into_iter()
191        .flat_map(|census| census.winner_axes.iter())
192        .filter(|entry| entry.status.is_driven())
193        .map(|entry| entry.axis)
194        .collect()
195}
196
197pub fn cascade_driver_census_is_consistent_v0() -> bool {
198    let Some(census) = cascade_driver_census_v0() else {
199        return false;
200    };
201    cascade_driver_census_payload_is_consistent_v0(census)
202}
203
204/// Summarizes modeled cascade-axis reach, failing closed if the embedded
205/// authority mirrors or producer census are inconsistent.
206pub fn summarize_cascade_axis_reach_v0() -> Option<CascadeAxisReachDisclosureV0> {
207    summarize_cascade_axis_reach_from_census_v0(cascade_driver_census_v0()?)
208}
209
210fn summarize_cascade_axis_reach_from_census_v0(
211    census: &CascadeDriverCensusV0,
212) -> Option<CascadeAxisReachDisclosureV0> {
213    if !cascade_driver_census_payload_is_consistent_v0(census) {
214        return None;
215    }
216    derive_cascade_axis_reach_v0(census)
217}
218
219fn cascade_driver_census_payload_is_consistent_v0(census: &CascadeDriverCensusV0) -> bool {
220    let catalog = cascade_level_catalog_v0();
221    let levels = census
222        .levels
223        .iter()
224        .filter_map(|entry| cascade_level_from_name_v0(entry.level.as_str()))
225        .collect::<Vec<_>>();
226    let driven = census
227        .levels
228        .iter()
229        .filter(|entry| entry.status == "driven")
230        .filter_map(|entry| cascade_level_from_name_v0(entry.level.as_str()))
231        .collect::<Vec<_>>();
232    let inline_important_driver_count = census
233        .levels
234        .iter()
235        .flat_map(|entry| entry.driver_inputs.iter())
236        .filter(|input| input.as_str() == "inlineStyleImportant")
237        .count();
238    let expected_driven = cascade_origin_driver_catalog_v0()
239        .into_iter()
240        .map(|driver| driver.level)
241        .collect::<BTreeSet<_>>();
242    let all_levels_have_evidence = census.levels.iter().all(|entry| {
243        (entry.status == "driven" && !entry.driver_inputs.is_empty() && entry.follow_up.is_none())
244            || (entry.status == "deferred"
245                && entry.driver_inputs.is_empty()
246                && entry
247                    .follow_up
248                    .as_deref()
249                    .is_some_and(|value| !value.is_empty()))
250    });
251    let expected_axes = cascade_winner_axis_catalog_from_authority_v0();
252    let producer_ids = census
253        .cascade_key_producers
254        .iter()
255        .map(|producer| {
256            (
257                producer.path.as_str(),
258                producer.symbol.as_str(),
259                producer.occurrence,
260            )
261        })
262        .collect::<BTreeSet<_>>();
263    let all_producers_have_consistent_evidence = census
264        .cascade_key_producers
265        .iter()
266        .all(cascade_key_producer_has_consistent_evidence_v0);
267    let all_axes_have_consistent_evidence =
268        census.winner_axes.iter().all(|entry| match entry.axis {
269            CascadeWinnerAxisV0::ScopeProximity => {
270                entry.status == CascadeDriverAxisStatusV0::AutomaticProductDriver
271                    && entry.named_driver
272                        == Some(CascadeAxisNamedDriverV0::LegacySelectorContextFallback)
273            }
274            _ => entry.status == CascadeDriverAxisStatusV0::Driven && entry.named_driver.is_none(),
275        });
276    let derived_reach = derive_cascade_axis_reach_v0(census);
277    census.schema_version == "0"
278        && census.product == "omena-cascade.driver-census"
279        && levels == catalog
280        && driven.into_iter().collect::<BTreeSet<_>>() == expected_driven
281        && inline_important_driver_count == 1
282        && all_levels_have_evidence
283        && census.winner_axes.len() == expected_axes.len()
284        && all_axes_have_consistent_evidence
285        && census
286            .winner_axes
287            .iter()
288            .map(|entry| entry.axis)
289            .eq(expected_axes)
290        && producer_ids.len() == census.cascade_key_producers.len()
291        && all_producers_have_consistent_evidence
292        && derived_reach.as_ref() == Some(&census.spec_axis_reach)
293}
294
295fn cascade_key_producer_has_consistent_evidence_v0(producer: &CascadeKeyProducerV0) -> bool {
296    if producer.path.is_empty() || producer.symbol.is_empty() || producer.occurrence == 0 {
297        return false;
298    }
299    matches!(
300        (producer.disposition, producer.scope_proximity_source),
301        (
302            CascadeKeyProducerDispositionV0::AutomaticProductDerived,
303            CascadeScopeProximitySourceV0::ConstantZero
304                | CascadeScopeProximitySourceV0::LegacySelectorContextFallback,
305        ) | (
306            CascadeKeyProducerDispositionV0::CallerSuppliedBoundary
307                | CascadeKeyProducerDispositionV0::Conformance,
308            CascadeScopeProximitySourceV0::CallerSupplied,
309        ) | (
310            CascadeKeyProducerDispositionV0::Generated,
311            CascadeScopeProximitySourceV0::GeneratedValue,
312        ) | (
313            CascadeKeyProducerDispositionV0::Fixture,
314            CascadeScopeProximitySourceV0::ConstantZero,
315        )
316    )
317}
318
319fn derive_cascade_axis_reach_v0(
320    census: &CascadeDriverCensusV0,
321) -> Option<CascadeAxisReachDisclosureV0> {
322    let driven_levels = census
323        .levels
324        .iter()
325        .filter(|entry| entry.status == "driven")
326        .filter_map(|entry| cascade_level_from_name_v0(entry.level.as_str()))
327        .collect::<BTreeSet<_>>();
328    let expected_driven_levels = cascade_origin_driver_catalog_v0()
329        .into_iter()
330        .map(|driver| driver.level)
331        .collect::<BTreeSet<_>>();
332    if driven_levels != expected_driven_levels
333        || !driven_levels.contains(&CascadeLevel::InlineNormal)
334        || !driven_levels.contains(&CascadeLevel::InlineImportant)
335    {
336        return None;
337    }
338
339    let axis_is_driven = |axis| {
340        census
341            .winner_axes
342            .iter()
343            .any(|entry| entry.axis == axis && entry.status.is_driven())
344    };
345    if !axis_is_driven(CascadeWinnerAxisV0::CascadeLevel)
346        || !axis_is_driven(CascadeWinnerAxisV0::LayerRank)
347        || !axis_is_driven(CascadeWinnerAxisV0::Specificity)
348        || !axis_is_driven(CascadeWinnerAxisV0::ScopeProximity)
349        || !axis_is_driven(CascadeWinnerAxisV0::SourceOrder)
350    {
351        return None;
352    }
353
354    let automatic_sources = census
355        .cascade_key_producers
356        .iter()
357        .filter(|producer| {
358            producer.disposition == CascadeKeyProducerDispositionV0::AutomaticProductDerived
359        })
360        .map(|producer| producer.scope_proximity_source)
361        .collect::<Vec<_>>();
362    if automatic_sources.is_empty()
363        || automatic_sources.iter().any(|source| {
364            !matches!(
365                source,
366                CascadeScopeProximitySourceV0::ConstantZero
367                    | CascadeScopeProximitySourceV0::LegacySelectorContextFallback
368            )
369        })
370        || !automatic_sources
371            .contains(&CascadeScopeProximitySourceV0::LegacySelectorContextFallback)
372    {
373        return None;
374    }
375
376    // Modeled limbs above are pinned by the level and key-axis authorities.
377    // Scope reach is derived from the syntactic producer census. Encapsulation
378    // remains an authored fragment boundary rather than a producer-scan claim.
379    Some(CascadeAxisReachDisclosureV0 {
380        origin_and_importance: CascadeAxisReachStatusV0::Modeled,
381        encapsulation_context: CascadeAxisReachStatusV0::OutOfFragment {
382            reason: CascadeAxisOutOfFragmentReasonV0::ShadowTreeEncapsulationContextUnmodeled,
383        },
384        style_attribute: CascadeAxisReachStatusV0::Modeled,
385        layers: CascadeAxisReachStatusV0::Modeled,
386        specificity: CascadeAxisReachStatusV0::Modeled,
387        scope_proximity: CascadeAxisReachStatusV0::NotReachedByProduct {
388            named_driver: CascadeAxisNamedDriverV0::LegacySelectorContextFallback,
389        },
390        order_of_appearance: CascadeAxisReachStatusV0::Modeled,
391    })
392}
393
394fn cascade_winner_axis_catalog_from_authority_v0() -> Vec<CascadeWinnerAxisV0> {
395    let mut axes = Vec::new();
396    for axis in cascade_key_axis_order_v0() {
397        let winner_axis = match axis {
398            CascadeKeyAxisV0::Level => CascadeWinnerAxisV0::CascadeLevel,
399            CascadeKeyAxisV0::LayerRank => CascadeWinnerAxisV0::LayerRank,
400            CascadeKeyAxisV0::ScopeProximity => CascadeWinnerAxisV0::ScopeProximity,
401            CascadeKeyAxisV0::SpecificityIds
402            | CascadeKeyAxisV0::SpecificityClasses
403            | CascadeKeyAxisV0::SpecificityElements => CascadeWinnerAxisV0::Specificity,
404            CascadeKeyAxisV0::SourceOrder => CascadeWinnerAxisV0::SourceOrder,
405        };
406        if axes.last() != Some(&winner_axis) {
407            axes.push(winner_axis);
408        }
409    }
410    axes
411}
412
413fn cascade_level_from_name_v0(name: &str) -> Option<CascadeLevel> {
414    cascade_level_catalog_v0()
415        .into_iter()
416        .find(|level| cascade_level_name_v0(*level) == name)
417}
418
419pub const fn cascade_level_for_origin(origin: CascadeOriginV0, important: bool) -> CascadeLevel {
420    match (origin, important) {
421        (CascadeOriginV0::UserAgent, false) => CascadeLevel::UserAgentNormal,
422        (CascadeOriginV0::User, false) => CascadeLevel::UserNormal,
423        (CascadeOriginV0::Author, false) => CascadeLevel::AuthorNormal,
424        (CascadeOriginV0::Inline, false) => CascadeLevel::InlineNormal,
425        (CascadeOriginV0::UserAgent, true) => CascadeLevel::UserAgentImportant,
426        (CascadeOriginV0::User, true) => CascadeLevel::UserImportant,
427        (CascadeOriginV0::Author, true) => CascadeLevel::AuthorImportant,
428        (CascadeOriginV0::Inline, true) => CascadeLevel::InlineImportant,
429    }
430}
431
432pub const fn cascade_level_catalog_v0() -> [CascadeLevel; 10] {
433    [
434        CascadeLevel::UserAgentNormal,
435        CascadeLevel::UserNormal,
436        CascadeLevel::AuthorNormal,
437        CascadeLevel::InlineNormal,
438        CascadeLevel::Animation,
439        CascadeLevel::AuthorImportant,
440        CascadeLevel::InlineImportant,
441        CascadeLevel::UserImportant,
442        CascadeLevel::UserAgentImportant,
443        CascadeLevel::Transition,
444    ]
445}
446
447pub const fn cascade_level_name_v0(level: CascadeLevel) -> &'static str {
448    match level {
449        CascadeLevel::UserAgentNormal => "userAgentNormal",
450        CascadeLevel::UserNormal => "userNormal",
451        CascadeLevel::AuthorNormal => "authorNormal",
452        CascadeLevel::InlineNormal => "inlineNormal",
453        CascadeLevel::Animation => "animation",
454        CascadeLevel::InlineImportant => "inlineImportant",
455        CascadeLevel::AuthorImportant => "authorImportant",
456        CascadeLevel::UserImportant => "userImportant",
457        CascadeLevel::UserAgentImportant => "userAgentImportant",
458        CascadeLevel::Transition => "transition",
459    }
460}
461
462pub const fn cascade_origin_driver_catalog_v0() -> [CascadeOriginDriverV0; 8] {
463    [
464        origin_driver(CascadeOriginV0::UserAgent, false),
465        origin_driver(CascadeOriginV0::User, false),
466        origin_driver(CascadeOriginV0::Author, false),
467        origin_driver(CascadeOriginV0::Inline, false),
468        origin_driver(CascadeOriginV0::Author, true),
469        origin_driver(CascadeOriginV0::Inline, true),
470        origin_driver(CascadeOriginV0::User, true),
471        origin_driver(CascadeOriginV0::UserAgent, true),
472    ]
473}
474
475const fn origin_driver(origin: CascadeOriginV0, important: bool) -> CascadeOriginDriverV0 {
476    CascadeOriginDriverV0 {
477        origin,
478        important,
479        level: cascade_level_for_origin(origin, important),
480    }
481}
482
483#[cfg(test)]
484mod tests {
485    use super::*;
486
487    #[test]
488    fn embedded_driver_census_derives_the_typed_axis_reach_disclosure() {
489        assert!(cascade_driver_census_is_consistent_v0());
490        assert_eq!(
491            summarize_cascade_axis_reach_v0(),
492            Some(CascadeAxisReachDisclosureV0 {
493                origin_and_importance: CascadeAxisReachStatusV0::Modeled,
494                encapsulation_context: CascadeAxisReachStatusV0::OutOfFragment {
495                    reason:
496                        CascadeAxisOutOfFragmentReasonV0::ShadowTreeEncapsulationContextUnmodeled,
497                },
498                style_attribute: CascadeAxisReachStatusV0::Modeled,
499                layers: CascadeAxisReachStatusV0::Modeled,
500                specificity: CascadeAxisReachStatusV0::Modeled,
501                scope_proximity: CascadeAxisReachStatusV0::NotReachedByProduct {
502                    named_driver: CascadeAxisNamedDriverV0::LegacySelectorContextFallback,
503                },
504                order_of_appearance: CascadeAxisReachStatusV0::Modeled,
505            })
506        );
507    }
508
509    #[test]
510    fn caller_supplied_proximity_surfaces_are_excluded_from_the_automatic_product_driver()
511    -> Result<(), &'static str> {
512        let census = cascade_driver_census_v0().ok_or("embedded census must parse")?;
513        let caller_supplied = census
514            .cascade_key_producers
515            .iter()
516            .filter(|producer| {
517                producer.disposition == CascadeKeyProducerDispositionV0::CallerSuppliedBoundary
518            })
519            .collect::<Vec<_>>();
520        assert_eq!(caller_supplied.len(), 3);
521        assert!(caller_supplied.iter().all(|producer| {
522            producer.scope_proximity_source == CascadeScopeProximitySourceV0::CallerSupplied
523        }));
524        assert!(caller_supplied.iter().any(|producer| {
525            producer.path == "rust/crates/omena-bundler/src/lib.rs"
526                && producer.symbol == "LinkedStylesheetRuleV0::cascade_key_with_global_source_order"
527        }));
528        assert!(caller_supplied.iter().any(|producer| {
529            producer.path == "rust/crates/omena-cascade-proof/src/proof_kernel.rs"
530                && producer.symbol == "cascade_key_from_certificate_v0"
531        }));
532        assert!(caller_supplied.iter().any(|producer| {
533            producer.path == "rust/crates/omena-transform-passes/src/runtime/winner_equality.rs"
534                && producer.symbol == "winner_for_pair"
535        }));
536
537        let automatic = census
538            .cascade_key_producers
539            .iter()
540            .filter(|producer| {
541                producer.disposition == CascadeKeyProducerDispositionV0::AutomaticProductDerived
542            })
543            .collect::<Vec<_>>();
544        assert!(automatic.iter().all(|producer| {
545            matches!(
546                producer.scope_proximity_source,
547                CascadeScopeProximitySourceV0::ConstantZero
548                    | CascadeScopeProximitySourceV0::LegacySelectorContextFallback
549            )
550        }));
551        assert!(automatic.iter().any(|producer| {
552            producer.path == "rust/crates/omena-semantic/src/design_tokens.rs"
553                && producer.scope_proximity_source
554                    == CascadeScopeProximitySourceV0::LegacySelectorContextFallback
555        }));
556        Ok(())
557    }
558
559    #[test]
560    fn inconsistent_axis_reach_census_fails_closed() -> Result<(), &'static str> {
561        let mut census = cascade_driver_census_v0()
562            .ok_or("embedded census must parse")?
563            .clone();
564        census.spec_axis_reach.scope_proximity = CascadeAxisReachStatusV0::Modeled;
565
566        assert_eq!(summarize_cascade_axis_reach_from_census_v0(&census), None);
567        Ok(())
568    }
569}