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 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 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 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 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
611pub 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#[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}