Skip to main content

glass/browser/session/
semantic.rs

1//! Versioned semantic observation contracts.
2//!
3//! This module defines the bounded external shape used by the semantic
4//! observation engine. It is intentionally separate from [`PageContext`], so
5//! existing detailed and raw observation callers remain compatible while the
6//! semantic surface is built incrementally.
7
8use serde::{Deserialize, Serialize};
9#[cfg(test)]
10use serde_json::Value;
11use std::collections::BTreeSet;
12
13use super::types::PageContext;
14use crate::browser::dom::CompactAxNode;
15
16pub const SEMANTIC_OBSERVATION_SCHEMA_VERSION: u32 = 1;
17const MAX_REGIONS: usize = 64;
18const MAX_EVIDENCE_ITEMS: usize = 8;
19const MAX_EVIDENCE_BYTES: usize = 128;
20const MAX_ID_BYTES: usize = 128;
21const MAX_LABEL_BYTES: usize = 256;
22const MAX_TITLE_BYTES: usize = 1_024;
23const MAX_URL_BYTES: usize = 2_048;
24const MAX_TARGETS: usize = 32;
25const MAX_CHANGE_ITEMS: usize = 128;
26const MAX_ROLE_BYTES: usize = 64;
27const MAX_VISIBLE_TEXT_BYTES: usize = 16 * 1024;
28
29/// Amount of semantic structure requested by a caller.
30#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
31#[serde(rename_all = "lowercase")]
32pub enum SemanticObservationLevel {
33    Summary,
34    Interactive,
35    Structured,
36    Detailed,
37    Raw,
38}
39
40impl SemanticObservationLevel {
41    fn includes_targets(self) -> bool {
42        matches!(
43            self,
44            Self::Interactive | Self::Structured | Self::Detailed | Self::Raw
45        )
46    }
47
48    fn includes_text(self) -> bool {
49        matches!(self, Self::Structured | Self::Detailed | Self::Raw)
50    }
51
52    fn includes_accessibility(self) -> bool {
53        matches!(self, Self::Detailed | Self::Raw)
54    }
55
56    fn includes_raw_accessibility(self) -> bool {
57        matches!(self, Self::Raw)
58    }
59}
60
61/// Bounded confidence attached to an evidence-backed classification.
62#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
63#[serde(rename_all = "lowercase")]
64pub enum SemanticConfidence {
65    Exact,
66    High,
67    Medium,
68    Low,
69    Unknown,
70}
71
72/// Advisory page classification. `Unknown` and `Generic` are valid outcomes.
73#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
74#[serde(rename_all = "camelCase")]
75pub enum SemanticPageKind {
76    Generic,
77    Home,
78    Search,
79    SearchResults,
80    Article,
81    Documentation,
82    Listing,
83    Detail,
84    Form,
85    Authentication,
86    Checkout,
87    Confirmation,
88    Dashboard,
89    Settings,
90    Error,
91    AccessDenied,
92    Unknown,
93}
94
95/// Initial semantic region taxonomy.
96#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
97#[serde(rename_all = "camelCase")]
98pub enum SemanticRegionKind {
99    Navigation,
100    Main,
101    Search,
102    Form,
103    Dialog,
104    Alert,
105    Status,
106    Toolbar,
107    FilterPanel,
108    Results,
109    Collection,
110    Table,
111    Pagination,
112    Article,
113    Sidebar,
114    CheckoutSummary,
115    Authentication,
116    Footer,
117    Unknown,
118}
119
120/// Route identity required to keep semantic handles scoped to one page.
121#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
122#[serde(rename_all = "camelCase")]
123pub struct SemanticRouteIdentity {
124    pub target_id: String,
125    pub frame_id: String,
126    pub url: String,
127}
128
129/// Page-level semantic summary.
130#[derive(Debug, Clone, Serialize, Deserialize)]
131#[serde(rename_all = "camelCase")]
132pub struct SemanticPage {
133    pub kind: SemanticPageKind,
134    pub title: String,
135    pub url: String,
136    pub target_id: String,
137    pub frame_id: String,
138    pub confidence: SemanticConfidence,
139    #[serde(default, skip_serializing_if = "Vec::is_empty")]
140    pub evidence: Vec<String>,
141}
142
143/// Revision-scoped handle for requesting one region's details.
144#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
145#[serde(rename_all = "camelCase")]
146pub struct SemanticExpansionHandle {
147    pub region_id: String,
148    pub revision: u64,
149    pub route: SemanticRouteIdentity,
150}
151
152/// A bounded, revision-scoped action reference in an interactive observation.
153#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
154#[serde(rename_all = "camelCase")]
155pub struct SemanticTarget {
156    pub reference: String,
157    pub role: String,
158    #[serde(default, skip_serializing_if = "String::is_empty")]
159    pub name: String,
160    #[serde(default, skip_serializing_if = "Option::is_none")]
161    pub input_type: Option<String>,
162}
163
164/// A bounded accessibility node included only by detailed semantic levels.
165#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
166#[serde(rename_all = "camelCase")]
167pub struct SemanticAccessibilityNode {
168    pub role: String,
169    #[serde(default, skip_serializing_if = "String::is_empty")]
170    pub name: String,
171    #[serde(default, skip_serializing_if = "Vec::is_empty")]
172    pub children: Vec<SemanticAccessibilityNode>,
173    #[serde(default, skip_serializing_if = "is_false")]
174    pub interactive: bool,
175}
176
177/// Kind of bounded semantic entity change between two compatible revisions.
178#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
179#[serde(rename_all = "lowercase")]
180pub enum SemanticChangeKind {
181    Added,
182    Removed,
183    Updated,
184}
185
186/// Region-level change identified by stable semantic IDs where possible.
187#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
188#[serde(rename_all = "camelCase")]
189pub struct SemanticRegionChange {
190    pub id: String,
191    pub kind: SemanticChangeKind,
192    #[serde(default, skip_serializing_if = "Option::is_none")]
193    pub previous_id: Option<String>,
194}
195
196/// Target-level change scoped to a semantic region.
197#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
198#[serde(rename_all = "camelCase")]
199pub struct SemanticTargetChange {
200    pub region_id: String,
201    pub target_id: String,
202    pub kind: SemanticChangeKind,
203    #[serde(default, skip_serializing_if = "Option::is_none")]
204    pub previous_target_id: Option<String>,
205}
206
207/// Conservative advisory mapping between revision-scoped target references.
208#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
209#[serde(rename_all = "camelCase")]
210pub struct SemanticContinuity {
211    pub previous_reference: String,
212    pub current_reference: String,
213    pub confidence: SemanticConfidence,
214    pub evidence: String,
215}
216
217/// Bounded changes between two observations on the same route.
218#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
219#[serde(rename_all = "camelCase")]
220pub struct SemanticChangeSet {
221    pub from_revision: u64,
222    pub to_revision: u64,
223    pub route: SemanticRouteIdentity,
224    #[serde(default, skip_serializing_if = "Vec::is_empty")]
225    pub regions: Vec<SemanticRegionChange>,
226    #[serde(default, skip_serializing_if = "Vec::is_empty")]
227    pub targets: Vec<SemanticTargetChange>,
228    #[serde(default, skip_serializing_if = "Vec::is_empty")]
229    pub continuity: Vec<SemanticContinuity>,
230}
231
232/// A bounded semantic region summary.
233#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
234#[serde(rename_all = "camelCase")]
235pub struct SemanticRegion {
236    pub id: String,
237    pub kind: SemanticRegionKind,
238    pub label: String,
239    pub interactive_count: usize,
240    #[serde(default, skip_serializing_if = "Option::is_none")]
241    pub item_count: Option<usize>,
242    pub confidence: SemanticConfidence,
243    #[serde(default, skip_serializing_if = "Vec::is_empty")]
244    pub evidence: Vec<String>,
245    #[serde(default, skip_serializing_if = "Vec::is_empty")]
246    pub targets: Vec<SemanticTarget>,
247    #[serde(default, skip_serializing_if = "Option::is_none")]
248    pub expansion: Option<SemanticExpansionHandle>,
249}
250
251/// Explicit bounds and omission metadata for one semantic observation.
252#[derive(Debug, Clone, Default, Serialize, Deserialize)]
253#[serde(rename_all = "camelCase")]
254pub struct SemanticObservationLimits {
255    pub truncated: bool,
256    pub omitted_regions: usize,
257    #[serde(default)]
258    pub omitted_targets: usize,
259    #[serde(default, skip_serializing_if = "Option::is_none")]
260    pub omitted_bytes: Option<usize>,
261}
262
263/// Versioned semantic page model.
264#[derive(Debug, Clone, Serialize, Deserialize)]
265#[serde(rename_all = "camelCase")]
266pub struct SemanticObservation {
267    pub schema_version: u32,
268    pub revision: u64,
269    pub level: SemanticObservationLevel,
270    pub route: SemanticRouteIdentity,
271    pub page: SemanticPage,
272    pub regions: Vec<SemanticRegion>,
273    #[serde(default, skip_serializing_if = "Option::is_none")]
274    pub text: Option<String>,
275    #[serde(default, skip_serializing_if = "Option::is_none")]
276    pub accessibility: Option<Vec<SemanticAccessibilityNode>>,
277    #[serde(default, skip_serializing_if = "Option::is_none")]
278    pub raw_accessibility: Option<Vec<SemanticAccessibilityNode>>,
279    #[serde(default, skip_serializing_if = "Option::is_none")]
280    pub changes: Option<SemanticChangeSet>,
281    pub limits: SemanticObservationLimits,
282}
283
284impl SemanticObservation {
285    /// Build a deterministic semantic summary from an existing fresh page
286    /// observation. Classification is advisory and never replaces revisioned
287    /// interactive references from the source observation.
288    pub fn from_page_context(
289        context: &PageContext,
290        level: SemanticObservationLevel,
291    ) -> Result<Self, SemanticObservationError> {
292        let route = SemanticRouteIdentity {
293            target_id: context.page.target_id.clone(),
294            frame_id: context.page.frame_id.clone(),
295            url: context.page.url.clone(),
296        };
297        let mut regions = Vec::new();
298        let mut anchors = Vec::new();
299        for root in &context.accessibility.roots {
300            collect_regions(
301                root,
302                context.accessibility.revision,
303                &route,
304                &mut regions,
305                &mut anchors,
306            );
307        }
308        if regions.is_empty() {
309            regions.push(SemanticRegion {
310                id: "region_main".into(),
311                kind: SemanticRegionKind::Unknown,
312                label: "Unclassified page content".into(),
313                interactive_count: context.accessibility.interactive.len(),
314                item_count: None,
315                confidence: SemanticConfidence::Unknown,
316                evidence: vec!["no recognized landmark role".into()],
317                targets: Vec::new(),
318                expansion: Some(SemanticExpansionHandle {
319                    region_id: "region_main".into(),
320                    revision: context.accessibility.revision,
321                    route: route.clone(),
322                }),
323            });
324            anchors.push(("region_main".into(), None));
325        }
326        populate_level(
327            &mut regions,
328            &context.accessibility.interactive,
329            &anchors,
330            level,
331        );
332        let (kind, confidence, evidence) = classify_page(context, &regions);
333        let observation = Self {
334            schema_version: SEMANTIC_OBSERVATION_SCHEMA_VERSION,
335            revision: context.accessibility.revision,
336            level,
337            route: route.clone(),
338            page: SemanticPage {
339                kind,
340                title: context.page.title.clone(),
341                url: context.page.url.clone(),
342                target_id: context.page.target_id.clone(),
343                frame_id: context.page.frame_id.clone(),
344                confidence,
345                evidence,
346            },
347            regions,
348            text: level
349                .includes_text()
350                .then(|| bounded_semantic_text(&context.text, MAX_VISIBLE_TEXT_BYTES)),
351            accessibility: level.includes_accessibility().then(|| {
352                context
353                    .accessibility
354                    .roots
355                    .iter()
356                    .map(to_semantic_node)
357                    .collect()
358            }),
359            raw_accessibility: level.includes_raw_accessibility().then(|| {
360                context
361                    .accessibility
362                    .roots
363                    .iter()
364                    .map(to_semantic_node)
365                    .collect()
366            }),
367            changes: None,
368            limits: SemanticObservationLimits {
369                truncated: context.accessibility.truncated
370                    || context.accessibility.omitted_count > 0
371                    || !context.incomplete.is_empty(),
372                omitted_regions: 0,
373                omitted_targets: context.accessibility.omitted_count,
374                omitted_bytes: None,
375            },
376        };
377        observation.validate()?;
378        Ok(observation)
379    }
380
381    /// Build a revision-scoped observation containing one previously named
382    /// region. The source context is still classified in full so the region
383    /// ID and its target grouping follow the same deterministic rules as the
384    /// page-level observation.
385    pub fn scoped_region_from_page_context(
386        context: &PageContext,
387        level: SemanticObservationLevel,
388        region_id: &str,
389    ) -> Result<Self, SemanticObservationError> {
390        let mut observation = Self::from_page_context(context, level)?;
391        let region_index = observation
392            .regions
393            .iter()
394            .position(|region| region.id == region_id)
395            .ok_or_else(|| {
396                SemanticObservationError::new(
397                    "regionId",
398                    format!(
399                        "region {region_id:?} is not present at revision {}",
400                        observation.revision
401                    ),
402                )
403            })?;
404        let omitted_regions = observation.regions.len().saturating_sub(1);
405        observation.regions = vec![observation.regions.remove(region_index)];
406        observation.limits.omitted_regions = omitted_regions;
407        observation.limits.truncated |= omitted_regions > 0;
408        observation.validate()?;
409        Ok(observation)
410    }
411
412    /// Compute bounded changes from an earlier compatible observation.
413    pub fn diff_from(
414        &self,
415        previous: &SemanticObservation,
416    ) -> Result<SemanticChangeSet, SemanticObservationError> {
417        self.validate()?;
418        previous.validate()?;
419        if self.route != previous.route {
420            return Err(SemanticObservationError::new(
421                "route",
422                "semantic diff requires the same target, frame, and URL",
423            ));
424        }
425        if self.level != previous.level {
426            return Err(SemanticObservationError::new(
427                "level",
428                "semantic diff requires matching observation levels",
429            ));
430        }
431        if self.revision < previous.revision {
432            return Err(SemanticObservationError::new(
433                "revision",
434                "semantic diff cannot move backwards in revision",
435            ));
436        }
437
438        let mut regions = Vec::new();
439        let mut matched_current = BTreeSet::new();
440        for previous_region in &previous.regions {
441            let exact = self
442                .regions
443                .iter()
444                .position(|region| region.id == previous_region.id);
445            let fallback = exact.or_else(|| {
446                let candidates: Vec<usize> = self
447                    .regions
448                    .iter()
449                    .enumerate()
450                    .filter(|(index, region)| {
451                        !matched_current.contains(index)
452                            && region.kind == previous_region.kind
453                            && region.label == previous_region.label
454                    })
455                    .map(|(index, _)| index)
456                    .collect();
457                (candidates.len() == 1).then_some(candidates[0])
458            });
459            if let Some(current_index) = fallback {
460                matched_current.insert(current_index);
461                let current_region = &self.regions[current_index];
462                if current_region != previous_region {
463                    regions.push(SemanticRegionChange {
464                        id: current_region.id.clone(),
465                        kind: SemanticChangeKind::Updated,
466                        previous_id: (current_region.id != previous_region.id)
467                            .then(|| previous_region.id.clone()),
468                    });
469                }
470            } else {
471                regions.push(SemanticRegionChange {
472                    id: previous_region.id.clone(),
473                    kind: SemanticChangeKind::Removed,
474                    previous_id: None,
475                });
476            }
477        }
478        for (index, current_region) in self.regions.iter().enumerate() {
479            if !matched_current.contains(&index)
480                && !previous
481                    .regions
482                    .iter()
483                    .any(|region| region.id == current_region.id)
484            {
485                regions.push(SemanticRegionChange {
486                    id: current_region.id.clone(),
487                    kind: SemanticChangeKind::Added,
488                    previous_id: None,
489                });
490            }
491        }
492
493        let (targets, continuity) = diff_targets(previous, self);
494        let changes = SemanticChangeSet {
495            from_revision: previous.revision,
496            to_revision: self.revision,
497            route: self.route.clone(),
498            regions,
499            targets,
500            continuity,
501        };
502        validate_change_set(&changes)?;
503        Ok(changes)
504    }
505
506    /// Attach a revision-aware diff to this observation.
507    pub fn with_changes_from(
508        mut self,
509        previous: &SemanticObservation,
510    ) -> Result<Self, SemanticObservationError> {
511        self.changes = Some(self.diff_from(previous)?);
512        self.validate()?;
513        Ok(self)
514    }
515
516    /// Validate bounds and cross-field identity invariants.
517    pub fn validate(&self) -> Result<(), SemanticObservationError> {
518        if self.schema_version != SEMANTIC_OBSERVATION_SCHEMA_VERSION {
519            return Err(SemanticObservationError::new(
520                "schemaVersion",
521                format!(
522                    "unsupported schema version {}; expected {}",
523                    self.schema_version, SEMANTIC_OBSERVATION_SCHEMA_VERSION
524                ),
525            ));
526        }
527        validate_route("route", &self.route)?;
528        validate_route_fields(
529            "page",
530            &self.page.target_id,
531            &self.page.frame_id,
532            &self.page.url,
533        )?;
534        validate_text("page.title", &self.page.title, MAX_TITLE_BYTES, true)?;
535        validate_evidence("page.evidence", &self.page.evidence)?;
536        if self.regions.len() > MAX_REGIONS {
537            return Err(SemanticObservationError::new(
538                "regions",
539                format!("contains more than {MAX_REGIONS} regions"),
540            ));
541        }
542        validate_level_payload(self)?;
543        if let Some(changes) = &self.changes {
544            validate_change_set(changes)?;
545            if changes.to_revision != self.revision || changes.route != self.route {
546                return Err(SemanticObservationError::new(
547                    "changes",
548                    "change set does not belong to this observation",
549                ));
550            }
551        }
552        if self.page.target_id != self.route.target_id
553            || self.page.frame_id != self.route.frame_id
554            || self.page.url != self.route.url
555        {
556            return Err(SemanticObservationError::new(
557                "page",
558                "page route identity does not match observation route",
559            ));
560        }
561        let mut region_ids = BTreeSet::new();
562        for (index, region) in self.regions.iter().enumerate() {
563            let path = format!("regions[{index}]");
564            validate_text(&format!("{path}.id"), &region.id, MAX_ID_BYTES, false)?;
565            if !region_ids.insert(region.id.as_str()) {
566                return Err(SemanticObservationError::new(
567                    format!("{path}.id"),
568                    "duplicate semantic region ID",
569                ));
570            }
571            validate_text(
572                &format!("{path}.label"),
573                &region.label,
574                MAX_LABEL_BYTES,
575                false,
576            )?;
577            validate_evidence(&format!("{path}.evidence"), &region.evidence)?;
578            if region.targets.len() > MAX_TARGETS {
579                return Err(SemanticObservationError::new(
580                    format!("{path}.targets"),
581                    format!("contains more than {MAX_TARGETS} targets"),
582                ));
583            }
584            for (target_index, target) in region.targets.iter().enumerate() {
585                let target_path = format!("{path}.targets[{target_index}]");
586                validate_text(
587                    &format!("{target_path}.reference"),
588                    &target.reference,
589                    MAX_ID_BYTES,
590                    false,
591                )?;
592                validate_text(
593                    &format!("{target_path}.role"),
594                    &target.role,
595                    MAX_ROLE_BYTES,
596                    false,
597                )?;
598                validate_text(
599                    &format!("{target_path}.name"),
600                    &target.name,
601                    MAX_LABEL_BYTES,
602                    true,
603                )?;
604                if let Some(input_type) = &target.input_type {
605                    validate_text(
606                        &format!("{target_path}.inputType"),
607                        input_type,
608                        MAX_ROLE_BYTES,
609                        false,
610                    )?;
611                }
612            }
613            if let Some(expansion) = &region.expansion {
614                if expansion.region_id != region.id || expansion.revision != self.revision {
615                    return Err(SemanticObservationError::new(
616                        format!("{path}.expansion"),
617                        "expansion handle does not belong to this region revision",
618                    ));
619                }
620                if expansion.route != self.route {
621                    return Err(SemanticObservationError::new(
622                        format!("{path}.expansion.route"),
623                        "expansion handle route does not match observation route",
624                    ));
625                }
626            }
627        }
628        Ok(())
629    }
630
631    /// Parse and validate a semantic observation from JSON.
632    pub fn from_json(input: &str) -> Result<Self, SemanticObservationError> {
633        let observation: Self = serde_json::from_str(input).map_err(|error| {
634            SemanticObservationError::new("$", format!("invalid observation shape: {error}"))
635        })?;
636        observation.validate()?;
637        Ok(observation)
638    }
639
640    /// Serialize a validated observation deterministically.
641    pub fn to_canonical_json(&self) -> Result<String, SemanticObservationError> {
642        self.validate()?;
643        serde_json::to_string(self)
644            .map_err(|error| SemanticObservationError::new("$", error.to_string()))
645    }
646}
647
648fn validate_level_payload(
649    observation: &SemanticObservation,
650) -> Result<(), SemanticObservationError> {
651    let has_targets = observation
652        .regions
653        .iter()
654        .any(|region| !region.targets.is_empty());
655    if has_targets && !observation.level.includes_targets() {
656        return Err(SemanticObservationError::new(
657            "regions.targets",
658            "target payload is not available at the summary observation level",
659        ));
660    }
661    if observation.text.is_some() != observation.level.includes_text() {
662        return Err(SemanticObservationError::new(
663            "text",
664            "visible text payload does not match observation level",
665        ));
666    }
667    if observation.accessibility.is_some() != observation.level.includes_accessibility() {
668        return Err(SemanticObservationError::new(
669            "accessibility",
670            "accessibility payload does not match observation level",
671        ));
672    }
673    if observation.raw_accessibility.is_some() != observation.level.includes_raw_accessibility() {
674        return Err(SemanticObservationError::new(
675            "rawAccessibility",
676            "raw accessibility payload does not match observation level",
677        ));
678    }
679    Ok(())
680}
681
682fn validate_change_set(changes: &SemanticChangeSet) -> Result<(), SemanticObservationError> {
683    validate_route("changes.route", &changes.route)?;
684    if changes.from_revision > changes.to_revision {
685        return Err(SemanticObservationError::new(
686            "changes.fromRevision",
687            "cannot be newer than toRevision",
688        ));
689    }
690    if changes.regions.len() > MAX_CHANGE_ITEMS {
691        return Err(SemanticObservationError::new(
692            "changes.regions",
693            format!("contains more than {MAX_CHANGE_ITEMS} changes"),
694        ));
695    }
696    if changes.targets.len() > MAX_CHANGE_ITEMS {
697        return Err(SemanticObservationError::new(
698            "changes.targets",
699            format!("contains more than {MAX_CHANGE_ITEMS} changes"),
700        ));
701    }
702    if changes.continuity.len() > MAX_CHANGE_ITEMS {
703        return Err(SemanticObservationError::new(
704            "changes.continuity",
705            format!("contains more than {MAX_CHANGE_ITEMS} mappings"),
706        ));
707    }
708    for (index, change) in changes.regions.iter().enumerate() {
709        validate_text(
710            &format!("changes.regions[{index}].id"),
711            &change.id,
712            MAX_ID_BYTES,
713            false,
714        )?;
715        if let Some(previous_id) = &change.previous_id {
716            validate_text(
717                &format!("changes.regions[{index}].previousId"),
718                previous_id,
719                MAX_ID_BYTES,
720                false,
721            )?;
722        }
723    }
724    for (index, change) in changes.targets.iter().enumerate() {
725        validate_text(
726            &format!("changes.targets[{index}].regionId"),
727            &change.region_id,
728            MAX_ID_BYTES,
729            false,
730        )?;
731        validate_text(
732            &format!("changes.targets[{index}].targetId"),
733            &change.target_id,
734            MAX_ID_BYTES,
735            false,
736        )?;
737        if let Some(previous_target_id) = &change.previous_target_id {
738            validate_text(
739                &format!("changes.targets[{index}].previousTargetId"),
740                previous_target_id,
741                MAX_ID_BYTES,
742                false,
743            )?;
744        }
745    }
746    for (index, continuity) in changes.continuity.iter().enumerate() {
747        validate_text(
748            &format!("changes.continuity[{index}].previousReference"),
749            &continuity.previous_reference,
750            MAX_ID_BYTES,
751            false,
752        )?;
753        validate_text(
754            &format!("changes.continuity[{index}].currentReference"),
755            &continuity.current_reference,
756            MAX_ID_BYTES,
757            false,
758        )?;
759        validate_text(
760            &format!("changes.continuity[{index}].evidence"),
761            &continuity.evidence,
762            MAX_EVIDENCE_BYTES,
763            false,
764        )?;
765    }
766    Ok(())
767}
768
769fn diff_targets(
770    previous: &SemanticObservation,
771    current: &SemanticObservation,
772) -> (Vec<SemanticTargetChange>, Vec<SemanticContinuity>) {
773    let mut changes = Vec::new();
774    let mut continuity = Vec::new();
775    let mut matched_current = BTreeSet::new();
776    for previous_region in &previous.regions {
777        let Some(current_region) = current
778            .regions
779            .iter()
780            .find(|region| region.id == previous_region.id)
781        else {
782            for target in &previous_region.targets {
783                changes.push(SemanticTargetChange {
784                    region_id: previous_region.id.clone(),
785                    target_id: target.reference.clone(),
786                    kind: SemanticChangeKind::Removed,
787                    previous_target_id: None,
788                });
789            }
790            continue;
791        };
792        for previous_target in &previous_region.targets {
793            let exact = current_region
794                .targets
795                .iter()
796                .position(|target| target.reference == previous_target.reference);
797            let fallback = exact.or_else(|| {
798                let candidates: Vec<usize> = current_region
799                    .targets
800                    .iter()
801                    .enumerate()
802                    .filter(|(index, target)| {
803                        !matched_current.contains(&(*index, current_region.id.clone()))
804                            && target.role == previous_target.role
805                            && target.name == previous_target.name
806                            && target.input_type == previous_target.input_type
807                    })
808                    .map(|(index, _)| index)
809                    .collect();
810                (candidates.len() == 1).then_some(candidates[0])
811            });
812            if let Some(current_index) = fallback {
813                matched_current.insert((current_index, current_region.id.clone()));
814                let current_target = &current_region.targets[current_index];
815                if current_target != previous_target {
816                    changes.push(SemanticTargetChange {
817                        region_id: current_region.id.clone(),
818                        target_id: current_target.reference.clone(),
819                        kind: SemanticChangeKind::Updated,
820                        previous_target_id: (current_target.reference != previous_target.reference)
821                            .then(|| previous_target.reference.clone()),
822                    });
823                }
824                if current_target.reference != previous_target.reference {
825                    continuity.push(SemanticContinuity {
826                        previous_reference: previous_target.reference.clone(),
827                        current_reference: current_target.reference.clone(),
828                        confidence: SemanticConfidence::Medium,
829                        evidence: "unique role/name/inputType match".into(),
830                    });
831                }
832            } else {
833                changes.push(SemanticTargetChange {
834                    region_id: previous_region.id.clone(),
835                    target_id: previous_target.reference.clone(),
836                    kind: SemanticChangeKind::Removed,
837                    previous_target_id: None,
838                });
839            }
840        }
841        for (index, current_target) in current_region.targets.iter().enumerate() {
842            if !matched_current.contains(&(index, current_region.id.clone()))
843                && !previous_region
844                    .targets
845                    .iter()
846                    .any(|target| target.reference == current_target.reference)
847            {
848                changes.push(SemanticTargetChange {
849                    region_id: current_region.id.clone(),
850                    target_id: current_target.reference.clone(),
851                    kind: SemanticChangeKind::Added,
852                    previous_target_id: None,
853                });
854            }
855        }
856    }
857    for current_region in &current.regions {
858        if !previous
859            .regions
860            .iter()
861            .any(|region| region.id == current_region.id)
862        {
863            for target in &current_region.targets {
864                changes.push(SemanticTargetChange {
865                    region_id: current_region.id.clone(),
866                    target_id: target.reference.clone(),
867                    kind: SemanticChangeKind::Added,
868                    previous_target_id: None,
869                });
870            }
871        }
872    }
873    changes.truncate(MAX_CHANGE_ITEMS);
874    continuity.truncate(MAX_CHANGE_ITEMS);
875    (changes, continuity)
876}
877
878fn validate_route(
879    path: &str,
880    route: &SemanticRouteIdentity,
881) -> Result<(), SemanticObservationError> {
882    validate_route_fields(path, &route.target_id, &route.frame_id, &route.url)
883}
884
885fn validate_route_fields(
886    path: &str,
887    target_id: &str,
888    frame_id: &str,
889    url: &str,
890) -> Result<(), SemanticObservationError> {
891    validate_text(&format!("{path}.targetId"), target_id, MAX_ID_BYTES, false)?;
892    validate_text(&format!("{path}.frameId"), frame_id, MAX_ID_BYTES, false)?;
893    validate_text(&format!("{path}.url"), url, MAX_URL_BYTES, false)
894}
895
896fn validate_evidence(path: &str, evidence: &[String]) -> Result<(), SemanticObservationError> {
897    if evidence.len() > MAX_EVIDENCE_ITEMS {
898        return Err(SemanticObservationError::new(
899            path,
900            format!("contains more than {MAX_EVIDENCE_ITEMS} evidence items"),
901        ));
902    }
903    for (index, item) in evidence.iter().enumerate() {
904        validate_text(&format!("{path}[{index}]"), item, MAX_EVIDENCE_BYTES, false)?;
905    }
906    Ok(())
907}
908
909fn validate_text(
910    path: &str,
911    value: &str,
912    maximum: usize,
913    allow_empty: bool,
914) -> Result<(), SemanticObservationError> {
915    if (!allow_empty && value.is_empty()) || value.len() > maximum {
916        let requirement = if allow_empty {
917            format!("at most {maximum} bytes")
918        } else {
919            format!("non-empty and at most {maximum} bytes")
920        };
921        return Err(SemanticObservationError::new(
922            path,
923            format!("must be {requirement}"),
924        ));
925    }
926    Ok(())
927}
928
929/// Path-aware semantic contract validation error.
930#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
931pub struct SemanticObservationError {
932    pub path: String,
933    pub reason: String,
934}
935
936impl SemanticObservationError {
937    fn new(path: impl Into<String>, reason: impl Into<String>) -> Self {
938        Self {
939            path: path.into(),
940            reason: reason.into(),
941        }
942    }
943}
944
945impl std::fmt::Display for SemanticObservationError {
946    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
947        write!(formatter, "{}: {}", self.path, self.reason)
948    }
949}
950
951impl std::error::Error for SemanticObservationError {}
952
953fn collect_regions(
954    node: &CompactAxNode,
955    revision: u64,
956    route: &SemanticRouteIdentity,
957    regions: &mut Vec<SemanticRegion>,
958    anchors: &mut Vec<(String, Option<String>)>,
959) {
960    if let Some(kind) = region_kind(&node.role) {
961        let ordinal = regions.iter().filter(|region| region.kind == kind).count() + 1;
962        let id = format!("region_{}_{}", region_kind_name(kind), ordinal);
963        let item_count = match kind {
964            SemanticRegionKind::Results
965            | SemanticRegionKind::Collection
966            | SemanticRegionKind::Table => Some(
967                node.children
968                    .iter()
969                    .filter(|child| matches!(child.role.as_str(), "listitem" | "row" | "option"))
970                    .count(),
971            ),
972            _ => None,
973        };
974        regions.push(SemanticRegion {
975            id: id.clone(),
976            kind,
977            label: if node.name.is_empty() {
978                region_kind_label(kind).into()
979            } else {
980                bounded_semantic_text(&node.name, MAX_LABEL_BYTES)
981            },
982            interactive_count: count_interactive(node),
983            item_count,
984            confidence: SemanticConfidence::Exact,
985            evidence: vec![format!("aria-role={}", node.role)],
986            targets: Vec::new(),
987            expansion: Some(SemanticExpansionHandle {
988                region_id: id.clone(),
989                revision,
990                route: route.clone(),
991            }),
992        });
993        anchors.push((id, Some(format!("{}:{}", node.role, node.name))));
994    }
995    for child in &node.children {
996        collect_regions(child, revision, route, regions, anchors);
997    }
998}
999
1000fn populate_level(
1001    regions: &mut [SemanticRegion],
1002    controls: &[crate::browser::dom::CompactInteractiveElement],
1003    anchors: &[(String, Option<String>)],
1004    level: SemanticObservationLevel,
1005) {
1006    if !level.includes_targets() {
1007        return;
1008    }
1009    for control in controls.iter().take(MAX_TARGETS) {
1010        let region_index = anchors
1011            .iter()
1012            .enumerate()
1013            .filter(|(_, (_, anchor))| {
1014                anchor
1015                    .as_ref()
1016                    .is_some_and(|anchor| control.ancestor_path.iter().any(|item| item == anchor))
1017            })
1018            .map(|(index, _)| index)
1019            .next_back()
1020            .unwrap_or_else(|| regions.len().saturating_sub(1));
1021        if let Some(region) = regions.get_mut(region_index) {
1022            region.targets.push(SemanticTarget {
1023                reference: control.reference.clone(),
1024                role: bounded_semantic_text(&control.role, MAX_ROLE_BYTES),
1025                name: bounded_semantic_text(&control.name, MAX_LABEL_BYTES),
1026                input_type: control
1027                    .input_type
1028                    .as_deref()
1029                    .map(|value| bounded_semantic_text(value, MAX_ROLE_BYTES)),
1030            });
1031        }
1032    }
1033}
1034
1035fn is_false(value: &bool) -> bool {
1036    !*value
1037}
1038
1039fn to_semantic_node(node: &CompactAxNode) -> SemanticAccessibilityNode {
1040    SemanticAccessibilityNode {
1041        role: bounded_semantic_text(&node.role, MAX_ROLE_BYTES),
1042        name: bounded_semantic_text(&node.name, MAX_LABEL_BYTES),
1043        children: node.children.iter().map(to_semantic_node).collect(),
1044        interactive: node.interactive,
1045    }
1046}
1047
1048fn count_interactive(node: &CompactAxNode) -> usize {
1049    usize::from(node.interactive) + node.children.iter().map(count_interactive).sum::<usize>()
1050}
1051
1052fn region_kind(role: &str) -> Option<SemanticRegionKind> {
1053    Some(match role {
1054        "navigation" => SemanticRegionKind::Navigation,
1055        "main" => SemanticRegionKind::Main,
1056        "search" => SemanticRegionKind::Search,
1057        "form" => SemanticRegionKind::Form,
1058        "dialog" => SemanticRegionKind::Dialog,
1059        "alert" => SemanticRegionKind::Alert,
1060        "status" => SemanticRegionKind::Status,
1061        "toolbar" => SemanticRegionKind::Toolbar,
1062        "complementary" => SemanticRegionKind::Sidebar,
1063        "article" => SemanticRegionKind::Article,
1064        "contentinfo" => SemanticRegionKind::Footer,
1065        "table" => SemanticRegionKind::Table,
1066        "list" => SemanticRegionKind::Collection,
1067        _ => return None,
1068    })
1069}
1070
1071fn region_kind_name(kind: SemanticRegionKind) -> &'static str {
1072    match kind {
1073        SemanticRegionKind::Navigation => "navigation",
1074        SemanticRegionKind::Main => "main",
1075        SemanticRegionKind::Search => "search",
1076        SemanticRegionKind::Form => "form",
1077        SemanticRegionKind::Dialog => "dialog",
1078        SemanticRegionKind::Alert => "alert",
1079        SemanticRegionKind::Status => "status",
1080        SemanticRegionKind::Toolbar => "toolbar",
1081        SemanticRegionKind::FilterPanel => "filter_panel",
1082        SemanticRegionKind::Results => "results",
1083        SemanticRegionKind::Collection => "collection",
1084        SemanticRegionKind::Table => "table",
1085        SemanticRegionKind::Pagination => "pagination",
1086        SemanticRegionKind::Article => "article",
1087        SemanticRegionKind::Sidebar => "sidebar",
1088        SemanticRegionKind::CheckoutSummary => "checkout_summary",
1089        SemanticRegionKind::Authentication => "authentication",
1090        SemanticRegionKind::Footer => "footer",
1091        SemanticRegionKind::Unknown => "unknown",
1092    }
1093}
1094
1095fn region_kind_label(kind: SemanticRegionKind) -> &'static str {
1096    match kind {
1097        SemanticRegionKind::Navigation => "Navigation",
1098        SemanticRegionKind::Main => "Main content",
1099        SemanticRegionKind::Search => "Search",
1100        SemanticRegionKind::Form => "Form",
1101        SemanticRegionKind::Dialog => "Dialog",
1102        SemanticRegionKind::Alert => "Alert",
1103        SemanticRegionKind::Status => "Status",
1104        SemanticRegionKind::Toolbar => "Toolbar",
1105        SemanticRegionKind::Sidebar => "Sidebar",
1106        SemanticRegionKind::Article => "Article",
1107        SemanticRegionKind::Footer => "Footer",
1108        SemanticRegionKind::Table => "Table",
1109        SemanticRegionKind::Collection => "Collection",
1110        _ => "Page region",
1111    }
1112}
1113
1114fn classify_page(
1115    context: &PageContext,
1116    regions: &[SemanticRegion],
1117) -> (SemanticPageKind, SemanticConfidence, Vec<String>) {
1118    let title = context.page.title.to_ascii_lowercase();
1119    let url = context.page.url.to_ascii_lowercase();
1120    let has = |kind| regions.iter().any(|region| region.kind == kind);
1121    if title.contains("sign in")
1122        || title.contains("log in")
1123        || url.contains("/login")
1124        || url.contains("/signin")
1125        || url.contains("/auth")
1126    {
1127        return (
1128            SemanticPageKind::Authentication,
1129            SemanticConfidence::High,
1130            vec!["title-or-url-signature=authentication".into()],
1131        );
1132    }
1133    if has(SemanticRegionKind::Search) {
1134        let kind = if url.contains("search") || title.contains("search") {
1135            SemanticPageKind::SearchResults
1136        } else {
1137            SemanticPageKind::Search
1138        };
1139        return (
1140            kind,
1141            SemanticConfidence::High,
1142            vec!["aria-role=search".into()],
1143        );
1144    }
1145    if has(SemanticRegionKind::Form) {
1146        return (
1147            SemanticPageKind::Form,
1148            SemanticConfidence::High,
1149            vec!["aria-role=form".into()],
1150        );
1151    }
1152    if has(SemanticRegionKind::Article) {
1153        return (
1154            SemanticPageKind::Article,
1155            SemanticConfidence::High,
1156            vec!["aria-role=article".into()],
1157        );
1158    }
1159    (
1160        SemanticPageKind::Generic,
1161        SemanticConfidence::Unknown,
1162        vec!["no-high-confidence-page-signature".into()],
1163    )
1164}
1165
1166fn bounded_semantic_text(value: &str, maximum: usize) -> String {
1167    if value.len() <= maximum {
1168        return value.to_string();
1169    }
1170    let mut end = maximum.saturating_sub(15);
1171    while end > 0 && !value.is_char_boundary(end) {
1172        end -= 1;
1173    }
1174    format!("{}…[truncated]", &value[..end])
1175}
1176
1177impl super::BrowserSession {
1178    /// Collect fresh, bounded semantic page structure from browser evidence.
1179    pub async fn semantic_observe(
1180        &self,
1181        level: SemanticObservationLevel,
1182    ) -> super::types::BrowserResult<SemanticObservation> {
1183        let context = self.observe_fresh().await?;
1184        SemanticObservation::from_page_context(&context, level).map_err(Into::into)
1185    }
1186
1187    /// Collect a fresh semantic observation and, unless `fresh_only` is set,
1188    /// assess stored knowledge against its current route and landmarks.
1189    ///
1190    /// The store is read-only for this operation. Assessment never supplies a
1191    /// target reference or authorizes an action; callers must still use the
1192    /// current observation and guarded action APIs.
1193    pub async fn semantic_observe_with_knowledge(
1194        &self,
1195        level: SemanticObservationLevel,
1196        store: &super::KnowledgeStore,
1197        options: super::KnowledgeLookupOptions,
1198        fresh_only: bool,
1199    ) -> super::types::BrowserResult<super::KnowledgeObservationReport> {
1200        let observation = self.semantic_observe(level).await?;
1201        if fresh_only {
1202            return Ok(super::KnowledgeObservationReport {
1203                observation,
1204                mode: super::KnowledgeObservationMode::FreshOnly,
1205                assessments: Vec::new(),
1206                eligible_record_ids: Vec::new(),
1207                stale_record_ids: Vec::new(),
1208                out_of_scope_record_ids: Vec::new(),
1209            });
1210        }
1211        let context = super::KnowledgeLookupContext::from_observation(&observation, options)?;
1212        let assessments = store.assess(&context);
1213        let mut eligible_record_ids = Vec::new();
1214        let mut stale_record_ids = Vec::new();
1215        let mut out_of_scope_record_ids = Vec::new();
1216        for assessment in &assessments {
1217            match assessment.status {
1218                super::KnowledgeAssessmentStatus::Eligible => {
1219                    eligible_record_ids.push(assessment.record_id.clone())
1220                }
1221                super::KnowledgeAssessmentStatus::Stale => {
1222                    stale_record_ids.push(assessment.record_id.clone())
1223                }
1224                super::KnowledgeAssessmentStatus::OutOfScope => {
1225                    out_of_scope_record_ids.push(assessment.record_id.clone())
1226                }
1227                super::KnowledgeAssessmentStatus::Contradicted
1228                | super::KnowledgeAssessmentStatus::Quarantined => {}
1229            }
1230        }
1231        Ok(super::KnowledgeObservationReport {
1232            observation,
1233            mode: super::KnowledgeObservationMode::Assessed,
1234            assessments,
1235            eligible_record_ids,
1236            stale_record_ids,
1237            out_of_scope_record_ids,
1238        })
1239    }
1240
1241    /// Expand one semantic region only when its page revision is still current.
1242    pub async fn semantic_expand_region(
1243        &self,
1244        region_id: &str,
1245        revision: u64,
1246        level: SemanticObservationLevel,
1247    ) -> super::types::BrowserResult<SemanticObservation> {
1248        let context = self.observe_fresh().await?;
1249        if context.accessibility.revision != revision {
1250            return Err(SemanticObservationError::new(
1251                "revision",
1252                format!(
1253                    "semantic region revision {revision} is stale; current revision is {}",
1254                    context.accessibility.revision
1255                ),
1256            )
1257            .into());
1258        }
1259        SemanticObservation::scoped_region_from_page_context(&context, level, region_id)
1260            .map_err(Into::into)
1261    }
1262}
1263
1264#[cfg(test)]
1265mod tests {
1266    use super::*;
1267
1268    fn observation() -> SemanticObservation {
1269        let route = SemanticRouteIdentity {
1270            target_id: "target".into(),
1271            frame_id: "frame".into(),
1272            url: "https://example.test/search".into(),
1273        };
1274        SemanticObservation {
1275            schema_version: SEMANTIC_OBSERVATION_SCHEMA_VERSION,
1276            revision: 42,
1277            level: SemanticObservationLevel::Summary,
1278            page: SemanticPage {
1279                kind: SemanticPageKind::SearchResults,
1280                title: "Search".into(),
1281                url: route.url.clone(),
1282                target_id: route.target_id.clone(),
1283                frame_id: route.frame_id.clone(),
1284                confidence: SemanticConfidence::High,
1285                evidence: vec!["role=main".into()],
1286            },
1287            regions: vec![SemanticRegion {
1288                id: "region_results".into(),
1289                kind: SemanticRegionKind::Results,
1290                label: "Search results".into(),
1291                interactive_count: 2,
1292                item_count: Some(10),
1293                confidence: SemanticConfidence::High,
1294                evidence: vec!["repeated item structure".into()],
1295                targets: Vec::new(),
1296                expansion: Some(SemanticExpansionHandle {
1297                    region_id: "region_results".into(),
1298                    revision: 42,
1299                    route: route.clone(),
1300                }),
1301            }],
1302            text: None,
1303            accessibility: None,
1304            raw_accessibility: None,
1305            changes: None,
1306            limits: SemanticObservationLimits::default(),
1307            route,
1308        }
1309    }
1310
1311    #[test]
1312    fn canonical_json_is_stable_and_camel_case() {
1313        let observation = observation();
1314        let first = observation.to_canonical_json().unwrap();
1315        let second = observation.to_canonical_json().unwrap();
1316        assert_eq!(first, second);
1317        assert!(first.contains("\"schemaVersion\":1"));
1318        assert!(first.contains("\"searchResults\""));
1319        assert!(first.contains("\"regionId\":\"region_results\""));
1320    }
1321
1322    #[test]
1323    fn rejects_duplicate_regions_and_route_mismatch() {
1324        let mut duplicate_regions = observation();
1325        duplicate_regions
1326            .regions
1327            .push(duplicate_regions.regions[0].clone());
1328        let error = duplicate_regions.validate().unwrap_err();
1329        assert_eq!(error.path, "regions[1].id");
1330
1331        let mut route_mismatch = observation();
1332        route_mismatch.page.url = "https://other.test".into();
1333        let error = route_mismatch.validate().unwrap_err();
1334        assert_eq!(error.path, "page");
1335    }
1336
1337    #[test]
1338    fn accepts_additive_unknown_response_fields() {
1339        let mut value: Value = serde_json::to_value(observation()).unwrap();
1340        value["futureField"] = Value::Bool(true);
1341        let decoded = SemanticObservation::from_json(&value.to_string()).unwrap();
1342        decoded.validate().unwrap();
1343    }
1344
1345    #[test]
1346    fn additive_response_fixture_remains_compatible() {
1347        let observation = SemanticObservation::from_json(include_str!(
1348            "../../../tests/fixtures/semantic-additive-v1.json"
1349        ))
1350        .unwrap();
1351        observation.validate().unwrap();
1352        assert_eq!(observation.revision, 7);
1353    }
1354
1355    #[test]
1356    fn computes_revision_changes_and_conservative_target_continuity() {
1357        let mut previous = observation();
1358        previous.level = SemanticObservationLevel::Interactive;
1359        previous.regions[0].targets.push(SemanticTarget {
1360            reference: "axr-42-9".into(),
1361            role: "button".into(),
1362            name: "Continue".into(),
1363            input_type: None,
1364        });
1365
1366        let mut current = previous.clone();
1367        current.revision = 43;
1368        current.regions[0].expansion.as_mut().unwrap().revision = 43;
1369        current.regions[0].targets[0].reference = "axr-43-9".into();
1370        current.changes = None;
1371
1372        let changes = current.diff_from(&previous).unwrap();
1373        assert_eq!(changes.from_revision, 42);
1374        assert_eq!(changes.to_revision, 43);
1375        assert_eq!(changes.targets.len(), 1);
1376        assert_eq!(changes.targets[0].kind, SemanticChangeKind::Updated);
1377        assert_eq!(changes.continuity.len(), 1);
1378        assert_eq!(changes.continuity[0].confidence, SemanticConfidence::Medium);
1379
1380        let enriched = current.with_changes_from(&previous).unwrap();
1381        assert!(enriched.changes.is_some());
1382        assert!(SemanticObservation::from_json(&enriched.to_canonical_json().unwrap()).is_ok());
1383    }
1384
1385    #[test]
1386    fn rejects_backwards_or_cross_route_semantic_diffs() {
1387        let previous = observation();
1388        let mut current = observation();
1389        current.revision = 41;
1390        current.regions[0].expansion.as_mut().unwrap().revision = 41;
1391        let error = current.diff_from(&previous).unwrap_err();
1392        assert_eq!(error.path, "revision");
1393
1394        current.revision = 42;
1395        current.regions[0].expansion.as_mut().unwrap().revision = 42;
1396        current.route.url = "https://other.test".into();
1397        current.page.url = "https://other.test".into();
1398        current.regions[0].expansion.as_mut().unwrap().route = current.route.clone();
1399        let error = current.diff_from(&previous).unwrap_err();
1400        assert_eq!(error.path, "route");
1401    }
1402
1403    #[test]
1404    fn classifies_landmarks_without_dropping_interactive_counts() {
1405        let page = super::super::types::PageInfo {
1406            url: "https://example.test/search".into(),
1407            title: "Search".into(),
1408            ready_state: "complete".into(),
1409            target_id: "target".into(),
1410            frame_id: "frame".into(),
1411        };
1412        let mut context = PageContext {
1413            page,
1414            text: "results".into(),
1415            dom: None,
1416            accessibility: super::super::types::CompactAccessibilitySnapshot {
1417                page: super::super::types::PageInfo {
1418                    url: "https://example.test/search".into(),
1419                    title: "Search".into(),
1420                    ready_state: "complete".into(),
1421                    target_id: "target".into(),
1422                    frame_id: "frame".into(),
1423                },
1424                revision: 7,
1425                roots: vec![CompactAxNode {
1426                    role: "search".into(),
1427                    name: "Site search".into(),
1428                    children: vec![CompactAxNode {
1429                        role: "textbox".into(),
1430                        name: "Query".into(),
1431                        children: Vec::new(),
1432                        interactive: true,
1433                    }],
1434                    interactive: false,
1435                }],
1436                interactive: Vec::new(),
1437                truncated: false,
1438                omitted_count: 0,
1439                ranking_applied: false,
1440                completeness: None,
1441            },
1442            consistency: super::super::types::ObservationConsistency {
1443                consistent: true,
1444                attempts: 1,
1445                start_revision: 7,
1446                end_revision: 7,
1447                start_mutation_revision: 0,
1448                end_mutation_revision: 0,
1449            },
1450            boundaries: Default::default(),
1451            incomplete: Vec::new(),
1452            screenshot: None,
1453        };
1454        context
1455            .accessibility
1456            .interactive
1457            .push(crate::browser::dom::CompactInteractiveElement {
1458                reference: "axr-7-9".into(),
1459                role: "textbox".into(),
1460                name: "Query".into(),
1461                backend_dom_node_id: 9,
1462                ancestor_path: vec!["search:Site search".into()],
1463                shadow_host_path: None,
1464                input_type: Some("search".into()),
1465                value: None,
1466                checked: None,
1467                selected_option: None,
1468                empty: false,
1469                read_only: false,
1470                required: false,
1471            });
1472        let semantic =
1473            SemanticObservation::from_page_context(&context, SemanticObservationLevel::Summary)
1474                .unwrap();
1475        assert_eq!(semantic.page.kind, SemanticPageKind::SearchResults);
1476        assert_eq!(semantic.regions[0].id, "region_search_1");
1477        assert_eq!(semantic.regions[0].interactive_count, 1);
1478        assert!(semantic.regions[0].targets.is_empty());
1479        assert!(!semantic.limits.truncated);
1480
1481        let interactive =
1482            SemanticObservation::from_page_context(&context, SemanticObservationLevel::Interactive)
1483                .unwrap();
1484        assert_eq!(interactive.regions[0].targets[0].reference, "axr-7-9");
1485        assert!(interactive.text.is_none());
1486
1487        let detailed =
1488            SemanticObservation::from_page_context(&context, SemanticObservationLevel::Detailed)
1489                .unwrap();
1490        assert_eq!(detailed.text.as_deref(), Some("results"));
1491        assert!(detailed.accessibility.is_some());
1492        assert!(detailed.raw_accessibility.is_none());
1493
1494        let scoped = SemanticObservation::scoped_region_from_page_context(
1495            &context,
1496            SemanticObservationLevel::Structured,
1497            "region_search_1",
1498        )
1499        .unwrap();
1500        assert_eq!(scoped.regions.len(), 1);
1501        assert_eq!(scoped.limits.omitted_regions, 0);
1502    }
1503}