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    /// Validate that `next` is a compatible revision for transition analysis.
410    ///
411    /// Forward revisions are accepted. An exact same-revision draft is also
412    /// accepted for deterministic self-comparisons; same-revision content
413    /// drift and revision regressions fail closed.
414    pub fn validate_revision_transition(&self, next: &Self) -> Result<(), WebIrValidationError> {
415        self.validate()?;
416        next.validate()?;
417        if next.revision < self.revision {
418            return Err(WebIrValidationError::new(
419                "revision",
420                "target revision is older than the source revision",
421            ));
422        }
423        if next.revision == self.revision && self != next {
424            return Err(WebIrValidationError::new(
425                "revision",
426                "same-revision drafts must have identical content",
427            ));
428        }
429        Ok(())
430    }
431
432    /// Compute deterministic changes between two validated draft revisions.
433    pub fn diff(&self, next: &Self) -> Result<GlassWebIrDiff, WebIrValidationError> {
434        self.validate_revision_transition(next)?;
435
436        let before_entities = self
437            .entities
438            .iter()
439            .map(|entity| (entity.id.clone(), entity))
440            .collect::<BTreeMap<_, _>>();
441        let after_entities = next
442            .entities
443            .iter()
444            .map(|entity| (entity.id.clone(), entity))
445            .collect::<BTreeMap<_, _>>();
446        let entity_ids = before_entities
447            .keys()
448            .chain(after_entities.keys())
449            .cloned()
450            .collect::<BTreeSet<_>>();
451        let mut entity_changes = Vec::new();
452        for id in entity_ids {
453            match (before_entities.get(&id), after_entities.get(&id)) {
454                (None, Some(after)) => entity_changes.push(DraftEntityChange {
455                    id,
456                    kind: DraftChangeKind::Added,
457                    before: None,
458                    after: Some((*after).clone()),
459                }),
460                (Some(before), None) => entity_changes.push(DraftEntityChange {
461                    id,
462                    kind: DraftChangeKind::Removed,
463                    before: Some((*before).clone()),
464                    after: None,
465                }),
466                (Some(before), Some(after)) if before != after => {
467                    entity_changes.push(DraftEntityChange {
468                        id,
469                        kind: DraftChangeKind::Changed,
470                        before: Some((*before).clone()),
471                        after: Some((*after).clone()),
472                    });
473                }
474                (Some(_), Some(_)) | (None, None) => {}
475            }
476        }
477
478        let before_relationships = self
479            .relationships
480            .iter()
481            .map(|relationship| (relationship_key(relationship), relationship))
482            .collect::<BTreeMap<_, _>>();
483        let after_relationships = next
484            .relationships
485            .iter()
486            .map(|relationship| (relationship_key(relationship), relationship))
487            .collect::<BTreeMap<_, _>>();
488        let relationship_keys = before_relationships
489            .keys()
490            .chain(after_relationships.keys())
491            .cloned()
492            .collect::<BTreeSet<_>>();
493        let mut relationship_changes = Vec::new();
494        for key in relationship_keys {
495            match (
496                before_relationships.get(&key),
497                after_relationships.get(&key),
498            ) {
499                (None, Some(relationship)) => {
500                    relationship_changes.push(DraftRelationshipChange {
501                        relationship: (*relationship).clone(),
502                        kind: DraftChangeKind::Added,
503                    });
504                }
505                (Some(relationship), None) => {
506                    relationship_changes.push(DraftRelationshipChange {
507                        relationship: (*relationship).clone(),
508                        kind: DraftChangeKind::Removed,
509                    });
510                }
511                (Some(_), Some(_)) | (None, None) => {}
512            }
513        }
514
515        Ok(GlassWebIrDiff {
516            schema_version: WEB_IR_DRAFT_SCHEMA_VERSION,
517            from_revision: self.revision,
518            to_revision: next.revision,
519            entity_changes,
520            relationship_changes,
521            coverage_changed: self.coverage != next.coverage,
522            limits_changed: self.limits != next.limits,
523            diagnostics_changed: self.diagnostics != next.diagnostics,
524            relationship_hint_diagnostics_changed: self.relationship_hint_diagnostics
525                != next.relationship_hint_diagnostics,
526        })
527    }
528
529    /// Classify one source entity against a later validated revision.
530    pub fn classify_entity_continuity(
531        &self,
532        next: &Self,
533        entity_id: &str,
534    ) -> Result<DraftEntityContinuity, WebIrValidationError> {
535        self.validate_revision_transition(next)?;
536        let requested_id = entity_id.to_owned();
537        let Some(source) = self.entities.iter().find(|entity| entity.id == entity_id) else {
538            return Ok(DraftEntityContinuity {
539                requested_id,
540                status: DraftEntityContinuityStatus::Removed,
541                current_id: None,
542                reason: "entity was not present in the source revision".into(),
543            });
544        };
545
546        if let Some(current) = next.entities.iter().find(|entity| entity.id == entity_id) {
547            let status = if source.semantic_identity_key() == current.semantic_identity_key() {
548                DraftEntityContinuityStatus::Unchanged
549            } else {
550                DraftEntityContinuityStatus::Changed
551            };
552            return Ok(DraftEntityContinuity {
553                requested_id,
554                status,
555                current_id: Some(current.id.clone()),
556                reason: match status {
557                    DraftEntityContinuityStatus::Unchanged => {
558                        "semantic identity remains compatible".into()
559                    }
560                    DraftEntityContinuityStatus::Changed => {
561                        "same revision-local ID has changed semantic identity".into()
562                    }
563                    _ => unreachable!("status is selected above"),
564                },
565            });
566        }
567
568        let Some(identity_key) = source.semantic_identity_key() else {
569            return Ok(DraftEntityContinuity {
570                requested_id,
571                status: DraftEntityContinuityStatus::Removed,
572                current_id: None,
573                reason: "entity has no semantic identity for bounded rebinding".into(),
574            });
575        };
576        let candidates = next
577            .entities
578            .iter()
579            .filter(|entity| entity.semantic_identity_key().as_deref() == Some(&identity_key))
580            .collect::<Vec<_>>();
581        match candidates.as_slice() {
582            [] => Ok(DraftEntityContinuity {
583                requested_id,
584                status: DraftEntityContinuityStatus::Removed,
585                current_id: None,
586                reason: "no compatible semantic identity was observed".into(),
587            }),
588            [candidate] => Ok(DraftEntityContinuity {
589                requested_id,
590                status: DraftEntityContinuityStatus::Rebound,
591                current_id: Some(candidate.id.clone()),
592                reason: "revision-local ID changed but semantic identity remained unique".into(),
593            }),
594            _ => Ok(DraftEntityContinuity {
595                requested_id,
596                status: DraftEntityContinuityStatus::Ambiguous,
597                current_id: None,
598                reason: "multiple compatible semantic identities were observed".into(),
599            }),
600        }
601    }
602
603    /// Serialize a validated draft deterministically.
604    pub fn to_canonical_json(&self) -> Result<String, WebIrValidationError> {
605        self.validate()?;
606        serde_json::to_string(self)
607            .map_err(|error| WebIrValidationError::new("$", error.to_string()))
608    }
609}
610
611/// Reconcile bounded extraction facts into a canonical draft graph.
612pub fn reconcile_evidence(
613    evidence: &ExtractionEvidence,
614) -> Result<GlassWebIrDraft, WebIrValidationError> {
615    evidence
616        .validate_relationship_hints()
617        .map_err(|error| WebIrValidationError::new(error.path, error.reason))?;
618    let mut relationship_hint_diagnostics = evidence
619        .facts
620        .iter()
621        .enumerate()
622        .filter_map(|(fact_index, fact)| {
623            Some(DraftRelationshipHintDiagnostic {
624                fact_index,
625                source: fact.source,
626                hint: fact.relationship_hint?,
627                parent_role: fact.parent_role.clone()?,
628                status: RelationshipHintDiagnosticStatus::Validated,
629            })
630        })
631        .collect::<Vec<_>>();
632    let mut facts = evidence
633        .facts
634        .iter()
635        .cloned()
636        .enumerate()
637        .collect::<Vec<_>>();
638    facts.sort_by_key(|(_, fact)| fact_sort_key(fact));
639
640    let mut entities = vec![DraftEntity {
641        id: "page".into(),
642        kind: DraftEntityKind::Page,
643        role: None,
644        name: None,
645        quality: EvidenceQuality::Confirmed,
646        evidence_sources: Vec::new(),
647    }];
648    let mut indexes: BTreeMap<String, Vec<usize>> = BTreeMap::new();
649    let mut suffixes: BTreeMap<String, usize> = BTreeMap::new();
650    let mut diagnostics = BTreeSet::new();
651
652    let mut parent_links = BTreeSet::new();
653    for (fact_index, fact) in facts {
654        let Some(kind) = canonical_kind(&fact) else {
655            if fact.relationship_hint.is_some()
656                && let Some(diagnostic) = relationship_hint_diagnostics
657                    .iter_mut()
658                    .find(|diagnostic| diagnostic.fact_index == fact_index)
659            {
660                diagnostic.status = RelationshipHintDiagnosticStatus::UnmatchedParent;
661            }
662            diagnostics.insert(format!("unsupportedFact:{}", fact.kind));
663            continue;
664        };
665        let key = canonical_key(kind, fact.role.as_deref(), fact.name.as_deref());
666        let existing = indexes.get(&key).and_then(|candidates| {
667            candidates
668                .iter()
669                .copied()
670                .find(|index| !entities[*index].evidence_sources.contains(&fact.source))
671        });
672        let entity_id = if let Some(index) = existing {
673            let entity = &mut entities[index];
674            entity.quality = stronger_quality(entity.quality, fact.quality);
675            entity.evidence_sources.push(fact.source);
676            entity.evidence_sources.sort();
677            entity.evidence_sources.dedup();
678            entity.id.clone()
679        } else {
680            let base_id = format!("entity_{}_{}", kind_name(kind), slug(fact.name.as_deref()));
681            let suffix = suffixes.entry(base_id.clone()).or_insert(0);
682            let id = if *suffix == 0 {
683                base_id.clone()
684            } else {
685                format!("{base_id}_{}", *suffix)
686            };
687            *suffix = suffix.saturating_add(1);
688            let index = entities.len();
689            entities.push(DraftEntity {
690                id: id.clone(),
691                kind,
692                role: fact.role,
693                name: fact.name,
694                quality: fact.quality,
695                evidence_sources: vec![fact.source],
696            });
697            indexes.entry(key).or_default().push(index);
698            id
699        };
700        if let Some(parent_role) = fact.parent_role {
701            parent_links.insert((
702                fact_index,
703                parent_role.to_ascii_lowercase(),
704                entity_id,
705                kind,
706                fact.relationship_hint,
707            ));
708        }
709    }
710
711    for index in 0..evidence.coverage.opaque_regions {
712        entities.push(DraftEntity {
713            id: format!("opaque_region_{index}"),
714            kind: DraftEntityKind::OpaqueRegion,
715            role: None,
716            name: None,
717            quality: EvidenceQuality::Opaque,
718            evidence_sources: Vec::new(),
719        });
720    }
721
722    let mut relationships = entities
723        .iter()
724        .filter(|entity| entity.kind != DraftEntityKind::Page)
725        .map(|entity| DraftRelationship {
726            from: "page".into(),
727            to: entity.id.clone(),
728            kind: DraftRelationshipKind::Contains,
729        })
730        .collect::<Vec<_>>();
731    for (fact_index, parent_role, child_id, child_kind, relationship_hint) in parent_links {
732        let Some(parent_kind) = parent_entity_kind(&parent_role) else {
733            if relationship_hint.is_some() {
734                set_hint_status(
735                    &mut relationship_hint_diagnostics,
736                    fact_index,
737                    RelationshipHintDiagnosticStatus::UnmatchedParent,
738                );
739            }
740            continue;
741        };
742        let Some(parent_id) = entities
743            .iter()
744            .find(|entity| {
745                entity.kind == parent_kind
746                    && entity
747                        .role
748                        .as_deref()
749                        .is_some_and(|role| role.eq_ignore_ascii_case(&parent_role))
750            })
751            .map(|entity| entity.id.clone())
752        else {
753            if relationship_hint.is_some() {
754                set_hint_status(
755                    &mut relationship_hint_diagnostics,
756                    fact_index,
757                    RelationshipHintDiagnosticStatus::UnmatchedParent,
758                );
759            }
760            continue;
761        };
762        if parent_id == child_id {
763            if relationship_hint.is_some() {
764                set_hint_status(
765                    &mut relationship_hint_diagnostics,
766                    fact_index,
767                    RelationshipHintDiagnosticStatus::UnmatchedParent,
768                );
769            }
770            continue;
771        }
772        relationships.push(DraftRelationship {
773            from: parent_id,
774            to: child_id,
775            kind: relationship_kind(parent_kind, child_kind, relationship_hint),
776        });
777        if relationship_hint.is_some() {
778            set_hint_status(
779                &mut relationship_hint_diagnostics,
780                fact_index,
781                RelationshipHintDiagnosticStatus::Emitted,
782            );
783        }
784    }
785    relationships.sort_by_key(|relationship| {
786        (
787            relationship.from.clone(),
788            relationship.to.clone(),
789            relationship.kind,
790        )
791    });
792    relationships.dedup();
793    let draft = GlassWebIrDraft {
794        schema_version: WEB_IR_DRAFT_SCHEMA_VERSION,
795        revision: evidence.revision,
796        entities,
797        relationships,
798        coverage: evidence.coverage.clone(),
799        limits: evidence.limits.clone(),
800        diagnostics: diagnostics.into_iter().collect(),
801        relationship_hint_diagnostics,
802    };
803    draft.validate()?;
804    Ok(draft)
805}
806
807/// A machine-readable draft Web IR validation failure.
808#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
809#[serde(rename_all = "camelCase")]
810pub struct WebIrValidationError {
811    pub path: String,
812    pub reason: String,
813}
814
815impl WebIrValidationError {
816    fn new(path: impl Into<String>, reason: impl Into<String>) -> Self {
817        Self {
818            path: path.into(),
819            reason: reason.into(),
820        }
821    }
822}
823
824impl Display for WebIrValidationError {
825    fn fmt(&self, formatter: &mut Formatter<'_>) -> std::fmt::Result {
826        write!(formatter, "{}: {}", self.path, self.reason)
827    }
828}
829
830impl std::error::Error for WebIrValidationError {}
831
832fn canonical_kind(fact: &EvidenceFact) -> Option<DraftEntityKind> {
833    let role = fact
834        .role
835        .as_deref()
836        .unwrap_or_default()
837        .to_ascii_lowercase();
838    if fact.source == EvidenceSource::Dom {
839        return match fact
840            .name
841            .as_deref()
842            .unwrap_or_default()
843            .to_ascii_uppercase()
844            .as_str()
845        {
846            "FORM" => Some(DraftEntityKind::Form),
847            "INPUT" | "TEXTAREA" | "SELECT" => Some(DraftEntityKind::Field),
848            "BUTTON" => Some(DraftEntityKind::Action),
849            "A" => Some(DraftEntityKind::Link),
850            "TABLE" => Some(DraftEntityKind::Table),
851            "TR" => Some(DraftEntityKind::Row),
852            "TD" | "TH" => Some(DraftEntityKind::Cell),
853            "DIALOG" => Some(DraftEntityKind::Dialog),
854            "NAV" => Some(DraftEntityKind::Region),
855            "ARTICLE" => Some(DraftEntityKind::CollectionItem),
856            _ => None,
857        };
858    }
859    match role.as_str() {
860        "form" => Some(DraftEntityKind::Form),
861        "navigation" | "main" | "search" | "complementary" | "article" | "toolbar" => {
862            Some(DraftEntityKind::Region)
863        }
864        "dialog" | "alertdialog" => Some(DraftEntityKind::Dialog),
865        "textbox" | "combobox" | "checkbox" | "radio" | "spinbutton" | "listbox" => {
866            Some(DraftEntityKind::Field)
867        }
868        "button" | "menuitem" | "tab" => Some(DraftEntityKind::Action),
869        "link" => Some(DraftEntityKind::Link),
870        "table" => Some(DraftEntityKind::Table),
871        "row" => Some(DraftEntityKind::Row),
872        "cell" | "gridcell" => Some(DraftEntityKind::Cell),
873        "list" => Some(DraftEntityKind::Collection),
874        "listitem" => Some(DraftEntityKind::CollectionItem),
875        "heading" | "text" => Some(DraftEntityKind::Text),
876        _ if fact.kind == "control" => Some(DraftEntityKind::UnknownInteractive),
877        _ => None,
878    }
879}
880
881fn canonical_key(kind: DraftEntityKind, role: Option<&str>, name: Option<&str>) -> String {
882    format!(
883        "{}|{}|{}",
884        kind_name(kind),
885        role.unwrap_or_default().to_ascii_lowercase(),
886        name.unwrap_or_default().to_ascii_lowercase()
887    )
888}
889
890fn relationship_key(relationship: &DraftRelationship) -> (String, String, DraftRelationshipKind) {
891    (
892        relationship.from.clone(),
893        relationship.to.clone(),
894        relationship.kind,
895    )
896}
897
898fn fact_sort_key(fact: &EvidenceFact) -> (EvidenceSource, String, String, String) {
899    (
900        fact.source,
901        fact.kind.clone(),
902        fact.role.clone().unwrap_or_default(),
903        fact.name.clone().unwrap_or_default(),
904    )
905}
906
907fn kind_name(kind: DraftEntityKind) -> &'static str {
908    match kind {
909        DraftEntityKind::Page => "page",
910        DraftEntityKind::Region => "region",
911        DraftEntityKind::Form => "form",
912        DraftEntityKind::Field => "field",
913        DraftEntityKind::Action => "action",
914        DraftEntityKind::Link => "link",
915        DraftEntityKind::NavigationItem => "navigationItem",
916        DraftEntityKind::Tab => "tab",
917        DraftEntityKind::Table => "table",
918        DraftEntityKind::Row => "row",
919        DraftEntityKind::Cell => "cell",
920        DraftEntityKind::Collection => "collection",
921        DraftEntityKind::CollectionItem => "collectionItem",
922        DraftEntityKind::Dialog => "dialog",
923        DraftEntityKind::PaginationControl => "paginationControl",
924        DraftEntityKind::Text => "text",
925        DraftEntityKind::UnknownInteractive => "unknownInteractive",
926        DraftEntityKind::OpaqueRegion => "opaqueRegion",
927    }
928}
929
930fn relationship_name(kind: DraftRelationshipKind) -> &'static str {
931    match kind {
932        DraftRelationshipKind::Contains => "contains",
933        DraftRelationshipKind::Labels => "labels",
934        DraftRelationshipKind::Owns => "owns",
935        DraftRelationshipKind::Controls => "controls",
936        DraftRelationshipKind::NavigatesTo => "navigatesTo",
937        DraftRelationshipKind::Opens => "opens",
938        DraftRelationshipKind::Confirms => "confirms",
939        DraftRelationshipKind::Cancels => "cancels",
940        DraftRelationshipKind::Continues => "continues",
941        DraftRelationshipKind::Submits => "submits",
942        DraftRelationshipKind::HeaderFor => "headerFor",
943        DraftRelationshipKind::CellOf => "cellOf",
944        DraftRelationshipKind::Selects => "selects",
945        DraftRelationshipKind::RepeatsAs => "repeatsAs",
946        DraftRelationshipKind::ScopedTo => "scopedTo",
947    }
948}
949
950fn hint_status_name(status: RelationshipHintDiagnosticStatus) -> &'static str {
951    match status {
952        RelationshipHintDiagnosticStatus::Validated => "validated",
953        RelationshipHintDiagnosticStatus::Emitted => "emitted",
954        RelationshipHintDiagnosticStatus::UnmatchedParent => "unmatchedParent",
955    }
956}
957
958fn set_hint_status(
959    diagnostics: &mut [DraftRelationshipHintDiagnostic],
960    fact_index: usize,
961    status: RelationshipHintDiagnosticStatus,
962) {
963    if let Some(diagnostic) = diagnostics
964        .iter_mut()
965        .find(|diagnostic| diagnostic.fact_index == fact_index)
966    {
967        diagnostic.status = status;
968    }
969}
970
971fn parent_entity_kind(role: &str) -> Option<DraftEntityKind> {
972    match role {
973        "form" => Some(DraftEntityKind::Form),
974        "dialog" | "alertdialog" => Some(DraftEntityKind::Dialog),
975        "article" | "complementary" | "main" | "navigation" | "region" | "search" | "toolbar" => {
976            Some(DraftEntityKind::Region)
977        }
978        _ => None,
979    }
980}
981
982fn relationship_kind(
983    parent_kind: DraftEntityKind,
984    child_kind: DraftEntityKind,
985    explicit_hint: Option<EvidenceRelationshipHint>,
986) -> DraftRelationshipKind {
987    if let Some(hint) = explicit_hint {
988        return match hint {
989            EvidenceRelationshipHint::Contains => DraftRelationshipKind::Contains,
990            EvidenceRelationshipHint::Labels => DraftRelationshipKind::Labels,
991            EvidenceRelationshipHint::Owns => DraftRelationshipKind::Owns,
992            EvidenceRelationshipHint::Controls => DraftRelationshipKind::Controls,
993            EvidenceRelationshipHint::NavigatesTo => DraftRelationshipKind::NavigatesTo,
994            EvidenceRelationshipHint::Opens => DraftRelationshipKind::Opens,
995            EvidenceRelationshipHint::Confirms => DraftRelationshipKind::Confirms,
996            EvidenceRelationshipHint::Cancels => DraftRelationshipKind::Cancels,
997            EvidenceRelationshipHint::Continues => DraftRelationshipKind::Continues,
998            EvidenceRelationshipHint::Submits => DraftRelationshipKind::Submits,
999            EvidenceRelationshipHint::HeaderFor => DraftRelationshipKind::HeaderFor,
1000            EvidenceRelationshipHint::CellOf => DraftRelationshipKind::CellOf,
1001            EvidenceRelationshipHint::Selects => DraftRelationshipKind::Selects,
1002            EvidenceRelationshipHint::RepeatsAs => DraftRelationshipKind::RepeatsAs,
1003            EvidenceRelationshipHint::ScopedTo => DraftRelationshipKind::ScopedTo,
1004        };
1005    }
1006    if parent_kind == DraftEntityKind::Form && child_kind == DraftEntityKind::Field {
1007        DraftRelationshipKind::Owns
1008    } else {
1009        DraftRelationshipKind::Contains
1010    }
1011}
1012
1013fn slug(value: Option<&str>) -> String {
1014    let mut output = String::new();
1015    for character in value.unwrap_or("entity").chars() {
1016        if character.is_ascii_alphanumeric() {
1017            output.push(character.to_ascii_lowercase());
1018        } else if !output.ends_with('-') {
1019            output.push('-');
1020        }
1021        if output.len() >= 48 {
1022            break;
1023        }
1024    }
1025    let trimmed = output.trim_matches('-');
1026    if trimmed.is_empty() {
1027        "entity".into()
1028    } else {
1029        trimmed.into()
1030    }
1031}
1032
1033fn stronger_quality(left: EvidenceQuality, right: EvidenceQuality) -> EvidenceQuality {
1034    if quality_rank(right) > quality_rank(left) {
1035        right
1036    } else {
1037        left
1038    }
1039}
1040
1041fn quality_rank(quality: EvidenceQuality) -> u8 {
1042    match quality {
1043        EvidenceQuality::Opaque => 0,
1044        EvidenceQuality::Conflicted => 1,
1045        EvidenceQuality::Inferred => 2,
1046        EvidenceQuality::Partial => 3,
1047        EvidenceQuality::Strong => 4,
1048        EvidenceQuality::Confirmed => 5,
1049    }
1050}
1051
1052#[cfg(test)]
1053mod tests {
1054    use super::*;
1055    use crate::extraction::{EvidenceCoverage, ExtractionScope};
1056
1057    fn evidence() -> ExtractionEvidence {
1058        ExtractionEvidence {
1059            schema_version: 1,
1060            revision: 9,
1061            scope: ExtractionScope::Document,
1062            sources: vec![EvidenceSource::Accessibility, EvidenceSource::Forms],
1063            facts: vec![
1064                EvidenceFact {
1065                    source: EvidenceSource::Accessibility,
1066                    kind: "node".into(),
1067                    quality: EvidenceQuality::Confirmed,
1068                    role: Some("textbox".into()),
1069                    name: Some("Email".into()),
1070                    input_type: Some("email".into()),
1071                    required: None,
1072                    read_only: None,
1073                    empty: None,
1074                    geometry_present: None,
1075                    parent_role: None,
1076                    relationship_hint: None,
1077                },
1078                EvidenceFact {
1079                    source: EvidenceSource::Accessibility,
1080                    kind: "node".into(),
1081                    quality: EvidenceQuality::Confirmed,
1082                    role: Some("textbox".into()),
1083                    name: Some("Email".into()),
1084                    input_type: Some("email".into()),
1085                    required: None,
1086                    read_only: None,
1087                    empty: None,
1088                    geometry_present: None,
1089                    parent_role: None,
1090                    relationship_hint: None,
1091                },
1092                EvidenceFact {
1093                    source: EvidenceSource::Forms,
1094                    kind: "control".into(),
1095                    quality: EvidenceQuality::Strong,
1096                    role: Some("textbox".into()),
1097                    name: Some("Email".into()),
1098                    input_type: Some("email".into()),
1099                    required: Some(true),
1100                    read_only: Some(false),
1101                    empty: Some(true),
1102                    geometry_present: None,
1103                    parent_role: None,
1104                    relationship_hint: None,
1105                },
1106            ],
1107            coverage: EvidenceCoverage {
1108                structural: EvidenceQuality::Partial,
1109                semantic: EvidenceQuality::Strong,
1110                interactive_entities_observed: 2,
1111                opaque_regions: 0,
1112                reasons: Vec::new(),
1113            },
1114            limits: ExtractionEvidenceLimits {
1115                truncated: false,
1116                omitted_facts: 0,
1117                text_bytes: 15,
1118                missing_sources: Vec::new(),
1119            },
1120        }
1121    }
1122
1123    #[test]
1124    fn reconciliation_merges_cross_source_facts_but_preserves_duplicate_candidates() {
1125        let draft = reconcile_evidence(&evidence()).unwrap();
1126        assert_eq!(draft.revision, 9);
1127        assert_eq!(draft.entities.len(), 3);
1128        assert_eq!(draft.relationships.len(), 2);
1129        assert_eq!(
1130            draft.entities[1].evidence_sources,
1131            vec![EvidenceSource::Accessibility, EvidenceSource::Forms]
1132        );
1133        assert_eq!(draft.entities[1].quality, EvidenceQuality::Confirmed);
1134    }
1135
1136    #[test]
1137    fn reconciliation_links_children_to_observed_regions_only() {
1138        let mut evidence = evidence();
1139        evidence.facts.push(EvidenceFact {
1140            source: EvidenceSource::Accessibility,
1141            kind: "node".into(),
1142            quality: EvidenceQuality::Confirmed,
1143            role: Some("search".into()),
1144            name: Some("Site search".into()),
1145            input_type: None,
1146            required: None,
1147            read_only: None,
1148            empty: None,
1149            geometry_present: None,
1150            parent_role: None,
1151            relationship_hint: None,
1152        });
1153        evidence.facts[0].parent_role = Some("search".into());
1154        let draft = reconcile_evidence(&evidence).unwrap();
1155        let region_id = draft
1156            .entities
1157            .iter()
1158            .find(|entity| entity.kind == DraftEntityKind::Region)
1159            .map(|entity| entity.id.clone())
1160            .unwrap();
1161        assert!(draft.relationships.iter().any(|relationship| {
1162            relationship.from == region_id && relationship.kind == DraftRelationshipKind::Contains
1163        }));
1164    }
1165
1166    #[test]
1167    fn reconciliation_emits_form_ownership_for_observed_form_ancestry() {
1168        let mut evidence = evidence();
1169        evidence.facts.push(EvidenceFact {
1170            source: EvidenceSource::Accessibility,
1171            kind: "node".into(),
1172            quality: EvidenceQuality::Confirmed,
1173            role: Some("form".into()),
1174            name: Some("Account".into()),
1175            input_type: None,
1176            required: None,
1177            read_only: None,
1178            empty: None,
1179            geometry_present: None,
1180            parent_role: None,
1181            relationship_hint: None,
1182        });
1183        evidence.facts[0].parent_role = Some("form".into());
1184        let draft = reconcile_evidence(&evidence).unwrap();
1185        let form_id = draft
1186            .entities
1187            .iter()
1188            .find(|entity| entity.kind == DraftEntityKind::Form)
1189            .map(|entity| entity.id.clone())
1190            .unwrap();
1191        let field_ids = draft
1192            .entities
1193            .iter()
1194            .filter(|entity| entity.kind == DraftEntityKind::Field)
1195            .map(|entity| entity.id.as_str())
1196            .collect::<BTreeSet<_>>();
1197        assert!(draft.relationships.iter().any(|relationship| {
1198            relationship.from == form_id
1199                && field_ids.contains(relationship.to.as_str())
1200                && relationship.kind == DraftRelationshipKind::Owns
1201        }));
1202    }
1203
1204    #[test]
1205    fn reconciliation_honors_explicit_relationship_hints() {
1206        let mut evidence = evidence();
1207        evidence.facts.push(EvidenceFact {
1208            source: EvidenceSource::Accessibility,
1209            kind: "node".into(),
1210            quality: EvidenceQuality::Confirmed,
1211            role: Some("form".into()),
1212            name: Some("Account".into()),
1213            input_type: None,
1214            required: None,
1215            read_only: None,
1216            empty: None,
1217            geometry_present: None,
1218            parent_role: None,
1219            relationship_hint: None,
1220        });
1221        evidence.facts[0].parent_role = Some("form".into());
1222        evidence.facts[0].relationship_hint = Some(EvidenceRelationshipHint::Controls);
1223        let draft = reconcile_evidence(&evidence).unwrap();
1224        assert!(
1225            draft
1226                .relationships
1227                .iter()
1228                .any(|relationship| relationship.kind == DraftRelationshipKind::Controls)
1229        );
1230        assert_eq!(draft.relationship_hint_diagnostics.len(), 1);
1231        let diagnostic = &draft.relationship_hint_diagnostics[0];
1232        assert_eq!(diagnostic.fact_index, 0);
1233        assert_eq!(diagnostic.source, EvidenceSource::Accessibility);
1234        assert_eq!(diagnostic.hint, EvidenceRelationshipHint::Controls);
1235        assert_eq!(diagnostic.parent_role, "form");
1236        assert_eq!(diagnostic.status, RelationshipHintDiagnosticStatus::Emitted);
1237        let expected = BTreeMap::from([(RelationshipHintDiagnosticStatus::Emitted, 1)]);
1238        draft.validate_hint_diagnostics_against(&expected).unwrap();
1239    }
1240
1241    #[test]
1242    fn reconciliation_rejects_unbounded_or_unsupported_hints() {
1243        let mut missing_parent = evidence();
1244        missing_parent.facts[0].relationship_hint = Some(EvidenceRelationshipHint::Controls);
1245        let error = reconcile_evidence(&missing_parent).unwrap_err();
1246        assert_eq!(error.path, "facts[0].relationshipHint");
1247
1248        let mut wrong_source = evidence();
1249        wrong_source.facts[0].parent_role = Some("search".into());
1250        wrong_source.facts[0].relationship_hint = Some(EvidenceRelationshipHint::Controls);
1251        wrong_source.facts[0].source = EvidenceSource::Layout;
1252        let error = reconcile_evidence(&wrong_source).unwrap_err();
1253        assert_eq!(error.path, "facts[0].relationshipHint");
1254    }
1255
1256    #[test]
1257    fn reconciliation_marks_valid_unmatched_hints_without_emitting_edges() {
1258        let mut evidence = evidence();
1259        evidence.facts[0].parent_role = Some("dialog".into());
1260        evidence.facts[0].relationship_hint = Some(EvidenceRelationshipHint::Controls);
1261        let draft = reconcile_evidence(&evidence).unwrap();
1262        assert_eq!(
1263            draft.relationship_hint_diagnostics[0].status,
1264            RelationshipHintDiagnosticStatus::UnmatchedParent
1265        );
1266        assert!(
1267            !draft
1268                .relationships
1269                .iter()
1270                .any(|relationship| relationship.kind == DraftRelationshipKind::Controls)
1271        );
1272        let expected = BTreeMap::from([(RelationshipHintDiagnosticStatus::UnmatchedParent, 1)]);
1273        draft.validate_hint_diagnostics_against(&expected).unwrap();
1274        let missing = BTreeMap::from([(RelationshipHintDiagnosticStatus::Emitted, 1)]);
1275        let error = draft
1276            .validate_hint_diagnostics_against(&missing)
1277            .unwrap_err();
1278        assert_eq!(
1279            error.path,
1280            "expectation.relationshipHintDiagnostics.emitted"
1281        );
1282    }
1283
1284    #[test]
1285    fn reconciliation_materializes_opaque_coverage_without_provenance_claims() {
1286        let mut evidence = evidence();
1287        evidence.coverage.opaque_regions = 2;
1288        let draft = reconcile_evidence(&evidence).unwrap();
1289        let opaque = draft
1290            .entities
1291            .iter()
1292            .filter(|entity| entity.kind == DraftEntityKind::OpaqueRegion)
1293            .collect::<Vec<_>>();
1294        assert_eq!(opaque.len(), 2);
1295        assert!(opaque.iter().all(|entity| {
1296            entity.quality == EvidenceQuality::Opaque && entity.evidence_sources.is_empty()
1297        }));
1298        assert_eq!(draft.relationships.len(), 4);
1299        draft.validate().unwrap();
1300    }
1301
1302    #[test]
1303    fn fixture_expectation_validates_minimum_graph_shape() {
1304        let draft = reconcile_evidence(&evidence()).unwrap();
1305        let mut expectation = DraftFixtureExpectation::default();
1306        expectation
1307            .required_entity_counts
1308            .insert(DraftEntityKind::Page, 1);
1309        expectation
1310            .required_entity_counts
1311            .insert(DraftEntityKind::Field, 2);
1312        expectation
1313            .required_relationship_counts
1314            .insert(DraftRelationshipKind::Contains, 2);
1315        draft.validate_against(&expectation).unwrap();
1316
1317        expectation
1318            .required_entity_counts
1319            .insert(DraftEntityKind::Dialog, 1);
1320        let error = draft.validate_against(&expectation).unwrap_err();
1321        assert_eq!(error.path, "expectation.entities.dialog");
1322    }
1323
1324    #[test]
1325    fn fixture_corpus_vocabulary_matches_draft_contract() {
1326        let corpus: serde_json::Value =
1327            serde_json::from_str(include_str!("../tests/fixtures/web-ir/corpus-v1.json")).unwrap();
1328        let fixtures = corpus["fixtures"].as_array().unwrap();
1329        assert_eq!(fixtures.len(), 8);
1330        for fixture in fixtures {
1331            let entities = fixture["expectedEntities"].as_array().unwrap();
1332            let relationships = fixture["expectedRelationships"].as_array().unwrap();
1333            assert_eq!(
1334                entities.first().and_then(serde_json::Value::as_str),
1335                Some("page")
1336            );
1337            assert!(relationships.iter().any(|value| value == "contains"));
1338            for entity in entities {
1339                assert!(DraftEntityKind::from_contract_name(entity.as_str().unwrap()).is_some());
1340            }
1341            for relationship in relationships {
1342                assert!(
1343                    DraftRelationshipKind::from_contract_name(relationship.as_str().unwrap())
1344                        .is_some()
1345                );
1346            }
1347            let opaque_count = entities
1348                .iter()
1349                .filter(|entity| entity.as_str() == Some("opaqueRegion"))
1350                .count() as u64;
1351            assert_eq!(opaque_count, fixture["opaqueRegions"].as_u64().unwrap());
1352        }
1353    }
1354
1355    #[test]
1356    fn reconciliation_is_deterministic_and_does_not_emit_raw_values() {
1357        let draft = reconcile_evidence(&evidence()).unwrap();
1358        let first = draft.to_canonical_json().unwrap();
1359        let second = reconcile_evidence(&evidence())
1360            .unwrap()
1361            .to_canonical_json()
1362            .unwrap();
1363        assert_eq!(first, second);
1364        assert!(!first.contains("secret"));
1365    }
1366
1367    #[test]
1368    fn revision_diff_reports_deterministic_entity_relationship_and_metadata_changes() {
1369        let before = reconcile_evidence(&evidence()).unwrap();
1370        let mut after_evidence = evidence();
1371        after_evidence.revision = 10;
1372        after_evidence.coverage.structural = EvidenceQuality::Strong;
1373        after_evidence.facts[0].parent_role = Some("form".into());
1374
1375        let mut form_fact = after_evidence.facts[0].clone();
1376        form_fact.role = Some("form".into());
1377        form_fact.name = Some("Account".into());
1378        form_fact.parent_role = None;
1379        after_evidence.facts.push(form_fact);
1380
1381        let mut layout_fact = after_evidence.facts[0].clone();
1382        layout_fact.source = EvidenceSource::Layout;
1383        layout_fact.quality = EvidenceQuality::Strong;
1384        layout_fact.geometry_present = Some(true);
1385        after_evidence.facts.push(layout_fact);
1386
1387        let after = reconcile_evidence(&after_evidence).unwrap();
1388        let diff = before.diff(&after).unwrap();
1389        assert_eq!(diff.from_revision, 9);
1390        assert_eq!(diff.to_revision, 10);
1391        assert!(
1392            diff.entity_changes
1393                .iter()
1394                .any(|change| change.kind == DraftChangeKind::Added)
1395        );
1396        assert!(
1397            diff.entity_changes
1398                .iter()
1399                .any(|change| change.kind == DraftChangeKind::Changed)
1400        );
1401        assert!(diff.relationship_changes.iter().any(|change| {
1402            change.kind == DraftChangeKind::Added
1403                && change.relationship.kind == DraftRelationshipKind::Owns
1404        }));
1405        assert!(diff.coverage_changed);
1406        assert!(!diff.limits_changed);
1407        assert!(!diff.diagnostics_changed);
1408        assert!(!diff.relationship_hint_diagnostics_changed);
1409
1410        let reverse_error = after.diff(&before).unwrap_err();
1411        assert_eq!(reverse_error.path, "revision");
1412        assert_eq!(
1413            reverse_error.reason,
1414            "target revision is older than the source revision"
1415        );
1416        assert_eq!(
1417            serde_json::to_string(&diff).unwrap(),
1418            serde_json::to_string(&before.diff(&after).unwrap()).unwrap()
1419        );
1420    }
1421
1422    #[test]
1423    fn continuity_classification_fails_closed_for_stale_and_ambiguous_targets() {
1424        let source = reconcile_evidence(&evidence()).unwrap();
1425        let target_id = source
1426            .entities
1427            .iter()
1428            .find(|entity| entity.kind == DraftEntityKind::Field)
1429            .map(|entity| entity.id.clone())
1430            .unwrap();
1431
1432        let unchanged = source
1433            .classify_entity_continuity(&source, &target_id)
1434            .unwrap();
1435        assert_eq!(unchanged.status, DraftEntityContinuityStatus::Unchanged);
1436
1437        let mut changed_draft = source.clone();
1438        changed_draft.revision = 10;
1439        changed_draft
1440            .entities
1441            .iter_mut()
1442            .find(|entity| entity.id == target_id)
1443            .unwrap()
1444            .name = Some("Different field".into());
1445        let changed = source
1446            .classify_entity_continuity(&changed_draft, &target_id)
1447            .unwrap();
1448        assert_eq!(changed.status, DraftEntityContinuityStatus::Changed);
1449
1450        let target_key = source
1451            .entities
1452            .iter()
1453            .find(|entity| entity.id == target_id)
1454            .and_then(DraftEntity::semantic_identity_key)
1455            .unwrap();
1456        let removed_ids = source
1457            .entities
1458            .iter()
1459            .filter(|entity| entity.semantic_identity_key().as_deref() == Some(target_key.as_str()))
1460            .map(|entity| entity.id.clone())
1461            .collect::<BTreeSet<_>>();
1462        let mut rebound_draft = source.clone();
1463        rebound_draft.revision = 10;
1464        rebound_draft
1465            .entities
1466            .retain(|entity| !removed_ids.contains(&entity.id));
1467        rebound_draft.relationships.retain(|relationship| {
1468            !removed_ids.contains(&relationship.from) && !removed_ids.contains(&relationship.to)
1469        });
1470        let mut rebound_entity = source
1471            .entities
1472            .iter()
1473            .find(|entity| entity.semantic_identity_key().as_deref() == Some(target_key.as_str()))
1474            .unwrap()
1475            .clone();
1476        let rebound_id = "replacement-field".to_owned();
1477        rebound_entity.id = rebound_id.clone();
1478        rebound_draft.entities.push(rebound_entity.clone());
1479        let rebound = source
1480            .classify_entity_continuity(&rebound_draft, &target_id)
1481            .unwrap();
1482        assert_eq!(rebound.status, DraftEntityContinuityStatus::Rebound);
1483        assert_eq!(rebound.current_id.as_deref(), Some(rebound_id.as_str()));
1484
1485        let mut removed_draft = source.clone();
1486        removed_draft.revision = 10;
1487        removed_draft
1488            .entities
1489            .retain(|entity| !removed_ids.contains(&entity.id));
1490        removed_draft.relationships.retain(|relationship| {
1491            !removed_ids.contains(&relationship.from) && !removed_ids.contains(&relationship.to)
1492        });
1493        let removed = source
1494            .classify_entity_continuity(&removed_draft, &target_id)
1495            .unwrap();
1496        assert_eq!(removed.status, DraftEntityContinuityStatus::Removed);
1497
1498        let mut ambiguous_draft = removed_draft;
1499        let mut first = rebound_entity;
1500        first.id = "replacement-a".into();
1501        let mut second = first.clone();
1502        second.id = "replacement-b".into();
1503        ambiguous_draft.entities.extend([first, second]);
1504        let ambiguous = source
1505            .classify_entity_continuity(&ambiguous_draft, &target_id)
1506            .unwrap();
1507        assert_eq!(ambiguous.status, DraftEntityContinuityStatus::Ambiguous);
1508        assert!(ambiguous.current_id.is_none());
1509    }
1510
1511    #[test]
1512    fn same_revision_content_drift_is_rejected() {
1513        let source = reconcile_evidence(&evidence()).unwrap();
1514        let mut drifted = source.clone();
1515        drifted
1516            .entities
1517            .iter_mut()
1518            .find(|entity| entity.kind == DraftEntityKind::Field)
1519            .unwrap()
1520            .name = Some("Changed at the same revision".into());
1521
1522        let error = source.validate_revision_transition(&drifted).unwrap_err();
1523        assert_eq!(error.path, "revision");
1524        assert_eq!(
1525            error.reason,
1526            "same-revision drafts must have identical content"
1527        );
1528    }
1529
1530    #[test]
1531    fn validation_rejects_dangling_relationships() {
1532        let mut draft = reconcile_evidence(&evidence()).unwrap();
1533        draft.relationships[0].to = "missing".into();
1534        let error = draft.validate().unwrap_err();
1535        assert_eq!(error.path, "relationships");
1536    }
1537}