1use 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#[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#[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#[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#[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#[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#[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#[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#[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 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 next.tombstones.remove(path);
1093 }
1094 }
1095 }
1096 next
1097 }
1098
1099 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 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
1181pub 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 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}