1use 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
15pub const WEB_IR_DRAFT_SCHEMA_VERSION: u32 = 1;
17const MAX_DRAFT_ENTITIES: usize = 4_096;
18const MAX_DRAFT_RELATIONSHIPS: usize = 8_192;
19
20#[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#[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#[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#[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#[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#[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#[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#[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#[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#[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 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#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
179pub enum RelationshipHintDiagnosticStatus {
180 Validated,
181 Emitted,
182 UnmatchedParent,
183}
184
185#[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#[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#[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 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 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 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 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 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 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 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 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
590pub 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#[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}