Skip to main content

glass/
web_ir.rs

1//! Experimental draft Glass Web IR reconciliation.
2//!
3//! This module intentionally remains a sidecar to the existing semantic
4//! observation and intent paths. It turns bounded evidence into canonical draft
5//! entities without dispatching browser actions or inventing unsupported links.
6
7use crate::extraction::{
8    EvidenceFact, EvidenceQuality, EvidenceRelationshipHint, EvidenceSource, ExtractionEvidence,
9    ExtractionEvidenceLimits,
10};
11use serde::{Deserialize, Serialize};
12use std::collections::{BTreeMap, BTreeSet};
13use std::fmt::{Display, Formatter};
14
15/// Version of the experimental draft Web IR contract.
16pub const WEB_IR_DRAFT_SCHEMA_VERSION: u32 = 1;
17const MAX_DRAFT_ENTITIES: usize = 4_096;
18const MAX_DRAFT_RELATIONSHIPS: usize = 8_192;
19
20/// Canonical entity kinds used by the draft graph.
21#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
22#[serde(rename_all = "camelCase")]
23pub enum DraftEntityKind {
24    Page,
25    Region,
26    Form,
27    Field,
28    Action,
29    Link,
30    NavigationItem,
31    Tab,
32    Table,
33    Row,
34    Cell,
35    Collection,
36    CollectionItem,
37    Dialog,
38    PaginationControl,
39    Text,
40    UnknownInteractive,
41    OpaqueRegion,
42}
43
44/// Canonical relationship kinds used by the draft graph.
45#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
46#[serde(rename_all = "camelCase")]
47pub enum DraftRelationshipKind {
48    Contains,
49    Labels,
50    Owns,
51    Controls,
52    NavigatesTo,
53    Opens,
54    Confirms,
55    Cancels,
56    Continues,
57    Submits,
58    HeaderFor,
59    CellOf,
60    Selects,
61    RepeatsAs,
62    ScopedTo,
63}
64
65/// One canonical entity reconciled from one or more evidence sources.
66#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
67#[serde(rename_all = "camelCase")]
68pub struct DraftEntity {
69    pub id: String,
70    pub kind: DraftEntityKind,
71    #[serde(skip_serializing_if = "Option::is_none")]
72    pub role: Option<String>,
73    #[serde(skip_serializing_if = "Option::is_none")]
74    pub name: Option<String>,
75    pub quality: EvidenceQuality,
76    pub evidence_sources: Vec<EvidenceSource>,
77}
78
79/// One relationship between canonical draft entities.
80#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
81#[serde(rename_all = "camelCase")]
82pub struct DraftRelationship {
83    pub from: String,
84    pub to: String,
85    pub kind: DraftRelationshipKind,
86}
87
88/// Kind of change represented by a draft Web IR diff.
89#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
90#[serde(rename_all = "camelCase")]
91pub enum DraftChangeKind {
92    Added,
93    Removed,
94    Changed,
95}
96
97/// One entity change between two validated draft revisions.
98#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
99#[serde(rename_all = "camelCase")]
100pub struct DraftEntityChange {
101    pub id: String,
102    pub kind: DraftChangeKind,
103    #[serde(skip_serializing_if = "Option::is_none")]
104    pub before: Option<DraftEntity>,
105    #[serde(skip_serializing_if = "Option::is_none")]
106    pub after: Option<DraftEntity>,
107}
108
109/// One relationship addition or removal between two validated draft revisions.
110#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
111#[serde(rename_all = "camelCase")]
112pub struct DraftRelationshipChange {
113    pub relationship: DraftRelationship,
114    pub kind: DraftChangeKind,
115}
116
117/// Deterministic changes between two validated draft Web IR revisions.
118#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
119#[serde(rename_all = "camelCase")]
120pub struct GlassWebIrDiff {
121    pub schema_version: u32,
122    pub from_revision: u64,
123    pub to_revision: u64,
124    #[serde(skip_serializing_if = "Vec::is_empty")]
125    pub entity_changes: Vec<DraftEntityChange>,
126    #[serde(skip_serializing_if = "Vec::is_empty")]
127    pub relationship_changes: Vec<DraftRelationshipChange>,
128    pub coverage_changed: bool,
129    pub limits_changed: bool,
130    pub diagnostics_changed: bool,
131    pub relationship_hint_diagnostics_changed: bool,
132}
133
134/// Continuity classification for one entity across draft revisions.
135#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
136#[serde(rename_all = "camelCase")]
137pub enum DraftEntityContinuityStatus {
138    Unchanged,
139    Changed,
140    Rebound,
141    Removed,
142    Ambiguous,
143}
144
145/// Explain whether a revision-local entity remains safe to use.
146#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
147#[serde(rename_all = "camelCase")]
148pub struct DraftEntityContinuity {
149    pub requested_id: String,
150    pub status: DraftEntityContinuityStatus,
151    #[serde(skip_serializing_if = "Option::is_none")]
152    pub current_id: Option<String>,
153    pub reason: String,
154}
155
156impl DraftEntity {
157    /// Return a bounded semantic key suitable only for revision comparison.
158    pub fn semantic_identity_key(&self) -> Option<String> {
159        if self.role.is_none() && self.name.is_none() {
160            return None;
161        }
162        Some(format!(
163            "{}|{}|{}",
164            kind_name(self.kind),
165            self.role
166                .as_deref()
167                .unwrap_or_default()
168                .to_ascii_lowercase(),
169            self.name
170                .as_deref()
171                .unwrap_or_default()
172                .to_ascii_lowercase()
173        ))
174    }
175}
176
177/// Outcome of source-level relationship-hint validation.
178#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
179pub enum RelationshipHintDiagnosticStatus {
180    Validated,
181    Emitted,
182    UnmatchedParent,
183}
184
185/// A redacted diagnostic for one validated relationship hint.
186#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
187#[serde(rename_all = "camelCase")]
188pub struct DraftRelationshipHintDiagnostic {
189    pub fact_index: usize,
190    pub source: EvidenceSource,
191    pub hint: EvidenceRelationshipHint,
192    pub parent_role: String,
193    pub status: RelationshipHintDiagnosticStatus,
194}
195
196/// A bounded, experimental Web IR draft.
197#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
198#[serde(rename_all = "camelCase")]
199pub struct GlassWebIrDraft {
200    pub schema_version: u32,
201    pub revision: u64,
202    pub entities: Vec<DraftEntity>,
203    pub relationships: Vec<DraftRelationship>,
204    pub coverage: crate::extraction::EvidenceCoverage,
205    pub limits: ExtractionEvidenceLimits,
206    #[serde(default, skip_serializing_if = "Vec::is_empty")]
207    pub diagnostics: Vec<String>,
208    #[serde(default, skip_serializing_if = "Vec::is_empty")]
209    pub relationship_hint_diagnostics: Vec<DraftRelationshipHintDiagnostic>,
210}
211
212/// Minimum graph shape expected from one representative fixture.
213#[derive(Debug, Clone, Default, PartialEq, Eq)]
214pub struct DraftFixtureExpectation {
215    pub required_entity_counts: BTreeMap<DraftEntityKind, u32>,
216    pub required_relationship_counts: BTreeMap<DraftRelationshipKind, u32>,
217    pub opaque_regions: u32,
218}
219
220impl DraftEntityKind {
221    /// Parse the stable fixture vocabulary into a draft entity kind.
222    pub fn from_contract_name(name: &str) -> Option<Self> {
223        Some(match name {
224            "page" => Self::Page,
225            "region" => Self::Region,
226            "form" => Self::Form,
227            "field" => Self::Field,
228            "action" => Self::Action,
229            "link" => Self::Link,
230            "navigationItem" => Self::NavigationItem,
231            "tab" => Self::Tab,
232            "table" => Self::Table,
233            "row" => Self::Row,
234            "cell" => Self::Cell,
235            "collection" => Self::Collection,
236            "collectionItem" => Self::CollectionItem,
237            "dialog" => Self::Dialog,
238            "paginationControl" => Self::PaginationControl,
239            "text" => Self::Text,
240            "unknownInteractive" => Self::UnknownInteractive,
241            "opaqueRegion" => Self::OpaqueRegion,
242            _ => return None,
243        })
244    }
245}
246
247impl DraftRelationshipKind {
248    /// Parse the stable fixture vocabulary into a draft relationship kind.
249    pub fn from_contract_name(name: &str) -> Option<Self> {
250        Some(match name {
251            "contains" => Self::Contains,
252            "labels" => Self::Labels,
253            "owns" => Self::Owns,
254            "controls" => Self::Controls,
255            "navigatesTo" => Self::NavigatesTo,
256            "opens" => Self::Opens,
257            "confirms" => Self::Confirms,
258            "cancels" => Self::Cancels,
259            "continues" => Self::Continues,
260            "submits" => Self::Submits,
261            "headerFor" => Self::HeaderFor,
262            "cellOf" => Self::CellOf,
263            "selects" => Self::Selects,
264            "repeatsAs" => Self::RepeatsAs,
265            "scopedTo" => Self::ScopedTo,
266            _ => return None,
267        })
268    }
269}
270
271impl GlassWebIrDraft {
272    /// Validate graph invariants before exposing a draft to another layer.
273    pub fn validate(&self) -> Result<(), WebIrValidationError> {
274        if self.schema_version != WEB_IR_DRAFT_SCHEMA_VERSION {
275            return Err(WebIrValidationError::new(
276                "schemaVersion",
277                "unsupported draft Web IR schema version",
278            ));
279        }
280        if self.entities.is_empty() || self.entities.len() > MAX_DRAFT_ENTITIES {
281            return Err(WebIrValidationError::new(
282                "entities",
283                "entity count must be between 1 and the draft bound",
284            ));
285        }
286        let page_count = self
287            .entities
288            .iter()
289            .filter(|entity| entity.kind == DraftEntityKind::Page)
290            .count();
291        if page_count != 1 {
292            return Err(WebIrValidationError::new(
293                "entities",
294                "drafts must contain exactly one page entity",
295            ));
296        }
297        let mut ids = BTreeSet::new();
298        for entity in &self.entities {
299            if entity.id.is_empty() || entity.id.len() > 128 || !ids.insert(entity.id.as_str()) {
300                return Err(WebIrValidationError::new(
301                    "entities.id",
302                    "entity IDs must be unique and bounded",
303                ));
304            }
305            if entity.evidence_sources.is_empty()
306                && entity.kind != DraftEntityKind::Page
307                && entity.kind != DraftEntityKind::OpaqueRegion
308            {
309                return Err(WebIrValidationError::new(
310                    "entities.evidenceSources",
311                    "non-page, non-opaque entities require source provenance",
312                ));
313            }
314        }
315        if self.relationships.len() > MAX_DRAFT_RELATIONSHIPS {
316            return Err(WebIrValidationError::new(
317                "relationships",
318                "relationship count exceeds the draft bound",
319            ));
320        }
321        for relationship in &self.relationships {
322            if relationship.from == relationship.to
323                || !ids.contains(relationship.from.as_str())
324                || !ids.contains(relationship.to.as_str())
325            {
326                return Err(WebIrValidationError::new(
327                    "relationships",
328                    "relationships must reference two distinct known entities",
329                ));
330            }
331        }
332        Ok(())
333    }
334
335    /// Validate that the draft satisfies a representative fixture minimum.
336    pub fn validate_against(
337        &self,
338        expectation: &DraftFixtureExpectation,
339    ) -> Result<(), WebIrValidationError> {
340        self.validate()?;
341        for (kind, minimum) in &expectation.required_entity_counts {
342            let actual = self
343                .entities
344                .iter()
345                .filter(|entity| entity.kind == *kind)
346                .count() as u32;
347            if actual < *minimum {
348                return Err(WebIrValidationError::new(
349                    format!("expectation.entities.{}", kind_name(*kind)),
350                    format!("expected at least {minimum}, observed {actual}"),
351                ));
352            }
353        }
354        let opaque_regions = self
355            .entities
356            .iter()
357            .filter(|entity| entity.kind == DraftEntityKind::OpaqueRegion)
358            .count() as u32;
359        if opaque_regions != expectation.opaque_regions {
360            return Err(WebIrValidationError::new(
361                "expectation.opaqueRegions",
362                format!(
363                    "expected {expected}, observed {opaque_regions}",
364                    expected = expectation.opaque_regions
365                ),
366            ));
367        }
368        for (kind, minimum) in &expectation.required_relationship_counts {
369            let actual = self
370                .relationships
371                .iter()
372                .filter(|relationship| relationship.kind == *kind)
373                .count() as u32;
374            if actual < *minimum {
375                return Err(WebIrValidationError::new(
376                    format!("expectation.relationships.{}", relationship_name(*kind)),
377                    format!("expected at least {minimum}, observed {actual}"),
378                ));
379            }
380        }
381        Ok(())
382    }
383
384    /// Validate minimum counts for relationship-hint diagnostic outcomes.
385    pub fn validate_hint_diagnostics_against(
386        &self,
387        expected_status_counts: &BTreeMap<RelationshipHintDiagnosticStatus, u32>,
388    ) -> Result<(), WebIrValidationError> {
389        self.validate()?;
390        for (status, minimum) in expected_status_counts {
391            let actual = self
392                .relationship_hint_diagnostics
393                .iter()
394                .filter(|diagnostic| diagnostic.status == *status)
395                .count() as u32;
396            if actual < *minimum {
397                return Err(WebIrValidationError::new(
398                    format!(
399                        "expectation.relationshipHintDiagnostics.{}",
400                        hint_status_name(*status)
401                    ),
402                    format!("expected at least {minimum}, observed {actual}"),
403                ));
404            }
405        }
406        Ok(())
407    }
408
409    /// Compute deterministic changes between two validated draft revisions.
410    pub fn diff(&self, next: &Self) -> Result<GlassWebIrDiff, WebIrValidationError> {
411        self.validate()?;
412        next.validate()?;
413
414        let before_entities = self
415            .entities
416            .iter()
417            .map(|entity| (entity.id.clone(), entity))
418            .collect::<BTreeMap<_, _>>();
419        let after_entities = next
420            .entities
421            .iter()
422            .map(|entity| (entity.id.clone(), entity))
423            .collect::<BTreeMap<_, _>>();
424        let entity_ids = before_entities
425            .keys()
426            .chain(after_entities.keys())
427            .cloned()
428            .collect::<BTreeSet<_>>();
429        let mut entity_changes = Vec::new();
430        for id in entity_ids {
431            match (before_entities.get(&id), after_entities.get(&id)) {
432                (None, Some(after)) => entity_changes.push(DraftEntityChange {
433                    id,
434                    kind: DraftChangeKind::Added,
435                    before: None,
436                    after: Some((*after).clone()),
437                }),
438                (Some(before), None) => entity_changes.push(DraftEntityChange {
439                    id,
440                    kind: DraftChangeKind::Removed,
441                    before: Some((*before).clone()),
442                    after: None,
443                }),
444                (Some(before), Some(after)) if before != after => {
445                    entity_changes.push(DraftEntityChange {
446                        id,
447                        kind: DraftChangeKind::Changed,
448                        before: Some((*before).clone()),
449                        after: Some((*after).clone()),
450                    });
451                }
452                (Some(_), Some(_)) | (None, None) => {}
453            }
454        }
455
456        let before_relationships = self
457            .relationships
458            .iter()
459            .map(|relationship| (relationship_key(relationship), relationship))
460            .collect::<BTreeMap<_, _>>();
461        let after_relationships = next
462            .relationships
463            .iter()
464            .map(|relationship| (relationship_key(relationship), relationship))
465            .collect::<BTreeMap<_, _>>();
466        let relationship_keys = before_relationships
467            .keys()
468            .chain(after_relationships.keys())
469            .cloned()
470            .collect::<BTreeSet<_>>();
471        let mut relationship_changes = Vec::new();
472        for key in relationship_keys {
473            match (
474                before_relationships.get(&key),
475                after_relationships.get(&key),
476            ) {
477                (None, Some(relationship)) => {
478                    relationship_changes.push(DraftRelationshipChange {
479                        relationship: (*relationship).clone(),
480                        kind: DraftChangeKind::Added,
481                    });
482                }
483                (Some(relationship), None) => {
484                    relationship_changes.push(DraftRelationshipChange {
485                        relationship: (*relationship).clone(),
486                        kind: DraftChangeKind::Removed,
487                    });
488                }
489                (Some(_), Some(_)) | (None, None) => {}
490            }
491        }
492
493        Ok(GlassWebIrDiff {
494            schema_version: WEB_IR_DRAFT_SCHEMA_VERSION,
495            from_revision: self.revision,
496            to_revision: next.revision,
497            entity_changes,
498            relationship_changes,
499            coverage_changed: self.coverage != next.coverage,
500            limits_changed: self.limits != next.limits,
501            diagnostics_changed: self.diagnostics != next.diagnostics,
502            relationship_hint_diagnostics_changed: self.relationship_hint_diagnostics
503                != next.relationship_hint_diagnostics,
504        })
505    }
506
507    /// Classify one source entity against a later validated revision.
508    pub fn classify_entity_continuity(
509        &self,
510        next: &Self,
511        entity_id: &str,
512    ) -> Result<DraftEntityContinuity, WebIrValidationError> {
513        self.validate()?;
514        next.validate()?;
515        let requested_id = entity_id.to_owned();
516        let Some(source) = self.entities.iter().find(|entity| entity.id == entity_id) else {
517            return Ok(DraftEntityContinuity {
518                requested_id,
519                status: DraftEntityContinuityStatus::Removed,
520                current_id: None,
521                reason: "entity was not present in the source revision".into(),
522            });
523        };
524
525        if let Some(current) = next.entities.iter().find(|entity| entity.id == entity_id) {
526            let status = if source.semantic_identity_key() == current.semantic_identity_key() {
527                DraftEntityContinuityStatus::Unchanged
528            } else {
529                DraftEntityContinuityStatus::Changed
530            };
531            return Ok(DraftEntityContinuity {
532                requested_id,
533                status,
534                current_id: Some(current.id.clone()),
535                reason: match status {
536                    DraftEntityContinuityStatus::Unchanged => {
537                        "semantic identity remains compatible".into()
538                    }
539                    DraftEntityContinuityStatus::Changed => {
540                        "same revision-local ID has changed semantic identity".into()
541                    }
542                    _ => unreachable!("status is selected above"),
543                },
544            });
545        }
546
547        let Some(identity_key) = source.semantic_identity_key() else {
548            return Ok(DraftEntityContinuity {
549                requested_id,
550                status: DraftEntityContinuityStatus::Removed,
551                current_id: None,
552                reason: "entity has no semantic identity for bounded rebinding".into(),
553            });
554        };
555        let candidates = next
556            .entities
557            .iter()
558            .filter(|entity| entity.semantic_identity_key().as_deref() == Some(&identity_key))
559            .collect::<Vec<_>>();
560        match candidates.as_slice() {
561            [] => Ok(DraftEntityContinuity {
562                requested_id,
563                status: DraftEntityContinuityStatus::Removed,
564                current_id: None,
565                reason: "no compatible semantic identity was observed".into(),
566            }),
567            [candidate] => Ok(DraftEntityContinuity {
568                requested_id,
569                status: DraftEntityContinuityStatus::Rebound,
570                current_id: Some(candidate.id.clone()),
571                reason: "revision-local ID changed but semantic identity remained unique".into(),
572            }),
573            _ => Ok(DraftEntityContinuity {
574                requested_id,
575                status: DraftEntityContinuityStatus::Ambiguous,
576                current_id: None,
577                reason: "multiple compatible semantic identities were observed".into(),
578            }),
579        }
580    }
581
582    /// Serialize a validated draft deterministically.
583    pub fn to_canonical_json(&self) -> Result<String, WebIrValidationError> {
584        self.validate()?;
585        serde_json::to_string(self)
586            .map_err(|error| WebIrValidationError::new("$", error.to_string()))
587    }
588}
589
590/// Reconcile bounded extraction facts into a canonical draft graph.
591pub fn reconcile_evidence(
592    evidence: &ExtractionEvidence,
593) -> Result<GlassWebIrDraft, WebIrValidationError> {
594    evidence
595        .validate_relationship_hints()
596        .map_err(|error| WebIrValidationError::new(error.path, error.reason))?;
597    let mut relationship_hint_diagnostics = evidence
598        .facts
599        .iter()
600        .enumerate()
601        .filter_map(|(fact_index, fact)| {
602            Some(DraftRelationshipHintDiagnostic {
603                fact_index,
604                source: fact.source,
605                hint: fact.relationship_hint?,
606                parent_role: fact.parent_role.clone()?,
607                status: RelationshipHintDiagnosticStatus::Validated,
608            })
609        })
610        .collect::<Vec<_>>();
611    let mut facts = evidence
612        .facts
613        .iter()
614        .cloned()
615        .enumerate()
616        .collect::<Vec<_>>();
617    facts.sort_by_key(|(_, fact)| fact_sort_key(fact));
618
619    let mut entities = vec![DraftEntity {
620        id: "page".into(),
621        kind: DraftEntityKind::Page,
622        role: None,
623        name: None,
624        quality: EvidenceQuality::Confirmed,
625        evidence_sources: Vec::new(),
626    }];
627    let mut indexes: BTreeMap<String, Vec<usize>> = BTreeMap::new();
628    let mut suffixes: BTreeMap<String, usize> = BTreeMap::new();
629    let mut diagnostics = BTreeSet::new();
630
631    let mut parent_links = BTreeSet::new();
632    for (fact_index, fact) in facts {
633        let Some(kind) = canonical_kind(&fact) else {
634            if fact.relationship_hint.is_some()
635                && let Some(diagnostic) = relationship_hint_diagnostics
636                    .iter_mut()
637                    .find(|diagnostic| diagnostic.fact_index == fact_index)
638            {
639                diagnostic.status = RelationshipHintDiagnosticStatus::UnmatchedParent;
640            }
641            diagnostics.insert(format!("unsupportedFact:{}", fact.kind));
642            continue;
643        };
644        let key = canonical_key(kind, fact.role.as_deref(), fact.name.as_deref());
645        let existing = indexes.get(&key).and_then(|candidates| {
646            candidates
647                .iter()
648                .copied()
649                .find(|index| !entities[*index].evidence_sources.contains(&fact.source))
650        });
651        let entity_id = if let Some(index) = existing {
652            let entity = &mut entities[index];
653            entity.quality = stronger_quality(entity.quality, fact.quality);
654            entity.evidence_sources.push(fact.source);
655            entity.evidence_sources.sort();
656            entity.evidence_sources.dedup();
657            entity.id.clone()
658        } else {
659            let base_id = format!("entity_{}_{}", kind_name(kind), slug(fact.name.as_deref()));
660            let suffix = suffixes.entry(base_id.clone()).or_insert(0);
661            let id = if *suffix == 0 {
662                base_id.clone()
663            } else {
664                format!("{base_id}_{}", *suffix)
665            };
666            *suffix = suffix.saturating_add(1);
667            let index = entities.len();
668            entities.push(DraftEntity {
669                id: id.clone(),
670                kind,
671                role: fact.role,
672                name: fact.name,
673                quality: fact.quality,
674                evidence_sources: vec![fact.source],
675            });
676            indexes.entry(key).or_default().push(index);
677            id
678        };
679        if let Some(parent_role) = fact.parent_role {
680            parent_links.insert((
681                fact_index,
682                parent_role.to_ascii_lowercase(),
683                entity_id,
684                kind,
685                fact.relationship_hint,
686            ));
687        }
688    }
689
690    for index in 0..evidence.coverage.opaque_regions {
691        entities.push(DraftEntity {
692            id: format!("opaque_region_{index}"),
693            kind: DraftEntityKind::OpaqueRegion,
694            role: None,
695            name: None,
696            quality: EvidenceQuality::Opaque,
697            evidence_sources: Vec::new(),
698        });
699    }
700
701    let mut relationships = entities
702        .iter()
703        .filter(|entity| entity.kind != DraftEntityKind::Page)
704        .map(|entity| DraftRelationship {
705            from: "page".into(),
706            to: entity.id.clone(),
707            kind: DraftRelationshipKind::Contains,
708        })
709        .collect::<Vec<_>>();
710    for (fact_index, parent_role, child_id, child_kind, relationship_hint) in parent_links {
711        let Some(parent_kind) = parent_entity_kind(&parent_role) else {
712            if relationship_hint.is_some() {
713                set_hint_status(
714                    &mut relationship_hint_diagnostics,
715                    fact_index,
716                    RelationshipHintDiagnosticStatus::UnmatchedParent,
717                );
718            }
719            continue;
720        };
721        let Some(parent_id) = entities
722            .iter()
723            .find(|entity| {
724                entity.kind == parent_kind
725                    && entity
726                        .role
727                        .as_deref()
728                        .is_some_and(|role| role.eq_ignore_ascii_case(&parent_role))
729            })
730            .map(|entity| entity.id.clone())
731        else {
732            if relationship_hint.is_some() {
733                set_hint_status(
734                    &mut relationship_hint_diagnostics,
735                    fact_index,
736                    RelationshipHintDiagnosticStatus::UnmatchedParent,
737                );
738            }
739            continue;
740        };
741        if parent_id == child_id {
742            if relationship_hint.is_some() {
743                set_hint_status(
744                    &mut relationship_hint_diagnostics,
745                    fact_index,
746                    RelationshipHintDiagnosticStatus::UnmatchedParent,
747                );
748            }
749            continue;
750        }
751        relationships.push(DraftRelationship {
752            from: parent_id,
753            to: child_id,
754            kind: relationship_kind(parent_kind, child_kind, relationship_hint),
755        });
756        if relationship_hint.is_some() {
757            set_hint_status(
758                &mut relationship_hint_diagnostics,
759                fact_index,
760                RelationshipHintDiagnosticStatus::Emitted,
761            );
762        }
763    }
764    relationships.sort_by_key(|relationship| {
765        (
766            relationship.from.clone(),
767            relationship.to.clone(),
768            relationship.kind,
769        )
770    });
771    relationships.dedup();
772    let draft = GlassWebIrDraft {
773        schema_version: WEB_IR_DRAFT_SCHEMA_VERSION,
774        revision: evidence.revision,
775        entities,
776        relationships,
777        coverage: evidence.coverage.clone(),
778        limits: evidence.limits.clone(),
779        diagnostics: diagnostics.into_iter().collect(),
780        relationship_hint_diagnostics,
781    };
782    draft.validate()?;
783    Ok(draft)
784}
785
786/// A machine-readable draft Web IR validation failure.
787#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
788#[serde(rename_all = "camelCase")]
789pub struct WebIrValidationError {
790    pub path: String,
791    pub reason: String,
792}
793
794impl WebIrValidationError {
795    fn new(path: impl Into<String>, reason: impl Into<String>) -> Self {
796        Self {
797            path: path.into(),
798            reason: reason.into(),
799        }
800    }
801}
802
803impl Display for WebIrValidationError {
804    fn fmt(&self, formatter: &mut Formatter<'_>) -> std::fmt::Result {
805        write!(formatter, "{}: {}", self.path, self.reason)
806    }
807}
808
809impl std::error::Error for WebIrValidationError {}
810
811fn canonical_kind(fact: &EvidenceFact) -> Option<DraftEntityKind> {
812    let role = fact
813        .role
814        .as_deref()
815        .unwrap_or_default()
816        .to_ascii_lowercase();
817    if fact.source == EvidenceSource::Dom {
818        return match fact
819            .name
820            .as_deref()
821            .unwrap_or_default()
822            .to_ascii_uppercase()
823            .as_str()
824        {
825            "FORM" => Some(DraftEntityKind::Form),
826            "INPUT" | "TEXTAREA" | "SELECT" => Some(DraftEntityKind::Field),
827            "BUTTON" => Some(DraftEntityKind::Action),
828            "A" => Some(DraftEntityKind::Link),
829            "TABLE" => Some(DraftEntityKind::Table),
830            "TR" => Some(DraftEntityKind::Row),
831            "TD" | "TH" => Some(DraftEntityKind::Cell),
832            "DIALOG" => Some(DraftEntityKind::Dialog),
833            "NAV" => Some(DraftEntityKind::Region),
834            "ARTICLE" => Some(DraftEntityKind::CollectionItem),
835            _ => None,
836        };
837    }
838    match role.as_str() {
839        "form" => Some(DraftEntityKind::Form),
840        "navigation" | "main" | "search" | "complementary" | "article" | "toolbar" => {
841            Some(DraftEntityKind::Region)
842        }
843        "dialog" | "alertdialog" => Some(DraftEntityKind::Dialog),
844        "textbox" | "combobox" | "checkbox" | "radio" | "spinbutton" | "listbox" => {
845            Some(DraftEntityKind::Field)
846        }
847        "button" | "menuitem" | "tab" => Some(DraftEntityKind::Action),
848        "link" => Some(DraftEntityKind::Link),
849        "table" => Some(DraftEntityKind::Table),
850        "row" => Some(DraftEntityKind::Row),
851        "cell" | "gridcell" => Some(DraftEntityKind::Cell),
852        "list" => Some(DraftEntityKind::Collection),
853        "listitem" => Some(DraftEntityKind::CollectionItem),
854        "heading" | "text" => Some(DraftEntityKind::Text),
855        _ if fact.kind == "control" => Some(DraftEntityKind::UnknownInteractive),
856        _ => None,
857    }
858}
859
860fn canonical_key(kind: DraftEntityKind, role: Option<&str>, name: Option<&str>) -> String {
861    format!(
862        "{}|{}|{}",
863        kind_name(kind),
864        role.unwrap_or_default().to_ascii_lowercase(),
865        name.unwrap_or_default().to_ascii_lowercase()
866    )
867}
868
869fn relationship_key(relationship: &DraftRelationship) -> (String, String, DraftRelationshipKind) {
870    (
871        relationship.from.clone(),
872        relationship.to.clone(),
873        relationship.kind,
874    )
875}
876
877fn fact_sort_key(fact: &EvidenceFact) -> (EvidenceSource, String, String, String) {
878    (
879        fact.source,
880        fact.kind.clone(),
881        fact.role.clone().unwrap_or_default(),
882        fact.name.clone().unwrap_or_default(),
883    )
884}
885
886fn kind_name(kind: DraftEntityKind) -> &'static str {
887    match kind {
888        DraftEntityKind::Page => "page",
889        DraftEntityKind::Region => "region",
890        DraftEntityKind::Form => "form",
891        DraftEntityKind::Field => "field",
892        DraftEntityKind::Action => "action",
893        DraftEntityKind::Link => "link",
894        DraftEntityKind::NavigationItem => "navigationItem",
895        DraftEntityKind::Tab => "tab",
896        DraftEntityKind::Table => "table",
897        DraftEntityKind::Row => "row",
898        DraftEntityKind::Cell => "cell",
899        DraftEntityKind::Collection => "collection",
900        DraftEntityKind::CollectionItem => "collectionItem",
901        DraftEntityKind::Dialog => "dialog",
902        DraftEntityKind::PaginationControl => "paginationControl",
903        DraftEntityKind::Text => "text",
904        DraftEntityKind::UnknownInteractive => "unknownInteractive",
905        DraftEntityKind::OpaqueRegion => "opaqueRegion",
906    }
907}
908
909fn relationship_name(kind: DraftRelationshipKind) -> &'static str {
910    match kind {
911        DraftRelationshipKind::Contains => "contains",
912        DraftRelationshipKind::Labels => "labels",
913        DraftRelationshipKind::Owns => "owns",
914        DraftRelationshipKind::Controls => "controls",
915        DraftRelationshipKind::NavigatesTo => "navigatesTo",
916        DraftRelationshipKind::Opens => "opens",
917        DraftRelationshipKind::Confirms => "confirms",
918        DraftRelationshipKind::Cancels => "cancels",
919        DraftRelationshipKind::Continues => "continues",
920        DraftRelationshipKind::Submits => "submits",
921        DraftRelationshipKind::HeaderFor => "headerFor",
922        DraftRelationshipKind::CellOf => "cellOf",
923        DraftRelationshipKind::Selects => "selects",
924        DraftRelationshipKind::RepeatsAs => "repeatsAs",
925        DraftRelationshipKind::ScopedTo => "scopedTo",
926    }
927}
928
929fn hint_status_name(status: RelationshipHintDiagnosticStatus) -> &'static str {
930    match status {
931        RelationshipHintDiagnosticStatus::Validated => "validated",
932        RelationshipHintDiagnosticStatus::Emitted => "emitted",
933        RelationshipHintDiagnosticStatus::UnmatchedParent => "unmatchedParent",
934    }
935}
936
937fn set_hint_status(
938    diagnostics: &mut [DraftRelationshipHintDiagnostic],
939    fact_index: usize,
940    status: RelationshipHintDiagnosticStatus,
941) {
942    if let Some(diagnostic) = diagnostics
943        .iter_mut()
944        .find(|diagnostic| diagnostic.fact_index == fact_index)
945    {
946        diagnostic.status = status;
947    }
948}
949
950fn parent_entity_kind(role: &str) -> Option<DraftEntityKind> {
951    match role {
952        "form" => Some(DraftEntityKind::Form),
953        "dialog" | "alertdialog" => Some(DraftEntityKind::Dialog),
954        "article" | "complementary" | "main" | "navigation" | "region" | "search" | "toolbar" => {
955            Some(DraftEntityKind::Region)
956        }
957        _ => None,
958    }
959}
960
961fn relationship_kind(
962    parent_kind: DraftEntityKind,
963    child_kind: DraftEntityKind,
964    explicit_hint: Option<EvidenceRelationshipHint>,
965) -> DraftRelationshipKind {
966    if let Some(hint) = explicit_hint {
967        return match hint {
968            EvidenceRelationshipHint::Contains => DraftRelationshipKind::Contains,
969            EvidenceRelationshipHint::Labels => DraftRelationshipKind::Labels,
970            EvidenceRelationshipHint::Owns => DraftRelationshipKind::Owns,
971            EvidenceRelationshipHint::Controls => DraftRelationshipKind::Controls,
972            EvidenceRelationshipHint::NavigatesTo => DraftRelationshipKind::NavigatesTo,
973            EvidenceRelationshipHint::Opens => DraftRelationshipKind::Opens,
974            EvidenceRelationshipHint::Confirms => DraftRelationshipKind::Confirms,
975            EvidenceRelationshipHint::Cancels => DraftRelationshipKind::Cancels,
976            EvidenceRelationshipHint::Continues => DraftRelationshipKind::Continues,
977            EvidenceRelationshipHint::Submits => DraftRelationshipKind::Submits,
978            EvidenceRelationshipHint::HeaderFor => DraftRelationshipKind::HeaderFor,
979            EvidenceRelationshipHint::CellOf => DraftRelationshipKind::CellOf,
980            EvidenceRelationshipHint::Selects => DraftRelationshipKind::Selects,
981            EvidenceRelationshipHint::RepeatsAs => DraftRelationshipKind::RepeatsAs,
982            EvidenceRelationshipHint::ScopedTo => DraftRelationshipKind::ScopedTo,
983        };
984    }
985    if parent_kind == DraftEntityKind::Form && child_kind == DraftEntityKind::Field {
986        DraftRelationshipKind::Owns
987    } else {
988        DraftRelationshipKind::Contains
989    }
990}
991
992fn slug(value: Option<&str>) -> String {
993    let mut output = String::new();
994    for character in value.unwrap_or("entity").chars() {
995        if character.is_ascii_alphanumeric() {
996            output.push(character.to_ascii_lowercase());
997        } else if !output.ends_with('-') {
998            output.push('-');
999        }
1000        if output.len() >= 48 {
1001            break;
1002        }
1003    }
1004    let trimmed = output.trim_matches('-');
1005    if trimmed.is_empty() {
1006        "entity".into()
1007    } else {
1008        trimmed.into()
1009    }
1010}
1011
1012fn stronger_quality(left: EvidenceQuality, right: EvidenceQuality) -> EvidenceQuality {
1013    if quality_rank(right) > quality_rank(left) {
1014        right
1015    } else {
1016        left
1017    }
1018}
1019
1020fn quality_rank(quality: EvidenceQuality) -> u8 {
1021    match quality {
1022        EvidenceQuality::Opaque => 0,
1023        EvidenceQuality::Conflicted => 1,
1024        EvidenceQuality::Inferred => 2,
1025        EvidenceQuality::Partial => 3,
1026        EvidenceQuality::Strong => 4,
1027        EvidenceQuality::Confirmed => 5,
1028    }
1029}
1030
1031#[cfg(test)]
1032mod tests {
1033    use super::*;
1034    use crate::extraction::{EvidenceCoverage, ExtractionScope};
1035
1036    fn evidence() -> ExtractionEvidence {
1037        ExtractionEvidence {
1038            schema_version: 1,
1039            revision: 9,
1040            scope: ExtractionScope::Document,
1041            sources: vec![EvidenceSource::Accessibility, EvidenceSource::Forms],
1042            facts: vec![
1043                EvidenceFact {
1044                    source: EvidenceSource::Accessibility,
1045                    kind: "node".into(),
1046                    quality: EvidenceQuality::Confirmed,
1047                    role: Some("textbox".into()),
1048                    name: Some("Email".into()),
1049                    input_type: Some("email".into()),
1050                    required: None,
1051                    read_only: None,
1052                    empty: None,
1053                    geometry_present: None,
1054                    parent_role: None,
1055                    relationship_hint: None,
1056                },
1057                EvidenceFact {
1058                    source: EvidenceSource::Accessibility,
1059                    kind: "node".into(),
1060                    quality: EvidenceQuality::Confirmed,
1061                    role: Some("textbox".into()),
1062                    name: Some("Email".into()),
1063                    input_type: Some("email".into()),
1064                    required: None,
1065                    read_only: None,
1066                    empty: None,
1067                    geometry_present: None,
1068                    parent_role: None,
1069                    relationship_hint: None,
1070                },
1071                EvidenceFact {
1072                    source: EvidenceSource::Forms,
1073                    kind: "control".into(),
1074                    quality: EvidenceQuality::Strong,
1075                    role: Some("textbox".into()),
1076                    name: Some("Email".into()),
1077                    input_type: Some("email".into()),
1078                    required: Some(true),
1079                    read_only: Some(false),
1080                    empty: Some(true),
1081                    geometry_present: None,
1082                    parent_role: None,
1083                    relationship_hint: None,
1084                },
1085            ],
1086            coverage: EvidenceCoverage {
1087                structural: EvidenceQuality::Partial,
1088                semantic: EvidenceQuality::Strong,
1089                interactive_entities_observed: 2,
1090                opaque_regions: 0,
1091                reasons: Vec::new(),
1092            },
1093            limits: ExtractionEvidenceLimits {
1094                truncated: false,
1095                omitted_facts: 0,
1096                text_bytes: 15,
1097                missing_sources: Vec::new(),
1098            },
1099        }
1100    }
1101
1102    #[test]
1103    fn reconciliation_merges_cross_source_facts_but_preserves_duplicate_candidates() {
1104        let draft = reconcile_evidence(&evidence()).unwrap();
1105        assert_eq!(draft.revision, 9);
1106        assert_eq!(draft.entities.len(), 3);
1107        assert_eq!(draft.relationships.len(), 2);
1108        assert_eq!(
1109            draft.entities[1].evidence_sources,
1110            vec![EvidenceSource::Accessibility, EvidenceSource::Forms]
1111        );
1112        assert_eq!(draft.entities[1].quality, EvidenceQuality::Confirmed);
1113    }
1114
1115    #[test]
1116    fn reconciliation_links_children_to_observed_regions_only() {
1117        let mut evidence = evidence();
1118        evidence.facts.push(EvidenceFact {
1119            source: EvidenceSource::Accessibility,
1120            kind: "node".into(),
1121            quality: EvidenceQuality::Confirmed,
1122            role: Some("search".into()),
1123            name: Some("Site search".into()),
1124            input_type: None,
1125            required: None,
1126            read_only: None,
1127            empty: None,
1128            geometry_present: None,
1129            parent_role: None,
1130            relationship_hint: None,
1131        });
1132        evidence.facts[0].parent_role = Some("search".into());
1133        let draft = reconcile_evidence(&evidence).unwrap();
1134        let region_id = draft
1135            .entities
1136            .iter()
1137            .find(|entity| entity.kind == DraftEntityKind::Region)
1138            .map(|entity| entity.id.clone())
1139            .unwrap();
1140        assert!(draft.relationships.iter().any(|relationship| {
1141            relationship.from == region_id && relationship.kind == DraftRelationshipKind::Contains
1142        }));
1143    }
1144
1145    #[test]
1146    fn reconciliation_emits_form_ownership_for_observed_form_ancestry() {
1147        let mut evidence = evidence();
1148        evidence.facts.push(EvidenceFact {
1149            source: EvidenceSource::Accessibility,
1150            kind: "node".into(),
1151            quality: EvidenceQuality::Confirmed,
1152            role: Some("form".into()),
1153            name: Some("Account".into()),
1154            input_type: None,
1155            required: None,
1156            read_only: None,
1157            empty: None,
1158            geometry_present: None,
1159            parent_role: None,
1160            relationship_hint: None,
1161        });
1162        evidence.facts[0].parent_role = Some("form".into());
1163        let draft = reconcile_evidence(&evidence).unwrap();
1164        let form_id = draft
1165            .entities
1166            .iter()
1167            .find(|entity| entity.kind == DraftEntityKind::Form)
1168            .map(|entity| entity.id.clone())
1169            .unwrap();
1170        let field_ids = draft
1171            .entities
1172            .iter()
1173            .filter(|entity| entity.kind == DraftEntityKind::Field)
1174            .map(|entity| entity.id.as_str())
1175            .collect::<BTreeSet<_>>();
1176        assert!(draft.relationships.iter().any(|relationship| {
1177            relationship.from == form_id
1178                && field_ids.contains(relationship.to.as_str())
1179                && relationship.kind == DraftRelationshipKind::Owns
1180        }));
1181    }
1182
1183    #[test]
1184    fn reconciliation_honors_explicit_relationship_hints() {
1185        let mut evidence = evidence();
1186        evidence.facts.push(EvidenceFact {
1187            source: EvidenceSource::Accessibility,
1188            kind: "node".into(),
1189            quality: EvidenceQuality::Confirmed,
1190            role: Some("form".into()),
1191            name: Some("Account".into()),
1192            input_type: None,
1193            required: None,
1194            read_only: None,
1195            empty: None,
1196            geometry_present: None,
1197            parent_role: None,
1198            relationship_hint: None,
1199        });
1200        evidence.facts[0].parent_role = Some("form".into());
1201        evidence.facts[0].relationship_hint = Some(EvidenceRelationshipHint::Controls);
1202        let draft = reconcile_evidence(&evidence).unwrap();
1203        assert!(
1204            draft
1205                .relationships
1206                .iter()
1207                .any(|relationship| relationship.kind == DraftRelationshipKind::Controls)
1208        );
1209        assert_eq!(draft.relationship_hint_diagnostics.len(), 1);
1210        let diagnostic = &draft.relationship_hint_diagnostics[0];
1211        assert_eq!(diagnostic.fact_index, 0);
1212        assert_eq!(diagnostic.source, EvidenceSource::Accessibility);
1213        assert_eq!(diagnostic.hint, EvidenceRelationshipHint::Controls);
1214        assert_eq!(diagnostic.parent_role, "form");
1215        assert_eq!(diagnostic.status, RelationshipHintDiagnosticStatus::Emitted);
1216        let expected = BTreeMap::from([(RelationshipHintDiagnosticStatus::Emitted, 1)]);
1217        draft.validate_hint_diagnostics_against(&expected).unwrap();
1218    }
1219
1220    #[test]
1221    fn reconciliation_rejects_unbounded_or_unsupported_hints() {
1222        let mut missing_parent = evidence();
1223        missing_parent.facts[0].relationship_hint = Some(EvidenceRelationshipHint::Controls);
1224        let error = reconcile_evidence(&missing_parent).unwrap_err();
1225        assert_eq!(error.path, "facts[0].relationshipHint");
1226
1227        let mut wrong_source = evidence();
1228        wrong_source.facts[0].parent_role = Some("search".into());
1229        wrong_source.facts[0].relationship_hint = Some(EvidenceRelationshipHint::Controls);
1230        wrong_source.facts[0].source = EvidenceSource::Layout;
1231        let error = reconcile_evidence(&wrong_source).unwrap_err();
1232        assert_eq!(error.path, "facts[0].relationshipHint");
1233    }
1234
1235    #[test]
1236    fn reconciliation_marks_valid_unmatched_hints_without_emitting_edges() {
1237        let mut evidence = evidence();
1238        evidence.facts[0].parent_role = Some("dialog".into());
1239        evidence.facts[0].relationship_hint = Some(EvidenceRelationshipHint::Controls);
1240        let draft = reconcile_evidence(&evidence).unwrap();
1241        assert_eq!(
1242            draft.relationship_hint_diagnostics[0].status,
1243            RelationshipHintDiagnosticStatus::UnmatchedParent
1244        );
1245        assert!(
1246            !draft
1247                .relationships
1248                .iter()
1249                .any(|relationship| relationship.kind == DraftRelationshipKind::Controls)
1250        );
1251        let expected = BTreeMap::from([(RelationshipHintDiagnosticStatus::UnmatchedParent, 1)]);
1252        draft.validate_hint_diagnostics_against(&expected).unwrap();
1253        let missing = BTreeMap::from([(RelationshipHintDiagnosticStatus::Emitted, 1)]);
1254        let error = draft
1255            .validate_hint_diagnostics_against(&missing)
1256            .unwrap_err();
1257        assert_eq!(
1258            error.path,
1259            "expectation.relationshipHintDiagnostics.emitted"
1260        );
1261    }
1262
1263    #[test]
1264    fn reconciliation_materializes_opaque_coverage_without_provenance_claims() {
1265        let mut evidence = evidence();
1266        evidence.coverage.opaque_regions = 2;
1267        let draft = reconcile_evidence(&evidence).unwrap();
1268        let opaque = draft
1269            .entities
1270            .iter()
1271            .filter(|entity| entity.kind == DraftEntityKind::OpaqueRegion)
1272            .collect::<Vec<_>>();
1273        assert_eq!(opaque.len(), 2);
1274        assert!(opaque.iter().all(|entity| {
1275            entity.quality == EvidenceQuality::Opaque && entity.evidence_sources.is_empty()
1276        }));
1277        assert_eq!(draft.relationships.len(), 4);
1278        draft.validate().unwrap();
1279    }
1280
1281    #[test]
1282    fn fixture_expectation_validates_minimum_graph_shape() {
1283        let draft = reconcile_evidence(&evidence()).unwrap();
1284        let mut expectation = DraftFixtureExpectation::default();
1285        expectation
1286            .required_entity_counts
1287            .insert(DraftEntityKind::Page, 1);
1288        expectation
1289            .required_entity_counts
1290            .insert(DraftEntityKind::Field, 2);
1291        expectation
1292            .required_relationship_counts
1293            .insert(DraftRelationshipKind::Contains, 2);
1294        draft.validate_against(&expectation).unwrap();
1295
1296        expectation
1297            .required_entity_counts
1298            .insert(DraftEntityKind::Dialog, 1);
1299        let error = draft.validate_against(&expectation).unwrap_err();
1300        assert_eq!(error.path, "expectation.entities.dialog");
1301    }
1302
1303    #[test]
1304    fn fixture_corpus_vocabulary_matches_draft_contract() {
1305        let corpus: serde_json::Value =
1306            serde_json::from_str(include_str!("../tests/fixtures/web-ir/corpus-v1.json")).unwrap();
1307        let fixtures = corpus["fixtures"].as_array().unwrap();
1308        assert_eq!(fixtures.len(), 8);
1309        for fixture in fixtures {
1310            let entities = fixture["expectedEntities"].as_array().unwrap();
1311            let relationships = fixture["expectedRelationships"].as_array().unwrap();
1312            assert_eq!(
1313                entities.first().and_then(serde_json::Value::as_str),
1314                Some("page")
1315            );
1316            assert!(relationships.iter().any(|value| value == "contains"));
1317            for entity in entities {
1318                assert!(DraftEntityKind::from_contract_name(entity.as_str().unwrap()).is_some());
1319            }
1320            for relationship in relationships {
1321                assert!(
1322                    DraftRelationshipKind::from_contract_name(relationship.as_str().unwrap())
1323                        .is_some()
1324                );
1325            }
1326            let opaque_count = entities
1327                .iter()
1328                .filter(|entity| entity.as_str() == Some("opaqueRegion"))
1329                .count() as u64;
1330            assert_eq!(opaque_count, fixture["opaqueRegions"].as_u64().unwrap());
1331        }
1332    }
1333
1334    #[test]
1335    fn reconciliation_is_deterministic_and_does_not_emit_raw_values() {
1336        let draft = reconcile_evidence(&evidence()).unwrap();
1337        let first = draft.to_canonical_json().unwrap();
1338        let second = reconcile_evidence(&evidence())
1339            .unwrap()
1340            .to_canonical_json()
1341            .unwrap();
1342        assert_eq!(first, second);
1343        assert!(!first.contains("secret"));
1344    }
1345
1346    #[test]
1347    fn revision_diff_reports_deterministic_entity_relationship_and_metadata_changes() {
1348        let before = reconcile_evidence(&evidence()).unwrap();
1349        let mut after_evidence = evidence();
1350        after_evidence.revision = 10;
1351        after_evidence.coverage.structural = EvidenceQuality::Strong;
1352        after_evidence.facts[0].parent_role = Some("form".into());
1353
1354        let mut form_fact = after_evidence.facts[0].clone();
1355        form_fact.role = Some("form".into());
1356        form_fact.name = Some("Account".into());
1357        form_fact.parent_role = None;
1358        after_evidence.facts.push(form_fact);
1359
1360        let mut layout_fact = after_evidence.facts[0].clone();
1361        layout_fact.source = EvidenceSource::Layout;
1362        layout_fact.quality = EvidenceQuality::Strong;
1363        layout_fact.geometry_present = Some(true);
1364        after_evidence.facts.push(layout_fact);
1365
1366        let after = reconcile_evidence(&after_evidence).unwrap();
1367        let diff = before.diff(&after).unwrap();
1368        assert_eq!(diff.from_revision, 9);
1369        assert_eq!(diff.to_revision, 10);
1370        assert!(
1371            diff.entity_changes
1372                .iter()
1373                .any(|change| change.kind == DraftChangeKind::Added)
1374        );
1375        assert!(
1376            diff.entity_changes
1377                .iter()
1378                .any(|change| change.kind == DraftChangeKind::Changed)
1379        );
1380        assert!(diff.relationship_changes.iter().any(|change| {
1381            change.kind == DraftChangeKind::Added
1382                && change.relationship.kind == DraftRelationshipKind::Owns
1383        }));
1384        assert!(diff.coverage_changed);
1385        assert!(!diff.limits_changed);
1386        assert!(!diff.diagnostics_changed);
1387        assert!(!diff.relationship_hint_diagnostics_changed);
1388
1389        let reverse = after.diff(&before).unwrap();
1390        assert!(
1391            reverse
1392                .entity_changes
1393                .iter()
1394                .any(|change| change.kind == DraftChangeKind::Removed)
1395        );
1396        assert!(
1397            reverse
1398                .relationship_changes
1399                .iter()
1400                .any(|change| change.kind == DraftChangeKind::Removed)
1401        );
1402        assert_eq!(
1403            serde_json::to_string(&diff).unwrap(),
1404            serde_json::to_string(&before.diff(&after).unwrap()).unwrap()
1405        );
1406    }
1407
1408    #[test]
1409    fn continuity_classification_fails_closed_for_stale_and_ambiguous_targets() {
1410        let source = reconcile_evidence(&evidence()).unwrap();
1411        let target_id = source
1412            .entities
1413            .iter()
1414            .find(|entity| entity.kind == DraftEntityKind::Field)
1415            .map(|entity| entity.id.clone())
1416            .unwrap();
1417
1418        let unchanged = source
1419            .classify_entity_continuity(&source, &target_id)
1420            .unwrap();
1421        assert_eq!(unchanged.status, DraftEntityContinuityStatus::Unchanged);
1422
1423        let mut changed_draft = source.clone();
1424        changed_draft
1425            .entities
1426            .iter_mut()
1427            .find(|entity| entity.id == target_id)
1428            .unwrap()
1429            .name = Some("Different field".into());
1430        let changed = source
1431            .classify_entity_continuity(&changed_draft, &target_id)
1432            .unwrap();
1433        assert_eq!(changed.status, DraftEntityContinuityStatus::Changed);
1434
1435        let target_key = source
1436            .entities
1437            .iter()
1438            .find(|entity| entity.id == target_id)
1439            .and_then(DraftEntity::semantic_identity_key)
1440            .unwrap();
1441        let removed_ids = source
1442            .entities
1443            .iter()
1444            .filter(|entity| entity.semantic_identity_key().as_deref() == Some(target_key.as_str()))
1445            .map(|entity| entity.id.clone())
1446            .collect::<BTreeSet<_>>();
1447        let mut rebound_draft = source.clone();
1448        rebound_draft
1449            .entities
1450            .retain(|entity| !removed_ids.contains(&entity.id));
1451        rebound_draft.relationships.retain(|relationship| {
1452            !removed_ids.contains(&relationship.from) && !removed_ids.contains(&relationship.to)
1453        });
1454        let mut rebound_entity = source
1455            .entities
1456            .iter()
1457            .find(|entity| entity.semantic_identity_key().as_deref() == Some(target_key.as_str()))
1458            .unwrap()
1459            .clone();
1460        let rebound_id = "replacement-field".to_owned();
1461        rebound_entity.id = rebound_id.clone();
1462        rebound_draft.entities.push(rebound_entity.clone());
1463        let rebound = source
1464            .classify_entity_continuity(&rebound_draft, &target_id)
1465            .unwrap();
1466        assert_eq!(rebound.status, DraftEntityContinuityStatus::Rebound);
1467        assert_eq!(rebound.current_id.as_deref(), Some(rebound_id.as_str()));
1468
1469        let mut removed_draft = source.clone();
1470        removed_draft
1471            .entities
1472            .retain(|entity| !removed_ids.contains(&entity.id));
1473        removed_draft.relationships.retain(|relationship| {
1474            !removed_ids.contains(&relationship.from) && !removed_ids.contains(&relationship.to)
1475        });
1476        let removed = source
1477            .classify_entity_continuity(&removed_draft, &target_id)
1478            .unwrap();
1479        assert_eq!(removed.status, DraftEntityContinuityStatus::Removed);
1480
1481        let mut ambiguous_draft = removed_draft;
1482        let mut first = rebound_entity;
1483        first.id = "replacement-a".into();
1484        let mut second = first.clone();
1485        second.id = "replacement-b".into();
1486        ambiguous_draft.entities.extend([first, second]);
1487        let ambiguous = source
1488            .classify_entity_continuity(&ambiguous_draft, &target_id)
1489            .unwrap();
1490        assert_eq!(ambiguous.status, DraftEntityContinuityStatus::Ambiguous);
1491        assert!(ambiguous.current_id.is_none());
1492    }
1493
1494    #[test]
1495    fn validation_rejects_dangling_relationships() {
1496        let mut draft = reconcile_evidence(&evidence()).unwrap();
1497        draft.relationships[0].to = "missing".into();
1498        let error = draft.validate().unwrap_err();
1499        assert_eq!(error.path, "relationships");
1500    }
1501}