use crate::extraction::{
EvidenceFact, EvidenceQuality, EvidenceRelationshipHint, EvidenceSource, ExtractionEvidence,
ExtractionEvidenceLimits,
};
use serde::{Deserialize, Serialize};
use std::collections::{BTreeMap, BTreeSet};
use std::fmt::{Display, Formatter};
pub const WEB_IR_DRAFT_SCHEMA_VERSION: u32 = 1;
const MAX_DRAFT_ENTITIES: usize = 4_096;
const MAX_DRAFT_RELATIONSHIPS: usize = 8_192;
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub enum DraftEntityKind {
Page,
Region,
Form,
Field,
Action,
Link,
NavigationItem,
Tab,
Table,
Row,
Cell,
Collection,
CollectionItem,
Dialog,
PaginationControl,
Text,
UnknownInteractive,
OpaqueRegion,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub enum DraftRelationshipKind {
Contains,
Labels,
Owns,
Controls,
NavigatesTo,
Opens,
Confirms,
Cancels,
Continues,
Submits,
HeaderFor,
CellOf,
Selects,
RepeatsAs,
ScopedTo,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct DraftEntity {
pub id: String,
pub kind: DraftEntityKind,
#[serde(skip_serializing_if = "Option::is_none")]
pub role: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub name: Option<String>,
pub quality: EvidenceQuality,
pub evidence_sources: Vec<EvidenceSource>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct DraftRelationship {
pub from: String,
pub to: String,
pub kind: DraftRelationshipKind,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub enum DraftChangeKind {
Added,
Removed,
Changed,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct DraftEntityChange {
pub id: String,
pub kind: DraftChangeKind,
#[serde(skip_serializing_if = "Option::is_none")]
pub before: Option<DraftEntity>,
#[serde(skip_serializing_if = "Option::is_none")]
pub after: Option<DraftEntity>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct DraftRelationshipChange {
pub relationship: DraftRelationship,
pub kind: DraftChangeKind,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct GlassWebIrDiff {
pub schema_version: u32,
pub from_revision: u64,
pub to_revision: u64,
#[serde(skip_serializing_if = "Vec::is_empty")]
pub entity_changes: Vec<DraftEntityChange>,
#[serde(skip_serializing_if = "Vec::is_empty")]
pub relationship_changes: Vec<DraftRelationshipChange>,
pub coverage_changed: bool,
pub limits_changed: bool,
pub diagnostics_changed: bool,
pub relationship_hint_diagnostics_changed: bool,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub enum DraftEntityContinuityStatus {
Unchanged,
Changed,
Rebound,
Removed,
Ambiguous,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct DraftEntityContinuity {
pub requested_id: String,
pub status: DraftEntityContinuityStatus,
#[serde(skip_serializing_if = "Option::is_none")]
pub current_id: Option<String>,
pub reason: String,
}
impl DraftEntity {
pub fn semantic_identity_key(&self) -> Option<String> {
if self.role.is_none() && self.name.is_none() {
return None;
}
Some(format!(
"{}|{}|{}",
kind_name(self.kind),
self.role
.as_deref()
.unwrap_or_default()
.to_ascii_lowercase(),
self.name
.as_deref()
.unwrap_or_default()
.to_ascii_lowercase()
))
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
pub enum RelationshipHintDiagnosticStatus {
Validated,
Emitted,
UnmatchedParent,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct DraftRelationshipHintDiagnostic {
pub fact_index: usize,
pub source: EvidenceSource,
pub hint: EvidenceRelationshipHint,
pub parent_role: String,
pub status: RelationshipHintDiagnosticStatus,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct GlassWebIrDraft {
pub schema_version: u32,
pub revision: u64,
pub entities: Vec<DraftEntity>,
pub relationships: Vec<DraftRelationship>,
pub coverage: crate::extraction::EvidenceCoverage,
pub limits: ExtractionEvidenceLimits,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub diagnostics: Vec<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub relationship_hint_diagnostics: Vec<DraftRelationshipHintDiagnostic>,
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct DraftFixtureExpectation {
pub required_entity_counts: BTreeMap<DraftEntityKind, u32>,
pub required_relationship_counts: BTreeMap<DraftRelationshipKind, u32>,
pub opaque_regions: u32,
}
impl DraftEntityKind {
pub fn from_contract_name(name: &str) -> Option<Self> {
Some(match name {
"page" => Self::Page,
"region" => Self::Region,
"form" => Self::Form,
"field" => Self::Field,
"action" => Self::Action,
"link" => Self::Link,
"navigationItem" => Self::NavigationItem,
"tab" => Self::Tab,
"table" => Self::Table,
"row" => Self::Row,
"cell" => Self::Cell,
"collection" => Self::Collection,
"collectionItem" => Self::CollectionItem,
"dialog" => Self::Dialog,
"paginationControl" => Self::PaginationControl,
"text" => Self::Text,
"unknownInteractive" => Self::UnknownInteractive,
"opaqueRegion" => Self::OpaqueRegion,
_ => return None,
})
}
}
impl DraftRelationshipKind {
pub fn from_contract_name(name: &str) -> Option<Self> {
Some(match name {
"contains" => Self::Contains,
"labels" => Self::Labels,
"owns" => Self::Owns,
"controls" => Self::Controls,
"navigatesTo" => Self::NavigatesTo,
"opens" => Self::Opens,
"confirms" => Self::Confirms,
"cancels" => Self::Cancels,
"continues" => Self::Continues,
"submits" => Self::Submits,
"headerFor" => Self::HeaderFor,
"cellOf" => Self::CellOf,
"selects" => Self::Selects,
"repeatsAs" => Self::RepeatsAs,
"scopedTo" => Self::ScopedTo,
_ => return None,
})
}
}
impl GlassWebIrDraft {
pub fn validate(&self) -> Result<(), WebIrValidationError> {
if self.schema_version != WEB_IR_DRAFT_SCHEMA_VERSION {
return Err(WebIrValidationError::new(
"schemaVersion",
"unsupported draft Web IR schema version",
));
}
if self.entities.is_empty() || self.entities.len() > MAX_DRAFT_ENTITIES {
return Err(WebIrValidationError::new(
"entities",
"entity count must be between 1 and the draft bound",
));
}
let page_count = self
.entities
.iter()
.filter(|entity| entity.kind == DraftEntityKind::Page)
.count();
if page_count != 1 {
return Err(WebIrValidationError::new(
"entities",
"drafts must contain exactly one page entity",
));
}
let mut ids = BTreeSet::new();
for entity in &self.entities {
if entity.id.is_empty() || entity.id.len() > 128 || !ids.insert(entity.id.as_str()) {
return Err(WebIrValidationError::new(
"entities.id",
"entity IDs must be unique and bounded",
));
}
if entity.evidence_sources.is_empty()
&& entity.kind != DraftEntityKind::Page
&& entity.kind != DraftEntityKind::OpaqueRegion
{
return Err(WebIrValidationError::new(
"entities.evidenceSources",
"non-page, non-opaque entities require source provenance",
));
}
}
if self.relationships.len() > MAX_DRAFT_RELATIONSHIPS {
return Err(WebIrValidationError::new(
"relationships",
"relationship count exceeds the draft bound",
));
}
for relationship in &self.relationships {
if relationship.from == relationship.to
|| !ids.contains(relationship.from.as_str())
|| !ids.contains(relationship.to.as_str())
{
return Err(WebIrValidationError::new(
"relationships",
"relationships must reference two distinct known entities",
));
}
}
Ok(())
}
pub fn validate_against(
&self,
expectation: &DraftFixtureExpectation,
) -> Result<(), WebIrValidationError> {
self.validate()?;
for (kind, minimum) in &expectation.required_entity_counts {
let actual = self
.entities
.iter()
.filter(|entity| entity.kind == *kind)
.count() as u32;
if actual < *minimum {
return Err(WebIrValidationError::new(
format!("expectation.entities.{}", kind_name(*kind)),
format!("expected at least {minimum}, observed {actual}"),
));
}
}
let opaque_regions = self
.entities
.iter()
.filter(|entity| entity.kind == DraftEntityKind::OpaqueRegion)
.count() as u32;
if opaque_regions != expectation.opaque_regions {
return Err(WebIrValidationError::new(
"expectation.opaqueRegions",
format!(
"expected {expected}, observed {opaque_regions}",
expected = expectation.opaque_regions
),
));
}
for (kind, minimum) in &expectation.required_relationship_counts {
let actual = self
.relationships
.iter()
.filter(|relationship| relationship.kind == *kind)
.count() as u32;
if actual < *minimum {
return Err(WebIrValidationError::new(
format!("expectation.relationships.{}", relationship_name(*kind)),
format!("expected at least {minimum}, observed {actual}"),
));
}
}
Ok(())
}
pub fn validate_hint_diagnostics_against(
&self,
expected_status_counts: &BTreeMap<RelationshipHintDiagnosticStatus, u32>,
) -> Result<(), WebIrValidationError> {
self.validate()?;
for (status, minimum) in expected_status_counts {
let actual = self
.relationship_hint_diagnostics
.iter()
.filter(|diagnostic| diagnostic.status == *status)
.count() as u32;
if actual < *minimum {
return Err(WebIrValidationError::new(
format!(
"expectation.relationshipHintDiagnostics.{}",
hint_status_name(*status)
),
format!("expected at least {minimum}, observed {actual}"),
));
}
}
Ok(())
}
pub fn diff(&self, next: &Self) -> Result<GlassWebIrDiff, WebIrValidationError> {
self.validate()?;
next.validate()?;
let before_entities = self
.entities
.iter()
.map(|entity| (entity.id.clone(), entity))
.collect::<BTreeMap<_, _>>();
let after_entities = next
.entities
.iter()
.map(|entity| (entity.id.clone(), entity))
.collect::<BTreeMap<_, _>>();
let entity_ids = before_entities
.keys()
.chain(after_entities.keys())
.cloned()
.collect::<BTreeSet<_>>();
let mut entity_changes = Vec::new();
for id in entity_ids {
match (before_entities.get(&id), after_entities.get(&id)) {
(None, Some(after)) => entity_changes.push(DraftEntityChange {
id,
kind: DraftChangeKind::Added,
before: None,
after: Some((*after).clone()),
}),
(Some(before), None) => entity_changes.push(DraftEntityChange {
id,
kind: DraftChangeKind::Removed,
before: Some((*before).clone()),
after: None,
}),
(Some(before), Some(after)) if before != after => {
entity_changes.push(DraftEntityChange {
id,
kind: DraftChangeKind::Changed,
before: Some((*before).clone()),
after: Some((*after).clone()),
});
}
(Some(_), Some(_)) | (None, None) => {}
}
}
let before_relationships = self
.relationships
.iter()
.map(|relationship| (relationship_key(relationship), relationship))
.collect::<BTreeMap<_, _>>();
let after_relationships = next
.relationships
.iter()
.map(|relationship| (relationship_key(relationship), relationship))
.collect::<BTreeMap<_, _>>();
let relationship_keys = before_relationships
.keys()
.chain(after_relationships.keys())
.cloned()
.collect::<BTreeSet<_>>();
let mut relationship_changes = Vec::new();
for key in relationship_keys {
match (
before_relationships.get(&key),
after_relationships.get(&key),
) {
(None, Some(relationship)) => {
relationship_changes.push(DraftRelationshipChange {
relationship: (*relationship).clone(),
kind: DraftChangeKind::Added,
});
}
(Some(relationship), None) => {
relationship_changes.push(DraftRelationshipChange {
relationship: (*relationship).clone(),
kind: DraftChangeKind::Removed,
});
}
(Some(_), Some(_)) | (None, None) => {}
}
}
Ok(GlassWebIrDiff {
schema_version: WEB_IR_DRAFT_SCHEMA_VERSION,
from_revision: self.revision,
to_revision: next.revision,
entity_changes,
relationship_changes,
coverage_changed: self.coverage != next.coverage,
limits_changed: self.limits != next.limits,
diagnostics_changed: self.diagnostics != next.diagnostics,
relationship_hint_diagnostics_changed: self.relationship_hint_diagnostics
!= next.relationship_hint_diagnostics,
})
}
pub fn classify_entity_continuity(
&self,
next: &Self,
entity_id: &str,
) -> Result<DraftEntityContinuity, WebIrValidationError> {
self.validate()?;
next.validate()?;
let requested_id = entity_id.to_owned();
let Some(source) = self.entities.iter().find(|entity| entity.id == entity_id) else {
return Ok(DraftEntityContinuity {
requested_id,
status: DraftEntityContinuityStatus::Removed,
current_id: None,
reason: "entity was not present in the source revision".into(),
});
};
if let Some(current) = next.entities.iter().find(|entity| entity.id == entity_id) {
let status = if source.semantic_identity_key() == current.semantic_identity_key() {
DraftEntityContinuityStatus::Unchanged
} else {
DraftEntityContinuityStatus::Changed
};
return Ok(DraftEntityContinuity {
requested_id,
status,
current_id: Some(current.id.clone()),
reason: match status {
DraftEntityContinuityStatus::Unchanged => {
"semantic identity remains compatible".into()
}
DraftEntityContinuityStatus::Changed => {
"same revision-local ID has changed semantic identity".into()
}
_ => unreachable!("status is selected above"),
},
});
}
let Some(identity_key) = source.semantic_identity_key() else {
return Ok(DraftEntityContinuity {
requested_id,
status: DraftEntityContinuityStatus::Removed,
current_id: None,
reason: "entity has no semantic identity for bounded rebinding".into(),
});
};
let candidates = next
.entities
.iter()
.filter(|entity| entity.semantic_identity_key().as_deref() == Some(&identity_key))
.collect::<Vec<_>>();
match candidates.as_slice() {
[] => Ok(DraftEntityContinuity {
requested_id,
status: DraftEntityContinuityStatus::Removed,
current_id: None,
reason: "no compatible semantic identity was observed".into(),
}),
[candidate] => Ok(DraftEntityContinuity {
requested_id,
status: DraftEntityContinuityStatus::Rebound,
current_id: Some(candidate.id.clone()),
reason: "revision-local ID changed but semantic identity remained unique".into(),
}),
_ => Ok(DraftEntityContinuity {
requested_id,
status: DraftEntityContinuityStatus::Ambiguous,
current_id: None,
reason: "multiple compatible semantic identities were observed".into(),
}),
}
}
pub fn to_canonical_json(&self) -> Result<String, WebIrValidationError> {
self.validate()?;
serde_json::to_string(self)
.map_err(|error| WebIrValidationError::new("$", error.to_string()))
}
}
pub fn reconcile_evidence(
evidence: &ExtractionEvidence,
) -> Result<GlassWebIrDraft, WebIrValidationError> {
evidence
.validate_relationship_hints()
.map_err(|error| WebIrValidationError::new(error.path, error.reason))?;
let mut relationship_hint_diagnostics = evidence
.facts
.iter()
.enumerate()
.filter_map(|(fact_index, fact)| {
Some(DraftRelationshipHintDiagnostic {
fact_index,
source: fact.source,
hint: fact.relationship_hint?,
parent_role: fact.parent_role.clone()?,
status: RelationshipHintDiagnosticStatus::Validated,
})
})
.collect::<Vec<_>>();
let mut facts = evidence
.facts
.iter()
.cloned()
.enumerate()
.collect::<Vec<_>>();
facts.sort_by_key(|(_, fact)| fact_sort_key(fact));
let mut entities = vec![DraftEntity {
id: "page".into(),
kind: DraftEntityKind::Page,
role: None,
name: None,
quality: EvidenceQuality::Confirmed,
evidence_sources: Vec::new(),
}];
let mut indexes: BTreeMap<String, Vec<usize>> = BTreeMap::new();
let mut suffixes: BTreeMap<String, usize> = BTreeMap::new();
let mut diagnostics = BTreeSet::new();
let mut parent_links = BTreeSet::new();
for (fact_index, fact) in facts {
let Some(kind) = canonical_kind(&fact) else {
if fact.relationship_hint.is_some()
&& let Some(diagnostic) = relationship_hint_diagnostics
.iter_mut()
.find(|diagnostic| diagnostic.fact_index == fact_index)
{
diagnostic.status = RelationshipHintDiagnosticStatus::UnmatchedParent;
}
diagnostics.insert(format!("unsupportedFact:{}", fact.kind));
continue;
};
let key = canonical_key(kind, fact.role.as_deref(), fact.name.as_deref());
let existing = indexes.get(&key).and_then(|candidates| {
candidates
.iter()
.copied()
.find(|index| !entities[*index].evidence_sources.contains(&fact.source))
});
let entity_id = if let Some(index) = existing {
let entity = &mut entities[index];
entity.quality = stronger_quality(entity.quality, fact.quality);
entity.evidence_sources.push(fact.source);
entity.evidence_sources.sort();
entity.evidence_sources.dedup();
entity.id.clone()
} else {
let base_id = format!("entity_{}_{}", kind_name(kind), slug(fact.name.as_deref()));
let suffix = suffixes.entry(base_id.clone()).or_insert(0);
let id = if *suffix == 0 {
base_id.clone()
} else {
format!("{base_id}_{}", *suffix)
};
*suffix = suffix.saturating_add(1);
let index = entities.len();
entities.push(DraftEntity {
id: id.clone(),
kind,
role: fact.role,
name: fact.name,
quality: fact.quality,
evidence_sources: vec![fact.source],
});
indexes.entry(key).or_default().push(index);
id
};
if let Some(parent_role) = fact.parent_role {
parent_links.insert((
fact_index,
parent_role.to_ascii_lowercase(),
entity_id,
kind,
fact.relationship_hint,
));
}
}
for index in 0..evidence.coverage.opaque_regions {
entities.push(DraftEntity {
id: format!("opaque_region_{index}"),
kind: DraftEntityKind::OpaqueRegion,
role: None,
name: None,
quality: EvidenceQuality::Opaque,
evidence_sources: Vec::new(),
});
}
let mut relationships = entities
.iter()
.filter(|entity| entity.kind != DraftEntityKind::Page)
.map(|entity| DraftRelationship {
from: "page".into(),
to: entity.id.clone(),
kind: DraftRelationshipKind::Contains,
})
.collect::<Vec<_>>();
for (fact_index, parent_role, child_id, child_kind, relationship_hint) in parent_links {
let Some(parent_kind) = parent_entity_kind(&parent_role) else {
if relationship_hint.is_some() {
set_hint_status(
&mut relationship_hint_diagnostics,
fact_index,
RelationshipHintDiagnosticStatus::UnmatchedParent,
);
}
continue;
};
let Some(parent_id) = entities
.iter()
.find(|entity| {
entity.kind == parent_kind
&& entity
.role
.as_deref()
.is_some_and(|role| role.eq_ignore_ascii_case(&parent_role))
})
.map(|entity| entity.id.clone())
else {
if relationship_hint.is_some() {
set_hint_status(
&mut relationship_hint_diagnostics,
fact_index,
RelationshipHintDiagnosticStatus::UnmatchedParent,
);
}
continue;
};
if parent_id == child_id {
if relationship_hint.is_some() {
set_hint_status(
&mut relationship_hint_diagnostics,
fact_index,
RelationshipHintDiagnosticStatus::UnmatchedParent,
);
}
continue;
}
relationships.push(DraftRelationship {
from: parent_id,
to: child_id,
kind: relationship_kind(parent_kind, child_kind, relationship_hint),
});
if relationship_hint.is_some() {
set_hint_status(
&mut relationship_hint_diagnostics,
fact_index,
RelationshipHintDiagnosticStatus::Emitted,
);
}
}
relationships.sort_by_key(|relationship| {
(
relationship.from.clone(),
relationship.to.clone(),
relationship.kind,
)
});
relationships.dedup();
let draft = GlassWebIrDraft {
schema_version: WEB_IR_DRAFT_SCHEMA_VERSION,
revision: evidence.revision,
entities,
relationships,
coverage: evidence.coverage.clone(),
limits: evidence.limits.clone(),
diagnostics: diagnostics.into_iter().collect(),
relationship_hint_diagnostics,
};
draft.validate()?;
Ok(draft)
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct WebIrValidationError {
pub path: String,
pub reason: String,
}
impl WebIrValidationError {
fn new(path: impl Into<String>, reason: impl Into<String>) -> Self {
Self {
path: path.into(),
reason: reason.into(),
}
}
}
impl Display for WebIrValidationError {
fn fmt(&self, formatter: &mut Formatter<'_>) -> std::fmt::Result {
write!(formatter, "{}: {}", self.path, self.reason)
}
}
impl std::error::Error for WebIrValidationError {}
fn canonical_kind(fact: &EvidenceFact) -> Option<DraftEntityKind> {
let role = fact
.role
.as_deref()
.unwrap_or_default()
.to_ascii_lowercase();
if fact.source == EvidenceSource::Dom {
return match fact
.name
.as_deref()
.unwrap_or_default()
.to_ascii_uppercase()
.as_str()
{
"FORM" => Some(DraftEntityKind::Form),
"INPUT" | "TEXTAREA" | "SELECT" => Some(DraftEntityKind::Field),
"BUTTON" => Some(DraftEntityKind::Action),
"A" => Some(DraftEntityKind::Link),
"TABLE" => Some(DraftEntityKind::Table),
"TR" => Some(DraftEntityKind::Row),
"TD" | "TH" => Some(DraftEntityKind::Cell),
"DIALOG" => Some(DraftEntityKind::Dialog),
"NAV" => Some(DraftEntityKind::Region),
"ARTICLE" => Some(DraftEntityKind::CollectionItem),
_ => None,
};
}
match role.as_str() {
"form" => Some(DraftEntityKind::Form),
"navigation" | "main" | "search" | "complementary" | "article" | "toolbar" => {
Some(DraftEntityKind::Region)
}
"dialog" | "alertdialog" => Some(DraftEntityKind::Dialog),
"textbox" | "combobox" | "checkbox" | "radio" | "spinbutton" | "listbox" => {
Some(DraftEntityKind::Field)
}
"button" | "menuitem" | "tab" => Some(DraftEntityKind::Action),
"link" => Some(DraftEntityKind::Link),
"table" => Some(DraftEntityKind::Table),
"row" => Some(DraftEntityKind::Row),
"cell" | "gridcell" => Some(DraftEntityKind::Cell),
"list" => Some(DraftEntityKind::Collection),
"listitem" => Some(DraftEntityKind::CollectionItem),
"heading" | "text" => Some(DraftEntityKind::Text),
_ if fact.kind == "control" => Some(DraftEntityKind::UnknownInteractive),
_ => None,
}
}
fn canonical_key(kind: DraftEntityKind, role: Option<&str>, name: Option<&str>) -> String {
format!(
"{}|{}|{}",
kind_name(kind),
role.unwrap_or_default().to_ascii_lowercase(),
name.unwrap_or_default().to_ascii_lowercase()
)
}
fn relationship_key(relationship: &DraftRelationship) -> (String, String, DraftRelationshipKind) {
(
relationship.from.clone(),
relationship.to.clone(),
relationship.kind,
)
}
fn fact_sort_key(fact: &EvidenceFact) -> (EvidenceSource, String, String, String) {
(
fact.source,
fact.kind.clone(),
fact.role.clone().unwrap_or_default(),
fact.name.clone().unwrap_or_default(),
)
}
fn kind_name(kind: DraftEntityKind) -> &'static str {
match kind {
DraftEntityKind::Page => "page",
DraftEntityKind::Region => "region",
DraftEntityKind::Form => "form",
DraftEntityKind::Field => "field",
DraftEntityKind::Action => "action",
DraftEntityKind::Link => "link",
DraftEntityKind::NavigationItem => "navigationItem",
DraftEntityKind::Tab => "tab",
DraftEntityKind::Table => "table",
DraftEntityKind::Row => "row",
DraftEntityKind::Cell => "cell",
DraftEntityKind::Collection => "collection",
DraftEntityKind::CollectionItem => "collectionItem",
DraftEntityKind::Dialog => "dialog",
DraftEntityKind::PaginationControl => "paginationControl",
DraftEntityKind::Text => "text",
DraftEntityKind::UnknownInteractive => "unknownInteractive",
DraftEntityKind::OpaqueRegion => "opaqueRegion",
}
}
fn relationship_name(kind: DraftRelationshipKind) -> &'static str {
match kind {
DraftRelationshipKind::Contains => "contains",
DraftRelationshipKind::Labels => "labels",
DraftRelationshipKind::Owns => "owns",
DraftRelationshipKind::Controls => "controls",
DraftRelationshipKind::NavigatesTo => "navigatesTo",
DraftRelationshipKind::Opens => "opens",
DraftRelationshipKind::Confirms => "confirms",
DraftRelationshipKind::Cancels => "cancels",
DraftRelationshipKind::Continues => "continues",
DraftRelationshipKind::Submits => "submits",
DraftRelationshipKind::HeaderFor => "headerFor",
DraftRelationshipKind::CellOf => "cellOf",
DraftRelationshipKind::Selects => "selects",
DraftRelationshipKind::RepeatsAs => "repeatsAs",
DraftRelationshipKind::ScopedTo => "scopedTo",
}
}
fn hint_status_name(status: RelationshipHintDiagnosticStatus) -> &'static str {
match status {
RelationshipHintDiagnosticStatus::Validated => "validated",
RelationshipHintDiagnosticStatus::Emitted => "emitted",
RelationshipHintDiagnosticStatus::UnmatchedParent => "unmatchedParent",
}
}
fn set_hint_status(
diagnostics: &mut [DraftRelationshipHintDiagnostic],
fact_index: usize,
status: RelationshipHintDiagnosticStatus,
) {
if let Some(diagnostic) = diagnostics
.iter_mut()
.find(|diagnostic| diagnostic.fact_index == fact_index)
{
diagnostic.status = status;
}
}
fn parent_entity_kind(role: &str) -> Option<DraftEntityKind> {
match role {
"form" => Some(DraftEntityKind::Form),
"dialog" | "alertdialog" => Some(DraftEntityKind::Dialog),
"article" | "complementary" | "main" | "navigation" | "region" | "search" | "toolbar" => {
Some(DraftEntityKind::Region)
}
_ => None,
}
}
fn relationship_kind(
parent_kind: DraftEntityKind,
child_kind: DraftEntityKind,
explicit_hint: Option<EvidenceRelationshipHint>,
) -> DraftRelationshipKind {
if let Some(hint) = explicit_hint {
return match hint {
EvidenceRelationshipHint::Contains => DraftRelationshipKind::Contains,
EvidenceRelationshipHint::Labels => DraftRelationshipKind::Labels,
EvidenceRelationshipHint::Owns => DraftRelationshipKind::Owns,
EvidenceRelationshipHint::Controls => DraftRelationshipKind::Controls,
EvidenceRelationshipHint::NavigatesTo => DraftRelationshipKind::NavigatesTo,
EvidenceRelationshipHint::Opens => DraftRelationshipKind::Opens,
EvidenceRelationshipHint::Confirms => DraftRelationshipKind::Confirms,
EvidenceRelationshipHint::Cancels => DraftRelationshipKind::Cancels,
EvidenceRelationshipHint::Continues => DraftRelationshipKind::Continues,
EvidenceRelationshipHint::Submits => DraftRelationshipKind::Submits,
EvidenceRelationshipHint::HeaderFor => DraftRelationshipKind::HeaderFor,
EvidenceRelationshipHint::CellOf => DraftRelationshipKind::CellOf,
EvidenceRelationshipHint::Selects => DraftRelationshipKind::Selects,
EvidenceRelationshipHint::RepeatsAs => DraftRelationshipKind::RepeatsAs,
EvidenceRelationshipHint::ScopedTo => DraftRelationshipKind::ScopedTo,
};
}
if parent_kind == DraftEntityKind::Form && child_kind == DraftEntityKind::Field {
DraftRelationshipKind::Owns
} else {
DraftRelationshipKind::Contains
}
}
fn slug(value: Option<&str>) -> String {
let mut output = String::new();
for character in value.unwrap_or("entity").chars() {
if character.is_ascii_alphanumeric() {
output.push(character.to_ascii_lowercase());
} else if !output.ends_with('-') {
output.push('-');
}
if output.len() >= 48 {
break;
}
}
let trimmed = output.trim_matches('-');
if trimmed.is_empty() {
"entity".into()
} else {
trimmed.into()
}
}
fn stronger_quality(left: EvidenceQuality, right: EvidenceQuality) -> EvidenceQuality {
if quality_rank(right) > quality_rank(left) {
right
} else {
left
}
}
fn quality_rank(quality: EvidenceQuality) -> u8 {
match quality {
EvidenceQuality::Opaque => 0,
EvidenceQuality::Conflicted => 1,
EvidenceQuality::Inferred => 2,
EvidenceQuality::Partial => 3,
EvidenceQuality::Strong => 4,
EvidenceQuality::Confirmed => 5,
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::extraction::{EvidenceCoverage, ExtractionScope};
fn evidence() -> ExtractionEvidence {
ExtractionEvidence {
schema_version: 1,
revision: 9,
scope: ExtractionScope::Document,
sources: vec![EvidenceSource::Accessibility, EvidenceSource::Forms],
facts: vec![
EvidenceFact {
source: EvidenceSource::Accessibility,
kind: "node".into(),
quality: EvidenceQuality::Confirmed,
role: Some("textbox".into()),
name: Some("Email".into()),
input_type: Some("email".into()),
required: None,
read_only: None,
empty: None,
geometry_present: None,
parent_role: None,
relationship_hint: None,
},
EvidenceFact {
source: EvidenceSource::Accessibility,
kind: "node".into(),
quality: EvidenceQuality::Confirmed,
role: Some("textbox".into()),
name: Some("Email".into()),
input_type: Some("email".into()),
required: None,
read_only: None,
empty: None,
geometry_present: None,
parent_role: None,
relationship_hint: None,
},
EvidenceFact {
source: EvidenceSource::Forms,
kind: "control".into(),
quality: EvidenceQuality::Strong,
role: Some("textbox".into()),
name: Some("Email".into()),
input_type: Some("email".into()),
required: Some(true),
read_only: Some(false),
empty: Some(true),
geometry_present: None,
parent_role: None,
relationship_hint: None,
},
],
coverage: EvidenceCoverage {
structural: EvidenceQuality::Partial,
semantic: EvidenceQuality::Strong,
interactive_entities_observed: 2,
opaque_regions: 0,
reasons: Vec::new(),
},
limits: ExtractionEvidenceLimits {
truncated: false,
omitted_facts: 0,
text_bytes: 15,
missing_sources: Vec::new(),
},
}
}
#[test]
fn reconciliation_merges_cross_source_facts_but_preserves_duplicate_candidates() {
let draft = reconcile_evidence(&evidence()).unwrap();
assert_eq!(draft.revision, 9);
assert_eq!(draft.entities.len(), 3);
assert_eq!(draft.relationships.len(), 2);
assert_eq!(
draft.entities[1].evidence_sources,
vec![EvidenceSource::Accessibility, EvidenceSource::Forms]
);
assert_eq!(draft.entities[1].quality, EvidenceQuality::Confirmed);
}
#[test]
fn reconciliation_links_children_to_observed_regions_only() {
let mut evidence = evidence();
evidence.facts.push(EvidenceFact {
source: EvidenceSource::Accessibility,
kind: "node".into(),
quality: EvidenceQuality::Confirmed,
role: Some("search".into()),
name: Some("Site search".into()),
input_type: None,
required: None,
read_only: None,
empty: None,
geometry_present: None,
parent_role: None,
relationship_hint: None,
});
evidence.facts[0].parent_role = Some("search".into());
let draft = reconcile_evidence(&evidence).unwrap();
let region_id = draft
.entities
.iter()
.find(|entity| entity.kind == DraftEntityKind::Region)
.map(|entity| entity.id.clone())
.unwrap();
assert!(draft.relationships.iter().any(|relationship| {
relationship.from == region_id && relationship.kind == DraftRelationshipKind::Contains
}));
}
#[test]
fn reconciliation_emits_form_ownership_for_observed_form_ancestry() {
let mut evidence = evidence();
evidence.facts.push(EvidenceFact {
source: EvidenceSource::Accessibility,
kind: "node".into(),
quality: EvidenceQuality::Confirmed,
role: Some("form".into()),
name: Some("Account".into()),
input_type: None,
required: None,
read_only: None,
empty: None,
geometry_present: None,
parent_role: None,
relationship_hint: None,
});
evidence.facts[0].parent_role = Some("form".into());
let draft = reconcile_evidence(&evidence).unwrap();
let form_id = draft
.entities
.iter()
.find(|entity| entity.kind == DraftEntityKind::Form)
.map(|entity| entity.id.clone())
.unwrap();
let field_ids = draft
.entities
.iter()
.filter(|entity| entity.kind == DraftEntityKind::Field)
.map(|entity| entity.id.as_str())
.collect::<BTreeSet<_>>();
assert!(draft.relationships.iter().any(|relationship| {
relationship.from == form_id
&& field_ids.contains(relationship.to.as_str())
&& relationship.kind == DraftRelationshipKind::Owns
}));
}
#[test]
fn reconciliation_honors_explicit_relationship_hints() {
let mut evidence = evidence();
evidence.facts.push(EvidenceFact {
source: EvidenceSource::Accessibility,
kind: "node".into(),
quality: EvidenceQuality::Confirmed,
role: Some("form".into()),
name: Some("Account".into()),
input_type: None,
required: None,
read_only: None,
empty: None,
geometry_present: None,
parent_role: None,
relationship_hint: None,
});
evidence.facts[0].parent_role = Some("form".into());
evidence.facts[0].relationship_hint = Some(EvidenceRelationshipHint::Controls);
let draft = reconcile_evidence(&evidence).unwrap();
assert!(
draft
.relationships
.iter()
.any(|relationship| relationship.kind == DraftRelationshipKind::Controls)
);
assert_eq!(draft.relationship_hint_diagnostics.len(), 1);
let diagnostic = &draft.relationship_hint_diagnostics[0];
assert_eq!(diagnostic.fact_index, 0);
assert_eq!(diagnostic.source, EvidenceSource::Accessibility);
assert_eq!(diagnostic.hint, EvidenceRelationshipHint::Controls);
assert_eq!(diagnostic.parent_role, "form");
assert_eq!(diagnostic.status, RelationshipHintDiagnosticStatus::Emitted);
let expected = BTreeMap::from([(RelationshipHintDiagnosticStatus::Emitted, 1)]);
draft.validate_hint_diagnostics_against(&expected).unwrap();
}
#[test]
fn reconciliation_rejects_unbounded_or_unsupported_hints() {
let mut missing_parent = evidence();
missing_parent.facts[0].relationship_hint = Some(EvidenceRelationshipHint::Controls);
let error = reconcile_evidence(&missing_parent).unwrap_err();
assert_eq!(error.path, "facts[0].relationshipHint");
let mut wrong_source = evidence();
wrong_source.facts[0].parent_role = Some("search".into());
wrong_source.facts[0].relationship_hint = Some(EvidenceRelationshipHint::Controls);
wrong_source.facts[0].source = EvidenceSource::Layout;
let error = reconcile_evidence(&wrong_source).unwrap_err();
assert_eq!(error.path, "facts[0].relationshipHint");
}
#[test]
fn reconciliation_marks_valid_unmatched_hints_without_emitting_edges() {
let mut evidence = evidence();
evidence.facts[0].parent_role = Some("dialog".into());
evidence.facts[0].relationship_hint = Some(EvidenceRelationshipHint::Controls);
let draft = reconcile_evidence(&evidence).unwrap();
assert_eq!(
draft.relationship_hint_diagnostics[0].status,
RelationshipHintDiagnosticStatus::UnmatchedParent
);
assert!(
!draft
.relationships
.iter()
.any(|relationship| relationship.kind == DraftRelationshipKind::Controls)
);
let expected = BTreeMap::from([(RelationshipHintDiagnosticStatus::UnmatchedParent, 1)]);
draft.validate_hint_diagnostics_against(&expected).unwrap();
let missing = BTreeMap::from([(RelationshipHintDiagnosticStatus::Emitted, 1)]);
let error = draft
.validate_hint_diagnostics_against(&missing)
.unwrap_err();
assert_eq!(
error.path,
"expectation.relationshipHintDiagnostics.emitted"
);
}
#[test]
fn reconciliation_materializes_opaque_coverage_without_provenance_claims() {
let mut evidence = evidence();
evidence.coverage.opaque_regions = 2;
let draft = reconcile_evidence(&evidence).unwrap();
let opaque = draft
.entities
.iter()
.filter(|entity| entity.kind == DraftEntityKind::OpaqueRegion)
.collect::<Vec<_>>();
assert_eq!(opaque.len(), 2);
assert!(opaque.iter().all(|entity| {
entity.quality == EvidenceQuality::Opaque && entity.evidence_sources.is_empty()
}));
assert_eq!(draft.relationships.len(), 4);
draft.validate().unwrap();
}
#[test]
fn fixture_expectation_validates_minimum_graph_shape() {
let draft = reconcile_evidence(&evidence()).unwrap();
let mut expectation = DraftFixtureExpectation::default();
expectation
.required_entity_counts
.insert(DraftEntityKind::Page, 1);
expectation
.required_entity_counts
.insert(DraftEntityKind::Field, 2);
expectation
.required_relationship_counts
.insert(DraftRelationshipKind::Contains, 2);
draft.validate_against(&expectation).unwrap();
expectation
.required_entity_counts
.insert(DraftEntityKind::Dialog, 1);
let error = draft.validate_against(&expectation).unwrap_err();
assert_eq!(error.path, "expectation.entities.dialog");
}
#[test]
fn fixture_corpus_vocabulary_matches_draft_contract() {
let corpus: serde_json::Value =
serde_json::from_str(include_str!("../tests/fixtures/web-ir/corpus-v1.json")).unwrap();
let fixtures = corpus["fixtures"].as_array().unwrap();
assert_eq!(fixtures.len(), 8);
for fixture in fixtures {
let entities = fixture["expectedEntities"].as_array().unwrap();
let relationships = fixture["expectedRelationships"].as_array().unwrap();
assert_eq!(
entities.first().and_then(serde_json::Value::as_str),
Some("page")
);
assert!(relationships.iter().any(|value| value == "contains"));
for entity in entities {
assert!(DraftEntityKind::from_contract_name(entity.as_str().unwrap()).is_some());
}
for relationship in relationships {
assert!(
DraftRelationshipKind::from_contract_name(relationship.as_str().unwrap())
.is_some()
);
}
let opaque_count = entities
.iter()
.filter(|entity| entity.as_str() == Some("opaqueRegion"))
.count() as u64;
assert_eq!(opaque_count, fixture["opaqueRegions"].as_u64().unwrap());
}
}
#[test]
fn reconciliation_is_deterministic_and_does_not_emit_raw_values() {
let draft = reconcile_evidence(&evidence()).unwrap();
let first = draft.to_canonical_json().unwrap();
let second = reconcile_evidence(&evidence())
.unwrap()
.to_canonical_json()
.unwrap();
assert_eq!(first, second);
assert!(!first.contains("secret"));
}
#[test]
fn revision_diff_reports_deterministic_entity_relationship_and_metadata_changes() {
let before = reconcile_evidence(&evidence()).unwrap();
let mut after_evidence = evidence();
after_evidence.revision = 10;
after_evidence.coverage.structural = EvidenceQuality::Strong;
after_evidence.facts[0].parent_role = Some("form".into());
let mut form_fact = after_evidence.facts[0].clone();
form_fact.role = Some("form".into());
form_fact.name = Some("Account".into());
form_fact.parent_role = None;
after_evidence.facts.push(form_fact);
let mut layout_fact = after_evidence.facts[0].clone();
layout_fact.source = EvidenceSource::Layout;
layout_fact.quality = EvidenceQuality::Strong;
layout_fact.geometry_present = Some(true);
after_evidence.facts.push(layout_fact);
let after = reconcile_evidence(&after_evidence).unwrap();
let diff = before.diff(&after).unwrap();
assert_eq!(diff.from_revision, 9);
assert_eq!(diff.to_revision, 10);
assert!(
diff.entity_changes
.iter()
.any(|change| change.kind == DraftChangeKind::Added)
);
assert!(
diff.entity_changes
.iter()
.any(|change| change.kind == DraftChangeKind::Changed)
);
assert!(diff.relationship_changes.iter().any(|change| {
change.kind == DraftChangeKind::Added
&& change.relationship.kind == DraftRelationshipKind::Owns
}));
assert!(diff.coverage_changed);
assert!(!diff.limits_changed);
assert!(!diff.diagnostics_changed);
assert!(!diff.relationship_hint_diagnostics_changed);
let reverse = after.diff(&before).unwrap();
assert!(
reverse
.entity_changes
.iter()
.any(|change| change.kind == DraftChangeKind::Removed)
);
assert!(
reverse
.relationship_changes
.iter()
.any(|change| change.kind == DraftChangeKind::Removed)
);
assert_eq!(
serde_json::to_string(&diff).unwrap(),
serde_json::to_string(&before.diff(&after).unwrap()).unwrap()
);
}
#[test]
fn continuity_classification_fails_closed_for_stale_and_ambiguous_targets() {
let source = reconcile_evidence(&evidence()).unwrap();
let target_id = source
.entities
.iter()
.find(|entity| entity.kind == DraftEntityKind::Field)
.map(|entity| entity.id.clone())
.unwrap();
let unchanged = source
.classify_entity_continuity(&source, &target_id)
.unwrap();
assert_eq!(unchanged.status, DraftEntityContinuityStatus::Unchanged);
let mut changed_draft = source.clone();
changed_draft
.entities
.iter_mut()
.find(|entity| entity.id == target_id)
.unwrap()
.name = Some("Different field".into());
let changed = source
.classify_entity_continuity(&changed_draft, &target_id)
.unwrap();
assert_eq!(changed.status, DraftEntityContinuityStatus::Changed);
let target_key = source
.entities
.iter()
.find(|entity| entity.id == target_id)
.and_then(DraftEntity::semantic_identity_key)
.unwrap();
let removed_ids = source
.entities
.iter()
.filter(|entity| entity.semantic_identity_key().as_deref() == Some(target_key.as_str()))
.map(|entity| entity.id.clone())
.collect::<BTreeSet<_>>();
let mut rebound_draft = source.clone();
rebound_draft
.entities
.retain(|entity| !removed_ids.contains(&entity.id));
rebound_draft.relationships.retain(|relationship| {
!removed_ids.contains(&relationship.from) && !removed_ids.contains(&relationship.to)
});
let mut rebound_entity = source
.entities
.iter()
.find(|entity| entity.semantic_identity_key().as_deref() == Some(target_key.as_str()))
.unwrap()
.clone();
let rebound_id = "replacement-field".to_owned();
rebound_entity.id = rebound_id.clone();
rebound_draft.entities.push(rebound_entity.clone());
let rebound = source
.classify_entity_continuity(&rebound_draft, &target_id)
.unwrap();
assert_eq!(rebound.status, DraftEntityContinuityStatus::Rebound);
assert_eq!(rebound.current_id.as_deref(), Some(rebound_id.as_str()));
let mut removed_draft = source.clone();
removed_draft
.entities
.retain(|entity| !removed_ids.contains(&entity.id));
removed_draft.relationships.retain(|relationship| {
!removed_ids.contains(&relationship.from) && !removed_ids.contains(&relationship.to)
});
let removed = source
.classify_entity_continuity(&removed_draft, &target_id)
.unwrap();
assert_eq!(removed.status, DraftEntityContinuityStatus::Removed);
let mut ambiguous_draft = removed_draft;
let mut first = rebound_entity;
first.id = "replacement-a".into();
let mut second = first.clone();
second.id = "replacement-b".into();
ambiguous_draft.entities.extend([first, second]);
let ambiguous = source
.classify_entity_continuity(&ambiguous_draft, &target_id)
.unwrap();
assert_eq!(ambiguous.status, DraftEntityContinuityStatus::Ambiguous);
assert!(ambiguous.current_id.is_none());
}
#[test]
fn validation_rejects_dangling_relationships() {
let mut draft = reconcile_evidence(&evidence()).unwrap();
draft.relationships[0].to = "missing".into();
let error = draft.validate().unwrap_err();
assert_eq!(error.path, "relationships");
}
}