Skip to main content

memstead_base/ops/
search.rs

1//! Full-text search across entities with BM25 scoring via tantivy.
2//!
3//! `SearchScope.query` is the sole text-predicate entry point.
4//! Empty or absent `query` ⇒ metadata-only scan (the `list` semantics
5//! path). Metadata, topology, and pagination filters still run
6//! in-memory against the store after the tantivy hit set is collected.
7
8use 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
25/// Hard ceiling on how many hits to pull back from tantivy per mem. The
26/// in-memory post-filter trims this down; the ceiling exists so misconfigured
27/// callers (e.g. an unbounded offset) can't degrade into a full-corpus scan
28/// per mem. 10k matches the "typical mem" perf budget.
29const MAX_HITS_PER_MEM: usize = 10_000;
30
31/// Resolve a hit's lead-section summary against its *own* mem schema —
32/// the renderer can't do this correctly (its `type_by_name` only sees the
33/// `default` schema), so the search op computes it here where the per-mem
34/// `schema` is in hand and stores it on the hit. Delegates to the shared
35/// [`crate::render::lead_section_pair`] so the lead-section rule has one home.
36fn 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
44/// Estimate token count for an entity (rough: markdown length / 4).
45fn estimate_tokens(entity: &crate::entity::Entity, schema: &TypeDefinition) -> usize {
46    let md = generate_markdown(entity, schema);
47    md.len() / 4
48}
49
50/// #54: a `related_to` neighbourhood larger than this is ranked by proximity
51/// and bounded to its nearest members so a hub can't flood the caller. Sized
52/// generously — a normal (non-hub) neighbourhood stays whole (the refusal AC).
53const RELATED_TO_NEIGHBOURHOOD_CAP: usize = 100;
54
55/// Default token budget bounding a single search page's hit payload. Sized
56/// to leave headroom under the MCP transport cap once both response channels
57/// (structured envelope + rendered markdown, each derived from the same
58/// hits) and the facets/frontmatter overhead are counted. Agents override via
59/// `token_budget`; a page that overflows it is greedily trimmed with a
60/// `SEARCH_RESULTS_TRUNCATED` warning.
61const DEFAULT_SEARCH_TOKEN_BUDGET: usize = 12_000;
62
63/// Rough serialized-token cost of one search hit (chars / 4) — the same
64/// heuristic the rest of the engine uses for token estimates. Drives the
65/// budget greedy-fill; `summary` is `#[serde(skip)]` so it doesn't serialize
66/// here, which slightly under-counts, but the markdown channel carries the
67/// summary instead, so the budget headroom absorbs it.
68fn hit_response_tokens(hit: &SearchHit) -> usize {
69    serde_json::to_string(hit).map(|s| s.len()).unwrap_or(0) / 4
70}
71
72/// Search entities with text matching and filtering.
73///
74/// Evaluates `scope.query` against the per-mem tantivy indexes when any
75/// text predicate is set; otherwise degrades to a metadata-only scan of the
76/// store (the `list` semantics path).
77pub 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    // `scope.query` is the sole text-predicate entry point. An absent or
108    // empty query falls through to the metadata-only scan below.
109    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    // Execute the tantivy query across the selected mems — at most one
113    // when `scope.mem` is Some, otherwise every indexed mem. Keep the
114    // highest score per entity (a cross-mem dedup is irrelevant today but
115    // cheap insurance).
116    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                // Empty-but-present facets keeps the response shape stable
165                // even when there are no hits — agents can always branch on
166                // the keys without null checks.
167                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        // Full section bodies are deliberately NOT carried on search hits:
243        // search finds entities, `memstead_entity` reads them in full.
244        // Shipping every required section per hit pushed a page of
245        // content-rich matches past the MCP transport token cap; the
246        // lead-section summary, `snippet`, and `matched_terms` carry enough
247        // signal to triage a hit, and the body is one `memstead_entity` call
248        // away. (`list` still ships sections — its human-facing roster
249        // consumers read them.)
250        let summary = Some(hit_summary(schema, |k| {
251            entity.sections.get(k).map(String::as_str)
252        }));
253
254        // Populate matched_terms + score_breakdown only when the
255        // caller actually supplied a text predicate. The metadata-only path
256        // keeps both as `None` so empty queries don't carry pointless feedback.
257        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    // #54: a `related_to` neighbourhood is ranked by proximity (nearer
284    // first) and bounded, not a flat alphabetical flood. Compute hop-
285    // distances (membership = the reachable set, unchanged) and the anchor's
286    // directly-typed neighbours; the sort and cap below consume them.
287    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    // Graph expansion. After the primary hit set is computed,
311    // optionally pull in neighbours reachable via the requested edge types.
312    // Non-query filters (mem, entity_type, filters, range_filters) also
313    // apply to expanded candidates — a violating neighbour is dropped. The
314    // `related_to`, `edge_type`, and text predicates deliberately do NOT
315    // apply: expansion is a graph-proximity surface on top of
316    // the primary hit set, not a second text query.
317    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    // Sort: a `related_to` neighbourhood ranks by proximity — nearer hops
331    // first, then a typed (dependency) link to the anchor before a
332    // co-mention at the same distance — otherwise by tantivy score. Title
333    // asc is the stable tiebreak throughout.
334    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    // #54: bound a hub neighbourhood to its nearest N (after proximity
361    // ranking) so it can't flood the caller; a neighbourhood at/under the
362    // cap is unchanged (refusal AC). The warning surfaces the truncation.
363    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    // Facets are computed over the unpaginated hit set. Pagination
374    // is for display, facets are for navigation — counting only the page
375    // window would mislead the agent.
376    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    // Token-budget guard: a page of content-rich hits can still overflow the
383    // MCP transport cap even after `limit`. Greedily keep hits while the
384    // running serialized cost stays under the budget; always keep at least
385    // one (a single oversized hit must still come back). `total` stays the
386    // full match count — the agent pages with `offset` or raises
387    // `token_budget`. Bounding here (not in the markdown renderer) keeps both
388    // response channels in lockstep, since both derive from `hits`.
389    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
418/// List entities with filtering (no text matching, returns all matching entities).
419pub 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        // Resolve the summary before moving `result_sections` into the hit —
516        // the closure borrows it, so the borrow must end first.
517        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
557// ---------------------------------------------------------------------------
558// Facets
559// ---------------------------------------------------------------------------
560
561/// Compute facet counts over the unpaginated hit set. Zero-count entries are
562/// excluded to keep the payload small — agents branch on presence, not on
563/// counts. `by_expansion` tags each hit `primary` or `expanded`.
564///
565/// `by_level` / `by_status` / `by_confidence` are the fixed Tier 1
566/// `Filterable::Equality` dimensions. We look them up by literal metadata
567/// key — the three closed fields on `Facets` match the three conventional
568/// names used across the built-in schemas. If a schema renames them (e.g.
569/// `verification_status` on assertions), that value lands in neither
570/// `by_status` nor a dynamic dim — Tier 1 freezes the facet set; extending
571/// is a Tier 2 concern.
572fn 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        // Stubs carry `entity_type: ""` by construction (store_builder::make_stub).
583        // Skip them here so the facet doesn't expose a meaningless empty-string
584        // bucket — an `entity_type` is semantically undefined for a stub.
585        // Agents that need stub counts already have `stub=true|false` filter +
586        // `memstead_health.stubs`.
587        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    // Deterministic order: count desc, then path asc. Makes the wire shape
630    // stable across runs for snapshot tests + readable for agents.
631    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
648// ---------------------------------------------------------------------------
649// Graph expansion
650// ---------------------------------------------------------------------------
651
652/// Append expanded hits to the primary set. For each primary seed, walk
653/// `edge_types` bidirectionally up to `expand_depth` hops (default 1) and
654/// add neighbours with `expansion: Some(ExpansionInfo)`. Score decays by
655/// `0.5^depth`. Non-query filters (`mem`, `entity_type`, `filters`,
656/// `range_filters`) are enforced on every candidate; violating neighbours
657/// are dropped. Duplicates across multiple seeds are resolved by keeping
658/// the highest-score candidate.
659///
660/// Re-sorting is the caller's job (happens once after expansion so primary
661/// and expanded hits interleave by score).
662fn 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    // Dedup across seeds: if a neighbour is reached from two primaries,
677    // keep the candidate with the highest score so agents see the shortest
678    // / highest-ranking path.
679    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        // Expanded hits follow the same no-section-bodies rule as primary
762        // search hits — see the note at the primary push site.
763        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
799// ---------------------------------------------------------------------------
800// Query derivation helpers
801// ---------------------------------------------------------------------------
802
803/// First positive term across `any` → `phrase`. Drives the single-snippet
804/// surface alongside the per-term snippets in [`compute_matched_terms`].
805fn 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
813/// Pick which mems to query. `None` = every indexed mem; `Some(name)`
814/// narrows to that mem (or empty when the name isn't indexed).
815fn 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
828/// Build a one-line snippet for a hit by finding the first case-insensitive
829/// substring match of `term` in the title or a weighted section. The
830/// per-term snippets with heading-path attribution live in
831/// [`compute_matched_terms`].
832fn 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
860/// Build a snippet showing context around the match.
861pub(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
892// ---------------------------------------------------------------------------
893// Filters
894// ---------------------------------------------------------------------------
895
896fn 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    // Two distinct branches decide whether an entity survives a filter
904    // key it can't equality-match — and they are NOT the same outcome:
905    //
906    // - **Field absent from this entity's type but equality-filterable on
907    //   some other reachable type** → exclude (`return false`). This is
908    //   the deliberate strict type-narrowing: `filters={level:"M0"}`
909    //   excludes memos/stubs that have no `level` field, so the result
910    //   doesn't lie about what matched.
911    // - **Field declared on this entity's type but `Filterable::None`, OR
912    //   absent here but declared only as non-filterable workspace-wide**
913    //   → pass through (`continue`). A non-filterable field can't
914    //   discriminate, so filtering on it is a no-op: the entity survives
915    //   and the result set equals the same search without the filter. The
916    //   `FIELD_NOT_FILTERABLE` warning still fires from
917    //   `collect_equality_filter_warnings`. The narrowing decision is keyed
918    //   on *filterability*, not mere declaration — a non-filterable field
919    //   never type-narrows in either the scoped or unscoped case.
920    //
921    // A key not declared by ANY reachable schema also passes through
922    // (the warning channel flags it `UNKNOWN_FILTER_KEY`) so a single
923    // typo doesn't collapse the result set.
924    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            // Declared but non-filterable — truly ignore (pass through).
938            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/// Workspace-wide verdict on a filter key, keyed on *filterability* rather
965/// than mere declaration. Both the application path (`apply_*_filters`) and
966/// the warning path (`collect_*_filter_warnings`) consult this single
967/// helper so they cannot disagree about what the filter did — the
968/// warning-matches-result contract.
969#[derive(PartialEq, Eq, Clone, Copy)]
970enum FieldFilterability {
971    /// No reachable schema (within `scope_mem` if set) declares the key.
972    Unknown,
973    /// Declared on ≥1 type, but no declaring type marks it filterable in
974    /// the requested mode → the filter is ignored, result = unfiltered.
975    DeclaredNotFilterable,
976    /// Filterable (in the requested mode) on ≥1 declaring type → the
977    /// filter narrows and value-matches.
978    Filterable,
979}
980
981/// Classify `key`'s filterability across the reachable schemas, ignoring
982/// any single reference type. `scope_mem = Some(v)` narrows to that
983/// mem's pinned schema (mirrors [`find_filter_declaring_types`] so the
984/// application and warning paths see the same reachable set); `None` scans
985/// every schema. `range = true` counts only `Filterable::Range`; `false`
986/// (equality) counts `Equality | Range`.
987///
988/// This replaces the old declaration-only `filter_declared_anywhere`
989/// boolean: a key declared only as non-filterable must be *ignored* (result
990/// = unfiltered), not type-narrowed, in both the scoped and unscoped cases.
991/// The deliberate narrowing on a *filterable* field absent from an
992/// entity's type is preserved via the `Filterable` verdict.
993fn 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    // Same two-branch posture as `apply_equality_filters`:
1047    // - Field absent from this type but range-filterable on another
1048    //   reachable type → exclude (narrowing).
1049    // - Field declared on this type but NOT `Filterable::Range` (so `None`
1050    //   or `Equality`), OR absent here but declared only as
1051    //   non-range-filterable workspace-wide → pass through: a
1052    //   non-range-filterable field can't range-discriminate, so the range
1053    //   filter is a no-op and the result set equals the same search without
1054    //   it. The `FIELD_NOT_RANGE_FILTERABLE` warning still fires. The
1055    //   narrowing decision is keyed on range-filterability, not mere
1056    //   declaration.
1057    // Malformed keys (no `min_`/`max_`/`_before`/`_after`) and
1058    // workspace-wide-unknown fields pass through too.
1059    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            // Declared but not range-filterable — truly ignore.
1073            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
1113/// Emit `STUB_FILTER_EXCLUDES_ALL` when both `stub=true` and `entity_type`
1114/// are set. Stubs carry `entity_type: ""` (see store_builder::make_stub),
1115/// so the combined filter excludes every stub by construction. Surfacing
1116/// the impossibility as a typed warning prevents an agent from reading an
1117/// empty hit set as "no such stub exists" when in fact no stub could ever
1118/// satisfy the filter.
1119fn 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                // Field not on the reference type (the scoped type, or the
1141                // engine fallback type in the unscoped case). Classify it
1142                // workspace-wide so the warning matches what the application
1143                // path did: a field declared only as non-filterable is
1144                // ignored (result = unfiltered) and must report
1145                // `FIELD_NOT_FILTERABLE`, not an "applied-with-narrowing"
1146                // code — the fallback type's accident of declaration does
1147                // not decide the outcome.
1148                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                // Filterable field. A comma-bearing value on a csv-array
1167                // field can never equal a single member (members are split
1168                // on comma), so the filter silently matches nothing —
1169                // surface the shape mismatch and the single-member form
1170                // (CLI F8). The filter still applies as written.
1171                if field_def.serialization == Serialization::CsvArray && value.contains(',') {
1172                    warnings.push(WarningHint::FilterValueMultiMember {
1173                        key: key.clone(),
1174                        value: value.clone(),
1175                    });
1176                }
1177                // #52: a value the field's `enum_values` allow-list rejects
1178                // can never match, so a 0-hit result would otherwise be
1179                // indistinguishable from a true no-match. Check per-member
1180                // for csv-array fields (each member is matched singly).
1181                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                // Classify workspace-wide (range mode) so the warning
1218                // matches the application path: a field declared only as
1219                // non-range-filterable is ignored (result = unfiltered) and
1220                // reports `FIELD_NOT_RANGE_FILTERABLE`, not an
1221                // applied-with-narrowing code.
1222                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
1250/// Resolve `entity_type` to a TypeDefinition by consulting the per-mem
1251/// schema map first (narrowed to `preferred_mem`'s schema if provided
1252/// and the type is declared there), then any reachable schema in the
1253/// map, then the builtin default. Used by both filter dispatch (where
1254/// the entity's mem drives resolution) and warning collection (where
1255/// the scope's mem narrows the reachable set).
1256fn 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
1275/// Locate every reachable type that declares `key` as a metadata
1276/// field, regardless of its `filterable` kind. `scope_mem = Some(v)`
1277/// narrows the search to that mem's pinned schema; `None` scans every
1278/// schema in `mem_schemas`. Empty return ⇒ no reachable schema
1279/// declares the filter at all — caller distinguishes the
1280/// "filter-on-other-type(s)" message from the "no-declaration-anywhere"
1281/// message based on the list length.
1282///
1283/// Multi-type result: when a filter (e.g. `status`) is declared on
1284/// several types with disjoint enum values, naming only the first
1285/// match sends the agent toward the wrong type — surface every
1286/// declaring type so the agent picks the right `--type` scope.
1287fn 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    /// Build per-mem tantivy indexes from a store's contents. Used by the
1374    /// unit tests since the search path now goes through tantivy.
1375    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    /// The direction selector threads from `SearchScope` into BOTH
1405    /// walkers: `related_to` membership narrows per direction with the
1406    /// per-hop transitive-closure semantics, expanded hits report the
1407    /// traversal direction, and the default (`both`) returns the
1408    /// historical undirected set.
1409    #[test]
1410    fn search_direction_narrows_related_to_and_expansion() {
1411        // x --USES--> seed --USES--> y --USES--> z
1412        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        // related_to: both (the default) = undirected; out/in narrow.
1439        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        // expand_via: primary hit is `seed`; `out` expands to y and z
1465        // (via_direction Out at each), `in` expands to x only.
1466        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    /// Plan 08 (metadata searchability): a value that exists only in an
1525    /// entity's metadata — declared filterable or not, declared at all
1526    /// or not — is returned by a free-text search; a metadata KEY finds
1527    /// its carriers; the hit is identifiable as a metadata match; a
1528    /// value that exists nowhere still returns zero; and where a term
1529    /// lives in both prose and metadata, the prose hit stays and ranks
1530    /// above the metadata-only hit (below-prose weight).
1531    #[test]
1532    fn search_finds_metadata_values_and_keys() {
1533        let mut store = Store::new();
1534        // `carrier` holds the identifier-shaped value in an UNDECLARED
1535        // metadata field (the default schema declares no `aktenzeichen`).
1536        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        // `prose` carries the shared term in its prose only.
1543        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        // `meta-only` carries the shared term in metadata only.
1550        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        // The motivating case: the identifier-shaped value is found.
1566        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        // …and the hit is identifiable as a metadata match.
1570        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        // The KEY finds its carrier too.
1580        let result = run_search(&store, &q("aktenzeichen"));
1581        assert_eq!(result.hits.len(), 1);
1582        assert_eq!(result.hits[0].title, "carrier");
1583
1584        // A value that exists nowhere returns zero — no spurious matches.
1585        let result = run_search(&store, &q("99/99/999"));
1586        assert!(result.hits.is_empty(), "{result:?}");
1587
1588        // Shared term: the prose hit stays present and ranks above the
1589        // metadata-only hit; the metadata hit is ADDED, nothing dropped.
1590        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    /// The metadata field is ADDITIVE only: an `--exclude` term that
1605    /// exists solely in an entity's metadata must NOT drop that entity
1606    /// from a prose query's results — exclusion consults prose fields
1607    /// only, so the pre-metadata-field result set never shrinks.
1608    /// (Grader counterexample from the plan-08 gate.)
1609    #[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        // Complement: the same token in PROSE still excludes.
1647        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    /// F7: a search scoped to entity_type=T with an unknown filter
1781    /// key must name `T` in the warning, not the schema's default
1782    /// type. Pre-fix the warning generator used the resolved
1783    /// `filter_schema.name` (the default type when `T` doesn't
1784    /// resolve), which read as if the search had been scoped to that
1785    /// unrelated type and cost an agent round-trip while they
1786    /// figured out the mismatch.
1787    #[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    /// F7: when the caller did NOT scope the search to any
1813    /// entity_type, the warning must omit the "for type 'X'" clause
1814    /// rather than name the schema's default type — the user didn't
1815    /// ask about any specific type, so naming one in the warning is
1816    /// misleading.
1817    #[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    /// F7 (range sibling): the range-filter warning has the same
1842    /// scoped-type contract as its equality cousin.
1843    #[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    /// Strict semantics — a filter on `level` (declared by `spec`)
1869    /// excludes entities whose type doesn't declare the field. A
1870    /// non-narrowing variant would pass all entities through and the
1871    /// result would lie about what matched.
1872    #[test]
1873    fn search_equality_filter_excludes_types_without_declared_field() {
1874        let mut store = Store::new();
1875        // Spec entity with the filter field set — must match.
1876        let mut spec_match = make_entity("level-m0", "specs");
1877        spec_match
1878            .metadata
1879            .insert("level".into(), MetadataValue::String("M0".into()));
1880        // Spec entity with the field set to a different value — must
1881        // be excluded by the value check.
1882        let mut spec_other = make_entity("level-m1", "specs");
1883        spec_other
1884            .metadata
1885            .insert("level".into(), MetadataValue::String("M1".into()));
1886        // Memo-typed entity that doesn't declare `level`. It's excluded
1887        // because the workspace-wide schema knows `level`.
1888        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    /// A workspace-wide-unknown filter key continues to warn and pass
1914    /// through (no result collapse on a single typo). Companion to the
1915    /// type-aware exclusion test
1916    /// above — exercises the unknown-key fallback gate inside
1917    /// `classify_filter_field` (the `Unknown` verdict passes through).
1918    #[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        // MCP F2: a filter on a field declared but marked
1953        // `Filterable::None` (here, the
1954        // universal `type` base field) is truly ignored — the result
1955        // set equals the same search without the filter, NOT an empty
1956        // set. Pre-fix this branch `return false`d and emptied the set
1957        // under a "filter ignored" banner; the warning's word and the
1958        // behaviour disagreed. The `FIELD_NOT_FILTERABLE` warning still
1959        // fires so the agent knows the filter had no effect.
1960        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    /// A search scoped to a type with a filter on a field that type
1984    /// declares but marks
1985    /// non-filterable returns the SAME hits as the same search without
1986    /// the filter — "ignored" means unfiltered, not emptied — plus a
1987    /// `FIELD_NOT_FILTERABLE` warning. The code/effect is coherent in
1988    /// the scoped shape just as in the unscoped shape above.
1989    #[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    /// An unscoped filter on a field that IS filterable on some type
2026    /// (here `maturity` on
2027    /// `concept`) narrows the result to the declaring type and carries
2028    /// `FILTER_TYPE_SCOPED` — a code distinct from the truly-unknown-key
2029    /// code, so a consumer branching on `code` alone learns the filter
2030    /// took effect.
2031    #[test]
2032    fn search_unscoped_filterable_field_narrows_with_distinct_code() {
2033        let mut store = Store::new();
2034        // Two concept entities, one matching the filter value.
2035        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        // A spec entity that doesn't declare `maturity` — narrowed away.
2046        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    /// MCP F3: an UNSCOPED filter on a field that is declared only as
2073    /// **non-filterable** (`source_quality`
2074    /// on `assertion`, `Filterable::None`) is ignored, not type-narrowed —
2075    /// the result equals the same search without the filter (the spec
2076    /// entities are retained, not silently dropped to the declaring type) —
2077    /// and the warning reports `FIELD_NOT_FILTERABLE`, not the
2078    /// `FILTER_TYPE_SCOPED` "applied-with-narrowing" code it carried pre-fix
2079    /// (which lied: no value predicate ever ran). Filterability, not the
2080    /// fallback type's accident of declaration, decides the outcome.
2081    #[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        // Both a wrong value and the assertion's real value must return the
2097        // same set as the unfiltered baseline — the filter is ignored, the
2098        // value is never matched. This is the discriminator that separates
2099        // "ignored" from "narrowed".
2100        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    /// MCP F4 (range): an UNSCOPED range filter on a field no type
2124    /// declares as range-filterable does
2125    /// not drop the types that lack the field. `level` is `Filterable::
2126    /// Equality` on `spec`; a `min_level` range filter is ignored, so a
2127    /// `memo` entity (which doesn't declare `level`) is retained rather than
2128    /// silently narrowed away — the warning's "ignored" word now matches the
2129    /// result set.
2130    #[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    /// Range warning, fallback-type independence: an unscoped range
2167    /// filter on a field the engine fallback type does NOT declare but
2168    /// another type declares as
2169    /// equality-only (`maturity` on `concept`) reports
2170    /// `FIELD_NOT_RANGE_FILTERABLE` — keyed on workspace-wide
2171    /// range-filterability, not on whether the fallback type happens to
2172    /// declare it (pre-fix it emitted `RANGE_FILTER_TYPE_SCOPED`).
2173    #[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    /// A truly-unknown filter key (no reachable schema declares it) runs
2206    /// the query unfiltered
2207    /// and carries `UNKNOWN_FILTER_KEY` — the only "ignored" code whose
2208    /// result set equals the unfiltered search via an unknown key.
2209    #[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    /// Range complement: a range filter on a field declared but not
2236    /// range-filterable
2237    /// (`level` is `filterable: equality`) is truly ignored — the
2238    /// result equals the same search without it — and carries
2239    /// `FIELD_NOT_RANGE_FILTERABLE`, not a silent empty.
2240    #[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            // Tiny budget: a single hit already exceeds it, so the page must
2324            // trim to exactly one and warn.
2325            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        // The lead-section summary is still resolved from the entity.
2387        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    // ---- Structured-query semantics ----
2466
2467    #[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    // ---- matched_terms + score_breakdown + heading_path ----
2661
2662    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        // H3 starts at offset 20 in the section content; match "anchor" is at offset 4 (before).
2814        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        // Match under one H3.
2849        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        // Section content:
2883        //   ### Response Shapes
2884        //   some text
2885        //   #### Markdown Output
2886        //   match distinct-keyword here
2887        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        // H2 → H4 directly (no H3). Only the H4 span exists.
2936        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        // Two `### Foo` under the same section; match in second one → path
2970        // carries "Foo" from the second span (same title, distinguished by
2971        // offset containment).
2972        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    // ---- Facets ----
3015
3016    #[test]
3017    fn facets_count_over_full_result_not_page() {
3018        // 12 matching entities; page limit 5. Facets must reflect all 12.
3019        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        // 3 specs in 'specs', 2 memos in 'memos'.
3052        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        // Without graph expansion every hit is primary; no `expanded`
3078        // dim is populated.
3079        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        // Two hits both matching under two distinct sub-sections.
3112        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        // Match falls inside an H2 section that has no H3–H6 spans. No
3162        // `by_subsection` entry should appear for it.
3163        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        // A heading containing a slash must not be split by a delimiter.
3190        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    // ---- Graph expansion via expand_via ----
3259
3260    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    /// An auto-emitted mention edge (`EdgeSource::BodyLink`) — a co-mention,
3274    /// not a typed dependency.
3275    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    /// #54: a `related_to` neighbourhood ranks by proximity — nearer hops
3287    /// first, and a typed (dependency) link to the anchor before a
3288    /// co-mention at the same hop. A small neighbourhood keeps full
3289    /// membership (only ordering changes — the refusal AC).
3290    #[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        // hub —USES→ dep1 (typed, dist 1); hub —REFERENCES(mention)→ men1
3299        // (dist 1); dep1 —USES→ far1 (dist 2 from hub).
3300        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        // Membership unchanged: hub(0) + dep1,men1(1) + far1(2) — all 4.
3321        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    /// #54: a hub neighbourhood larger than the cap is bounded to its
3342    /// nearest N with a `NEIGHBOURHOOD_CAPPED` warning.
3343    #[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            // Facet side check lands below — here, confirm the wire contract:
3425            // expanded hits carry a decayed score_breakdown, no matched_terms.
3426            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        // Facet by_expansion now carries both keys.
3431        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        // Primary is a spec; neighbour is a memo. entity_type filter drops it.
3439        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        // primary --R--> a --R--> b
3477        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        // primary --R--> a --R--> b, depth 2
3558        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        // Production stubs carry `entity_type: ""` (crud::make_stub). When a
3756        // mixed hit-set reaches compute_facets, the empty string must not
3757        // surface as its own `by_type` bucket — the type is semantically
3758        // undefined for a stub. Agents read stub counts from the `stub`
3759        // filter or memstead_health.stubs, not from the type facet.
3760        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(); // match production make_stub
3764        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    /// A hit's summary is resolved against its *own* mem schema at
3779    /// search time,
3780    /// not the global `default` schema. A `software`-schema `requirement`
3781    /// projects its `Statement` anchor — pre-fix `type_by_name` missed it
3782    /// (requirement isn't a `default`-schema type) and rendered `—`.
3783    #[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        // Index + per-mem schema map keyed to the *software* schema, so the
3818        // search op resolves `requirement` against it (not the default schema).
3819        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        // Metadata-only scan returns the requirement.
3830        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        // The envelope projects the anchor section, not the `—` fallback.
3850        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    /// The engine-stamped `created_date` is range-filterable, so the
3860    /// canonical
3861    /// "entities created since X" query works and returns only entities
3862    /// past the bound — pre-fix it warned `FIELD_NOT_RANGE_FILTERABLE`.
3863    #[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    /// Build a one-type schema whose `tags` field is a csv-array,
3897    /// equality-filterable metadata field — the shape CLI F8 is about.
3898    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    /// CLI F8 positive: a comma-bearing value on a csv-array field warns
3944    /// `FILTER_VALUE_MULTI_MEMBER` — the silent zero gets a recoverable
3945    /// signal naming the single-member form.
3946    #[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    /// CLI F8 complement: a single-member value is the supported shape —
3956    /// no multi-member warning.
3957    #[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    /// CLI F8 complement: a genuinely-unknown key still warns
3967    /// `UNKNOWN_FILTER_KEY` (the new advisory is additive, not a
3968    /// replacement).
3969    #[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    /// #52: filtering a valid enum-constrained field with a value outside
3980    /// `enum_values` warns `INVALID_ENUM_VALUE`, so a 0-hit result isn't
3981    /// mistaken for a true no-match.
3982    #[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    /// #52 refusal: a valid enum value filters normally — no false warning.
3992    #[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    /// #52 complement: an unknown field key keeps `UNKNOWN_FILTER_KEY` (the
4002    /// enum check runs only on declared fields), not the enum warning.
4003    #[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}