Skip to main content

omena_transform_cst/
observation_equivalence.rs

1//! Observation-indexed equivalence for transform outputs.
2//!
3//! This relation checks projections supplied by an observation carrier. It
4//! cannot establish that the selected profile models every browser-observable
5//! effect; that remains a corpus and modelling obligation.
6
7use std::collections::BTreeSet;
8use std::fmt;
9
10use serde::Serialize;
11
12use super::{
13    ObservationKindV0, PassObservationSurfaceV0, TransformPassDescriptorV0,
14    TransformPassObservationRecordV0, default_transform_pass_descriptors,
15    default_transform_pass_observation_records,
16};
17
18pub const OBSERVATION_KIND_COUNT_V0: usize = 16;
19
20pub const fn all_observation_kinds_v0() -> [ObservationKindV0; OBSERVATION_KIND_COUNT_V0] {
21    [
22        ObservationKindV0::SelectorMatching,
23        ObservationKindV0::CascadeWinner,
24        ObservationKindV0::CascadeWinnerEquality,
25        ObservationKindV0::ExportedClassNames,
26        ObservationKindV0::CustomPropertyComputedValue,
27        ObservationKindV0::KeyframesReachability,
28        ObservationKindV0::SourceMapTrace,
29        ObservationKindV0::LayerRank,
30        ObservationKindV0::Specificity,
31        ObservationKindV0::Inheritance,
32        ObservationKindV0::DeclarationOrder,
33        ObservationKindV0::TargetPredicate,
34        ObservationKindV0::ModuleResolution,
35        ObservationKindV0::ImportContext,
36        ObservationKindV0::ValueGraphReachability,
37        ObservationKindV0::SemanticMarker,
38    ]
39}
40
41#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize)]
42#[serde(rename_all = "camelCase")]
43#[non_exhaustive]
44pub enum TransformObserverV0 {
45    RawBytes,
46    Contract(ObservationKindV0),
47}
48
49#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
50#[serde(rename_all = "camelCase")]
51#[non_exhaustive]
52pub enum TransformObserverClassV0 {
53    RawRepresentation,
54    OutputProjection,
55    ReadOnlyInput,
56}
57
58pub const fn observation_kind_observer_class_v0(
59    kind: ObservationKindV0,
60) -> TransformObserverClassV0 {
61    match kind {
62        ObservationKindV0::TargetPredicate => TransformObserverClassV0::ReadOnlyInput,
63        ObservationKindV0::SelectorMatching
64        | ObservationKindV0::CascadeWinner
65        | ObservationKindV0::CascadeWinnerEquality
66        | ObservationKindV0::ExportedClassNames
67        | ObservationKindV0::CustomPropertyComputedValue
68        | ObservationKindV0::KeyframesReachability
69        | ObservationKindV0::SourceMapTrace
70        | ObservationKindV0::LayerRank
71        | ObservationKindV0::Specificity
72        | ObservationKindV0::Inheritance
73        | ObservationKindV0::DeclarationOrder
74        | ObservationKindV0::ModuleResolution
75        | ObservationKindV0::ImportContext
76        | ObservationKindV0::ValueGraphReachability
77        | ObservationKindV0::SemanticMarker => TransformObserverClassV0::OutputProjection,
78    }
79}
80
81impl TransformObserverV0 {
82    pub const fn observer_class(self) -> TransformObserverClassV0 {
83        match self {
84            Self::RawBytes => TransformObserverClassV0::RawRepresentation,
85            Self::Contract(kind) => observation_kind_observer_class_v0(kind),
86        }
87    }
88}
89
90#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
91#[serde(rename_all = "camelCase")]
92#[non_exhaustive]
93pub struct TransformObservationProjectionV0 {
94    kind: ObservationKindV0,
95    value: String,
96}
97
98impl TransformObservationProjectionV0 {
99    pub fn new(kind: ObservationKindV0, value: impl Into<String>) -> Self {
100        Self {
101            kind,
102            value: value.into(),
103        }
104    }
105
106    pub const fn kind(&self) -> ObservationKindV0 {
107        self.kind
108    }
109
110    pub fn value(&self) -> &str {
111        self.value.as_str()
112    }
113}
114
115#[derive(Debug, Clone, PartialEq, Eq)]
116#[non_exhaustive]
117pub enum TransformObservationOutputErrorV0 {
118    DuplicateProjection { kind: ObservationKindV0 },
119    MissingProjection { kind: ObservationKindV0 },
120    ProjectionCount { expected: usize, actual: usize },
121}
122
123impl fmt::Display for TransformObservationOutputErrorV0 {
124    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
125        match self {
126            Self::DuplicateProjection { kind } => {
127                write!(formatter, "duplicate observation projection: {kind:?}")
128            }
129            Self::MissingProjection { kind } => {
130                write!(formatter, "missing observation projection: {kind:?}")
131            }
132            Self::ProjectionCount { expected, actual } => write!(
133                formatter,
134                "observation projection count is {actual}; expected {expected}"
135            ),
136        }
137    }
138}
139
140impl std::error::Error for TransformObservationOutputErrorV0 {}
141
142#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
143#[serde(rename_all = "camelCase")]
144#[non_exhaustive]
145pub struct TransformObservationOutputV0 {
146    raw_bytes: Vec<u8>,
147    projections: Vec<TransformObservationProjectionV0>,
148}
149
150impl TransformObservationOutputV0 {
151    pub fn new(
152        raw_bytes: impl Into<Vec<u8>>,
153        mut projections: Vec<TransformObservationProjectionV0>,
154    ) -> Result<Self, TransformObservationOutputErrorV0> {
155        let mut observed = BTreeSet::new();
156        for projection in &projections {
157            if !observed.insert(projection.kind) {
158                return Err(TransformObservationOutputErrorV0::DuplicateProjection {
159                    kind: projection.kind,
160                });
161            }
162        }
163        for kind in all_observation_kinds_v0() {
164            if !observed.contains(&kind) {
165                return Err(TransformObservationOutputErrorV0::MissingProjection { kind });
166            }
167        }
168        if projections.len() != OBSERVATION_KIND_COUNT_V0 {
169            return Err(TransformObservationOutputErrorV0::ProjectionCount {
170                expected: OBSERVATION_KIND_COUNT_V0,
171                actual: projections.len(),
172            });
173        }
174        projections.sort_by_key(TransformObservationProjectionV0::kind);
175        Ok(Self {
176            raw_bytes: raw_bytes.into(),
177            projections,
178        })
179    }
180
181    pub fn raw_bytes(&self) -> &[u8] {
182        self.raw_bytes.as_slice()
183    }
184
185    pub fn projections(&self) -> &[TransformObservationProjectionV0] {
186        self.projections.as_slice()
187    }
188}
189
190#[derive(Debug, Clone, PartialEq, Eq)]
191#[non_exhaustive]
192pub enum TransformObservationProfileErrorV0 {
193    EmptyProfileId,
194    DuplicateObserver { observer: TransformObserverV0 },
195}
196
197impl fmt::Display for TransformObservationProfileErrorV0 {
198    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
199        match self {
200            Self::EmptyProfileId => formatter.write_str("observation profile id is empty"),
201            Self::DuplicateObserver { observer } => {
202                write!(
203                    formatter,
204                    "duplicate observation profile observer: {observer:?}"
205                )
206            }
207        }
208    }
209}
210
211impl std::error::Error for TransformObservationProfileErrorV0 {}
212
213#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
214#[serde(rename_all = "camelCase")]
215#[non_exhaustive]
216pub struct TransformObservationProfileV0 {
217    profile_id: String,
218    observers: Vec<TransformObserverV0>,
219}
220
221impl TransformObservationProfileV0 {
222    pub fn new(
223        profile_id: impl Into<String>,
224        observers: Vec<TransformObserverV0>,
225    ) -> Result<Self, TransformObservationProfileErrorV0> {
226        let profile_id = profile_id.into();
227        if profile_id.is_empty() {
228            return Err(TransformObservationProfileErrorV0::EmptyProfileId);
229        }
230        let mut observed = BTreeSet::new();
231        for observer in &observers {
232            if !observed.insert(*observer) {
233                return Err(TransformObservationProfileErrorV0::DuplicateObserver {
234                    observer: *observer,
235                });
236            }
237        }
238        Ok(Self {
239            profile_id,
240            observers,
241        })
242    }
243
244    pub fn profile_id(&self) -> &str {
245        self.profile_id.as_str()
246    }
247
248    pub fn observers(&self) -> &[TransformObserverV0] {
249        self.observers.as_slice()
250    }
251}
252
253#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
254#[serde(rename_all = "camelCase", tag = "kind", content = "value")]
255#[non_exhaustive]
256pub enum ObservationProjectionValueV0 {
257    RawBytes(Vec<u8>),
258    Contract(String),
259}
260
261pub fn project_transform_observation_v0(
262    output: &TransformObservationOutputV0,
263    observer: TransformObserverV0,
264) -> ObservationProjectionValueV0 {
265    match observer {
266        TransformObserverV0::RawBytes => {
267            ObservationProjectionValueV0::RawBytes(output.raw_bytes.clone())
268        }
269        TransformObserverV0::Contract(kind) => {
270            let Some(projection) = output
271                .projections
272                .iter()
273                .find(|projection| projection.kind == kind)
274            else {
275                unreachable!("TransformObservationOutputV0 validates all 16 projections")
276            };
277            ObservationProjectionValueV0::Contract(projection.value.clone())
278        }
279    }
280}
281
282#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
283#[serde(rename_all = "camelCase")]
284#[non_exhaustive]
285pub struct TransformObservationEquivalenceV0 {
286    pub schema_version: &'static str,
287    pub product: &'static str,
288    pub profile_id: String,
289    pub compared_observer_count: usize,
290    pub differing_observers: Vec<TransformObserverV0>,
291    pub equivalent: bool,
292}
293
294pub fn compare_transform_observation_outputs_v0(
295    profile: &TransformObservationProfileV0,
296    left: &TransformObservationOutputV0,
297    right: &TransformObservationOutputV0,
298) -> TransformObservationEquivalenceV0 {
299    let differing_observers = profile
300        .observers
301        .iter()
302        .copied()
303        .filter(|observer| {
304            project_transform_observation_v0(left, *observer)
305                != project_transform_observation_v0(right, *observer)
306        })
307        .collect::<Vec<_>>();
308    TransformObservationEquivalenceV0 {
309        schema_version: "0",
310        product: "omena-transform-cst.observation-indexed-equivalence",
311        profile_id: profile.profile_id.clone(),
312        compared_observer_count: profile.observers.len(),
313        equivalent: differing_observers.is_empty(),
314        differing_observers,
315    }
316}
317
318/// Compare one authority-produced observation projection.
319///
320/// The caller supplies values computed by the owning parser or semantic
321/// authority; this function owns the observation-indexed equality relation.
322pub fn compare_transform_observation_projection_values_v0(
323    profile_id: impl Into<String>,
324    kind: ObservationKindV0,
325    left_projection: &str,
326    right_projection: &str,
327) -> TransformObservationEquivalenceV0 {
328    let observer = TransformObserverV0::Contract(kind);
329    let equivalent = left_projection == right_projection;
330    TransformObservationEquivalenceV0 {
331        schema_version: "0",
332        product: "omena-transform-cst.observation-indexed-equivalence",
333        profile_id: profile_id.into(),
334        compared_observer_count: 1,
335        differing_observers: if equivalent {
336            Vec::new()
337        } else {
338            vec![observer]
339        },
340        equivalent,
341    }
342}
343
344/// Compare exact output bytes through the same observation relation used by
345/// committed transform-independence data.
346pub fn compare_raw_transform_observation_bytes_v0(
347    profile_id: impl Into<String>,
348    left: &[u8],
349    right: &[u8],
350) -> TransformObservationEquivalenceV0 {
351    let equivalent = left == right;
352    TransformObservationEquivalenceV0 {
353        schema_version: "0",
354        product: "omena-transform-cst.observation-indexed-equivalence",
355        profile_id: profile_id.into(),
356        compared_observer_count: 1,
357        differing_observers: if equivalent {
358            Vec::new()
359        } else {
360            vec![TransformObserverV0::RawBytes]
361        },
362        equivalent,
363    }
364}
365
366pub fn observation_indexed_equivalent_v0(
367    profile: &TransformObservationProfileV0,
368    left: &TransformObservationOutputV0,
369    right: &TransformObservationOutputV0,
370) -> bool {
371    compare_transform_observation_outputs_v0(profile, left, right).equivalent
372}
373
374#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
375#[serde(rename_all = "camelCase")]
376#[non_exhaustive]
377pub struct TransformObservationMatrixV0 {
378    descriptors: Vec<TransformPassDescriptorV0>,
379    observation_records: Vec<TransformPassObservationRecordV0>,
380}
381
382impl TransformObservationMatrixV0 {
383    pub fn descriptors(&self) -> &[TransformPassDescriptorV0] {
384        self.descriptors.as_slice()
385    }
386
387    pub fn observation_records(&self) -> &[TransformPassObservationRecordV0] {
388        self.observation_records.as_slice()
389    }
390
391    pub fn unknown_gap_count(&self) -> usize {
392        self.observation_records
393            .iter()
394            .filter(|record| matches!(record.surface, PassObservationSurfaceV0::UnknownGap { .. }))
395            .count()
396    }
397}
398
399/// Extract the observation matrix through its public producers.
400///
401/// Keeping this call boundary explicit prevents the proof relation from
402/// acquiring a second parser for the descriptor source table.
403pub fn default_transform_observation_matrix_v0() -> TransformObservationMatrixV0 {
404    TransformObservationMatrixV0 {
405        descriptors: default_transform_pass_descriptors(),
406        observation_records: default_transform_pass_observation_records(),
407    }
408}
409
410#[cfg(test)]
411mod tests {
412    use serde::Deserialize;
413
414    use super::*;
415
416    #[derive(Debug, Deserialize)]
417    #[serde(rename_all = "camelCase")]
418    struct ObservationTruthTableRowV0 {
419        case_id: String,
420        pass_id: String,
421        observation_kind: String,
422        left_projection: String,
423        right_projection: String,
424        expected_equivalent: bool,
425    }
426
427    fn parse_observation_kind(value: &str) -> Option<ObservationKindV0> {
428        all_observation_kinds_v0().into_iter().find(|kind| {
429            serde_json::to_value(kind)
430                .ok()
431                .and_then(|value| value.as_str().map(str::to_string))
432                .as_deref()
433                == Some(value)
434        })
435    }
436
437    fn output_with_override(
438        raw: &str,
439        kind: ObservationKindV0,
440        value: &str,
441    ) -> Result<TransformObservationOutputV0, TransformObservationOutputErrorV0> {
442        let projections = all_observation_kinds_v0()
443            .into_iter()
444            .map(|candidate| {
445                let projection = if candidate == kind {
446                    value.to_string()
447                } else {
448                    format!("{candidate:?}:stable")
449                };
450                TransformObservationProjectionV0::new(candidate, projection)
451            })
452            .collect();
453        TransformObservationOutputV0::new(raw.as_bytes(), projections)
454    }
455
456    #[test]
457    fn public_matrix_extraction_is_complete_without_table_parsing() {
458        let matrix = default_transform_observation_matrix_v0();
459        assert_eq!(matrix.descriptors().len(), 44);
460        assert_eq!(matrix.observation_records().len(), 44);
461        assert_eq!(matrix.unknown_gap_count(), 0);
462        assert_eq!(all_observation_kinds_v0().len(), OBSERVATION_KIND_COUNT_V0);
463    }
464
465    #[test]
466    fn committed_truth_table_covers_every_observation_kind()
467    -> Result<(), Box<dyn std::error::Error>> {
468        let rows = serde_json::from_str::<Vec<ObservationTruthTableRowV0>>(include_str!(
469            "../data/observation-equivalence-truth-table-v0.json"
470        ))?;
471        let matrix = default_transform_observation_matrix_v0();
472        let mut covered = BTreeSet::new();
473        for row in rows {
474            let kind = parse_observation_kind(row.observation_kind.as_str())
475                .ok_or_else(|| format!("unknown observation kind in {}", row.case_id))?;
476            let record = matrix
477                .observation_records()
478                .iter()
479                .find(|record| record.id == row.pass_id)
480                .ok_or_else(|| format!("unknown pass in {}", row.case_id))?;
481            let PassObservationSurfaceV0::Declared(contract) = &record.surface else {
482                return Err(format!("unknown observation surface in {}", row.case_id).into());
483            };
484            assert!(
485                contract.observes.contains(&kind) || contract.preserves.contains(&kind),
486                "{} does not declare {:?}",
487                row.pass_id,
488                kind
489            );
490            let profile = TransformObservationProfileV0::new(
491                format!("truth-table:{}", row.case_id),
492                vec![TransformObserverV0::Contract(kind)],
493            )?;
494            let left = output_with_override("left", kind, row.left_projection.as_str())?;
495            let right = output_with_override("right", kind, row.right_projection.as_str())?;
496            assert_eq!(
497                observation_indexed_equivalent_v0(&profile, &left, &right),
498                row.expected_equivalent,
499                "{}",
500                row.case_id
501            );
502            covered.insert(kind);
503        }
504        assert_eq!(covered, all_observation_kinds_v0().into_iter().collect());
505        Ok(())
506    }
507
508    #[test]
509    fn declared_projection_relation_distinguishes_semantic_and_raw_values()
510    -> Result<(), Box<dyn std::error::Error>> {
511        let left = output_with_override(
512            ".a { color: red; }",
513            ObservationKindV0::SelectorMatching,
514            "selector:.a;winner:color=red",
515        )?;
516        let right = output_with_override(
517            ".a{color:red}",
518            ObservationKindV0::SelectorMatching,
519            "selector:.a;winner:color=red",
520        )?;
521        let parsed = TransformObservationProfileV0::new(
522            "parsed-semantics",
523            vec![TransformObserverV0::Contract(
524                ObservationKindV0::SelectorMatching,
525            )],
526        )?;
527        let raw =
528            TransformObservationProfileV0::new("raw-bytes", vec![TransformObserverV0::RawBytes])?;
529
530        let parsed_equivalent = observation_indexed_equivalent_v0(&parsed, &left, &right);
531        let raw_equivalent = observation_indexed_equivalent_v0(&raw, &left, &right);
532        println!(
533            "declaredOnly=true parsedProfile={} rawProfile={}",
534            parsed_equivalent, raw_equivalent
535        );
536        assert!(parsed_equivalent);
537        assert!(!raw_equivalent);
538        Ok(())
539    }
540
541    #[test]
542    fn declared_projection_relation_scopes_a_cascade_winner_change()
543    -> Result<(), Box<dyn std::error::Error>> {
544        let matrix = default_transform_observation_matrix_v0();
545        let whitespace = matrix
546            .observation_records()
547            .iter()
548            .find(|record| record.id == "whitespace-strip")
549            .ok_or("whitespace-strip observation record missing")?;
550        let PassObservationSurfaceV0::Declared(contract) = &whitespace.surface else {
551            return Err("whitespace-strip observation surface is unknown".into());
552        };
553        assert!(
554            contract
555                .preserves
556                .contains(&ObservationKindV0::CascadeWinner)
557        );
558        assert!(
559            contract
560                .preserves
561                .contains(&ObservationKindV0::SourceMapTrace)
562        );
563
564        let left = output_with_override(
565            ".a{color:red}",
566            ObservationKindV0::CascadeWinner,
567            "winner:red",
568        )?;
569        let right = output_with_override(
570            ".a{color:blue}",
571            ObservationKindV0::CascadeWinner,
572            "winner:blue",
573        )?;
574        let cascade = TransformObservationProfileV0::new(
575            "cascade-winner",
576            vec![TransformObserverV0::Contract(
577                ObservationKindV0::CascadeWinner,
578            )],
579        )?;
580        let disjoint = TransformObservationProfileV0::new(
581            "source-map-trace",
582            vec![TransformObserverV0::Contract(
583                ObservationKindV0::SourceMapTrace,
584            )],
585        )?;
586
587        let cascade_equivalent = observation_indexed_equivalent_v0(&cascade, &left, &right);
588        let disjoint_equivalent = observation_indexed_equivalent_v0(&disjoint, &left, &right);
589        println!(
590            "declaredOnly=true cascadeProfile={} disjointSourceMapProfile={}",
591            cascade_equivalent, disjoint_equivalent
592        );
593        assert!(!cascade_equivalent);
594        assert!(disjoint_equivalent);
595        Ok(())
596    }
597
598    #[test]
599    fn target_predicate_is_explicitly_read_only_and_matches_the_matrix_asymmetry() {
600        let matrix = default_transform_observation_matrix_v0();
601        let mut observed = 0usize;
602        let mut preserved = 0usize;
603        for record in matrix.observation_records() {
604            let PassObservationSurfaceV0::Declared(contract) = &record.surface else {
605                continue;
606            };
607            observed += usize::from(
608                contract
609                    .observes
610                    .contains(&ObservationKindV0::TargetPredicate),
611            );
612            preserved += usize::from(
613                contract
614                    .preserves
615                    .contains(&ObservationKindV0::TargetPredicate),
616            );
617        }
618        assert_eq!(observed, 13);
619        assert_eq!(preserved, 0);
620        assert_eq!(
621            observation_kind_observer_class_v0(ObservationKindV0::TargetPredicate),
622            TransformObserverClassV0::ReadOnlyInput
623        );
624    }
625}