Skip to main content

glass/browser/session/
intent.rs

1//! Versioned intent-resolution contracts.
2//!
3//! This module describes how a caller asks Glass to resolve bounded browser
4//! intent into inspectable candidates. It does not perform resolution or
5//! authorize an action; those concerns remain in the later resolver and the
6//! existing guarded action executor.
7
8use serde::{Deserialize, Serialize};
9use sha2::{Digest, Sha256};
10use std::collections::BTreeSet;
11
12#[cfg(test)]
13use super::{
14    SemanticExpansionHandle, SemanticObservation, SemanticPage, SemanticRegion, SemanticTarget,
15};
16use super::{SemanticPageKind, SemanticRegionKind, SemanticRouteIdentity};
17
18pub const INTENT_RESOLUTION_SCHEMA_VERSION: u32 = 1;
19const MAX_INTENT_BYTES: usize = 512;
20const MAX_ACTION_BYTES: usize = 64;
21const MAX_ID_BYTES: usize = 128;
22const MAX_LABEL_BYTES: usize = 256;
23const MAX_EVIDENCE_ITEMS: usize = 8;
24const MAX_EVIDENCE_BYTES: usize = 160;
25const MAX_EXCLUDE_TEXT: usize = 8;
26const MAX_CANDIDATES: usize = 32;
27const MAX_SUGGESTIONS: usize = 8;
28const MAX_EXECUTION_VALUE_BYTES: usize = 4_096;
29
30/// Action requested after intent resolution succeeds.
31#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
32#[serde(rename_all = "lowercase")]
33pub enum SemanticIntentAction {
34    Click,
35    Type,
36    Clear,
37    Check,
38    Uncheck,
39    Select,
40    Submit,
41    Open,
42    Close,
43    Search,
44    Filter,
45    Sort,
46    Paginate,
47    Toggle,
48    Expand,
49    Collapse,
50    Download,
51    Upload,
52    Inspect,
53    Extract,
54}
55
56/// A caller-approved candidate and the bounded value needed by value-bearing
57/// actions. Resolution remains inspectable and separate from dispatch.
58#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
59#[serde(rename_all = "camelCase", deny_unknown_fields)]
60pub struct SemanticIntentExecutionRequest {
61    pub request: SemanticIntentRequest,
62    pub candidate_id: String,
63    #[serde(default, skip_serializing_if = "Option::is_none")]
64    pub value: Option<String>,
65}
66
67impl SemanticIntentExecutionRequest {
68    pub fn validate(&self) -> Result<(), IntentResolutionError> {
69        self.request.validate()?;
70        validate_text("candidateId", &self.candidate_id, MAX_ID_BYTES, false)?;
71        if let Some(value) = &self.value {
72            validate_text("value", value, MAX_EXECUTION_VALUE_BYTES, true)?;
73        }
74        match self.request.action {
75            SemanticIntentAction::Type | SemanticIntentAction::Select
76                if self.value.as_deref().is_none_or(str::is_empty) =>
77            {
78                return Err(IntentResolutionError::new(
79                    "value",
80                    "this action requires a non-empty value",
81                ));
82            }
83            SemanticIntentAction::Click
84            | SemanticIntentAction::Clear
85            | SemanticIntentAction::Check
86            | SemanticIntentAction::Uncheck
87            | SemanticIntentAction::Submit
88            | SemanticIntentAction::Open
89            | SemanticIntentAction::Close
90            | SemanticIntentAction::Search
91            | SemanticIntentAction::Filter
92            | SemanticIntentAction::Sort
93            | SemanticIntentAction::Paginate
94            | SemanticIntentAction::Expand
95            | SemanticIntentAction::Collapse
96                if self.value.is_some() =>
97            {
98                return Err(IntentResolutionError::new(
99                    "value",
100                    "this action does not accept a value",
101                ));
102            }
103            SemanticIntentAction::Toggle
104            | SemanticIntentAction::Download
105            | SemanticIntentAction::Upload
106            | SemanticIntentAction::Inspect
107            | SemanticIntentAction::Extract => {
108                return Err(IntentResolutionError::new(
109                    "action",
110                    "this intent action is not supported by the guarded execution boundary yet",
111                ));
112            }
113            SemanticIntentAction::Type | SemanticIntentAction::Select => {}
114            SemanticIntentAction::Click
115            | SemanticIntentAction::Clear
116            | SemanticIntentAction::Check
117            | SemanticIntentAction::Uncheck
118            | SemanticIntentAction::Submit
119            | SemanticIntentAction::Open
120            | SemanticIntentAction::Close
121            | SemanticIntentAction::Search
122            | SemanticIntentAction::Filter
123            | SemanticIntentAction::Sort
124            | SemanticIntentAction::Paginate
125            | SemanticIntentAction::Expand
126            | SemanticIntentAction::Collapse => {}
127        }
128        Ok(())
129    }
130
131    pub fn from_json(input: &str) -> Result<Self, IntentResolutionError> {
132        let request: Self = serde_json::from_str(input).map_err(|error| {
133            IntentResolutionError::new(
134                "$",
135                format!("invalid intent execution request shape: {error}"),
136            )
137        })?;
138        request.validate()?;
139        Ok(request)
140    }
141
142    pub fn to_canonical_json(&self) -> Result<String, IntentResolutionError> {
143        self.validate()?;
144        serde_json::to_string(self)
145            .map_err(|error| IntentResolutionError::new("$", error.to_string()))
146    }
147}
148
149/// Whether a resolved intent crossed the guarded action boundary.
150#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
151#[serde(rename_all = "snake_case")]
152pub enum SemanticIntentExecutionStatus {
153    Executed,
154    NotExecuted,
155}
156
157/// Resolution evidence and optional guarded action evidence from one attempt.
158#[derive(Debug, Clone, Serialize)]
159#[serde(rename_all = "camelCase")]
160pub struct SemanticIntentExecutionResult {
161    pub resolution_id: String,
162    pub candidate_id: String,
163    pub status: SemanticIntentExecutionStatus,
164    pub resolution: SemanticIntentResult,
165    #[serde(skip_serializing_if = "Option::is_none")]
166    pub action: Option<super::ActionOutcome>,
167    #[serde(skip_serializing_if = "Option::is_none")]
168    pub execution_id: Option<String>,
169    #[serde(skip_serializing_if = "Option::is_none")]
170    pub reason: Option<String>,
171}
172
173/// Deterministic purpose produced from a bounded intent phrase.
174#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
175#[serde(rename_all = "camelCase")]
176pub enum SemanticIntentPurpose {
177    Activate,
178    Open,
179    Continue,
180    Submit,
181    Cancel,
182    Close,
183    Search,
184    Filter,
185    Sort,
186    PaginationNext,
187    PaginationPrevious,
188    Select,
189    Check,
190    Uncheck,
191    Toggle,
192    Enter,
193    Clear,
194    Replace,
195    Expand,
196    Collapse,
197    Download,
198    Upload,
199    Choose,
200    Inspect,
201    Extract,
202}
203
204/// Normalized intent used by deterministic candidate generation.
205#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
206#[serde(rename_all = "camelCase", deny_unknown_fields)]
207pub struct NormalizedSemanticIntent {
208    pub canonical: String,
209    pub purpose: SemanticIntentPurpose,
210    #[serde(default, skip_serializing_if = "Vec::is_empty")]
211    pub terms: Vec<String>,
212}
213
214/// Normalize one request without consulting a browser or external service.
215pub fn normalize_intent(
216    request: &SemanticIntentRequest,
217) -> Result<NormalizedSemanticIntent, IntentResolutionError> {
218    request.validate()?;
219    let normalized = request
220        .intent
221        .split_whitespace()
222        .collect::<Vec<_>>()
223        .join(" ")
224        .to_ascii_lowercase();
225    if normalized.is_empty()
226        || matches!(
227            normalized.as_str(),
228            "do it" | "do that" | "handle this" | "click it" | "use it"
229        )
230    {
231        return Err(IntentResolutionError::new(
232            "intent",
233            "unsupportedIntent: provide a concrete purpose or target constraint",
234        ));
235    }
236
237    let (purpose, canonical, terms) =
238        if normalized == "next" || normalized == "next page" || normalized == "go to the next page"
239        {
240            (
241                SemanticIntentPurpose::PaginationNext,
242                "paginate next".into(),
243                Vec::new(),
244            )
245        } else if normalized == "previous"
246            || normalized == "previous page"
247            || normalized == "go to the previous page"
248        {
249            (
250                SemanticIntentPurpose::PaginationPrevious,
251                "paginate previous".into(),
252                Vec::new(),
253            )
254        } else if let Some(value) = normalized.strip_prefix("search for ") {
255            concrete_phrase(
256                request.action,
257                SemanticIntentPurpose::Search,
258                "search",
259                value,
260            )?
261        } else if let Some(value) = normalized.strip_prefix("filter by ") {
262            concrete_phrase(
263                request.action,
264                SemanticIntentPurpose::Filter,
265                "filter",
266                value,
267            )?
268        } else if let Some(value) = normalized.strip_prefix("sort by ") {
269            concrete_phrase(request.action, SemanticIntentPurpose::Sort, "sort", value)?
270        } else if normalized.starts_with("continue ") {
271            compatible_phrase(
272                request.action,
273                SemanticIntentPurpose::Continue,
274                normalized.clone(),
275            )?
276        } else if normalized.starts_with("open ") {
277            compatible_phrase(
278                request.action,
279                SemanticIntentPurpose::Open,
280                normalized.clone(),
281            )?
282        } else if normalized == "submit" || normalized.starts_with("submit ") {
283            compatible_phrase(
284                request.action,
285                SemanticIntentPurpose::Submit,
286                normalized.clone(),
287            )?
288        } else if normalized == "cancel" || normalized.starts_with("cancel ") {
289            compatible_phrase(
290                request.action,
291                SemanticIntentPurpose::Cancel,
292                normalized.clone(),
293            )?
294        } else if normalized == "close" || normalized.starts_with("close ") {
295            compatible_phrase(
296                request.action,
297                SemanticIntentPurpose::Close,
298                normalized.clone(),
299            )?
300        } else {
301            let purpose = purpose_for_action(request.action);
302            let canonical = normalized.clone();
303            let terms = terms_from(&normalized);
304            (purpose, canonical, terms)
305        };
306
307    validate_text("normalizedIntent", &canonical, MAX_INTENT_BYTES, false)?;
308    Ok(NormalizedSemanticIntent {
309        canonical,
310        purpose,
311        terms,
312    })
313}
314
315/// Resolve an intent against one current semantic observation.
316pub fn resolve_intent(
317    request: &SemanticIntentRequest,
318    observation: &super::SemanticObservation,
319) -> SemanticIntentResult {
320    resolve_intent_with_historical_matches(request, observation, &BTreeSet::new())
321}
322
323/// Resolve an intent against fresh semantic evidence with optional eligible
324/// historical fingerprint digests. Historical evidence can explain a current
325/// candidate but never creates one and never upgrades its confidence by
326/// itself.
327pub fn resolve_intent_with_historical_matches(
328    request: &SemanticIntentRequest,
329    observation: &super::SemanticObservation,
330    historical_fingerprints: &BTreeSet<String>,
331) -> SemanticIntentResult {
332    let normalized = match normalize_intent(request) {
333        Ok(normalized) => normalized,
334        Err(error) => {
335            let normalized_intent = request
336                .intent
337                .split_whitespace()
338                .collect::<Vec<_>>()
339                .join(" ")
340                .to_ascii_lowercase();
341            return result_for(
342                request,
343                observation,
344                IntentResultParts {
345                    normalized_intent: if normalized_intent.is_empty() {
346                        "unsupported".into()
347                    } else {
348                        normalized_intent
349                    },
350                    resolution: SemanticResolution::UnsupportedIntent,
351                    policy_decision: IntentPolicyDecision::Rejected,
352                    candidates: Vec::new(),
353                    excluded_candidates: Vec::new(),
354                    selected_candidate: None,
355                    suggested_constraints: Vec::new(),
356                    reason: Some(error.reason),
357                },
358            );
359        }
360    };
361    if request
362        .expected_revision
363        .is_some_and(|revision| revision != observation.revision)
364    {
365        return result_for(
366            request,
367            observation,
368            IntentResultParts {
369                normalized_intent: normalized.canonical,
370                resolution: SemanticResolution::StaleRevision,
371                policy_decision: IntentPolicyDecision::Rejected,
372                candidates: Vec::new(),
373                excluded_candidates: Vec::new(),
374                selected_candidate: None,
375                suggested_constraints: Vec::new(),
376                reason: Some("expected revision is no longer current".into()),
377            },
378        );
379    }
380
381    let mut candidates = Vec::new();
382    let mut excluded = Vec::new();
383    let mut candidate_index = 0usize;
384    for region in &observation.regions {
385        for target in &region.targets {
386            let id = format!("candidate_{}", candidate_index + 1);
387            candidate_index += 1;
388            let (include, evidence, excluded_reason) = score_candidate(
389                request,
390                &normalized,
391                observation,
392                region,
393                target,
394                historical_fingerprints,
395            );
396            if !include {
397                if excluded.len() < request.constraints.max_candidates {
398                    excluded.push(ExcludedIntentCandidate {
399                        id,
400                        reason: excluded_reason.unwrap_or(IntentEvidence {
401                            category: IntentEvidenceCategory::NegativeConflict,
402                            detail: "candidate did not satisfy the declared constraints".into(),
403                        }),
404                    });
405                }
406                continue;
407            }
408            if candidates.len() >= request.constraints.max_candidates {
409                continue;
410            }
411            let confidence = confidence_for(&evidence);
412            candidates.push(SemanticIntentCandidate {
413                id,
414                reference: target.reference.clone(),
415                role: target.role.clone(),
416                name: target.name.clone(),
417                input_type: target.input_type.clone(),
418                region_id: Some(region.id.clone()),
419                region_kind: Some(region.kind),
420                confidence,
421                evidence,
422                fingerprint: Some(SemanticTargetFingerprint {
423                    revision: observation.revision,
424                    route: observation.route.clone(),
425                    role: target.role.clone(),
426                    name: target.name.clone(),
427                    input_type: target.input_type.clone(),
428                    region_id: Some(region.id.clone()),
429                    region_kind: Some(region.kind),
430                    purpose: normalized.purpose,
431                    invalidated_by: vec![
432                        FingerprintInvalidation::Revision,
433                        FingerprintInvalidation::Route,
434                        FingerprintInvalidation::Role,
435                        FingerprintInvalidation::Name,
436                        FingerprintInvalidation::Region,
437                    ],
438                }),
439            });
440        }
441    }
442
443    let classification = classify_resolution(&candidates);
444    let (resolution, policy_decision, selected_candidate, reason) =
445        apply_resolution_policy(request.resolution_policy, classification, &candidates);
446    let suggested_constraints = suggestions_for(&candidates);
447    result_for(
448        request,
449        observation,
450        IntentResultParts {
451            normalized_intent: normalized.canonical,
452            resolution,
453            policy_decision,
454            candidates,
455            excluded_candidates: excluded,
456            selected_candidate,
457            suggested_constraints,
458            reason,
459        },
460    )
461}
462
463fn score_candidate(
464    request: &SemanticIntentRequest,
465    normalized: &NormalizedSemanticIntent,
466    observation: &super::SemanticObservation,
467    region: &super::SemanticRegion,
468    target: &super::SemanticTarget,
469    historical_fingerprints: &BTreeSet<String>,
470) -> (bool, Vec<IntentEvidence>, Option<IntentEvidence>) {
471    if request.constraints.must_be_visible || request.constraints.must_be_enabled {
472        return (
473            false,
474            Vec::new(),
475            Some(IntentEvidence {
476                category: IntentEvidenceCategory::PolicyExclusion,
477                detail: "visibility or enabled state requires actionability validation".into(),
478            }),
479        );
480    }
481    if request
482        .scope
483        .page_kind
484        .is_some_and(|kind| kind != observation.page.kind)
485    {
486        return (
487            false,
488            Vec::new(),
489            Some(IntentEvidence {
490                category: IntentEvidenceCategory::NegativeConflict,
491                detail: "page kind is outside the requested scope".into(),
492            }),
493        );
494    }
495    if request
496        .scope
497        .region_kind
498        .is_some_and(|kind| kind != region.kind)
499        || request
500            .scope
501            .region_id
502            .as_deref()
503            .is_some_and(|id| id != region.id)
504    {
505        return (
506            false,
507            Vec::new(),
508            Some(IntentEvidence {
509                category: IntentEvidenceCategory::NegativeConflict,
510                detail: "region is outside the requested scope".into(),
511            }),
512        );
513    }
514    let name_lower = target.name.to_ascii_lowercase();
515    if request
516        .constraints
517        .exclude_text
518        .iter()
519        .any(|value| name_lower.contains(&value.to_ascii_lowercase()))
520    {
521        return (
522            false,
523            Vec::new(),
524            Some(IntentEvidence {
525                category: IntentEvidenceCategory::NegativeConflict,
526                detail: "accessible name contains excluded text".into(),
527            }),
528        );
529    }
530    if request
531        .constraints
532        .role
533        .as_deref()
534        .is_some_and(|role| role != target.role)
535    {
536        return (
537            false,
538            Vec::new(),
539            Some(IntentEvidence {
540                category: IntentEvidenceCategory::NegativeConflict,
541                detail: "role does not match the declared constraint".into(),
542            }),
543        );
544    }
545    if request
546        .constraints
547        .name
548        .as_deref()
549        .is_some_and(|name| name != target.name)
550    {
551        return (
552            false,
553            Vec::new(),
554            Some(IntentEvidence {
555                category: IntentEvidenceCategory::NegativeConflict,
556                detail: "accessible name does not match the declared constraint".into(),
557            }),
558        );
559    }
560    if request
561        .constraints
562        .name_contains
563        .as_deref()
564        .is_some_and(|value| !name_lower.contains(&value.to_ascii_lowercase()))
565    {
566        return (
567            false,
568            Vec::new(),
569            Some(IntentEvidence {
570                category: IntentEvidenceCategory::NegativeConflict,
571                detail: "accessible name does not contain the declared constraint".into(),
572            }),
573        );
574    }
575
576    let mut evidence = vec![IntentEvidence {
577        category: IntentEvidenceCategory::RouteMatch,
578        detail: "candidate belongs to the observed target and frame route".into(),
579    }];
580    if request.constraints.role.is_some() {
581        evidence.push(IntentEvidence {
582            category: IntentEvidenceCategory::ExactRole,
583            detail: "role matches the declared constraint".into(),
584        });
585    }
586    if request.constraints.name.is_some() {
587        evidence.push(IntentEvidence {
588            category: IntentEvidenceCategory::ExactName,
589            detail: "accessible name matches the declared constraint".into(),
590        });
591    }
592    if request.constraints.name_contains.is_some()
593        || normalized
594            .terms
595            .iter()
596            .any(|term| name_lower.contains(term))
597        || synonyms_match(normalized, &name_lower)
598    {
599        evidence.push(IntentEvidence {
600            category: if request.constraints.name_contains.is_some() {
601                IntentEvidenceCategory::ExactName
602            } else {
603                IntentEvidenceCategory::SemanticName
604            },
605            detail: "bounded intent terms match the accessible name".into(),
606        });
607    }
608    if request.scope.region_kind.is_some() || request.scope.region_id.is_some() {
609        evidence.push(IntentEvidence {
610            category: IntentEvidenceCategory::RegionMatch,
611            detail: "candidate belongs to the requested semantic region".into(),
612        });
613    }
614    if historical_fingerprints.contains(&target_fingerprint_digest(
615        &target.role,
616        &target.name,
617        target.input_type.as_deref(),
618        Some(region.kind),
619        normalized.purpose,
620    )) {
621        evidence.push(IntentEvidence {
622            category: IntentEvidenceCategory::HistoricalMatch,
623            detail: "eligible scoped knowledge matches this current candidate fingerprint".into(),
624        });
625    }
626    (true, evidence, None)
627}
628
629fn synonyms_match(normalized: &NormalizedSemanticIntent, name: &str) -> bool {
630    let synonyms: &[(&str, &[&str])] = &[
631        ("settings", &["preferences", "configuration", "options"]),
632        ("continue", &["proceed", "next"]),
633        ("close", &["dismiss", "exit"]),
634        ("open", &["view", "show"]),
635        ("search", &["find", "lookup"]),
636    ];
637    normalized.terms.iter().any(|term| {
638        synonyms
639            .iter()
640            .find(|(source, _)| source == term)
641            .is_some_and(|(_, alternatives)| alternatives.iter().any(|value| name.contains(value)))
642    })
643}
644
645fn confidence_for(evidence: &[IntentEvidence]) -> IntentConfidence {
646    let has = |category| evidence.iter().any(|item| item.category == category);
647    if has(IntentEvidenceCategory::ExactName) && has(IntentEvidenceCategory::ExactRole) {
648        IntentConfidence::Exact
649    } else if has(IntentEvidenceCategory::ExactName)
650        || (has(IntentEvidenceCategory::SemanticName) && has(IntentEvidenceCategory::RegionMatch))
651    {
652        IntentConfidence::High
653    } else if has(IntentEvidenceCategory::SemanticName) || has(IntentEvidenceCategory::RegionMatch)
654    {
655        IntentConfidence::Medium
656    } else {
657        IntentConfidence::Low
658    }
659}
660
661fn classify_resolution(candidates: &[SemanticIntentCandidate]) -> SemanticResolution {
662    match candidates {
663        [] => SemanticResolution::NotFound,
664        [candidate] => match candidate.confidence {
665            IntentConfidence::Exact => SemanticResolution::Exact,
666            IntentConfidence::High => SemanticResolution::UniqueHighConfidence,
667            IntentConfidence::Medium | IntentConfidence::Low | IntentConfidence::Insufficient => {
668                SemanticResolution::UniqueLowConfidence
669            }
670        },
671        _ => SemanticResolution::Ambiguous,
672    }
673}
674
675fn apply_resolution_policy(
676    policy: SemanticResolutionPolicy,
677    resolution: SemanticResolution,
678    candidates: &[SemanticIntentCandidate],
679) -> (
680    SemanticResolution,
681    IntentPolicyDecision,
682    Option<String>,
683    Option<String>,
684) {
685    if matches!(policy, SemanticResolutionPolicy::ReportOnly) {
686        return (
687            resolution,
688            IntentPolicyDecision::ReportOnly,
689            None,
690            Some("reportOnly never dispatches an action".into()),
691        );
692    }
693    if matches!(policy, SemanticResolutionPolicy::InteractiveConfirmation) {
694        return (
695            resolution,
696            IntentPolicyDecision::ConfirmationRequired,
697            None,
698            Some("caller confirmation is required before dispatch".into()),
699        );
700    }
701    let allowed = match policy {
702        SemanticResolutionPolicy::RequireExact => resolution == SemanticResolution::Exact,
703        SemanticResolutionPolicy::RequireUniqueHighConfidence => matches!(
704            resolution,
705            SemanticResolution::Exact | SemanticResolution::UniqueHighConfidence
706        ),
707        SemanticResolutionPolicy::AllowUniqueMediumConfidence => {
708            matches!(
709                resolution,
710                SemanticResolution::Exact
711                    | SemanticResolution::UniqueHighConfidence
712                    | SemanticResolution::UniqueLowConfidence
713            ) && candidates.first().is_some_and(|candidate| {
714                !matches!(
715                    candidate.confidence,
716                    IntentConfidence::Low | IntentConfidence::Insufficient
717                )
718            })
719        }
720        SemanticResolutionPolicy::ReportOnly
721        | SemanticResolutionPolicy::InteractiveConfirmation => false,
722    };
723    if allowed {
724        (
725            resolution,
726            IntentPolicyDecision::Allowed,
727            candidates.first().map(|candidate| candidate.id.clone()),
728            None,
729        )
730    } else if matches!(resolution, SemanticResolution::UniqueLowConfidence) {
731        (
732            SemanticResolution::PolicyRejected,
733            IntentPolicyDecision::Rejected,
734            None,
735            Some("candidate did not meet the declared confidence policy".into()),
736        )
737    } else {
738        (
739            resolution,
740            IntentPolicyDecision::Rejected,
741            None,
742            Some("resolution did not produce one policy-eligible candidate".into()),
743        )
744    }
745}
746
747struct IntentResultParts {
748    normalized_intent: String,
749    resolution: SemanticResolution,
750    policy_decision: IntentPolicyDecision,
751    candidates: Vec<SemanticIntentCandidate>,
752    excluded_candidates: Vec<ExcludedIntentCandidate>,
753    selected_candidate: Option<String>,
754    suggested_constraints: Vec<IntentConstraintSuggestion>,
755    reason: Option<String>,
756}
757
758fn suggestions_for(candidates: &[SemanticIntentCandidate]) -> Vec<IntentConstraintSuggestion> {
759    if candidates.len() < 2 {
760        return Vec::new();
761    }
762    let mut suggestions = Vec::new();
763    let mut region_kinds = Vec::new();
764    for candidate in candidates {
765        if let Some(kind) = candidate.region_kind
766            && !region_kinds.contains(&kind)
767        {
768            region_kinds.push(kind);
769        }
770    }
771    if region_kinds.len() > 1 {
772        for region_kind in region_kinds {
773            suggestions.push(IntentConstraintSuggestion {
774                region_kind: Some(region_kind),
775                ..IntentConstraintSuggestion::default()
776            });
777        }
778    }
779    let mut roles = Vec::new();
780    for candidate in candidates {
781        if !roles.contains(&candidate.role) {
782            roles.push(candidate.role.clone());
783        }
784    }
785    if roles.len() > 1 {
786        for role in roles {
787            suggestions.push(IntentConstraintSuggestion {
788                role: Some(role),
789                ..IntentConstraintSuggestion::default()
790            });
791        }
792    }
793    suggestions.truncate(MAX_SUGGESTIONS);
794    suggestions
795}
796
797fn result_for(
798    request: &SemanticIntentRequest,
799    observation: &super::SemanticObservation,
800    parts: IntentResultParts,
801) -> SemanticIntentResult {
802    let result = SemanticIntentResult {
803        schema_version: INTENT_RESOLUTION_SCHEMA_VERSION,
804        intent: request.intent.clone(),
805        action: request.action,
806        normalized_intent: parts.normalized_intent,
807        resolution: parts.resolution,
808        policy_decision: parts.policy_decision,
809        route: Some(observation.route.clone()),
810        revision: Some(observation.revision),
811        excluded_count: parts.excluded_candidates.len(),
812        candidates: parts.candidates,
813        excluded_candidates: parts.excluded_candidates,
814        selected_candidate: parts.selected_candidate,
815        suggested_constraints: parts.suggested_constraints,
816        reason: parts.reason,
817    };
818    debug_assert!(result.validate().is_ok());
819    result
820}
821
822fn concrete_phrase(
823    action: SemanticIntentAction,
824    purpose: SemanticIntentPurpose,
825    verb: &str,
826    value: &str,
827) -> Result<(SemanticIntentPurpose, String, Vec<String>), IntentResolutionError> {
828    if value.trim().is_empty() {
829        return Err(IntentResolutionError::new(
830            "intent",
831            "unsupportedIntent: the phrase needs a bounded value",
832        ));
833    }
834    let compatible = match purpose {
835        SemanticIntentPurpose::Search => {
836            matches!(
837                action,
838                SemanticIntentAction::Search | SemanticIntentAction::Click
839            )
840        }
841        SemanticIntentPurpose::Filter => {
842            matches!(
843                action,
844                SemanticIntentAction::Filter | SemanticIntentAction::Click
845            )
846        }
847        SemanticIntentPurpose::Sort => {
848            matches!(
849                action,
850                SemanticIntentAction::Sort | SemanticIntentAction::Click
851            )
852        }
853        _ => false,
854    };
855    if !compatible {
856        return Err(IntentResolutionError::new(
857            "action",
858            "action is incompatible with the normalized intent purpose",
859        ));
860    }
861    Ok((
862        purpose,
863        format!("{verb} {}", value.trim()),
864        terms_from(value),
865    ))
866}
867
868fn compatible_phrase(
869    action: SemanticIntentAction,
870    purpose: SemanticIntentPurpose,
871    canonical: String,
872) -> Result<(SemanticIntentPurpose, String, Vec<String>), IntentResolutionError> {
873    let compatible = match purpose {
874        SemanticIntentPurpose::Open | SemanticIntentPurpose::Continue => {
875            matches!(
876                action,
877                SemanticIntentAction::Click | SemanticIntentAction::Open
878            )
879        }
880        SemanticIntentPurpose::Submit => {
881            matches!(
882                action,
883                SemanticIntentAction::Click | SemanticIntentAction::Submit
884            )
885        }
886        SemanticIntentPurpose::Cancel | SemanticIntentPurpose::Close => {
887            matches!(
888                action,
889                SemanticIntentAction::Click | SemanticIntentAction::Close
890            )
891        }
892        _ => false,
893    };
894    if !compatible {
895        return Err(IntentResolutionError::new(
896            "action",
897            "action is incompatible with the normalized intent purpose",
898        ));
899    }
900    Ok((purpose, canonical.clone(), terms_from(&canonical)))
901}
902
903fn purpose_for_action(action: SemanticIntentAction) -> SemanticIntentPurpose {
904    match action {
905        SemanticIntentAction::Click => SemanticIntentPurpose::Activate,
906        SemanticIntentAction::Type => SemanticIntentPurpose::Enter,
907        SemanticIntentAction::Clear => SemanticIntentPurpose::Clear,
908        SemanticIntentAction::Check => SemanticIntentPurpose::Check,
909        SemanticIntentAction::Uncheck => SemanticIntentPurpose::Uncheck,
910        SemanticIntentAction::Select => SemanticIntentPurpose::Select,
911        SemanticIntentAction::Submit => SemanticIntentPurpose::Submit,
912        SemanticIntentAction::Open => SemanticIntentPurpose::Open,
913        SemanticIntentAction::Close => SemanticIntentPurpose::Close,
914        SemanticIntentAction::Search => SemanticIntentPurpose::Search,
915        SemanticIntentAction::Filter => SemanticIntentPurpose::Filter,
916        SemanticIntentAction::Sort => SemanticIntentPurpose::Sort,
917        SemanticIntentAction::Paginate => SemanticIntentPurpose::PaginationNext,
918        SemanticIntentAction::Toggle => SemanticIntentPurpose::Toggle,
919        SemanticIntentAction::Expand => SemanticIntentPurpose::Expand,
920        SemanticIntentAction::Collapse => SemanticIntentPurpose::Collapse,
921        SemanticIntentAction::Download => SemanticIntentPurpose::Download,
922        SemanticIntentAction::Upload => SemanticIntentPurpose::Upload,
923        SemanticIntentAction::Inspect => SemanticIntentPurpose::Inspect,
924        SemanticIntentAction::Extract => SemanticIntentPurpose::Extract,
925    }
926}
927
928fn terms_from(value: &str) -> Vec<String> {
929    value
930        .split_whitespace()
931        .filter(|term| !matches!(*term, "the" | "a" | "an" | "to" | "for" | "by"))
932        .take(8)
933        .map(|term| term.to_string())
934        .collect()
935}
936
937/// Result classification exposed to every interface.
938#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
939#[serde(rename_all = "camelCase")]
940pub enum SemanticResolution {
941    Exact,
942    UniqueHighConfidence,
943    UniqueLowConfidence,
944    Ambiguous,
945    NotFound,
946    StaleRevision,
947    PolicyRejected,
948    UnsupportedIntent,
949}
950
951/// Confidence attached to one candidate after evidence is considered.
952#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
953#[serde(rename_all = "lowercase")]
954pub enum IntentConfidence {
955    Exact,
956    High,
957    Medium,
958    Low,
959    Insufficient,
960}
961
962/// Public evidence categories; numeric scores are intentionally not part of
963/// the contract.
964#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
965#[serde(rename_all = "camelCase")]
966pub enum IntentEvidenceCategory {
967    ExactRole,
968    ExactName,
969    SemanticName,
970    RegionMatch,
971    FormRelationship,
972    HeadingContext,
973    StateMatch,
974    RouteMatch,
975    WorkflowContext,
976    HistoricalMatch,
977    NegativeConflict,
978    PolicyExclusion,
979}
980
981/// One bounded explanation for including or excluding a candidate.
982#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
983#[serde(rename_all = "camelCase", deny_unknown_fields)]
984pub struct IntentEvidence {
985    pub category: IntentEvidenceCategory,
986    pub detail: String,
987}
988
989/// Optional page and region scope for candidate generation.
990#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
991#[serde(rename_all = "camelCase", deny_unknown_fields)]
992pub struct IntentScope {
993    #[serde(default, skip_serializing_if = "Option::is_none")]
994    pub page_kind: Option<SemanticPageKind>,
995    #[serde(default, skip_serializing_if = "Option::is_none")]
996    pub region_kind: Option<SemanticRegionKind>,
997    #[serde(default, skip_serializing_if = "Option::is_none")]
998    pub region_id: Option<String>,
999    #[serde(default, skip_serializing_if = "Option::is_none")]
1000    pub form_label: Option<String>,
1001}
1002
1003/// Explicit candidate constraints. No field is an implicit selector.
1004#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1005#[serde(rename_all = "camelCase", deny_unknown_fields)]
1006pub struct IntentConstraints {
1007    #[serde(default, skip_serializing_if = "Option::is_none")]
1008    pub role: Option<String>,
1009    #[serde(default, skip_serializing_if = "Option::is_none")]
1010    pub name: Option<String>,
1011    #[serde(default, skip_serializing_if = "Option::is_none")]
1012    pub name_contains: Option<String>,
1013    #[serde(default)]
1014    pub must_be_visible: bool,
1015    #[serde(default)]
1016    pub must_be_enabled: bool,
1017    #[serde(default, skip_serializing_if = "Vec::is_empty")]
1018    pub exclude_text: Vec<String>,
1019    #[serde(default = "default_max_candidates")]
1020    pub max_candidates: usize,
1021}
1022
1023impl Default for IntentConstraints {
1024    fn default() -> Self {
1025        Self {
1026            role: None,
1027            name: None,
1028            name_contains: None,
1029            must_be_visible: false,
1030            must_be_enabled: false,
1031            exclude_text: Vec::new(),
1032            max_candidates: default_max_candidates(),
1033        }
1034    }
1035}
1036
1037fn default_max_candidates() -> usize {
1038    MAX_CANDIDATES
1039}
1040
1041/// Caller-selected certainty and execution behavior.
1042#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
1043#[serde(rename_all = "camelCase")]
1044pub enum SemanticResolutionPolicy {
1045    ReportOnly,
1046    RequireExact,
1047    RequireUniqueHighConfidence,
1048    AllowUniqueMediumConfidence,
1049    InteractiveConfirmation,
1050}
1051
1052/// Versioned request to resolve one bounded intent.
1053#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1054#[serde(rename_all = "camelCase", deny_unknown_fields)]
1055pub struct SemanticIntentRequest {
1056    pub schema_version: u32,
1057    pub intent: String,
1058    pub action: SemanticIntentAction,
1059    #[serde(default)]
1060    pub scope: IntentScope,
1061    #[serde(default)]
1062    pub constraints: IntentConstraints,
1063    pub resolution_policy: SemanticResolutionPolicy,
1064    #[serde(default, skip_serializing_if = "Option::is_none")]
1065    pub expected_revision: Option<u64>,
1066}
1067
1068/// A current, revision-scoped target candidate.
1069#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1070#[serde(rename_all = "camelCase", deny_unknown_fields)]
1071pub struct SemanticIntentCandidate {
1072    pub id: String,
1073    pub reference: String,
1074    pub role: String,
1075    pub name: String,
1076    #[serde(default, skip_serializing_if = "Option::is_none")]
1077    pub input_type: Option<String>,
1078    #[serde(default, skip_serializing_if = "Option::is_none")]
1079    pub region_id: Option<String>,
1080    #[serde(default, skip_serializing_if = "Option::is_none")]
1081    pub region_kind: Option<SemanticRegionKind>,
1082    pub confidence: IntentConfidence,
1083    #[serde(default, skip_serializing_if = "Vec::is_empty")]
1084    pub evidence: Vec<IntentEvidence>,
1085    #[serde(default, skip_serializing_if = "Option::is_none")]
1086    pub fingerprint: Option<SemanticTargetFingerprint>,
1087}
1088
1089/// Conditions that invalidate a target fingerprint.
1090#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
1091#[serde(rename_all = "camelCase")]
1092pub enum FingerprintInvalidation {
1093    #[serde(rename = "revisionChanged")]
1094    Revision,
1095    #[serde(rename = "routeChanged")]
1096    Route,
1097    #[serde(rename = "roleChanged")]
1098    Role,
1099    #[serde(rename = "nameChanged")]
1100    Name,
1101    #[serde(rename = "regionChanged")]
1102    Region,
1103}
1104
1105/// Fresh evidence used to rank a later resolution attempt.
1106#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1107#[serde(rename_all = "camelCase", deny_unknown_fields)]
1108pub struct SemanticTargetFingerprint {
1109    pub revision: u64,
1110    pub route: SemanticRouteIdentity,
1111    pub role: String,
1112    pub name: String,
1113    #[serde(default, skip_serializing_if = "Option::is_none")]
1114    pub input_type: Option<String>,
1115    #[serde(default, skip_serializing_if = "Option::is_none")]
1116    pub region_id: Option<String>,
1117    #[serde(default, skip_serializing_if = "Option::is_none")]
1118    pub region_kind: Option<SemanticRegionKind>,
1119    pub purpose: SemanticIntentPurpose,
1120    pub invalidated_by: Vec<FingerprintInvalidation>,
1121}
1122
1123/// Derive the stable, non-authorizing digest used by persistent target
1124/// knowledge. The current target reference, route handles, and accessible
1125/// name are intentionally not part of the stored value.
1126pub fn target_fingerprint_digest(
1127    role: &str,
1128    name: &str,
1129    input_type: Option<&str>,
1130    region_kind: Option<SemanticRegionKind>,
1131    purpose: SemanticIntentPurpose,
1132) -> String {
1133    let canonical = serde_json::json!({
1134        "role": role,
1135        "name": name,
1136        "inputType": input_type,
1137        "regionKind": region_kind,
1138        "purpose": purpose,
1139    });
1140    let digest =
1141        Sha256::digest(serde_json::to_vec(&canonical).expect("JSON value is serializable"));
1142    format!("sha256:{digest:x}")
1143}
1144
1145/// Candidate excluded from consideration for an inspectable reason.
1146#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1147#[serde(rename_all = "camelCase", deny_unknown_fields)]
1148pub struct ExcludedIntentCandidate {
1149    pub id: String,
1150    pub reason: IntentEvidence,
1151}
1152
1153/// A bounded suggestion that a caller can add to disambiguate.
1154#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
1155#[serde(rename_all = "camelCase", deny_unknown_fields)]
1156pub struct IntentConstraintSuggestion {
1157    #[serde(default, skip_serializing_if = "Option::is_none")]
1158    pub region_kind: Option<SemanticRegionKind>,
1159    #[serde(default, skip_serializing_if = "Option::is_none")]
1160    pub name_contains: Option<String>,
1161    #[serde(default, skip_serializing_if = "Option::is_none")]
1162    pub role: Option<String>,
1163}
1164
1165/// Explicit policy outcome for the resolution attempt.
1166#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
1167#[serde(rename_all = "camelCase")]
1168pub enum IntentPolicyDecision {
1169    Allowed,
1170    ReportOnly,
1171    ConfirmationRequired,
1172    Rejected,
1173}
1174
1175/// Resolution result returned before any optional guarded action dispatch.
1176#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1177#[serde(rename_all = "camelCase", deny_unknown_fields)]
1178pub struct SemanticIntentResult {
1179    pub schema_version: u32,
1180    pub intent: String,
1181    pub action: SemanticIntentAction,
1182    pub normalized_intent: String,
1183    pub resolution: SemanticResolution,
1184    pub policy_decision: IntentPolicyDecision,
1185    #[serde(default, skip_serializing_if = "Option::is_none")]
1186    pub route: Option<SemanticRouteIdentity>,
1187    #[serde(default, skip_serializing_if = "Option::is_none")]
1188    pub revision: Option<u64>,
1189    #[serde(default, skip_serializing_if = "Vec::is_empty")]
1190    pub candidates: Vec<SemanticIntentCandidate>,
1191    #[serde(default, skip_serializing_if = "Vec::is_empty")]
1192    pub excluded_candidates: Vec<ExcludedIntentCandidate>,
1193    pub excluded_count: usize,
1194    #[serde(default, skip_serializing_if = "Option::is_none")]
1195    pub selected_candidate: Option<String>,
1196    #[serde(default, skip_serializing_if = "Vec::is_empty")]
1197    pub suggested_constraints: Vec<IntentConstraintSuggestion>,
1198    #[serde(default, skip_serializing_if = "Option::is_none")]
1199    pub reason: Option<String>,
1200}
1201
1202impl SemanticIntentRequest {
1203    pub fn validate(&self) -> Result<(), IntentResolutionError> {
1204        if self.schema_version != INTENT_RESOLUTION_SCHEMA_VERSION {
1205            return Err(IntentResolutionError::new(
1206                "schemaVersion",
1207                format!(
1208                    "unsupported schema version {}; expected {}",
1209                    self.schema_version, INTENT_RESOLUTION_SCHEMA_VERSION
1210                ),
1211            ));
1212        }
1213        validate_text("intent", &self.intent, MAX_INTENT_BYTES, false)?;
1214        validate_scope(&self.scope)?;
1215        validate_constraints(&self.constraints)?;
1216        if self.scope.region_id.is_some() && self.scope.region_kind.is_none() {
1217            return Err(IntentResolutionError::new(
1218                "scope.regionId",
1219                "regionId requires regionKind or a later concrete region handle",
1220            ));
1221        }
1222        Ok(())
1223    }
1224
1225    pub fn from_json(input: &str) -> Result<Self, IntentResolutionError> {
1226        let request: Self = serde_json::from_str(input).map_err(|error| {
1227            IntentResolutionError::new("$", format!("invalid intent request shape: {error}"))
1228        })?;
1229        request.validate()?;
1230        Ok(request)
1231    }
1232
1233    pub fn to_canonical_json(&self) -> Result<String, IntentResolutionError> {
1234        self.validate()?;
1235        serde_json::to_string(self)
1236            .map_err(|error| IntentResolutionError::new("$", error.to_string()))
1237    }
1238}
1239
1240impl SemanticIntentResult {
1241    pub fn validate(&self) -> Result<(), IntentResolutionError> {
1242        if self.schema_version != INTENT_RESOLUTION_SCHEMA_VERSION {
1243            return Err(IntentResolutionError::new(
1244                "schemaVersion",
1245                format!(
1246                    "unsupported schema version {}; expected {}",
1247                    self.schema_version, INTENT_RESOLUTION_SCHEMA_VERSION
1248                ),
1249            ));
1250        }
1251        validate_text("intent", &self.intent, MAX_INTENT_BYTES, false)?;
1252        validate_text(
1253            "normalizedIntent",
1254            &self.normalized_intent,
1255            MAX_INTENT_BYTES,
1256            false,
1257        )?;
1258        if let Some(route) = &self.route {
1259            validate_text("route.targetId", &route.target_id, MAX_ID_BYTES, false)?;
1260            validate_text("route.frameId", &route.frame_id, MAX_ID_BYTES, false)?;
1261            validate_text("route.url", &route.url, 2_048, false)?;
1262        }
1263        if self.candidates.len() > MAX_CANDIDATES {
1264            return Err(IntentResolutionError::new(
1265                "candidates",
1266                format!("contains more than {MAX_CANDIDATES} candidates"),
1267            ));
1268        }
1269        if self.excluded_candidates.len() > MAX_CANDIDATES {
1270            return Err(IntentResolutionError::new(
1271                "excludedCandidates",
1272                format!("contains more than {MAX_CANDIDATES} candidates"),
1273            ));
1274        }
1275        if self.suggested_constraints.len() > MAX_SUGGESTIONS {
1276            return Err(IntentResolutionError::new(
1277                "suggestedConstraints",
1278                format!("contains more than {MAX_SUGGESTIONS} suggestions"),
1279            ));
1280        }
1281        for (index, candidate) in self.candidates.iter().enumerate() {
1282            validate_candidate(&format!("candidates[{index}]"), candidate)?;
1283        }
1284        for (index, candidate) in self.excluded_candidates.iter().enumerate() {
1285            validate_text(
1286                &format!("excludedCandidates[{index}].id"),
1287                &candidate.id,
1288                MAX_ID_BYTES,
1289                false,
1290            )?;
1291            validate_evidence(
1292                &format!("excludedCandidates[{index}].reason"),
1293                std::slice::from_ref(&candidate.reason),
1294            )?;
1295        }
1296        if self.excluded_count < self.excluded_candidates.len() {
1297            return Err(IntentResolutionError::new(
1298                "excludedCount",
1299                "cannot be less than the returned excluded candidate count",
1300            ));
1301        }
1302        if let Some(selected) = &self.selected_candidate {
1303            validate_text("selectedCandidate", selected, MAX_ID_BYTES, false)?;
1304            if !self
1305                .candidates
1306                .iter()
1307                .any(|candidate| &candidate.id == selected)
1308            {
1309                return Err(IntentResolutionError::new(
1310                    "selectedCandidate",
1311                    "does not identify a returned candidate",
1312                ));
1313            }
1314        }
1315        for (index, suggestion) in self.suggested_constraints.iter().enumerate() {
1316            if let Some(value) = &suggestion.name_contains {
1317                validate_text(
1318                    &format!("suggestedConstraints[{index}].nameContains"),
1319                    value,
1320                    MAX_LABEL_BYTES,
1321                    false,
1322                )?;
1323            }
1324            if let Some(value) = &suggestion.role {
1325                validate_text(
1326                    &format!("suggestedConstraints[{index}].role"),
1327                    value,
1328                    MAX_ACTION_BYTES,
1329                    false,
1330                )?;
1331            }
1332        }
1333        if let Some(reason) = &self.reason {
1334            validate_text("reason", reason, MAX_EVIDENCE_BYTES, false)?;
1335        }
1336        Ok(())
1337    }
1338
1339    pub fn from_json(input: &str) -> Result<Self, IntentResolutionError> {
1340        let result: Self = serde_json::from_str(input).map_err(|error| {
1341            IntentResolutionError::new("$", format!("invalid intent result shape: {error}"))
1342        })?;
1343        result.validate()?;
1344        Ok(result)
1345    }
1346
1347    pub fn to_canonical_json(&self) -> Result<String, IntentResolutionError> {
1348        self.validate()?;
1349        serde_json::to_string(self)
1350            .map_err(|error| IntentResolutionError::new("$", error.to_string()))
1351    }
1352}
1353
1354fn validate_scope(scope: &IntentScope) -> Result<(), IntentResolutionError> {
1355    if let Some(value) = &scope.region_id {
1356        validate_text("scope.regionId", value, MAX_ID_BYTES, false)?;
1357    }
1358    if let Some(value) = &scope.form_label {
1359        validate_text("scope.formLabel", value, MAX_LABEL_BYTES, false)?;
1360    }
1361    Ok(())
1362}
1363
1364fn validate_constraints(constraints: &IntentConstraints) -> Result<(), IntentResolutionError> {
1365    if let Some(value) = &constraints.role {
1366        validate_text("constraints.role", value, MAX_ACTION_BYTES, false)?;
1367    }
1368    for (path, value) in [
1369        ("constraints.name", constraints.name.as_deref()),
1370        (
1371            "constraints.nameContains",
1372            constraints.name_contains.as_deref(),
1373        ),
1374    ] {
1375        if let Some(value) = value {
1376            validate_text(path, value, MAX_LABEL_BYTES, false)?;
1377        }
1378    }
1379    if constraints.exclude_text.len() > MAX_EXCLUDE_TEXT {
1380        return Err(IntentResolutionError::new(
1381            "constraints.excludeText",
1382            format!("contains more than {MAX_EXCLUDE_TEXT} values"),
1383        ));
1384    }
1385    for (index, value) in constraints.exclude_text.iter().enumerate() {
1386        validate_text(
1387            &format!("constraints.excludeText[{index}]"),
1388            value,
1389            MAX_LABEL_BYTES,
1390            false,
1391        )?;
1392    }
1393    if constraints.max_candidates == 0 || constraints.max_candidates > MAX_CANDIDATES {
1394        return Err(IntentResolutionError::new(
1395            "constraints.maxCandidates",
1396            format!("must be between 1 and {MAX_CANDIDATES}"),
1397        ));
1398    }
1399    Ok(())
1400}
1401
1402fn validate_candidate(
1403    path: &str,
1404    candidate: &SemanticIntentCandidate,
1405) -> Result<(), IntentResolutionError> {
1406    for (suffix, value, maximum) in [
1407        ("id", candidate.id.as_str(), MAX_ID_BYTES),
1408        ("reference", candidate.reference.as_str(), MAX_ID_BYTES),
1409        ("role", candidate.role.as_str(), MAX_ACTION_BYTES),
1410        ("name", candidate.name.as_str(), MAX_LABEL_BYTES),
1411    ] {
1412        validate_text(&format!("{path}.{suffix}"), value, maximum, false)?;
1413    }
1414    if let Some(input_type) = &candidate.input_type {
1415        validate_text(
1416            &format!("{path}.inputType"),
1417            input_type,
1418            MAX_ACTION_BYTES,
1419            false,
1420        )?;
1421    }
1422    if let Some(region_id) = &candidate.region_id {
1423        validate_text(&format!("{path}.regionId"), region_id, MAX_ID_BYTES, false)?;
1424    }
1425    validate_evidence(&format!("{path}.evidence"), &candidate.evidence)?;
1426    if let Some(fingerprint) = &candidate.fingerprint {
1427        validate_text(
1428            &format!("{path}.fingerprint.role"),
1429            &fingerprint.role,
1430            MAX_ACTION_BYTES,
1431            false,
1432        )?;
1433        validate_text(
1434            &format!("{path}.fingerprint.name"),
1435            &fingerprint.name,
1436            MAX_LABEL_BYTES,
1437            false,
1438        )?;
1439        if fingerprint.invalidated_by.is_empty() {
1440            return Err(IntentResolutionError::new(
1441                format!("{path}.fingerprint.invalidatedBy"),
1442                "must declare at least one invalidation condition",
1443            ));
1444        }
1445    }
1446    Ok(())
1447}
1448
1449fn validate_evidence(path: &str, evidence: &[IntentEvidence]) -> Result<(), IntentResolutionError> {
1450    if evidence.len() > MAX_EVIDENCE_ITEMS {
1451        return Err(IntentResolutionError::new(
1452            path,
1453            format!("contains more than {MAX_EVIDENCE_ITEMS} items"),
1454        ));
1455    }
1456    let mut categories = BTreeSet::new();
1457    for (index, item) in evidence.iter().enumerate() {
1458        if !categories.insert(item.category) {
1459            return Err(IntentResolutionError::new(
1460                format!("{path}[{index}].category"),
1461                "duplicate evidence category",
1462            ));
1463        }
1464        validate_text(
1465            &format!("{path}[{index}].detail"),
1466            &item.detail,
1467            MAX_EVIDENCE_BYTES,
1468            false,
1469        )?;
1470    }
1471    Ok(())
1472}
1473
1474fn validate_text(
1475    path: &str,
1476    value: &str,
1477    maximum: usize,
1478    allow_empty: bool,
1479) -> Result<(), IntentResolutionError> {
1480    if (!allow_empty && value.is_empty()) || value.len() > maximum {
1481        let requirement = if allow_empty {
1482            format!("at most {maximum} bytes")
1483        } else {
1484            format!("non-empty and at most {maximum} bytes")
1485        };
1486        return Err(IntentResolutionError::new(
1487            path,
1488            format!("must be {requirement}"),
1489        ));
1490    }
1491    Ok(())
1492}
1493
1494/// Path-aware intent contract validation error.
1495#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
1496pub struct IntentResolutionError {
1497    pub path: String,
1498    pub reason: String,
1499}
1500
1501impl IntentResolutionError {
1502    fn new(path: impl Into<String>, reason: impl Into<String>) -> Self {
1503        Self {
1504            path: path.into(),
1505            reason: reason.into(),
1506        }
1507    }
1508}
1509
1510impl std::fmt::Display for IntentResolutionError {
1511    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1512        write!(formatter, "{}: {}", self.path, self.reason)
1513    }
1514}
1515
1516impl std::error::Error for IntentResolutionError {}
1517
1518impl super::BrowserSession {
1519    /// Resolve one declared intent against a fresh interactive observation.
1520    /// This method returns candidates only; it never dispatches an action.
1521    pub async fn resolve_intent(
1522        &self,
1523        request: &SemanticIntentRequest,
1524    ) -> super::types::BrowserResult<SemanticIntentResult> {
1525        let observation = self
1526            .semantic_observe(super::SemanticObservationLevel::Interactive)
1527            .await?;
1528        Ok(resolve_intent(request, &observation))
1529    }
1530
1531    /// Resolve against fresh semantic evidence while using only eligible
1532    /// stored target fingerprints as secondary explanation evidence.
1533    /// Historical records never create candidates, supply current references,
1534    /// or authorize an action.
1535    pub async fn resolve_intent_with_knowledge(
1536        &self,
1537        request: &SemanticIntentRequest,
1538        store: &super::KnowledgeStore,
1539        lookup_options: super::KnowledgeLookupOptions,
1540    ) -> super::types::BrowserResult<SemanticIntentResult> {
1541        let observation = self
1542            .semantic_observe(super::SemanticObservationLevel::Interactive)
1543            .await?;
1544        let context =
1545            super::KnowledgeLookupContext::from_observation(&observation, lookup_options)?;
1546        let assessments = store.assess(&context);
1547        let historical_fingerprints = store
1548            .records()
1549            .iter()
1550            .zip(assessments)
1551            .filter(|(record, assessment)| {
1552                record.kind == super::KnowledgeRecordKind::TargetFingerprint
1553                    && assessment.status == super::KnowledgeAssessmentStatus::Eligible
1554            })
1555            .filter_map(|(record, _)| {
1556                record
1557                    .data
1558                    .get("fingerprint")
1559                    .and_then(|value| value.as_str())
1560            })
1561            .map(str::to_string)
1562            .collect();
1563        Ok(resolve_intent_with_historical_matches(
1564            request,
1565            &observation,
1566            &historical_fingerprints,
1567        ))
1568    }
1569
1570    /// Resolve and, when the caller-selected candidate remains policy-eligible,
1571    /// execute one supported action through the existing guarded primitives.
1572    ///
1573    /// The semantic observation is fresh for this attempt. When the request
1574    /// carries `expectedRevision`, any intervening page change produces a
1575    /// non-executed stale result. A caller can therefore resolve first, show
1576    /// the candidates, and call this method with the returned revision and
1577    /// candidate ID as an explicit confirmation.
1578    pub async fn execute_intent(
1579        &self,
1580        execution: &SemanticIntentExecutionRequest,
1581    ) -> super::types::BrowserResult<SemanticIntentExecutionResult> {
1582        execution.validate()?;
1583        let observation = self
1584            .semantic_observe(super::SemanticObservationLevel::Interactive)
1585            .await?;
1586        let resolution = resolve_intent(&execution.request, &observation);
1587        let resolution_id = intent_resolution_id(
1588            &execution.request,
1589            resolution.revision.unwrap_or(observation.revision),
1590            &execution.candidate_id,
1591        )?;
1592        let candidate = resolution
1593            .candidates
1594            .iter()
1595            .find(|candidate| candidate.id == execution.candidate_id);
1596        let eligible = candidate.is_some()
1597            && match resolution.policy_decision {
1598                IntentPolicyDecision::Allowed => {
1599                    resolution.selected_candidate.as_deref() == Some(&execution.candidate_id)
1600                }
1601                IntentPolicyDecision::ConfirmationRequired => !matches!(
1602                    resolution.resolution,
1603                    SemanticResolution::NotFound
1604                        | SemanticResolution::StaleRevision
1605                        | SemanticResolution::PolicyRejected
1606                        | SemanticResolution::UnsupportedIntent
1607                ),
1608                IntentPolicyDecision::ReportOnly | IntentPolicyDecision::Rejected => false,
1609            };
1610
1611        let Some(candidate) = candidate else {
1612            return Ok(SemanticIntentExecutionResult {
1613                resolution_id,
1614                candidate_id: execution.candidate_id.clone(),
1615                status: SemanticIntentExecutionStatus::NotExecuted,
1616                resolution,
1617                action: None,
1618                execution_id: None,
1619                reason: Some("selected candidate is not present in the fresh resolution".into()),
1620            });
1621        };
1622        if !eligible {
1623            return Ok(SemanticIntentExecutionResult {
1624                resolution_id,
1625                candidate_id: execution.candidate_id.clone(),
1626                status: SemanticIntentExecutionStatus::NotExecuted,
1627                reason: resolution
1628                    .reason
1629                    .clone()
1630                    .or_else(|| Some("resolution policy did not authorize this candidate".into())),
1631                resolution,
1632                action: None,
1633                execution_id: None,
1634            });
1635        }
1636
1637        let expected_revision = resolution
1638            .revision
1639            .ok_or("eligible intent resolution did not contain a current revision")?;
1640        let action = match execution.request.action {
1641            SemanticIntentAction::Click
1642            | SemanticIntentAction::Submit
1643            | SemanticIntentAction::Open
1644            | SemanticIntentAction::Close
1645            | SemanticIntentAction::Search
1646            | SemanticIntentAction::Filter
1647            | SemanticIntentAction::Sort
1648            | SemanticIntentAction::Paginate
1649            | SemanticIntentAction::Expand
1650            | SemanticIntentAction::Collapse => {
1651                self.click_with_revision(&candidate.reference, expected_revision)
1652                    .await?
1653            }
1654            SemanticIntentAction::Type => {
1655                self.type_text_with_expected_revision(
1656                    execution.value.as_deref().ok_or("type requires a value")?,
1657                    Some(&candidate.reference),
1658                    Some(expected_revision),
1659                )
1660                .await?
1661            }
1662            SemanticIntentAction::Clear => {
1663                self.clear_with_revision(&candidate.reference, Some(expected_revision))
1664                    .await?
1665            }
1666            SemanticIntentAction::Check => {
1667                self.check_with_revision(&candidate.reference, Some(expected_revision))
1668                    .await?
1669            }
1670            SemanticIntentAction::Uncheck => {
1671                self.uncheck_with_revision(&candidate.reference, Some(expected_revision))
1672                    .await?
1673            }
1674            SemanticIntentAction::Select => {
1675                self.select_option_with_revision(
1676                    &candidate.reference,
1677                    execution
1678                        .value
1679                        .as_deref()
1680                        .ok_or("select requires a value")?,
1681                    Some(expected_revision),
1682                )
1683                .await?
1684            }
1685            SemanticIntentAction::Toggle
1686            | SemanticIntentAction::Download
1687            | SemanticIntentAction::Upload
1688            | SemanticIntentAction::Inspect
1689            | SemanticIntentAction::Extract => unreachable!("validated intent action"),
1690        };
1691        let execution_id = action.execution_id.clone();
1692        Ok(SemanticIntentExecutionResult {
1693            resolution_id,
1694            candidate_id: execution.candidate_id.clone(),
1695            status: SemanticIntentExecutionStatus::Executed,
1696            resolution,
1697            action: Some(action),
1698            execution_id: Some(execution_id),
1699            reason: None,
1700        })
1701    }
1702}
1703
1704fn intent_resolution_id(
1705    request: &SemanticIntentRequest,
1706    revision: u64,
1707    candidate_id: &str,
1708) -> Result<String, super::IntentResolutionError> {
1709    let canonical = request.to_canonical_json()?;
1710    let mut hasher = Sha256::new();
1711    hasher.update(canonical.as_bytes());
1712    hasher.update(revision.to_le_bytes());
1713    hasher.update(candidate_id.as_bytes());
1714    Ok(format!("res_{}", hex_digest(hasher.finalize().as_slice())))
1715}
1716
1717fn hex_digest(bytes: &[u8]) -> String {
1718    bytes.iter().map(|byte| format!("{byte:02x}")).collect()
1719}
1720
1721#[cfg(test)]
1722mod tests {
1723    use super::*;
1724
1725    fn make_request() -> SemanticIntentRequest {
1726        SemanticIntentRequest {
1727            schema_version: INTENT_RESOLUTION_SCHEMA_VERSION,
1728            intent: "open settings".into(),
1729            action: SemanticIntentAction::Click,
1730            scope: IntentScope {
1731                page_kind: Some(SemanticPageKind::Dashboard),
1732                region_kind: Some(SemanticRegionKind::Navigation),
1733                ..IntentScope::default()
1734            },
1735            constraints: IntentConstraints {
1736                role: Some("button".into()),
1737                must_be_visible: true,
1738                ..IntentConstraints::default()
1739            },
1740            resolution_policy: SemanticResolutionPolicy::RequireUniqueHighConfidence,
1741            expected_revision: Some(42),
1742        }
1743    }
1744
1745    #[test]
1746    fn request_round_trip_is_canonical_and_bounded() {
1747        let request = make_request();
1748        let first = request.to_canonical_json().unwrap();
1749        assert!(first.contains("requireUniqueHighConfidence"));
1750        assert!(first.contains("pageKind"));
1751        assert_eq!(
1752            SemanticIntentRequest::from_json(&first)
1753                .unwrap()
1754                .to_canonical_json()
1755                .unwrap(),
1756            first
1757        );
1758    }
1759
1760    #[test]
1761    fn request_rejects_invalid_scope_and_limits() {
1762        let mut request = make_request();
1763        request.scope.region_id = Some("region_navigation".into());
1764        request.scope.region_kind = None;
1765        let error = request.validate().unwrap_err();
1766        assert_eq!(error.path, "scope.regionId");
1767
1768        let mut request = make_request();
1769        request.constraints.max_candidates = MAX_CANDIDATES + 1;
1770        let error = request.validate().unwrap_err();
1771        assert_eq!(error.path, "constraints.maxCandidates");
1772    }
1773
1774    #[test]
1775    fn execution_request_requires_bounded_action_inputs() {
1776        let mut request = make_request();
1777        request.constraints.must_be_visible = false;
1778        let mut execution = SemanticIntentExecutionRequest {
1779            request,
1780            candidate_id: "candidate_1".into(),
1781            value: None,
1782        };
1783        assert_eq!(execution.validate().unwrap(), ());
1784
1785        execution.request.action = SemanticIntentAction::Type;
1786        let error = execution.validate().unwrap_err();
1787        assert_eq!(error.path, "value");
1788
1789        execution.value = Some("settings".into());
1790        assert_eq!(execution.validate().unwrap(), ());
1791
1792        execution.request.action = SemanticIntentAction::Toggle;
1793        let error = execution.validate().unwrap_err();
1794        assert_eq!(error.path, "action");
1795    }
1796
1797    #[test]
1798    fn resolution_ids_are_stable_and_request_scoped() {
1799        let request = make_request();
1800        let first = intent_resolution_id(&request, 42, "candidate_1").unwrap();
1801        assert_eq!(
1802            first,
1803            intent_resolution_id(&request, 42, "candidate_1").unwrap()
1804        );
1805        assert_ne!(
1806            first,
1807            intent_resolution_id(&request, 43, "candidate_1").unwrap()
1808        );
1809        assert_ne!(
1810            first,
1811            intent_resolution_id(&request, 42, "candidate_2").unwrap()
1812        );
1813    }
1814
1815    #[test]
1816    fn normalization_is_deterministic_and_rejects_vague_phrases() {
1817        let mut request = make_request();
1818        request.intent = "go to the next page".into();
1819        request.action = SemanticIntentAction::Paginate;
1820        let normalized = normalize_intent(&request).unwrap();
1821        assert_eq!(normalized.canonical, "paginate next");
1822        assert_eq!(normalized.purpose, SemanticIntentPurpose::PaginationNext);
1823
1824        request.intent = "search for blue shoes".into();
1825        request.action = SemanticIntentAction::Search;
1826        let normalized = normalize_intent(&request).unwrap();
1827        assert_eq!(normalized.canonical, "search blue shoes");
1828        assert_eq!(
1829            normalized.terms,
1830            vec!["blue".to_string(), "shoes".to_string()]
1831        );
1832
1833        request.intent = "do it".into();
1834        let error = normalize_intent(&request).unwrap_err();
1835        assert_eq!(error.path, "intent");
1836
1837        request.intent = "search for blue shoes".into();
1838        request.action = SemanticIntentAction::Type;
1839        let error = normalize_intent(&request).unwrap_err();
1840        assert_eq!(error.path, "action");
1841    }
1842
1843    fn semantic_observation() -> SemanticObservation {
1844        let route = SemanticRouteIdentity {
1845            target_id: "target".into(),
1846            frame_id: "frame".into(),
1847            url: "https://example.test/dashboard".into(),
1848        };
1849        let page = SemanticPage {
1850            kind: SemanticPageKind::Dashboard,
1851            title: "Dashboard".into(),
1852            url: route.url.clone(),
1853            target_id: route.target_id.clone(),
1854            frame_id: route.frame_id.clone(),
1855            confidence: super::super::SemanticConfidence::High,
1856            evidence: vec!["fixture".into()],
1857        };
1858        let region = SemanticRegion {
1859            id: "region_navigation_1".into(),
1860            kind: SemanticRegionKind::Navigation,
1861            label: "Navigation".into(),
1862            interactive_count: 3,
1863            item_count: None,
1864            confidence: super::super::SemanticConfidence::Exact,
1865            evidence: vec!["aria-role=navigation".into()],
1866            targets: vec![
1867                SemanticTarget {
1868                    reference: "r42:b1".into(),
1869                    role: "button".into(),
1870                    name: "Settings".into(),
1871                    input_type: None,
1872                },
1873                SemanticTarget {
1874                    reference: "r42:b2".into(),
1875                    role: "button".into(),
1876                    name: "Continue shopping".into(),
1877                    input_type: None,
1878                },
1879                SemanticTarget {
1880                    reference: "r42:b3".into(),
1881                    role: "button".into(),
1882                    name: "Continue to payment".into(),
1883                    input_type: None,
1884                },
1885            ],
1886            expansion: Some(SemanticExpansionHandle {
1887                region_id: "region_navigation_1".into(),
1888                revision: 42,
1889                route: route.clone(),
1890            }),
1891        };
1892        SemanticObservation {
1893            schema_version: super::INTENT_RESOLUTION_SCHEMA_VERSION,
1894            revision: 42,
1895            level: super::super::SemanticObservationLevel::Interactive,
1896            route,
1897            page,
1898            regions: vec![region],
1899            text: None,
1900            accessibility: None,
1901            raw_accessibility: None,
1902            changes: None,
1903            limits: super::super::SemanticObservationLimits::default(),
1904        }
1905    }
1906
1907    #[test]
1908    fn resolver_returns_selected_exact_and_explicit_ambiguous_results() {
1909        let observation = semantic_observation();
1910        let mut request = make_request();
1911        request.constraints.must_be_visible = false;
1912        request.constraints.name = Some("Settings".into());
1913        let result = resolve_intent(&request, &observation);
1914        assert_eq!(result.resolution, SemanticResolution::Exact);
1915        assert_eq!(result.policy_decision, IntentPolicyDecision::Allowed);
1916        assert_eq!(result.selected_candidate.as_deref(), Some("candidate_1"));
1917
1918        request.intent = "continue checkout".into();
1919        request.constraints.name = None;
1920        request.constraints.name_contains = Some("continue".into());
1921        let result = resolve_intent(&request, &observation);
1922        assert_eq!(result.resolution, SemanticResolution::Ambiguous);
1923        assert_eq!(result.policy_decision, IntentPolicyDecision::Rejected);
1924        assert!(result.selected_candidate.is_none());
1925        assert_eq!(result.candidates.len(), 2);
1926    }
1927
1928    #[test]
1929    fn historical_fingerprint_explains_only_a_current_candidate() {
1930        let observation = semantic_observation();
1931        let mut request = make_request();
1932        request.constraints.must_be_visible = false;
1933        request.constraints.name = Some("Settings".into());
1934        let fingerprint = target_fingerprint_digest(
1935            "button",
1936            "Settings",
1937            None,
1938            Some(SemanticRegionKind::Navigation),
1939            SemanticIntentPurpose::Open,
1940        );
1941        let mut historical = BTreeSet::new();
1942        historical.insert(fingerprint);
1943        let result = resolve_intent_with_historical_matches(&request, &observation, &historical);
1944        assert_eq!(result.resolution, SemanticResolution::Exact);
1945        assert!(
1946            result.candidates[0]
1947                .evidence
1948                .iter()
1949                .any(|evidence| { evidence.category == IntentEvidenceCategory::HistoricalMatch })
1950        );
1951
1952        let mut unknown = BTreeSet::new();
1953        unknown.insert("sha256:not-current".into());
1954        let without_match =
1955            resolve_intent_with_historical_matches(&request, &observation, &unknown);
1956        assert_eq!(without_match.candidates.len(), result.candidates.len());
1957        assert!(
1958            !without_match.candidates[0]
1959                .evidence
1960                .iter()
1961                .any(|evidence| evidence.category == IntentEvidenceCategory::HistoricalMatch)
1962        );
1963    }
1964
1965    #[test]
1966    fn resolver_fails_closed_for_stale_revision_and_unproven_state() {
1967        let observation = semantic_observation();
1968        let mut request = make_request();
1969        request.expected_revision = Some(41);
1970        let result = resolve_intent(&request, &observation);
1971        assert_eq!(result.resolution, SemanticResolution::StaleRevision);
1972
1973        request.expected_revision = Some(42);
1974        request.constraints.must_be_enabled = true;
1975        let result = resolve_intent(&request, &observation);
1976        assert_eq!(result.resolution, SemanticResolution::NotFound);
1977        assert_eq!(result.excluded_count, 3);
1978        assert!(result.selected_candidate.is_none());
1979    }
1980
1981    #[test]
1982    fn result_rejects_duplicate_evidence_and_unknown_fields() {
1983        let result = SemanticIntentResult {
1984            schema_version: INTENT_RESOLUTION_SCHEMA_VERSION,
1985            intent: "open settings".into(),
1986            action: SemanticIntentAction::Click,
1987            normalized_intent: "open settings".into(),
1988            resolution: SemanticResolution::UniqueHighConfidence,
1989            policy_decision: IntentPolicyDecision::Allowed,
1990            route: None,
1991            revision: Some(42),
1992            candidates: vec![SemanticIntentCandidate {
1993                id: "candidate_1".into(),
1994                reference: "r42:b17".into(),
1995                role: "button".into(),
1996                name: "Settings".into(),
1997                input_type: None,
1998                region_id: None,
1999                region_kind: None,
2000                confidence: IntentConfidence::High,
2001                evidence: vec![IntentEvidence {
2002                    category: IntentEvidenceCategory::ExactName,
2003                    detail: "accessible name exact match".into(),
2004                }],
2005                fingerprint: None,
2006            }],
2007            excluded_candidates: Vec::new(),
2008            excluded_count: 0,
2009            selected_candidate: Some("candidate_1".into()),
2010            suggested_constraints: Vec::new(),
2011            reason: None,
2012        };
2013        let canonical = result.to_canonical_json().unwrap();
2014        let mut value: serde_json::Value = serde_json::from_str(&canonical).unwrap();
2015        value["futureField"] = true.into();
2016        assert_eq!(
2017            SemanticIntentResult::from_json(&value.to_string())
2018                .unwrap_err()
2019                .path,
2020            "$"
2021        );
2022
2023        let mut duplicate = result;
2024        duplicate.candidates[0].evidence.push(IntentEvidence {
2025            category: IntentEvidenceCategory::ExactName,
2026            detail: "duplicate".into(),
2027        });
2028        assert_eq!(
2029            duplicate.validate().unwrap_err().path,
2030            "candidates[0].evidence[1].category"
2031        );
2032    }
2033}