Skip to main content

rac_engine/
delta_generation.rs

1//! Preview-only P6 base-plus-delta document generation.
2//!
3//! The immutable base is shared by `Arc`; staging a change clones only the
4//! current overlay.  A live document is selected as:
5//!
6//! `(base - tombstones) + upserts`
7//!
8//! P6.2 adds an independently staged identity/status projection and exact
9//! resolution over the overlay. P6.3 adds token rows, postings, filters, and
10//! exact global search statistics. P6.4 adds relationship rows, reverse target
11//! buckets, resolved edges, and inbound counts. P6.5 adds scope/live-decision
12//! rows and validated portfolio projections. P6.6 materializes durable store
13//! segments from those projections; fresh derivation remains test-only.
14
15use std::collections::{BTreeMap, BTreeSet, HashMap};
16use std::sync::Arc;
17
18use serde_json::Value;
19
20use crate::derived::DerivedIndex;
21use crate::portfolio::{portfolio_from_rows, portfolio_row, PortfolioRow};
22use crate::pycompat::{py_casefold, py_strip};
23use crate::relationships::{
24    edge_spec, rows_from_corpus_items, CorpusItem, Relationship, ValidationRow,
25    ISSUE_SELF_REFERENCE, ISSUE_TARGET_AMBIGUOUS, ISSUE_TARGET_NOT_FOUND,
26};
27use crate::resolve::{
28    artifact_status, entry_from_item, entry_has_tags, field_tokens_of, identity_entry_from_item,
29    is_retired_status, match_entry_with_fields, rank_and_build, resolved_from_entry, tokenize,
30    CorpusStats, FieldTokens, IndexEntry, ResolutionResult, SearchResult, OUTCOME_DUPLICATE,
31    OUTCOME_NOT_FOUND, OUTCOME_RESOLVED,
32};
33use crate::retrieve::{scope_rows_from_items, ScopeRow};
34
35/// The identity/status projection for one parsed artifact. Rows are shared by
36/// `Arc` so compaction promotes unchanged identities without rebuilding them.
37#[derive(Clone)]
38pub struct IdentityRow {
39    pub entry: IndexEntry,
40    pub status: String,
41}
42
43impl IdentityRow {
44    fn from_item(item: &CorpusItem) -> Self {
45        Self {
46            entry: identity_entry_from_item(item),
47            status: artifact_status(&item.artifact),
48        }
49    }
50}
51
52/// Immutable base identity rows plus a cumulative changeset-bound overlay.
53///
54/// The compacted alias map stays shared. Point resolution consults that map,
55/// masks base rows changed in the overlay, and scans only the (bounded) delta
56/// aliases. Staging therefore never clones or walks the full base.
57#[derive(Clone, Default)]
58pub struct IdentityGeneration {
59    base: Arc<BTreeMap<String, Arc<IdentityRow>>>,
60    base_aliases: Arc<BTreeMap<String, Vec<String>>>,
61    upserts: BTreeMap<String, Arc<IdentityRow>>,
62    tombstones: BTreeSet<String>,
63}
64
65impl IdentityGeneration {
66    pub fn empty() -> Self {
67        Self::default()
68    }
69
70    pub fn stage(&self, changed: &BTreeSet<String>, parsed: &BTreeMap<String, CorpusItem>) -> Self {
71        let mut next = self.clone();
72        for path in changed {
73            if let Some(item) = parsed.get(path) {
74                next.upserts
75                    .insert(path.clone(), Arc::new(IdentityRow::from_item(item)));
76                next.tombstones.remove(path);
77            } else {
78                next.upserts.remove(path);
79                if next.base.contains_key(path) {
80                    next.tombstones.insert(path.clone());
81                } else {
82                    next.tombstones.remove(path);
83                }
84            }
85        }
86        next
87    }
88
89    pub fn from_items<'a>(items: impl IntoIterator<Item = (&'a str, &'a CorpusItem)>) -> Self {
90        let base: BTreeMap<String, Arc<IdentityRow>> = items
91            .into_iter()
92            .map(|(path, item)| (path.to_string(), Arc::new(IdentityRow::from_item(item))))
93            .collect();
94        Self {
95            base_aliases: Arc::new(alias_map(&base)),
96            base: Arc::new(base),
97            upserts: BTreeMap::new(),
98            tombstones: BTreeSet::new(),
99        }
100    }
101
102    pub fn promote(&mut self) {
103        let mut base = self.base.as_ref().clone();
104        for path in &self.tombstones {
105            base.remove(path);
106        }
107        for (path, row) in &self.upserts {
108            base.insert(path.clone(), Arc::clone(row));
109        }
110        self.base_aliases = Arc::new(alias_map(&base));
111        self.base = Arc::new(base);
112        self.upserts.clear();
113        self.tombstones.clear();
114    }
115
116    pub fn resolve(&self, artifact_id: &str) -> ResolutionResult {
117        let wanted = py_casefold(py_strip(artifact_id));
118        let mut matches: Vec<&IndexEntry> = self
119            .base_aliases
120            .get(&wanted)
121            .into_iter()
122            .flatten()
123            .filter(|path| !self.tombstones.contains(*path) && !self.upserts.contains_key(*path))
124            .filter_map(|path| self.base.get(path).map(|row| &row.entry))
125            .collect();
126        matches.extend(
127            self.upserts
128                .values()
129                .filter(|row| {
130                    row.entry
131                        .aliases
132                        .iter()
133                        .any(|alias| py_casefold(alias) == wanted)
134                })
135                .map(|row| &row.entry),
136        );
137        if matches.is_empty() {
138            return ResolutionResult {
139                artifact_id: artifact_id.to_string(),
140                outcome: OUTCOME_NOT_FOUND,
141                artifact: None,
142                duplicate_paths: Vec::new(),
143            };
144        }
145        if matches.len() > 1 {
146            let mut duplicate_paths: Vec<String> =
147                matches.iter().map(|entry| entry.path.clone()).collect();
148            duplicate_paths.sort();
149            return ResolutionResult {
150                artifact_id: artifact_id.to_string(),
151                outcome: OUTCOME_DUPLICATE,
152                artifact: None,
153                duplicate_paths,
154            };
155        }
156        ResolutionResult {
157            artifact_id: artifact_id.to_string(),
158            outcome: OUTCOME_RESOLVED,
159            artifact: Some(resolved_from_entry(matches[0])),
160            duplicate_paths: Vec::new(),
161        }
162    }
163
164    pub fn status_for_path(&self, path: &str) -> Option<&str> {
165        if let Some(row) = self.upserts.get(path) {
166            return Some(&row.status);
167        }
168        if self.tombstones.contains(path) {
169            return None;
170        }
171        self.base.get(path).map(|row| row.status.as_str())
172    }
173
174    pub fn entries(&self) -> Vec<IndexEntry> {
175        self.base
176            .keys()
177            .chain(self.upserts.keys())
178            .collect::<BTreeSet<_>>()
179            .into_iter()
180            .filter_map(|path| {
181                self.upserts.get(path).or_else(|| {
182                    (!self.tombstones.contains(path))
183                        .then(|| self.base.get(path))
184                        .flatten()
185                })
186            })
187            .map(|row| row.entry.clone())
188            .collect()
189    }
190
191    pub fn base_len(&self) -> usize {
192        self.base.len()
193    }
194
195    pub fn upsert_len(&self) -> usize {
196        self.upserts.len()
197    }
198
199    pub fn tombstone_len(&self) -> usize {
200        self.tombstones.len()
201    }
202}
203
204/// Immutable relationship rows plus a cumulative source overlay. Raw target
205/// buckets let an identity/alias edit re-resolve only sources that mention an
206/// affected identifier, including otherwise unchanged documents.
207#[derive(Clone, Default)]
208pub struct GraphGeneration {
209    base_rows: Arc<BTreeMap<String, Arc<ValidationRow>>>,
210    base_relationships: Arc<BTreeMap<String, Arc<Vec<Relationship>>>>,
211    base_referrers: Arc<BTreeMap<String, Vec<String>>>,
212    base_inbound: Arc<BTreeMap<String, i64>>,
213    row_upserts: BTreeMap<String, Arc<ValidationRow>>,
214    relationship_upserts: BTreeMap<String, Arc<Vec<Relationship>>>,
215    tombstones: BTreeSet<String>,
216    inbound_delta: BTreeMap<String, i64>,
217}
218
219impl GraphGeneration {
220    pub fn empty() -> Self {
221        Self::default()
222    }
223
224    pub fn from_items<'a>(
225        items: impl IntoIterator<Item = (&'a str, &'a CorpusItem)>,
226        identity: &IdentityGeneration,
227    ) -> Self {
228        let owned: Vec<(&str, &CorpusItem)> = items.into_iter().collect();
229        let corpus: Vec<CorpusItem> = owned.iter().map(|(_, item)| (*item).clone()).collect();
230        let rows = rows_from_corpus_items(&corpus);
231        let base_rows: BTreeMap<String, Arc<ValidationRow>> = rows
232            .into_iter()
233            .zip(owned.iter())
234            .map(|(row, (key, _))| ((*key).to_string(), Arc::new(row)))
235            .collect();
236        let base_relationships: BTreeMap<String, Arc<Vec<Relationship>>> = base_rows
237            .iter()
238            .map(|(path, row)| (path.clone(), Arc::new(resolve_graph_row(row, identity))))
239            .collect();
240        let base_inbound =
241            inbound_for_relationships(base_relationships.values().flat_map(|v| v.iter()));
242        Self {
243            base_referrers: Arc::new(referrer_map(&base_rows)),
244            base_rows: Arc::new(base_rows),
245            base_relationships: Arc::new(base_relationships),
246            base_inbound: Arc::new(base_inbound),
247            row_upserts: BTreeMap::new(),
248            relationship_upserts: BTreeMap::new(),
249            tombstones: BTreeSet::new(),
250            inbound_delta: BTreeMap::new(),
251        }
252    }
253
254    pub fn stage(
255        &self,
256        changed: &BTreeSet<String>,
257        parsed: &BTreeMap<String, CorpusItem>,
258        identity: &IdentityGeneration,
259    ) -> Self {
260        let mut next = self.clone();
261        let mut affected_aliases = BTreeSet::new();
262        for path in changed {
263            if let Some(old) = self.row(path) {
264                affected_aliases.extend(old.identifiers.iter().map(|id| py_casefold(id)));
265            }
266            if let Some(item) = parsed.get(path) {
267                let row = rows_from_corpus_items(std::slice::from_ref(item))
268                    .into_iter()
269                    .next()
270                    .expect("one graph row per parsed item");
271                affected_aliases.extend(row.identifiers.iter().map(|id| py_casefold(id)));
272                next.row_upserts.insert(path.clone(), Arc::new(row));
273                next.tombstones.remove(path);
274            } else {
275                next.row_upserts.remove(path);
276                next.relationship_upserts.remove(path);
277                if next.base_rows.contains_key(path) {
278                    next.tombstones.insert(path.clone());
279                } else {
280                    next.tombstones.remove(path);
281                }
282            }
283        }
284
285        let mut affected_sources = changed.clone();
286        for alias in affected_aliases {
287            if let Some(paths) = self.base_referrers.get(&alias) {
288                affected_sources.extend(paths.iter().cloned());
289            }
290            for (path, row) in &next.row_upserts {
291                if row_references(row, &alias) {
292                    affected_sources.insert(path.clone());
293                }
294            }
295        }
296        for path in affected_sources {
297            if let Some(edges) = self.relationships_for_source(&path) {
298                adjust_inbound(&mut next.inbound_delta, edges, -1);
299            }
300            if let Some(row) = next.row(&path) {
301                let edges = Arc::new(resolve_graph_row(row, identity));
302                adjust_inbound(&mut next.inbound_delta, &edges, 1);
303                next.relationship_upserts.insert(path, edges);
304            } else {
305                next.relationship_upserts.remove(&path);
306            }
307        }
308        next
309    }
310
311    pub fn promote(&mut self) {
312        let mut rows = self.base_rows.as_ref().clone();
313        let mut relationships = self.base_relationships.as_ref().clone();
314        for path in &self.tombstones {
315            rows.remove(path);
316            relationships.remove(path);
317        }
318        for (path, row) in &self.row_upserts {
319            rows.insert(path.clone(), Arc::clone(row));
320        }
321        for (path, edges) in &self.relationship_upserts {
322            relationships.insert(path.clone(), Arc::clone(edges));
323        }
324        self.base_referrers = Arc::new(referrer_map(&rows));
325        self.base_inbound = Arc::new(inbound_for_relationships(
326            relationships.values().flat_map(|edges| edges.iter()),
327        ));
328        self.base_rows = Arc::new(rows);
329        self.base_relationships = Arc::new(relationships);
330        self.row_upserts.clear();
331        self.relationship_upserts.clear();
332        self.tombstones.clear();
333        self.inbound_delta.clear();
334    }
335
336    pub fn relationships(&self) -> Vec<Relationship> {
337        self.base_relationships
338            .keys()
339            .chain(self.relationship_upserts.keys())
340            .collect::<BTreeSet<_>>()
341            .into_iter()
342            .filter_map(|path| self.relationships_for_source(path))
343            .flat_map(|edges| edges.iter().cloned())
344            .collect()
345    }
346
347    pub fn inbound_count(&self, path: &str) -> i64 {
348        self.inbound_counts().get(path).copied().unwrap_or(0)
349    }
350
351    pub fn inbound_counts(&self) -> HashMap<String, i64> {
352        let mut counts: HashMap<String, i64> = self
353            .base_inbound
354            .iter()
355            .map(|(path, count)| (path.clone(), *count))
356            .collect();
357        for (path, delta) in &self.inbound_delta {
358            let count = counts.entry(path.clone()).or_insert(0);
359            *count += delta;
360            if *count == 0 {
361                counts.remove(path);
362            }
363        }
364        counts
365    }
366
367    fn row(&self, path: &str) -> Option<&ValidationRow> {
368        self.row_upserts.get(path).map(AsRef::as_ref).or_else(|| {
369            (!self.tombstones.contains(path))
370                .then(|| self.base_rows.get(path).map(AsRef::as_ref))
371                .flatten()
372        })
373    }
374
375    fn relationships_for_source(&self, path: &str) -> Option<&[Relationship]> {
376        self.relationship_upserts
377            .get(path)
378            .map(|edges| edges.as_slice())
379            .or_else(|| {
380                (!self.tombstones.contains(path))
381                    .then(|| {
382                        self.base_relationships
383                            .get(path)
384                            .map(|edges| edges.as_slice())
385                    })
386                    .flatten()
387            })
388    }
389
390    pub fn base_len(&self) -> usize {
391        self.base_rows.len()
392    }
393    pub fn upsert_len(&self) -> usize {
394        self.row_upserts.len()
395    }
396    pub fn tombstone_len(&self) -> usize {
397        self.tombstones.len()
398    }
399}
400
401fn adjust_inbound(deltas: &mut BTreeMap<String, i64>, edges: &[Relationship], direction: i64) {
402    for edge in edges {
403        if let Some(path) = &edge.resolved_path {
404            *deltas.entry(path.clone()).or_insert(0) += direction;
405        }
406    }
407    deltas.retain(|_, delta| *delta != 0);
408}
409
410fn inbound_for_relationships<'a>(
411    edges: impl IntoIterator<Item = &'a Relationship>,
412) -> BTreeMap<String, i64> {
413    let mut inbound = BTreeMap::new();
414    for edge in edges {
415        if let Some(path) = &edge.resolved_path {
416            *inbound.entry(path.clone()).or_insert(0) += 1;
417        }
418    }
419    inbound
420}
421
422fn row_references(row: &ValidationRow, wanted: &str) -> bool {
423    row.edges
424        .iter()
425        .any(|(_, refs)| refs.iter().any(|target| py_casefold(target) == wanted))
426}
427
428fn referrer_map(rows: &BTreeMap<String, Arc<ValidationRow>>) -> BTreeMap<String, Vec<String>> {
429    let mut refs: BTreeMap<String, Vec<String>> = BTreeMap::new();
430    for (path, row) in rows {
431        for (_, targets) in &row.edges {
432            for target in targets {
433                refs.entry(py_casefold(target))
434                    .or_default()
435                    .push(path.clone());
436            }
437        }
438    }
439    refs
440}
441
442fn resolve_graph_row(row: &ValidationRow, identity: &IdentityGeneration) -> Vec<Relationship> {
443    let mut edges = Vec::new();
444    for (section, targets) in &row.edges {
445        let external = edge_spec(section).is_some_and(|spec| spec.external);
446        for target in targets {
447            let (resolved_path, issue) = if external {
448                (None, None)
449            } else {
450                let result = identity.resolve(target);
451                match result.outcome {
452                    OUTCOME_NOT_FOUND => (None, Some(ISSUE_TARGET_NOT_FOUND.to_string())),
453                    OUTCOME_DUPLICATE => (None, Some(ISSUE_TARGET_AMBIGUOUS.to_string())),
454                    OUTCOME_RESOLVED => {
455                        let path = result
456                            .artifact
457                            .expect("resolved identity has artifact")
458                            .path;
459                        if path == row.path {
460                            (None, Some(ISSUE_SELF_REFERENCE.to_string()))
461                        } else {
462                            (Some(path), None)
463                        }
464                    }
465                    _ => unreachable!("identity resolution outcome"),
466                }
467            };
468            edges.push(Relationship {
469                source_path: row.path.clone(),
470                relationship: section.clone(),
471                target: target.clone(),
472                resolved_path,
473                issue,
474            });
475        }
476    }
477    edges
478}
479
480fn alias_map(rows: &BTreeMap<String, Arc<IdentityRow>>) -> BTreeMap<String, Vec<String>> {
481    let mut aliases: BTreeMap<String, Vec<String>> = BTreeMap::new();
482    for (path, row) in rows {
483        for alias in &row.entry.aliases {
484            aliases
485                .entry(py_casefold(alias))
486                .or_default()
487                .push(path.clone());
488        }
489    }
490    aliases
491}
492
493/// Searchable row owned by the P6.3 generation. P6.4 supplies graph-derived
494/// inbound counts at query time so the token row remains graph-independent.
495#[derive(Clone)]
496pub struct SearchRow {
497    pub entry: IndexEntry,
498    pub fields: FieldTokens,
499    pub status: String,
500}
501
502impl SearchRow {
503    fn from_item(item: &CorpusItem) -> Self {
504        let entry = entry_from_item(item, 0);
505        Self {
506            fields: field_tokens_of(&entry),
507            status: artifact_status(&item.artifact),
508            entry,
509        }
510    }
511}
512
513/// Immutable compacted token/posting base plus a cumulative search overlay.
514#[derive(Clone, Default)]
515pub struct SearchGeneration {
516    base: Arc<BTreeMap<String, Arc<SearchRow>>>,
517    base_postings: Arc<BTreeMap<String, Vec<String>>>,
518    base_length_sums: [i64; 6],
519    upserts: BTreeMap<String, Arc<SearchRow>>,
520    tombstones: BTreeSet<String>,
521}
522
523impl SearchGeneration {
524    pub fn empty() -> Self {
525        Self::default()
526    }
527
528    pub fn from_items<'a>(items: impl IntoIterator<Item = (&'a str, &'a CorpusItem)>) -> Self {
529        let base: BTreeMap<String, Arc<SearchRow>> = items
530            .into_iter()
531            .map(|(path, item)| (path.to_string(), Arc::new(SearchRow::from_item(item))))
532            .collect();
533        Self {
534            base_postings: Arc::new(postings_for(&base)),
535            base_length_sums: length_sums(base.values().map(AsRef::as_ref)),
536            base: Arc::new(base),
537            upserts: BTreeMap::new(),
538            tombstones: BTreeSet::new(),
539        }
540    }
541
542    pub fn stage(
543        &self,
544        changed: &BTreeSet<String>,
545        parsed: &BTreeMap<String, CorpusItem>,
546    ) -> Self {
547        let mut next = self.clone();
548        for path in changed {
549            if let Some(item) = parsed.get(path) {
550                next
551                    .upserts
552                    .insert(path.clone(), Arc::new(SearchRow::from_item(item)));
553                next.tombstones.remove(path);
554            } else {
555                next.upserts.remove(path);
556                if next.base.contains_key(path) {
557                    next.tombstones.insert(path.clone());
558                } else {
559                    next.tombstones.remove(path);
560                }
561            }
562        }
563        next
564    }
565
566    pub fn promote(&mut self) {
567        let mut base = self.base.as_ref().clone();
568        for path in &self.tombstones {
569            base.remove(path);
570        }
571        for (path, row) in &self.upserts {
572            base.insert(path.clone(), Arc::clone(row));
573        }
574        self.base_postings = Arc::new(postings_for(&base));
575        self.base_length_sums = length_sums(base.values().map(AsRef::as_ref));
576        self.base = Arc::new(base);
577        self.upserts.clear();
578        self.tombstones.clear();
579    }
580
581    pub fn search(
582        &self,
583        query: &str,
584        artifact_type: Option<&str>,
585        tags: &[String],
586        live_only: bool,
587        graph: &GraphGeneration,
588    ) -> SearchResult {
589        let terms = tokenize(query);
590        if terms.is_empty() {
591            return empty_search(query, artifact_type);
592        }
593        let distinct: BTreeSet<&str> = terms.iter().map(String::as_str).collect();
594        let mut candidates: Option<BTreeSet<String>> = None;
595        for term in distinct {
596            let paths = self.paths_for_term(term);
597            candidates = Some(match candidates {
598                Some(current) => current.intersection(&paths).cloned().collect(),
599                None => paths,
600            });
601            if candidates.as_ref().is_some_and(BTreeSet::is_empty) {
602                return empty_search(query, artifact_type);
603            }
604        }
605
606        let tag_filter: Vec<String> = tags.iter().map(|tag| py_casefold(tag)).collect();
607        let inbound_by_path = graph.inbound_counts();
608        let mut prepared = Vec::new();
609        for path in candidates.unwrap_or_default() {
610            let Some(row) = self.row(&path) else {
611                continue;
612            };
613            if artifact_type.is_some_and(|wanted| row.entry.artifact_type != wanted) {
614                continue;
615            }
616            if !tag_filter.is_empty() && !entry_has_tags(&row.entry, &tag_filter) {
617                continue;
618            }
619            if live_only && is_retired_status(&row.entry.artifact_type, &row.status) {
620                continue;
621            }
622            let Some(tier) = match_entry_with_fields(&row.entry, &row.fields, &terms) else {
623                continue;
624            };
625            let mut entry = row.entry.clone();
626            entry.inbound_count = inbound_by_path
627                .get(row.entry.path.as_str())
628                .copied()
629                .unwrap_or(0);
630            prepared.push((entry, &row.fields, tier));
631        }
632        if prepared.is_empty() {
633            return empty_search(query, artifact_type);
634        }
635        let stats = self.stats(&terms);
636        let matched = prepared
637            .iter()
638            .map(|(entry, fields, tier)| (entry, *fields, tier.clone()))
639            .collect();
640        rank_and_build(query, artifact_type, matched, &terms, &stats)
641    }
642
643    pub fn entries(&self, graph: &GraphGeneration) -> Vec<IndexEntry> {
644        let inbound = graph.inbound_counts();
645        self.base
646            .keys()
647            .chain(self.upserts.keys())
648            .collect::<BTreeSet<_>>()
649            .into_iter()
650            .filter_map(|path| self.row(path))
651            .map(|row| {
652                let mut entry = row.entry.clone();
653                entry.inbound_count = inbound.get(&entry.path).copied().unwrap_or(0);
654                entry
655            })
656            .collect()
657    }
658
659    fn entries_and_fields(&self, graph: &GraphGeneration) -> (Vec<IndexEntry>, Vec<FieldTokens>) {
660        let inbound = graph.inbound_counts();
661        let mut entries = Vec::new();
662        let mut fields = Vec::new();
663        for path in self
664            .base
665            .keys()
666            .chain(self.upserts.keys())
667            .collect::<BTreeSet<_>>()
668        {
669            let Some(row) = self.row(path) else { continue };
670            let mut entry = row.entry.clone();
671            entry.inbound_count = inbound.get(&entry.path).copied().unwrap_or(0);
672            entries.push(entry);
673            fields.push(row.fields.clone());
674        }
675        (entries, fields)
676    }
677
678    fn row(&self, path: &str) -> Option<&SearchRow> {
679        self.upserts.get(path).map(AsRef::as_ref).or_else(|| {
680            (!self.tombstones.contains(path))
681                .then(|| self.base.get(path).map(AsRef::as_ref))
682                .flatten()
683        })
684    }
685
686    fn paths_for_term(&self, term: &str) -> BTreeSet<String> {
687        let mut paths = BTreeSet::new();
688        for (token, posting) in self.base_postings.range(term.to_string()..) {
689            if !token.starts_with(term) {
690                break;
691            }
692            paths.extend(posting.iter().filter(|path| {
693                !self.tombstones.contains(*path) && !self.upserts.contains_key(*path)
694            }).cloned());
695        }
696        for (path, row) in &self.upserts {
697            if row_has_term(row, term) {
698                paths.insert(path.clone());
699            }
700        }
701        paths
702    }
703
704    fn stats(&self, terms: &[String]) -> CorpusStats {
705        let mut df = HashMap::new();
706        for term in terms {
707            let count = self.paths_for_term(term).len() as i64;
708            *df.entry(term.clone()).or_insert(0) += count;
709        }
710        let mut sums = self.base_length_sums;
711        for path in self.tombstones.iter().chain(self.upserts.keys()) {
712            if let Some(row) = self.base.get(path) {
713                subtract_lengths(&mut sums, &row.fields);
714            }
715        }
716        for row in self.upserts.values() {
717            add_lengths(&mut sums, &row.fields);
718        }
719        let replaced = self
720            .upserts
721            .keys()
722            .filter(|path| self.base.contains_key(*path))
723            .count();
724        let n = self.base.len() - self.tombstones.len() - replaced + self.upserts.len();
725        let mut avglen = [0.0; 6];
726        if n != 0 {
727            for (average, sum) in avglen.iter_mut().zip(sums) {
728                *average = sum as f64 / n as f64;
729            }
730        }
731        CorpusStats {
732            n: n as i64,
733            df,
734            avglen,
735        }
736    }
737
738    pub fn base_len(&self) -> usize {
739        self.base.len()
740    }
741
742    pub fn upsert_len(&self) -> usize {
743        self.upserts.len()
744    }
745
746    pub fn tombstone_len(&self) -> usize {
747        self.tombstones.len()
748    }
749}
750
751fn empty_search(query: &str, artifact_type: Option<&str>) -> SearchResult {
752    SearchResult {
753        query: query.to_string(),
754        artifact_type: artifact_type.map(str::to_string),
755        matches: Vec::new(),
756    }
757}
758
759fn field_lengths(fields: &FieldTokens) -> [i64; 6] {
760    [
761        fields.id.len() as i64,
762        fields.title.len() as i64,
763        fields.path.len() as i64,
764        fields.heading.len() as i64,
765        fields.body.len() as i64,
766        fields.tags.len() as i64,
767    ]
768}
769
770fn add_lengths(sums: &mut [i64; 6], fields: &FieldTokens) {
771    for (sum, length) in sums.iter_mut().zip(field_lengths(fields)) {
772        *sum += length;
773    }
774}
775
776fn subtract_lengths(sums: &mut [i64; 6], fields: &FieldTokens) {
777    for (sum, length) in sums.iter_mut().zip(field_lengths(fields)) {
778        *sum -= length;
779    }
780}
781
782fn length_sums<'a>(rows: impl IntoIterator<Item = &'a SearchRow>) -> [i64; 6] {
783    let mut sums = [0; 6];
784    for row in rows {
785        add_lengths(&mut sums, &row.fields);
786    }
787    sums
788}
789
790fn row_has_term(row: &SearchRow, term: &str) -> bool {
791    all_tokens(&row.fields).any(|token| token.starts_with(term))
792}
793
794fn all_tokens(fields: &FieldTokens) -> impl Iterator<Item = &str> {
795    fields
796        .id
797        .iter()
798        .chain(&fields.title)
799        .chain(&fields.path)
800        .chain(&fields.heading)
801        .chain(&fields.body)
802        .chain(&fields.tags)
803        .map(String::as_str)
804}
805
806fn postings_for(rows: &BTreeMap<String, Arc<SearchRow>>) -> BTreeMap<String, Vec<String>> {
807    let mut postings: BTreeMap<String, BTreeSet<String>> = BTreeMap::new();
808    for (path, row) in rows {
809        for token in all_tokens(&row.fields) {
810            postings
811                .entry(token.to_string())
812                .or_default()
813                .insert(path.clone());
814        }
815    }
816    postings
817        .into_iter()
818        .map(|(token, paths)| (token, paths.into_iter().collect()))
819        .collect()
820}
821
822/// Per-document scope and live-decision projections. An entry is present in
823/// `scope` only when the document is a live decision with declared scope;
824/// `live` independently tracks every live decision for topic-mode filtering.
825#[derive(Clone, Default)]
826pub struct ScopeGeneration {
827    base_scope: Arc<BTreeMap<String, Arc<ScopeRow>>>,
828    base_live: Arc<BTreeMap<String, String>>,
829    scope_upserts: BTreeMap<String, Arc<ScopeRow>>,
830    live_upserts: BTreeMap<String, String>,
831    tombstones: BTreeSet<String>,
832}
833
834impl ScopeGeneration {
835    pub fn empty() -> Self {
836        Self::default()
837    }
838
839    pub fn from_items<'a>(items: impl IntoIterator<Item = (&'a str, &'a CorpusItem)>) -> Self {
840        let mut scope = BTreeMap::new();
841        let mut live = BTreeMap::new();
842        for (path, item) in items {
843            if is_live_decision_item(item) {
844                live.insert(path.to_string(), item.path.clone());
845            }
846            if let Some(row) = scope_row(item) {
847                scope.insert(path.to_string(), Arc::new(row));
848            }
849        }
850        Self {
851            base_scope: Arc::new(scope),
852            base_live: Arc::new(live),
853            scope_upserts: BTreeMap::new(),
854            live_upserts: BTreeMap::new(),
855            tombstones: BTreeSet::new(),
856        }
857    }
858
859    pub fn stage(
860        &self,
861        changed: &BTreeSet<String>,
862        parsed: &BTreeMap<String, CorpusItem>,
863    ) -> Self {
864        let mut next = self.clone();
865        for path in changed {
866            next.scope_upserts.remove(path);
867            next.live_upserts.remove(path);
868            if let Some(item) = parsed.get(path) {
869                if is_live_decision_item(item) {
870                    next.live_upserts.insert(path.clone(), item.path.clone());
871                }
872                if let Some(row) = scope_row(item) {
873                    next.scope_upserts.insert(path.clone(), Arc::new(row));
874                }
875                if next.base_scope.contains_key(path) || next.base_live.contains_key(path) {
876                    next.tombstones.insert(path.clone());
877                } else {
878                    next.tombstones.remove(path);
879                }
880            } else if next.base_scope.contains_key(path) || next.base_live.contains_key(path) {
881                next.tombstones.insert(path.clone());
882            } else {
883                next.tombstones.remove(path);
884            }
885        }
886        next
887    }
888
889    pub fn promote(&mut self) {
890        let mut scope = self.base_scope.as_ref().clone();
891        let mut live = self.base_live.as_ref().clone();
892        for path in &self.tombstones {
893            scope.remove(path);
894            live.remove(path);
895        }
896        for path in self.scope_upserts.keys() {
897            scope.remove(path);
898        }
899        for path in self.live_upserts.keys() {
900            live.remove(path);
901        }
902        for (path, row) in &self.scope_upserts {
903            scope.insert(path.clone(), Arc::clone(row));
904        }
905        for (path, display_path) in &self.live_upserts {
906            live.insert(path.clone(), display_path.clone());
907        }
908        self.base_scope = Arc::new(scope);
909        self.base_live = Arc::new(live);
910        self.scope_upserts.clear();
911        self.live_upserts.clear();
912        self.tombstones.clear();
913    }
914
915    pub fn rows(&self) -> Vec<ScopeRow> {
916        self.base_scope
917            .keys()
918            .chain(self.scope_upserts.keys())
919            .collect::<BTreeSet<_>>()
920            .into_iter()
921            .filter_map(|path| {
922                self.scope_upserts.get(path).or_else(|| {
923                    (!self.tombstones.contains(path))
924                        .then(|| self.base_scope.get(path))
925                        .flatten()
926                })
927            })
928            .map(|row| row.as_ref().clone())
929            .collect()
930    }
931
932    pub fn live_paths(&self) -> Vec<String> {
933        self.base_live
934            .keys()
935            .chain(self.live_upserts.keys())
936            .collect::<BTreeSet<_>>()
937            .into_iter()
938            .filter_map(|path| {
939                self.live_upserts.get(path).or_else(|| {
940                    (!self.tombstones.contains(path))
941                        .then(|| self.base_live.get(path))
942                        .flatten()
943                })
944            })
945            .cloned()
946            .collect()
947    }
948
949    pub fn base_scope_len(&self) -> usize {
950        self.base_scope.len()
951    }
952    pub fn scope_upsert_len(&self) -> usize {
953        self.scope_upserts.len()
954    }
955}
956
957fn is_live_decision_item(item: &CorpusItem) -> bool {
958    item.spec
959        .is_some_and(|spec| spec.name == crate::derived::DECISION_TYPE)
960        && crate::resolve::is_live_decision(&item.artifact)
961}
962
963fn scope_row(item: &CorpusItem) -> Option<ScopeRow> {
964    scope_rows_from_items(std::slice::from_ref(item))
965        .into_iter()
966        .next()
967}
968
969/// Incrementally validated per-document portfolio projections. Global summary
970/// fields reduce these compact rows at publication time; parsing, structural
971/// validation, and completeness classification remain change-bound.
972#[derive(Clone, Default)]
973pub struct SummaryGeneration {
974    base: Arc<BTreeMap<String, Arc<PortfolioRow>>>,
975    upserts: BTreeMap<String, Arc<PortfolioRow>>,
976    tombstones: BTreeSet<String>,
977}
978
979impl SummaryGeneration {
980    pub fn empty() -> Self {
981        Self::default()
982    }
983
984    pub fn from_items<'a>(items: impl IntoIterator<Item = (&'a str, &'a CorpusItem)>) -> Self {
985        Self {
986            base: Arc::new(
987                items
988                    .into_iter()
989                    .map(|(path, item)| (path.to_string(), Arc::new(portfolio_row(item))))
990                    .collect(),
991            ),
992            upserts: BTreeMap::new(),
993            tombstones: BTreeSet::new(),
994        }
995    }
996
997    pub fn stage(
998        &self,
999        changed: &BTreeSet<String>,
1000        parsed: &BTreeMap<String, CorpusItem>,
1001    ) -> Self {
1002        let mut next = self.clone();
1003        for path in changed {
1004            if let Some(item) = parsed.get(path) {
1005                next
1006                    .upserts
1007                    .insert(path.clone(), Arc::new(portfolio_row(item)));
1008                next.tombstones.remove(path);
1009            } else {
1010                next.upserts.remove(path);
1011                if next.base.contains_key(path) {
1012                    next.tombstones.insert(path.clone());
1013                } else {
1014                    next.tombstones.remove(path);
1015                }
1016            }
1017        }
1018        next
1019    }
1020
1021    pub fn value(&self, directory: &str, recursive: bool) -> Value {
1022        let rows: Vec<PortfolioRow> = self
1023            .base
1024            .keys()
1025            .chain(self.upserts.keys())
1026            .collect::<BTreeSet<_>>()
1027            .into_iter()
1028            .filter_map(|path| {
1029                self.upserts.get(path).or_else(|| {
1030                    (!self.tombstones.contains(path))
1031                        .then(|| self.base.get(path))
1032                        .flatten()
1033                })
1034            })
1035            .map(|row| row.as_ref().clone())
1036            .collect();
1037        crate::output::portfolio_summary_value(&portfolio_from_rows(directory, &rows, recursive))
1038    }
1039
1040    pub fn promote(&mut self) {
1041        let mut base = self.base.as_ref().clone();
1042        for path in &self.tombstones {
1043            base.remove(path);
1044        }
1045        for (path, row) in &self.upserts {
1046            base.insert(path.clone(), Arc::clone(row));
1047        }
1048        self.base = Arc::new(base);
1049        self.upserts.clear();
1050        self.tombstones.clear();
1051    }
1052
1053    pub fn base_len(&self) -> usize {
1054        self.base.len()
1055    }
1056    pub fn upsert_len(&self) -> usize {
1057        self.upserts.len()
1058    }
1059}
1060
1061/// Parsed documents for an immutable base plus one cumulative overlay.
1062#[derive(Clone, Default)]
1063pub struct DeltaDocuments {
1064    base: Arc<BTreeMap<String, CorpusItem>>,
1065    upserts: BTreeMap<String, CorpusItem>,
1066    tombstones: BTreeSet<String>,
1067}
1068
1069impl DeltaDocuments {
1070    pub fn empty() -> Self {
1071        Self::default()
1072    }
1073
1074    /// Stage one detected change set without mutating the served generation.
1075    pub fn stage(
1076        &self,
1077        changed: &BTreeSet<String>,
1078        mut parsed: BTreeMap<String, CorpusItem>,
1079    ) -> Self {
1080        let mut next = self.clone();
1081        for path in changed {
1082            if let Some(item) = parsed.remove(path) {
1083                next.upserts.insert(path.clone(), item);
1084                next.tombstones.remove(path);
1085            } else {
1086                next.upserts.remove(path);
1087                if next.base.contains_key(path) {
1088                    next.tombstones.insert(path.clone());
1089                } else {
1090                    // Adding and deleting a path within one uncompacted
1091                    // window leaves no trace in the base-relative overlay.
1092                    next.tombstones.remove(path);
1093                }
1094            }
1095        }
1096        next
1097    }
1098
1099    /// Materialize in canonical manifest order for the still-full P6.1
1100    /// derivation referee.
1101    pub fn ordered_items<'a>(
1102        &self,
1103        ordered_paths: impl IntoIterator<Item = &'a str>,
1104    ) -> Vec<CorpusItem> {
1105        ordered_paths
1106            .into_iter()
1107            .filter_map(|path| {
1108                self.upserts
1109                    .get(path)
1110                    .or_else(|| {
1111                        if self.tombstones.contains(path) {
1112                            None
1113                        } else {
1114                            self.base.get(path)
1115                        }
1116                    })
1117                    .cloned()
1118            })
1119            .collect()
1120    }
1121
1122    /// Fold the overlay into a new immutable base after durable compaction.
1123    pub fn promote<'a>(&mut self, ordered_paths: impl IntoIterator<Item = &'a str>) {
1124        let live = ordered_paths
1125            .into_iter()
1126            .filter_map(|path| {
1127                self.upserts
1128                    .get(path)
1129                    .or_else(|| {
1130                        if self.tombstones.contains(path) {
1131                            None
1132                        } else {
1133                            self.base.get(path)
1134                        }
1135                    })
1136                    .cloned()
1137                    .map(|item| (path.to_string(), item))
1138            })
1139            .collect();
1140        self.base = Arc::new(live);
1141        self.upserts.clear();
1142        self.tombstones.clear();
1143    }
1144
1145    pub fn base_len(&self) -> usize {
1146        self.base.len()
1147    }
1148
1149    pub fn upsert_len(&self) -> usize {
1150        self.upserts.len()
1151    }
1152
1153    pub fn tombstone_len(&self) -> usize {
1154        self.tombstones.len()
1155    }
1156
1157    pub fn delta_len(&self) -> usize {
1158        self.upserts.len() + self.tombstones.len()
1159    }
1160
1161    pub fn live_len(&self) -> usize {
1162        let replaced = self
1163            .upserts
1164            .keys()
1165            .filter(|path| self.base.contains_key(*path))
1166            .count();
1167        self.base.len() - self.tombstones.len() - replaced + self.upserts.len()
1168    }
1169
1170    pub fn changed_paths(&self) -> Vec<String> {
1171        self.upserts
1172            .keys()
1173            .chain(self.tombstones.iter())
1174            .cloned()
1175            .collect::<BTreeSet<_>>()
1176            .into_iter()
1177            .collect()
1178    }
1179}
1180
1181/// One fully-derived, immutable logical generation published atomically.
1182pub struct DeltaGeneration {
1183    pub base_generation: u64,
1184    pub serving_generation: u64,
1185    pub changed_paths: Vec<String>,
1186    pub identity: IdentityGeneration,
1187    pub search: SearchGeneration,
1188    pub graph: GraphGeneration,
1189    pub scope: ScopeGeneration,
1190    pub summary: SummaryGeneration,
1191}
1192
1193impl DeltaGeneration {
1194    /// Assemble the persisted/read-model bundle from the already-published
1195    /// incremental projections. This performs no parsing or artifact
1196    /// validation and is therefore suitable for durable compaction.
1197    pub fn materialize_derived(&self, directory: &str, recursive: bool) -> DerivedIndex {
1198        let (index_entries, field_tokens) = self.search.entries_and_fields(&self.graph);
1199        DerivedIndex {
1200            index_entries,
1201            field_tokens,
1202            relationships: self.graph.relationships(),
1203            live_decision_paths: self.scope.live_paths(),
1204            portfolio_summary: self.summary.value(directory, recursive),
1205            scope_rows: self.scope.rows(),
1206        }
1207    }
1208}
1209
1210#[cfg(test)]
1211mod tests {
1212    use super::*;
1213
1214    type EdgeSignature = (String, String, String, Option<String>, Option<String>);
1215
1216    const DOC: &str = "---\nschema_version: 1\nid: ADR-1\ntype: decision\n---\n# ADR-1: Delta\n\n## Context\n\nTest.\n\n## Decision\n\nKeep.\n\n## Consequences\n\nNone.\n\n## Status\n\nAccepted\n";
1217
1218    fn item(path: &str, id: &str, status: &str) -> CorpusItem {
1219        let text = DOC.replace("ADR-1", id).replace("Accepted", status);
1220        let artifact = crate::parse::parse_text(&text, path);
1221        let spec = crate::spec::spec_for(&crate::classify::classify(&artifact).artifact_type);
1222        CorpusItem {
1223            path: path.to_string(),
1224            artifact,
1225            spec,
1226        }
1227    }
1228
1229    fn tagged_item(path: &str, id: &str, status: &str, tag: &str) -> CorpusItem {
1230        let text = DOC
1231            .replace("ADR-1", id)
1232            .replace("Accepted", status)
1233            .replacen(
1234                "type: decision\n---",
1235                &format!("type: decision\ntags: [{tag}]\n---"),
1236                1,
1237            );
1238        let artifact = crate::parse::parse_text(&text, path);
1239        let spec = crate::spec::spec_for(&crate::classify::classify(&artifact).artifact_type);
1240        CorpusItem {
1241            path: path.to_string(),
1242            artifact,
1243            spec,
1244        }
1245    }
1246
1247    fn related_item(path: &str, id: &str, target: &str) -> CorpusItem {
1248        let text = format!(
1249            "---\nschema_version: 1\nid: {id}\ntype: requirement\n---\n# Requirement\n\n## Status\n\nAccepted\n\n## Problem\n\nTest.\n\n## Requirements\n\n- [REQ-001] Keep the graph exact.\n\n## Related Decisions\n\n- {target}\n"
1250        );
1251        let artifact = crate::parse::parse_text(&text, path);
1252        let spec = crate::spec::spec_for(&crate::classify::classify(&artifact).artifact_type);
1253        CorpusItem {
1254            path: path.to_string(),
1255            artifact,
1256            spec,
1257        }
1258    }
1259
1260    fn scoped_item(path: &str, id: &str, status: &str, scope: Option<&str>) -> CorpusItem {
1261        let scope = scope
1262            .map(|value| format!("\n\n## Applies To\n\n- {value}"))
1263            .unwrap_or_default();
1264        let text = DOC
1265            .replace("ADR-1", id)
1266            .replace("Accepted", status)
1267            .replace("\n## Status", &format!("{scope}\n\n## Status"));
1268        let artifact = crate::parse::parse_text(&text, path);
1269        let spec = crate::spec::spec_for(&crate::classify::classify(&artifact).artifact_type);
1270        CorpusItem {
1271            path: path.to_string(),
1272            artifact,
1273            spec,
1274        }
1275    }
1276
1277    fn scope_signature(rows: Vec<ScopeRow>) -> Vec<(String, String, String, String, Vec<String>)> {
1278        rows.into_iter()
1279            .map(|row| (row.id, row.title, row.status, row.path, row.scope_entries))
1280            .collect()
1281    }
1282
1283    fn edge_signature(edges: Vec<Relationship>) -> Vec<EdgeSignature> {
1284        edges
1285            .into_iter()
1286            .map(|edge| {
1287                (
1288                    edge.source_path,
1289                    edge.relationship,
1290                    edge.target,
1291                    edge.resolved_path,
1292                    edge.issue,
1293                )
1294            })
1295            .collect()
1296    }
1297
1298    fn assert_graph_matches_fresh(graph: &GraphGeneration, items: &BTreeMap<String, CorpusItem>) {
1299        let ordered: Vec<CorpusItem> = items.values().cloned().collect();
1300        assert_eq!(
1301            edge_signature(graph.relationships()),
1302            edge_signature(crate::relationships::relationships_from_corpus(&ordered)),
1303        );
1304        let mut expected = HashMap::new();
1305        for edge in crate::relationships::relationships_from_corpus(&ordered) {
1306            if let Some(path) = edge.resolved_path {
1307                *expected.entry(path).or_insert(0) += 1;
1308            }
1309        }
1310        assert_eq!(graph.inbound_counts(), expected);
1311    }
1312
1313    fn assert_search_matches_fresh(
1314        search: &SearchGeneration,
1315        items: &BTreeMap<String, CorpusItem>,
1316        query: &str,
1317        artifact_type: Option<&str>,
1318        tags: &[String],
1319        live_only: bool,
1320    ) {
1321        let ordered: Vec<CorpusItem> = items.values().cloned().collect();
1322        let referee = crate::resolve::index_from_items(&ordered);
1323        let identity = IdentityGeneration::from_items(
1324            items.iter().map(|(path, item)| (path.as_str(), item)),
1325        );
1326        let graph = GraphGeneration::from_items(
1327            items.iter().map(|(path, item)| (path.as_str(), item)),
1328            &identity,
1329        );
1330        let expected = crate::resolve::search_index_filtered(
1331            &referee,
1332            query,
1333            artifact_type,
1334            tags,
1335            live_only,
1336        );
1337        let actual = search.search(query, artifact_type, tags, live_only, &graph);
1338        assert_eq!(
1339            crate::output::search_result_value(&actual, true),
1340            crate::output::search_result_value(&expected, true),
1341            "query={query} type={artifact_type:?} tags={tags:?} live={live_only}"
1342        );
1343    }
1344
1345    #[test]
1346    fn overlay_stage_promote_and_changed_order_are_canonical() {
1347        let initial = BTreeMap::from([
1348            (
1349                "b.md".to_string(),
1350                item("b.md", "RAC-111111111111", "Accepted"),
1351            ),
1352            (
1353                "d.md".to_string(),
1354                item("d.md", "RAC-222222222222", "Accepted"),
1355            ),
1356        ]);
1357        let mut documents = DeltaDocuments::empty().stage(
1358            &BTreeSet::from(["b.md".to_string(), "d.md".to_string()]),
1359            initial,
1360        );
1361        documents.promote(["b.md", "d.md"]);
1362
1363        documents = documents.stage(
1364            &BTreeSet::from(["a.md".to_string(), "b.md".to_string(), "d.md".to_string()]),
1365            BTreeMap::from([
1366                (
1367                    "a.md".to_string(),
1368                    item("a.md", "RAC-333333333333", "Proposed"),
1369                ),
1370                (
1371                    "d.md".to_string(),
1372                    item("d.md", "RAC-222222222222", "Accepted"),
1373                ),
1374            ]),
1375        );
1376        assert_eq!(documents.base_len(), 2);
1377        assert_eq!(documents.upsert_len(), 2);
1378        assert_eq!(documents.tombstone_len(), 1);
1379        assert_eq!(documents.live_len(), 2);
1380        assert_eq!(documents.changed_paths(), vec!["a.md", "b.md", "d.md"]);
1381        let live: Vec<String> = documents
1382            .ordered_items(["a.md", "d.md"])
1383            .into_iter()
1384            .map(|item| item.path)
1385            .collect();
1386        assert_eq!(live, vec!["a.md", "d.md"]);
1387
1388        documents.promote(["a.md", "d.md"]);
1389        assert_eq!(documents.base_len(), 2);
1390        assert_eq!(documents.delta_len(), 0);
1391        assert_eq!(documents.live_len(), 2);
1392    }
1393
1394    #[test]
1395    fn identity_overlay_resolves_masks_duplicates_and_promotes() {
1396        let base_items = BTreeMap::from([
1397            (
1398                "b.md".to_string(),
1399                item("b.md", "RAC-111111111111", "Accepted"),
1400            ),
1401            (
1402                "d.md".to_string(),
1403                item("d.md", "RAC-222222222222", "Accepted"),
1404            ),
1405        ]);
1406        let mut identity = IdentityGeneration::from_items(
1407            base_items.iter().map(|(path, item)| (path.as_str(), item)),
1408        );
1409        assert_eq!(
1410            identity.resolve(" RAC-111111111111 ").outcome,
1411            OUTCOME_RESOLVED
1412        );
1413        assert_eq!(identity.status_for_path("b.md"), Some("Accepted"));
1414
1415        let changed = BTreeSet::from(["a.md".to_string(), "b.md".to_string(), "d.md".to_string()]);
1416        let parsed = BTreeMap::from([
1417            (
1418                "a.md".to_string(),
1419                item("a.md", "RAC-333333333333", "Proposed"),
1420            ),
1421            (
1422                "d.md".to_string(),
1423                item("d.md", "RAC-333333333333", "Accepted"),
1424            ),
1425        ]);
1426        let shared_base = Arc::clone(&identity.base);
1427        let shared_aliases = Arc::clone(&identity.base_aliases);
1428        identity = identity.stage(&changed, &parsed);
1429        assert!(Arc::ptr_eq(&shared_base, &identity.base));
1430        assert!(Arc::ptr_eq(&shared_aliases, &identity.base_aliases));
1431        assert_eq!(
1432            identity.resolve("RAC-111111111111").outcome,
1433            OUTCOME_NOT_FOUND
1434        );
1435        let duplicate = identity.resolve("RAC-333333333333");
1436        assert_eq!(duplicate.outcome, OUTCOME_DUPLICATE);
1437        assert_eq!(duplicate.duplicate_paths, vec!["a.md", "d.md"]);
1438        assert_eq!(identity.status_for_path("a.md"), Some("Proposed"));
1439        assert_eq!(identity.status_for_path("b.md"), None);
1440        assert_eq!(identity.base_len(), 2);
1441        assert_eq!(identity.upsert_len(), 2);
1442        assert_eq!(identity.tombstone_len(), 1);
1443
1444        identity.promote();
1445        assert_eq!(identity.base_len(), 2);
1446        assert_eq!(identity.upsert_len(), 0);
1447        assert_eq!(identity.tombstone_len(), 0);
1448        assert_eq!(
1449            identity.resolve("RAC-333333333333").duplicate_paths,
1450            vec!["a.md", "d.md"]
1451        );
1452    }
1453
1454    #[test]
1455    fn graph_overlay_re_resolves_unchanged_referrers_for_identity_changes() {
1456        let mut items = BTreeMap::from([
1457            ("a.md".to_string(), related_item("a.md", "REQ-001", "RAC-222222222222")),
1458            ("b.md".to_string(), item("b.md", "RAC-222222222222", "Accepted")),
1459        ]);
1460        let mut identity = IdentityGeneration::from_items(
1461            items.iter().map(|(path, item)| (path.as_str(), item)),
1462        );
1463        let mut graph = GraphGeneration::from_items(
1464            items.iter().map(|(path, item)| (path.as_str(), item)),
1465            &identity,
1466        );
1467        assert_graph_matches_fresh(&graph, &items);
1468        assert_eq!(graph.inbound_count("b.md"), 1);
1469
1470        let shared_rows = Arc::clone(&graph.base_rows);
1471        let shared_edges = Arc::clone(&graph.base_relationships);
1472        let changed = BTreeSet::from(["b.md".to_string()]);
1473        let parsed = BTreeMap::from([(
1474            "b.md".to_string(),
1475            item("b.md", "RAC-333333333333", "Accepted"),
1476        )]);
1477        identity = identity.stage(&changed, &parsed);
1478        graph = graph.stage(&changed, &parsed, &identity);
1479        items.insert("b.md".to_string(), parsed["b.md"].clone());
1480        assert!(Arc::ptr_eq(&shared_rows, &graph.base_rows));
1481        assert!(Arc::ptr_eq(&shared_edges, &graph.base_relationships));
1482        assert_graph_matches_fresh(&graph, &items);
1483        assert_eq!(
1484            graph.relationships()[0].issue.as_deref(),
1485            Some(ISSUE_TARGET_NOT_FOUND)
1486        );
1487
1488        let changed = BTreeSet::from(["d.md".to_string()]);
1489        let parsed = BTreeMap::from([(
1490            "d.md".to_string(),
1491            item("d.md", "RAC-222222222222", "Accepted"),
1492        )]);
1493        identity = identity.stage(&changed, &parsed);
1494        graph = graph.stage(&changed, &parsed, &identity);
1495        items.insert("d.md".to_string(), parsed["d.md"].clone());
1496        assert_graph_matches_fresh(&graph, &items);
1497        assert_eq!(
1498            graph.relationships()[0].resolved_path.as_deref(),
1499            Some("d.md")
1500        );
1501
1502        graph.promote();
1503        identity.promote();
1504        assert_eq!(graph.base_len(), 3);
1505        assert_eq!(graph.upsert_len(), 0);
1506        assert_eq!(graph.tombstone_len(), 0);
1507        assert_graph_matches_fresh(&graph, &items);
1508    }
1509
1510    #[test]
1511    fn graph_overlay_handles_source_edit_delete_and_ambiguity() {
1512        let mut items = BTreeMap::from([
1513            ("a.md".to_string(), related_item("a.md", "REQ-001", "RAC-222222222222")),
1514            ("b.md".to_string(), item("b.md", "RAC-222222222222", "Accepted")),
1515            ("c.md".to_string(), item("c.md", "RAC-333333333333", "Accepted")),
1516        ]);
1517        let mut identity = IdentityGeneration::from_items(
1518            items.iter().map(|(path, item)| (path.as_str(), item)),
1519        );
1520        let mut graph = GraphGeneration::from_items(
1521            items.iter().map(|(path, item)| (path.as_str(), item)),
1522            &identity,
1523        );
1524
1525        let changed = BTreeSet::from(["a.md".to_string()]);
1526        let parsed = BTreeMap::from([(
1527            "a.md".to_string(),
1528            related_item("a.md", "REQ-001", "RAC-333333333333"),
1529        )]);
1530        identity = identity.stage(&changed, &parsed);
1531        graph = graph.stage(&changed, &parsed, &identity);
1532        items.insert("a.md".to_string(), parsed["a.md"].clone());
1533        assert_graph_matches_fresh(&graph, &items);
1534        assert_eq!(graph.inbound_count("c.md"), 1);
1535
1536        let changed = BTreeSet::from(["b.md".to_string()]);
1537        let parsed = BTreeMap::from([(
1538            "b.md".to_string(),
1539            item("b.md", "RAC-333333333333", "Accepted"),
1540        )]);
1541        identity = identity.stage(&changed, &parsed);
1542        graph = graph.stage(&changed, &parsed, &identity);
1543        items.insert("b.md".to_string(), parsed["b.md"].clone());
1544        assert_graph_matches_fresh(&graph, &items);
1545        assert_eq!(
1546            graph.relationships()[0].issue.as_deref(),
1547            Some(ISSUE_TARGET_AMBIGUOUS)
1548        );
1549        assert!(graph.inbound_counts().is_empty());
1550
1551        let changed = BTreeSet::from(["a.md".to_string()]);
1552        let parsed = BTreeMap::new();
1553        identity = identity.stage(&changed, &parsed);
1554        graph = graph.stage(&changed, &parsed, &identity);
1555        items.remove("a.md");
1556        assert_graph_matches_fresh(&graph, &items);
1557        assert!(graph.relationships().is_empty());
1558    }
1559
1560    #[test]
1561    fn scope_overlay_tracks_live_status_scope_delete_and_promote() {
1562        let mut items = BTreeMap::from([
1563            (
1564                "b.md".to_string(),
1565                scoped_item(
1566                    "b.md",
1567                    "RAC-111111111111",
1568                    "Accepted",
1569                    Some("src/**"),
1570                ),
1571            ),
1572            (
1573                "d.md".to_string(),
1574                scoped_item("d.md", "RAC-222222222222", "Accepted", None),
1575            ),
1576        ]);
1577        let mut scope = ScopeGeneration::from_items(
1578            items.iter().map(|(path, item)| (path.as_str(), item)),
1579        );
1580        assert_eq!(scope.live_paths(), vec!["b.md", "d.md"]);
1581        assert_eq!(scope.rows().len(), 1);
1582
1583        let shared_scope = Arc::clone(&scope.base_scope);
1584        let shared_live = Arc::clone(&scope.base_live);
1585        let changed = BTreeSet::from(["a.md".to_string(), "b.md".to_string()]);
1586        let parsed = BTreeMap::from([
1587            (
1588                "a.md".to_string(),
1589                scoped_item(
1590                    "a.md",
1591                    "RAC-333333333333",
1592                    "Accepted",
1593                    Some("docs/**"),
1594                ),
1595            ),
1596            (
1597                "b.md".to_string(),
1598                scoped_item(
1599                    "b.md",
1600                    "RAC-111111111111",
1601                    "Superseded",
1602                    Some("src/**"),
1603                ),
1604            ),
1605        ]);
1606        scope = scope.stage(&changed, &parsed);
1607        items.extend(parsed.clone());
1608        assert!(Arc::ptr_eq(&shared_scope, &scope.base_scope));
1609        assert!(Arc::ptr_eq(&shared_live, &scope.base_live));
1610        assert_eq!(scope.live_paths(), vec!["a.md", "d.md"]);
1611        assert_eq!(
1612            scope_signature(scope.rows()),
1613            scope_signature(scope_rows_from_items(
1614                &items.values().cloned().collect::<Vec<_>>()
1615            ))
1616        );
1617
1618        let changed = BTreeSet::from(["d.md".to_string()]);
1619        scope = scope.stage(&changed, &BTreeMap::new());
1620        items.remove("d.md");
1621        assert_eq!(scope.live_paths(), vec!["a.md"]);
1622        assert_eq!(
1623            scope_signature(scope.rows()),
1624            scope_signature(scope_rows_from_items(
1625                &items.values().cloned().collect::<Vec<_>>()
1626            ))
1627        );
1628
1629        scope.promote();
1630        assert_eq!(scope.base_scope_len(), 1);
1631        assert_eq!(scope.scope_upsert_len(), 0);
1632        assert_eq!(scope.live_paths(), vec!["a.md"]);
1633    }
1634
1635    #[test]
1636    fn summary_overlay_reuses_validated_base_rows_and_matches_fresh() {
1637        let directory = ".";
1638        let mut items = BTreeMap::from([
1639            (
1640                "b.md".to_string(),
1641                item("b.md", "RAC-111111111111", "Accepted"),
1642            ),
1643            (
1644                "d.md".to_string(),
1645                tagged_item("d.md", "RAC-222222222222", "Accepted", "alpha"),
1646            ),
1647        ]);
1648        let mut summary = SummaryGeneration::from_items(
1649            items.iter().map(|(path, item)| (path.as_str(), item)),
1650        );
1651        let fresh = |items: &BTreeMap<String, CorpusItem>| {
1652            crate::output::portfolio_summary_value(&crate::portfolio::portfolio_from_corpus(
1653                directory,
1654                &items.values().cloned().collect::<Vec<_>>(),
1655                true,
1656            ))
1657        };
1658        assert_eq!(summary.value(directory, true), fresh(&items));
1659
1660        let shared = Arc::clone(&summary.base);
1661        let changed = BTreeSet::from(["a.md".to_string(), "b.md".to_string()]);
1662        let parsed = BTreeMap::from([
1663            (
1664                "a.md".to_string(),
1665                item("a.md", "RAC-333333333333", "Proposed"),
1666            ),
1667            (
1668                "b.md".to_string(),
1669                item("b.md", "RAC-111111111111", "Superseded"),
1670            ),
1671        ]);
1672        summary = summary.stage(&changed, &parsed);
1673        items.extend(parsed);
1674        assert!(Arc::ptr_eq(&shared, &summary.base));
1675        assert_eq!(summary.value(directory, true), fresh(&items));
1676        assert_eq!(summary.base_len(), 2);
1677        assert_eq!(summary.upsert_len(), 2);
1678
1679        summary.promote();
1680        assert_eq!(summary.base_len(), 3);
1681        assert_eq!(summary.upsert_len(), 0);
1682        assert_eq!(summary.value(directory, true), fresh(&items));
1683    }
1684
1685    #[test]
1686    fn search_overlay_matches_fresh_stats_filters_and_ranking() {
1687        let mut items = BTreeMap::from([
1688            (
1689                "a.md".to_string(),
1690                tagged_item("a.md", "RAC-111111111111", "Accepted", "alpha"),
1691            ),
1692            (
1693                "b.md".to_string(),
1694                tagged_item("b.md", "RAC-222222222222", "Superseded", "beta"),
1695            ),
1696        ]);
1697        let mut search = SearchGeneration::from_items(
1698            items.iter().map(|(path, item)| (path.as_str(), item)),
1699        );
1700        for (query, artifact_type, tags, live_only) in [
1701            ("delta", None, vec![], false),
1702            ("RAC 111111111111", Some("decision"), vec![], false),
1703            ("delta delta", None, vec![], false),
1704            ("delta", None, vec!["alpha".to_string()], false),
1705            ("zzzz-no-match", None, vec![], false),
1706        ] {
1707            assert_search_matches_fresh(
1708                &search,
1709                &items,
1710                query,
1711                artifact_type,
1712                &tags,
1713                live_only,
1714            );
1715        }
1716
1717        let shared_base = Arc::clone(&search.base);
1718        let shared_postings = Arc::clone(&search.base_postings);
1719        let changed = BTreeSet::from([
1720            "a.md".to_string(),
1721            "b.md".to_string(),
1722            "c.md".to_string(),
1723        ]);
1724        let parsed = BTreeMap::from([
1725            (
1726                "b.md".to_string(),
1727                tagged_item("b.md", "RAC-333333333333", "Accepted", "alpha"),
1728            ),
1729            (
1730                "c.md".to_string(),
1731                tagged_item("c.md", "RAC-444444444444", "Accepted", "gamma"),
1732            ),
1733        ]);
1734        search = search.stage(&changed, &parsed);
1735        assert!(Arc::ptr_eq(&shared_base, &search.base));
1736        assert!(Arc::ptr_eq(&shared_postings, &search.base_postings));
1737        items.remove("a.md");
1738        items.extend(parsed);
1739        for query in ["delta", "RAC 333333333333", "delta delta"] {
1740            assert_search_matches_fresh(&search, &items, query, None, &[], false);
1741        }
1742        assert_search_matches_fresh(
1743            &search,
1744            &items,
1745            "delta",
1746            None,
1747            &["alpha".to_string()],
1748            true,
1749        );
1750        assert_eq!(search.base_len(), 2);
1751        assert_eq!(search.upsert_len(), 2);
1752        assert_eq!(search.tombstone_len(), 1);
1753
1754        search.promote();
1755        assert_eq!(search.base_len(), 2);
1756        assert_eq!(search.upsert_len(), 0);
1757        assert_eq!(search.tombstone_len(), 0);
1758        assert_search_matches_fresh(&search, &items, "delta", None, &[], false);
1759    }
1760}