Skip to main content

sim_lib_doc_core/
projection.rs

1//! Projection selection for office documents.
2
3use std::collections::BTreeMap;
4
5use sim_kernel::{Cx, Symbol, Value};
6
7use crate::{Doc, OfficeError};
8
9/// Open tag key for a preferred document lens.
10pub const TAG_LENS: &str = "lens";
11/// Open tag key for the intended surface or export target.
12pub const TAG_TARGET: &str = "target";
13/// Open tag key for a preferred backend or file/service family.
14pub const TAG_BACKEND: &str = "backend";
15/// Open tag key for statement-specific projections.
16pub const TAG_STATEMENT_KIND: &str = "statement-kind";
17/// Open tag key for the requested fidelity level.
18pub const TAG_FIDELITY: &str = "fidelity";
19
20const LENS_SOURCE: &str = "source";
21const LENS_FORMATTED: &str = "formatted";
22const TARGET_DECK: &str = "deck";
23const TARGET_SCREEN: &str = "screen";
24const FIDELITY_SUMMARY: &str = "summary";
25const FIDELITY_STANDARD: &str = "standard";
26const FIDELITY_FULL: &str = "full";
27
28/// Open projection capabilities.
29///
30/// Tags are ordinary strings so new office domains, backends, statement kinds,
31/// and surface hosts can participate without adding a closed enum to the core.
32#[derive(Clone, Debug, Default, PartialEq, Eq)]
33pub struct ProjectionCaps {
34    /// Open capability tags used by the projection ranker.
35    pub tags: BTreeMap<String, String>,
36}
37
38impl ProjectionCaps {
39    /// Build an empty capability set.
40    #[must_use]
41    pub fn new() -> Self {
42        Self::default()
43    }
44
45    /// Insert one open tag.
46    #[must_use]
47    pub fn with_tag(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {
48        self.tags.insert(key.into(), value.into());
49        self
50    }
51
52    /// Borrow a tag value.
53    #[must_use]
54    pub fn get(&self, key: &str) -> Option<&str> {
55        self.tags.get(key).map(String::as_str)
56    }
57
58    /// Set the preferred document lens.
59    #[must_use]
60    pub fn lens(self, lens: &str) -> Self {
61        self.with_tag(TAG_LENS, lens)
62    }
63
64    /// Set the intended surface or export target.
65    #[must_use]
66    pub fn target(self, target: &str) -> Self {
67        self.with_tag(TAG_TARGET, target)
68    }
69
70    /// Set the preferred backend or file/service family.
71    #[must_use]
72    pub fn backend(self, backend: &str) -> Self {
73        self.with_tag(TAG_BACKEND, backend)
74    }
75
76    /// Set the statement kind.
77    #[must_use]
78    pub fn statement_kind(self, statement_kind: &str) -> Self {
79        self.with_tag(TAG_STATEMENT_KIND, statement_kind)
80    }
81
82    /// Set the requested fidelity level.
83    #[must_use]
84    pub fn fidelity(self, fidelity: &str) -> Self {
85        self.with_tag(TAG_FIDELITY, fidelity)
86    }
87}
88
89/// Office projection caps are surface-cap metadata carried as open tags.
90pub type SurfaceCaps = ProjectionCaps;
91
92/// A request to project one document for one capability set.
93#[derive(Clone, Debug, PartialEq)]
94pub struct ProjectionRequest<'a> {
95    /// Document being projected.
96    pub doc: &'a Doc,
97    /// Open surface and export capabilities.
98    pub caps: &'a ProjectionCaps,
99}
100
101impl<'a> ProjectionRequest<'a> {
102    /// Build a projection request.
103    #[must_use]
104    pub fn new(doc: &'a Doc, caps: &'a ProjectionCaps) -> Self {
105        Self { doc, caps }
106    }
107}
108
109#[derive(Clone, Copy, Debug, PartialEq, Eq)]
110struct ProjectionBranch {
111    name: &'static str,
112    required_tags: &'static [(&'static str, &'static str)],
113}
114
115impl ProjectionBranch {
116    fn score(self, caps: &ProjectionCaps) -> Option<usize> {
117        let mut score = 0;
118        for (key, value) in self.required_tags {
119            if caps.get(key)? != *value {
120                return None;
121            }
122            score += 10;
123        }
124        Some(score)
125    }
126}
127
128const SCREEN_BRANCH: ProjectionBranch = ProjectionBranch {
129    name: "screen-pane",
130    required_tags: &[(TAG_TARGET, TARGET_SCREEN)],
131};
132const DECK_BRANCH: ProjectionBranch = ProjectionBranch {
133    name: "deck-export",
134    required_tags: &[(TAG_TARGET, TARGET_DECK)],
135};
136const STATEMENT_BRANCH: ProjectionBranch = ProjectionBranch {
137    name: "statement-table",
138    required_tags: &[(TAG_STATEMENT_KIND, "statement")],
139};
140const GENERIC_BRANCH: ProjectionBranch = ProjectionBranch {
141    name: "generic-doc",
142    required_tags: &[],
143};
144
145const BRANCHES: &[ProjectionBranch] =
146    &[SCREEN_BRANCH, DECK_BRANCH, STATEMENT_BRANCH, GENERIC_BRANCH];
147
148/// Project a document to a deterministic runtime value for the requested caps.
149pub fn project(cx: &mut Cx, req: &ProjectionRequest<'_>) -> Result<Value, OfficeError> {
150    let branch = rank_branch(req.caps);
151    let fidelity = req.caps.get(TAG_FIDELITY).unwrap_or(FIDELITY_STANDARD);
152    let lens = req.caps.get(TAG_LENS).unwrap_or(LENS_FORMATTED);
153    let mut entries = vec![
154        symbol_value(cx, "kind", "office/projection")?,
155        string_value(cx, "branch", branch.name)?,
156        string_value(cx, "doc-kind", req.doc.kind.as_str())?,
157        string_value(cx, "doc-id", req.doc.id.as_str())?,
158        string_value(cx, TAG_LENS, lens)?,
159        string_value(cx, TAG_FIDELITY, fidelity)?,
160    ];
161
162    if fidelity != FIDELITY_SUMMARY {
163        entries.push(string_value(
164            cx,
165            TAG_TARGET,
166            req.caps.get(TAG_TARGET).unwrap_or("generic"),
167        )?);
168        if let Some(backend) = req.caps.get(TAG_BACKEND) {
169            entries.push(string_value(cx, TAG_BACKEND, backend)?);
170        }
171        if let Some(statement_kind) = req.caps.get(TAG_STATEMENT_KIND) {
172            entries.push(string_value(cx, TAG_STATEMENT_KIND, statement_kind)?);
173        }
174        entries.push(body_value(cx, lens, &req.doc.body)?);
175    }
176
177    if fidelity == FIDELITY_FULL {
178        entries.push(tags_value(cx, req.caps)?);
179        entries.push(origin_value(cx, req.doc)?);
180    }
181
182    cx.factory().table(entries).map_err(OfficeError::from)
183}
184
185fn rank_branch(caps: &ProjectionCaps) -> ProjectionBranch {
186    BRANCHES
187        .iter()
188        .enumerate()
189        .filter_map(|(index, branch)| branch.score(caps).map(|score| (*branch, score, index)))
190        .max_by(|left, right| left.1.cmp(&right.1).then_with(|| right.2.cmp(&left.2)))
191        .map(|(branch, _, _)| branch)
192        .unwrap_or(GENERIC_BRANCH)
193}
194
195fn symbol_value(cx: &mut Cx, key: &str, value: &str) -> Result<(Symbol, Value), OfficeError> {
196    Ok((
197        Symbol::new(key),
198        cx.factory()
199            .symbol(Symbol::new(value.to_owned()))
200            .map_err(OfficeError::from)?,
201    ))
202}
203
204fn string_value(cx: &mut Cx, key: &str, value: &str) -> Result<(Symbol, Value), OfficeError> {
205    Ok((
206        Symbol::new(key),
207        cx.factory()
208            .string(value.to_owned())
209            .map_err(OfficeError::from)?,
210    ))
211}
212
213fn body_value(cx: &mut Cx, lens: &str, body: &Value) -> Result<(Symbol, Value), OfficeError> {
214    let value = if lens == LENS_SOURCE {
215        body.clone()
216    } else {
217        let display = body.object().display(cx).map_err(OfficeError::from)?;
218        cx.factory().string(display).map_err(OfficeError::from)?
219    };
220    Ok((Symbol::new("body"), value))
221}
222
223fn tags_value(cx: &mut Cx, caps: &ProjectionCaps) -> Result<(Symbol, Value), OfficeError> {
224    let mut pairs = Vec::with_capacity(caps.tags.len());
225    for (key, value) in &caps.tags {
226        pairs.push((
227            Symbol::new(key.clone()),
228            cx.factory()
229                .string(value.clone())
230                .map_err(OfficeError::from)?,
231        ));
232    }
233    Ok((
234        Symbol::new("caps"),
235        cx.factory().table(pairs).map_err(OfficeError::from)?,
236    ))
237}
238
239fn origin_value(cx: &mut Cx, doc: &Doc) -> Result<(Symbol, Value), OfficeError> {
240    let mut refs = Vec::with_capacity(doc.origin.len());
241    for external in &doc.origin {
242        let mut fields = vec![
243            string_value(cx, TAG_BACKEND, &external.backend)?,
244            string_value(cx, "external-id", &external.external_id)?,
245        ];
246        if let Some(version) = &external.version {
247            fields.push(string_value(cx, "version", version)?);
248        }
249        if let Some(web_url) = &external.web_url {
250            fields.push(string_value(cx, "web-url", web_url)?);
251        }
252        refs.push(cx.factory().table(fields).map_err(OfficeError::from)?);
253    }
254    Ok((
255        Symbol::new("origin"),
256        cx.factory().list(refs).map_err(OfficeError::from)?,
257    ))
258}
259
260#[cfg(test)]
261mod tests {
262    use sim_kernel::{Expr, testing::bare_cx as cx};
263
264    use super::*;
265    use crate::{DocId, DocKind};
266
267    fn doc(cx: &mut Cx) -> Doc {
268        Doc::new(
269            DocKind::new("report"),
270            DocId::new("doc-1"),
271            cx.factory().string("body text".to_owned()).unwrap(),
272            vec![],
273        )
274    }
275
276    fn projected_expr(cx: &mut Cx, doc: &Doc, caps: &ProjectionCaps) -> Expr {
277        project(cx, &ProjectionRequest::new(doc, caps))
278            .unwrap()
279            .object()
280            .as_expr(cx)
281            .unwrap()
282    }
283
284    fn map_len(expr: &Expr) -> usize {
285        let Expr::Map(entries) = expr else {
286            panic!("projection must be a map");
287        };
288        entries.len()
289    }
290
291    fn string_field(expr: &Expr, name: &str) -> String {
292        let Expr::Map(entries) = expr else {
293            panic!("projection must be a map");
294        };
295        let value = entries
296            .iter()
297            .find_map(|(key, value)| match key {
298                Expr::Symbol(symbol) if symbol.name.as_ref() == name => Some(value),
299                _ => None,
300            })
301            .unwrap_or_else(|| panic!("missing field {name}"));
302        match value {
303            Expr::String(text) => text.clone(),
304            Expr::Symbol(symbol) => symbol.to_string(),
305            other => panic!("field {name} is not a string-like value: {other:?}"),
306        }
307    }
308
309    #[test]
310    fn summary_projection_has_fewer_fields_than_full() {
311        let mut cx = cx();
312        let doc = doc(&mut cx);
313        let summary = ProjectionCaps::new()
314            .target(TARGET_SCREEN)
315            .fidelity(FIDELITY_SUMMARY);
316        let full = ProjectionCaps::new()
317            .target(TARGET_SCREEN)
318            .backend("codec/ooxml")
319            .statement_kind("statement")
320            .fidelity(FIDELITY_FULL);
321
322        let summary_expr = projected_expr(&mut cx, &doc, &summary);
323        let full_expr = projected_expr(&mut cx, &doc, &full);
324
325        assert!(map_len(&summary_expr) < map_len(&full_expr));
326        assert_eq!(string_field(&summary_expr, "branch"), "screen-pane");
327    }
328
329    #[test]
330    fn deck_and_screen_targets_select_different_branches() {
331        let mut cx = cx();
332        let doc = doc(&mut cx);
333        let screen = ProjectionCaps::new().target(TARGET_SCREEN);
334        let deck = ProjectionCaps::new().target(TARGET_DECK);
335
336        let screen_expr = projected_expr(&mut cx, &doc, &screen);
337        let deck_expr = projected_expr(&mut cx, &doc, &deck);
338
339        assert_eq!(string_field(&screen_expr, "branch"), "screen-pane");
340        assert_eq!(string_field(&deck_expr, "branch"), "deck-export");
341    }
342
343    #[test]
344    fn unknown_caps_fall_back_deterministically() {
345        let mut cx = cx();
346        let doc = doc(&mut cx);
347        let caps = ProjectionCaps::new()
348            .target("unknown")
349            .with_tag("unknown-tag", "value");
350
351        let first = projected_expr(&mut cx, &doc, &caps);
352        let second = projected_expr(&mut cx, &doc, &caps);
353
354        assert!(first.canonical_eq(&second));
355        assert_eq!(string_field(&first, "branch"), "generic-doc");
356    }
357
358    #[test]
359    fn source_and_formatted_lenses_rank_without_closed_enum() {
360        let mut cx = cx();
361        let doc = doc(&mut cx);
362        let source = ProjectionCaps::new().lens(LENS_SOURCE);
363        let formatted = ProjectionCaps::new().lens(LENS_FORMATTED);
364
365        let source_expr = projected_expr(&mut cx, &doc, &source);
366        let formatted_expr = projected_expr(&mut cx, &doc, &formatted);
367
368        assert_eq!(string_field(&source_expr, TAG_LENS), LENS_SOURCE);
369        assert_eq!(string_field(&formatted_expr, TAG_LENS), LENS_FORMATTED);
370        assert_ne!(source_expr, formatted_expr);
371    }
372}