1use std::collections::{HashMap, HashSet};
9use std::sync::Arc;
10
11use memstead_schema::{Filterable, Schema, Serialization, TypeDefinition, type_by_name};
12
13use super::{
14 ExpansionInfo, Facets, ListResult, Query, ScoreBreakdown, SearchHit, SearchResult, SearchScope,
15 SubsectionFacet, SummaryPair, WarningHint,
16};
17use crate::entity::EntityId;
18use crate::entity::generator::generate_markdown;
19use crate::graph::query;
20use crate::search_index::{
21 MemIndex, compute_matched_terms, compute_score_breakdown, query as search_query,
22};
23use crate::store::Store;
24
25const MAX_HITS_PER_MEM: usize = 10_000;
30
31fn hit_summary<'a>(
37 schema: &TypeDefinition,
38 get_section: impl Fn(&str) -> Option<&'a str>,
39) -> SummaryPair {
40 let (heading, value) = crate::render::lead_section_pair(schema, get_section);
41 SummaryPair { heading, value }
42}
43
44fn estimate_tokens(entity: &crate::entity::Entity, schema: &TypeDefinition) -> usize {
46 let md = generate_markdown(entity, schema);
47 md.len() / 4
48}
49
50const RELATED_TO_NEIGHBOURHOOD_CAP: usize = 100;
54
55const DEFAULT_SEARCH_TOKEN_BUDGET: usize = 12_000;
62
63fn hit_response_tokens(hit: &SearchHit) -> usize {
69 serde_json::to_string(hit).map(|s| s.len()).unwrap_or(0) / 4
70}
71
72pub fn search(
78 store: &Store,
79 scope: &SearchScope,
80 default_schema: &TypeDefinition,
81 search_indexes: &HashMap<String, MemIndex>,
82 mem_schemas: &HashMap<String, Arc<Schema>>,
83) -> SearchResult {
84 let mut warnings: Vec<WarningHint> = Vec::new();
85 let scoped_type = scope.entity_type.as_deref();
86 let scope_mem = scope.mem.as_deref();
87 let filter_type = scoped_type.and_then(|t| resolve_type(t, scope_mem, mem_schemas));
88 let filter_schema: &TypeDefinition = filter_type.as_deref().unwrap_or(default_schema);
89 collect_equality_filter_warnings(
90 &scope.filters,
91 filter_schema,
92 scoped_type,
93 scope_mem,
94 mem_schemas,
95 &mut warnings,
96 );
97 collect_range_filter_warnings(
98 &scope.range_filters,
99 filter_schema,
100 scoped_type,
101 scope_mem,
102 mem_schemas,
103 &mut warnings,
104 );
105 collect_stub_type_exclusion_warning(scope, &mut warnings);
106
107 let effective_query: Option<&Query> = scope.query.as_ref().filter(|q| !q.is_empty());
110 let query_has_text = effective_query.is_some();
111
112 let mut scored_ids: HashMap<EntityId, f32> = HashMap::new();
117 if query_has_text {
118 let query = effective_query.unwrap();
119 let target_mems = resolve_target_mems(search_indexes, scope.mem.as_deref());
120 if let Some(name) = scope.mem.as_ref()
121 && target_mems.is_empty()
122 {
123 warnings.push(WarningHint::SearchMemIndexUnavailable {
124 mem: name.clone(),
125 reason: "missing_index",
126 error: None,
127 });
128 }
129 for mem_name in &target_mems {
130 let Some(idx) = search_indexes.get(mem_name.as_str()) else {
131 continue;
132 };
133 let schema = mem_schemas.get(mem_name.as_str());
134 match search_query::execute_on_mem(idx, schema, query, MAX_HITS_PER_MEM) {
135 Ok(hits) => {
136 for (id, score) in hits {
137 let slot = scored_ids.entry(id).or_insert(f32::MIN);
138 if score > *slot {
139 *slot = score;
140 }
141 }
142 }
143 Err(e) => {
144 tracing::warn!(
145 mem = mem_name.as_str(),
146 error = %e,
147 "tantivy query failed; mem contributes no hits"
148 );
149 warnings.push(WarningHint::SearchMemIndexUnavailable {
150 mem: mem_name.to_string(),
151 reason: "query_failed",
152 error: Some(e.to_string()),
153 });
154 }
155 }
156 }
157 if scored_ids.is_empty() {
158 return SearchResult {
159 total: 0,
160 returned: 0,
161 offset: scope.offset.unwrap_or(0),
162 total_tokens: 0,
163 hits: Vec::new(),
164 facets: Some(Facets::default()),
168 warnings,
169 };
170 }
171 }
172
173 let query_term = first_positive_term(effective_query);
174
175 let mut hits: Vec<SearchHit> = Vec::new();
176 for entity in store.all_entities() {
177 match scope.stub {
178 Some(true) if !entity.stub => continue,
179 Some(false) if entity.stub => continue,
180 _ => {}
181 }
182
183 if let Some(ref mem) = scope.mem
184 && entity.mem != *mem
185 {
186 continue;
187 }
188
189 if query_has_text && !scored_ids.contains_key(&entity.id) {
190 continue;
191 }
192
193 if let Some(ref type_name) = scope.entity_type
194 && entity.entity_type != *type_name
195 {
196 continue;
197 }
198
199 let resolved = resolve_type(&entity.entity_type, Some(entity.mem.as_str()), mem_schemas);
200 let schema: &TypeDefinition = resolved.as_deref().unwrap_or(default_schema);
201
202 if !apply_equality_filters(
203 entity,
204 &scope.filters,
205 schema,
206 scope.mem.as_deref(),
207 mem_schemas,
208 ) {
209 continue;
210 }
211 if !apply_range_filters(
212 entity,
213 &scope.range_filters,
214 schema,
215 scope.mem.as_deref(),
216 mem_schemas,
217 ) {
218 continue;
219 }
220
221 if let Some(ref edge_type) = scope.edge_type {
222 let has_out = store
223 .outgoing(&entity.id)
224 .iter()
225 .any(|e| e.rel_type == *edge_type);
226 let has_in = store
227 .incoming(&entity.id)
228 .iter()
229 .any(|e| e.rel_type == *edge_type);
230 if !has_out && !has_in {
231 continue;
232 }
233 }
234
235 let score = scored_ids.get(&entity.id).copied().unwrap_or(0.0);
236 let snippet = query_term
237 .as_ref()
238 .and_then(|term| snippet_for(entity, term, schema));
239
240 let tokens = estimate_tokens(entity, schema);
241
242 let summary = Some(hit_summary(schema, |k| {
251 entity.sections.get(k).map(String::as_str)
252 }));
253
254 let (matched_terms, score_breakdown) = if let Some(q) = effective_query {
258 let mt = compute_matched_terms(entity, q);
259 let sb = compute_score_breakdown(schema, score, &mt);
260 (mt, Some(sb))
261 } else {
262 (None, None)
263 };
264
265 hits.push(SearchHit {
266 id: entity.id.clone(),
267 title: entity.title.clone(),
268 mem: entity.mem.clone(),
269 entity_type: entity.entity_type.clone(),
270 stub: entity.stub,
271 last_modified: entity.metadata.get("last_modified").map(|v| v.to_string()),
272 score,
273 tokens,
274 snippet,
275 summary,
276 sections: HashMap::new(),
277 score_breakdown,
278 matched_terms,
279 expansion: None,
280 });
281 }
282
283 let neighbourhood: Option<(HashMap<EntityId, usize>, HashSet<EntityId>)> =
288 if let Some(ref related_to) = scope.related_to {
289 let depth = scope.depth.unwrap_or(1);
290 let distances = query::reachable_distances(store, related_to, depth, scope.direction);
291 hits.retain(|h| distances.contains_key(&h.id));
292 let typed_direct: HashSet<EntityId> = store
293 .outgoing(related_to)
294 .iter()
295 .filter(|e| e.source != crate::store::EdgeSource::BodyLink)
296 .map(|e| e.target.clone())
297 .chain(
298 store
299 .incoming(related_to)
300 .iter()
301 .filter(|e| e.source != crate::store::EdgeSource::BodyLink)
302 .map(|e| e.from.clone()),
303 )
304 .collect();
305 Some((distances, typed_direct))
306 } else {
307 None
308 };
309
310 if let Some(ref edge_types) = scope.expand_via
318 && !edge_types.is_empty()
319 {
320 expand_hits(
321 &mut hits,
322 store,
323 edge_types,
324 scope,
325 default_schema,
326 mem_schemas,
327 );
328 }
329
330 if let Some((distances, typed_direct)) = neighbourhood.as_ref() {
335 hits.sort_by(|a, b| {
336 let da = distances.get(&a.id).copied().unwrap_or(usize::MAX);
337 let db = distances.get(&b.id).copied().unwrap_or(usize::MAX);
338 da.cmp(&db)
339 .then_with(|| {
340 typed_direct
341 .contains(&b.id)
342 .cmp(&typed_direct.contains(&a.id))
343 })
344 .then_with(|| {
345 b.score
346 .partial_cmp(&a.score)
347 .unwrap_or(std::cmp::Ordering::Equal)
348 })
349 .then_with(|| a.title.cmp(&b.title))
350 });
351 } else {
352 hits.sort_by(|a, b| {
353 b.score
354 .partial_cmp(&a.score)
355 .unwrap_or(std::cmp::Ordering::Equal)
356 .then_with(|| a.title.cmp(&b.title))
357 });
358 }
359
360 if neighbourhood.is_some() && hits.len() > RELATED_TO_NEIGHBOURHOOD_CAP {
364 warnings.push(WarningHint::NeighbourhoodCapped {
365 kept: RELATED_TO_NEIGHBOURHOOD_CAP,
366 total: hits.len(),
367 });
368 hits.truncate(RELATED_TO_NEIGHBOURHOOD_CAP);
369 }
370
371 let total = hits.len();
372 let total_tokens: usize = hits.iter().map(|h| h.tokens).sum();
373 let facets = compute_facets(&hits, store);
377 let offset = scope.offset.unwrap_or(0);
378 let limit = scope.limit.unwrap_or(50).min(200);
379
380 let mut paginated: Vec<SearchHit> = hits.into_iter().skip(offset).take(limit).collect();
381
382 let budget = scope.token_budget.unwrap_or(DEFAULT_SEARCH_TOKEN_BUDGET);
390 let pre_budget = paginated.len();
391 let mut running = 0usize;
392 let mut keep = 0usize;
393 for hit in &paginated {
394 let cost = hit_response_tokens(hit);
395 if keep > 0 && running + cost > budget {
396 break;
397 }
398 running += cost;
399 keep += 1;
400 }
401 if keep < pre_budget {
402 paginated.truncate(keep);
403 warnings.push(WarningHint::SearchResultsTruncated { kept: keep, budget });
404 }
405 let returned = paginated.len();
406
407 SearchResult {
408 total,
409 returned,
410 offset,
411 total_tokens,
412 hits: paginated,
413 facets: Some(facets),
414 warnings,
415 }
416}
417
418pub fn list(
420 store: &Store,
421 scope: &SearchScope,
422 default_schema: &TypeDefinition,
423 mem_schemas: &HashMap<String, Arc<Schema>>,
424) -> ListResult {
425 let mut hits: Vec<SearchHit> = Vec::new();
426 let mut total_tokens = 0;
427 let mut warnings: Vec<WarningHint> = Vec::new();
428 let scoped_type = scope.entity_type.as_deref();
429 let scope_mem = scope.mem.as_deref();
430 let filter_type = scoped_type.and_then(|t| resolve_type(t, scope_mem, mem_schemas));
431 let filter_schema: &TypeDefinition = filter_type.as_deref().unwrap_or(default_schema);
432 collect_equality_filter_warnings(
433 &scope.filters,
434 filter_schema,
435 scoped_type,
436 scope_mem,
437 mem_schemas,
438 &mut warnings,
439 );
440 collect_range_filter_warnings(
441 &scope.range_filters,
442 filter_schema,
443 scoped_type,
444 scope_mem,
445 mem_schemas,
446 &mut warnings,
447 );
448 collect_stub_type_exclusion_warning(scope, &mut warnings);
449
450 for entity in store.all_entities() {
451 match scope.stub {
452 Some(true) if !entity.stub => continue,
453 Some(false) if entity.stub => continue,
454 _ => {}
455 }
456
457 if let Some(ref mem) = scope.mem
458 && entity.mem != *mem
459 {
460 continue;
461 }
462
463 if let Some(ref type_name) = scope.entity_type
464 && entity.entity_type != *type_name
465 {
466 continue;
467 }
468
469 let resolved = resolve_type(&entity.entity_type, Some(entity.mem.as_str()), mem_schemas);
470 let schema: &TypeDefinition = resolved.as_deref().unwrap_or(default_schema);
471
472 if !apply_equality_filters(
473 entity,
474 &scope.filters,
475 schema,
476 scope.mem.as_deref(),
477 mem_schemas,
478 ) {
479 continue;
480 }
481 if !apply_range_filters(
482 entity,
483 &scope.range_filters,
484 schema,
485 scope.mem.as_deref(),
486 mem_schemas,
487 ) {
488 continue;
489 }
490
491 if let Some(ref edge_type) = scope.edge_type {
492 let has_out = store
493 .outgoing(&entity.id)
494 .iter()
495 .any(|e| e.rel_type == *edge_type);
496 let has_in = store
497 .incoming(&entity.id)
498 .iter()
499 .any(|e| e.rel_type == *edge_type);
500 if !has_out && !has_in {
501 continue;
502 }
503 }
504
505 let tokens = estimate_tokens(entity, schema);
506 total_tokens += tokens;
507
508 let mut result_sections = HashMap::new();
509 for section_def in schema.sections.iter().filter(|s| s.required) {
510 if let Some(content) = entity.sections.get(section_def.key.as_str()) {
511 result_sections.insert(section_def.key.clone(), content.clone());
512 }
513 }
514
515 let summary = Some(hit_summary(schema, |k| {
518 result_sections.get(k).map(String::as_str)
519 }));
520
521 hits.push(SearchHit {
522 id: entity.id.clone(),
523 title: entity.title.clone(),
524 mem: entity.mem.clone(),
525 entity_type: entity.entity_type.clone(),
526 stub: entity.stub,
527 last_modified: entity.metadata.get("last_modified").map(|v| v.to_string()),
528 score: 0.0,
529 tokens,
530 snippet: None,
531 summary,
532 sections: result_sections,
533 score_breakdown: None,
534 matched_terms: None,
535 expansion: None,
536 });
537 }
538
539 hits.sort_by(|a, b| a.title.cmp(&b.title));
540
541 let total = hits.len();
542 let offset = scope.offset.unwrap_or(0);
543 let limit = scope.limit.unwrap_or(50).min(200);
544 let paginated: Vec<SearchHit> = hits.into_iter().skip(offset).take(limit).collect();
545 let returned = paginated.len();
546
547 ListResult {
548 total,
549 returned,
550 offset,
551 total_tokens,
552 hits: paginated,
553 warnings,
554 }
555}
556
557fn compute_facets(hits: &[SearchHit], store: &Store) -> Facets {
573 let mut by_type: HashMap<String, usize> = HashMap::new();
574 let mut by_mem: HashMap<String, usize> = HashMap::new();
575 let mut by_level: HashMap<String, usize> = HashMap::new();
576 let mut by_status: HashMap<String, usize> = HashMap::new();
577 let mut by_confidence: HashMap<String, usize> = HashMap::new();
578 let mut subsection_counts: HashMap<Vec<String>, usize> = HashMap::new();
579 let mut by_expansion: HashMap<String, usize> = HashMap::new();
580
581 for hit in hits {
582 if !hit.entity_type.is_empty() {
588 *by_type.entry(hit.entity_type.clone()).or_insert(0) += 1;
589 }
590 *by_mem.entry(hit.mem.clone()).or_insert(0) += 1;
591
592 if let Some(entity) = store.get(&hit.id) {
593 if let Some(v) = entity.metadata.get("level") {
594 *by_level.entry(v.to_frontmatter_string()).or_insert(0) += 1;
595 }
596 if let Some(v) = entity.metadata.get("status") {
597 *by_status.entry(v.to_frontmatter_string()).or_insert(0) += 1;
598 }
599 if let Some(v) = entity.metadata.get("confidence") {
600 *by_confidence.entry(v.to_frontmatter_string()).or_insert(0) += 1;
601 }
602 }
603
604 let tag = if hit.expansion.is_some() {
605 "expanded"
606 } else {
607 "primary"
608 };
609 *by_expansion.entry(tag.into()).or_insert(0) += 1;
610
611 if let Some(matched) = &hit.matched_terms {
612 for term_matches in matched.values() {
613 for tm in term_matches {
614 let Some(heading_path) = &tm.heading_path else {
615 continue;
616 };
617 if heading_path.is_empty() {
618 continue;
619 }
620 let mut path = Vec::with_capacity(heading_path.len() + 1);
621 path.push(tm.field.clone());
622 path.extend(heading_path.iter().cloned());
623 *subsection_counts.entry(path).or_insert(0) += 1;
624 }
625 }
626 }
627 }
628
629 let mut by_subsection: Vec<SubsectionFacet> = subsection_counts
632 .into_iter()
633 .map(|(path, count)| SubsectionFacet { path, count })
634 .collect();
635 by_subsection.sort_by(|a, b| b.count.cmp(&a.count).then_with(|| a.path.cmp(&b.path)));
636
637 Facets {
638 by_type,
639 by_mem,
640 by_level,
641 by_status,
642 by_confidence,
643 by_subsection,
644 by_expansion,
645 }
646}
647
648fn expand_hits(
663 hits: &mut Vec<SearchHit>,
664 store: &Store,
665 edge_types: &[String],
666 scope: &SearchScope,
667 default_schema: &TypeDefinition,
668 mem_schemas: &HashMap<String, Arc<Schema>>,
669) {
670 let depth_limit = scope.expand_depth.unwrap_or(1);
671 if depth_limit == 0 {
672 return;
673 }
674 let primary_ids: HashSet<EntityId> = hits.iter().map(|h| h.id.clone()).collect();
675
676 let mut expanded: HashMap<
680 EntityId,
681 (
682 f32,
683 String,
684 usize,
685 EntityId,
686 crate::graph::query::TraversalDirection,
687 ),
688 > = HashMap::new();
689
690 for primary in hits.iter() {
691 let reached =
692 query::reachable_via(store, &primary.id, edge_types, depth_limit, scope.direction);
693 for reached_via in reached {
694 if primary_ids.contains(&reached_via.id) {
695 continue;
696 }
697 let decay = 0.5f32.powi(reached_via.depth as i32);
698 let score = primary.score * decay;
699 let better = match expanded.get(&reached_via.id) {
700 Some((prev_score, _, _, _, _)) => score > *prev_score,
701 None => true,
702 };
703 if better {
704 expanded.insert(
705 reached_via.id.clone(),
706 (
707 score,
708 reached_via.via_edge,
709 reached_via.depth,
710 primary.id.clone(),
711 reached_via.direction,
712 ),
713 );
714 }
715 }
716 }
717
718 for (id, (score, via_edge, depth, of, via_direction)) in expanded {
719 let Some(entity) = store.get(&id) else {
720 continue;
721 };
722 match scope.stub {
723 Some(true) if !entity.stub => continue,
724 Some(false) if entity.stub => continue,
725 _ => {}
726 }
727 if let Some(ref mem) = scope.mem
728 && entity.mem != *mem
729 {
730 continue;
731 }
732 if let Some(ref type_name) = scope.entity_type
733 && entity.entity_type != *type_name
734 {
735 continue;
736 }
737
738 let resolved = resolve_type(&entity.entity_type, Some(entity.mem.as_str()), mem_schemas);
739 let schema: &TypeDefinition = resolved.as_deref().unwrap_or(default_schema);
740
741 if !apply_equality_filters(
742 entity,
743 &scope.filters,
744 schema,
745 scope.mem.as_deref(),
746 mem_schemas,
747 ) {
748 continue;
749 }
750 if !apply_range_filters(
751 entity,
752 &scope.range_filters,
753 schema,
754 scope.mem.as_deref(),
755 mem_schemas,
756 ) {
757 continue;
758 }
759
760 let tokens = estimate_tokens(entity, schema);
761 let summary = Some(hit_summary(schema, |k| {
764 entity.sections.get(k).map(String::as_str)
765 }));
766
767 let decay = 0.5f32.powi(depth as i32);
768 let score_breakdown = ScoreBreakdown {
769 bm25: 0.0,
770 title_boost: 0.0,
771 field_weights: HashMap::new(),
772 expansion_decay: Some(decay),
773 };
774
775 hits.push(SearchHit {
776 id: id.clone(),
777 title: entity.title.clone(),
778 mem: entity.mem.clone(),
779 entity_type: entity.entity_type.clone(),
780 stub: entity.stub,
781 last_modified: entity.metadata.get("last_modified").map(|v| v.to_string()),
782 score,
783 tokens,
784 snippet: None,
785 summary,
786 sections: HashMap::new(),
787 score_breakdown: Some(score_breakdown),
788 matched_terms: None,
789 expansion: Some(ExpansionInfo {
790 of,
791 via_edge,
792 depth,
793 via_direction,
794 }),
795 });
796 }
797}
798
799fn first_positive_term(query: Option<&Query>) -> Option<String> {
806 let q = query?;
807 if let Some(t) = q.any.first() {
808 return Some(t.clone());
809 }
810 q.phrase.clone()
811}
812
813fn resolve_target_mems<'a>(
816 search_indexes: &'a HashMap<String, MemIndex>,
817 requested: Option<&str>,
818) -> Vec<&'a String> {
819 match requested {
820 Some(name) => search_indexes
821 .keys()
822 .filter(|k| k.as_str() == name)
823 .collect(),
824 None => search_indexes.keys().collect(),
825 }
826}
827
828fn snippet_for(
833 entity: &crate::entity::Entity,
834 term: &str,
835 schema: &TypeDefinition,
836) -> Option<String> {
837 let lower_term = term.to_lowercase();
838 if entity.title.to_lowercase().contains(&lower_term) {
839 return Some(build_snippet(&entity.title, term));
840 }
841 let mut best: Option<(f32, String)> = None;
842 for section_def in &schema.sections {
843 if section_def.search_weight == 0.0 {
844 continue;
845 }
846 if let Some(content) = entity.sections.get(section_def.key.as_str())
847 && content.to_lowercase().contains(&lower_term)
848 {
849 let snippet = build_snippet(content, term);
850 let pick = match &best {
851 Some((w, _)) if *w >= section_def.search_weight => continue,
852 _ => (section_def.search_weight, snippet),
853 };
854 best = Some(pick);
855 }
856 }
857 best.map(|(_, s)| s)
858}
859
860pub(crate) fn build_snippet(content: &str, query: &str) -> String {
862 let lower = content.to_lowercase();
863 let lower_query = query.to_lowercase();
864 let pos = match lower.find(&lower_query) {
865 Some(p) => p,
866 None => return content.chars().take(100).collect(),
867 };
868
869 let context = 50;
870 let start = content[..pos]
871 .char_indices()
872 .rev()
873 .nth(context)
874 .map(|(i, _)| i)
875 .unwrap_or(0);
876 let end_of_match = pos + query.len();
877 let end = content[end_of_match..]
878 .char_indices()
879 .nth(context)
880 .map(|(i, _)| end_of_match + i)
881 .unwrap_or(content.len());
882
883 let prefix = if start > 0 { "..." } else { "" };
884 let suffix = if end < content.len() { "..." } else { "" };
885 let before = &content[start..pos];
886 let matched = &content[pos..end_of_match];
887 let after = &content[end_of_match..end];
888
889 format!("{prefix}{before}**{matched}**{after}{suffix}")
890}
891
892fn apply_equality_filters(
897 entity: &crate::entity::Entity,
898 filters: &HashMap<String, String>,
899 schema: &TypeDefinition,
900 scope_mem: Option<&str>,
901 mem_schemas: &HashMap<String, Arc<Schema>>,
902) -> bool {
903 for (key, filter_value) in filters {
925 let Some(field_def) = schema.metadata_field(key) else {
926 if classify_filter_field(key, scope_mem, mem_schemas, false)
927 == FieldFilterability::Filterable
928 {
929 return false;
930 }
931 continue;
932 };
933 if !matches!(
934 field_def.filterable,
935 Filterable::Equality | Filterable::Range
936 ) {
937 continue;
939 }
940 let is_csv = field_def.serialization == Serialization::CsvArray;
941
942 match entity.metadata.get(key) {
943 Some(val) => {
944 let val_str = val.to_frontmatter_string();
945 if is_csv {
946 let items: Vec<&str> = val_str
947 .split(',')
948 .map(|s| s.trim())
949 .filter(|s| !s.is_empty())
950 .collect();
951 if !items.iter().any(|item| *item == filter_value) {
952 return false;
953 }
954 } else if val_str != *filter_value {
955 return false;
956 }
957 }
958 None => return false,
959 }
960 }
961 true
962}
963
964#[derive(PartialEq, Eq, Clone, Copy)]
970enum FieldFilterability {
971 Unknown,
973 DeclaredNotFilterable,
976 Filterable,
979}
980
981fn classify_filter_field(
994 key: &str,
995 scope_mem: Option<&str>,
996 mem_schemas: &HashMap<String, Arc<Schema>>,
997 range: bool,
998) -> FieldFilterability {
999 let counts = |f: Filterable| {
1000 if range {
1001 f == Filterable::Range
1002 } else {
1003 matches!(f, Filterable::Equality | Filterable::Range)
1004 }
1005 };
1006 let mut declared = false;
1007 let mut filterable = false;
1008 let mut scan = |schema: &Schema| {
1009 for t in schema.types.values() {
1010 if let Some(fd) = t.metadata_field(key) {
1011 declared = true;
1012 if counts(fd.filterable) {
1013 filterable = true;
1014 }
1015 }
1016 }
1017 };
1018 match scope_mem {
1019 Some(v) => {
1020 if let Some(s) = mem_schemas.get(v) {
1021 scan(s);
1022 }
1023 }
1024 None => {
1025 for s in mem_schemas.values() {
1026 scan(s);
1027 }
1028 }
1029 }
1030 if filterable {
1031 FieldFilterability::Filterable
1032 } else if declared {
1033 FieldFilterability::DeclaredNotFilterable
1034 } else {
1035 FieldFilterability::Unknown
1036 }
1037}
1038
1039fn apply_range_filters(
1040 entity: &crate::entity::Entity,
1041 filters: &HashMap<String, String>,
1042 schema: &TypeDefinition,
1043 scope_mem: Option<&str>,
1044 mem_schemas: &HashMap<String, Arc<Schema>>,
1045) -> bool {
1046 for (key, filter_value) in filters {
1060 let Some((field_name, op)) = parse_range_key(key) else {
1061 continue;
1062 };
1063 let Some(field_def) = schema.metadata_field(field_name) else {
1064 if classify_filter_field(field_name, scope_mem, mem_schemas, true)
1065 == FieldFilterability::Filterable
1066 {
1067 return false;
1068 }
1069 continue;
1070 };
1071 if field_def.filterable != Filterable::Range {
1072 continue;
1074 }
1075
1076 let Some(val) = entity.metadata.get(field_name) else {
1077 return false;
1078 };
1079 let matched = match op {
1080 RangeOp::Min => compare_numeric(val, filter_value, |ev, fv| ev >= fv),
1081 RangeOp::Max => compare_numeric(val, filter_value, |ev, fv| ev <= fv),
1082 RangeOp::Before => val.to_frontmatter_string() <= *filter_value,
1083 RangeOp::After => val.to_frontmatter_string() >= *filter_value,
1084 };
1085 if !matched {
1086 return false;
1087 }
1088 }
1089 true
1090}
1091
1092#[derive(Copy, Clone)]
1093enum RangeOp {
1094 Min,
1095 Max,
1096 Before,
1097 After,
1098}
1099
1100fn parse_range_key(key: &str) -> Option<(&str, RangeOp)> {
1101 if let Some(field) = key.strip_prefix("min_") {
1102 Some((field, RangeOp::Min))
1103 } else if let Some(field) = key.strip_prefix("max_") {
1104 Some((field, RangeOp::Max))
1105 } else if let Some(field) = key.strip_suffix("_before") {
1106 Some((field, RangeOp::Before))
1107 } else {
1108 key.strip_suffix("_after")
1109 .map(|field| (field, RangeOp::After))
1110 }
1111}
1112
1113fn collect_stub_type_exclusion_warning(scope: &SearchScope, warnings: &mut Vec<WarningHint>) {
1120 if scope.stub == Some(true)
1121 && let Some(entity_type) = scope.entity_type.as_deref()
1122 {
1123 warnings.push(WarningHint::StubFilterExcludesAll {
1124 entity_type: entity_type.to_string(),
1125 });
1126 }
1127}
1128
1129fn collect_equality_filter_warnings(
1130 filters: &HashMap<String, String>,
1131 schema: &TypeDefinition,
1132 scoped_type: Option<&str>,
1133 scope_mem: Option<&str>,
1134 mem_schemas: &HashMap<String, Arc<Schema>>,
1135 warnings: &mut Vec<WarningHint>,
1136) {
1137 for (key, value) in filters {
1138 match schema.metadata_field(key) {
1139 None => {
1140 match classify_filter_field(key, scope_mem, mem_schemas, false) {
1149 FieldFilterability::DeclaredNotFilterable => {
1150 warnings.push(WarningHint::FieldNotFilterable { field: key.clone() });
1151 }
1152 _ => {
1153 let others = find_filter_declaring_types(key, scope_mem, mem_schemas);
1154 warnings.push(WarningHint::UnknownFilterKey {
1155 key: key.clone(),
1156 scoped_type: scoped_type.map(|s| s.to_string()),
1157 declared_on_other_types: others,
1158 });
1159 }
1160 }
1161 }
1162 Some(field_def) if field_def.filterable == Filterable::None => {
1163 warnings.push(WarningHint::FieldNotFilterable { field: key.clone() });
1164 }
1165 Some(field_def) => {
1166 if field_def.serialization == Serialization::CsvArray && value.contains(',') {
1172 warnings.push(WarningHint::FilterValueMultiMember {
1173 key: key.clone(),
1174 value: value.clone(),
1175 });
1176 }
1177 if let Some(allowed) = field_def.enum_values.as_ref() {
1182 let members: Vec<&str> = if field_def.serialization == Serialization::CsvArray {
1183 value.split(',').map(str::trim).collect()
1184 } else {
1185 vec![value.as_str()]
1186 };
1187 for member in members {
1188 if !member.is_empty() && !allowed.iter().any(|a| a == member) {
1189 warnings.push(WarningHint::FilterValueNotInEnum {
1190 key: key.clone(),
1191 value: member.to_string(),
1192 allowed: allowed.clone(),
1193 });
1194 }
1195 }
1196 }
1197 }
1198 }
1199 }
1200}
1201
1202fn collect_range_filter_warnings(
1203 filters: &HashMap<String, String>,
1204 schema: &TypeDefinition,
1205 scoped_type: Option<&str>,
1206 scope_mem: Option<&str>,
1207 mem_schemas: &HashMap<String, Arc<Schema>>,
1208 warnings: &mut Vec<WarningHint>,
1209) {
1210 for key in filters.keys() {
1211 let Some((field_name, _)) = parse_range_key(key) else {
1212 warnings.push(WarningHint::RangeFilterKeyMalformed { key: key.clone() });
1213 continue;
1214 };
1215 match schema.metadata_field(field_name) {
1216 None => {
1217 match classify_filter_field(field_name, scope_mem, mem_schemas, true) {
1223 FieldFilterability::DeclaredNotFilterable => {
1224 warnings.push(WarningHint::FieldNotRangeFilterable {
1225 field: field_name.to_string(),
1226 });
1227 }
1228 _ => {
1229 let others =
1230 find_filter_declaring_types(field_name, scope_mem, mem_schemas);
1231 warnings.push(WarningHint::UnknownRangeFilterField {
1232 field: field_name.to_string(),
1233 key: key.clone(),
1234 scoped_type: scoped_type.map(|s| s.to_string()),
1235 declared_on_other_types: others,
1236 });
1237 }
1238 }
1239 }
1240 Some(field_def) if field_def.filterable != Filterable::Range => {
1241 warnings.push(WarningHint::FieldNotRangeFilterable {
1242 field: field_name.to_string(),
1243 });
1244 }
1245 Some(_) => {}
1246 }
1247 }
1248}
1249
1250fn resolve_type(
1257 entity_type: &str,
1258 preferred_mem: Option<&str>,
1259 mem_schemas: &HashMap<String, Arc<Schema>>,
1260) -> Option<Arc<TypeDefinition>> {
1261 if let Some(v) = preferred_mem
1262 && let Some(s) = mem_schemas.get(v)
1263 && let Some(t) = s.get_type(entity_type)
1264 {
1265 return Some(t);
1266 }
1267 for s in mem_schemas.values() {
1268 if let Some(t) = s.get_type(entity_type) {
1269 return Some(t);
1270 }
1271 }
1272 type_by_name(entity_type)
1273}
1274
1275fn find_filter_declaring_types(
1288 key: &str,
1289 scope_mem: Option<&str>,
1290 mem_schemas: &HashMap<String, Arc<Schema>>,
1291) -> Vec<String> {
1292 let mut found: Vec<String> = Vec::new();
1293 let mut scan = |schema: &Schema| {
1294 for t in schema.types.values() {
1295 if t.metadata_field(key).is_some() && !found.contains(&t.name) {
1296 found.push(t.name.clone());
1297 }
1298 }
1299 };
1300 match scope_mem {
1301 Some(v) => {
1302 if let Some(s) = mem_schemas.get(v) {
1303 scan(s);
1304 }
1305 }
1306 None => {
1307 for s in mem_schemas.values() {
1308 scan(s);
1309 }
1310 }
1311 }
1312 found.sort();
1313 found
1314}
1315
1316fn compare_numeric(
1317 val: &crate::entity::MetadataValue,
1318 filter_str: &str,
1319 cmp: impl Fn(f64, f64) -> bool,
1320) -> bool {
1321 let entity_num = match val {
1322 crate::entity::MetadataValue::Integer(n) => *n as f64,
1323 crate::entity::MetadataValue::Float(f) => *f,
1324 crate::entity::MetadataValue::String(s) => match s.parse::<f64>() {
1325 Ok(n) => n,
1326 Err(_) => return false,
1327 },
1328 _ => return false,
1329 };
1330 let filter_num = match filter_str.parse::<f64>() {
1331 Ok(n) => n,
1332 Err(_) => return false,
1333 };
1334 cmp(entity_num, filter_num)
1335}
1336
1337#[cfg(test)]
1338mod tests {
1339 use super::*;
1340 use crate::entity::{Entity, EntityId, MetadataValue};
1341 use crate::search_index::MemIndex;
1342 use crate::store::Store;
1343 use indexmap::IndexMap;
1344 use memstead_schema::{Schema, type_by_name};
1345
1346 fn make_entity(name: &str, mem: &str) -> Entity {
1347 let mut metadata = IndexMap::new();
1348 metadata.insert("level".into(), MetadataValue::String("M0".into()));
1349 metadata.insert("type".into(), MetadataValue::String("spec".into()));
1350 metadata.insert("tags".into(), MetadataValue::String("backend, api".into()));
1351
1352 let mut sections = IndexMap::new();
1353 sections.insert("identity".into(), format!("Identity of {name}."));
1354 sections.insert("purpose".into(), format!("Purpose of {name}."));
1355
1356 Entity {
1357 id: EntityId::new(mem, name),
1358 title: name.to_string(),
1359 entity_type: "spec".into(),
1360 mem: mem.into(),
1361 file_path: format!("{name}.md"),
1362 metadata,
1363 sections,
1364 relationships: Vec::new(),
1365 content_hash: "abc123".into(),
1366 stub: false,
1367 stub_kind: None,
1368 heading_spans: std::collections::HashMap::new(),
1369 raw_section_headings: Vec::new(),
1370 }
1371 }
1372
1373 fn build_test_indexes(
1376 store: &Store,
1377 ) -> (HashMap<String, MemIndex>, HashMap<String, Arc<Schema>>) {
1378 let schema = Schema::builtin_default();
1379 let mut indexes = HashMap::new();
1380 let mut schemas = HashMap::new();
1381 let mems: HashSet<String> = store
1382 .all_entities()
1383 .filter(|e| !e.stub)
1384 .map(|e| e.mem.clone())
1385 .collect();
1386 for mem in mems {
1387 let mut idx = MemIndex::build_in_ram(mem.clone(), Some(&schema)).unwrap();
1388 for e in store.all_entities().filter(|e| e.mem == mem) {
1389 idx.index_entity(e).unwrap();
1390 }
1391 idx.commit().unwrap();
1392 indexes.insert(mem.clone(), idx);
1393 schemas.insert(mem, schema.clone());
1394 }
1395 (indexes, schemas)
1396 }
1397
1398 fn run_search(store: &Store, scope: &SearchScope) -> SearchResult {
1399 let (indexes, schemas) = build_test_indexes(store);
1400 let schema = type_by_name("spec").unwrap();
1401 search(store, scope, &schema, &indexes, &schemas)
1402 }
1403
1404 #[test]
1410 fn search_direction_narrows_related_to_and_expansion() {
1411 let mut store = Store::new();
1413 for n in ["x", "seed", "y", "z"] {
1414 let e = make_entity(n, "specs");
1415 store.upsert(e.id.clone(), e);
1416 }
1417 let id = |n: &str| EntityId(format!("specs--{n}"));
1418 let mut edge = |f: &str, t: &str| {
1419 store.add_edge(
1420 id(f),
1421 crate::store::Edge {
1422 rel_type: "USES".into(),
1423 target: id(t),
1424 source: crate::store::EdgeSource::Explicit,
1425 },
1426 )
1427 };
1428 edge("x", "seed");
1429 edge("seed", "y");
1430 edge("y", "z");
1431
1432 let titles = |r: &SearchResult| {
1433 let mut v: Vec<String> = r.hits.iter().map(|h| h.title.clone()).collect();
1434 v.sort();
1435 v
1436 };
1437
1438 let base = SearchScope {
1440 related_to: Some(id("seed")),
1441 depth: Some(5),
1442 ..Default::default()
1443 };
1444 assert_eq!(titles(&run_search(&store, &base)), ["seed", "x", "y", "z"]);
1445 let out_scope = SearchScope {
1446 direction: crate::graph::query::TraversalDirection::Out,
1447 ..base.clone()
1448 };
1449 assert_eq!(
1450 titles(&run_search(&store, &out_scope)),
1451 ["seed", "y", "z"],
1452 "out = transitive descendants only, at every hop"
1453 );
1454 let in_scope = SearchScope {
1455 direction: crate::graph::query::TraversalDirection::In,
1456 ..base
1457 };
1458 assert_eq!(
1459 titles(&run_search(&store, &in_scope)),
1460 ["seed", "x"],
1461 "in = transitive ancestors only"
1462 );
1463
1464 let expand_base = SearchScope {
1467 query: Some(Query {
1468 any: vec!["seed".into()],
1469 ..Default::default()
1470 }),
1471 expand_via: Some(vec!["USES".into()]),
1472 expand_depth: Some(3),
1473 ..Default::default()
1474 };
1475 let out_result = run_search(
1476 &store,
1477 &SearchScope {
1478 direction: crate::graph::query::TraversalDirection::Out,
1479 ..expand_base.clone()
1480 },
1481 );
1482 let expanded: Vec<(String, String)> = out_result
1483 .hits
1484 .iter()
1485 .filter_map(|h| {
1486 h.expansion.as_ref().map(|e| {
1487 (
1488 h.title.clone(),
1489 serde_json::to_value(e.via_direction)
1490 .unwrap()
1491 .as_str()
1492 .unwrap()
1493 .to_string(),
1494 )
1495 })
1496 })
1497 .collect();
1498 let mut expanded_sorted = expanded.clone();
1499 expanded_sorted.sort();
1500 assert_eq!(
1501 expanded_sorted,
1502 [
1503 ("y".to_string(), "out".to_string()),
1504 ("z".to_string(), "out".to_string())
1505 ],
1506 "out-expansion reaches descendants only and reports the direction"
1507 );
1508 let in_result = run_search(
1509 &store,
1510 &SearchScope {
1511 direction: crate::graph::query::TraversalDirection::In,
1512 ..expand_base
1513 },
1514 );
1515 let expanded_in: Vec<String> = in_result
1516 .hits
1517 .iter()
1518 .filter(|h| h.expansion.is_some())
1519 .map(|h| h.title.clone())
1520 .collect();
1521 assert_eq!(expanded_in, ["x"], "in-expansion reaches ancestors only");
1522 }
1523
1524 #[test]
1532 fn search_finds_metadata_values_and_keys() {
1533 let mut store = Store::new();
1534 let mut carrier = make_entity("carrier", "specs");
1537 carrier.metadata.insert(
1538 "aktenzeichen".into(),
1539 MetadataValue::String("20/54/033".into()),
1540 );
1541 store.upsert(carrier.id.clone(), carrier);
1542 let mut prose = make_entity("prose", "specs");
1544 prose.sections.insert(
1545 "identity".into(),
1546 "shared-token lives in prose here.".into(),
1547 );
1548 store.upsert(prose.id.clone(), prose);
1549 let mut meta_only = make_entity("meta-only", "specs");
1551 meta_only.metadata.insert(
1552 "note".into(),
1553 MetadataValue::String("shared-token via metadata".into()),
1554 );
1555 store.upsert(meta_only.id.clone(), meta_only);
1556
1557 let q = |term: &str| SearchScope {
1558 query: Some(Query {
1559 any: vec![term.into()],
1560 ..Default::default()
1561 }),
1562 ..Default::default()
1563 };
1564
1565 let result = run_search(&store, &q("20/54/033"));
1567 assert_eq!(result.hits.len(), 1, "identifier found: {result:?}");
1568 assert_eq!(result.hits[0].title, "carrier");
1569 let matched = result.hits[0]
1571 .matched_terms
1572 .as_ref()
1573 .expect("matched_terms present");
1574 assert!(
1575 matched.values().flatten().any(|tm| tm.field == "metadata"),
1576 "metadata-only hit reports field \"metadata\": {matched:?}"
1577 );
1578
1579 let result = run_search(&store, &q("aktenzeichen"));
1581 assert_eq!(result.hits.len(), 1);
1582 assert_eq!(result.hits[0].title, "carrier");
1583
1584 let result = run_search(&store, &q("99/99/999"));
1586 assert!(result.hits.is_empty(), "{result:?}");
1587
1588 let result = run_search(&store, &q("shared-token"));
1591 let titles: Vec<&str> = result.hits.iter().map(|h| h.title.as_str()).collect();
1592 assert!(
1593 titles.contains(&"prose") && titles.contains(&"meta-only"),
1594 "{titles:?}"
1595 );
1596 let prose_pos = titles.iter().position(|t| *t == "prose").unwrap();
1597 let meta_pos = titles.iter().position(|t| *t == "meta-only").unwrap();
1598 assert!(
1599 prose_pos < meta_pos,
1600 "prose match ranks above the metadata-only match: {titles:?}"
1601 );
1602 }
1603
1604 #[test]
1610 fn search_exclude_ignores_metadata_only_tokens() {
1611 let mut store = Store::new();
1612 let mut gamma = make_entity("gamma", "specs");
1613 gamma
1614 .sections
1615 .insert("identity".into(), "graphword appears here.".into());
1616 gamma.metadata.insert(
1617 "status_note".into(),
1618 MetadataValue::String("draftword".into()),
1619 );
1620 store.upsert(gamma.id.clone(), gamma);
1621 let mut delta = make_entity("delta", "specs");
1622 delta
1623 .sections
1624 .insert("identity".into(), "graphword also here.".into());
1625 store.upsert(delta.id.clone(), delta);
1626
1627 let result = run_search(
1628 &store,
1629 &SearchScope {
1630 query: Some(Query {
1631 any: vec!["graphword".into()],
1632 not: vec!["draftword".into()],
1633 ..Default::default()
1634 }),
1635 ..Default::default()
1636 },
1637 );
1638 let mut titles: Vec<&str> = result.hits.iter().map(|h| h.title.as_str()).collect();
1639 titles.sort();
1640 assert_eq!(
1641 titles,
1642 ["delta", "gamma"],
1643 "a metadata-only token must not exclude gamma"
1644 );
1645
1646 let mut store2 = Store::new();
1648 let mut eps = make_entity("eps", "specs");
1649 eps.sections.insert(
1650 "identity".into(),
1651 "graphword and draftword in prose.".into(),
1652 );
1653 store2.upsert(eps.id.clone(), eps);
1654 let result = run_search(
1655 &store2,
1656 &SearchScope {
1657 query: Some(Query {
1658 any: vec!["graphword".into()],
1659 not: vec!["draftword".into()],
1660 ..Default::default()
1661 }),
1662 ..Default::default()
1663 },
1664 );
1665 assert!(
1666 result.hits.is_empty(),
1667 "prose exclusion unchanged: {result:?}"
1668 );
1669 }
1670
1671 #[test]
1672 fn search_by_title() {
1673 let mut store = Store::new();
1674 let e1 = make_entity("graph-engine", "specs");
1675 let e2 = make_entity("mcp-server", "specs");
1676 store.upsert(e1.id.clone(), e1);
1677 store.upsert(e2.id.clone(), e2);
1678
1679 let scope = SearchScope {
1680 query: Some(Query {
1681 any: vec!["graph".into()],
1682 ..Default::default()
1683 }),
1684 ..Default::default()
1685 };
1686
1687 let result = run_search(&store, &scope);
1688 assert_eq!(result.total, 1);
1689 assert_eq!(result.hits[0].id.name(), "graph-engine");
1690 }
1691
1692 #[test]
1693 fn search_by_section_content() {
1694 let mut store = Store::new();
1695 let mut e = make_entity("test-entity", "specs");
1696 e.sections.insert(
1697 "identity".into(),
1698 "Uses the graph database for queries.".into(),
1699 );
1700 store.upsert(e.id.clone(), e);
1701
1702 let scope = SearchScope {
1703 query: Some(Query {
1704 phrase: Some("graph database".into()),
1705 ..Default::default()
1706 }),
1707 ..Default::default()
1708 };
1709
1710 let result = run_search(&store, &scope);
1711 assert_eq!(result.total, 1);
1712 }
1713
1714 #[test]
1715 fn search_with_mem_filter() {
1716 let mut store = Store::new();
1717 store.upsert(EntityId::new("specs", "a"), make_entity("a", "specs"));
1718 store.upsert(EntityId::new("memos", "b"), make_entity("b", "memos"));
1719
1720 let scope = SearchScope {
1721 mem: Some("specs".into()),
1722 ..Default::default()
1723 };
1724
1725 let result = run_search(&store, &scope);
1726 assert_eq!(result.total, 1);
1727 assert_eq!(result.hits[0].mem, "specs");
1728 }
1729
1730 #[test]
1731 fn search_with_equality_filter() {
1732 let mut store = Store::new();
1733 let mut e1 = make_entity("m0-entity", "specs");
1734 e1.metadata
1735 .insert("level".into(), MetadataValue::String("M0".into()));
1736 let mut e2 = make_entity("m1-entity", "specs");
1737 e2.metadata
1738 .insert("level".into(), MetadataValue::String("M1".into()));
1739 store.upsert(e1.id.clone(), e1);
1740 store.upsert(e2.id.clone(), e2);
1741
1742 let scope = SearchScope {
1743 filters: HashMap::from([("level".into(), "M0".into())]),
1744 ..Default::default()
1745 };
1746
1747 let result = run_search(&store, &scope);
1748 assert_eq!(result.total, 1);
1749 assert_eq!(result.hits[0].id.name(), "m0-entity");
1750 assert!(result.warnings.is_empty(), "no warnings for valid filter");
1751 }
1752
1753 #[test]
1754 fn search_unknown_filter_key_warns_and_keeps_hits() {
1755 let mut store = Store::new();
1756 let e1 = make_entity("m0-entity", "specs");
1757 let e2 = make_entity("m1-entity", "specs");
1758 store.upsert(e1.id.clone(), e1);
1759 store.upsert(e2.id.clone(), e2);
1760
1761 let scope = SearchScope {
1762 filters: HashMap::from([("stauts".into(), "active".into())]),
1763 ..Default::default()
1764 };
1765
1766 let result = run_search(&store, &scope);
1767 assert_eq!(
1768 result.total, 2,
1769 "unknown filter should be skipped, not reject all entities"
1770 );
1771 assert_eq!(result.warnings.len(), 1);
1772 assert!(
1773 result.warnings[0].to_string().contains("stauts")
1774 && result.warnings[0].to_string().contains("unknown"),
1775 "warning mentions unknown key: {:?}",
1776 result.warnings
1777 );
1778 }
1779
1780 #[test]
1788 fn search_unknown_filter_key_names_scoped_entity_type() {
1789 let mut store = Store::new();
1790 let e = make_entity("only", "specs");
1791 store.upsert(e.id.clone(), e);
1792
1793 let scope = SearchScope {
1794 entity_type: Some("contract".into()),
1795 filters: HashMap::from([("confidence".into(), "verified".into())]),
1796 ..Default::default()
1797 };
1798
1799 let result = run_search(&store, &scope);
1800 assert_eq!(result.warnings.len(), 1, "{:?}", result.warnings);
1801 let warning = result.warnings[0].to_string();
1802 assert!(
1803 warning.contains("'contract'"),
1804 "warning must name the agent's scoped type: {warning}",
1805 );
1806 assert!(
1807 !warning.contains("'spec'"),
1808 "warning must not name an unrelated default type: {warning}",
1809 );
1810 }
1811
1812 #[test]
1818 fn search_unknown_filter_key_omits_type_when_no_scope() {
1819 let mut store = Store::new();
1820 let e = make_entity("only", "specs");
1821 store.upsert(e.id.clone(), e);
1822
1823 let scope = SearchScope {
1824 filters: HashMap::from([("confidence".into(), "verified".into())]),
1825 ..Default::default()
1826 };
1827
1828 let result = run_search(&store, &scope);
1829 assert_eq!(result.warnings.len(), 1, "{:?}", result.warnings);
1830 let warning = result.warnings[0].to_string();
1831 assert!(
1832 warning.contains("confidence"),
1833 "warning must name the unknown key: {warning}",
1834 );
1835 assert!(
1836 !warning.contains("for type"),
1837 "warning must omit the type-name clause when caller didn't scope: {warning}",
1838 );
1839 }
1840
1841 #[test]
1844 fn search_unknown_range_filter_names_scoped_entity_type() {
1845 let mut store = Store::new();
1846 let e = make_entity("only", "specs");
1847 store.upsert(e.id.clone(), e);
1848
1849 let scope = SearchScope {
1850 entity_type: Some("contract".into()),
1851 range_filters: HashMap::from([("min_priority".into(), "0".into())]),
1852 ..Default::default()
1853 };
1854
1855 let result = run_search(&store, &scope);
1856 assert_eq!(result.warnings.len(), 1, "{:?}", result.warnings);
1857 let warning = result.warnings[0].to_string();
1858 assert!(
1859 warning.contains("'contract'"),
1860 "range warning must name the agent's scoped type: {warning}",
1861 );
1862 assert!(
1863 !warning.contains("'spec'"),
1864 "range warning must not name an unrelated default type: {warning}",
1865 );
1866 }
1867
1868 #[test]
1873 fn search_equality_filter_excludes_types_without_declared_field() {
1874 let mut store = Store::new();
1875 let mut spec_match = make_entity("level-m0", "specs");
1877 spec_match
1878 .metadata
1879 .insert("level".into(), MetadataValue::String("M0".into()));
1880 let mut spec_other = make_entity("level-m1", "specs");
1883 spec_other
1884 .metadata
1885 .insert("level".into(), MetadataValue::String("M1".into()));
1886 let mut memo = make_entity("memo-no-level", "specs");
1889 memo.entity_type = "memo".into();
1890 store.upsert(spec_match.id.clone(), spec_match.clone());
1891 store.upsert(spec_other.id.clone(), spec_other);
1892 store.upsert(memo.id.clone(), memo);
1893
1894 let scope = SearchScope {
1895 filters: HashMap::from([("level".into(), "M0".into())]),
1896 ..Default::default()
1897 };
1898
1899 let result = run_search(&store, &scope);
1900 assert_eq!(
1901 result.total,
1902 1,
1903 "strict filter must keep only the matching spec entity; got {:?}",
1904 result
1905 .hits
1906 .iter()
1907 .map(|h| h.id.to_string())
1908 .collect::<Vec<_>>(),
1909 );
1910 assert_eq!(result.hits[0].id, spec_match.id);
1911 }
1912
1913 #[test]
1919 fn search_workspace_wide_unknown_filter_passes_through() {
1920 let mut store = Store::new();
1921 let e = make_entity("only", "specs");
1922 store.upsert(e.id.clone(), e);
1923
1924 let scope = SearchScope {
1925 filters: HashMap::from([("definitely-not-a-real-field".into(), "x".into())]),
1926 ..Default::default()
1927 };
1928
1929 let result = run_search(&store, &scope);
1930 assert_eq!(
1931 result.total,
1932 1,
1933 "unknown-anywhere filter key must not collapse the result set; got {:?}",
1934 result
1935 .hits
1936 .iter()
1937 .map(|h| h.id.to_string())
1938 .collect::<Vec<_>>(),
1939 );
1940 assert!(
1941 result
1942 .warnings
1943 .iter()
1944 .any(|w| w.to_string().contains("definitely-not-a-real-field")),
1945 "unknown-key warning must still surface: {:?}",
1946 result.warnings,
1947 );
1948 }
1949
1950 #[test]
1951 fn search_non_filterable_field_ignored_returns_unfiltered() {
1952 let mut store = Store::new();
1961 store.upsert(EntityId::new("specs", "a"), make_entity("a", "specs"));
1962 store.upsert(EntityId::new("specs", "b"), make_entity("b", "specs"));
1963
1964 let scope = SearchScope {
1965 filters: HashMap::from([("type".into(), "totally-different".into())]),
1966 ..Default::default()
1967 };
1968
1969 let result = run_search(&store, &scope);
1970 assert_eq!(
1971 result.total, 2,
1972 "non-filterable field filter must be ignored — result equals the unfiltered search, not emptied",
1973 );
1974 assert_eq!(result.warnings.len(), 1);
1975 assert_eq!(
1976 result.warnings[0].code(),
1977 "FIELD_NOT_FILTERABLE",
1978 "non-filterable field must still warn so the agent knows the filter had no effect: {:?}",
1979 result.warnings,
1980 );
1981 }
1982
1983 #[test]
1990 fn search_scoped_non_filterable_field_matches_unfiltered() {
1991 let mut store = Store::new();
1992 store.upsert(EntityId::new("specs", "a"), make_entity("a", "specs"));
1993 store.upsert(EntityId::new("specs", "b"), make_entity("b", "specs"));
1994
1995 let baseline = run_search(
1996 &store,
1997 &SearchScope {
1998 entity_type: Some("spec".into()),
1999 ..Default::default()
2000 },
2001 );
2002 let filtered = run_search(
2003 &store,
2004 &SearchScope {
2005 entity_type: Some("spec".into()),
2006 filters: HashMap::from([("type".into(), "irrelevant".into())]),
2007 ..Default::default()
2008 },
2009 );
2010 assert_eq!(
2011 filtered.total, baseline.total,
2012 "non-filterable filter must leave the scoped result set identical to the unfiltered search",
2013 );
2014 assert_eq!(filtered.total, 2);
2015 assert!(
2016 filtered
2017 .warnings
2018 .iter()
2019 .any(|w| w.code() == "FIELD_NOT_FILTERABLE"),
2020 "scoped non-filterable filter must warn FIELD_NOT_FILTERABLE: {:?}",
2021 filtered.warnings,
2022 );
2023 }
2024
2025 #[test]
2032 fn search_unscoped_filterable_field_narrows_with_distinct_code() {
2033 let mut store = Store::new();
2034 let mut c_match = make_entity("c-emerging", "specs");
2036 c_match.entity_type = "concept".into();
2037 c_match
2038 .metadata
2039 .insert("maturity".into(), MetadataValue::String("emerging".into()));
2040 let mut c_other = make_entity("c-stable", "specs");
2041 c_other.entity_type = "concept".into();
2042 c_other
2043 .metadata
2044 .insert("maturity".into(), MetadataValue::String("stable".into()));
2045 let spec = make_entity("s", "specs");
2047 store.upsert(c_match.id.clone(), c_match.clone());
2048 store.upsert(c_other.id.clone(), c_other);
2049 store.upsert(spec.id.clone(), spec);
2050
2051 let result = run_search(
2052 &store,
2053 &SearchScope {
2054 filters: HashMap::from([("maturity".into(), "emerging".into())]),
2055 ..Default::default()
2056 },
2057 );
2058 assert_eq!(
2059 result.total, 1,
2060 "only the matching concept survives the narrowing"
2061 );
2062 assert_eq!(result.hits[0].id, c_match.id);
2063 assert_eq!(result.warnings.len(), 1, "{:?}", result.warnings);
2064 assert_eq!(
2065 result.warnings[0].code(),
2066 "FILTER_TYPE_SCOPED",
2067 "applied-with-narrowing must carry a code distinct from UNKNOWN_FILTER_KEY: {:?}",
2068 result.warnings,
2069 );
2070 }
2071
2072 #[test]
2082 fn search_unscoped_non_filterable_field_ignored_not_narrowed() {
2083 let mut store = Store::new();
2084 let mut assertion = make_entity("a-claim", "specs");
2085 assertion.entity_type = "assertion".into();
2086 assertion.metadata.insert(
2087 "source_quality".into(),
2088 MetadataValue::String("experimental".into()),
2089 );
2090 store.upsert(assertion.id.clone(), assertion);
2091 store.upsert(EntityId::new("specs", "s1"), make_entity("s1", "specs"));
2092 store.upsert(EntityId::new("specs", "s2"), make_entity("s2", "specs"));
2093
2094 let baseline = run_search(&store, &SearchScope::default());
2095
2096 for value in ["WRONG-VALUE", "experimental"] {
2101 let result = run_search(
2102 &store,
2103 &SearchScope {
2104 filters: HashMap::from([("source_quality".into(), value.into())]),
2105 ..Default::default()
2106 },
2107 );
2108 assert_eq!(
2109 result.total, baseline.total,
2110 "non-filterable filter (value={value}) must return the unfiltered set, not narrow to the declaring type",
2111 );
2112 assert_eq!(result.total, 3);
2113 assert_eq!(result.warnings.len(), 1, "{:?}", result.warnings);
2114 assert_eq!(
2115 result.warnings[0].code(),
2116 "FIELD_NOT_FILTERABLE",
2117 "unscoped non-filterable field must report FIELD_NOT_FILTERABLE, not FILTER_TYPE_SCOPED: {:?}",
2118 result.warnings,
2119 );
2120 }
2121 }
2122
2123 #[test]
2131 fn search_unscoped_non_range_filterable_field_not_dropped() {
2132 let mut store = Store::new();
2133 store.upsert(EntityId::new("specs", "s1"), make_entity("s1", "specs"));
2134 let mut memo = make_entity("m1", "specs");
2135 memo.entity_type = "memo".into();
2136 memo.metadata.shift_remove("level");
2137 store.upsert(memo.id.clone(), memo);
2138
2139 let result = run_search(
2140 &store,
2141 &SearchScope {
2142 range_filters: HashMap::from([("min_level".into(), "M0".into())]),
2143 ..Default::default()
2144 },
2145 );
2146 assert_eq!(
2147 result.total,
2148 2,
2149 "non-range-filterable range filter must not drop the memo lacking the field; got {:?}",
2150 result
2151 .hits
2152 .iter()
2153 .map(|h| h.id.to_string())
2154 .collect::<Vec<_>>(),
2155 );
2156 assert!(
2157 result
2158 .warnings
2159 .iter()
2160 .any(|w| w.code() == "FIELD_NOT_RANGE_FILTERABLE"),
2161 "must warn FIELD_NOT_RANGE_FILTERABLE: {:?}",
2162 result.warnings,
2163 );
2164 }
2165
2166 #[test]
2174 fn search_unscoped_range_on_equality_only_other_type_field() {
2175 let mut store = Store::new();
2176 let mut concept = make_entity("c1", "specs");
2177 concept.entity_type = "concept".into();
2178 concept
2179 .metadata
2180 .insert("maturity".into(), MetadataValue::String("stable".into()));
2181 store.upsert(concept.id.clone(), concept);
2182 store.upsert(EntityId::new("specs", "s1"), make_entity("s1", "specs"));
2183
2184 let result = run_search(
2185 &store,
2186 &SearchScope {
2187 range_filters: HashMap::from([("min_maturity".into(), "stable".into())]),
2188 ..Default::default()
2189 },
2190 );
2191 assert_eq!(
2192 result.total, 2,
2193 "non-range-filterable field range filter must leave the set unfiltered",
2194 );
2195 assert!(
2196 result
2197 .warnings
2198 .iter()
2199 .any(|w| w.code() == "FIELD_NOT_RANGE_FILTERABLE"),
2200 "must warn FIELD_NOT_RANGE_FILTERABLE (not RANGE_FILTER_TYPE_SCOPED): {:?}",
2201 result.warnings,
2202 );
2203 }
2204
2205 #[test]
2210 fn search_truly_unknown_key_ignored_with_unknown_code() {
2211 let mut store = Store::new();
2212 store.upsert(EntityId::new("specs", "a"), make_entity("a", "specs"));
2213 store.upsert(EntityId::new("specs", "b"), make_entity("b", "specs"));
2214
2215 let result = run_search(
2216 &store,
2217 &SearchScope {
2218 filters: HashMap::from([("boguskey".into(), "x".into())]),
2219 ..Default::default()
2220 },
2221 );
2222 assert_eq!(
2223 result.total, 2,
2224 "truly-unknown key must leave the result set unfiltered"
2225 );
2226 assert_eq!(result.warnings.len(), 1);
2227 assert_eq!(
2228 result.warnings[0].code(),
2229 "UNKNOWN_FILTER_KEY",
2230 "a key no schema declares must carry UNKNOWN_FILTER_KEY: {:?}",
2231 result.warnings,
2232 );
2233 }
2234
2235 #[test]
2241 fn search_non_range_filterable_field_ignored_returns_unfiltered() {
2242 let mut store = Store::new();
2243 store.upsert(EntityId::new("specs", "a"), make_entity("a", "specs"));
2244 store.upsert(EntityId::new("specs", "b"), make_entity("b", "specs"));
2245
2246 let result = run_search(
2247 &store,
2248 &SearchScope {
2249 entity_type: Some("spec".into()),
2250 range_filters: HashMap::from([("min_level".into(), "M0".into())]),
2251 ..Default::default()
2252 },
2253 );
2254 assert_eq!(
2255 result.total, 2,
2256 "non-range-filterable field range filter must be ignored, not empty the set",
2257 );
2258 assert!(
2259 result
2260 .warnings
2261 .iter()
2262 .any(|w| w.code() == "FIELD_NOT_RANGE_FILTERABLE"),
2263 "must warn FIELD_NOT_RANGE_FILTERABLE: {:?}",
2264 result.warnings,
2265 );
2266 }
2267
2268 #[test]
2269 fn search_range_filter_unknown_field_warns() {
2270 let mut store = Store::new();
2271 let e = make_entity("only", "specs");
2272 store.upsert(e.id.clone(), e);
2273
2274 let scope = SearchScope {
2275 range_filters: HashMap::from([("min_nonexistent".into(), "0".into())]),
2276 ..Default::default()
2277 };
2278
2279 let result = run_search(&store, &scope);
2280 assert_eq!(
2281 result.total, 1,
2282 "unknown range field should be skipped, not reject all entities"
2283 );
2284 assert_eq!(result.warnings.len(), 1);
2285 assert!(
2286 result.warnings[0].to_string().contains("nonexistent"),
2287 "warning mentions unknown range field: {:?}",
2288 result.warnings
2289 );
2290 }
2291
2292 #[test]
2293 fn list_unknown_filter_key_warns() {
2294 let mut store = Store::new();
2295 store.upsert(EntityId::new("specs", "a"), make_entity("a", "specs"));
2296
2297 let schema = type_by_name("spec").unwrap();
2298 let scope = SearchScope {
2299 filters: HashMap::from([("nope".into(), "x".into())]),
2300 ..Default::default()
2301 };
2302
2303 let schemas: HashMap<String, Arc<Schema>> = HashMap::new();
2304 let result = list(&store, &scope, &schema, &schemas);
2305 assert_eq!(result.total, 1);
2306 assert_eq!(result.warnings.len(), 1);
2307 }
2308
2309 #[test]
2310 fn token_budget_trims_overflowing_page_and_warns() {
2311 let mut store = Store::new();
2312 for i in 0..20 {
2313 let mut e = make_entity(&format!("entity-{i:02}"), "specs");
2314 e.sections.insert("identity".into(), "graph ".repeat(50));
2315 store.upsert(e.id.clone(), e);
2316 }
2317
2318 let scope = SearchScope {
2319 query: Some(Query {
2320 any: vec!["graph".into()],
2321 ..Default::default()
2322 }),
2323 token_budget: Some(20),
2326 ..Default::default()
2327 };
2328
2329 let result = run_search(&store, &scope);
2330 assert_eq!(result.total, 20, "total reflects the full match count");
2331 assert!(result.returned >= 1, "at least one hit always returns");
2332 assert!(result.returned < 20, "the page was trimmed by the budget");
2333 assert_eq!(result.hits.len(), result.returned);
2334 let trunc = result
2335 .warnings
2336 .iter()
2337 .find(|w| w.code() == "SEARCH_RESULTS_TRUNCATED")
2338 .expect("budget trim emits SEARCH_RESULTS_TRUNCATED");
2339 assert!(trunc.message().contains("budget"));
2340 }
2341
2342 #[test]
2343 fn ample_budget_returns_all_hits_without_warning() {
2344 let mut store = Store::new();
2345 for i in 0..5 {
2346 let mut e = make_entity(&format!("entity-{i}"), "specs");
2347 e.sections.insert("identity".into(), "graph".into());
2348 store.upsert(e.id.clone(), e);
2349 }
2350 let scope = SearchScope {
2351 query: Some(Query {
2352 any: vec!["graph".into()],
2353 ..Default::default()
2354 }),
2355 token_budget: Some(1_000_000),
2356 ..Default::default()
2357 };
2358 let result = run_search(&store, &scope);
2359 assert_eq!(result.returned, 5);
2360 assert!(
2361 result
2362 .warnings
2363 .iter()
2364 .all(|w| w.code() != "SEARCH_RESULTS_TRUNCATED"),
2365 "an ample budget does not trim"
2366 );
2367 }
2368
2369 #[test]
2370 fn search_hits_carry_no_section_bodies() {
2371 let mut store = Store::new();
2372 store.upsert(EntityId::new("specs", "a"), make_entity("a", "specs"));
2373 let scope = SearchScope {
2374 query: Some(Query {
2375 any: vec!["Identity".into()],
2376 ..Default::default()
2377 }),
2378 ..Default::default()
2379 };
2380 let result = run_search(&store, &scope);
2381 assert_eq!(result.total, 1);
2382 assert!(
2383 result.hits[0].sections.is_empty(),
2384 "search hits ship no section bodies — read them with memstead_entity"
2385 );
2386 assert!(result.hits[0].summary.is_some(), "summary still resolved");
2388 }
2389
2390 #[test]
2391 fn list_hits_still_carry_section_bodies() {
2392 let mut store = Store::new();
2393 store.upsert(EntityId::new("specs", "a"), make_entity("a", "specs"));
2394 let schema = type_by_name("spec").unwrap();
2395 let schemas: HashMap<String, Arc<Schema>> = HashMap::new();
2396 let result = list(&store, &SearchScope::default(), &schema, &schemas);
2397 assert_eq!(result.total, 1);
2398 assert!(
2399 !result.hits[0].sections.is_empty(),
2400 "list hits keep section bodies for human-facing roster consumers"
2401 );
2402 }
2403
2404 #[test]
2405 fn search_csv_array_filter() {
2406 let mut store = Store::new();
2407 let mut e = make_entity("tagged", "specs");
2408 e.metadata.insert(
2409 "tags".into(),
2410 MetadataValue::String("backend, api, rust".into()),
2411 );
2412 store.upsert(e.id.clone(), e);
2413
2414 let scope = SearchScope {
2415 filters: HashMap::from([("tags".into(), "api".into())]),
2416 ..Default::default()
2417 };
2418
2419 let result = run_search(&store, &scope);
2420 assert_eq!(result.total, 1);
2421 }
2422
2423 #[test]
2424 fn search_pagination() {
2425 let mut store = Store::new();
2426 for i in 0..10 {
2427 let e = make_entity(&format!("entity-{i:02}"), "specs");
2428 store.upsert(e.id.clone(), e);
2429 }
2430
2431 let scope = SearchScope {
2432 limit: Some(3),
2433 offset: Some(2),
2434 ..Default::default()
2435 };
2436
2437 let result = run_search(&store, &scope);
2438 assert_eq!(result.total, 10);
2439 assert_eq!(result.returned, 3);
2440 assert_eq!(result.offset, 2);
2441 }
2442
2443 #[test]
2444 fn list_entities() {
2445 let mut store = Store::new();
2446 store.upsert(EntityId::new("specs", "a"), make_entity("a", "specs"));
2447 store.upsert(EntityId::new("specs", "b"), make_entity("b", "specs"));
2448
2449 let schema = type_by_name("spec").unwrap();
2450 let scope = SearchScope::default();
2451
2452 let schemas: HashMap<String, Arc<Schema>> = HashMap::new();
2453 let result = list(&store, &scope, &schema, &schemas);
2454 assert_eq!(result.total, 2);
2455 assert!(result.total_tokens > 0);
2456 }
2457
2458 #[test]
2459 fn build_snippet_basic() {
2460 let content = "The graph engine processes queries efficiently.";
2461 let snippet = build_snippet(content, "engine");
2462 assert!(snippet.contains("**engine**"));
2463 }
2464
2465 #[test]
2468 fn query_any_or_semantics() {
2469 let mut store = Store::new();
2470 let mut a = make_entity("a", "specs");
2471 a.sections
2472 .insert("identity".into(), "authentication flow".into());
2473 let mut b = make_entity("b", "specs");
2474 b.sections
2475 .insert("identity".into(), "login pipeline".into());
2476 let mut c = make_entity("c", "specs");
2477 c.sections
2478 .insert("identity".into(), "unrelated subject".into());
2479 store.upsert(a.id.clone(), a);
2480 store.upsert(b.id.clone(), b);
2481 store.upsert(c.id.clone(), c);
2482
2483 let scope = SearchScope {
2484 query: Some(Query {
2485 any: vec!["authentication".into(), "login".into()],
2486 ..Default::default()
2487 }),
2488 ..Default::default()
2489 };
2490 let result = run_search(&store, &scope);
2491 let names: Vec<_> = result
2492 .hits
2493 .iter()
2494 .map(|h| h.id.name().to_string())
2495 .collect();
2496 assert_eq!(result.total, 2, "union of any terms: {names:?}");
2497 assert!(names.contains(&"a".to_string()));
2498 assert!(names.contains(&"b".to_string()));
2499 }
2500
2501 #[test]
2502 fn query_not_excludes() {
2503 let mut store = Store::new();
2504 let mut a = make_entity("a", "specs");
2505 a.sections
2506 .insert("identity".into(), "uses authentication".into());
2507 let mut b = make_entity("b", "specs");
2508 b.sections
2509 .insert("identity".into(), "uses authentication mock".into());
2510 store.upsert(a.id.clone(), a);
2511 store.upsert(b.id.clone(), b);
2512
2513 let scope = SearchScope {
2514 query: Some(Query {
2515 any: vec!["authentication".into()],
2516 not: vec!["mock".into()],
2517 ..Default::default()
2518 }),
2519 ..Default::default()
2520 };
2521 let result = run_search(&store, &scope);
2522 assert_eq!(result.total, 1);
2523 assert_eq!(result.hits[0].id.name(), "a");
2524 }
2525
2526 #[test]
2527 fn query_phrase_match() {
2528 let mut store = Store::new();
2529 let mut a = make_entity("a", "specs");
2530 a.sections.insert(
2531 "identity".into(),
2532 "the client side agent runs locally".into(),
2533 );
2534 let mut b = make_entity("b", "specs");
2535 b.sections.insert(
2536 "identity".into(),
2537 "the client invokes the side channel for the agent".into(),
2538 );
2539 store.upsert(a.id.clone(), a);
2540 store.upsert(b.id.clone(), b);
2541
2542 let scope = SearchScope {
2543 query: Some(Query {
2544 phrase: Some("client side agent".into()),
2545 ..Default::default()
2546 }),
2547 ..Default::default()
2548 };
2549 let result = run_search(&store, &scope);
2550 assert_eq!(result.total, 1);
2551 assert_eq!(result.hits[0].id.name(), "a");
2552 }
2553
2554 #[test]
2555 fn query_field_restricted() {
2556 let mut store = Store::new();
2557 let mut a = make_entity("a", "specs");
2558 a.sections.insert("identity".into(), "foo content".into());
2559 let mut b = make_entity("b", "specs");
2560 b.sections.insert("purpose".into(), "foo content".into());
2561 store.upsert(a.id.clone(), a);
2562 store.upsert(b.id.clone(), b);
2563
2564 let scope = SearchScope {
2565 query: Some(Query {
2566 any: vec!["foo".into()],
2567 field: Some("identity".into()),
2568 ..Default::default()
2569 }),
2570 ..Default::default()
2571 };
2572 let result = run_search(&store, &scope);
2573 assert_eq!(result.total, 1);
2574 assert_eq!(result.hits[0].id.name(), "a");
2575 }
2576
2577 #[test]
2578 fn query_empty_is_metadata_filter() {
2579 let mut store = Store::new();
2580 let mut memo_entity = make_entity("m", "specs");
2581 memo_entity.entity_type = "memo".into();
2582 store.upsert(memo_entity.id.clone(), memo_entity);
2583 store.upsert(EntityId::new("specs", "s1"), make_entity("s1", "specs"));
2584 store.upsert(EntityId::new("specs", "s2"), make_entity("s2", "specs"));
2585
2586 let scope = SearchScope {
2587 query: Some(Query::default()),
2588 entity_type: Some("spec".into()),
2589 ..Default::default()
2590 };
2591 let result = run_search(&store, &scope);
2592 assert_eq!(
2593 result.total, 2,
2594 "empty query ⇒ metadata filter over entity_type"
2595 );
2596 }
2597
2598 #[test]
2599 fn query_diacritic_folding() {
2600 let mut store = Store::new();
2601 let mut a = make_entity("a", "specs");
2602 a.sections.insert("identity".into(), "schöne Häuser".into());
2603 store.upsert(a.id.clone(), a);
2604
2605 let scope = SearchScope {
2606 query: Some(Query {
2607 any: vec!["hauser".into()],
2608 ..Default::default()
2609 }),
2610 ..Default::default()
2611 };
2612 let result = run_search(&store, &scope);
2613 assert_eq!(result.total, 1);
2614 }
2615
2616 #[test]
2617 fn query_spans_all_mems_when_mem_none() {
2618 let mut store = Store::new();
2619 let mut a = make_entity("a", "specs");
2620 a.sections.insert("identity".into(), "foo".into());
2621 let mut b = make_entity("b", "memos");
2622 b.sections.insert("identity".into(), "foo".into());
2623 store.upsert(a.id.clone(), a);
2624 store.upsert(b.id.clone(), b);
2625
2626 let scope = SearchScope {
2627 query: Some(Query {
2628 any: vec!["foo".into()],
2629 ..Default::default()
2630 }),
2631 ..Default::default()
2632 };
2633 let result = run_search(&store, &scope);
2634 assert_eq!(result.total, 2);
2635 }
2636
2637 #[test]
2638 fn query_targets_single_mem_when_named() {
2639 let mut store = Store::new();
2640 let mut a = make_entity("a", "specs");
2641 a.sections.insert("identity".into(), "foo".into());
2642 let mut b = make_entity("b", "memos");
2643 b.sections.insert("identity".into(), "foo".into());
2644 store.upsert(a.id.clone(), a);
2645 store.upsert(b.id.clone(), b);
2646
2647 let scope = SearchScope {
2648 query: Some(Query {
2649 any: vec!["foo".into()],
2650 ..Default::default()
2651 }),
2652 mem: Some("memos".into()),
2653 ..Default::default()
2654 };
2655 let result = run_search(&store, &scope);
2656 assert_eq!(result.total, 1);
2657 assert_eq!(result.hits[0].mem, "memos");
2658 }
2659
2660 use crate::entity::HeadingSpan;
2663
2664 #[test]
2665 fn matched_terms_populated_for_any() {
2666 let mut store = Store::new();
2667 let mut a = make_entity("a", "specs");
2668 a.sections
2669 .insert("identity".into(), "auth flow uses oidc sessions".into());
2670 store.upsert(a.id.clone(), a);
2671
2672 let scope = SearchScope {
2673 query: Some(Query {
2674 any: vec!["auth".into(), "oidc".into()],
2675 ..Default::default()
2676 }),
2677 ..Default::default()
2678 };
2679 let result = run_search(&store, &scope);
2680 assert_eq!(result.total, 1);
2681 let hit = &result.hits[0];
2682 let mt = hit.matched_terms.as_ref().expect("matched_terms populated");
2683 assert!(mt.contains_key("auth"), "auth keyed: {mt:?}");
2684 assert!(mt.contains_key("oidc"), "oidc keyed: {mt:?}");
2685 }
2686
2687 #[test]
2688 fn matched_terms_per_field() {
2689 let mut store = Store::new();
2690 let mut a = make_entity("graph-engine", "specs");
2691 a.sections.insert(
2692 "identity".into(),
2693 "graph-engine uses graph primitives".into(),
2694 );
2695 store.upsert(a.id.clone(), a);
2696
2697 let scope = SearchScope {
2698 query: Some(Query {
2699 any: vec!["graph".into()],
2700 ..Default::default()
2701 }),
2702 ..Default::default()
2703 };
2704 let result = run_search(&store, &scope);
2705 let hit = &result.hits[0];
2706 let mt = hit.matched_terms.as_ref().unwrap();
2707 let fields: Vec<&str> = mt["graph"].iter().map(|tm| tm.field.as_str()).collect();
2708 assert!(fields.contains(&"title"), "title field: {fields:?}");
2709 assert!(fields.contains(&"identity"), "identity field: {fields:?}");
2710 }
2711
2712 #[test]
2713 fn matched_terms_excludes_not_terms() {
2714 let mut store = Store::new();
2715 let mut a = make_entity("a", "specs");
2716 a.sections
2717 .insert("identity".into(), "uses authentication".into());
2718 store.upsert(a.id.clone(), a);
2719
2720 let scope = SearchScope {
2721 query: Some(Query {
2722 any: vec!["authentication".into()],
2723 not: vec!["mock".into()],
2724 ..Default::default()
2725 }),
2726 ..Default::default()
2727 };
2728 let result = run_search(&store, &scope);
2729 let hit = &result.hits[0];
2730 let mt = hit.matched_terms.as_ref().unwrap();
2731 assert!(mt.contains_key("authentication"));
2732 assert!(
2733 !mt.contains_key("mock"),
2734 "negative predicate must not populate matched_terms: {mt:?}"
2735 );
2736 }
2737
2738 #[test]
2739 fn score_breakdown_sums_to_score() {
2740 let mut store = Store::new();
2741 let mut a = make_entity("a", "specs");
2742 a.sections
2743 .insert("identity".into(), "graph engine core".into());
2744 store.upsert(a.id.clone(), a);
2745
2746 let scope = SearchScope {
2747 query: Some(Query {
2748 any: vec!["graph".into()],
2749 ..Default::default()
2750 }),
2751 ..Default::default()
2752 };
2753 let result = run_search(&store, &scope);
2754 let hit = &result.hits[0];
2755 let br = hit.score_breakdown.as_ref().expect("breakdown populated");
2756 let sum: f32 = br.bm25 + br.title_boost + br.field_weights.values().sum::<f32>();
2757 assert!(
2758 (sum - hit.score).abs() < 0.01,
2759 "components should sum to score: sum={sum} score={}",
2760 hit.score
2761 );
2762 }
2763
2764 #[test]
2765 fn phrase_snippet_contains_full_phrase() {
2766 let mut store = Store::new();
2767 let mut a = make_entity("a", "specs");
2768 a.sections.insert(
2769 "identity".into(),
2770 "the client side agent runs locally".into(),
2771 );
2772 store.upsert(a.id.clone(), a);
2773
2774 let scope = SearchScope {
2775 query: Some(Query {
2776 phrase: Some("client side agent".into()),
2777 ..Default::default()
2778 }),
2779 ..Default::default()
2780 };
2781 let result = run_search(&store, &scope);
2782 let hit = &result.hits[0];
2783 let mt = hit.matched_terms.as_ref().unwrap();
2784 let matches = mt
2785 .get("client side agent")
2786 .expect("phrase term keyed in matched_terms");
2787 let identity_snippet = matches
2788 .iter()
2789 .find(|tm| tm.field == "identity")
2790 .expect("phrase matched in identity");
2791 assert!(
2792 identity_snippet.snippet.contains("client side agent"),
2793 "snippet must contain full phrase: {}",
2794 identity_snippet.snippet
2795 );
2796 }
2797
2798 fn entity_with_heading_spans(
2799 name: &str,
2800 section_key: &str,
2801 content: &str,
2802 spans: Vec<HeadingSpan>,
2803 ) -> Entity {
2804 let mut e = make_entity(name, "specs");
2805 e.sections
2806 .insert(section_key.to_string(), content.to_string());
2807 e.heading_spans.insert(section_key.to_string(), spans);
2808 e
2809 }
2810
2811 #[test]
2812 fn heading_path_none_when_match_above_first_subheading() {
2813 let content = "the anchor word here\n### Later Heading\nmore text";
2815 let h3_offset = content.find("### Later Heading").unwrap();
2816 let spans = vec![HeadingSpan {
2817 level: 3,
2818 title: "Later Heading".into(),
2819 start_offset: h3_offset,
2820 end_offset: content.len(),
2821 }];
2822 let mut store = Store::new();
2823 store.upsert(
2824 EntityId::new("specs", "a"),
2825 entity_with_heading_spans("a", "identity", content, spans),
2826 );
2827
2828 let scope = SearchScope {
2829 query: Some(Query {
2830 any: vec!["anchor".into()],
2831 field: Some("identity".into()),
2832 ..Default::default()
2833 }),
2834 ..Default::default()
2835 };
2836 let result = run_search(&store, &scope);
2837 let mt = result.hits[0].matched_terms.as_ref().unwrap();
2838 let tm = &mt["anchor"][0];
2839 assert!(
2840 tm.heading_path.is_none(),
2841 "match above first subheading ⇒ no heading_path: {:?}",
2842 tm.heading_path
2843 );
2844 }
2845
2846 #[test]
2847 fn heading_path_single_level() {
2848 let content = "### Response Shapes\nhandles unique keyword here\n";
2850 let spans = vec![HeadingSpan {
2851 level: 3,
2852 title: "Response Shapes".into(),
2853 start_offset: 0,
2854 end_offset: content.len(),
2855 }];
2856 let mut store = Store::new();
2857 store.upsert(
2858 EntityId::new("specs", "a"),
2859 entity_with_heading_spans("a", "identity", content, spans),
2860 );
2861
2862 let scope = SearchScope {
2863 query: Some(Query {
2864 any: vec!["unique".into()],
2865 field: Some("identity".into()),
2866 ..Default::default()
2867 }),
2868 ..Default::default()
2869 };
2870 let result = run_search(&store, &scope);
2871 let mt = result.hits[0].matched_terms.as_ref().unwrap();
2872 let tm = &mt["unique"][0];
2873 assert_eq!(
2874 tm.heading_path,
2875 Some(vec!["Response Shapes".into()]),
2876 "single-level path under one H3"
2877 );
2878 }
2879
2880 #[test]
2881 fn heading_path_nested_h3_h4() {
2882 let mut content = String::new();
2888 content.push_str("### Response Shapes\n");
2889 content.push_str("some text\n");
2890 let h4_start = content.len();
2891 content.push_str("#### Markdown Output\n");
2892 let payload_start = content.len();
2893 content.push_str("distinct-keyword is below\n");
2894 let spans = vec![
2895 HeadingSpan {
2896 level: 3,
2897 title: "Response Shapes".into(),
2898 start_offset: 0,
2899 end_offset: content.len(),
2900 },
2901 HeadingSpan {
2902 level: 4,
2903 title: "Markdown Output".into(),
2904 start_offset: h4_start,
2905 end_offset: content.len(),
2906 },
2907 ];
2908 let _ = payload_start;
2909 let mut store = Store::new();
2910 store.upsert(
2911 EntityId::new("specs", "a"),
2912 entity_with_heading_spans("a", "identity", &content, spans),
2913 );
2914
2915 let scope = SearchScope {
2916 query: Some(Query {
2917 any: vec!["distinct-keyword".into()],
2918 field: Some("identity".into()),
2919 ..Default::default()
2920 }),
2921 ..Default::default()
2922 };
2923 let result = run_search(&store, &scope);
2924 let mt = result.hits[0].matched_terms.as_ref().unwrap();
2925 let tm = &mt["distinct-keyword"][0];
2926 assert_eq!(
2927 tm.heading_path,
2928 Some(vec!["Response Shapes".into(), "Markdown Output".into()]),
2929 "nested path: outermost (H3) first, innermost (H4) last"
2930 );
2931 }
2932
2933 #[test]
2934 fn heading_path_survives_level_skip() {
2935 let content = "#### Direct Subsection\nrare-match word here\n";
2937 let spans = vec![HeadingSpan {
2938 level: 4,
2939 title: "Direct Subsection".into(),
2940 start_offset: 0,
2941 end_offset: content.len(),
2942 }];
2943 let mut store = Store::new();
2944 store.upsert(
2945 EntityId::new("specs", "a"),
2946 entity_with_heading_spans("a", "identity", content, spans),
2947 );
2948
2949 let scope = SearchScope {
2950 query: Some(Query {
2951 any: vec!["rare-match".into()],
2952 field: Some("identity".into()),
2953 ..Default::default()
2954 }),
2955 ..Default::default()
2956 };
2957 let result = run_search(&store, &scope);
2958 let mt = result.hits[0].matched_terms.as_ref().unwrap();
2959 let tm = &mt["rare-match"][0];
2960 assert_eq!(
2961 tm.heading_path,
2962 Some(vec!["Direct Subsection".into()]),
2963 "flat H4 span produces single-element path; no virtual H3 inserted"
2964 );
2965 }
2966
2967 #[test]
2968 fn heading_path_distinguishes_duplicate_siblings() {
2969 let mut content = String::new();
2973 content.push_str("### Foo\nfirst body\n");
2974 let second_start = content.len();
2975 content.push_str("### Foo\nsecond body carries sentinel-word here\n");
2976 let spans = vec![
2977 HeadingSpan {
2978 level: 3,
2979 title: "Foo".into(),
2980 start_offset: 0,
2981 end_offset: second_start,
2982 },
2983 HeadingSpan {
2984 level: 3,
2985 title: "Foo".into(),
2986 start_offset: second_start,
2987 end_offset: content.len(),
2988 },
2989 ];
2990 let mut store = Store::new();
2991 store.upsert(
2992 EntityId::new("specs", "a"),
2993 entity_with_heading_spans("a", "identity", &content, spans),
2994 );
2995
2996 let scope = SearchScope {
2997 query: Some(Query {
2998 any: vec!["sentinel-word".into()],
2999 field: Some("identity".into()),
3000 ..Default::default()
3001 }),
3002 ..Default::default()
3003 };
3004 let result = run_search(&store, &scope);
3005 let mt = result.hits[0].matched_terms.as_ref().unwrap();
3006 let tm = &mt["sentinel-word"][0];
3007 assert_eq!(
3008 tm.heading_path,
3009 Some(vec!["Foo".into()]),
3010 "second `### Foo` span contains the match (offset-based)"
3011 );
3012 }
3013
3014 #[test]
3017 fn facets_count_over_full_result_not_page() {
3018 let mut store = Store::new();
3020 for i in 0..12 {
3021 let mut e = make_entity(&format!("e-{i:02}"), "specs");
3022 e.sections
3023 .insert("identity".into(), "shared-keyword here".into());
3024 store.upsert(e.id.clone(), e);
3025 }
3026
3027 let scope = SearchScope {
3028 query: Some(Query {
3029 any: vec!["shared-keyword".into()],
3030 ..Default::default()
3031 }),
3032 limit: Some(5),
3033 ..Default::default()
3034 };
3035 let result = run_search(&store, &scope);
3036 assert_eq!(result.total, 12);
3037 assert_eq!(result.returned, 5);
3038 let facets = result.facets.as_ref().expect("facets present");
3039 let by_type_sum: usize = facets.by_type.values().sum();
3040 assert_eq!(
3041 by_type_sum, 12,
3042 "by_type must cover the full unpaginated set, not just the page"
3043 );
3044 let by_mem_sum: usize = facets.by_mem.values().sum();
3045 assert_eq!(by_mem_sum, 12);
3046 }
3047
3048 #[test]
3049 fn facets_by_type_and_mem_exact() {
3050 let mut store = Store::new();
3051 for i in 0..3 {
3053 let mut e = make_entity(&format!("s-{i}"), "specs");
3054 e.sections.insert("identity".into(), "shared anchor".into());
3055 store.upsert(e.id.clone(), e);
3056 }
3057 for i in 0..2 {
3058 let mut e = make_entity(&format!("m-{i}"), "memos");
3059 e.entity_type = "memo".into();
3060 e.sections.insert("identity".into(), "shared anchor".into());
3061 store.upsert(e.id.clone(), e);
3062 }
3063
3064 let scope = SearchScope {
3065 query: Some(Query {
3066 any: vec!["anchor".into()],
3067 ..Default::default()
3068 }),
3069 ..Default::default()
3070 };
3071 let result = run_search(&store, &scope);
3072 let facets = result.facets.as_ref().unwrap();
3073 assert_eq!(facets.by_type.get("spec").copied(), Some(3));
3074 assert_eq!(facets.by_type.get("memo").copied(), Some(2));
3075 assert_eq!(facets.by_mem.get("specs").copied(), Some(3));
3076 assert_eq!(facets.by_mem.get("memos").copied(), Some(2));
3077 assert_eq!(facets.by_expansion.get("primary").copied(), Some(5));
3080 assert!(!facets.by_expansion.contains_key("expanded"));
3081 }
3082
3083 #[test]
3084 fn facets_empty_when_no_hits() {
3085 let mut store = Store::new();
3086 let e = make_entity("lonely", "specs");
3087 store.upsert(e.id.clone(), e);
3088
3089 let scope = SearchScope {
3090 query: Some(Query {
3091 any: vec!["never-occurs-keyword".into()],
3092 ..Default::default()
3093 }),
3094 ..Default::default()
3095 };
3096 let result = run_search(&store, &scope);
3097 assert_eq!(result.total, 0);
3098 let facets = result
3099 .facets
3100 .as_ref()
3101 .expect("facets is Some(Facets::default()) even when hit set is empty");
3102 assert!(facets.by_type.is_empty());
3103 assert!(facets.by_mem.is_empty());
3104 assert!(facets.by_level.is_empty());
3105 assert!(facets.by_subsection.is_empty());
3106 assert!(facets.by_expansion.is_empty());
3107 }
3108
3109 #[test]
3110 fn facets_by_subsection_exact() {
3111 let content_a = "### Response Shapes\nentity-a unique-anchor here\n";
3113 let spans_a = vec![HeadingSpan {
3114 level: 3,
3115 title: "Response Shapes".into(),
3116 start_offset: 0,
3117 end_offset: content_a.len(),
3118 }];
3119 let content_b = "### Tool Surface\nentity-b unique-anchor here\n";
3120 let spans_b = vec![HeadingSpan {
3121 level: 3,
3122 title: "Tool Surface".into(),
3123 start_offset: 0,
3124 end_offset: content_b.len(),
3125 }];
3126 let mut store = Store::new();
3127 store.upsert(
3128 EntityId::new("specs", "a"),
3129 entity_with_heading_spans("a", "identity", content_a, spans_a),
3130 );
3131 store.upsert(
3132 EntityId::new("specs", "b"),
3133 entity_with_heading_spans("b", "identity", content_b, spans_b),
3134 );
3135
3136 let scope = SearchScope {
3137 query: Some(Query {
3138 any: vec!["unique-anchor".into()],
3139 field: Some("identity".into()),
3140 ..Default::default()
3141 }),
3142 ..Default::default()
3143 };
3144 let result = run_search(&store, &scope);
3145 let facets = result.facets.as_ref().unwrap();
3146 assert_eq!(facets.by_subsection.len(), 2);
3147 let paths: std::collections::HashSet<Vec<String>> = facets
3148 .by_subsection
3149 .iter()
3150 .map(|e| e.path.clone())
3151 .collect();
3152 assert!(paths.contains(&vec!["identity".into(), "Response Shapes".into()]));
3153 assert!(paths.contains(&vec!["identity".into(), "Tool Surface".into()]));
3154 for entry in &facets.by_subsection {
3155 assert_eq!(entry.count, 1);
3156 }
3157 }
3158
3159 #[test]
3160 fn facets_by_subsection_excludes_h2_only_matches() {
3161 let mut store = Store::new();
3164 let mut e = make_entity("a", "specs");
3165 e.sections
3166 .insert("identity".into(), "only-here unique-keyword lives".into());
3167 store.upsert(e.id.clone(), e);
3168
3169 let scope = SearchScope {
3170 query: Some(Query {
3171 any: vec!["unique-keyword".into()],
3172 field: Some("identity".into()),
3173 ..Default::default()
3174 }),
3175 ..Default::default()
3176 };
3177 let result = run_search(&store, &scope);
3178 assert_eq!(result.total, 1);
3179 let facets = result.facets.as_ref().unwrap();
3180 assert!(
3181 facets.by_subsection.is_empty(),
3182 "H2-only match must not contribute to by_subsection: {:?}",
3183 facets.by_subsection
3184 );
3185 }
3186
3187 #[test]
3188 fn facets_by_subsection_survives_punctuation_in_heading() {
3189 let content = "### Client/Server split\nword punctuation-anchor exists\n";
3191 let spans = vec![HeadingSpan {
3192 level: 3,
3193 title: "Client/Server split".into(),
3194 start_offset: 0,
3195 end_offset: content.len(),
3196 }];
3197 let mut store = Store::new();
3198 store.upsert(
3199 EntityId::new("specs", "a"),
3200 entity_with_heading_spans("a", "identity", content, spans),
3201 );
3202
3203 let scope = SearchScope {
3204 query: Some(Query {
3205 any: vec!["punctuation-anchor".into()],
3206 field: Some("identity".into()),
3207 ..Default::default()
3208 }),
3209 ..Default::default()
3210 };
3211 let result = run_search(&store, &scope);
3212 let facets = result.facets.as_ref().unwrap();
3213 assert_eq!(facets.by_subsection.len(), 1);
3214 let entry = &facets.by_subsection[0];
3215 assert_eq!(entry.count, 1);
3216 assert_eq!(
3217 entry.path,
3218 vec!["identity".to_string(), "Client/Server split".to_string()],
3219 "punctuation in heading must remain a single path element"
3220 );
3221 }
3222
3223 #[test]
3224 fn facets_by_level_counts_when_present() {
3225 let mut store = Store::new();
3226 let mut e1 = make_entity("a", "specs");
3227 e1.metadata
3228 .insert("level".into(), MetadataValue::String("M0".into()));
3229 e1.sections
3230 .insert("identity".into(), "shared anchor".into());
3231 let mut e2 = make_entity("b", "specs");
3232 e2.metadata
3233 .insert("level".into(), MetadataValue::String("M1".into()));
3234 e2.sections
3235 .insert("identity".into(), "shared anchor".into());
3236 let mut e3 = make_entity("c", "specs");
3237 e3.metadata
3238 .insert("level".into(), MetadataValue::String("M1".into()));
3239 e3.sections
3240 .insert("identity".into(), "shared anchor".into());
3241 store.upsert(e1.id.clone(), e1);
3242 store.upsert(e2.id.clone(), e2);
3243 store.upsert(e3.id.clone(), e3);
3244
3245 let scope = SearchScope {
3246 query: Some(Query {
3247 any: vec!["anchor".into()],
3248 ..Default::default()
3249 }),
3250 ..Default::default()
3251 };
3252 let result = run_search(&store, &scope);
3253 let facets = result.facets.as_ref().unwrap();
3254 assert_eq!(facets.by_level.get("M0").copied(), Some(1));
3255 assert_eq!(facets.by_level.get("M1").copied(), Some(2));
3256 }
3257
3258 use crate::store::{Edge, EdgeSource};
3261
3262 fn add_edge(store: &mut Store, from: EntityId, to: EntityId, rel: &str) {
3263 store.add_edge(
3264 from,
3265 Edge {
3266 rel_type: rel.into(),
3267 target: to,
3268 source: EdgeSource::Explicit,
3269 },
3270 );
3271 }
3272
3273 fn add_body_edge(store: &mut Store, from: EntityId, to: EntityId) {
3276 store.add_edge(
3277 from,
3278 Edge {
3279 rel_type: "REFERENCES".into(),
3280 target: to,
3281 source: EdgeSource::BodyLink,
3282 },
3283 );
3284 }
3285
3286 #[test]
3291 fn related_to_ranks_by_proximity_then_typed() {
3292 let mut store = Store::new();
3293 for n in ["hub", "dep1", "men1", "far1"] {
3294 let e = make_entity(n, "specs");
3295 store.upsert(e.id.clone(), e);
3296 }
3297 let hub = EntityId::new("specs", "hub");
3298 add_edge(
3301 &mut store,
3302 hub.clone(),
3303 EntityId::new("specs", "dep1"),
3304 "USES",
3305 );
3306 add_body_edge(&mut store, hub.clone(), EntityId::new("specs", "men1"));
3307 add_edge(
3308 &mut store,
3309 EntityId::new("specs", "dep1"),
3310 EntityId::new("specs", "far1"),
3311 "USES",
3312 );
3313
3314 let scope = SearchScope {
3315 related_to: Some(hub.clone()),
3316 depth: Some(2),
3317 ..Default::default()
3318 };
3319 let result = run_search(&store, &scope);
3320 let order: Vec<&str> = result.hits.iter().map(|h| h.id.name()).collect();
3322 assert_eq!(
3323 result.total, 4,
3324 "small neighbourhood keeps full membership: {order:?}"
3325 );
3326 let pos = |n: &str| order.iter().position(|x| *x == n).unwrap();
3327 assert!(
3328 pos("dep1") < pos("far1"),
3329 "nearer before farther: {order:?}"
3330 );
3331 assert!(
3332 pos("men1") < pos("far1"),
3333 "nearer before farther: {order:?}"
3334 );
3335 assert!(
3336 pos("dep1") < pos("men1"),
3337 "typed link before co-mention at the same hop: {order:?}"
3338 );
3339 }
3340
3341 #[test]
3344 fn related_to_hub_is_capped_with_warning() {
3345 let mut store = Store::new();
3346 let hub = EntityId::new("specs", "hub");
3347 store.upsert(hub.clone(), make_entity("hub", "specs"));
3348 for i in 0..150 {
3349 let n = format!("n{i:03}");
3350 let id = EntityId::new("specs", &n);
3351 store.upsert(id.clone(), make_entity(&n, "specs"));
3352 add_edge(&mut store, hub.clone(), id, "USES");
3353 }
3354 let scope = SearchScope {
3355 related_to: Some(hub.clone()),
3356 depth: Some(1),
3357 limit: Some(200),
3358 ..Default::default()
3359 };
3360 let result = run_search(&store, &scope);
3361 assert_eq!(
3362 result.total, RELATED_TO_NEIGHBOURHOOD_CAP,
3363 "hub neighbourhood bounded to the cap"
3364 );
3365 assert!(
3366 result
3367 .warnings
3368 .iter()
3369 .any(|w| w.code() == "NEIGHBOURHOOD_CAPPED"),
3370 "capping must surface a warning; got {:?}",
3371 result.warnings.iter().map(|w| w.code()).collect::<Vec<_>>()
3372 );
3373 }
3374
3375 #[test]
3376 fn expand_via_pulls_in_direct_neighbours() {
3377 let mut store = Store::new();
3378 let mut primary = make_entity("primary", "specs");
3379 primary
3380 .sections
3381 .insert("identity".into(), "auth flow".into());
3382 let n1 = make_entity("n1", "specs");
3383 let n2 = make_entity("n2", "specs");
3384 let primary_id = primary.id.clone();
3385 store.upsert(primary_id.clone(), primary);
3386 store.upsert(n1.id.clone(), n1);
3387 store.upsert(n2.id.clone(), n2);
3388 add_edge(
3389 &mut store,
3390 primary_id.clone(),
3391 EntityId::new("specs", "n1"),
3392 "REFERENCES",
3393 );
3394 add_edge(
3395 &mut store,
3396 primary_id.clone(),
3397 EntityId::new("specs", "n2"),
3398 "REFERENCES",
3399 );
3400
3401 let scope = SearchScope {
3402 query: Some(Query {
3403 any: vec!["auth".into()],
3404 ..Default::default()
3405 }),
3406 expand_via: Some(vec!["REFERENCES".into()]),
3407 expand_depth: Some(1),
3408 ..Default::default()
3409 };
3410 let result = run_search(&store, &scope);
3411 assert_eq!(result.total, 3, "primary + 2 expanded");
3412
3413 let expanded: Vec<&SearchHit> = result
3414 .hits
3415 .iter()
3416 .filter(|h| h.expansion.is_some())
3417 .collect();
3418 assert_eq!(expanded.len(), 2);
3419 for h in expanded {
3420 let exp = h.expansion.as_ref().unwrap();
3421 assert_eq!(exp.of, primary_id);
3422 assert_eq!(exp.via_edge, "REFERENCES");
3423 assert_eq!(exp.depth, 1);
3424 let bd = h.score_breakdown.as_ref().unwrap();
3427 assert_eq!(bd.expansion_decay, Some(0.5));
3428 assert!(h.matched_terms.is_none());
3429 }
3430 let facets = result.facets.as_ref().unwrap();
3432 assert_eq!(facets.by_expansion.get("primary").copied(), Some(1));
3433 assert_eq!(facets.by_expansion.get("expanded").copied(), Some(2));
3434 }
3435
3436 #[test]
3437 fn expand_via_respects_filter() {
3438 let mut store = Store::new();
3440 let mut primary = make_entity("primary", "specs");
3441 primary
3442 .sections
3443 .insert("identity".into(), "auth flow".into());
3444 let mut neighbor = make_entity("neighbor", "specs");
3445 neighbor.entity_type = "memo".into();
3446 let primary_id = primary.id.clone();
3447 store.upsert(primary_id.clone(), primary);
3448 store.upsert(neighbor.id.clone(), neighbor);
3449 add_edge(
3450 &mut store,
3451 primary_id,
3452 EntityId::new("specs", "neighbor"),
3453 "REFERENCES",
3454 );
3455
3456 let scope = SearchScope {
3457 query: Some(Query {
3458 any: vec!["auth".into()],
3459 ..Default::default()
3460 }),
3461 entity_type: Some("spec".into()),
3462 expand_via: Some(vec!["REFERENCES".into()]),
3463 expand_depth: Some(1),
3464 ..Default::default()
3465 };
3466 let result = run_search(&store, &scope);
3467 assert_eq!(
3468 result.total, 1,
3469 "only the primary — memo neighbour dropped by entity_type"
3470 );
3471 assert!(result.hits[0].expansion.is_none());
3472 }
3473
3474 #[test]
3475 fn expand_via_respects_depth() {
3476 let mut store = Store::new();
3478 let mut primary = make_entity("primary", "specs");
3479 primary.sections.insert("identity".into(), "anchor".into());
3480 let a = make_entity("a", "specs");
3481 let b = make_entity("b", "specs");
3482 let primary_id = primary.id.clone();
3483 store.upsert(primary_id.clone(), primary);
3484 store.upsert(a.id.clone(), a);
3485 store.upsert(b.id.clone(), b);
3486 add_edge(
3487 &mut store,
3488 primary_id.clone(),
3489 EntityId::new("specs", "a"),
3490 "REFERENCES",
3491 );
3492 add_edge(
3493 &mut store,
3494 EntityId::new("specs", "a"),
3495 EntityId::new("specs", "b"),
3496 "REFERENCES",
3497 );
3498
3499 let make_scope = |depth: usize| SearchScope {
3500 query: Some(Query {
3501 any: vec!["anchor".into()],
3502 ..Default::default()
3503 }),
3504 expand_via: Some(vec!["REFERENCES".into()]),
3505 expand_depth: Some(depth),
3506 ..Default::default()
3507 };
3508 let r1 = run_search(&store, &make_scope(1));
3509 assert_eq!(r1.total, 2, "depth 1: primary + a");
3510
3511 let r2 = run_search(&store, &make_scope(2));
3512 assert_eq!(r2.total, 3, "depth 2: primary + a + b");
3513 let b_hit = r2.hits.iter().find(|h| h.id.name() == "b").unwrap();
3514 assert_eq!(b_hit.expansion.as_ref().unwrap().depth, 2);
3515 }
3516
3517 #[test]
3518 fn expand_via_empty_edge_types_skips() {
3519 let mut store = Store::new();
3520 let mut primary = make_entity("primary", "specs");
3521 primary.sections.insert("identity".into(), "anchor".into());
3522 let n = make_entity("n", "specs");
3523 let primary_id = primary.id.clone();
3524 store.upsert(primary_id.clone(), primary);
3525 store.upsert(n.id.clone(), n);
3526 add_edge(
3527 &mut store,
3528 primary_id,
3529 EntityId::new("specs", "n"),
3530 "REFERENCES",
3531 );
3532
3533 let scope_empty = SearchScope {
3534 query: Some(Query {
3535 any: vec!["anchor".into()],
3536 ..Default::default()
3537 }),
3538 expand_via: Some(Vec::new()),
3539 ..Default::default()
3540 };
3541 let scope_none = SearchScope {
3542 query: Some(Query {
3543 any: vec!["anchor".into()],
3544 ..Default::default()
3545 }),
3546 expand_via: None,
3547 ..Default::default()
3548 };
3549 let r_empty = run_search(&store, &scope_empty);
3550 let r_none = run_search(&store, &scope_none);
3551 assert_eq!(r_empty.total, 1);
3552 assert_eq!(r_empty.total, r_none.total);
3553 }
3554
3555 #[test]
3556 fn expand_via_score_decay() {
3557 let mut store = Store::new();
3559 let mut primary = make_entity("primary", "specs");
3560 primary.sections.insert("identity".into(), "keyword".into());
3561 let a = make_entity("a", "specs");
3562 let b = make_entity("b", "specs");
3563 let primary_id = primary.id.clone();
3564 store.upsert(primary_id.clone(), primary);
3565 store.upsert(a.id.clone(), a);
3566 store.upsert(b.id.clone(), b);
3567 add_edge(
3568 &mut store,
3569 primary_id.clone(),
3570 EntityId::new("specs", "a"),
3571 "REFERENCES",
3572 );
3573 add_edge(
3574 &mut store,
3575 EntityId::new("specs", "a"),
3576 EntityId::new("specs", "b"),
3577 "REFERENCES",
3578 );
3579
3580 let scope = SearchScope {
3581 query: Some(Query {
3582 any: vec!["keyword".into()],
3583 ..Default::default()
3584 }),
3585 expand_via: Some(vec!["REFERENCES".into()]),
3586 expand_depth: Some(2),
3587 ..Default::default()
3588 };
3589 let result = run_search(&store, &scope);
3590 let primary_hit = result
3591 .hits
3592 .iter()
3593 .find(|h| h.id == primary_id)
3594 .expect("primary present");
3595 let primary_score = primary_hit.score;
3596 assert!(primary_score > 0.0, "primary must have BM25 score");
3597
3598 let a_hit = result.hits.iter().find(|h| h.id.name() == "a").unwrap();
3599 let b_hit = result.hits.iter().find(|h| h.id.name() == "b").unwrap();
3600 assert!((a_hit.score - primary_score * 0.5).abs() < 0.0001);
3601 assert!((b_hit.score - primary_score * 0.25).abs() < 0.0001);
3602 assert_eq!(
3603 a_hit.score_breakdown.as_ref().unwrap().expansion_decay,
3604 Some(0.5)
3605 );
3606 assert_eq!(
3607 b_hit.score_breakdown.as_ref().unwrap().expansion_decay,
3608 Some(0.25)
3609 );
3610 }
3611
3612 #[test]
3613 fn expand_via_via_edge_label_correct() {
3614 let mut store = Store::new();
3615 let mut primary = make_entity("primary", "specs");
3616 primary.sections.insert("identity".into(), "keyword".into());
3617 let realizes_n = make_entity("realizes-target", "specs");
3618 let references_n = make_entity("references-target", "specs");
3619 let primary_id = primary.id.clone();
3620 store.upsert(primary_id.clone(), primary);
3621 store.upsert(realizes_n.id.clone(), realizes_n);
3622 store.upsert(references_n.id.clone(), references_n);
3623 add_edge(
3624 &mut store,
3625 primary_id.clone(),
3626 EntityId::new("specs", "realizes-target"),
3627 "REALIZES",
3628 );
3629 add_edge(
3630 &mut store,
3631 primary_id,
3632 EntityId::new("specs", "references-target"),
3633 "REFERENCES",
3634 );
3635
3636 let scope = SearchScope {
3637 query: Some(Query {
3638 any: vec!["keyword".into()],
3639 ..Default::default()
3640 }),
3641 expand_via: Some(vec!["REALIZES".into(), "REFERENCES".into()]),
3642 expand_depth: Some(1),
3643 ..Default::default()
3644 };
3645 let result = run_search(&store, &scope);
3646 let rt = result
3647 .hits
3648 .iter()
3649 .find(|h| h.id.name() == "realizes-target")
3650 .unwrap();
3651 assert_eq!(rt.expansion.as_ref().unwrap().via_edge, "REALIZES");
3652 let rf = result
3653 .hits
3654 .iter()
3655 .find(|h| h.id.name() == "references-target")
3656 .unwrap();
3657 assert_eq!(rf.expansion.as_ref().unwrap().via_edge, "REFERENCES");
3658 }
3659
3660 fn make_stub_entity(name: &str, mem: &str) -> Entity {
3661 let mut e = make_entity(name, mem);
3662 e.stub = true;
3663 e
3664 }
3665
3666 #[test]
3667 fn search_filter_stub_none_returns_both() {
3668 let mut store = Store::new();
3669 let real = make_entity("real-a", "specs");
3670 let stub = make_stub_entity("stub-b", "specs");
3671 store.upsert(real.id.clone(), real);
3672 store.upsert(stub.id.clone(), stub);
3673
3674 let scope = SearchScope::default();
3675 let result = run_search(&store, &scope);
3676 assert_eq!(result.total, 2, "default returns both stubs and reals");
3677
3678 let stub_hit = result
3679 .hits
3680 .iter()
3681 .find(|h| h.id.name() == "stub-b")
3682 .expect("stub must appear in default results");
3683 assert!(
3684 stub_hit.stub,
3685 "hit.stub reflects entity.stub (regression guard)"
3686 );
3687 let real_hit = result
3688 .hits
3689 .iter()
3690 .find(|h| h.id.name() == "real-a")
3691 .expect("real must appear");
3692 assert!(!real_hit.stub);
3693 }
3694
3695 #[test]
3696 fn search_filter_stub_true_returns_only_stubs() {
3697 let mut store = Store::new();
3698 let real = make_entity("real-a", "specs");
3699 let stub = make_stub_entity("stub-b", "specs");
3700 store.upsert(real.id.clone(), real);
3701 store.upsert(stub.id.clone(), stub);
3702
3703 let scope = SearchScope {
3704 stub: Some(true),
3705 ..Default::default()
3706 };
3707 let result = run_search(&store, &scope);
3708 assert_eq!(result.total, 1);
3709 assert_eq!(result.hits[0].id.name(), "stub-b");
3710 assert!(result.hits[0].stub);
3711 }
3712
3713 #[test]
3714 fn search_filter_stub_false_excludes_stubs() {
3715 let mut store = Store::new();
3716 let real = make_entity("real-a", "specs");
3717 let stub = make_stub_entity("stub-b", "specs");
3718 store.upsert(real.id.clone(), real);
3719 store.upsert(stub.id.clone(), stub);
3720
3721 let scope = SearchScope {
3722 stub: Some(false),
3723 ..Default::default()
3724 };
3725 let result = run_search(&store, &scope);
3726 assert_eq!(result.total, 1);
3727 assert_eq!(result.hits[0].id.name(), "real-a");
3728 assert!(!result.hits[0].stub);
3729 }
3730
3731 #[test]
3732 fn search_filter_stub_intersects_entity_type() {
3733 let mut store = Store::new();
3734 let real_spec = make_entity("real-spec", "specs");
3735 let stub_spec = make_stub_entity("stub-spec", "specs");
3736 let mut stub_memo = make_stub_entity("stub-memo", "specs");
3737 stub_memo.entity_type = "memo".into();
3738 store.upsert(real_spec.id.clone(), real_spec);
3739 store.upsert(stub_spec.id.clone(), stub_spec);
3740 store.upsert(stub_memo.id.clone(), stub_memo);
3741
3742 let scope = SearchScope {
3743 stub: Some(true),
3744 entity_type: Some("spec".into()),
3745 ..Default::default()
3746 };
3747 let result = run_search(&store, &scope);
3748 assert_eq!(result.total, 1);
3749 assert_eq!(result.hits[0].id.name(), "stub-spec");
3750 assert!(result.hits[0].stub);
3751 }
3752
3753 #[test]
3754 fn facets_by_type_omits_empty_bucket_for_stubs() {
3755 let mut store = Store::new();
3761 let real = make_entity("real-a", "specs");
3762 let mut stub = make_stub_entity("stub-b", "specs");
3763 stub.entity_type = String::new(); store.upsert(real.id.clone(), real);
3765 store.upsert(stub.id.clone(), stub);
3766
3767 let result = run_search(&store, &SearchScope::default());
3768 assert_eq!(result.total, 2, "both entities are in the hit set");
3769 let facets = result.facets.as_ref().expect("facets present");
3770 assert_eq!(facets.by_type.get("spec").copied(), Some(1));
3771 assert!(
3772 !facets.by_type.contains_key(""),
3773 "by_type must not expose an empty-string bucket for stubs: {:?}",
3774 facets.by_type
3775 );
3776 }
3777
3778 #[test]
3784 fn search_summary_uses_per_mem_schema_anchor_section() {
3785 use memstead_schema::SchemaRegistry;
3786
3787 let software = SchemaRegistry::builtin()
3788 .get("software", &semver::Version::new(0, 2, 0))
3789 .expect("software builtin present");
3790 let req_type = software.get_type("requirement").expect("requirement type");
3791
3792 let mut metadata = IndexMap::new();
3793 metadata.insert("type".into(), MetadataValue::String("requirement".into()));
3794 let mut sections = IndexMap::new();
3795 sections.insert(
3796 "statement".into(),
3797 "The system shall encrypt tokens at rest.".into(),
3798 );
3799 let entity = Entity {
3800 id: EntityId::new("reqs", "encrypt-tokens"),
3801 title: "Encrypt tokens".into(),
3802 entity_type: "requirement".into(),
3803 mem: "reqs".into(),
3804 file_path: "encrypt-tokens.md".into(),
3805 metadata,
3806 sections,
3807 relationships: Vec::new(),
3808 content_hash: "h".into(),
3809 stub: false,
3810 stub_kind: None,
3811 heading_spans: std::collections::HashMap::new(),
3812 raw_section_headings: Vec::new(),
3813 };
3814 let mut store = Store::new();
3815 store.upsert(entity.id.clone(), entity);
3816
3817 let mut idx = MemIndex::build_in_ram("reqs".into(), Some(&software)).unwrap();
3820 for e in store.all_entities() {
3821 idx.index_entity(e).unwrap();
3822 }
3823 idx.commit().unwrap();
3824 let mut indexes = HashMap::new();
3825 indexes.insert("reqs".to_string(), idx);
3826 let mut schemas: HashMap<String, Arc<Schema>> = HashMap::new();
3827 schemas.insert("reqs".to_string(), software.clone());
3828
3829 let result = search(
3831 &store,
3832 &SearchScope::default(),
3833 &req_type,
3834 &indexes,
3835 &schemas,
3836 );
3837 assert_eq!(result.total, 1);
3838 let summary = result.hits[0]
3839 .summary
3840 .as_ref()
3841 .expect("summary computed at search time");
3842 assert_eq!(summary.heading, "Statement");
3843 assert!(
3844 summary.value.contains("encrypt tokens at rest"),
3845 "got: {}",
3846 summary.value
3847 );
3848
3849 let envelope = crate::render::build_search_envelope(&result, 0);
3851 assert_eq!(envelope.hits[0].summary_heading, "Statement");
3852 assert!(
3853 envelope.hits[0]
3854 .summary_value
3855 .contains("encrypt tokens at rest")
3856 );
3857 }
3858
3859 #[test]
3864 fn range_filter_on_created_date_works() {
3865 let mut store = Store::new();
3866 let mut old = make_entity("old", "specs");
3867 old.metadata.insert(
3868 "created_date".into(),
3869 MetadataValue::String("2020-01-01".into()),
3870 );
3871 let mut recent = make_entity("recent", "specs");
3872 recent.metadata.insert(
3873 "created_date".into(),
3874 MetadataValue::String("2026-06-01".into()),
3875 );
3876 store.upsert(old.id.clone(), old);
3877 store.upsert(recent.id.clone(), recent);
3878
3879 let scope = SearchScope {
3880 range_filters: HashMap::from([("created_date_after".into(), "2025-01-01".into())]),
3881 ..Default::default()
3882 };
3883 let result = run_search(&store, &scope);
3884 assert_eq!(
3885 result.total, 1,
3886 "only the entity created after the bound matches"
3887 );
3888 assert_eq!(result.hits[0].id.name(), "recent");
3889 assert!(
3890 result.warnings.is_empty(),
3891 "created_date is range-filterable — no FIELD_NOT_RANGE_FILTERABLE warning; got {:?}",
3892 result.warnings
3893 );
3894 }
3895
3896 fn csv_tag_schema() -> std::sync::Arc<Schema> {
3899 let manifest = "name: tagtest\nversion: 0.1.0\ndescription: t\nwhen_to_use: t\n\
3900types:\n - thing\nrelationships:\n mode: open\n definitions:\n \
3901- name: PART_OF\n description: parent\n default_weight: 3.0\n \
3902- name: _default\n description: fallback\n default_weight: 1.0\n\
3903community:\n resolution: 1.0\n seed: 42\n";
3904 let type_yaml = "name: thing\ndescription: t\nwhen_to_use: t\nsections:\n \
3905- key: body\n heading: Body\n required: true\n catch_all: true\n \
3906search_weight: 1.0\n write_rules: []\nmetadata_fields:\n - key: labels\n \
3907description: csv labels\n field_type: string\n serialization: csv_array\n \
3908filterable: equality\n - key: priority\n description: prio\n field_type: string\n \
3909enum_values: [low, mid, high]\n filterable: equality\ntitle_weight: 1.0\ntext_fields:\n - body\n\
3910hierarchy_relationship: PART_OF\nno_self_loop_relationships: []\n\
3911updatable_fields: [title, body, labels]\nhealth_required_fields: []\n\
3912staleness_threshold_days: 90\nwrite_rules: []\n";
3913 std::sync::Arc::new(
3914 memstead_schema::load_schema_from_memory(
3915 manifest,
3916 &[("thing".to_string(), type_yaml.to_string())],
3917 )
3918 .expect("csv-tag test schema must load"),
3919 )
3920 }
3921
3922 fn codes_for(filters: &[(&str, &str)]) -> Vec<&'static str> {
3923 let schema = csv_tag_schema();
3924 let type_def = schema.get_type("thing").expect("thing type present");
3925 let type_def = type_def.as_ref();
3926 let mem_schemas: HashMap<String, Arc<Schema>> = HashMap::new();
3927 let filters: HashMap<String, String> = filters
3928 .iter()
3929 .map(|(k, v)| (k.to_string(), v.to_string()))
3930 .collect();
3931 let mut warnings = Vec::new();
3932 super::collect_equality_filter_warnings(
3933 &filters,
3934 type_def,
3935 None,
3936 None,
3937 &mem_schemas,
3938 &mut warnings,
3939 );
3940 warnings.iter().map(|w| w.code()).collect()
3941 }
3942
3943 #[test]
3947 fn csv_filter_comma_value_warns_multi_member() {
3948 let codes = codes_for(&[("labels", "dedup,retry")]);
3949 assert!(
3950 codes.contains(&"FILTER_VALUE_MULTI_MEMBER"),
3951 "comma-bearing csv value must warn; got: {codes:?}",
3952 );
3953 }
3954
3955 #[test]
3958 fn csv_filter_single_member_does_not_warn() {
3959 let codes = codes_for(&[("labels", "dedup")]);
3960 assert!(
3961 !codes.contains(&"FILTER_VALUE_MULTI_MEMBER"),
3962 "single-member csv value must not warn; got: {codes:?}",
3963 );
3964 }
3965
3966 #[test]
3970 fn unknown_filter_key_still_warns_unknown() {
3971 let codes = codes_for(&[("nonexistent", "x")]);
3972 assert!(
3973 codes.contains(&"UNKNOWN_FILTER_KEY"),
3974 "unknown key must still warn UNKNOWN_FILTER_KEY; got: {codes:?}",
3975 );
3976 assert!(!codes.contains(&"FILTER_VALUE_MULTI_MEMBER"));
3977 }
3978
3979 #[test]
3983 fn enum_filter_invalid_value_warns() {
3984 let codes = codes_for(&[("priority", "urgent")]);
3985 assert!(
3986 codes.contains(&"INVALID_ENUM_VALUE"),
3987 "out-of-enum filter value must warn INVALID_ENUM_VALUE; got: {codes:?}",
3988 );
3989 }
3990
3991 #[test]
3993 fn enum_filter_valid_value_does_not_warn() {
3994 let codes = codes_for(&[("priority", "high")]);
3995 assert!(
3996 !codes.contains(&"INVALID_ENUM_VALUE"),
3997 "a valid enum value must not warn; got: {codes:?}",
3998 );
3999 }
4000
4001 #[test]
4004 fn enum_check_does_not_fire_on_unknown_key() {
4005 let codes = codes_for(&[("nonexistent", "urgent")]);
4006 assert!(codes.contains(&"UNKNOWN_FILTER_KEY"));
4007 assert!(!codes.contains(&"INVALID_ENUM_VALUE"));
4008 }
4009}