Skip to main content

type_bridge_schema_compat/
shadow.rs

1//! Honest, comparison-only shadowing of the frozen V1 schema parser.
2
3use std::collections::{BTreeMap, BTreeSet};
4use std::error::Error;
5use std::fmt;
6
7use type_bridge_contract::fingerprint::SemanticProfileId;
8use type_bridge_contract::id::TypeKind;
9use type_bridge_contract::schema::{
10    AnnotationKindId, DeclaredIdentityFingerprint, DocumentId, FunctionReturnMode, InterfaceKind,
11    SchemaAnnotationValue, SchemaDiagnostics, SchemaFact, SemanticProfile,
12    SemanticSchemaFingerprint, TypeReference,
13};
14use type_bridge_contract::value::{CanonicalValue, Cardinality as V2Cardinality, ValueTypeTag};
15use type_bridge_core_lib::_parser as parser;
16use type_bridge_core_lib::_schema::{
17    Cardinality as V1Cardinality, FunctionType as V1Function, OwnedAttribute, PlayedRole, RoleSpec,
18    SchemaError, StructType as V1Struct, TypeSchema,
19};
20use type_bridge_schema::{ResolvedSchema, resolve};
21
22use crate::typeql_to_declared;
23
24const SHADOW_DOCUMENT: &str = "v1-shadow.typeql";
25
26/// One semantic dimension understood by the comparison report.
27#[derive(Clone, Copy, Debug, Eq, Ord, PartialEq, PartialOrd)]
28pub enum ShadowDimension {
29    /// Presence of a schema type label.
30    TypeExistence,
31    /// Entity, relation, or attribute kind.
32    TypeKind,
33    /// Nearest declared parent label.
34    DirectParent,
35    /// Effective type abstractness.
36    TypeAbstract,
37    /// Effective attribute independence.
38    AttributeIndependent,
39    /// Effective attribute value domain.
40    ValueType,
41    /// Effective ownership interfaces and their annotations.
42    EffectiveOwns,
43    /// Effective relation-role interfaces and their annotations.
44    EffectiveRelates,
45    /// Effective role-playing interfaces and their annotations.
46    EffectivePlays,
47    /// Documentation and metadata annotations.
48    DocumentationAndMetadata,
49    /// Ordered function signatures.
50    FunctionSignatures,
51    /// Function bodies and function annotations.
52    FunctionBodiesAndAnnotations,
53    /// Ordered struct fields.
54    StructFields,
55    /// Source text, comments, and source spans.
56    SourceCommentsAndSpans,
57    /// The declared distinction between omitted and explicit defaults.
58    OmittedVersusExplicitIdentity,
59    /// Independent annotation fact identity and removal semantics.
60    IndependentAnnotationIdentityAndRemoval,
61    /// Annotations on `sub` facts.
62    SubAnnotations,
63    /// Extension and capability negotiation semantics.
64    ExtensionsAndCapabilities,
65    /// Resolver origin paths, descriptors, and dependency SCCs.
66    ResolverGraphsAndOrigins,
67    /// Cardinalities representable by V2 `u64` but not V1 `u32`.
68    CardinalityOutsideV1U32,
69}
70
71/// Coverage recorded alongside every compared result.
72#[derive(Clone, Debug, Eq, PartialEq)]
73pub struct ShadowCoverage {
74    compared: BTreeSet<ShadowDimension>,
75    unimplemented: BTreeSet<ShadowDimension>,
76    not_representable: BTreeSet<ShadowDimension>,
77    blind_spots: BTreeSet<ShadowDimension>,
78}
79
80/// Why one shadow dimension is or is not part of the verdict.
81#[derive(Clone, Copy, Debug, Eq, PartialEq)]
82pub enum ShadowCoverageState {
83    /// Both lanes expose the dimension and the report compares it.
84    Compared,
85    /// Both lanes can expose the dimension, but the shadow projection does not yet compare it.
86    Unimplemented,
87    /// The frozen V1 model discards or cannot encode the dimension.
88    NotRepresentable,
89}
90
91impl ShadowCoverage {
92    /// Return dimensions actually compared by this implementation.
93    #[must_use]
94    pub const fn compared(&self) -> &BTreeSet<ShadowDimension> {
95        &self.compared
96    }
97
98    /// Compatibility spelling for [`Self::compared`].
99    #[must_use]
100    pub const fn covered(&self) -> &BTreeSet<ShadowDimension> {
101        self.compared()
102    }
103
104    /// Return representable dimensions not yet compared by this implementation.
105    #[must_use]
106    pub const fn unimplemented(&self) -> &BTreeSet<ShadowDimension> {
107        &self.unimplemented
108    }
109
110    /// Return dimensions that the frozen V1 model cannot faithfully represent.
111    #[must_use]
112    pub const fn not_representable(&self) -> &BTreeSet<ShadowDimension> {
113        &self.not_representable
114    }
115
116    /// Return the union of unimplemented and unrepresentable dimensions.
117    #[must_use]
118    pub const fn blind_spots(&self) -> &BTreeSet<ShadowDimension> {
119        &self.blind_spots
120    }
121
122    /// Return the explicit coverage state for one known dimension.
123    #[must_use]
124    pub fn state(&self, dimension: ShadowDimension) -> ShadowCoverageState {
125        if self.compared.contains(&dimension) {
126            ShadowCoverageState::Compared
127        } else if self.unimplemented.contains(&dimension) {
128            ShadowCoverageState::Unimplemented
129        } else {
130            debug_assert!(self.not_representable.contains(&dimension));
131            ShadowCoverageState::NotRepresentable
132        }
133    }
134
135    /// Report whether all known dimensions are compared.
136    #[must_use]
137    pub fn is_complete(&self) -> bool {
138        self.blind_spots.is_empty()
139    }
140}
141
142/// Stable outcome for one independently executed parser or resolver lane.
143#[derive(Clone, Debug, Eq, PartialEq)]
144pub enum ShadowLaneOutcome {
145    /// The lane accepted its input.
146    Accepted(ShadowLaneSummary),
147    /// The lane rejected its input.
148    Rejected(ShadowLaneRejection),
149    /// The lane could not run because its prerequisite lane rejected.
150    NotRun(ShadowLaneNotRun),
151}
152
153impl ShadowLaneOutcome {
154    /// Report whether this lane accepted its input.
155    #[must_use]
156    pub const fn is_accepted(&self) -> bool {
157        matches!(self, Self::Accepted(_))
158    }
159}
160
161/// Deterministic summary for one accepted lane.
162#[derive(Clone, Debug, Eq, PartialEq)]
163pub struct ShadowLaneSummary {
164    type_count: usize,
165}
166
167impl ShadowLaneSummary {
168    fn new(type_count: usize) -> Self {
169        Self { type_count }
170    }
171
172    /// Return the number of entity, relation, and attribute types accepted.
173    #[must_use]
174    pub const fn type_count(&self) -> usize {
175        self.type_count
176    }
177}
178
179/// Stable rejection payload for one lane.
180#[derive(Clone, Debug, Eq, PartialEq)]
181pub struct ShadowLaneRejection {
182    code: String,
183    message: String,
184}
185
186impl ShadowLaneRejection {
187    fn new(code: impl Into<String>, message: impl Into<String>) -> Self {
188        Self {
189            code: code.into(),
190            message: message.into(),
191        }
192    }
193
194    /// Return the stable rejection code.
195    #[must_use]
196    pub fn code(&self) -> &str {
197        &self.code
198    }
199
200    /// Return the diagnostic message.
201    #[must_use]
202    pub fn message(&self) -> &str {
203        &self.message
204    }
205}
206
207/// Explanation for a lane skipped after a prerequisite rejection.
208#[derive(Clone, Debug, Eq, PartialEq)]
209pub struct ShadowLaneNotRun {
210    code: &'static str,
211}
212
213impl ShadowLaneNotRun {
214    fn new(code: &'static str) -> Self {
215        Self { code }
216    }
217
218    /// Return the stable prerequisite-failure code.
219    #[must_use]
220    pub const fn code(&self) -> &'static str {
221        self.code
222    }
223}
224
225/// Comparison verdict for successfully projected effective schemas.
226#[derive(Clone, Copy, Debug, Eq, PartialEq)]
227pub enum ShadowVerdict {
228    /// Every covered basic-type dimension matched.
229    Matched,
230    /// At least one covered basic-type dimension differed.
231    Mismatched,
232}
233
234/// One deterministic field-level difference between effective projections.
235#[derive(Clone, Debug, Eq, Ord, PartialEq, PartialOrd)]
236pub struct ShadowFinding {
237    dimension: ShadowDimension,
238    type_label: String,
239    v1_value: Option<String>,
240    v2_value: Option<String>,
241}
242
243impl ShadowFinding {
244    fn new(
245        dimension: ShadowDimension,
246        type_label: impl Into<String>,
247        v1_value: Option<String>,
248        v2_value: Option<String>,
249    ) -> Self {
250        Self {
251            dimension,
252            type_label: type_label.into(),
253            v1_value,
254            v2_value,
255        }
256    }
257
258    /// Return the mismatched semantic dimension.
259    #[must_use]
260    pub const fn dimension(&self) -> ShadowDimension {
261        self.dimension
262    }
263
264    /// Return the affected type label.
265    #[must_use]
266    pub fn type_label(&self) -> &str {
267        &self.type_label
268    }
269
270    /// Return the canonical V1 value, or `None` when absent.
271    #[must_use]
272    pub fn v1_value(&self) -> Option<&str> {
273        self.v1_value.as_deref()
274    }
275
276    /// Return the canonical V2 value, or `None` when absent.
277    #[must_use]
278    pub fn v2_value(&self) -> Option<&str> {
279        self.v2_value.as_deref()
280    }
281}
282
283/// Successful comparison of the two effective projections.
284#[derive(Clone, Debug, Eq, PartialEq)]
285pub struct ShadowCompared {
286    verdict: ShadowVerdict,
287    coverage: ShadowCoverage,
288    findings: Vec<ShadowFinding>,
289}
290
291impl ShadowCompared {
292    /// Return the verdict over covered dimensions.
293    #[must_use]
294    pub const fn verdict(&self) -> ShadowVerdict {
295        self.verdict
296    }
297
298    /// Return explicit covered and uncovered dimensions.
299    #[must_use]
300    pub const fn coverage(&self) -> &ShadowCoverage {
301        &self.coverage
302    }
303
304    /// Return findings in deterministic dimension, label, and value order.
305    #[must_use]
306    pub fn findings(&self) -> &[ShadowFinding] {
307        &self.findings
308    }
309
310    /// Report whether this comparison alone is sufficient for a V1 cutover.
311    ///
312    /// A partial match is intentionally never cutover evidence.
313    #[must_use]
314    pub fn is_cutover_evidence(&self) -> bool {
315        self.verdict == ShadowVerdict::Matched && self.coverage.is_complete()
316    }
317}
318
319/// A lane whose absence prevents an honest effective-schema comparison.
320#[derive(Clone, Copy, Debug, Eq, Ord, PartialEq, PartialOrd)]
321pub enum ShadowUnavailableLane {
322    /// The validating and inheritance-resolving V1 lane rejected.
323    V1Effective,
324    /// The V2 TypeQL-to-declared lane rejected.
325    V2Declared,
326    /// The V2 pure resolver lane rejected or could not run.
327    V2Effective,
328}
329
330/// Result when no effective-schema comparison can be made.
331#[derive(Clone, Debug, Eq, PartialEq)]
332pub struct ShadowNotCompared {
333    unavailable_lanes: BTreeSet<ShadowUnavailableLane>,
334}
335
336impl ShadowNotCompared {
337    /// Return every unavailable prerequisite lane.
338    #[must_use]
339    pub const fn unavailable_lanes(&self) -> &BTreeSet<ShadowUnavailableLane> {
340        &self.unavailable_lanes
341    }
342}
343
344/// Either a real projection comparison or an explicit unavailable result.
345#[derive(Clone, Debug, Eq, PartialEq)]
346pub enum ShadowComparison {
347    /// Both effective lanes accepted and were compared.
348    Compared(ShadowCompared),
349    /// One or more effective lanes were unavailable; rejection parity is not equality.
350    NotCompared(ShadowNotCompared),
351}
352
353/// Complete comparison-only report for one TypeQL source.
354#[derive(Clone, Debug, Eq, PartialEq)]
355pub struct V1ShadowReport {
356    profile: SemanticProfileId,
357    v1_direct: ShadowLaneOutcome,
358    v1_effective: ShadowLaneOutcome,
359    v2_declared: ShadowLaneOutcome,
360    v2_effective: ShadowLaneOutcome,
361    v2_declared_fingerprint: Option<DeclaredIdentityFingerprint>,
362    v2_semantic_fingerprint: Option<SemanticSchemaFingerprint>,
363    comparison: ShadowComparison,
364}
365
366impl V1ShadowReport {
367    /// Return the semantic-default profile used by the V2 resolver.
368    #[must_use]
369    pub const fn profile(&self) -> &SemanticProfileId {
370        &self.profile
371    }
372
373    /// Return the raw V1 parser lane outcome.
374    #[must_use]
375    pub const fn v1_direct(&self) -> &ShadowLaneOutcome {
376        &self.v1_direct
377    }
378
379    /// Return the validating, inheritance-resolving V1 lane outcome.
380    #[must_use]
381    pub const fn v1_effective(&self) -> &ShadowLaneOutcome {
382        &self.v1_effective
383    }
384
385    /// Return the V2 declared-fact lane outcome.
386    #[must_use]
387    pub const fn v2_declared(&self) -> &ShadowLaneOutcome {
388        &self.v2_declared
389    }
390
391    /// Return the V2 pure-resolver lane outcome.
392    #[must_use]
393    pub const fn v2_effective(&self) -> &ShadowLaneOutcome {
394        &self.v2_effective
395    }
396
397    /// Return the V2 direct-identity fingerprint when declaration succeeded.
398    #[must_use]
399    pub const fn v2_declared_fingerprint(&self) -> Option<&DeclaredIdentityFingerprint> {
400        self.v2_declared_fingerprint.as_ref()
401    }
402
403    /// Return the V2 semantic fingerprint when resolution succeeded.
404    #[must_use]
405    pub const fn v2_semantic_fingerprint(&self) -> Option<&SemanticSchemaFingerprint> {
406        self.v2_semantic_fingerprint.as_ref()
407    }
408
409    /// Return the honest effective-projection comparison state.
410    #[must_use]
411    pub const fn comparison(&self) -> &ShadowComparison {
412        &self.comparison
413    }
414}
415
416/// Internal setup failure distinct from a parser or resolver lane rejection.
417#[derive(Clone, Debug, Eq, PartialEq)]
418pub struct V1ShadowInternalError {
419    code: &'static str,
420    message: String,
421}
422
423impl V1ShadowInternalError {
424    fn new(code: &'static str, message: impl Into<String>) -> Self {
425        Self {
426            code,
427            message: message.into(),
428        }
429    }
430
431    /// Return the stable internal-error code.
432    #[must_use]
433    pub const fn code(&self) -> &'static str {
434        self.code
435    }
436
437    /// Return the internal-error message.
438    #[must_use]
439    pub fn message(&self) -> &str {
440        &self.message
441    }
442}
443
444impl fmt::Display for V1ShadowInternalError {
445    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
446        write!(formatter, "{}: {}", self.code, self.message)
447    }
448}
449
450impl Error for V1ShadowInternalError {}
451
452/// Execute the V1 direct/effective and V2 declared/effective lanes independently.
453///
454/// This API accepts TypeQL source, never a [`TypeSchema`], so it cannot become a
455/// backdoor V1-to-V2 adapter. A matched verdict covers only the dimensions listed
456/// by [`ShadowCoverage::covered`].
457pub fn v1_shadow_report(
458    typeql: &str,
459    profile: &SemanticProfileId,
460) -> Result<V1ShadowReport, V1ShadowInternalError> {
461    let document = DocumentId::new(SHADOW_DOCUMENT).map_err(|diagnostic| {
462        V1ShadowInternalError::new("shadow_document_id_invalid", diagnostic.to_string())
463    })?;
464
465    let v1_direct_result = parser::parse_typeql(typeql);
466    let v1_direct = match &v1_direct_result {
467        Ok(schema) => accepted_v1(schema),
468        Err(error) => rejected_v1(error),
469    };
470
471    let semantic_profile = SemanticProfile::resolve(profile).map_err(|diagnostic| {
472        V1ShadowInternalError::new("shadow_semantic_profile_invalid", diagnostic.to_string())
473    })?;
474    let v1_effective_result = TypeSchema::from_typeql(typeql);
475    let v1_effective_projection = v1_effective_result
476        .as_ref()
477        .ok()
478        .map(|schema| project_v1(schema, &semantic_profile));
479    let v1_effective = match &v1_effective_result {
480        Ok(schema) => accepted_v1(schema),
481        Err(error) => rejected_v1(error),
482    };
483
484    let v2_declared_result = typeql_to_declared(document, typeql);
485    let v2_declared_fingerprint = v2_declared_result
486        .as_ref()
487        .ok()
488        .map(|declared| declared.declared_identity_fingerprint().clone());
489    let v2_declared = match &v2_declared_result {
490        Ok(declared) => ShadowLaneOutcome::Accepted(ShadowLaneSummary::new(
491            declared
492                .facts()
493                .filter(|fact| matches!(fact, SchemaFact::Type(_)))
494                .count(),
495        )),
496        Err(diagnostics) => rejected_v2(diagnostics),
497    };
498
499    let v2_resolved_result = v2_declared_result
500        .as_ref()
501        .ok()
502        .map(|declared| resolve(declared, profile));
503    let v2_effective_projection = v2_resolved_result
504        .as_ref()
505        .and_then(|result| result.as_ref().ok())
506        .map(project_v2);
507    let v2_semantic_fingerprint = v2_resolved_result
508        .as_ref()
509        .and_then(|result| result.as_ref().ok())
510        .map(|resolved| resolved.semantic_fingerprint().clone());
511    let v2_effective = match &v2_resolved_result {
512        Some(Ok(resolved)) => {
513            ShadowLaneOutcome::Accepted(ShadowLaneSummary::new(resolved.types().len()))
514        }
515        Some(Err(diagnostics)) => rejected_v2(diagnostics),
516        None => ShadowLaneOutcome::NotRun(ShadowLaneNotRun::new("v2_declared_rejected")),
517    };
518
519    let comparison = match (&v1_effective_projection, &v2_effective_projection) {
520        (Some(v1), Some(v2)) => ShadowComparison::Compared(compare(v1, v2)),
521        _ => {
522            let mut unavailable_lanes = BTreeSet::new();
523            if v1_effective_projection.is_none() {
524                unavailable_lanes.insert(ShadowUnavailableLane::V1Effective);
525            }
526            if v2_declared_result.is_err() {
527                unavailable_lanes.insert(ShadowUnavailableLane::V2Declared);
528            }
529            if v2_effective_projection.is_none() {
530                unavailable_lanes.insert(ShadowUnavailableLane::V2Effective);
531            }
532            ShadowComparison::NotCompared(ShadowNotCompared { unavailable_lanes })
533        }
534    };
535
536    Ok(V1ShadowReport {
537        profile: profile.clone(),
538        v1_direct,
539        v1_effective,
540        v2_declared,
541        v2_effective,
542        v2_declared_fingerprint,
543        v2_semantic_fingerprint,
544        comparison,
545    })
546}
547
548#[derive(Clone, Copy, Debug, Eq, PartialEq)]
549enum BasicTypeKind {
550    Entity,
551    Relation,
552    Attribute,
553}
554
555impl BasicTypeKind {
556    const fn as_str(self) -> &'static str {
557        match self {
558            Self::Entity => "entity",
559            Self::Relation => "relation",
560            Self::Attribute => "attribute",
561        }
562    }
563}
564
565#[derive(Clone, Debug, Eq, PartialEq)]
566struct BasicTypeProjection {
567    kind: BasicTypeKind,
568    parent: Option<String>,
569    is_abstract: bool,
570    is_independent: bool,
571    value_type: Option<String>,
572    owns: BTreeMap<String, String>,
573    relates: BTreeMap<String, String>,
574    plays: BTreeMap<String, String>,
575}
576
577#[derive(Clone, Debug, Eq, PartialEq)]
578struct SchemaProjection {
579    types: BTreeMap<String, BasicTypeProjection>,
580    functions: BTreeMap<String, String>,
581    structs: BTreeMap<String, String>,
582    documentation_and_metadata: BTreeMap<String, String>,
583}
584
585fn accepted_v1(schema: &TypeSchema) -> ShadowLaneOutcome {
586    ShadowLaneOutcome::Accepted(ShadowLaneSummary::new(
587        schema.entities.len() + schema.relations.len() + schema.attributes.len(),
588    ))
589}
590
591fn rejected_v1(error: &SchemaError) -> ShadowLaneOutcome {
592    let code = match error {
593        SchemaError::ParseError { .. } => "v1_parse_error",
594        SchemaError::InheritanceCycle { .. } => "v1_inheritance_cycle",
595        SchemaError::UnknownParent { .. } => "v1_unknown_parent",
596        SchemaError::DuplicateDefinition { .. } => "v1_duplicate_definition",
597        SchemaError::ValidationError { .. } => "v1_validation_error",
598    };
599    ShadowLaneOutcome::Rejected(ShadowLaneRejection::new(code, error.to_string()))
600}
601
602fn rejected_v2(diagnostics: &SchemaDiagnostics) -> ShadowLaneOutcome {
603    let code = diagnostics
604        .iter()
605        .next()
606        .map(|entry| entry.diagnostic().code().as_str())
607        .unwrap_or("v2_schema_rejected");
608    ShadowLaneOutcome::Rejected(ShadowLaneRejection::new(code, diagnostics.to_string()))
609}
610
611fn project_v1(schema: &TypeSchema, profile: &SemanticProfile) -> SchemaProjection {
612    let mut types = BTreeMap::new();
613    let mut documentation_and_metadata = BTreeMap::new();
614    for entity in schema.entities.values() {
615        types.insert(
616            entity.name.clone(),
617            BasicTypeProjection {
618                kind: BasicTypeKind::Entity,
619                parent: entity.parent.clone(),
620                is_abstract: entity.is_abstract,
621                is_independent: false,
622                value_type: None,
623                owns: project_v1_owns(&entity.owns, profile),
624                relates: BTreeMap::new(),
625                plays: project_v1_plays(&entity.plays, profile),
626            },
627        );
628        insert_doc_meta(
629            &mut documentation_and_metadata,
630            format!("type {}", entity.name),
631            entity.doc.as_deref(),
632            entity
633                .meta
634                .iter()
635                .map(|(key, value)| (key.as_str(), value.as_str())),
636        );
637        project_v1_interface_docs(
638            &mut documentation_and_metadata,
639            &entity.name,
640            &entity.owns,
641            &entity.plays,
642            &[],
643        );
644    }
645    for relation in schema.relations.values() {
646        types.insert(
647            relation.name.clone(),
648            BasicTypeProjection {
649                kind: BasicTypeKind::Relation,
650                parent: relation.parent.clone(),
651                is_abstract: relation.is_abstract,
652                is_independent: false,
653                value_type: None,
654                owns: project_v1_owns(&relation.owns, profile),
655                relates: project_v1_relates(&relation.roles, profile),
656                plays: project_v1_plays(&relation.plays, profile),
657            },
658        );
659        insert_doc_meta(
660            &mut documentation_and_metadata,
661            format!("type {}", relation.name),
662            relation.doc.as_deref(),
663            relation
664                .meta
665                .iter()
666                .map(|(key, value)| (key.as_str(), value.as_str())),
667        );
668        project_v1_interface_docs(
669            &mut documentation_and_metadata,
670            &relation.name,
671            &relation.owns,
672            &relation.plays,
673            &relation.roles,
674        );
675    }
676    for attribute in schema.attributes.values() {
677        types.insert(
678            attribute.name.clone(),
679            BasicTypeProjection {
680                kind: BasicTypeKind::Attribute,
681                parent: attribute.parent.clone(),
682                is_abstract: attribute.is_abstract,
683                is_independent: attribute.is_independent,
684                value_type: normalize_v1_value_type(&attribute.value_type),
685                owns: BTreeMap::new(),
686                relates: BTreeMap::new(),
687                plays: BTreeMap::new(),
688            },
689        );
690        insert_doc_meta(
691            &mut documentation_and_metadata,
692            format!("type {}", attribute.name),
693            attribute.doc.as_deref(),
694            attribute
695                .meta
696                .iter()
697                .map(|(key, value)| (key.as_str(), value.as_str())),
698        );
699    }
700    SchemaProjection {
701        types,
702        functions: schema
703            .functions
704            .iter()
705            .map(|(name, function)| (name.clone(), project_v1_function(function)))
706            .collect(),
707        structs: schema
708            .structs
709            .iter()
710            .map(|(name, value)| (name.clone(), project_v1_struct(value)))
711            .collect(),
712        documentation_and_metadata,
713    }
714}
715
716fn project_v2(schema: &ResolvedSchema) -> SchemaProjection {
717    let mut documentation_and_metadata = BTreeMap::new();
718    let types = schema
719        .types()
720        .values()
721        .map(|resolved| {
722            let id = resolved.id();
723            let kind = match id.kind() {
724                TypeKind::Entity => BasicTypeKind::Entity,
725                TypeKind::Relation => BasicTypeKind::Relation,
726                TypeKind::Attribute => BasicTypeKind::Attribute,
727                TypeKind::Struct => unreachable!("resolved structs are not schema types"),
728            };
729            let parent = resolved
730                .supertypes()
731                .first()
732                .map(|parent| parent.label().as_str().to_owned());
733            let is_independent = resolved
734                .annotations()
735                .contains_key(&AnnotationKindId::Independent);
736            let value_type = resolved
737                .value_type()
738                .map(|value| v2_value_type(value.value_type()).to_owned());
739            insert_v2_doc_meta(
740                &mut documentation_and_metadata,
741                format!("type {}", id.label().as_str()),
742                resolved.annotations(),
743            );
744            for owns in resolved.owns().values() {
745                insert_v2_doc_meta(
746                    &mut documentation_and_metadata,
747                    format!(
748                        "owns {} {}",
749                        id.label().as_str(),
750                        owns.id().attribute().label().as_str()
751                    ),
752                    owns.annotations(),
753                );
754            }
755            for plays in resolved.plays().values() {
756                insert_v2_doc_meta(
757                    &mut documentation_and_metadata,
758                    format!(
759                        "plays {} {}:{}",
760                        id.label().as_str(),
761                        plays.id().role().declaring_relation().as_str(),
762                        plays.id().role().label().as_str()
763                    ),
764                    plays.annotations(),
765                );
766            }
767            for relates in resolved.relates().values() {
768                insert_v2_doc_meta(
769                    &mut documentation_and_metadata,
770                    format!(
771                        "relates {} {}",
772                        id.label().as_str(),
773                        relates.id().role().label().as_str()
774                    ),
775                    relates.annotations(),
776                );
777            }
778            (
779                id.label().as_str().to_owned(),
780                BasicTypeProjection {
781                    kind,
782                    parent,
783                    is_abstract: resolved.is_abstract(),
784                    is_independent,
785                    value_type,
786                    owns: resolved
787                        .owns()
788                        .values()
789                        .map(|owns| {
790                            (
791                                owns.id().attribute().label().as_str().to_owned(),
792                                format_owns(owns.cardinality(), owns.is_key(), owns.is_unique()),
793                            )
794                        })
795                        .collect(),
796                    relates: resolved
797                        .relates()
798                        .values()
799                        .map(|relates| {
800                            let replaced = relates
801                                .replaced_roles()
802                                .iter()
803                                .map(|role| role.label().as_str())
804                                .collect::<Vec<_>>()
805                                .join(",");
806                            (
807                                relates.id().role().label().as_str().to_owned(),
808                                format_relates(
809                                    relates.cardinality(),
810                                    relates.is_abstract(),
811                                    &replaced,
812                                ),
813                            )
814                        })
815                        .collect(),
816                    plays: resolved
817                        .plays()
818                        .values()
819                        .map(|plays| {
820                            (
821                                format!(
822                                    "{}:{}",
823                                    plays.id().role().declaring_relation().as_str(),
824                                    plays.id().role().label().as_str()
825                                ),
826                                format_cardinality(plays.cardinality()),
827                            )
828                        })
829                        .collect(),
830                },
831            )
832        })
833        .collect();
834    SchemaProjection {
835        types,
836        functions: schema
837            .functions()
838            .values()
839            .map(|function| {
840                (
841                    function.id().label().as_str().to_owned(),
842                    project_v2_function(function.declaration()),
843                )
844            })
845            .collect(),
846        structs: schema
847            .structs()
848            .values()
849            .map(|value| {
850                (
851                    value.id().label().as_str().to_owned(),
852                    value
853                        .fields()
854                        .iter()
855                        .map(|field| {
856                            format!(
857                                "{}:{}{}",
858                                field.name().as_str(),
859                                v2_value_type(field.value_type()),
860                                if field.optional() { "?" } else { "" }
861                            )
862                        })
863                        .collect::<Vec<_>>()
864                        .join(","),
865                )
866            })
867            .collect(),
868        documentation_and_metadata,
869    }
870}
871
872fn compare(v1: &SchemaProjection, v2: &SchemaProjection) -> ShadowCompared {
873    let coverage = coverage();
874    let labels = v1
875        .types
876        .keys()
877        .chain(v2.types.keys())
878        .cloned()
879        .collect::<BTreeSet<_>>();
880    let mut findings = BTreeSet::new();
881    for label in labels {
882        match (v1.types.get(&label), v2.types.get(&label)) {
883            (Some(left), Some(right)) => {
884                if left.kind != right.kind {
885                    findings.insert(ShadowFinding::new(
886                        ShadowDimension::TypeKind,
887                        &label,
888                        Some(left.kind.as_str().to_owned()),
889                        Some(right.kind.as_str().to_owned()),
890                    ));
891                }
892                insert_difference(
893                    &mut findings,
894                    ShadowDimension::DirectParent,
895                    &label,
896                    left.parent.clone(),
897                    right.parent.clone(),
898                );
899                insert_difference(
900                    &mut findings,
901                    ShadowDimension::TypeAbstract,
902                    &label,
903                    Some(left.is_abstract.to_string()),
904                    Some(right.is_abstract.to_string()),
905                );
906                insert_difference(
907                    &mut findings,
908                    ShadowDimension::AttributeIndependent,
909                    &label,
910                    Some(left.is_independent.to_string()),
911                    Some(right.is_independent.to_string()),
912                );
913                insert_difference(
914                    &mut findings,
915                    ShadowDimension::ValueType,
916                    &label,
917                    left.value_type.clone(),
918                    right.value_type.clone(),
919                );
920                compare_named_values(
921                    &mut findings,
922                    ShadowDimension::EffectiveOwns,
923                    &format!("{label} owns "),
924                    &left.owns,
925                    &right.owns,
926                );
927                compare_named_values(
928                    &mut findings,
929                    ShadowDimension::EffectiveRelates,
930                    &format!("{label} relates "),
931                    &left.relates,
932                    &right.relates,
933                );
934                compare_named_values(
935                    &mut findings,
936                    ShadowDimension::EffectivePlays,
937                    &format!("{label} plays "),
938                    &left.plays,
939                    &right.plays,
940                );
941            }
942            (Some(left), None) => {
943                findings.insert(ShadowFinding::new(
944                    ShadowDimension::TypeExistence,
945                    &label,
946                    Some(left.kind.as_str().to_owned()),
947                    None,
948                ));
949            }
950            (None, Some(right)) => {
951                findings.insert(ShadowFinding::new(
952                    ShadowDimension::TypeExistence,
953                    &label,
954                    None,
955                    Some(right.kind.as_str().to_owned()),
956                ));
957            }
958            (None, None) => {}
959        }
960    }
961    compare_named_values(
962        &mut findings,
963        ShadowDimension::FunctionSignatures,
964        "function ",
965        &v1.functions,
966        &v2.functions,
967    );
968    compare_named_values(
969        &mut findings,
970        ShadowDimension::StructFields,
971        "struct ",
972        &v1.structs,
973        &v2.structs,
974    );
975    compare_named_values(
976        &mut findings,
977        ShadowDimension::DocumentationAndMetadata,
978        "",
979        &v1.documentation_and_metadata,
980        &v2.documentation_and_metadata,
981    );
982    let findings = findings.into_iter().collect::<Vec<_>>();
983    ShadowCompared {
984        verdict: if findings.is_empty() {
985            ShadowVerdict::Matched
986        } else {
987            ShadowVerdict::Mismatched
988        },
989        coverage,
990        findings,
991    }
992}
993
994fn compare_named_values(
995    findings: &mut BTreeSet<ShadowFinding>,
996    dimension: ShadowDimension,
997    subject_prefix: &str,
998    v1: &BTreeMap<String, String>,
999    v2: &BTreeMap<String, String>,
1000) {
1001    for key in v1.keys().chain(v2.keys()).collect::<BTreeSet<_>>() {
1002        insert_difference(
1003            findings,
1004            dimension,
1005            &format!("{subject_prefix}{key}"),
1006            v1.get(key).cloned(),
1007            v2.get(key).cloned(),
1008        );
1009    }
1010}
1011
1012fn insert_difference(
1013    findings: &mut BTreeSet<ShadowFinding>,
1014    dimension: ShadowDimension,
1015    label: &str,
1016    v1_value: Option<String>,
1017    v2_value: Option<String>,
1018) {
1019    if v1_value != v2_value {
1020        findings.insert(ShadowFinding::new(dimension, label, v1_value, v2_value));
1021    }
1022}
1023
1024fn coverage() -> ShadowCoverage {
1025    let compared = BTreeSet::from([
1026        ShadowDimension::TypeExistence,
1027        ShadowDimension::TypeKind,
1028        ShadowDimension::DirectParent,
1029        ShadowDimension::TypeAbstract,
1030        ShadowDimension::AttributeIndependent,
1031        ShadowDimension::ValueType,
1032        ShadowDimension::EffectiveOwns,
1033        ShadowDimension::EffectiveRelates,
1034        ShadowDimension::EffectivePlays,
1035        ShadowDimension::DocumentationAndMetadata,
1036        ShadowDimension::FunctionSignatures,
1037    ]);
1038    let unimplemented = BTreeSet::new();
1039    let not_representable = BTreeSet::from([
1040        ShadowDimension::FunctionBodiesAndAnnotations,
1041        ShadowDimension::StructFields,
1042        ShadowDimension::SourceCommentsAndSpans,
1043        ShadowDimension::OmittedVersusExplicitIdentity,
1044        ShadowDimension::IndependentAnnotationIdentityAndRemoval,
1045        ShadowDimension::SubAnnotations,
1046        ShadowDimension::ExtensionsAndCapabilities,
1047        ShadowDimension::ResolverGraphsAndOrigins,
1048        ShadowDimension::CardinalityOutsideV1U32,
1049    ]);
1050    let blind_spots = unimplemented.union(&not_representable).copied().collect();
1051    ShadowCoverage {
1052        compared,
1053        unimplemented,
1054        not_representable,
1055        blind_spots,
1056    }
1057}
1058
1059fn project_v1_owns(owns: &[OwnedAttribute], profile: &SemanticProfile) -> BTreeMap<String, String> {
1060    owns.iter()
1061        .map(|owns| {
1062            let cardinality = if owns.is_key {
1063                V2Cardinality::new(1, Some(1)).expect("the effective key cardinality is exact-one")
1064            } else {
1065                v1_cardinality(owns.cardinality.as_ref(), profile, InterfaceKind::Owns)
1066            };
1067            (
1068                owns.name.clone(),
1069                format_owns(cardinality, owns.is_key, owns.is_key || owns.is_unique),
1070            )
1071        })
1072        .collect()
1073}
1074
1075fn project_v1_plays(plays: &[PlayedRole], profile: &SemanticProfile) -> BTreeMap<String, String> {
1076    plays
1077        .iter()
1078        .map(|plays| {
1079            (
1080                plays.role_ref.clone(),
1081                format_cardinality(v1_cardinality(
1082                    plays.cardinality.as_ref(),
1083                    profile,
1084                    InterfaceKind::Plays,
1085                )),
1086            )
1087        })
1088        .collect()
1089}
1090
1091fn project_v1_relates(roles: &[RoleSpec], profile: &SemanticProfile) -> BTreeMap<String, String> {
1092    roles
1093        .iter()
1094        .map(|role| {
1095            (
1096                role.name.clone(),
1097                format_relates(
1098                    v1_cardinality(role.cardinality.as_ref(), profile, InterfaceKind::Relates),
1099                    role.is_abstract,
1100                    role.overrides.as_deref().unwrap_or(""),
1101                ),
1102            )
1103        })
1104        .collect()
1105}
1106
1107fn v1_cardinality(
1108    cardinality: Option<&V1Cardinality>,
1109    profile: &SemanticProfile,
1110    interface: InterfaceKind,
1111) -> V2Cardinality {
1112    cardinality.map_or_else(
1113        || profile.default_cardinality(interface),
1114        |cardinality| {
1115            V2Cardinality::new(u64::from(cardinality.min), cardinality.max.map(u64::from))
1116                .expect("validated V1 cardinality must fit the V2 domain")
1117        },
1118    )
1119}
1120
1121fn format_cardinality(cardinality: V2Cardinality) -> String {
1122    format!(
1123        "{}..{}",
1124        cardinality.min(),
1125        cardinality
1126            .max()
1127            .map_or_else(|| "unbounded".to_owned(), |maximum| maximum.to_string())
1128    )
1129}
1130
1131fn format_owns(cardinality: V2Cardinality, key: bool, unique: bool) -> String {
1132    format!(
1133        "card={};key={key};unique={unique}",
1134        format_cardinality(cardinality)
1135    )
1136}
1137
1138fn format_relates(cardinality: V2Cardinality, is_abstract: bool, replaces: &str) -> String {
1139    format!(
1140        "card={};abstract={is_abstract};replaces={replaces}",
1141        format_cardinality(cardinality)
1142    )
1143}
1144
1145fn project_v1_interface_docs(
1146    projection: &mut BTreeMap<String, String>,
1147    owner: &str,
1148    owns: &[OwnedAttribute],
1149    plays: &[PlayedRole],
1150    relates: &[RoleSpec],
1151) {
1152    for interface in owns {
1153        insert_doc_meta(
1154            projection,
1155            format!("owns {owner} {}", interface.name),
1156            interface.doc.as_deref(),
1157            interface
1158                .meta
1159                .iter()
1160                .map(|(key, value)| (key.as_str(), value.as_str())),
1161        );
1162    }
1163    for interface in plays {
1164        insert_doc_meta(
1165            projection,
1166            format!("plays {owner} {}", interface.role_ref),
1167            interface.doc.as_deref(),
1168            interface
1169                .meta
1170                .iter()
1171                .map(|(key, value)| (key.as_str(), value.as_str())),
1172        );
1173    }
1174    for interface in relates {
1175        insert_doc_meta(
1176            projection,
1177            format!("relates {owner} {}", interface.name),
1178            interface.doc.as_deref(),
1179            interface
1180                .meta
1181                .iter()
1182                .map(|(key, value)| (key.as_str(), value.as_str())),
1183        );
1184    }
1185}
1186
1187fn insert_doc_meta<'a>(
1188    projection: &mut BTreeMap<String, String>,
1189    subject: String,
1190    doc: Option<&str>,
1191    meta: impl Iterator<Item = (&'a str, &'a str)>,
1192) {
1193    let meta = meta
1194        .map(|(key, value)| format!("{key:?}={value:?}"))
1195        .collect::<Vec<_>>()
1196        .join(",");
1197    if doc.is_some() || !meta.is_empty() {
1198        projection.insert(subject, format!("doc={doc:?};meta={meta}"));
1199    }
1200}
1201
1202fn insert_v2_doc_meta(
1203    projection: &mut BTreeMap<String, String>,
1204    subject: String,
1205    annotations: &BTreeMap<AnnotationKindId, SchemaAnnotationValue>,
1206) {
1207    let doc = annotations.get(&AnnotationKindId::Doc).and_then(|value| {
1208        if let SchemaAnnotationValue::Doc(doc) = value {
1209            Some(doc.as_str())
1210        } else {
1211            None
1212        }
1213    });
1214    let meta = annotations.iter().filter_map(|(kind, value)| {
1215        let AnnotationKindId::Meta(key) = kind else {
1216            return None;
1217        };
1218        let value = match value {
1219            SchemaAnnotationValue::Meta(CanonicalValue::String(value)) => value.as_str().to_owned(),
1220            SchemaAnnotationValue::Meta(value) => format!("{value:?}"),
1221            _ => return None,
1222        };
1223        Some((key.as_str(), value))
1224    });
1225    let meta = meta.collect::<Vec<_>>();
1226    insert_doc_meta(
1227        projection,
1228        subject,
1229        doc,
1230        meta.iter().map(|(key, value)| (*key, value.as_str())),
1231    );
1232}
1233
1234fn project_v1_function(function: &V1Function) -> String {
1235    let parameters = function
1236        .parameters
1237        .iter()
1238        .map(|parameter| {
1239            format!(
1240                "{}:{}",
1241                parameter.name,
1242                normalize_type_token(&parameter.type_)
1243            )
1244        })
1245        .collect::<Vec<_>>()
1246        .join(",");
1247    let returns = function
1248        .return_type
1249        .types
1250        .iter()
1251        .map(|value| {
1252            format!(
1253                "{}{}",
1254                normalize_type_token(&value.name),
1255                if value.optional { "?" } else { "" }
1256            )
1257        })
1258        .collect::<Vec<_>>()
1259        .join(",");
1260    let mode = if function.return_type.is_stream {
1261        "stream"
1262    } else if function.return_type.types.len() == 1 {
1263        "scalar"
1264    } else {
1265        "tuple"
1266    };
1267    format!("({parameters})->{mode}({returns})")
1268}
1269
1270fn project_v2_function(function: &type_bridge_contract::schema::FunctionFact) -> String {
1271    let signature = function.signature();
1272    let parameters = signature
1273        .parameters()
1274        .iter()
1275        .map(|parameter| {
1276            format!(
1277                "{}:{}",
1278                parameter.name().as_str(),
1279                type_reference(parameter.type_ref())
1280            )
1281        })
1282        .collect::<Vec<_>>()
1283        .join(",");
1284    let (mode, elements) = match signature.returns() {
1285        FunctionReturnMode::Scalar(element) => ("scalar", std::slice::from_ref(element)),
1286        FunctionReturnMode::Tuple(elements) => ("tuple", elements.as_slice()),
1287        FunctionReturnMode::Stream(elements) => ("stream", elements.as_slice()),
1288    };
1289    let returns = elements
1290        .iter()
1291        .map(|element| {
1292            format!(
1293                "{}{}",
1294                type_reference(element.type_ref()),
1295                if element.optional() { "?" } else { "" }
1296            )
1297        })
1298        .collect::<Vec<_>>()
1299        .join(",");
1300    format!("({parameters})->{mode}({returns})")
1301}
1302
1303fn type_reference(reference: &TypeReference) -> String {
1304    match reference {
1305        TypeReference::Value(value) => v2_value_type(*value).to_owned(),
1306        TypeReference::Schema(label) => label.as_str().to_owned(),
1307    }
1308}
1309
1310fn project_v1_struct(value: &V1Struct) -> String {
1311    value
1312        .fields
1313        .iter()
1314        .map(|field| {
1315            format!(
1316                "{}:{}{}",
1317                field.name,
1318                normalize_type_token(&field.value_type),
1319                if field.optional { "?" } else { "" }
1320            )
1321        })
1322        .collect::<Vec<_>>()
1323        .join(",")
1324}
1325
1326fn normalize_type_token(value: &str) -> String {
1327    normalize_v1_value_type(value).unwrap_or_else(|| value.trim().to_owned())
1328}
1329
1330fn normalize_v1_value_type(value: &str) -> Option<String> {
1331    let normalized = match value.trim() {
1332        "" => return None,
1333        "long" | "integer" => "integer",
1334        "bool" | "boolean" => "boolean",
1335        "datetime-tz" | "datetime_tz" => "datetime-tz",
1336        other => other,
1337    };
1338    Some(normalized.to_owned())
1339}
1340
1341const fn v2_value_type(value: ValueTypeTag) -> &'static str {
1342    match value {
1343        ValueTypeTag::String => "string",
1344        ValueTypeTag::Long => "integer",
1345        ValueTypeTag::Double => "double",
1346        ValueTypeTag::Boolean => "boolean",
1347        ValueTypeTag::Date => "date",
1348        ValueTypeTag::DateTime => "datetime",
1349        ValueTypeTag::DateTimeTz => "datetime-tz",
1350        ValueTypeTag::Decimal => "decimal",
1351        ValueTypeTag::Duration => "duration",
1352    }
1353}