Skip to main content

glass/
extraction.rs

1//! Experimental bounded evidence-extraction contracts.
2//!
3//! This module defines the request boundary for the native extraction engine
4//! planned by issue #30. It does not perform browser work. Inputs are strict
5//! authored contracts; observed evidence will use a separate tolerant model.
6use crate::browser::dom::{CompactAxNode, DomNode};
7use crate::browser::session::PageContext;
8use serde::{Deserialize, Serialize};
9use std::collections::BTreeSet;
10use std::fmt::{Display, Formatter};
11
12/// Version of the extraction request contract.
13pub const EXTRACTION_CONTRACT_SCHEMA_VERSION: u32 = 1;
14
15/// Maximum number of browser nodes one extraction may inspect.
16pub const MAX_EXTRACTION_NODES: u32 = 8_192;
17/// Maximum text bytes retained by one extraction.
18pub const MAX_EXTRACTION_TEXT_BYTES: u32 = 128 * 1024;
19/// Maximum DOM/accessibility depth one extraction may traverse.
20pub const MAX_EXTRACTION_DEPTH: u16 = 64;
21/// Maximum wall-clock duration permitted for one extraction.
22pub const MAX_EXTRACTION_DURATION_MS: u64 = 15_000;
23/// Maximum serialized evidence bytes returned by one extraction.
24pub const MAX_EXTRACTION_OUTPUT_BYTES: u32 = 256 * 1024;
25
26/// The page scope from which evidence may be gathered.
27#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
28#[serde(rename_all = "camelCase", deny_unknown_fields)]
29pub enum ExtractionScope {
30    /// Extract from the active document.
31    Document,
32    /// Extract from one previously identified semantic region.
33    Region { region_id: String },
34    /// Extract from one bounded browsing-context scope.
35    Frame { frame_id: String },
36}
37
38impl ExtractionScope {
39    fn validate(&self) -> Result<(), ExtractionContractError> {
40        match self {
41            Self::Document => Ok(()),
42            Self::Region { region_id } => validate_identifier("scope.regionId", region_id),
43            Self::Frame { frame_id } => validate_identifier("scope.frameId", frame_id),
44        }
45    }
46}
47
48/// Browser evidence categories an extraction may request.
49#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
50#[serde(rename_all = "camelCase", deny_unknown_fields)]
51pub enum EvidenceSource {
52    Dom,
53    Accessibility,
54    Layout,
55    Forms,
56    Navigation,
57    Tables,
58    Collections,
59    Dialogs,
60    Frames,
61    ShadowDom,
62    BoundedProbe,
63}
64
65/// Hard resource limits for one extraction request.
66#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
67#[serde(rename_all = "camelCase", deny_unknown_fields)]
68pub struct ExtractionBudgets {
69    pub max_nodes: u32,
70    pub max_text_bytes: u32,
71    pub max_depth: u16,
72    pub max_duration_ms: u64,
73    pub max_output_bytes: u32,
74}
75
76impl Default for ExtractionBudgets {
77    fn default() -> Self {
78        Self {
79            max_nodes: 2_048,
80            max_text_bytes: 32 * 1024,
81            max_depth: 32,
82            max_duration_ms: 5_000,
83            max_output_bytes: 64 * 1024,
84        }
85    }
86}
87
88impl ExtractionBudgets {
89    /// Validate all limits against the extraction contract maxima.
90    pub fn validate(&self) -> Result<(), ExtractionContractError> {
91        validate_positive_bounded("budgets.maxNodes", self.max_nodes, MAX_EXTRACTION_NODES)?;
92        validate_positive_bounded(
93            "budgets.maxTextBytes",
94            self.max_text_bytes,
95            MAX_EXTRACTION_TEXT_BYTES,
96        )?;
97        validate_positive_bounded("budgets.maxDepth", self.max_depth, MAX_EXTRACTION_DEPTH)?;
98        validate_positive_bounded(
99            "budgets.maxDurationMs",
100            self.max_duration_ms,
101            MAX_EXTRACTION_DURATION_MS,
102        )?;
103        validate_positive_bounded(
104            "budgets.maxOutputBytes",
105            self.max_output_bytes,
106            MAX_EXTRACTION_OUTPUT_BYTES,
107        )?;
108        Ok(())
109    }
110}
111
112/// A strict, non-mutating evidence extraction request.
113#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
114#[serde(rename_all = "camelCase", deny_unknown_fields)]
115pub struct ExtractionRequest {
116    pub schema_version: u32,
117    pub scope: ExtractionScope,
118    pub sources: Vec<EvidenceSource>,
119    pub budgets: ExtractionBudgets,
120}
121
122impl ExtractionRequest {
123    /// Validate the authored request before any browser work starts.
124    pub fn validate(&self) -> Result<(), ExtractionContractError> {
125        if self.schema_version != EXTRACTION_CONTRACT_SCHEMA_VERSION {
126            return Err(ExtractionContractError::new(
127                "schemaVersion",
128                "unsupported extraction contract schema version",
129            ));
130        }
131        self.scope.validate()?;
132        if self.sources.is_empty() {
133            return Err(ExtractionContractError::new(
134                "sources",
135                "at least one evidence source is required",
136            ));
137        }
138        let mut unique_sources = BTreeSet::new();
139        for source in &self.sources {
140            if !unique_sources.insert(*source) {
141                return Err(ExtractionContractError::new(
142                    "sources",
143                    "evidence sources must not be duplicated",
144                ));
145            }
146        }
147        self.budgets.validate()
148    }
149
150    /// Parse and validate a strict authored request.
151    pub fn from_json(input: &str) -> Result<Self, ExtractionContractError> {
152        let request: Self = serde_json::from_str(input).map_err(|error| {
153            ExtractionContractError::new("$", format!("invalid extraction request: {error}"))
154        })?;
155        request.validate()?;
156        Ok(request)
157    }
158
159    /// Serialize a validated request with stable field ordering.
160    pub fn to_canonical_json(&self) -> Result<String, ExtractionContractError> {
161        self.validate()?;
162        serde_json::to_string(self).map_err(|error| {
163            ExtractionContractError::new(
164                "$",
165                format!("failed to serialize extraction request: {error}"),
166            )
167        })
168    }
169}
170
171/// Explainable quality class attached to an evidence fact.
172#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
173#[serde(rename_all = "lowercase")]
174pub enum EvidenceQuality {
175    Confirmed,
176    Strong,
177    Partial,
178    Inferred,
179    Conflicted,
180    Opaque,
181}
182
183/// Explicit semantic relationship hint carried by bounded evidence.
184#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
185#[serde(rename_all = "camelCase")]
186pub enum EvidenceRelationshipHint {
187    Contains,
188    Labels,
189    Owns,
190    Controls,
191    NavigatesTo,
192    Opens,
193    Confirms,
194    Cancels,
195    Continues,
196    Submits,
197    HeaderFor,
198    CellOf,
199    Selects,
200    RepeatsAs,
201    ScopedTo,
202}
203
204/// Coverage summary for one bounded extraction.
205#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
206#[serde(rename_all = "camelCase")]
207pub struct EvidenceCoverage {
208    pub structural: EvidenceQuality,
209    pub semantic: EvidenceQuality,
210    pub interactive_entities_observed: u32,
211    pub opaque_regions: u32,
212    #[serde(default, skip_serializing_if = "Vec::is_empty")]
213    pub reasons: Vec<String>,
214}
215
216/// Bounded evidence produced from an existing page observation.
217#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
218#[serde(rename_all = "camelCase")]
219pub struct ExtractionEvidence {
220    pub schema_version: u32,
221    pub revision: u64,
222    pub scope: ExtractionScope,
223    pub sources: Vec<EvidenceSource>,
224    pub facts: Vec<EvidenceFact>,
225    pub limits: ExtractionEvidenceLimits,
226    pub coverage: EvidenceCoverage,
227}
228
229/// One redacted, source-labelled fact from browser evidence.
230#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
231#[serde(rename_all = "camelCase")]
232pub struct EvidenceFact {
233    pub source: EvidenceSource,
234    pub kind: String,
235    pub quality: EvidenceQuality,
236    #[serde(skip_serializing_if = "Option::is_none")]
237    pub role: Option<String>,
238    #[serde(skip_serializing_if = "Option::is_none")]
239    pub name: Option<String>,
240    /// Bounded observed accessibility region role for relationship reconciliation.
241    #[serde(skip_serializing_if = "Option::is_none")]
242    pub parent_role: Option<String>,
243    /// Explicit semantic relationship, when a source can prove one.
244    #[serde(skip_serializing_if = "Option::is_none")]
245    pub relationship_hint: Option<EvidenceRelationshipHint>,
246    #[serde(skip_serializing_if = "Option::is_none")]
247    pub input_type: Option<String>,
248    #[serde(skip_serializing_if = "Option::is_none")]
249    pub required: Option<bool>,
250    #[serde(skip_serializing_if = "Option::is_none")]
251    pub read_only: Option<bool>,
252    #[serde(skip_serializing_if = "Option::is_none")]
253    pub empty: Option<bool>,
254    #[serde(skip_serializing_if = "Option::is_none")]
255    pub geometry_present: Option<bool>,
256}
257
258impl ExtractionEvidence {
259    /// Validate every explicit relationship hint against its source contract.
260    pub fn validate_relationship_hints(&self) -> Result<(), ExtractionContractError> {
261        for (index, fact) in self.facts.iter().enumerate() {
262            let path = format!("facts[{index}].relationshipHint");
263            fact.validate_relationship_hint(&path)?;
264        }
265        Ok(())
266    }
267}
268
269impl EvidenceFact {
270    /// Validate one explicit relationship hint and its required parent role.
271    pub fn validate_relationship_hint(&self, path: &str) -> Result<(), ExtractionContractError> {
272        let Some(hint) = self.relationship_hint else {
273            return Ok(());
274        };
275        if self.parent_role.as_deref().is_none_or(str::is_empty) {
276            return Err(ExtractionContractError::new(
277                path,
278                "relationship hints require a bounded parent role",
279            ));
280        }
281        if !relationship_hint_allowed(self.source, hint) {
282            return Err(ExtractionContractError::new(
283                path,
284                format!(
285                    "relationship hint {hint:?} is not supported by source {:?}",
286                    self.source
287                ),
288            ));
289        }
290        Ok(())
291    }
292}
293
294fn relationship_hint_allowed(source: EvidenceSource, hint: EvidenceRelationshipHint) -> bool {
295    match source {
296        EvidenceSource::Accessibility => matches!(
297            hint,
298            EvidenceRelationshipHint::Contains
299                | EvidenceRelationshipHint::Labels
300                | EvidenceRelationshipHint::Owns
301                | EvidenceRelationshipHint::Controls
302                | EvidenceRelationshipHint::Opens
303                | EvidenceRelationshipHint::Confirms
304                | EvidenceRelationshipHint::Cancels
305                | EvidenceRelationshipHint::Continues
306                | EvidenceRelationshipHint::Selects
307                | EvidenceRelationshipHint::ScopedTo
308        ),
309        EvidenceSource::Dom => matches!(
310            hint,
311            EvidenceRelationshipHint::Contains
312                | EvidenceRelationshipHint::Labels
313                | EvidenceRelationshipHint::Owns
314                | EvidenceRelationshipHint::Controls
315                | EvidenceRelationshipHint::NavigatesTo
316                | EvidenceRelationshipHint::Opens
317                | EvidenceRelationshipHint::Confirms
318                | EvidenceRelationshipHint::Cancels
319                | EvidenceRelationshipHint::Continues
320                | EvidenceRelationshipHint::Submits
321                | EvidenceRelationshipHint::HeaderFor
322                | EvidenceRelationshipHint::CellOf
323                | EvidenceRelationshipHint::Selects
324                | EvidenceRelationshipHint::RepeatsAs
325                | EvidenceRelationshipHint::ScopedTo
326        ),
327        EvidenceSource::Forms => matches!(
328            hint,
329            EvidenceRelationshipHint::Contains
330                | EvidenceRelationshipHint::Labels
331                | EvidenceRelationshipHint::Owns
332                | EvidenceRelationshipHint::Controls
333                | EvidenceRelationshipHint::Submits
334        ),
335        EvidenceSource::Layout => matches!(
336            hint,
337            EvidenceRelationshipHint::Contains | EvidenceRelationshipHint::ScopedTo
338        ),
339        EvidenceSource::Navigation
340        | EvidenceSource::Tables
341        | EvidenceSource::Collections
342        | EvidenceSource::Dialogs
343        | EvidenceSource::Frames
344        | EvidenceSource::ShadowDom
345        | EvidenceSource::BoundedProbe => false,
346    }
347}
348
349/// Explicit omissions and truncation from one evidence extraction.
350#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
351#[serde(rename_all = "camelCase")]
352pub struct ExtractionEvidenceLimits {
353    pub truncated: bool,
354    pub omitted_facts: u32,
355    pub text_bytes: u32,
356    pub missing_sources: Vec<EvidenceSource>,
357}
358
359/// Extract bounded, redacted facts from an existing page observation.
360///
361/// This adapter is deliberately side-effect free. It consumes only the
362/// already-collected `PageContext`; browser acquisition remains owned by the
363/// session observation layer.
364pub fn extract_page_context(
365    context: &PageContext,
366    request: &ExtractionRequest,
367) -> Result<ExtractionEvidence, ExtractionContractError> {
368    request.validate()?;
369    if !matches!(request.scope, ExtractionScope::Document) {
370        return Err(ExtractionContractError::new(
371            "scope",
372            "region and frame extraction require a scoped page observation",
373        ));
374    }
375
376    let mut collector = EvidenceCollector::new(request.budgets);
377    let mut missing_sources = BTreeSet::new();
378    let incomplete_accessibility = context.incomplete.iter().any(|reason| {
379        matches!(
380            reason,
381            crate::browser::session::ObservationIncompleteReason::AccessibilityNode
382                | crate::browser::session::ObservationIncompleteReason::AccessibilityLabel
383        )
384    });
385
386    for source in &request.sources {
387        match source {
388            EvidenceSource::Accessibility => {
389                if incomplete_accessibility {
390                    missing_sources.insert(*source);
391                } else {
392                    for root in &context.accessibility.roots {
393                        collect_accessibility(root, &mut collector, 0, None);
394                    }
395                }
396            }
397            EvidenceSource::Dom => {
398                if let Some(root) = context.dom.as_ref() {
399                    collect_dom(root, &mut collector, 0);
400                } else {
401                    missing_sources.insert(*source);
402                }
403            }
404            EvidenceSource::Forms => {
405                for control in &context.accessibility.interactive {
406                    if control.input_type.is_some() || is_form_control_role(&control.role) {
407                        collector.push(EvidenceFact {
408                            source: *source,
409                            kind: "control".into(),
410                            quality: EvidenceQuality::Strong,
411                            role: Some(control.role.clone()),
412                            name: Some(control.name.clone()),
413                            input_type: control.input_type.clone(),
414                            required: Some(control.required),
415                            read_only: Some(control.read_only),
416                            empty: Some(control.empty),
417                            geometry_present: None,
418                            parent_role: control
419                                .ancestor_path
420                                .last()
421                                .and_then(|value| value.split(':').next())
422                                .filter(|value| !value.is_empty())
423                                .map(str::to_owned),
424                            relationship_hint: custom_form_control_hint(
425                                &control.role,
426                                control
427                                    .ancestor_path
428                                    .last()
429                                    .and_then(|value| value.split(':').next()),
430                            ),
431                        });
432                    }
433                }
434            }
435            EvidenceSource::Layout => {
436                if let Some(root) = context.dom.as_ref() {
437                    collect_layout(root, &mut collector, *source, 0);
438                } else {
439                    missing_sources.insert(*source);
440                }
441            }
442            _ => {
443                missing_sources.insert(*source);
444            }
445        }
446    }
447    collector.truncated |= context.accessibility.truncated || context.boundaries.truncated;
448    let missing_source_values: Vec<_> = missing_sources.iter().copied().collect();
449    let coverage = evidence_coverage(
450        context,
451        &request.sources,
452        &missing_sources,
453        collector.truncated,
454    );
455    let mut evidence = ExtractionEvidence {
456        schema_version: EXTRACTION_CONTRACT_SCHEMA_VERSION,
457        revision: context.accessibility.revision,
458        scope: request.scope.clone(),
459        sources: request.sources.clone(),
460        facts: collector.facts,
461        limits: ExtractionEvidenceLimits {
462            truncated: collector.truncated,
463            omitted_facts: collector.omitted_facts,
464            text_bytes: collector.text_bytes,
465            missing_sources: missing_source_values,
466        },
467        coverage,
468    };
469    trim_to_output_budget(&mut evidence, request.budgets.max_output_bytes)?;
470    Ok(evidence)
471}
472
473struct EvidenceCollector {
474    budgets: ExtractionBudgets,
475    facts: Vec<EvidenceFact>,
476    omitted_facts: u32,
477    text_bytes: u32,
478    truncated: bool,
479}
480
481impl EvidenceCollector {
482    fn new(budgets: ExtractionBudgets) -> Self {
483        Self {
484            budgets,
485            facts: Vec::new(),
486            omitted_facts: 0,
487            text_bytes: 0,
488            truncated: false,
489        }
490    }
491
492    fn allow_depth(&mut self, depth: u16) -> bool {
493        if depth >= self.budgets.max_depth {
494            self.omitted_facts = self.omitted_facts.saturating_add(1);
495            self.truncated = true;
496            false
497        } else {
498            true
499        }
500    }
501
502    fn push(&mut self, mut fact: EvidenceFact) {
503        if self.facts.len() as u32 >= self.budgets.max_nodes {
504            self.omitted_facts = self.omitted_facts.saturating_add(1);
505            self.truncated = true;
506            return;
507        }
508        fact.role = self.bound_text(fact.role.take());
509        fact.name = self.bound_text(fact.name.take());
510        fact.input_type = self.bound_text(fact.input_type.take());
511        self.facts.push(fact);
512    }
513
514    fn bound_text(&mut self, value: Option<String>) -> Option<String> {
515        let value = value?;
516        let remaining = self.budgets.max_text_bytes.saturating_sub(self.text_bytes) as usize;
517        if remaining == 0 {
518            self.truncated = true;
519            return None;
520        }
521        let bounded = truncate_utf8(&value, remaining);
522
523        self.text_bytes = self.text_bytes.saturating_add(bounded.len() as u32);
524        if bounded.len() < value.len() {
525            self.truncated = true;
526        }
527        Some(bounded)
528    }
529}
530fn evidence_coverage(
531    context: &PageContext,
532    sources: &[EvidenceSource],
533    missing_sources: &BTreeSet<EvidenceSource>,
534    truncated: bool,
535) -> EvidenceCoverage {
536    let requested = |source| sources.contains(&source);
537    let available = |source| requested(source) && !missing_sources.contains(&source);
538    let structural = if available(EvidenceSource::Dom) && !truncated {
539        EvidenceQuality::Strong
540    } else if available(EvidenceSource::Dom) || available(EvidenceSource::Accessibility) {
541        EvidenceQuality::Partial
542    } else {
543        EvidenceQuality::Opaque
544    };
545    let semantic = if available(EvidenceSource::Accessibility) && !truncated {
546        EvidenceQuality::Strong
547    } else if requested(EvidenceSource::Accessibility) {
548        EvidenceQuality::Partial
549    } else {
550        EvidenceQuality::Opaque
551    };
552    let opaque_regions = (context.boundaries.child_frames
553        + context.boundaries.shadow_roots
554        + context.boundaries.canvases) as u32;
555    let mut reasons = missing_sources
556        .iter()
557        .map(|source| format!("missingSource:{source:?}"))
558        .collect::<Vec<_>>();
559    if truncated {
560        reasons.push("budgetTruncated".into());
561    }
562    if context.boundaries.child_frames > 0 {
563        reasons.push("frameBoundary".into());
564    }
565    if context.boundaries.shadow_roots > 0 {
566        reasons.push("shadowBoundary".into());
567    }
568    if context.boundaries.canvases > 0 {
569        reasons.push("canvasBoundary".into());
570    }
571    reasons.truncate(16);
572    EvidenceCoverage {
573        structural,
574        semantic,
575        interactive_entities_observed: context.accessibility.interactive.len() as u32,
576        opaque_regions,
577        reasons,
578    }
579}
580
581fn is_form_control_role(role: &str) -> bool {
582    matches!(
583        role,
584        "checkbox"
585            | "combobox"
586            | "listbox"
587            | "radio"
588            | "slider"
589            | "spinbutton"
590            | "switch"
591            | "textbox"
592    )
593}
594
595fn custom_form_control_hint(
596    control_role: &str,
597    parent_role: Option<&str>,
598) -> Option<EvidenceRelationshipHint> {
599    if parent_role.is_some_and(|role| role.eq_ignore_ascii_case("form"))
600        && matches!(
601            control_role,
602            "checkbox" | "combobox" | "listbox" | "radio" | "slider" | "spinbutton" | "switch"
603        )
604    {
605        Some(EvidenceRelationshipHint::Controls)
606    } else {
607        None
608    }
609}
610fn is_region_role(role: &str) -> bool {
611    matches!(
612        role,
613        "article" | "complementary" | "main" | "navigation" | "region" | "search" | "toolbar"
614    )
615}
616
617fn collect_accessibility(
618    node: &CompactAxNode,
619    collector: &mut EvidenceCollector,
620    depth: u16,
621    parent_role: Option<&str>,
622) {
623    if !collector.allow_depth(depth) {
624        return;
625    }
626    collector.push(EvidenceFact {
627        source: EvidenceSource::Accessibility,
628        kind: "node".into(),
629        quality: EvidenceQuality::Confirmed,
630        role: Some(node.role.clone()),
631        name: Some(node.name.clone()),
632        input_type: None,
633        required: None,
634        read_only: None,
635        empty: None,
636        geometry_present: None,
637        parent_role: parent_role.map(str::to_owned),
638        relationship_hint: None,
639    });
640    let next_parent_role = if is_region_role(&node.role) {
641        Some(node.role.as_str())
642    } else {
643        parent_role
644    };
645    for child in &node.children {
646        collect_accessibility(child, collector, depth.saturating_add(1), next_parent_role);
647    }
648}
649
650fn collect_dom(node: &DomNode, collector: &mut EvidenceCollector, depth: u16) {
651    if !collector.allow_depth(depth) {
652        return;
653    }
654    collector.push(EvidenceFact {
655        source: EvidenceSource::Dom,
656        kind: "element".into(),
657        quality: EvidenceQuality::Confirmed,
658        role: None,
659        name: Some(node.node_name.clone()),
660        input_type: None,
661        required: None,
662        read_only: None,
663        empty: None,
664        geometry_present: None,
665        parent_role: None,
666        relationship_hint: None,
667    });
668    for child in &node.children {
669        collect_dom(child, collector, depth.saturating_add(1));
670    }
671}
672
673fn collect_layout(
674    node: &DomNode,
675    collector: &mut EvidenceCollector,
676    source: EvidenceSource,
677    depth: u16,
678) {
679    if !collector.allow_depth(depth) {
680        return;
681    }
682    collector.push(EvidenceFact {
683        source,
684        kind: "geometry".into(),
685        quality: EvidenceQuality::Strong,
686        role: None,
687        name: Some(node.node_name.clone()),
688        input_type: None,
689        required: None,
690        read_only: None,
691        empty: None,
692        geometry_present: Some(node.bounding_box.is_some()),
693        parent_role: None,
694        relationship_hint: None,
695    });
696    for child in &node.children {
697        collect_layout(child, collector, source, depth.saturating_add(1));
698    }
699}
700
701fn trim_to_output_budget(
702    evidence: &mut ExtractionEvidence,
703    max_output_bytes: u32,
704) -> Result<(), ExtractionContractError> {
705    while serde_json::to_vec(evidence)
706        .map_err(|error| ExtractionContractError::new("$", error.to_string()))?
707        .len()
708        > max_output_bytes as usize
709    {
710        if evidence.facts.pop().is_none() {
711            return Err(ExtractionContractError::new(
712                "budgets.maxOutputBytes",
713                "output budget is too small for extraction metadata",
714            ));
715        }
716        evidence.limits.omitted_facts = evidence.limits.omitted_facts.saturating_add(1);
717        evidence.limits.truncated = true;
718    }
719    Ok(())
720}
721
722fn truncate_utf8(value: &str, max_bytes: usize) -> String {
723    if value.len() <= max_bytes {
724        return value.to_owned();
725    }
726    value
727        .char_indices()
728        .take_while(|(index, _)| *index < max_bytes)
729        .map(|(_, character)| character)
730        .collect()
731}
732
733/// A machine-readable extraction contract error.
734#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
735#[serde(rename_all = "camelCase")]
736pub struct ExtractionContractError {
737    pub path: String,
738    pub reason: String,
739}
740
741impl ExtractionContractError {
742    fn new(path: impl Into<String>, reason: impl Into<String>) -> Self {
743        Self {
744            path: path.into(),
745            reason: reason.into(),
746        }
747    }
748}
749
750impl Display for ExtractionContractError {
751    fn fmt(&self, formatter: &mut Formatter<'_>) -> std::fmt::Result {
752        write!(formatter, "{}: {}", self.path, self.reason)
753    }
754}
755
756impl std::error::Error for ExtractionContractError {}
757
758fn validate_identifier(path: &str, value: &str) -> Result<(), ExtractionContractError> {
759    if value.is_empty() || value.len() > 256 || value.chars().any(char::is_control) {
760        return Err(ExtractionContractError::new(
761            path,
762            "identifier must contain 1-256 non-control characters",
763        ));
764    }
765    Ok(())
766}
767
768fn validate_positive_bounded<T>(
769    path: &str,
770    value: T,
771    maximum: T,
772) -> Result<(), ExtractionContractError>
773where
774    T: Copy + PartialOrd + From<u8>,
775{
776    if value < T::from(1) || value > maximum {
777        return Err(ExtractionContractError::new(
778            path,
779            "value must be positive and within the extraction contract bound",
780        ));
781    }
782    Ok(())
783}
784
785#[cfg(test)]
786mod tests {
787    use super::*;
788    use serde_json::json;
789
790    fn request() -> ExtractionRequest {
791        ExtractionRequest {
792            schema_version: EXTRACTION_CONTRACT_SCHEMA_VERSION,
793            scope: ExtractionScope::Document,
794            sources: vec![EvidenceSource::Accessibility, EvidenceSource::Forms],
795            budgets: ExtractionBudgets::default(),
796        }
797    }
798
799    #[test]
800    fn request_accepts_bounded_non_mutating_sources() {
801        let request = request();
802        assert!(request.validate().is_ok());
803        assert_eq!(
804            request.to_canonical_json().unwrap(),
805            request.to_canonical_json().unwrap()
806        );
807    }
808
809    #[test]
810    fn request_rejects_duplicate_sources() {
811        let mut request = request();
812        request.sources.push(EvidenceSource::Forms);
813        let error = request.validate().unwrap_err();
814        assert_eq!(error.path, "sources");
815    }
816
817    #[test]
818    fn request_rejects_budget_overflow_and_zero() {
819        let mut request = request();
820        request.budgets.max_nodes = 0;
821        assert_eq!(request.validate().unwrap_err().path, "budgets.maxNodes");
822        request.budgets.max_nodes = MAX_EXTRACTION_NODES + 1;
823        assert_eq!(request.validate().unwrap_err().path, "budgets.maxNodes");
824    }
825
826    #[test]
827    fn request_rejects_unknown_authored_fields() {
828        let value = json!({
829            "schemaVersion": 1,
830            "scope": "document",
831            "sources": ["accessibility"],
832            "budgets": ExtractionBudgets::default(),
833            "futureField": true
834        });
835        assert!(ExtractionRequest::from_json(&value.to_string()).is_err());
836    }
837
838    #[test]
839    fn scoped_requests_require_bounded_identifiers() {
840        let mut request = request();
841        request.scope = ExtractionScope::Region {
842            region_id: "".into(),
843        };
844        assert_eq!(request.validate().unwrap_err().path, "scope.regionId");
845    }
846    fn page_context() -> PageContext {
847        use crate::browser::dom::{CompactAxNode, CompactInteractiveElement, DomNode};
848        use crate::browser::session::{
849            CompactAccessibilitySnapshot, ObservationBoundarySummary, ObservationConsistency,
850            PageInfo,
851        };
852
853        let page = PageInfo {
854            url: "https://example.test/form".into(),
855            title: "Example form".into(),
856            ready_state: "interactive".into(),
857            target_id: "target-1".into(),
858            frame_id: "frame-1".into(),
859        };
860        let accessibility = CompactAccessibilitySnapshot {
861            page: page.clone(),
862            revision: 7,
863            roots: vec![CompactAxNode {
864                role: "form".into(),
865                name: "Example form".into(),
866                children: vec![CompactAxNode {
867                    role: "textbox".into(),
868                    name: "Full name".into(),
869                    children: Vec::new(),
870                    interactive: true,
871                }],
872                interactive: false,
873            }],
874            interactive: vec![CompactInteractiveElement {
875                reference: "axr-1-1".into(),
876                role: "textbox".into(),
877                name: "Full name".into(),
878                backend_dom_node_id: 11,
879                ancestor_path: Vec::new(),
880                shadow_host_path: None,
881                input_type: Some("text".into()),
882                value: Some("secret-value".into()),
883                checked: None,
884                selected_option: None,
885                empty: true,
886                read_only: false,
887                required: true,
888            }],
889            truncated: false,
890            omitted_count: 0,
891            ranking_applied: false,
892            completeness: None,
893        };
894        PageContext {
895            page,
896            text: "secret-value".into(),
897            dom: Some(DomNode {
898                node_id: 1,
899                node_name: "HTML".into(),
900                node_value: String::new(),
901                children: vec![DomNode {
902                    node_id: 2,
903                    node_name: "INPUT".into(),
904                    node_value: String::new(),
905                    children: Vec::new(),
906                    attributes: vec!["type".into(), "text".into()],
907                    bounding_box: Some([0.0, 0.0, 120.0, 24.0]),
908                }],
909                attributes: Vec::new(),
910                bounding_box: Some([0.0, 0.0, 800.0, 600.0]),
911            }),
912            accessibility,
913            consistency: ObservationConsistency {
914                consistent: true,
915                attempts: 1,
916                start_revision: 7,
917                end_revision: 7,
918                start_mutation_revision: 0,
919                end_mutation_revision: 0,
920            },
921            boundaries: ObservationBoundarySummary::default(),
922            incomplete: Vec::new(),
923            screenshot: None,
924        }
925    }
926
927    #[test]
928    fn extraction_collects_supported_sources_and_reports_missing_sources() {
929        let mut request = request();
930        request.sources = vec![
931            EvidenceSource::Accessibility,
932            EvidenceSource::Dom,
933            EvidenceSource::Forms,
934            EvidenceSource::Layout,
935            EvidenceSource::Navigation,
936        ];
937        let evidence = extract_page_context(&page_context(), &request).unwrap();
938        assert_eq!(evidence.revision, 7);
939        assert!(!evidence.facts.is_empty());
940        assert_eq!(evidence.coverage.structural, EvidenceQuality::Strong);
941        assert_eq!(evidence.coverage.semantic, EvidenceQuality::Strong);
942        assert_eq!(
943            evidence.facts.first().map(|fact| fact.quality),
944            Some(EvidenceQuality::Confirmed)
945        );
946        assert!(
947            evidence
948                .coverage
949                .reasons
950                .iter()
951                .any(|reason| reason == "missingSource:Navigation")
952        );
953        assert_eq!(
954            evidence.limits.missing_sources,
955            vec![EvidenceSource::Navigation]
956        );
957        assert!(
958            !serde_json::to_string(&evidence)
959                .unwrap()
960                .contains("secret-value")
961        );
962    }
963    #[test]
964    fn extraction_emits_controls_hint_for_custom_form_roles() {
965        let mut plain_request = request();
966        plain_request.sources = vec![EvidenceSource::Forms];
967        let plain_evidence = extract_page_context(&page_context(), &plain_request).unwrap();
968        assert!(
969            plain_evidence
970                .facts
971                .iter()
972                .all(|fact| fact.relationship_hint.is_none())
973        );
974
975        let mut context = page_context();
976        context.accessibility.interactive[0].role = "combobox".into();
977        context.accessibility.interactive[0].ancestor_path = vec!["form:Example form".into()];
978        let mut request = request();
979        request.sources = vec![EvidenceSource::Forms];
980        let evidence = extract_page_context(&context, &request).unwrap();
981        let fact = evidence
982            .facts
983            .iter()
984            .find(|fact| fact.source == EvidenceSource::Forms)
985            .expect("custom form control should produce a form fact");
986        assert_eq!(fact.parent_role.as_deref(), Some("form"));
987        assert_eq!(
988            fact.relationship_hint,
989            Some(EvidenceRelationshipHint::Controls)
990        );
991    }
992
993    #[test]
994    fn extraction_emits_controls_hints_for_extended_custom_form_roles() {
995        for role in ["listbox", "slider", "spinbutton", "switch"] {
996            let mut context = page_context();
997            context.accessibility.interactive[0].role = role.into();
998            context.accessibility.interactive[0].ancestor_path = vec!["form:Example form".into()];
999            let mut request = request();
1000            request.sources = vec![EvidenceSource::Forms];
1001            let evidence = extract_page_context(&context, &request).unwrap();
1002            let fact = evidence
1003                .facts
1004                .iter()
1005                .find(|fact| fact.source == EvidenceSource::Forms)
1006                .expect("extended custom form control should produce a form fact");
1007            assert_eq!(
1008                fact.relationship_hint,
1009                Some(EvidenceRelationshipHint::Controls)
1010            );
1011        }
1012    }
1013
1014    #[test]
1015    fn extracted_custom_control_hint_reconciles_to_controls_edge() {
1016        let mut context = page_context();
1017        context.accessibility.interactive[0].role = "combobox".into();
1018        context.accessibility.interactive[0].ancestor_path = vec!["form:Example form".into()];
1019        let evidence = extract_page_context(&context, &request()).unwrap();
1020        let draft = crate::web_ir::reconcile_evidence(&evidence).unwrap();
1021        assert_eq!(draft.relationship_hint_diagnostics.len(), 1);
1022        assert_eq!(
1023            draft.relationship_hint_diagnostics[0].status,
1024            crate::web_ir::RelationshipHintDiagnosticStatus::Emitted
1025        );
1026        assert!(draft.relationships.iter().any(
1027            |relationship| relationship.kind == crate::web_ir::DraftRelationshipKind::Controls
1028        ));
1029    }
1030
1031    #[test]
1032    fn extraction_enforces_node_and_depth_budgets() {
1033        let mut request = request();
1034        request.sources = vec![EvidenceSource::Dom];
1035        request.budgets.max_nodes = 1;
1036        request.budgets.max_depth = 1;
1037        let evidence = extract_page_context(&page_context(), &request).unwrap();
1038        assert_eq!(evidence.facts.len(), 1);
1039        assert!(evidence.limits.truncated);
1040        assert!(evidence.limits.omitted_facts > 0);
1041    }
1042
1043    #[test]
1044    fn extraction_rejects_unbound_scopes_until_scoped_observation_exists() {
1045        let mut request = request();
1046        request.scope = ExtractionScope::Region {
1047            region_id: "region-main".into(),
1048        };
1049        let error = extract_page_context(&page_context(), &request).unwrap_err();
1050        assert_eq!(error.path, "scope");
1051    }
1052    #[test]
1053    fn extraction_reports_opaque_boundaries_without_claiming_completeness() {
1054        let mut context = page_context();
1055        context.boundaries.child_frames = 1;
1056        let evidence = extract_page_context(&context, &request()).unwrap();
1057        assert_eq!(evidence.coverage.opaque_regions, 1);
1058        assert!(
1059            evidence
1060                .coverage
1061                .reasons
1062                .iter()
1063                .any(|reason| reason == "frameBoundary")
1064        );
1065        assert_ne!(evidence.coverage.semantic, EvidenceQuality::Opaque);
1066    }
1067}