Skip to main content

sim_lib_view/
dispatch.rs

1//! Shape-based lens dispatch.
2//!
3//! Choosing a lens for a value is overload selection, which is exactly what the
4//! kernel `Shape` matcher already does. The dispatcher reuses that matcher and
5//! the documented resolution order; it is implemented once here and never
6//! reimplemented per domain. Resolution order (WEBUI_4 "Dispatch"):
7//!
8//! 1. explicit operator choice;
9//! 2. saved workspace preference;
10//! 3. best Shape match (most specific wins; ties by quality then cost);
11//! 4. class match fallback;
12//! 5. the universal default (always matches, lowest quality).
13//!
14//! Every candidate must pass capability filtering; a denied lens is skipped and
15//! resolution falls through, ending at the read-only universal default.
16
17use sim_kernel::{CapabilityName, Cx, Error, Expr, Result, Symbol};
18
19use crate::contract::{Lens, LensKind};
20
21/// The context a dispatch runs in: operator choice, saved preference, active
22/// mode, the value's class, and the capability predicate.
23pub struct DispatchContext<'a> {
24    /// An explicit lens choice (from `intent/set-lens`).
25    pub explicit: Option<Symbol>,
26    /// A saved workspace preference for this resource.
27    pub preference: Option<Symbol>,
28    /// The active experience mode, if any.
29    pub active_mode: Option<Symbol>,
30    /// The value's class symbol, for the class-match fallback.
31    pub value_class: Option<Symbol>,
32    /// Returns whether a capability is granted to the operator.
33    pub granted: &'a dyn Fn(&CapabilityName) -> bool,
34}
35
36impl<'a> DispatchContext<'a> {
37    /// A context that grants every capability and has no preferences.
38    pub fn permissive(grant_all: &'a dyn Fn(&CapabilityName) -> bool) -> Self {
39        Self {
40            explicit: None,
41            preference: None,
42            active_mode: None,
43            value_class: None,
44            granted: grant_all,
45        }
46    }
47}
48
49/// Why the dispatcher chose a lens.
50#[derive(Clone, Debug, PartialEq, Eq)]
51pub enum DispatchReason {
52    /// Selected by explicit operator choice.
53    Explicit,
54    /// Selected by saved workspace preference.
55    Preference,
56    /// Selected as the best Shape match, with the winning match score.
57    ShapeMatch(i32),
58    /// Selected by class-match fallback.
59    ClassMatch,
60    /// Selected as the universal default.
61    UniversalDefault,
62}
63
64/// The outcome of a successful dispatch.
65#[derive(Clone, Debug, PartialEq, Eq)]
66pub struct DispatchOutcome {
67    /// The chosen lens id.
68    pub lens_id: Symbol,
69    /// Why it was chosen.
70    pub reason: DispatchReason,
71}
72
73/// A registry of lenses with a single shared dispatcher.
74#[derive(Default)]
75pub struct LensRegistry {
76    lenses: Vec<Lens>,
77}
78
79impl LensRegistry {
80    /// An empty registry.
81    pub fn new() -> Self {
82        Self::default()
83    }
84
85    /// Register a lens (last registration of an id wins on exact ties).
86    pub fn register(&mut self, lens: Lens) {
87        self.lenses.push(lens);
88    }
89
90    /// Look up a lens by id.
91    pub fn get(&self, id: &Symbol) -> Option<&Lens> {
92        self.lenses.iter().find(|lens| &lens.meta.id == id)
93    }
94
95    /// All registered lenses.
96    pub fn lenses(&self) -> &[Lens] {
97        &self.lenses
98    }
99
100    /// Dispatch a `View` lens for `target`.
101    pub fn dispatch_view(
102        &self,
103        cx: &mut Cx,
104        target: &Expr,
105        ctx: &DispatchContext,
106    ) -> Result<DispatchOutcome> {
107        self.dispatch(cx, LensKind::View, target, ctx)
108    }
109
110    /// Dispatch an `Editor` lens for `target`.
111    pub fn dispatch_editor(
112        &self,
113        cx: &mut Cx,
114        target: &Expr,
115        ctx: &DispatchContext,
116    ) -> Result<DispatchOutcome> {
117        self.dispatch(cx, LensKind::Editor, target, ctx)
118    }
119
120    /// Resolve a lens of `kind` for `target` per the documented order.
121    pub fn dispatch(
122        &self,
123        cx: &mut Cx,
124        kind: LensKind,
125        target: &Expr,
126        ctx: &DispatchContext,
127    ) -> Result<DispatchOutcome> {
128        // 1. explicit operator choice.
129        if let Some(outcome) = self.pick_named(&ctx.explicit, kind, ctx, DispatchReason::Explicit) {
130            return Ok(outcome);
131        }
132        // 2. saved workspace preference.
133        if let Some(outcome) =
134            self.pick_named(&ctx.preference, kind, ctx, DispatchReason::Preference)
135        {
136            return Ok(outcome);
137        }
138        // 3. best Shape match (most specific wins; ties by quality then cost).
139        if let Some(outcome) = self.pick_shape_match(cx, kind, target, ctx)? {
140            return Ok(outcome);
141        }
142        // 4. class match fallback.
143        if let Some(outcome) = self.pick_class_match(kind, ctx) {
144            return Ok(outcome);
145        }
146        // 5. universal default.
147        if let Some(lens) = self.lenses.iter().find(|lens| {
148            lens.meta.kind == kind && lens.meta.universal_default && self.allowed(lens, ctx)
149        }) {
150            return Ok(DispatchOutcome {
151                lens_id: lens.meta.id.clone(),
152                reason: DispatchReason::UniversalDefault,
153            });
154        }
155        Err(Error::HostError(format!(
156            "no {kind:?} lens available for the value (not even a universal default)"
157        )))
158    }
159
160    pub(crate) fn allowed(&self, lens: &Lens, ctx: &DispatchContext) -> bool {
161        lens.meta
162            .required_capabilities
163            .iter()
164            .all(|capability| (ctx.granted)(capability))
165    }
166
167    fn pick_named(
168        &self,
169        id: &Option<Symbol>,
170        kind: LensKind,
171        ctx: &DispatchContext,
172        reason: DispatchReason,
173    ) -> Option<DispatchOutcome> {
174        let id = id.as_ref()?;
175        let lens = self.get(id)?;
176        if lens.meta.kind == kind && self.allowed(lens, ctx) {
177            Some(DispatchOutcome {
178                lens_id: id.clone(),
179                reason,
180            })
181        } else {
182            None
183        }
184    }
185
186    fn pick_shape_match(
187        &self,
188        cx: &mut Cx,
189        kind: LensKind,
190        target: &Expr,
191        ctx: &DispatchContext,
192    ) -> Result<Option<DispatchOutcome>> {
193        let mut best: Option<(i32, i32, i32, Symbol)> = None;
194        for lens in &self.lenses {
195            if lens.meta.kind != kind || lens.meta.universal_default || !self.allowed(lens, ctx) {
196                continue;
197            }
198            let Some(score) = best_shape_score(cx, lens, target)? else {
199                continue;
200            };
201            // Rank by (score, quality, -cost); first registered wins exact ties.
202            let candidate = (score, lens.meta.quality, -lens.meta.cost);
203            let better = match &best {
204                Some((bs, bq, bc, _)) => candidate > (*bs, *bq, *bc),
205                None => true,
206            };
207            if better {
208                best = Some((candidate.0, candidate.1, candidate.2, lens.meta.id.clone()));
209            }
210        }
211        Ok(best.map(|(score, _, _, lens_id)| DispatchOutcome {
212            lens_id,
213            reason: DispatchReason::ShapeMatch(score),
214        }))
215    }
216
217    fn pick_class_match(&self, kind: LensKind, ctx: &DispatchContext) -> Option<DispatchOutcome> {
218        let class = ctx.value_class.as_ref()?;
219        let mut best: Option<(i32, i32, Symbol)> = None;
220        for lens in &self.lenses {
221            if lens.meta.kind != kind
222                || lens.meta.universal_default
223                || !self.allowed(lens, ctx)
224                || !lens.meta.claimed_classes.contains(class)
225            {
226                continue;
227            }
228            let candidate = (lens.meta.quality, -lens.meta.cost);
229            let better = match &best {
230                Some((bq, bc, _)) => candidate > (*bq, *bc),
231                None => true,
232            };
233            if better {
234                best = Some((candidate.0, candidate.1, lens.meta.id.clone()));
235            }
236        }
237        best.map(|(_, _, lens_id)| DispatchOutcome {
238            lens_id,
239            reason: DispatchReason::ClassMatch,
240        })
241    }
242}
243
244/// The best accepted Shape match score among a lens's claimed Shapes, if any.
245pub(crate) fn best_shape_score(cx: &mut Cx, lens: &Lens, target: &Expr) -> Result<Option<i32>> {
246    let mut best: Option<i32> = None;
247    for shape_value in &lens.meta.claimed_shapes {
248        let Some(shape) = shape_value.object().as_shape() else {
249            continue;
250        };
251        let matched = shape.check_expr(cx, target)?;
252        if matched.accepted {
253            let score = matched.score.value();
254            best = Some(best.map_or(score, |current| current.max(score)));
255        }
256    }
257    Ok(best)
258}