Skip to main content

writerslogic_warm/core/memory_api/
recall.rs

1// Copyright 2024-2026 WritersLogic Contributors
2// SPDX-License-Identifier: Apache-2.0
3
4//! `recall`, `explain`, `get` and `history`.
5//!
6//! The default path is document search plus one metadata parse and one map lookup per
7//! result. When nothing in the store has a validity window or a supersession link and no
8//! recency policy is set, recall asks document search for exactly `k` results with the
9//! options the caller gave, so its ranking and scores are document search's own. Otherwise it
10//! resolves temporal eligibility before candidate selection, then applies recency policies
11//! within the caller's candidate budget.
12
13use anyhow::Result;
14use std::collections::{BTreeSet, HashSet};
15
16use super::index::{Edge, Index};
17use super::stamp::{user_metadata, Stamp};
18use super::types::*;
19use super::{Memory, MemoryError, RecencyContext};
20use crate::core::documents::{terms, DocumentResult, SearchOptions};
21
22/// Searches tried before a recall settles for an index that may trail the searched revision.
23const SNAPSHOT_ATTEMPTS: usize = 4;
24
25/// A fact's status at `now`, or none when `known_at` predates its ingest.
26pub(super) fn evaluate(
27    stamp: Option<&Stamp>,
28    edges: &[Edge],
29    now: Millis,
30    known_at: Option<Millis>,
31) -> Option<FactStatus> {
32    if let (Some(known), Some(stamp)) = (known_at, stamp) {
33        if stamp.ingest > known {
34            return None;
35        }
36    }
37    let superseded = edges
38        .iter()
39        .any(|edge| edge.effective <= now && known_at.is_none_or(|known| edge.ingest <= known));
40    if superseded {
41        return Some(FactStatus::Superseded);
42    }
43    let Some(stamp) = stamp else {
44        return Some(FactStatus::Current);
45    };
46    if stamp.valid_to.is_some_and(|to| to <= now) {
47        return Some(FactStatus::Expired);
48    }
49    if stamp.valid_from.is_some_and(|from| from > now) {
50        return Some(FactStatus::NotYetValid);
51    }
52    Some(FactStatus::Current)
53}
54
55struct Ranked {
56    hit: DocumentResult,
57    stamp: Option<Stamp>,
58    status: FactStatus,
59    recency: f64,
60    historical: f64,
61    score: f64,
62}
63
64struct Run {
65    ranked: Vec<Ranked>,
66    now: Millis,
67    lexical_weight: f64,
68    semantic_weight: f64,
69    fusion: String,
70}
71
72impl RecallOptions {
73    fn search_options(&self, k: u32) -> SearchOptions {
74        SearchOptions {
75            k: Some(k),
76            candidate_limit: self.candidate_limit,
77            filter: self.filter.clone(),
78            embedding: self.embedding.clone(),
79            lexical_weight: self.lexical_weight,
80            semantic_weight: self.semantic_weight,
81            min_semantic_score: self.min_semantic_score,
82            fusion: self.fusion.clone(),
83            ..Default::default()
84        }
85    }
86}
87
88fn sorted_ids(edges: &[Edge]) -> Vec<String> {
89    let mut edges: Vec<&Edge> = edges.iter().collect();
90    edges.sort_by(|a, b| (a.effective, a.ingest, &a.by).cmp(&(b.effective, b.ingest, &b.by)));
91    edges.into_iter().map(|edge| edge.by.clone()).collect()
92}
93
94/// The query's tokens, lowercased, each with its stem; computed once per query rather than
95/// once per explained result.
96fn query_terms(query: &str) -> Vec<(String, Option<String>)> {
97    query
98        .split(|c: char| !c.is_alphanumeric())
99        .filter(|token| !token.is_empty())
100        .map(|token| {
101            let lower = token.to_lowercase();
102            let stem = terms(&lower, true).into_keys().next();
103            (lower, stem)
104        })
105        .collect()
106}
107
108fn matched_terms(query: &[(String, Option<String>)], text: &str) -> Vec<String> {
109    let stems = terms(text, true);
110    let raw = terms(text, false);
111    let mut matched: Vec<String> = Vec::new();
112    for (lower, stem) in query {
113        let hit =
114            raw.contains_key(lower) || stem.as_ref().is_some_and(|stem| stems.contains_key(stem));
115        if hit && !matched.contains(lower) {
116            matched.push(lower.clone());
117        }
118    }
119    matched
120}
121
122impl Memory {
123    /// Facts relevant to `query`, best first.
124    ///
125    /// By default only facts that are current now are returned, ranked as document search
126    /// ranks them. `options` selects history, an `as_of` or `known_at` time, a subject or
127    /// entities, and per-result explanations.
128    pub fn recall(&self, query: &str, options: &RecallOptions) -> Result<Vec<Recalled>> {
129        self.with_revision(|| self.recall_once(query, options))
130    }
131
132    fn recall_once(&self, query: &str, options: &RecallOptions) -> Result<Vec<Recalled>> {
133        let k = options.k.unwrap_or(self.config.default_k);
134        self.core
135            .validate_document_search(query, &options.search_options(k))
136            .map_err(|error| MemoryError::Invalid(error.to_string()))?;
137        let history = options.history.unwrap_or(self.config.default_history);
138        self.ensure_fresh();
139        let Some(mut run) = self.run(query, options, history, k, false)? else {
140            return Ok(Vec::new());
141        };
142        let index = self.index.read();
143        let explained_terms = options.explain.then(|| query_terms(query));
144        let ranked = std::mem::take(&mut run.ranked);
145        Ok(ranked
146            .into_iter()
147            .enumerate()
148            .map(|(position, ranked)| {
149                let explanation = explained_terms.as_deref().map(|terms| {
150                    self.explanation(&index, &run, terms, options, &ranked, Some(position + 1))
151                });
152                self.recalled(&index, ranked, explanation)
153            })
154            .collect())
155    }
156
157    /// Why `id` does or does not come back for `query`: its lexical and semantic scores,
158    /// matched terms, recency and validity adjustments and supersession chain.
159    ///
160    /// The rank is among the ranked candidates with history included, so it can differ from
161    /// the position in a current-only recall. A fact outside the candidates is still
162    /// explained, with `retrieved: false`.
163    pub fn explain(&self, query: &str, id: &str, options: &RecallOptions) -> Result<Explanation> {
164        self.with_revision(|| self.explain_once(query, id, options))
165    }
166
167    fn explain_once(&self, query: &str, id: &str, options: &RecallOptions) -> Result<Explanation> {
168        self.core
169            .validate_document_search(
170                query,
171                &options.search_options(options.k.unwrap_or(self.config.default_k)),
172            )
173            .map_err(|error| MemoryError::Invalid(error.to_string()))?;
174        self.ensure_fresh();
175        let (version, metadata, text) = self
176            .core
177            .document_snapshot(id)
178            .ok_or_else(|| MemoryError::NotFound(format!("unknown fact {id:?}")))?;
179        let k = options.k.unwrap_or(self.config.default_k).min(1000);
180        let limit = options
181            .candidate_limit
182            .unwrap_or_else(|| 100.max(k))
183            .clamp(1, 1000);
184        let run = self.run(query, options, HistoryMode::All, limit, true)?;
185        let now = options.as_of.unwrap_or_else(|| self.clock.now_millis());
186        let index = self.index.read();
187        if let Some(run) = &run {
188            if let Some(position) = run.ranked.iter().position(|r| r.hit.document_id == id) {
189                let ranked = &run.ranked[position];
190                return Ok(self.explanation(
191                    &index,
192                    run,
193                    &query_terms(query),
194                    options,
195                    ranked,
196                    Some(position + 1),
197                ));
198            }
199        }
200        let stamp = Stamp::from_parts(&version, &metadata);
201        let status = evaluate(stamp.as_ref(), index.edges(id), now, options.known_at)
202            .unwrap_or(FactStatus::Current);
203        Ok(Explanation {
204            retrieved: false,
205            rank: None,
206            lexical_score: None,
207            semantic_score: None,
208            retrieval_score: None,
209            lexical_weight: options.lexical_weight.unwrap_or(1.0),
210            semantic_weight: options.semantic_weight.unwrap_or(1.0),
211            fusion: options.fusion.clone().unwrap_or_else(|| "blend".into()),
212            matched_terms: text
213                .as_deref()
214                .map(|text| matched_terms(&query_terms(query), text))
215                .unwrap_or_default(),
216            recency_factor: 1.0,
217            historical_factor: 1.0,
218            final_score: None,
219            status,
220            as_of: now,
221            known_at: options.known_at,
222            valid_from: stamp.as_ref().and_then(|s| s.valid_from),
223            valid_to: stamp.as_ref().and_then(|s| s.valid_to),
224            within_validity: stamp.as_ref().is_none_or(|s| {
225                s.valid_from.is_none_or(|from| from <= now) && s.valid_to.is_none_or(|to| now < to)
226            }),
227            chain: self.chain(&index, id),
228        })
229    }
230
231    /// One stored fact, or none.
232    pub fn get(&self, id: &str) -> Result<Option<FactRecord>> {
233        let now = self.clock.now_millis();
234        self.with_snapshot(|index| Ok(self.record(index, id, now)))
235    }
236
237    /// Every version of the fact `id` belongs to: it, what it superseded and what superseded
238    /// it, in effect order, each with its status now.
239    pub fn history(&self, id: &str) -> Result<Vec<FactRecord>> {
240        let now = self.clock.now_millis();
241        self.with_snapshot(|index| {
242            if !self.core.has_document(id) {
243                return Err(MemoryError::NotFound(format!("unknown fact {id:?}")).into());
244            }
245            let mut records: Vec<FactRecord> = index
246                .component(id)
247                .iter()
248                .filter_map(|member| self.record(index, member, now))
249                .collect();
250            records.sort_by(|a, b| {
251                (a.effective_from, a.ingest_time, &a.id).cmp(&(
252                    b.effective_from,
253                    b.ingest_time,
254                    &b.id,
255                ))
256            });
257            Ok(records)
258        })
259    }
260
261    fn record(&self, index: &Index, id: &str, now: Millis) -> Option<FactRecord> {
262        let (version, metadata, text) = self.core.document_snapshot(id)?;
263        let stamp = Stamp::from_parts(&version, &metadata);
264        let edges = index.edges(id);
265        let status = evaluate(stamp.as_ref(), edges, now, None).unwrap_or(FactStatus::Current);
266        let ingest_time = stamp.as_ref().map(|s| s.ingest);
267        let known = stamp.unwrap_or_default();
268        Some(FactRecord {
269            id: id.to_owned(),
270            text,
271            status,
272            ingest_time,
273            event_time: known.event,
274            valid_from: known.valid_from,
275            valid_to: known.valid_to,
276            effective_from: known.effective_from(),
277            subject: known.subject,
278            entities: known.entities,
279            supersedes: index_predecessors(index, id, &known.supersedes),
280            superseded_by: sorted_ids(edges),
281            metadata: user_metadata(metadata),
282        })
283    }
284
285    /// Searches, evaluates and ranks. None when no fact can qualify.
286    ///
287    /// A fact's status is judged from the supersession index, so the index must describe the
288    /// same store revision the search read. A commit landing between the two (this process's
289    /// own `remember` before it settles the index, or a write through the core) would otherwise
290    /// show a fact next to an unindexed successor and report both as current. After a miss the
291    /// index is brought to the store's revision and the search repeats; it takes no writer lock,
292    /// so an observer callback running under one may still recall. After `SNAPSHOT_ATTEMPTS`
293    /// an explicit retryable error is returned.
294    fn run(
295        &self,
296        query: &str,
297        options: &RecallOptions,
298        history: HistoryMode,
299        k: u32,
300        force_wide: bool,
301    ) -> Result<Option<Run>> {
302        let mut attempt = 1;
303        loop {
304            let (run, consistent) = self.run_once(query, options, history, k, force_wide)?;
305            if consistent {
306                return Ok(run);
307            }
308            if attempt == SNAPSHOT_ATTEMPTS {
309                return Err(MemoryError::Concurrent(
310                    "memory changed during recall; retry the operation".into(),
311                )
312                .into());
313            }
314            attempt += 1;
315            self.ensure_fresh();
316        }
317    }
318
319    /// One search and evaluation, and whether the index matched the revision searched.
320    fn run_once(
321        &self,
322        query: &str,
323        options: &RecallOptions,
324        history: HistoryMode,
325        k: u32,
326        force_wide: bool,
327    ) -> Result<(Option<Run>, bool)> {
328        let revision = self.core.revision();
329        let now = options.as_of.unwrap_or_else(|| self.clock.now_millis());
330        use crate::core::query_limits::{QueryBudget, QueryLimits};
331        let mut budget = QueryBudget::new(QueryLimits::default());
332        let (indexed, constrained, restrict) = {
333            let index = self.index.read();
334            (
335                index.revision,
336                index.constrained(),
337                Self::restriction(&index, options, &mut budget)?,
338            )
339        };
340        let indexed = indexed == Some(revision);
341        let mut restrict = match restrict {
342            Some(ids) if ids.is_empty() => return Ok((None, indexed)),
343            other => other,
344        };
345        let needs_status = constrained || options.known_at.is_some();
346        if history == HistoryMode::Historical && !needs_status && !force_wide {
347            return Ok((None, indexed));
348        }
349        if needs_status && (history != HistoryMode::All || options.known_at.is_some()) {
350            let edges = self.index.read().capture_edges(&mut budget)?;
351            let scope = restrict
352                .take()
353                .map(|ids| ids.into_iter().collect::<BTreeSet<_>>());
354            let mut eligible = Vec::new();
355            let scanned = self.core.try_scan_documents(scope.as_ref(), |document| {
356                budget.work(1)?;
357                let stamp = Stamp::from_parts(document.version, document.metadata);
358                let status = evaluate(
359                    stamp.as_ref(),
360                    edges.get(document.id).map_or(&[], Vec::as_slice),
361                    now,
362                    options.known_at,
363                );
364                let keep = status.is_some_and(|status| match history {
365                    HistoryMode::All => true,
366                    HistoryMode::Current => status == FactStatus::Current,
367                    HistoryMode::Historical => status != FactStatus::Current,
368                });
369                if keep {
370                    budget.reserve(document.id.len().saturating_mul(4).saturating_add(256))?;
371                    eligible.push(document.id.to_owned());
372                }
373                Ok(())
374            })?;
375            if scanned != revision {
376                return Ok((None, false));
377            }
378            if eligible.is_empty() {
379                return Ok((None, indexed && self.core.revision() == revision));
380            }
381            restrict = Some(eligible);
382        }
383        let reorder =
384            !self.recency.is_identity() || (needs_status && self.config.historical_weight != 1.0);
385        let wide = reorder || force_wide;
386        let fetch = if wide {
387            k.max(options.candidate_limit.unwrap_or_else(|| 100.max(k)))
388                .min(1000)
389        } else {
390            k
391        };
392        let search = SearchOptions {
393            document_ids: restrict,
394            ..options.search_options(fetch)
395        };
396        let hits = self
397            .core
398            .search_documents(query, &search)
399            .map_err(|error| MemoryError::Invalid(format!("{error}")))?;
400        let index = self.index.read();
401        // Commits bump the revision inside the gate that excludes the search's capture, so an
402        // unchanged revision across the search means the capture saw exactly `revision`.
403        let consistent =
404            indexed && index.revision == Some(revision) && self.core.revision() == revision;
405        let evaluated: Vec<_> = hits
406            .into_iter()
407            .filter_map(|hit| {
408                let stamp = Stamp::from_parts(&hit.version, &hit.metadata);
409                let status = evaluate(
410                    stamp.as_ref(),
411                    index.edges(&hit.document_id),
412                    now,
413                    options.known_at,
414                )?;
415                Some((hit, stamp, status))
416            })
417            .collect();
418        drop(index);
419        let mut seen: HashSet<String> = HashSet::with_capacity(evaluated.len());
420        let mut ranked = Vec::with_capacity(evaluated.len());
421        for (hit, stamp, status) in evaluated {
422            // Without reordering the result is the first `k` kept hits in search order.
423            if !reorder && !force_wide && ranked.len() >= k as usize {
424                break;
425            }
426            if !seen.insert(hit.document_id.clone()) {
427                continue;
428            }
429            let keep = match history {
430                HistoryMode::All => true,
431                HistoryMode::Current => status == FactStatus::Current,
432                HistoryMode::Historical => status != FactStatus::Current,
433            };
434            if !keep {
435                continue;
436            }
437            let recency = if self.recency.is_identity() {
438                1.0
439            } else {
440                self.recency.factor(&RecencyContext {
441                    now,
442                    event_time: stamp.as_ref().and_then(|s| s.event),
443                    valid_from: stamp.as_ref().and_then(|s| s.valid_from),
444                    ingest_time: stamp.as_ref().map(|s| s.ingest),
445                    status,
446                })
447            };
448            let historical = if status == FactStatus::Current {
449                1.0
450            } else {
451                self.config.historical_weight
452            };
453            let score = if reorder {
454                hit.score * recency * historical
455            } else {
456                hit.score
457            };
458            ranked.push(Ranked {
459                hit,
460                stamp,
461                status,
462                recency,
463                historical,
464                score,
465            });
466        }
467        if reorder {
468            ranked.sort_by(|a, b| b.score.total_cmp(&a.score));
469        }
470        if !force_wide {
471            ranked.truncate(k as usize);
472        }
473        let run = Run {
474            ranked,
475            now,
476            lexical_weight: options.lexical_weight.unwrap_or(1.0),
477            semantic_weight: options.semantic_weight.unwrap_or(1.0),
478            fusion: options.fusion.clone().unwrap_or_else(|| "blend".into()),
479        };
480        Ok((
481            Some(run),
482            consistent && self.core.memory_revision_matches(revision),
483        ))
484    }
485
486    /// Ids a subject or entity filter limits recall to; none when unrestricted.
487    fn restriction(
488        index: &Index,
489        options: &RecallOptions,
490        budget: &mut crate::core::query_limits::QueryBudget,
491    ) -> Result<Option<Vec<String>>> {
492        let mut allowed: Option<BTreeSet<&str>> = None;
493        if let Some(subject) = &options.subject {
494            let mut ids = BTreeSet::new();
495            for id in index.subject_id_refs(subject.trim()) {
496                budget.work(1)?;
497                if !ids.contains(id) {
498                    budget.reserve(64)?;
499                    ids.insert(id);
500                }
501            }
502            allowed = Some(ids);
503        }
504        if let Some(entities) = &options.entities {
505            let mut union = BTreeSet::new();
506            for entity in entities {
507                budget.work(1)?;
508                for id in index.entity_ids(entity.trim()) {
509                    budget.work(1)?;
510                    if !union.contains(id.as_str()) {
511                        budget.reserve(64)?;
512                        union.insert(id.as_str());
513                    }
514                }
515            }
516            allowed = Some(match allowed {
517                Some(mut existing) => {
518                    budget.work(existing.len())?;
519                    existing.retain(|id| union.contains(id));
520                    existing
521                }
522                None => union,
523            });
524        }
525        allowed
526            .map(|ids| {
527                let mut owned = Vec::new();
528                for id in ids {
529                    budget.work(1)?;
530                    budget.reserve(id.len().saturating_add(256))?;
531                    owned.push(id.to_owned());
532                }
533                Ok(owned)
534            })
535            .transpose()
536    }
537
538    fn recalled(
539        &self,
540        index: &Index,
541        ranked: Ranked,
542        explanation: Option<Explanation>,
543    ) -> Recalled {
544        let Ranked {
545            hit,
546            stamp,
547            status,
548            score,
549            ..
550        } = ranked;
551        let ingest_time = stamp.as_ref().map(|s| s.ingest);
552        let known = stamp.unwrap_or_default();
553        Recalled {
554            superseded_by: sorted_ids(index.edges(&hit.document_id)),
555            id: hit.document_id,
556            text: hit.text,
557            score,
558            status,
559            ingest_time,
560            event_time: known.event,
561            valid_from: known.valid_from,
562            valid_to: known.valid_to,
563            subject: known.subject,
564            entities: known.entities,
565            supersedes: known.supersedes,
566            metadata: user_metadata(hit.metadata),
567            source_uri: hit.source_uri,
568            explanation,
569        }
570    }
571
572    fn explanation(
573        &self,
574        index: &Index,
575        run: &Run,
576        query: &[(String, Option<String>)],
577        options: &RecallOptions,
578        ranked: &Ranked,
579        rank: Option<usize>,
580    ) -> Explanation {
581        let stamp = ranked.stamp.as_ref();
582        let id = &ranked.hit.document_id;
583        Explanation {
584            retrieved: true,
585            rank,
586            lexical_score: Some(ranked.hit.lexical_score),
587            semantic_score: ranked.hit.semantic_score,
588            retrieval_score: Some(ranked.hit.score),
589            lexical_weight: run.lexical_weight,
590            semantic_weight: run.semantic_weight,
591            fusion: run.fusion.clone(),
592            matched_terms: ranked
593                .hit
594                .text
595                .as_deref()
596                .map(|text| matched_terms(query, text))
597                .unwrap_or_default(),
598            recency_factor: ranked.recency,
599            historical_factor: ranked.historical,
600            final_score: Some(ranked.score),
601            status: ranked.status,
602            as_of: run.now,
603            known_at: options.known_at,
604            valid_from: stamp.and_then(|s| s.valid_from),
605            valid_to: stamp.and_then(|s| s.valid_to),
606            within_validity: stamp.is_none_or(|s| {
607                s.valid_from.is_none_or(|from| from <= run.now)
608                    && s.valid_to.is_none_or(|to| run.now < to)
609            }),
610            chain: self.chain(index, id),
611        }
612    }
613
614    /// The facts `id` supersedes and the facts that supersede it, transitively, in effect
615    /// order.
616    fn chain(&self, index: &Index, id: &str) -> Vec<ChainLink> {
617        let mut links = Vec::new();
618        for (relation, members) in [
619            (ChainRelation::Predecessor, index.ancestors(id)),
620            (ChainRelation::Successor, index.descendants(id)),
621        ] {
622            for member in members {
623                let Some((version, metadata, _)) = self.core.document_snapshot(&member) else {
624                    continue;
625                };
626                let stamp = Stamp::from_parts(&version, &metadata).unwrap_or_default();
627                links.push(ChainLink {
628                    id: member,
629                    relation,
630                    effective_from: stamp.effective_from(),
631                    ingest_time: stamp.ingest,
632                });
633            }
634        }
635        links.sort_by(|a, b| {
636            (a.effective_from, a.ingest_time, &a.id).cmp(&(b.effective_from, b.ingest_time, &b.id))
637        });
638        links
639    }
640}
641
642/// The facts `id` supersedes: its own stamp's list, which the index mirrors.
643fn index_predecessors(index: &Index, id: &str, stamped: &[String]) -> Vec<String> {
644    let mut ids: BTreeSet<String> = stamped.iter().cloned().collect();
645    ids.extend(index.direct_predecessors(id).iter().cloned());
646    ids.into_iter().collect()
647}
648
649#[cfg(test)]
650mod scope_budget_tests {
651    use super::*;
652    use crate::core::query_limits::{QueryBudget, QueryLimitExceeded, QueryLimits};
653
654    #[test]
655    fn scoped_postings_are_bounded_before_capture_and_intersection() {
656        let mut index = Index::default();
657        for id in ["a", "b", "c"] {
658            index.insert(
659                id,
660                &Stamp {
661                    subject: Some("subject".into()),
662                    entities: vec!["entity".into()],
663                    ..Stamp::default()
664                },
665                &|_| None,
666            );
667        }
668        let options = RecallOptions {
669            subject: Some("subject".into()),
670            ..RecallOptions::default()
671        };
672        for work in [5, 6, 7] {
673            let mut budget = QueryBudget::new(QueryLimits {
674                max_work: work,
675                ..QueryLimits::default()
676            });
677            let result = Memory::restriction(&index, &options, &mut budget);
678            assert_eq!(result.is_ok(), work >= 6);
679        }
680        let bytes = 3 * (64 + 256 + 1);
681        for limit in [bytes - 1, bytes, bytes + 1] {
682            let mut budget = QueryBudget::new(QueryLimits {
683                max_bytes: limit,
684                ..QueryLimits::default()
685            });
686            let result = Memory::restriction(&index, &options, &mut budget);
687            assert_eq!(result.is_ok(), limit >= bytes);
688        }
689        let options = RecallOptions {
690            entities: Some(vec!["entity".into()]),
691            ..options
692        };
693        let mut budget = QueryBudget::new(QueryLimits {
694            max_bytes: 63,
695            ..QueryLimits::default()
696        });
697        let error = Memory::restriction(&index, &options, &mut budget).unwrap_err();
698        assert_eq!(
699            error.downcast_ref::<QueryLimitExceeded>().unwrap().resource,
700            "bytes"
701        );
702        let mut budget = QueryBudget::new(QueryLimits::default());
703        assert_eq!(
704            Memory::restriction(&index, &options, &mut budget)
705                .unwrap()
706                .unwrap(),
707            vec!["a", "b", "c"]
708        );
709    }
710}