Skip to main content

writerslogic_warm/core/memory_api/
write.rs

1// Copyright 2024-2026 WritersLogic Contributors
2// SPDX-License-Identifier: Apache-2.0
3
4//! `remember` and `forget`.
5
6use anyhow::Result;
7use serde_json::{Map, Value};
8use std::collections::{BTreeSet, HashMap, HashSet};
9
10use super::stamp::{stable_hash, Placement, Stamp, KEY};
11use super::types::*;
12use super::{FactView, IdConflict, Memory, MemoryError};
13use crate::core::documents::{chunk_document, DocumentInput};
14
15const MAX_BATCH: usize = 4096;
16const WRITE_ATTEMPTS: usize = 16;
17const MAX_TEXT_BYTES: usize = 64 * 1024;
18const CHUNK_WORDS: u32 = 4096;
19const MAX_ID_BYTES: usize = 4096;
20const MAX_LABEL_BYTES: usize = 1024;
21const MAX_LABELS: usize = 256;
22const MAX_LINKS: usize = 256;
23
24/// One input after validation and policy resolution.
25struct Planned {
26    id: String,
27    input: RememberInput,
28    stamp: Stamp,
29    /// The identical fact was already stored.
30    idempotent: bool,
31    /// The stored fact this one overwrites under `IdConflict::Replace`.
32    replaces: bool,
33    /// The overwritten fact's stamp, when the replaced document was a memory fact.
34    previous: Option<Stamp>,
35    document: Option<DocumentInput>,
36}
37
38/// Stores the stamp where it fits and returns the document version to use: the compact
39/// stamp, or none (the engine default) when it went to the `_mem` metadata key.
40fn apply_placement(stamp: &Stamp, metadata: &mut Value) -> Option<String> {
41    if let Some(map) = metadata.as_object_mut() {
42        map.remove(KEY);
43    }
44    match stamp.placement() {
45        Placement::Version(version) => Some(version),
46        Placement::Metadata(value) => {
47            metadata[KEY] = value;
48            None
49        }
50    }
51}
52
53fn valid_id(id: &str) -> bool {
54    !id.is_empty()
55        && id.len() <= MAX_ID_BYTES
56        && !id.chars().any(char::is_control)
57        && !id.starts_with("hms:")
58}
59
60fn label(value: &str, what: &str) -> Result<String> {
61    let value = value.trim();
62    check!(
63        !value.is_empty() && value.len() <= MAX_LABEL_BYTES && !value.contains('\0'),
64        "{what} must contain 1..={MAX_LABEL_BYTES} bytes"
65    );
66    Ok(value.to_owned())
67}
68
69fn push_unique(list: &mut Vec<String>, value: String) {
70    if !list.contains(&value) {
71        list.push(value);
72    }
73}
74
75fn time_part(value: Option<Millis>) -> Vec<u8> {
76    value.map_or_else(|| b"-".to_vec(), |v| v.to_string().into_bytes())
77}
78
79impl Memory {
80    /// Remembers one fact. See [`Memory::remember_many`].
81    pub fn remember(&self, input: RememberInput) -> Result<Remembered> {
82        let mut outcomes = self.remember_many(vec![input])?;
83        Ok(outcomes.remove(0))
84    }
85
86    /// Remembers facts in one atomic transaction: every fact is stored or none is. Later
87    /// inputs see earlier ones for supersession. Retrying a call is safe: a fact whose id and
88    /// content already match is reported with `created: false` and stored once. Without an
89    /// explicit id the id is a hash of the content, so a retried call is still idempotent.
90    pub fn remember_many(&self, inputs: Vec<RememberInput>) -> Result<Vec<Remembered>> {
91        check!(
92            !inputs.is_empty() && inputs.len() <= MAX_BATCH,
93            "remember requires 1..={MAX_BATCH} facts"
94        );
95        let now = self.clock.now_millis();
96        for _ in 0..WRITE_ATTEMPTS {
97            let Some(before) = self.planning_revision() else {
98                continue;
99            };
100            match self.remember_at_revision(inputs.clone(), now, before) {
101                Ok(Some(outcomes)) => return Ok(outcomes),
102                Ok(None) => {}
103                Err(error) => return Err(error),
104            }
105        }
106        Err(MemoryError::Concurrent(
107            "memory changed while planning remember; retry the operation".into(),
108        )
109        .into())
110    }
111
112    pub(super) fn remember_at_revision(
113        &self,
114        inputs: Vec<RememberInput>,
115        now: Millis,
116        before: u64,
117    ) -> Result<Option<Vec<Remembered>>> {
118        check!(
119            !inputs.is_empty() && inputs.len() <= MAX_BATCH,
120            "remember requires 1..={MAX_BATCH} facts"
121        );
122        let planning = (|| -> Result<Option<Vec<Planned>>> {
123            if self.index.read().revision != Some(before) {
124                return Ok(None);
125            }
126            let mut planned: Vec<Planned> = Vec::with_capacity(inputs.len());
127            let mut seen = HashSet::with_capacity(inputs.len());
128            for input in inputs {
129                let fact = self.plan_one(&planned, input, now)?;
130                check!(
131                    seen.insert(fact.id.clone()),
132                    "fact id {:?} appears twice in one call",
133                    fact.id
134                );
135                planned.push(fact);
136            }
137            Ok(Some(planned))
138        })();
139        let mut planned = match planning {
140            Ok(Some(planned)) => planned,
141            Ok(None) => return Ok(None),
142            Err(_) if self.core.revision() != before => return Ok(None),
143            Err(error) => return Err(error),
144        };
145        let documents: Vec<DocumentInput> = planned
146            .iter_mut()
147            .filter_map(|fact| fact.document.take())
148            .collect();
149        let changed = !documents.is_empty();
150        if !self.core.change_memory_documents(documents, &[], before)? {
151            return Ok(None);
152        }
153        let written: Vec<&Planned> = planned.iter().filter(|fact| !fact.idempotent).collect();
154        if !written.is_empty() {
155            self.settle(before, u64::from(changed), |index| {
156                // Other facts' stamps can name a replaced fact; when the index links it to
157                // anything, only a rebuild from the store reproduces those links. An
158                // unlinked one is updated in place: its old stamp out, the new one in.
159                let linked = written.iter().any(|fact| {
160                    fact.replaces
161                        && (!index.edges(&fact.id).is_empty()
162                            || !index.direct_predecessors(&fact.id).is_empty())
163                });
164                if linked {
165                    return false;
166                }
167                for fact in &written {
168                    if let Some(old) = &fact.previous {
169                        index.remove(&fact.id, old);
170                    }
171                }
172                let batch: HashMap<&str, (Millis, Millis)> = written
173                    .iter()
174                    .map(|f| (f.id.as_str(), (f.stamp.effective_from(), f.stamp.ingest)))
175                    .collect();
176                for fact in &written {
177                    let lookup = |id: &str| {
178                        batch.get(id).copied().or_else(|| {
179                            fact.stamp
180                                .subject
181                                .as_deref()
182                                .and_then(|subject| index.member(subject, id))
183                        })
184                    };
185                    // `lookup` borrows the index immutably, so resolve before inserting.
186                    let known: HashMap<String, (Millis, Millis)> = fact
187                        .stamp
188                        .superseded_by
189                        .iter()
190                        .filter_map(|id| lookup(id).map(|times| (id.clone(), times)))
191                        .collect();
192                    index.insert(&fact.id, &fact.stamp, &|id| known.get(id).copied());
193                }
194                true
195            });
196        }
197        let outcomes: Vec<Remembered> = planned
198            .iter()
199            .map(|fact| Remembered {
200                id: fact.id.clone(),
201                created: !fact.idempotent,
202                supersedes: fact.stamp.supersedes.clone(),
203                superseded_by: fact.stamp.superseded_by.clone(),
204                ingest_time: fact.stamp.ingest,
205            })
206            .collect();
207        if let Some(observer) = &self.observer {
208            for (fact, outcome) in planned.iter().zip(&outcomes) {
209                if outcome.created {
210                    observer.on_remembered(outcome, &fact.input);
211                }
212            }
213        }
214        Ok(Some(outcomes))
215    }
216
217    fn plan_one(
218        &self,
219        earlier: &[Planned],
220        mut input: RememberInput,
221        now: Millis,
222    ) -> Result<Planned> {
223        check!(
224            !input.text.trim().is_empty() && input.text.len() <= MAX_TEXT_BYTES,
225            "text must contain 1..={MAX_TEXT_BYTES} bytes"
226        );
227        if let (Some(from), Some(to)) = (input.valid_from, input.valid_to) {
228            check!(from < to, "validFrom must be before validTo");
229        }
230        let user_metadata = match input.metadata.take() {
231            None | Some(Value::Null) => Map::new(),
232            Some(Value::Object(map)) => map,
233            Some(_) => invalid!("metadata must be a JSON object"),
234        };
235        check!(
236            !user_metadata.contains_key(KEY),
237            "metadata key {KEY:?} is reserved"
238        );
239        let subject = input
240            .subject
241            .as_deref()
242            .map(|s| label(s, "subject"))
243            .transpose()?;
244        let mut entities = Vec::new();
245        for entity in input.entities.iter().flatten() {
246            push_unique(&mut entities, label(entity, "entity")?);
247        }
248        if let Some(extractor) = &self.entities {
249            for entity in extractor.entities(&input.text) {
250                if let Ok(entity) = label(&entity, "entity") {
251                    push_unique(&mut entities, entity);
252                }
253            }
254        }
255        check!(
256            entities.len() <= MAX_LABELS,
257            "at most {MAX_LABELS} entities"
258        );
259        let mut explicit = Vec::new();
260        for id in input.supersedes.iter().flatten() {
261            check!(valid_id(id), "supersedes holds an invalid fact id");
262            push_unique(&mut explicit, id.clone());
263        }
264        explicit.sort_unstable();
265        check!(
266            explicit.len() <= MAX_LINKS,
267            "at most {MAX_LINKS} supersedes"
268        );
269        let metadata_bytes = serde_json::to_vec(&user_metadata)?;
270        let embedding_bytes: Vec<u8> = input
271            .embedding
272            .iter()
273            .flatten()
274            .flat_map(|value| value.to_bits().to_le_bytes())
275            .collect();
276        let hash = stable_hash(&[
277            input.text.as_bytes(),
278            &metadata_bytes,
279            &time_part(input.event_time),
280            &time_part(input.valid_from),
281            &time_part(input.valid_to),
282            subject.as_deref().unwrap_or("").as_bytes(),
283            explicit.join("\u{1}").as_bytes(),
284            entities.join("\u{1}").as_bytes(),
285            input.source_uri.as_deref().unwrap_or("").as_bytes(),
286            &embedding_bytes,
287        ]);
288        let id = match input.id.as_deref() {
289            Some(id) => {
290                check!(
291                    valid_id(id),
292                    "id must be 1..={MAX_ID_BYTES} bytes, without control characters or the \"hms:\" prefix"
293                );
294                id.to_owned()
295            }
296            None => format!("mem:{hash}"),
297        };
298        check!(!explicit.contains(&id), "a fact cannot supersede itself");
299
300        let mut replaces = false;
301        let mut previous = None;
302        let mut idempotent_stamp = None;
303        if let Some((version, metadata, _)) = self.core.document_snapshot(&id) {
304            match Stamp::from_parts(&version, &metadata) {
305                Some(stamp) if stamp.hash.as_deref() == Some(hash.as_str()) => {
306                    idempotent_stamp = Some(stamp);
307                }
308                existing => {
309                    if self.config.on_id_conflict != IdConflict::Replace {
310                        return Err(MemoryError::Conflict(format!(
311                            "fact {id:?} already exists with different content; use another id \
312                             or set onIdConflict to \"replace\""
313                        ))
314                        .into());
315                    }
316                    check!(
317                        existing.is_some() || self.index.read().edges(&id).is_empty(),
318                        "document {id:?} is not a memory fact and is superseded by others"
319                    );
320                    replaces = true;
321                    previous = existing;
322                }
323            }
324        }
325        if let Some(stamp) = idempotent_stamp {
326            return Ok(Planned {
327                id,
328                input,
329                stamp,
330                idempotent: true,
331                replaces: false,
332                previous: None,
333                document: None,
334            });
335        }
336
337        for old in &explicit {
338            check!(
339                self.core.has_document(old) || earlier.iter().any(|fact| &fact.id == old),
340                "cannot supersede unknown fact {old:?}"
341            );
342        }
343        let mut supersedes = explicit;
344        let mut superseded_by: Vec<String> = Vec::new();
345        if self.supersession_active {
346            if let Some(subject) = &subject {
347                let mut group: Vec<FactView> = self
348                    .index
349                    .read()
350                    .members(subject)
351                    .into_iter()
352                    .filter(|member| member.id != id)
353                    .map(|member| FactView {
354                        id: member.id,
355                        subject: Some(subject.clone()),
356                        effective_from: member.effective,
357                        ingest_time: member.ingest,
358                    })
359                    .collect();
360                for fact in earlier
361                    .iter()
362                    .filter(|f| f.stamp.subject.as_ref() == Some(subject))
363                {
364                    group.retain(|member| member.id != fact.id);
365                    group.push(FactView {
366                        id: fact.id.clone(),
367                        subject: Some(subject.clone()),
368                        effective_from: fact.stamp.effective_from(),
369                        ingest_time: fact.stamp.ingest,
370                    });
371                }
372                group.sort_by(|a, b| {
373                    (a.effective_from, a.ingest_time, &a.id).cmp(&(
374                        b.effective_from,
375                        b.ingest_time,
376                        &b.id,
377                    ))
378                });
379                let incoming = FactView {
380                    id: id.clone(),
381                    subject: Some(subject.clone()),
382                    effective_from: input.valid_from.or(input.event_time).unwrap_or(now),
383                    ingest_time: now,
384                };
385                let resolution = self.supersession.resolve(&incoming, &group);
386                for linked in resolution
387                    .supersedes
388                    .iter()
389                    .chain(&resolution.superseded_by)
390                {
391                    check!(
392                        group.iter().any(|member| &member.id == linked),
393                        "supersession policy returned {linked:?}, which is not a stored fact of subject {subject:?}"
394                    );
395                }
396                for old in resolution.supersedes {
397                    push_unique(&mut supersedes, old);
398                }
399                for newer in resolution.superseded_by {
400                    push_unique(&mut superseded_by, newer);
401                }
402                supersedes.sort_unstable();
403                superseded_by.sort_unstable();
404            }
405        }
406        for old in &supersedes {
407            check!(
408                !self.index.read().supersedes_transitively(old, &id),
409                "superseding {old:?} would make a cycle"
410            );
411        }
412        for newer in &superseded_by {
413            check!(
414                !self.index.read().supersedes_transitively(&id, newer),
415                "being superseded by {newer:?} would make a cycle"
416            );
417        }
418
419        let stamp = Stamp {
420            ingest: now,
421            event: input.event_time,
422            valid_from: input.valid_from,
423            valid_to: input.valid_to,
424            subject,
425            supersedes,
426            superseded_by,
427            entities,
428            hash: Some(hash),
429        };
430        let mut metadata = Value::Object(user_metadata);
431        let version = apply_placement(&stamp, &mut metadata);
432        let document = DocumentInput {
433            id: id.clone(),
434            text: input.text.clone(),
435            source_uri: input.source_uri.clone(),
436            version,
437            metadata: Some(metadata),
438            chunk_words: Some(CHUNK_WORDS),
439            overlap_words: Some(0),
440            store_text: Some(true),
441            embeddings: input.embedding.clone().map(|embedding| vec![embedding]),
442            title_weight: None,
443        };
444        let chunks = chunk_document(&document)
445            .map_err(|e| MemoryError::Invalid(format!("invalid fact: {e}")))?;
446        check!(
447            chunks.len() == 1,
448            "text must fit one chunk of at most {CHUNK_WORDS} words; remember it as several facts"
449        );
450        Ok(Planned {
451            id,
452            input,
453            stamp,
454            idempotent: false,
455            replaces,
456            previous,
457            document: Some(document),
458        })
459    }
460
461    /// Removes facts. Idempotent: repeating a call removes nothing more. Removal is one
462    /// journal transaction per 4096 facts, so a crash between transactions leaves a prefix
463    /// removed and the same call finishes the rest.
464    ///
465    /// Without `include_chain` only the matched facts go, and a removed successor makes its
466    /// predecessor current again. Deleted text stays in the append-only journal until the
467    /// store compacts. The text segment is zeroed and synced once before this returns; `purge`
468    /// then compacts and overwrites what the compaction replaced.
469    pub fn forget(&self, scope: &ForgetScope, options: &ForgetOptions) -> Result<ForgetReport> {
470        let mut completed = BTreeSet::new();
471        for _ in 0..WRITE_ATTEMPTS {
472            let Some(before) = self.planning_revision() else {
473                continue;
474            };
475            match self.forget_at_revision(scope, options, before, &mut completed) {
476                Ok(Some(report)) => return Ok(report),
477                Ok(None) => {}
478                Err(error) => return Err(error),
479            }
480        }
481        Err(MemoryError::Concurrent(
482            "memory changed during forgetting; retry to finish any committed prefix".into(),
483        )
484        .into())
485    }
486
487    pub(super) fn forget_at_revision(
488        &self,
489        scope: &ForgetScope,
490        options: &ForgetOptions,
491        before: u64,
492        completed: &mut BTreeSet<String>,
493    ) -> Result<Option<ForgetReport>> {
494        let mut ids = self.resolve_scope(scope)?;
495        if options.include_chain {
496            let index = self.index.read();
497            let mut chained = BTreeSet::new();
498            for id in &ids {
499                chained.extend(index.component(id));
500            }
501            ids = chained;
502        }
503        let mut stamps: Vec<(String, Stamp)> = Vec::new();
504        for id in &ids {
505            if let Some((version, metadata, _)) = self.core.document_snapshot(id) {
506                stamps.push((
507                    id.clone(),
508                    Stamp::from_parts(&version, &metadata).unwrap_or_default(),
509                ));
510            }
511        }
512        let forgotten: Vec<String> = stamps.iter().map(|(id, _)| id.clone()).collect();
513        if options.dry_run || forgotten.is_empty() {
514            if !self.core.memory_revision_matches(before) {
515                return Ok(None);
516            }
517            let forgotten = if options.dry_run {
518                forgotten
519            } else {
520                completed.iter().cloned().collect()
521            };
522            let purged = if !options.dry_run && !completed.is_empty() {
523                crate::core::error::after_commit(
524                    "forgotten-text synchronization",
525                    self.core.sync_text_segment(),
526                )?;
527                if options.purge {
528                    crate::core::error::after_commit(
529                        "forgotten-data purge",
530                        self.core.compact_scrubbing(),
531                    )?;
532                }
533                if let Some(observer) = &self.observer {
534                    observer.on_forgotten(&forgotten);
535                }
536                options.purge
537            } else {
538                false
539            };
540            return Ok(Some(ForgetReport {
541                forgotten,
542                dry_run: options.dry_run,
543                purged,
544            }));
545        }
546        let rewrites = self.bridge_survivors(&forgotten)?;
547        let mut revision = before;
548        let mut commits = 0;
549        // Survivors are re-linked before anything is deleted: a crash between the two leaves
550        // extra but valid links, and the same call finishes the job.
551        for batch in rewrites.chunks(MAX_BATCH) {
552            if !self
553                .core
554                .change_memory_documents(batch.to_vec(), &[], revision)?
555            {
556                return Ok(None);
557            }
558            revision = revision.wrapping_add(1);
559            commits += 1;
560        }
561        for batch in forgotten.chunks(MAX_BATCH) {
562            if !self
563                .core
564                .change_memory_documents(Vec::new(), batch, revision)?
565            {
566                return Ok(None);
567            }
568            completed.extend(batch.iter().cloned());
569            revision = revision.wrapping_add(1);
570            commits += 1;
571        }
572        crate::core::error::after_commit(
573            "forgotten-text synchronization",
574            self.core.sync_text_segment(),
575        )?;
576        if rewrites.is_empty() {
577            self.settle(before, commits, |index| {
578                for (id, stamp) in &stamps {
579                    index.remove(id, stamp);
580                }
581                true
582            });
583        } else {
584            self.index.write().revision = None;
585        }
586        let mut purged = false;
587        if options.purge {
588            crate::core::error::after_commit(
589                "forgotten-data purge",
590                self.core.compact_scrubbing(),
591            )?;
592            self.index.write().revision = None;
593            purged = true;
594        }
595        let forgotten: Vec<String> = completed.iter().cloned().collect();
596        if let Some(observer) = &self.observer {
597            observer.on_forgotten(&forgotten);
598        }
599        Ok(Some(ForgetReport {
600            forgotten,
601            dry_run: false,
602            purged,
603        }))
604    }
605
606    /// Rewrites of the surviving facts whose links run through facts about to be removed, so
607    /// that removing a fact from the middle of a chain does not make an older version current
608    /// again: each survivor that superseded a removed fact now supersedes what that fact did.
609    fn bridge_survivors(&self, forgotten: &[String]) -> Result<Vec<DocumentInput>> {
610        let dead: BTreeSet<String> = forgotten.iter().cloned().collect();
611        let index = self.index.read();
612        let mut affected: BTreeSet<String> = BTreeSet::new();
613        for id in forgotten {
614            affected.extend(
615                index
616                    .edges(id)
617                    .iter()
618                    .map(|edge| edge.by.clone())
619                    .filter(|by| !dead.contains(by)),
620            );
621        }
622        let mut rewrites = Vec::new();
623        for survivor in affected {
624            let Some(parts) = self.core.document_parts(&survivor) else {
625                continue;
626            };
627            let Some(mut stamp) = Stamp::from_parts(&parts.version, &parts.metadata) else {
628                continue;
629            };
630            let mut supersedes: BTreeSet<String> = stamp
631                .supersedes
632                .iter()
633                .filter(|id| !dead.contains(*id))
634                .cloned()
635                .collect();
636            supersedes.extend(index.bridge(&survivor, &dead));
637            let supersedes: Vec<String> = supersedes.into_iter().collect();
638            let hints: Vec<String> = stamp
639                .superseded_by
640                .iter()
641                .filter(|id| !dead.contains(*id))
642                .cloned()
643                .collect();
644            let mut current = stamp.supersedes.clone();
645            current.sort_unstable();
646            if supersedes == current && hints == stamp.superseded_by {
647                continue;
648            }
649            stamp.supersedes = supersedes;
650            stamp.superseded_by = hints;
651            let mut metadata = parts.metadata;
652            let version = apply_placement(&stamp, &mut metadata);
653            rewrites.push(DocumentInput {
654                id: survivor,
655                text: parts.text.unwrap_or_default(),
656                source_uri: parts.source_uri,
657                version,
658                metadata: Some(metadata),
659                chunk_words: Some(CHUNK_WORDS),
660                overlap_words: Some(0),
661                store_text: Some(true),
662                embeddings: parts.embedding.map(|embedding| vec![embedding]),
663                title_weight: None,
664            });
665        }
666        Ok(rewrites)
667    }
668
669    fn resolve_scope(&self, scope: &ForgetScope) -> Result<BTreeSet<String>> {
670        match scope {
671            ForgetScope::Ids { ids } => {
672                check!(
673                    ids.iter().all(|id| valid_id(id)),
674                    "ids must be valid fact ids"
675                );
676                Ok(ids.iter().cloned().collect())
677            }
678            ForgetScope::Subject { subject } => Ok(self
679                .index
680                .read()
681                .subject_ids(subject.trim())
682                .into_iter()
683                .collect()),
684            ForgetScope::Entity { entity } => Ok(self
685                .index
686                .read()
687                .entity_ids(entity.trim())
688                .iter()
689                .cloned()
690                .collect()),
691            ForgetScope::IngestedBefore { time } => {
692                Ok(self.scan_ids(|_, stamp| stamp.is_some_and(|stamp| stamp.ingest < *time)))
693            }
694            ForgetScope::EventBefore { time } => Ok(self.scan_ids(|_, stamp| {
695                stamp.is_some_and(|stamp| {
696                    stamp.event.or(stamp.valid_from).unwrap_or(stamp.ingest) < *time
697                })
698            })),
699            ForgetScope::Matching { filter } => {
700                let Some(filter) = filter.as_object().filter(|f| !f.is_empty()) else {
701                    invalid!("filter must be a non-empty JSON object");
702                };
703                check!(
704                    !filter.contains_key(KEY),
705                    "metadata key {KEY:?} is reserved"
706                );
707                let mut ids = BTreeSet::new();
708                self.core.scan_documents(|document| {
709                    if Stamp::from_parts(document.version, document.metadata).is_some()
710                        && filter
711                            .iter()
712                            .all(|(key, value)| document.metadata.get(key) == Some(value))
713                    {
714                        ids.insert(document.id.to_owned());
715                    }
716                });
717                Ok(ids)
718            }
719        }
720    }
721
722    fn scan_ids(&self, keep: impl Fn(&str, Option<&Stamp>) -> bool) -> BTreeSet<String> {
723        let mut ids = BTreeSet::new();
724        self.core.scan_documents(|document| {
725            let stamp = Stamp::from_parts(document.version, document.metadata);
726            if stamp.is_some() && keep(document.id, stamp.as_ref()) {
727                ids.insert(document.id.to_owned());
728            }
729        });
730        ids
731    }
732}