1use 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
318pub 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
344pub 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
399pub 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}