Skip to main content

memstead_base/
render.rs

1//! Markdown rendering of Engine result types.
2//!
3//! Shared by `memstead-mcp` (wraps output in MCP `CallToolResult`) and
4//! `memstead-cli` (prints directly to stdout).
5
6use std::collections::HashMap;
7use std::sync::{Arc, OnceLock};
8
9use memstead_schema::{
10    FieldType, Filterable, ManualAuthoring, PerEdgeDescription, RelationshipMode, Schema,
11    Serialization, TypeDefinition, all_types, type_by_name,
12};
13use serde::Serialize;
14
15use crate::chunking::estimate_tokens;
16use crate::graph::community::generate_auto_summary;
17use crate::ops::Direction;
18use crate::ops::{ExpansionInfo, Facets, ScoreBreakdown, SubsectionFacet, TermMatch};
19use crate::store::Store;
20use crate::{
21    ContextResult, Edge, Entity, InEdge, ListResult, LouvainOutput, SearchHit, SearchResult,
22};
23
24// ---------------------------------------------------------------------------
25// Entity rendering
26// ---------------------------------------------------------------------------
27
28/// Render a single entity as markdown with frontmatter metadata.
29pub fn render_entity_markdown(entity: &Entity, sections_filter: Option<&[String]>) -> String {
30    let body_text = render_entity_body(entity, sections_filter);
31
32    // Frontmatter — _tokens reflects the rendered output, not the full entity.
33    let mut lines = Vec::new();
34    lines.push("---".to_string());
35    lines.push(format!("_hash: {}", entity.content_hash));
36    // Typed stub provenance — only emitted when the entity carries
37    // a `stub_kind` (real entities are absent from this surface).
38    // Agents reading a stub three calls after the mutation that
39    // produced it recover the diagnostic context that the
40    // mutation-time warning carried.
41    if let Some(kind) = &entity.stub_kind {
42        match kind {
43            crate::entity::StubKind::ForwardReference => {
44                lines.push("_stub_kind: forward_reference".to_string());
45            }
46            crate::entity::StubKind::LoadTime => {
47                lines.push("_stub_kind: load_time".to_string());
48            }
49            crate::entity::StubKind::Residual {
50                since_commit,
51                readonly_referrers,
52            } => {
53                lines.push("_stub_kind: residual".to_string());
54                if !since_commit.is_empty() {
55                    lines.push(format!("_stub_since_commit: {since_commit}"));
56                }
57                if !readonly_referrers.is_empty() {
58                    let refs: Vec<String> =
59                        readonly_referrers.iter().map(|r| r.to_string()).collect();
60                    lines.push(format!("_stub_readonly_referrers: [{}]", refs.join(", ")));
61                }
62            }
63        }
64    }
65    let tokens = estimate_tokens(&body_text);
66    lines.push(format!("_tokens: {tokens}"));
67
68    // When sections are filtered and some were excluded, show full entity size
69    // so agents know how much they're missing.
70    let is_filtered = sections_filter.is_some_and(|f| {
71        let all_keys: Vec<&String> = entity.sections.keys().collect();
72        f.len() < all_keys.len() || !all_keys.iter().all(|k| f.iter().any(|fk| fk == *k))
73    });
74    if is_filtered {
75        let full_body = render_entity_body(entity, None);
76        let full_tokens = estimate_tokens(&full_body);
77        lines.push(format!("_tokens_unfiltered_body: {full_tokens}"));
78    }
79
80    // Emit entity metadata
81    for (key, value) in &entity.metadata {
82        lines.push(format!("{key}: {value}"));
83    }
84    lines.push("---".to_string());
85    lines.push(String::new());
86
87    lines.push(body_text);
88    lines.join("\n")
89}
90
91/// Token estimate for an entity's rendered body (title + sections +
92/// relationships, filter applied) — the exact number `render_entity_markdown`
93/// embeds as its frontmatter `_tokens`. Use this when building a structured
94/// envelope so the envelope's `_tokens` and the markdown channel's frontmatter
95/// `_tokens` describe the *same* thing for a given `_hash`: the rendered body,
96/// not the full markdown document (which would additionally count frontmatter).
97pub fn rendered_body_tokens(entity: &Entity, sections_filter: Option<&[String]>) -> usize {
98    estimate_tokens(&render_entity_body(entity, sections_filter))
99}
100
101/// Build the body (title + sections + relationships) for an entity, optionally filtered.
102///
103/// Section iteration order follows `entity.sections` — an `IndexMap`, so
104/// insertion order is the authoritative render order. The parser inserts keys
105/// in the schema's declared order, which is what ships to clients. Do not
106/// migrate `entity.sections` back to `HashMap`.
107fn render_entity_body(entity: &Entity, sections_filter: Option<&[String]>) -> String {
108    let mut body = Vec::new();
109
110    body.push(format!("# {}", entity.title));
111    body.push(String::new());
112
113    // Look up the entity's TypeDefinition across every built-in schema
114    // so non-default schemas (e.g. `ingest.inconsistency`) get their
115    // declared headings rendered exactly as the on-disk markdown
116    // emitted them. Falls back to key→heading derivation when no
117    // built-in schema declares this type — preserves the prior shape
118    // for custom workspace schemas not yet bridged through the
119    // renderer.
120    let type_def = lookup_builtin_type(&entity.entity_type);
121
122    for (key, content) in &entity.sections {
123        if let Some(filter) = sections_filter
124            && !filter.iter().any(|f| f == key)
125        {
126            continue;
127        }
128        let heading = section_heading_for(type_def.as_deref(), key);
129        body.push(format!("## {heading}"));
130        body.push(String::new());
131        body.push(content.trim().to_string());
132        body.push(String::new());
133    }
134
135    if !entity.relationships.is_empty()
136        && sections_filter.is_none_or(|f| f.iter().any(|s| s == "relationships"))
137    {
138        body.push("## Relationships".to_string());
139        body.push(String::new());
140        for rel in &entity.relationships {
141            // Mirror the on-disk renderer (`entity::generator`):
142            // canonical em-dash delimiter when the relation carries a
143            // per-edge description, simple form otherwise.
144            match rel
145                .description
146                .as_deref()
147                .map(str::trim)
148                .filter(|s| !s.is_empty())
149            {
150                Some(text) => body.push(format!(
151                    "- **{}**: [[{}]] \u{2014} {text}",
152                    rel.rel_type, rel.target
153                )),
154                None => body.push(format!("- **{}**: [[{}]]", rel.rel_type, rel.target)),
155            }
156        }
157        body.push(String::new());
158    }
159
160    body.join("\n")
161}
162
163/// Render a `## Relations` section as markdown — typed edges grouped by
164/// direction. Appended to `memstead_entity` output when `include_relations: true`.
165/// A JSON-shaped version is available via `render_relations_json` for the
166/// `memstead-cli relations --json` consumer.
167pub fn render_relations_markdown(
168    entity_id: &str,
169    outgoing: &[Edge],
170    incoming: &[InEdge],
171) -> String {
172    let mut lines = Vec::new();
173    lines.push(String::new());
174    lines.push("## Relations".to_string());
175    lines.push(String::new());
176
177    if outgoing.is_empty() && incoming.is_empty() {
178        lines.push(format!("(no relations for {entity_id})"));
179        lines.push(String::new());
180        return lines.join("\n");
181    }
182
183    if !outgoing.is_empty() {
184        lines.push("### Outgoing".to_string());
185        for e in outgoing {
186            lines.push(format!("- **{}** → [[{}]]", e.rel_type, e.target));
187        }
188        lines.push(String::new());
189    }
190
191    if !incoming.is_empty() {
192        lines.push("### Incoming".to_string());
193        for e in incoming {
194            lines.push(format!("- [[{}]] → **{}** → (this)", e.from, e.rel_type));
195        }
196        lines.push(String::new());
197    }
198
199    lines.join("\n")
200}
201
202/// Render outgoing/incoming relations as a JSON envelope. Consumed by
203/// `memstead-cli relations --json`; no MCP path uses it.
204pub fn render_relations_json(
205    entity_id: &str,
206    outgoing: &[Edge],
207    incoming: &[InEdge],
208) -> serde_json::Value {
209    let out: Vec<serde_json::Value> = outgoing
210        .iter()
211        .map(|e| {
212            serde_json::json!({
213                "type": e.rel_type,
214                "target": e.target.to_string(),
215                "source": format!("{:?}", e.source).to_lowercase(),
216            })
217        })
218        .collect();
219
220    let inc: Vec<serde_json::Value> = incoming
221        .iter()
222        .map(|e| {
223            serde_json::json!({
224                "type": e.rel_type,
225                "from": e.from.to_string(),
226                "source": format!("{:?}", e.source).to_lowercase(),
227            })
228        })
229        .collect();
230
231    serde_json::json!({
232        "entity": entity_id,
233        "outgoing": out,
234        "incoming": inc,
235    })
236}
237
238// ---------------------------------------------------------------------------
239// Search / List rendering
240// ---------------------------------------------------------------------------
241
242/// Render search results as markdown.
243pub fn render_search_markdown(result: &SearchResult, offset: usize) -> String {
244    let mut lines = Vec::new();
245
246    lines.push("---".to_string());
247    lines.push(format!("_total: {}", result.total));
248    lines.push(format!("_returned: {}", result.returned));
249    lines.push(format!("_offset: {offset}"));
250    lines.push(format!("_total_tokens: {}", result.total_tokens));
251    lines.push("---".to_string());
252    lines.push(String::new());
253
254    if !result.warnings.is_empty() {
255        // Render each search warning with its typed code as the lead — same
256        // shape mutation-tool `## Warnings` blocks already use — so an
257        // agent reading the markdown sees the code without decoding
258        // the structured channel.
259        lines.push("## Filter warnings".to_string());
260        for w in &result.warnings {
261            lines.push(format!("- **{}**: {}", w.code(), w.message()));
262        }
263        lines.push(String::new());
264    }
265
266    if let Some(facets) = &result.facets
267        && let Some(block) = render_facets_block(facets)
268    {
269        lines.push(block);
270    }
271
272    for hit in &result.hits {
273        lines.push(format!(
274            "### {} — {} (_score: {:.1}, _tokens: {})",
275            hit.id, hit.title, hit.score, hit.tokens,
276        ));
277        lines.push(hit_summary_line(hit));
278        if let Some(line) = render_matched_terms_line(hit.matched_terms.as_ref()) {
279            lines.push(line);
280        }
281        if let Some(line) = render_score_breakdown_line(hit.score_breakdown.as_ref()) {
282            lines.push(line);
283        }
284        if let Some(line) = render_heading_paths_line(hit.matched_terms.as_ref()) {
285            lines.push(line);
286        }
287        if let Some(line) = render_expansion_line(hit.expansion.as_ref()) {
288            lines.push(line);
289        }
290        if let Some(snippet) = &hit.snippet {
291            lines.push(format!("> ...{snippet}..."));
292        }
293        lines.push(String::new());
294    }
295
296    lines.join("\n")
297}
298
299/// Render the `## Facets` block for a `SearchResult`. Returns `None` when
300/// every facet bucket is empty — callers elide the section entirely in
301/// that case. Buckets with mixed presence each ship independently.
302///
303/// Ordering: keys inside a bucket sort by count desc, then key asc so the
304/// output is deterministic for tests. `by_subsection` uses its native
305/// stored order (already sorted by count desc in `ops::search`).
306fn render_facets_block(facets: &Facets) -> Option<String> {
307    let blocks: Vec<(&str, String)> = [
308        ("by_type", &facets.by_type),
309        ("by_mem", &facets.by_mem),
310        ("by_level", &facets.by_level),
311        ("by_status", &facets.by_status),
312        ("by_confidence", &facets.by_confidence),
313        ("by_expansion", &facets.by_expansion),
314    ]
315    .into_iter()
316    .filter_map(|(name, bucket)| format_facet_bucket(bucket).map(|s| (name, s)))
317    .collect();
318
319    if blocks.is_empty() && facets.by_subsection.is_empty() {
320        return None;
321    }
322
323    let mut out = String::new();
324    out.push_str("## Facets\n");
325    for (name, body) in blocks {
326        out.push_str(&format!("- **{name}:** {body}\n"));
327    }
328    if !facets.by_subsection.is_empty() {
329        out.push_str("- **by_subsection:**\n");
330        for entry in &facets.by_subsection {
331            out.push_str(&format!("  - {}\n", format_subsection_facet(entry)));
332        }
333    }
334    Some(out)
335}
336
337fn format_facet_bucket(bucket: &HashMap<String, usize>) -> Option<String> {
338    if bucket.is_empty() {
339        return None;
340    }
341    let mut entries: Vec<(&String, &usize)> = bucket.iter().collect();
342    entries.sort_by(|a, b| b.1.cmp(a.1).then_with(|| a.0.cmp(b.0)));
343    Some(
344        entries
345            .iter()
346            .map(|(k, v)| format!("{k}={v}"))
347            .collect::<Vec<_>>()
348            .join(", "),
349    )
350}
351
352fn format_subsection_facet(entry: &SubsectionFacet) -> String {
353    let path = entry.path.join(" › ");
354    format!("`{path}`: {}", entry.count)
355}
356
357/// Render the `**Matched terms:**` line for one hit. `matched_terms`
358/// groups `TermMatch`es per query term; output is one `term (field×N, ...)`
359/// group per term, joined with `, `. Terms and fields both sort
360/// alphabetically for deterministic output.
361fn render_matched_terms_line(matched: Option<&HashMap<String, Vec<TermMatch>>>) -> Option<String> {
362    let matched = matched?;
363    if matched.is_empty() {
364        return None;
365    }
366    let mut terms: Vec<(&String, &Vec<TermMatch>)> = matched.iter().collect();
367    terms.sort_by(|a, b| a.0.cmp(b.0));
368    let groups: Vec<String> = terms
369        .iter()
370        .map(|(term, tms)| {
371            let mut field_counts: HashMap<&str, usize> = HashMap::new();
372            for tm in tms.iter() {
373                *field_counts.entry(tm.field.as_str()).or_insert(0) += 1;
374            }
375            let mut fields: Vec<(&&str, &usize)> = field_counts.iter().collect();
376            fields.sort_by(|a, b| a.0.cmp(b.0));
377            let inner: Vec<String> = fields.iter().map(|(f, n)| format!("{f}×{n}")).collect();
378            format!("`{term}` ({})", inner.join(", "))
379        })
380        .collect();
381    Some(format!("**Matched terms:** {}", groups.join(", ")))
382}
383
384/// Render the `**Score:**` line from a `ScoreBreakdown`. Fields render as
385/// `bm25 X.X + title X.X + <field> X.X [+ expansion_decay ×X.X]`. Zero-
386/// valued components still ship — the breakdown is informational, and the
387/// composition "title 0.0" is itself a fact worth surfacing.
388fn render_score_breakdown_line(breakdown: Option<&ScoreBreakdown>) -> Option<String> {
389    let b = breakdown?;
390    let mut parts: Vec<String> = Vec::new();
391    parts.push(format!("bm25 {:.1}", b.bm25));
392    parts.push(format!("title {:.1}", b.title_boost));
393    let mut fields: Vec<(&String, &f32)> = b.field_weights.iter().collect();
394    fields.sort_by(|a, b| a.0.cmp(b.0));
395    for (k, v) in fields {
396        parts.push(format!("{k} {v:.1}"));
397    }
398    if let Some(decay) = b.expansion_decay {
399        parts.push(format!("expansion_decay ×{decay:.1}"));
400    }
401    Some(format!("**Score:** {}", parts.join(" + ")))
402}
403
404/// Render the `**Heading path:**` line for one hit. Collects distinct
405/// non-empty `heading_path`s across the hit's `TermMatch`es. Single path
406/// renders inline (`A › B`), multiple paths render as `A › B; C › D`.
407fn render_heading_paths_line(matched: Option<&HashMap<String, Vec<TermMatch>>>) -> Option<String> {
408    let matched = matched?;
409    let mut paths: Vec<Vec<String>> = Vec::new();
410    let mut term_keys: Vec<&String> = matched.keys().collect();
411    term_keys.sort();
412    for term in term_keys {
413        for tm in &matched[term] {
414            if let Some(path) = &tm.heading_path
415                && !path.is_empty()
416                && !paths.iter().any(|p| p == path)
417            {
418                paths.push(path.clone());
419            }
420        }
421    }
422    if paths.is_empty() {
423        return None;
424    }
425    let formatted: Vec<String> = paths.iter().map(|p| p.join(" › ")).collect();
426    Some(format!("**Heading path:** {}", formatted.join("; ")))
427}
428
429/// Render the `**Expansion:**` line for one hit — `from <id> via <edge>
430/// (depth N)`.
431fn render_expansion_line(expansion: Option<&ExpansionInfo>) -> Option<String> {
432    let e = expansion?;
433    Some(format!(
434        "**Expansion:** from `{}` via `{}` (depth {})",
435        e.of, e.via_edge, e.depth,
436    ))
437}
438
439/// Render list results as markdown.
440pub fn render_list_markdown(result: &ListResult) -> String {
441    let mut lines = Vec::new();
442
443    lines.push("---".to_string());
444    lines.push(format!("_total: {}", result.total));
445    lines.push(format!("_returned: {}", result.returned));
446    lines.push(format!("_offset: {}", result.offset));
447    lines.push(format!("_total_tokens: {}", result.total_tokens));
448    lines.push("---".to_string());
449    lines.push(String::new());
450
451    if !result.warnings.is_empty() {
452        lines.push("## Filter warnings".to_string());
453        for w in &result.warnings {
454            lines.push(format!("- **{}**: {}", w.code(), w.message()));
455        }
456        lines.push(String::new());
457    }
458
459    for hit in &result.hits {
460        let meta = hit
461            .sections
462            .get("level")
463            .map(|l| format!("{l}, "))
464            .unwrap_or_default();
465        lines.push(format!(
466            "### {} — {} ({meta}_tokens: {})",
467            hit.id, hit.title, hit.tokens,
468        ));
469        lines.push(hit_summary_line(hit));
470        lines.push(String::new());
471    }
472
473    lines.join("\n")
474}
475
476// ---------------------------------------------------------------------------
477// Context / Overview rendering
478// ---------------------------------------------------------------------------
479
480/// Render a `## Community Context` section — cluster id + neighbor list —
481/// appended to `memstead_entity` output when `include_context: true`. No
482/// frontmatter; the entity body owns that.
483pub fn render_community_context_section(result: &ContextResult, cluster_id: &str) -> String {
484    let mut lines = Vec::new();
485    lines.push(String::new());
486    lines.push("## Community Context".to_string());
487    lines.push(String::new());
488    lines.push(format!("**Cluster {cluster_id}**"));
489    lines.push(String::new());
490
491    if !result.neighbors.is_empty() {
492        lines.push("### Neighbors".to_string());
493        for n in &result.neighbors {
494            let dir = match n.direction {
495                Direction::Outgoing => "→",
496                Direction::Incoming => "←",
497            };
498            lines.push(format!(
499                "- {} —{}— **{}** ({})",
500                result.entity_id, dir, n.id, n.relationship,
501            ));
502        }
503        lines.push(String::new());
504    }
505
506    lines.join("\n")
507}
508
509/// Render context (community cluster) as markdown.
510pub fn render_context_markdown(result: &ContextResult, cluster_id: &str) -> String {
511    let mut lines = Vec::new();
512
513    lines.push("---".to_string());
514    lines.push(format!("_cluster_id: {cluster_id}"));
515    lines.push("---".to_string());
516    lines.push(String::new());
517    lines.push(format!("## Cluster {cluster_id}"));
518    lines.push(String::new());
519
520    // Neighbors grouped by direction
521    lines.push("### Neighbors".to_string());
522    for n in &result.neighbors {
523        let dir = match n.direction {
524            Direction::Outgoing => "→",
525            Direction::Incoming => "←",
526        };
527        lines.push(format!(
528            "- {} —{}— **{}** ({})",
529            result.entity_id, dir, n.id, n.relationship,
530        ));
531    }
532    lines.push(String::new());
533
534    lines.join("\n")
535}
536
537/// Render overview (all clusters) as markdown. `store` provides entity titles
538/// for the on-the-fly auto-summary (title-join) — there is no stored summary.
539pub fn render_overview_markdown(output: &LouvainOutput, store: &Store) -> String {
540    let mut lines = Vec::new();
541
542    let entity_count: usize = output.clusters.values().map(|c| c.entities.len()).sum();
543
544    lines.push("---".to_string());
545    lines.push(format!("_cluster_count: {}", output.count));
546    lines.push(format!("_entity_count: {entity_count}"));
547    // Use compact formatting to match JS: "0" instead of "0.0000"
548    let mod_str = if output.modularity == 0.0 {
549        "0".to_string()
550    } else {
551        format!("{:.4}", output.modularity)
552    };
553    lines.push(format!("_modularity: {mod_str}"));
554    lines.push("---".to_string());
555    lines.push(String::new());
556
557    // Sort clusters by ID for deterministic output
558    let mut cluster_ids: Vec<&String> = output.clusters.keys().collect();
559    cluster_ids.sort();
560
561    for cluster_id in cluster_ids {
562        let info = &output.clusters[cluster_id];
563        let summary = generate_auto_summary(store, &info.entities);
564
565        lines.push(format!(
566            "## Cluster {cluster_id} ({} entities)",
567            info.entities.len(),
568        ));
569        if !summary.is_empty() {
570            lines.push(summary);
571        }
572        for entity_id in &info.entities {
573            lines.push(format!("- {entity_id}"));
574        }
575        lines.push(String::new());
576    }
577
578    lines.join("\n")
579}
580
581// ---------------------------------------------------------------------------
582// JSON envelopes for search / list — consumed by `memstead-cli` only
583// ---------------------------------------------------------------------------
584//
585// These wrap the core `SearchResult` / `ListResult` with precomputed
586// `summary_heading` / `summary_value` per hit — the same values the
587// markdown renderer emits — so the CLI's `--json` output doesn't
588// reimplement schema lead-section lookup. The MCP side carries no JSON
589// sidecar; these envelopes remain on the `memstead-cli search --json` /
590// `memstead-cli list --json` path.
591//
592// Snake-case field names are intentional: they match on-disk YAML and the
593// core `SearchHit` struct. Do not add `rename_all = "camelCase"`.
594
595/// Envelope wrapping a `SearchHit` with precomputed summary fields.
596#[derive(Serialize)]
597pub struct SearchHitEnvelope<'a> {
598    #[serde(flatten)]
599    pub hit: &'a SearchHit,
600    pub summary_heading: String,
601    pub summary_value: String,
602}
603
604/// Envelope for a full `SearchResult`:
605/// `_-prefixed` engine-emitted counters at the top level, `facets`
606/// as a structured object (not a markdown blob), and the full per-hit
607/// shape (score, score_breakdown, matched_terms, expansion) inherited
608/// verbatim from `SearchHit` so the structured envelope is the
609/// branching surface — agents reading `structured_content` don't have
610/// to parse the text channel's rendered prose to recover scores or
611/// score components. CLI `--json` and MCP `structured_content` share
612/// this shape.
613#[derive(Serialize)]
614pub struct SearchResultEnvelope<'a> {
615    #[serde(rename = "_total")]
616    pub total: usize,
617    #[serde(rename = "_returned")]
618    pub returned: usize,
619    #[serde(rename = "_offset")]
620    pub offset: usize,
621    /// Sum of estimated tokens across all matching entities (pre-pagination).
622    /// Mirrors `ListResultEnvelope.total_tokens` so the field has consistent
623    /// meaning across both surfaces — migration cost for agents is zero.
624    #[serde(rename = "_total_tokens")]
625    pub total_tokens: usize,
626    pub hits: Vec<SearchHitEnvelope<'a>>,
627    /// Faceted counts over the unpaginated hit set. Skipped on the
628    /// wire when the engine produced no facets (rare; the unified
629    /// engine always populates an empty `Facets::default()` for
630    /// shape stability).
631    #[serde(skip_serializing_if = "Option::is_none")]
632    pub facets: Option<&'a Facets>,
633    #[serde(skip_serializing_if = "Vec::is_empty")]
634    pub warnings: &'a Vec<crate::ops::WarningHint>,
635}
636
637/// Envelope for a full `ListResult`. The engine-meta counters carry the
638/// same `_`-prefixed wire keys as [`SearchResultEnvelope`] (and as both
639/// surfaces' markdown form) so an agent moving between `memstead list --json`
640/// and `memstead search --json` parses one envelope-meta convention. The
641/// `_` prefix reads as "engine-meta, not entity content".
642#[derive(Serialize)]
643pub struct ListResultEnvelope<'a> {
644    #[serde(rename = "_total")]
645    pub total: usize,
646    #[serde(rename = "_returned")]
647    pub returned: usize,
648    #[serde(rename = "_offset")]
649    pub offset: usize,
650    #[serde(rename = "_total_tokens")]
651    pub total_tokens: usize,
652    pub hits: Vec<SearchHitEnvelope<'a>>,
653    #[serde(skip_serializing_if = "Vec::is_empty")]
654    pub warnings: &'a Vec<crate::ops::WarningHint>,
655}
656
657/// Build the structured `memstead_entity` envelope. Identity fields
658/// (`_hash`, `id`, `mem`, `type`, `_stub_kind`) come from the parsed
659/// `Entity` and live at the top level. Every schema-declared frontmatter
660/// key surfaces under a nested `metadata: {...}` map — its single home.
661/// Read a metadata
662/// value as `envelope.metadata.<key>`; generic consumers iterate the map
663/// without per-type branching. The prior shape additionally hoisted
664/// `level`/`stability`/`created_date`/`last_modified` to the top level,
665/// serialising those fields twice; that hoist is gone. The read-only
666/// identity triple (`mem`/`id`/`type`) is excluded from the nested map
667/// — it appears only top-level — and underscore-prefixed internal keys
668/// (`_hash`, `_tokens*`, `_mem_schema`, `_stub_*`) live in dedicated
669/// top-level slots and never appear inside the nested map. `sections` and
670/// `relationships` round-trip the engine's internal IndexMap / Vec
671/// shapes verbatim. `_tokens` is computed from the rendered body
672/// (filter and opt-in inserts applied) so agents can pre-size before
673/// a follow-up `token_budget`-bounded read. `_mem_schema` rides
674/// when the workspace pinned a schema for the mem.
675///
676/// Per-section filtering applies — when `sections_filter` is
677/// `Some`, the structured `sections` map carries only the requested
678/// keys (matching the markdown projection). The unfiltered-base
679/// token cost surfaces as `_tokens_unfiltered_body` so agents can
680/// predict the cost of dropping the filter. The name avoids implying a
681/// monotonic relationship (`_tokens_unfiltered_body ≥ _tokens`) that the
682/// opt-in (`include_relations` / `include_context`) path can invert:
683/// opt-in inserts contribute to `_tokens` but not to this baseline. Stub
684/// entities ship every key with empty `sections` / `relationships`
685/// arrays.
686///
687/// The structured envelope is the contract for `memstead_entity`:
688/// agents read `_hash`, sections, and relations from typed fields
689/// rather than string-scraping the markdown frontmatter.
690pub fn build_entity_envelope(
691    entity: &Entity,
692    rendered_body_tokens: usize,
693    full_tokens: Option<usize>,
694    sections_filter: Option<&[String]>,
695    schema_anchor: Option<&str>,
696    outgoing_edges: &[crate::store::Edge],
697) -> serde_json::Value {
698    let mut envelope = serde_json::Map::new();
699    envelope.insert(
700        "_hash".to_string(),
701        serde_json::Value::String(entity.content_hash.clone()),
702    );
703    envelope.insert(
704        "id".to_string(),
705        serde_json::Value::String(entity.id.to_string()),
706    );
707    envelope.insert(
708        "mem".to_string(),
709        serde_json::Value::String(entity.mem.clone()),
710    );
711    envelope.insert(
712        "type".to_string(),
713        serde_json::Value::String(entity.entity_type.clone()),
714    );
715
716    // Metadata has exactly one home on the envelope — the nested
717    // `metadata` map. Scalars like `level`/`stability`/`created_date`/
718    // `last_modified` are NOT hoisted to the top level; agents read
719    // `envelope.metadata.<key>`. The nested map is authoritative because
720    // it carries every schema-declared frontmatter key (including
721    // type-specific fields a top-level hoist never covered).
722    //
723    // Identity keys stay top-level and are excluded here so they too
724    // appear exactly once: `_hash`, `id`, `mem`, `type` are the
725    // entity's structural identity (inserted above), not free-form
726    // metadata. `mem`/`id`/`type` is the engine's read-only key triple
727    // (`READ_ONLY_METADATA_KEYS`); `_`-prefixed internal keys live in
728    // dedicated top-level slots (`_tokens*`, `_mem_schema`, `_stub_*`).
729    // Stub entities surface an empty `metadata: {}` so consumers don't
730    // branch on its presence.
731    let mut metadata = serde_json::Map::new();
732    for (key, value) in &entity.metadata {
733        if key.starts_with('_')
734            || crate::runtime_validator::READ_ONLY_METADATA_KEYS.contains(&key.as_str())
735        {
736            continue;
737        }
738        metadata.insert(
739            key.clone(),
740            serde_json::Value::String(value.to_frontmatter_string()),
741        );
742    }
743    envelope.insert("metadata".to_string(), serde_json::Value::Object(metadata));
744
745    envelope.insert(
746        "_tokens".to_string(),
747        serde_json::Value::Number(serde_json::Number::from(rendered_body_tokens)),
748    );
749    if let Some(t) = full_tokens {
750        // This measures the unfiltered base body cost without
751        // `include_relations` / `include_context` opt-in inserts.
752        // `_tokens` may exceed `_tokens_unfiltered_body` when opt-ins
753        // are active (the opt-in inserts contribute to `_tokens` but not
754        // to this baseline) — the field name avoids implying a monotonic
755        // relationship the opt-in path can invert.
756        envelope.insert(
757            "_tokens_unfiltered_body".to_string(),
758            serde_json::Value::Number(serde_json::Number::from(t)),
759        );
760    }
761    if let Some(s) = schema_anchor {
762        envelope.insert(
763            "_mem_schema".to_string(),
764            serde_json::Value::String(s.to_string()),
765        );
766    }
767
768    if let Some(kind) = &entity.stub_kind {
769        envelope.insert(
770            "_stub_kind".to_string(),
771            serde_json::to_value(kind).unwrap_or(serde_json::Value::Null),
772        );
773    }
774
775    let mut sections = serde_json::Map::new();
776    for (key, content) in &entity.sections {
777        if let Some(filter) = sections_filter
778            && !filter.iter().any(|f| f == key)
779        {
780            continue;
781        }
782        sections.insert(key.clone(), serde_json::Value::String(content.clone()));
783    }
784    envelope.insert("sections".to_string(), serde_json::Value::Object(sections));
785
786    // Resolve each relationship's `source` label against the store's
787    // outgoing-edge index. A hardcoded `"explicit"` would disagree
788    // with the stub-adoption
789    // response's `incoming[].source` for alias-synthesised
790    // REFERENCES edges (and was actively misleading because
791    // REFERENCES carries `manual_authoring: forbidden` — no edge of
792    // that rel-type can be authored explicitly). The store's
793    // `EdgeSource` is the single source of truth; the markdown
794    // round-trip (which doesn't encode source) is no longer
795    // consulted for this field.
796    let resolve_source = |rel: &crate::entity::Relationship| -> &'static str {
797        outgoing_edges
798            .iter()
799            .find(|e| e.rel_type == rel.rel_type && e.target == rel.target)
800            .map(|e| match e.source {
801                crate::store::EdgeSource::BodyLink => "body_link",
802                crate::store::EdgeSource::Hierarchy => "hierarchy",
803                crate::store::EdgeSource::Explicit => "explicit",
804            })
805            .unwrap_or("explicit")
806    };
807    let relationships = entity
808        .relationships
809        .iter()
810        .map(|rel| {
811            let mut obj = serde_json::Map::new();
812            obj.insert(
813                "rel_type".to_string(),
814                serde_json::Value::String(rel.rel_type.clone()),
815            );
816            obj.insert(
817                "target".to_string(),
818                serde_json::Value::String(rel.target.to_string()),
819            );
820            obj.insert(
821                "source".to_string(),
822                serde_json::Value::String(resolve_source(rel).to_string()),
823            );
824            if let Some(desc) = rel
825                .description
826                .as_deref()
827                .map(str::trim)
828                .filter(|s| !s.is_empty())
829            {
830                obj.insert(
831                    "description".to_string(),
832                    serde_json::Value::String(desc.to_string()),
833                );
834            }
835            serde_json::Value::Object(obj)
836        })
837        .collect();
838    envelope.insert(
839        "relationships".to_string(),
840        serde_json::Value::Array(relationships),
841    );
842
843    serde_json::Value::Object(envelope)
844}
845
846/// Build a `SearchResultEnvelope` borrowing from `result`.
847pub fn build_search_envelope<'a>(
848    result: &'a SearchResult,
849    offset: usize,
850) -> SearchResultEnvelope<'a> {
851    SearchResultEnvelope {
852        total: result.total,
853        returned: result.returned,
854        offset,
855        total_tokens: result.total_tokens,
856        hits: result.hits.iter().map(build_hit_envelope).collect(),
857        facets: result.facets.as_ref(),
858        warnings: &result.warnings,
859    }
860}
861
862/// Build a `ListResultEnvelope` borrowing from `result`.
863pub fn build_list_envelope(result: &ListResult) -> ListResultEnvelope<'_> {
864    ListResultEnvelope {
865        total: result.total,
866        returned: result.returned,
867        offset: result.offset,
868        total_tokens: result.total_tokens,
869        hits: result.hits.iter().map(build_hit_envelope).collect(),
870        warnings: &result.warnings,
871    }
872}
873
874fn build_hit_envelope(hit: &SearchHit) -> SearchHitEnvelope<'_> {
875    let (heading, value) = hit_summary_pair(hit);
876    SearchHitEnvelope {
877        hit,
878        summary_heading: heading,
879        summary_value: value,
880    }
881}
882
883// ---------------------------------------------------------------------------
884// Helpers
885// ---------------------------------------------------------------------------
886
887/// Build the one-line summary for a search/list hit.
888///
889/// Resolves the hit's schema and uses its lead section (first required, or
890/// first section if none are required) as the label. Never panics — unknown
891/// schemas or schemas with no sections fall back to `**Summary**: —`.
892fn hit_summary_line(hit: &SearchHit) -> String {
893    let (heading, value) = hit_summary_pair(hit);
894    format!("**{heading}**: {value}")
895}
896
897/// Resolve `(heading, value)` for a hit's summary line — the single source of
898/// truth for lead-section lookup. Used by both markdown rendering and the
899/// structured-content envelope.
900///
901/// Prefers the engine-precomputed [`SearchHit::summary`] (resolved against the
902/// hit's own mem schema at search time). Falls back to the global
903/// `type_by_name` lookup only for hits built outside the search op (FFI/bridge
904/// and test fixtures) — that fallback sees only the `default` schema, which is
905/// why the engine resolves the pair where the per-mem schema is in hand.
906fn hit_summary_pair(hit: &SearchHit) -> (String, String) {
907    if let Some(summary) = &hit.summary {
908        return (summary.heading.clone(), summary.value.clone());
909    }
910    summary_pair(type_by_name(&hit.entity_type).as_deref(), &hit.sections)
911}
912
913/// Resolve `(heading, value)` given a schema and the hit's section map.
914fn summary_pair(
915    schema: Option<&TypeDefinition>,
916    sections: &HashMap<String, String>,
917) -> (String, String) {
918    match schema {
919        Some(schema) => lead_section_pair(schema, |k| sections.get(k).map(String::as_str)),
920        None => ("Summary".to_string(), "—".to_string()),
921    }
922}
923
924/// The lead-section `(heading, value)` for a hit given its resolved schema:
925/// the first required section (or the first section when none are required),
926/// with its value pulled from `sections`. Returns `("Summary", "—")` when the
927/// type declares no sections, and an honest `"—"` value when the lead section
928/// is absent/empty in this hit. The single source of truth shared by the
929/// render-time fallback ([`summary_pair`]) and the search op, which calls it
930/// with each hit's correctly-resolved per-mem schema.
931pub(crate) fn lead_section_pair<'a>(
932    schema: &TypeDefinition,
933    get_section: impl Fn(&str) -> Option<&'a str>,
934) -> (String, String) {
935    let Some(section) = schema
936        .required_sections()
937        .next()
938        .or(schema.sections.first())
939    else {
940        return ("Summary".to_string(), "—".to_string());
941    };
942    let value = get_section(section.key.as_str()).unwrap_or("—");
943    (section.heading.clone(), value.to_string())
944}
945
946/// Convert a section key to a display heading via the simple
947/// derivation: first char uppercased, underscores → spaces. Used as
948/// a fallback when no schema-declared heading is available.
949fn section_key_to_heading(key: &str) -> String {
950    let mut chars = key.chars();
951    match chars.next() {
952        None => String::new(),
953        Some(c) => {
954            let first: String = c.to_uppercase().collect();
955            let rest: String = chars.map(|c| if c == '_' { ' ' } else { c }).collect();
956            format!("{first}{rest}")
957        }
958    }
959}
960
961/// Resolve the heading for `key` from the type's declared sections;
962/// fall back to the key-derivation when the type is unknown or the
963/// key is not declared (e.g. the `relationships` virtual surface, or
964/// catch-all extra keys). The schema-declared heading is the on-disk
965/// truth — the renderer must echo it so rendered text matches the
966/// markdown file content.
967fn section_heading_for(type_def: Option<&TypeDefinition>, key: &str) -> String {
968    type_def
969        .and_then(|t| t.sections.iter().find(|s| s.key == key))
970        .map(|s| s.heading.clone())
971        .unwrap_or_else(|| section_key_to_heading(key))
972}
973
974/// Search every built-in schema for `name`, returning the first match.
975/// Caches the loaded schema list via `OnceLock` so subsequent renders
976/// pay only the HashMap lookup cost.
977///
978/// Distinct from `memstead_schema::type_by_name`, which is limited to the
979/// `default` schema — that helper exists for legacy short-name lookups
980/// and is left unchanged here. Custom workspace schemas (not embedded
981/// in the binary) still fall through to the key-derivation path.
982fn lookup_builtin_type(name: &str) -> Option<Arc<TypeDefinition>> {
983    static CACHE: OnceLock<Vec<Arc<Schema>>> = OnceLock::new();
984    let schemas =
985        CACHE.get_or_init(|| memstead_schema::builtins::load_builtin_schemas().unwrap_or_default());
986    for s in schemas {
987        if let Some(t) = s.get_type(name) {
988            return Some(t);
989        }
990    }
991    None
992}
993
994// ---------------------------------------------------------------------------
995// Schema introspection rendering
996// ---------------------------------------------------------------------------
997
998/// Render the full schema catalog as markdown — built-in default types.
999pub fn render_type_catalog_markdown() -> String {
1000    render_type_catalog_lines(all_types())
1001}
1002
1003/// Render the type catalog for an arbitrary loaded [`Schema`].
1004/// Same shape as [`render_type_catalog_markdown`]; iterates the
1005/// schema's own types in name order so multi-mem workspaces can
1006/// describe the schema pinned by the writable mem, not the engine's
1007/// hard-coded built-in.
1008pub fn render_type_catalog_markdown_for(schema: &Schema) -> String {
1009    let mut types: Vec<Arc<TypeDefinition>> = schema.types.values().cloned().collect();
1010    types.sort_by(|a, b| a.name.as_str().cmp(b.name.as_str()));
1011    render_type_catalog_lines(types)
1012}
1013
1014fn render_type_catalog_lines(types: Vec<Arc<TypeDefinition>>) -> String {
1015    let mut lines = vec![
1016        "# Available types".to_string(),
1017        String::new(),
1018        "Run `memstead type <name>` (or call `memstead_schema` with a type name) to see its metadata fields, sections, relationship types, and writing guidance."
1019            .to_string(),
1020        String::new(),
1021    ];
1022    for schema in types {
1023        let required_sections = schema.required_sections().count();
1024        let total_sections = schema.sections.len();
1025        let metadata_count = schema.metadata_fields.len();
1026        lines.push(format!(
1027            "- **{}** — {} sections ({} required), {} metadata fields, staleness {}d",
1028            schema.name.as_str(),
1029            total_sections,
1030            required_sections,
1031            metadata_count,
1032            schema.staleness_threshold_days,
1033        ));
1034    }
1035    lines.push(String::new());
1036    lines.join("\n")
1037}
1038
1039/// Render a single type's definition as agent-friendly markdown.
1040pub fn render_type_info_markdown(schema: &TypeDefinition) -> String {
1041    let mut lines = Vec::new();
1042    lines.push(format!("# Type: {}", schema.name.as_str()));
1043    lines.push(String::new());
1044    lines.push(format!(
1045        "Staleness threshold: {} days. Hierarchy: `{}`.",
1046        schema.staleness_threshold_days, schema.hierarchy_relationship,
1047    ));
1048    lines.push(String::new());
1049
1050    // Metadata fields
1051    lines.push("## Metadata fields".to_string());
1052    for field in &schema.metadata_fields {
1053        lines.push(format!("- {}", describe_metadata_field(field)));
1054    }
1055    lines.push(String::new());
1056
1057    // Sections
1058    lines.push("## Sections".to_string());
1059    for section in &schema.sections {
1060        let req = if section.required {
1061            "required"
1062        } else {
1063            "optional"
1064        };
1065        let catch_all = if section.catch_all { ", catch-all" } else { "" };
1066        lines.push(format!(
1067            "- **{}** ({req}{catch_all}, search_weight: {:.1})",
1068            section.key, section.search_weight,
1069        ));
1070        for rule in &section.write_rules {
1071            lines.push(format!("  - Write rule: {rule}"));
1072        }
1073    }
1074    lines.push(String::new());
1075
1076    // Relationship types
1077    lines.push("## Relationship types (with edge weights)".to_string());
1078    for (rel_type, weight) in &schema.edge_weights {
1079        if rel_type == "_default" {
1080            continue;
1081        }
1082        let mut flags: Vec<&str> = Vec::new();
1083        if rel_type == &schema.hierarchy_relationship {
1084            flags.push("hierarchy");
1085        }
1086        if schema
1087            .propagating_relationships
1088            .iter()
1089            .any(|r| r == rel_type)
1090        {
1091            flags.push("propagating");
1092        }
1093        let flag_str = if flags.is_empty() {
1094            String::new()
1095        } else {
1096            format!(" ({})", flags.join(", "))
1097        };
1098        lines.push(format!("- **{rel_type}**: {weight}{flag_str}"));
1099    }
1100    // Default weight
1101    if let Some((_, default_weight)) = schema.edge_weights.iter().find(|(n, _)| *n == "_default") {
1102        lines.push(format!(
1103            "- _default_ (any other relationship type): {default_weight}"
1104        ));
1105    }
1106    lines.push(String::new());
1107
1108    // Writing guidance (schema-level)
1109    if !schema.write_rules.is_empty() {
1110        lines.push("## Writing guidance".to_string());
1111        for rule in &schema.write_rules {
1112            lines.push(format!("- {rule}"));
1113        }
1114        lines.push(String::new());
1115    }
1116
1117    // System context
1118    let system_msg = schema.system_message_str();
1119    if !system_msg.is_empty() {
1120        lines.push("## System context".to_string());
1121        lines.push(system_msg.to_string());
1122        lines.push(String::new());
1123    }
1124
1125    lines.join("\n")
1126}
1127
1128/// Render a [`PerEdgeDescription`] to its wire literal — bit-identical to
1129/// what the schema YAML accepts so consumers can echo the value back
1130/// without case fiddling. `forbidden` (the default) is emitted explicitly
1131/// rather than omitted so a schema without an explicit declaration still
1132/// surfaces the resolved posture on the wire.
1133pub fn per_edge_description_str(p: PerEdgeDescription) -> &'static str {
1134    match p {
1135        PerEdgeDescription::Forbidden => "forbidden",
1136        PerEdgeDescription::Optional => "optional",
1137        PerEdgeDescription::Required => "required",
1138    }
1139}
1140
1141/// Stable wire string for the `manual_authoring` posture.
1142pub fn manual_authoring_str(p: ManualAuthoring) -> &'static str {
1143    match p {
1144        ManualAuthoring::Allow => "allow",
1145        ManualAuthoring::Warn => "warn",
1146        ManualAuthoring::Forbidden => "forbidden",
1147    }
1148}
1149
1150/// Verbosity selector for [`build_schema_payload`].
1151///
1152/// `Full` is the complete payload — every description, `when_to_use`,
1153/// write-rule, and writing-guidance string. `Lite` drops that long-form
1154/// prose and returns a structural skeleton: entity-type names with their
1155/// section keys and metadata-field shapes, relationship names with their
1156/// allowed endpoints. The skeleton keeps every *flag* an agent needs to
1157/// author a legal write — the alias-model pointer, required-section and
1158/// required-field markers, endpoint constraints, the manual-authoring
1159/// posture, the `acyclic` flag, and the per-edge-description posture — so
1160/// a lite caller can plan a write without round-tripping to full and
1161/// without walking into a write-time refusal. Full and lite emit the two
1162/// heavy arrays under *distinct keys* (`types` / `relationships` vs.
1163/// `types_summary` / `relationships_summary`), so a consumer decodes by
1164/// key presence rather than by branching on the request shape.
1165#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
1166pub enum SchemaVerbosity {
1167    #[default]
1168    Full,
1169    Lite,
1170}
1171
1172impl SchemaVerbosity {
1173    /// Parse the wire token (`"full"` / `"lite"`). Returns `None` for an
1174    /// unrecognized token so the calling surface can raise a typed error
1175    /// naming the bad value rather than silently defaulting. An absent
1176    /// parameter maps to `Full` at the call site, not here.
1177    pub fn from_wire(s: &str) -> Option<Self> {
1178        match s {
1179            "full" => Some(Self::Full),
1180            "lite" => Some(Self::Lite),
1181            _ => None,
1182        }
1183    }
1184
1185    /// The wire token for this verbosity.
1186    pub fn as_wire(self) -> &'static str {
1187        match self {
1188            Self::Full => "full",
1189            Self::Lite => "lite",
1190        }
1191    }
1192}
1193
1194/// Trust origin of a schema (or the mem that pins it), decided at
1195/// adopt/write time and reported — never re-derived — on the read path.
1196///
1197/// `FirstParty` is an engine built-in or a schema authored/explicitly
1198/// trusted in this workspace. Its prose-instruction fields
1199/// (`system_context`, `write_rules`, `writing_guidance`, `when_to_use`,
1200/// prose `description`, `default_writing_guidance`) guide *authoring* in
1201/// this workspace and are served in full.
1202///
1203/// `ThirdParty` is a schema that arrived from outside this workspace
1204/// (registry-installed or adopted from a foreign folder/clone) and has
1205/// not been explicitly trusted. Memstead's value proposition pulls a
1206/// mem's schema directly into a consuming agent's context, where the
1207/// schema's free-text fields are framed *as instructions* ("System
1208/// context", "Writing guidance"). A third-party schema is therefore
1209/// served structural-only: [`build_schema_payload`] forces the
1210/// [`SchemaVerbosity::Lite`] skeleton regardless of the requested
1211/// verbosity, omitting every prose-instruction field. This is lossless
1212/// for the legitimate use case — the omitted fields only guide writing,
1213/// and a write never targets a foreign mem.
1214///
1215/// The class is unforgeable by a publisher: it is decided by *how* the
1216/// schema entered the workspace, not by any content the schema carries.
1217/// An unknown/ambiguous origin classifies `ThirdParty` — the safe
1218/// default (a stranger's prose is never served as first-party
1219/// instructions on the strength of a missing label).
1220#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
1221pub enum OriginClass {
1222    /// Engine built-in, or authored/explicitly trusted in this workspace.
1223    FirstParty,
1224    /// Arrived from outside this workspace and not explicitly trusted.
1225    /// The safe default for an unlabelled/ambiguous origin.
1226    #[default]
1227    ThirdParty,
1228}
1229
1230impl OriginClass {
1231    /// The wire token for this origin (`"first-party"` / `"third-party"`),
1232    /// emitted on every schema read so a consuming host can quarantine
1233    /// non-first-party content.
1234    pub fn as_wire(self) -> &'static str {
1235        match self {
1236            Self::FirstParty => "first-party",
1237            Self::ThirdParty => "third-party",
1238        }
1239    }
1240
1241    /// Whether this origin must have its schema served structural-only
1242    /// (prose-instruction fields omitted) on the read path.
1243    pub fn is_third_party(self) -> bool {
1244        matches!(self, Self::ThirdParty)
1245    }
1246}
1247
1248/// Build the transport-neutral, rmcp-free JSON payload for a schema read
1249/// (`memstead_schema`). Shared by the MCP server, the HTTP surface, and
1250/// the filesystem-mem MCP flavour so every surface emits identical
1251/// schema-read bytes from one source. `used_by` lists the writable mems
1252/// whose pinned schema resolves to this one; `verbosity` toggles the full
1253/// payload versus the lightweight skeleton (see [`SchemaVerbosity`]).
1254///
1255/// `origin` ([`OriginClass`]) is reported on the wire as `origin` and
1256/// governs de-framing: a [`OriginClass::ThirdParty`] schema is served
1257/// structural-only — the requested `verbosity` is overridden to
1258/// [`SchemaVerbosity::Lite`] so none of its prose-instruction fields
1259/// (`system_context`, `write_rules`, `writing_guidance`, `when_to_use`,
1260/// prose `description`, `default_writing_guidance`) reach a consuming
1261/// agent as instructions. A `full`-verbosity request on a third-party
1262/// schema therefore still omits them — the override is one-directional.
1263pub fn build_schema_payload(
1264    schema: &Arc<Schema>,
1265    used_by: Vec<String>,
1266    verbosity: SchemaVerbosity,
1267    origin: OriginClass,
1268) -> serde_json::Value {
1269    let manifest = &schema.manifest;
1270    // De-frame third-party schemas: their prose-instruction fields only
1271    // guide authoring (which never targets a foreign mem), so omitting
1272    // them is lossless — and serving them would place a stranger's
1273    // free-text in the consuming agent's instruction context. The Lite
1274    // skeleton keeps every structural flag an agent needs to understand
1275    // and query the mem. The override is one-directional: a `full`
1276    // request cannot re-admit the prose for a third-party schema.
1277    let verbosity = if origin.is_third_party() {
1278        SchemaVerbosity::Lite
1279    } else {
1280        verbosity
1281    };
1282
1283    // `_default` is the schema's internal weight-fallback knob — it
1284    // sets the edge weight every `_default`-less rel-type inherits and
1285    // is *not* a usable rel-type on `memstead_relate` (the relate path
1286    // rejects it with `INVALID_REL_TYPE`). Surfacing it in the agent-
1287    // facing vocabulary cost one round-trip per
1288    // session as agents tried it and learned the asymmetry by trial,
1289    // so it is suppressed here: the schema response advertises only
1290    // the rel-types `memstead_relate` actually accepts. Schemas that
1291    // declare `_default` for weight purposes are unaffected — the
1292    // engine still consults it for `edge_weight` fallback.
1293    let relationships: Vec<serde_json::Value> = manifest
1294        .relationships
1295        .definitions
1296        .iter()
1297        .filter(|d| d.name != "_default")
1298        .map(|d| {
1299            // Surface the `acyclic` flag so agents can predict cycle-check
1300            // refusal from introspection without trial-and-error.
1301            // Combined with each type's `propagating_relationships`
1302            // list (below), the schema response fully describes the
1303            // self-loop / long-cycle gates.
1304            //
1305            // Surface the `manual_authoring` posture so agents see at
1306            // introspection time which rel-types refuse explicit
1307            // `memstead_relate` (forbidden), warn softly (warn), or
1308            // admit explicit authoring (allow, default).
1309            //
1310            // Surface the source/target type pinning declared on the
1311            // schema's `RelationshipDefinition` so agents can pre-filter
1312            // rel-types for their `(from_type, to_type)` pair from
1313            // introspection instead of trial-and-error against
1314            // `INVALID_REL_SHAPE`. Field names mirror the
1315            // `INVALID_REL_SHAPE` `details.allowed_source_types` /
1316            // `details.allowed_target_types` payload so the agent
1317            // learns the contract once. Empty arrays = "any type
1318            // admitted" (no pinning).
1319            serde_json::json!({
1320                "name": d.name,
1321                "description": d.description,
1322                "when_to_use": d.when_to_use,
1323                "default_weight": d.default_weight,
1324                "acyclic": d.acyclic,
1325                "per_edge_description": per_edge_description_str(d.per_edge_description),
1326                "manual_authoring": manual_authoring_str(d.manual_authoring),
1327                "allowed_sources": d.source_types,
1328                "allowed_targets": d.target_types,
1329            })
1330        })
1331        .collect();
1332
1333    // Outbound cross-mem vocabulary, one entry per target schema.
1334    // Same shape as the YAML — `{ to_schema, definitions: [...] }` —
1335    // so consumers can decode the section symmetrically with the
1336    // intra-mem `relationships` array. `_default` filtering mirrors
1337    // the intra-mem block; the rest of the per-definition shape is
1338    // identical so a single decoder handles both.
1339    let cross_mem_relationships: Vec<serde_json::Value> = manifest
1340        .cross_mem_relationships
1341        .iter()
1342        .map(|entry| {
1343            let definitions: Vec<serde_json::Value> = entry
1344                .definitions
1345                .iter()
1346                .filter(|d| d.name != "_default")
1347                .map(|d| {
1348                    serde_json::json!({
1349                        "name": d.name,
1350                        "description": d.description,
1351                        "when_to_use": d.when_to_use,
1352                        "default_weight": d.default_weight,
1353                        "source_types": d.source_types,
1354                        "target_types": d.target_types,
1355                        "per_edge_description": per_edge_description_str(d.per_edge_description),
1356                    })
1357                })
1358                .collect();
1359            serde_json::json!({
1360                "to_schema": entry.to_schema,
1361                "definitions": definitions,
1362            })
1363        })
1364        .collect();
1365
1366    // Iterate type names in manifest-declared order so the output is
1367    // deterministic and matches the schema author's intent.
1368    let types_full: Vec<serde_json::Value> = manifest
1369        .types
1370        .iter()
1371        .filter_map(|name| schema.types.get(name.as_str()).map(|td| (name, td)))
1372        .map(|(_, td)| {
1373            let sections: Vec<serde_json::Value> = td
1374                .sections
1375                .iter()
1376                .map(|s| {
1377                    serde_json::json!({
1378                        "key": s.key,
1379                        "heading": s.heading,
1380                        "required": s.required,
1381                        "write_rules": s.write_rules,
1382                    })
1383                })
1384                .collect();
1385
1386            let fields: Vec<serde_json::Value> = td
1387                .metadata_fields
1388                .iter()
1389                .map(|f| {
1390                    let mut obj = serde_json::json!({
1391                        "name": f.key,
1392                        "description": f.description,
1393                        "required": !f.optional,
1394                    });
1395                    if let Some(enum_values) = &f.enum_values {
1396                        obj.as_object_mut()
1397                            .unwrap()
1398                            .insert("enum".into(), serde_json::json!(enum_values));
1399                    }
1400                    // Surface schema-declared `default_value` so agents
1401                    // see what the create path fills in when a required
1402                    // field is omitted. Without this, the engine appears
1403                    // to silently default — `priority: mid` on a
1404                    // `coverage_gap` would land with no schema-side
1405                    // explanation of where the value came from.
1406                    if let Some(default) = &f.default_value {
1407                        obj.as_object_mut()
1408                            .unwrap()
1409                            .insert("default".into(), serde_json::json!(default));
1410                    }
1411                    // Surface the `filterable` posture so an agent constructs
1412                    // valid `filters` / `range_filters` from the schema body
1413                    // in one shot. Always present: `"equality"` accepts
1414                    // `filters`, `"range"` accepts `range_filters`, `null`
1415                    // means not filterable.
1416                    obj.as_object_mut().unwrap().insert(
1417                        "filterable".into(),
1418                        match f.filterable.as_wire_str() {
1419                            Some(s) => serde_json::json!(s),
1420                            None => serde_json::Value::Null,
1421                        },
1422                    );
1423                    obj
1424                })
1425                .collect();
1426
1427            // Expose the per-type `propagating_relationships` list so agents
1428            // can predict self-loop refusal. The engine refuses
1429            // `memstead_relate type=R from=X(type=T) to=X` whenever R
1430            // appears here, independent of R's `acyclic` flag.
1431            serde_json::json!({
1432                "name": td.name,
1433                "description": td.description,
1434                "when_to_use": td.when_to_use,
1435                "sections": sections,
1436                "fields": fields,
1437                "writing_guidance": td.write_rules,
1438                "system_context": td.system_message_str(),
1439                "staleness_threshold_days": td.staleness_threshold_days,
1440                "propagating_relationships": td.propagating_relationships,
1441            })
1442        })
1443        .collect();
1444
1445    let mode = match manifest.relationships.mode {
1446        RelationshipMode::Strict => "strict",
1447        RelationshipMode::Open => "open",
1448    };
1449
1450    let full = verbosity == SchemaVerbosity::Full;
1451
1452    // Scalar fields present in BOTH modes. `ref` names the schema even
1453    // in the lite skeleton; `relationship_mode`, `community`, and
1454    // `used_by` are bounded and cheap.
1455    let mut payload = serde_json::json!({
1456        "ref": format!("{}@{}", manifest.name, schema.version),
1457        "relationship_mode": mode,
1458        "community": {
1459            "resolution": manifest.community.resolution,
1460            "seed": manifest.community.seed,
1461        },
1462        "used_by": used_by,
1463        // Machine-readable trust origin, present in both modes. A
1464        // consuming host reads this to decide whether to treat the
1465        // schema as workspace instructions (`first-party`) or quarantine
1466        // it as untrusted (`third-party`). Additive — a client that
1467        // ignores it still decodes the rest of the payload unchanged.
1468        "origin": origin.as_wire(),
1469    });
1470    let obj = payload.as_object_mut().unwrap();
1471
1472    // Schema-level prose — FULL mode only. An agent that asked for the
1473    // lite skeleton is orienting on structure; the human-readable
1474    // `description` / `when_to_use` is exactly the weight the lite cut
1475    // exists to drop. The schema `ref` still identifies the schema.
1476    if full {
1477        obj.insert(
1478            "description".into(),
1479            serde_json::Value::String(manifest.description.clone()),
1480        );
1481        obj.insert(
1482            "when_to_use".into(),
1483            serde_json::Value::String(manifest.when_to_use.clone()),
1484        );
1485    }
1486
1487    // Schema-level `alias_target_rel_type` pointer — names the rel-type
1488    // that body wiki-links `[[target]]` auto-emit through the
1489    // alias-synthesis pass. Present in BOTH modes: it governs whether an
1490    // unbacked wiki-link bakes an edge or refuses with
1491    // `WIKILINK_WITHOUT_RELATION`, so dropping it from lite would leave a
1492    // caller one round-trip from a write-time refusal. Schemas omitting
1493    // the field render with the key absent so existing agents don't see
1494    // a noisy `null`.
1495    if let Some(target) = &manifest.alias_target_rel_type {
1496        obj.insert(
1497            "alias_target_rel_type".into(),
1498            serde_json::Value::String(target.clone()),
1499        );
1500    }
1501
1502    // Surface `default_writing_guidance` at the top level so plugin-side
1503    // resolvers can concatenate the schema-generic prose with per-mem
1504    // additions without parsing schema YAML themselves. FULL mode only —
1505    // it is guidance prose. Field-by-field omission — a schema with
1506    // neither `avoid` nor `goal` declared emits no key at all (both
1507    // `Option<String>` inside an `Option<DefaultWritingGuidance>`).
1508    if full && let Some(dwg) = &manifest.default_writing_guidance {
1509        let mut block = serde_json::Map::new();
1510        if let Some(avoid) = &dwg.avoid {
1511            block.insert("avoid".into(), serde_json::Value::String(avoid.clone()));
1512        }
1513        if let Some(goal) = &dwg.goal {
1514            block.insert("goal".into(), serde_json::Value::String(goal.clone()));
1515        }
1516        if !block.is_empty() {
1517            obj.insert(
1518                "default_writing_guidance".into(),
1519                serde_json::Value::Object(block),
1520            );
1521        }
1522    }
1523
1524    if full {
1525        obj.insert(
1526            "relationships".into(),
1527            serde_json::Value::Array(relationships),
1528        );
1529        // Only surface the cross-mem block when the schema declares
1530        // outbound entries — keeps the response minimal for schemas
1531        // that don't speak cross-mem vocabulary.
1532        if !cross_mem_relationships.is_empty() {
1533            obj.insert(
1534                "cross_mem_relationships".into(),
1535                serde_json::Value::Array(cross_mem_relationships),
1536            );
1537        }
1538        obj.insert("types".into(), serde_json::Value::Array(types_full));
1539    } else {
1540        // Lite relationship form: name + endpoint constraints
1541        // (`allowed_sources`/`allowed_targets`) + manual-authoring
1542        // posture + `acyclic` + per-edge-description posture — every flag
1543        // that governs a relate-path refusal (`INVALID_REL_SHAPE`,
1544        // `RELATION_MANUAL_AUTHORING_FORBIDDEN`, cycle check,
1545        // `MISSING_REQUIRED_DESCRIPTION`) — with the description /
1546        // when_to_use / weight prose dropped. The ~42 rel-types carry the
1547        // bulk of the bytes, so this is the load-bearing half of the cut.
1548        // Projected from the rich array so each field value has one source.
1549        let relationships_summary: Vec<serde_json::Value> = relationships
1550            .iter()
1551            .map(|r| {
1552                serde_json::json!({
1553                    "name": r["name"],
1554                    "allowed_sources": r["allowed_sources"],
1555                    "allowed_targets": r["allowed_targets"],
1556                    "manual_authoring": r["manual_authoring"],
1557                    "acyclic": r["acyclic"],
1558                    "per_edge_description": r["per_edge_description"],
1559                })
1560            })
1561            .collect();
1562        obj.insert(
1563            "relationships_summary".into(),
1564            serde_json::Value::Array(relationships_summary),
1565        );
1566
1567        // Lite cross-mem form mirrors the intra-mem lite shape:
1568        // name + endpoint pinning, prose dropped. Same emit-when-non-empty
1569        // rule as full mode.
1570        if !cross_mem_relationships.is_empty() {
1571            let cross_summary: Vec<serde_json::Value> = cross_mem_relationships
1572                .iter()
1573                .map(|e| {
1574                    let definitions: Vec<serde_json::Value> = e["definitions"]
1575                        .as_array()
1576                        .map(|defs| {
1577                            defs.iter()
1578                                .map(|d| {
1579                                    serde_json::json!({
1580                                        "name": d["name"],
1581                                        "source_types": d["source_types"],
1582                                        "target_types": d["target_types"],
1583                                    })
1584                                })
1585                                .collect()
1586                        })
1587                        .unwrap_or_default();
1588                    serde_json::json!({
1589                        "to_schema": e["to_schema"],
1590                        "definitions": definitions,
1591                    })
1592                })
1593                .collect();
1594            obj.insert(
1595                "cross_mem_relationships_summary".into(),
1596                serde_json::Value::Array(cross_summary),
1597            );
1598        }
1599
1600        // Lite entity-type form: name + section keys (each with its
1601        // `required` marker) + metadata-field shapes (name, required,
1602        // `enum`, `default`) + `propagating_relationships` — the
1603        // structural minimum to author a legal write — with the
1604        // type/section prose (descriptions, write_rules, writing_guidance,
1605        // system_context) dropped. `propagating_relationships` rides along
1606        // because it governs the self-loop relate refusal (relate R X→X
1607        // when R propagates on type T), one of the refusals the lite view
1608        // must let an agent avoid. Projected from the rich array.
1609        let types_summary: Vec<serde_json::Value> = types_full
1610            .iter()
1611            .map(|t| {
1612                let sections: Vec<serde_json::Value> = t["sections"]
1613                    .as_array()
1614                    .map(|secs| {
1615                        secs.iter()
1616                            .map(|s| {
1617                                serde_json::json!({
1618                                    "key": s["key"],
1619                                    "required": s["required"],
1620                                })
1621                            })
1622                            .collect()
1623                    })
1624                    .unwrap_or_default();
1625                let fields: Vec<serde_json::Value> = t["fields"]
1626                    .as_array()
1627                    .map(|fs| {
1628                        fs.iter()
1629                            .map(|f| {
1630                                let mut o = serde_json::Map::new();
1631                                o.insert("name".into(), f["name"].clone());
1632                                o.insert("required".into(), f["required"].clone());
1633                                if let Some(e) = f.get("enum") {
1634                                    o.insert("enum".into(), e.clone());
1635                                }
1636                                if let Some(d) = f.get("default") {
1637                                    o.insert("default".into(), d.clone());
1638                                }
1639                                serde_json::Value::Object(o)
1640                            })
1641                            .collect()
1642                    })
1643                    .unwrap_or_default();
1644                serde_json::json!({
1645                    "name": t["name"],
1646                    "sections": sections,
1647                    "fields": fields,
1648                    "propagating_relationships": t["propagating_relationships"],
1649                })
1650            })
1651            .collect();
1652        obj.insert(
1653            "types_summary".into(),
1654            serde_json::Value::Array(types_summary),
1655        );
1656    }
1657
1658    payload
1659}
1660
1661/// Format a metadata field definition as a single bullet line.
1662fn describe_metadata_field(field: &memstead_schema::MetadataFieldDef) -> String {
1663    let type_str = match field.field_type {
1664        FieldType::String => "String",
1665        FieldType::Number => "Number",
1666        FieldType::Date => "Date",
1667        FieldType::Boolean => "Boolean",
1668    };
1669
1670    let mut flags: Vec<&str> = Vec::new();
1671    if field.optional {
1672        flags.push("optional");
1673    } else {
1674        flags.push("required");
1675    }
1676    if field.init_timestamp {
1677        flags.push("auto-init");
1678    }
1679    if field.auto_timestamp {
1680        flags.push("auto-update");
1681    }
1682    match field.serialization {
1683        Serialization::CsvArray => flags.push("csv array"),
1684        Serialization::OmitWhenFalsy => flags.push("omit when falsy"),
1685        Serialization::Default => {}
1686    }
1687
1688    let mut extras: Vec<String> = Vec::new();
1689    if let Some(values) = &field.enum_values {
1690        extras.push(format!("enum: {}", values.join(", ")));
1691    }
1692    if let Some(default) = &field.default_value {
1693        extras.push(format!("default: {default}"));
1694    }
1695    let filterable_str = match field.filterable {
1696        Filterable::None => None,
1697        Filterable::Equality => Some("filterable: equality"),
1698        Filterable::Range => Some("filterable: range"),
1699    };
1700    if let Some(f) = filterable_str {
1701        extras.push(f.to_string());
1702    }
1703
1704    let extras_str = if extras.is_empty() {
1705        String::new()
1706    } else {
1707        format!(" — {}", extras.join(" — "))
1708    };
1709
1710    format!(
1711        "**{key}**: {type_str} ({flags}){extras_str}",
1712        key = field.key,
1713        flags = flags.join(", "),
1714    )
1715}
1716
1717#[cfg(test)]
1718mod tests {
1719    use super::*;
1720    use crate::{Entity, EntityId, ListResult, SearchResult};
1721    use indexmap::IndexMap;
1722    use std::collections::HashMap;
1723
1724    fn make_hit(id: &str, title: &str, entity_type: &str, sections: &[(&str, &str)]) -> SearchHit {
1725        SearchHit {
1726            id: EntityId(id.to_string()),
1727            title: title.to_string(),
1728            mem: id.split("--").next().unwrap_or("").to_string(),
1729            entity_type: entity_type.to_string(),
1730            stub: false,
1731            score: 1.0,
1732            tokens: 10,
1733            snippet: None,
1734            sections: sections
1735                .iter()
1736                .map(|(k, v)| (k.to_string(), v.to_string()))
1737                .collect(),
1738            score_breakdown: None,
1739            matched_terms: None,
1740            expansion: None,
1741            // Test fixtures exercise the render-time fallback (default-schema
1742            // lookup); the engine-precomputed path is set in the search op.
1743            summary: None,
1744        }
1745    }
1746
1747    fn search_result(hits: Vec<SearchHit>) -> SearchResult {
1748        let returned = hits.len();
1749        let total_tokens = hits.iter().map(|h| h.tokens).sum();
1750        SearchResult {
1751            total: returned,
1752            returned,
1753            offset: 0,
1754            total_tokens,
1755            hits,
1756            facets: None,
1757            warnings: vec![],
1758        }
1759    }
1760
1761    fn list_result(hits: Vec<SearchHit>) -> ListResult {
1762        let returned = hits.len();
1763        ListResult {
1764            total: returned,
1765            returned,
1766            offset: 0,
1767            total_tokens: hits.iter().map(|h| h.tokens).sum(),
1768            hits,
1769            warnings: vec![],
1770        }
1771    }
1772
1773    fn test_entity() -> Entity {
1774        Entity {
1775            id: EntityId("specs--test-entity".to_string()),
1776            title: "Test Entity".to_string(),
1777            entity_type: "spec".to_string(),
1778            mem: "specs".to_string(),
1779            file_path: "test-entity.md".to_string(),
1780            metadata: IndexMap::new(),
1781            sections: IndexMap::from([
1782                ("identity".to_string(), "A test entity for unit tests.".to_string()),
1783                ("purpose".to_string(), "Validates render logic.".to_string()),
1784                ("specifies".to_string(), "Long section content that adds significant token weight to the full entity estimate.".to_string()),
1785            ]),
1786            relationships: vec![],
1787            content_hash: "abc123".to_string(),
1788            stub: false,
1789            stub_kind: None,
1790            heading_spans: std::collections::HashMap::new(),
1791        }
1792    }
1793
1794    #[test]
1795    fn section_key_to_heading_basic() {
1796        assert_eq!(section_key_to_heading("identity"), "Identity");
1797        assert_eq!(section_key_to_heading("current_state"), "Current state");
1798    }
1799
1800    #[test]
1801    fn render_uses_schema_declared_heading_for_non_trivial_casing() {
1802        // The `ingest.inconsistency` schema declares `claim_a` with
1803        // heading "Claim A" — the simple key-derivation would produce
1804        // "Claim a", which would disagree with the on-disk markdown
1805        // emitted by the generator. The renderer must echo the
1806        // schema's declared heading verbatim.
1807        let mut sections: IndexMap<String, String> = IndexMap::new();
1808        sections.insert("claim_a".to_string(), "Body A.".to_string());
1809        sections.insert("claim_b".to_string(), "Body B.".to_string());
1810
1811        let entity = Entity {
1812            id: EntityId("ingest--example".to_string()),
1813            title: "Example".to_string(),
1814            entity_type: "inconsistency".to_string(),
1815            mem: "ingest".to_string(),
1816            file_path: "example.md".to_string(),
1817            metadata: IndexMap::new(),
1818            sections,
1819            relationships: vec![],
1820            content_hash: "h".to_string(),
1821            stub: false,
1822            stub_kind: None,
1823            heading_spans: std::collections::HashMap::new(),
1824        };
1825
1826        let md = render_entity_markdown(&entity, None);
1827        assert!(
1828            md.contains("## Claim A"),
1829            "expected schema-declared `## Claim A` heading; got:\n{md}"
1830        );
1831        assert!(
1832            md.contains("## Claim B"),
1833            "expected schema-declared `## Claim B` heading; got:\n{md}"
1834        );
1835        // The naive derivation would have produced lower-case `a`/`b`.
1836        assert!(
1837            !md.contains("## Claim a"),
1838            "renderer must not fall back to key-derivation when the \
1839             schema declares a heading; got:\n{md}"
1840        );
1841    }
1842
1843    #[test]
1844    fn render_falls_back_to_key_derivation_for_unknown_types() {
1845        // When the entity_type is not in any built-in schema (custom
1846        // workspace schemas, legacy entities), the renderer falls back
1847        // to the simple key→heading derivation.
1848        let mut sections: IndexMap<String, String> = IndexMap::new();
1849        sections.insert("identity".to_string(), "body".to_string());
1850
1851        let entity = Entity {
1852            id: EntityId("custom--example".to_string()),
1853            title: "Example".to_string(),
1854            entity_type: "not-a-builtin-type".to_string(),
1855            mem: "custom".to_string(),
1856            file_path: "example.md".to_string(),
1857            metadata: IndexMap::new(),
1858            sections,
1859            relationships: vec![],
1860            content_hash: "h".to_string(),
1861            stub: false,
1862            stub_kind: None,
1863            heading_spans: std::collections::HashMap::new(),
1864        };
1865
1866        let md = render_entity_markdown(&entity, None);
1867        assert!(
1868            md.contains("## Identity"),
1869            "fallback derivation must produce `## Identity`; got:\n{md}"
1870        );
1871    }
1872
1873    // Regression lock for deterministic section order. The invariant:
1874    // render_entity_body walks `entity.sections` in IndexMap insertion order,
1875    // so whatever order the parser/caller inserts is what ships. The parser
1876    // inserts in schema-declared order; this test deliberately inserts in
1877    // REVERSE schema order to prove the renderer honors insertion order
1878    // (not the schema's declared order directly).
1879    #[test]
1880    fn render_entity_sections_follow_indexmap_insertion_order() {
1881        let mut sections: IndexMap<String, String> = IndexMap::new();
1882        sections.insert("specifies".to_string(), "S content.".to_string());
1883        sections.insert("purpose".to_string(), "P content.".to_string());
1884        sections.insert("identity".to_string(), "I content.".to_string());
1885
1886        let entity = Entity {
1887            id: EntityId("specs--order-test".to_string()),
1888            title: "Order Test".to_string(),
1889            entity_type: "spec".to_string(),
1890            mem: "specs".to_string(),
1891            file_path: "order-test.md".to_string(),
1892            metadata: IndexMap::new(),
1893            sections,
1894            relationships: vec![],
1895            content_hash: "abc123".to_string(),
1896            stub: false,
1897            stub_kind: None,
1898            heading_spans: std::collections::HashMap::new(),
1899        };
1900
1901        let md = render_entity_markdown(&entity, None);
1902        let specifies_pos = md.find("## Specifies").expect("## Specifies must appear");
1903        let purpose_pos = md.find("## Purpose").expect("## Purpose must appear");
1904        let identity_pos = md.find("## Identity").expect("## Identity must appear");
1905
1906        assert!(
1907            specifies_pos < purpose_pos,
1908            "Specifies (inserted first) must render before Purpose; got:\n{md}"
1909        );
1910        assert!(
1911            purpose_pos < identity_pos,
1912            "Purpose (inserted second) must render before Identity; got:\n{md}"
1913        );
1914    }
1915
1916    /// `_tokens_unfiltered_body` rides only when a section filter
1917    /// narrows the rendered output; it carries the unfiltered-base
1918    /// cost so agents can predict the cost of dropping the filter. The
1919    /// name avoids a monotonic-relationship implication
1920    /// that the opt-in path could invert.
1921    #[test]
1922    fn tokens_reflect_filtered_output() {
1923        let entity = test_entity();
1924
1925        // Full render — no filter
1926        let full = render_entity_markdown(&entity, None);
1927        assert!(full.contains("_tokens:"), "should have _tokens");
1928        assert!(
1929            !full.contains("_tokens_unfiltered_body:"),
1930            "should NOT have _tokens_unfiltered_body when unfiltered"
1931        );
1932        assert!(
1933            !full.contains("_tokens_full:"),
1934            "old _tokens_full name must not survive — rename is one-way"
1935        );
1936
1937        // Filtered render — request only "identity"
1938        let filtered = render_entity_markdown(&entity, Some(&["identity".to_string()]));
1939        assert!(filtered.contains("_tokens:"), "should have _tokens");
1940        assert!(
1941            filtered.contains("_tokens_unfiltered_body:"),
1942            "should have _tokens_unfiltered_body when filtered"
1943        );
1944        assert!(
1945            !filtered.contains("_tokens_full:"),
1946            "old _tokens_full name must not survive — rename is one-way"
1947        );
1948
1949        // Extract token values
1950        let full_tokens: usize = full
1951            .lines()
1952            .find(|l| l.starts_with("_tokens:"))
1953            .unwrap()
1954            .trim_start_matches("_tokens: ")
1955            .parse()
1956            .unwrap();
1957        let filtered_tokens: usize = filtered
1958            .lines()
1959            .find(|l| l.starts_with("_tokens:"))
1960            .unwrap()
1961            .trim_start_matches("_tokens: ")
1962            .parse()
1963            .unwrap();
1964        let tokens_unfiltered_body: usize = filtered
1965            .lines()
1966            .find(|l| l.starts_with("_tokens_unfiltered_body:"))
1967            .unwrap()
1968            .trim_start_matches("_tokens_unfiltered_body: ")
1969            .parse()
1970            .unwrap();
1971
1972        assert!(
1973            filtered_tokens < full_tokens,
1974            "filtered _tokens ({filtered_tokens}) should be less than full _tokens ({full_tokens})"
1975        );
1976        assert!(
1977            tokens_unfiltered_body >= full_tokens,
1978            "_tokens_unfiltered_body ({tokens_unfiltered_body}) should be >= full render _tokens ({full_tokens})"
1979        );
1980    }
1981
1982    // -----------------------------------------------------------------------
1983    // Summary line — search rendering
1984    // -----------------------------------------------------------------------
1985
1986    #[test]
1987    fn render_search_uses_first_required_section_for_spec() {
1988        let hit = make_hit(
1989            "specs--demo",
1990            "Demo Spec",
1991            "spec",
1992            &[
1993                ("identity", "A demo spec."),
1994                ("purpose", "Verifies rendering."),
1995            ],
1996        );
1997        let out = render_search_markdown(&search_result(vec![hit]), 0);
1998        assert!(
1999            out.contains("**Identity**: A demo spec."),
2000            "expected Identity line for spec hit, got:\n{out}"
2001        );
2002    }
2003
2004    #[test]
2005    fn render_search_uses_first_required_section_for_memo() {
2006        let hit = make_hit(
2007            "memos--d1",
2008            "Memo One",
2009            "memo",
2010            &[("claim", "Some claim."), ("context", "Some context.")],
2011        );
2012        let out = render_search_markdown(&search_result(vec![hit]), 0);
2013        assert!(
2014            out.contains("**Claim**: Some claim."),
2015            "expected Claim line for memo hit, got:\n{out}"
2016        );
2017        assert!(
2018            !out.contains("**Identity**"),
2019            "memo hit must not render Identity label"
2020        );
2021        assert!(
2022            !out.contains("**Purpose**"),
2023            "memo hit must not render Purpose label"
2024        );
2025    }
2026
2027    #[test]
2028    fn render_search_uses_first_required_section_for_concept() {
2029        let hit = make_hit(
2030            "concepts--thing",
2031            "Thing",
2032            "concept",
2033            &[("definition", "A thing."), ("explanation", "Details.")],
2034        );
2035        let out = render_search_markdown(&search_result(vec![hit]), 0);
2036        assert!(
2037            out.contains("**Definition**: A thing."),
2038            "expected Definition line for concept hit, got:\n{out}"
2039        );
2040    }
2041
2042    #[test]
2043    fn render_search_missing_summary_section_shows_dash() {
2044        // Memo hit with no "claim" section — renderer falls back to em-dash.
2045        let hit = make_hit("memos--empty", "Empty Memo", "memo", &[]);
2046        let out = render_search_markdown(&search_result(vec![hit]), 0);
2047        assert!(
2048            out.contains("**Claim**: —"),
2049            "expected Claim dash fallback, got:\n{out}"
2050        );
2051    }
2052
2053    #[test]
2054    fn render_search_mixes_schemas_in_one_result() {
2055        let spec_hit = make_hit(
2056            "specs--s1",
2057            "Spec One",
2058            "spec",
2059            &[("identity", "Spec body.")],
2060        );
2061        let memo_hit = make_hit("memos--m1", "Memo One", "memo", &[("claim", "Memo claim.")]);
2062        let out = render_search_markdown(&search_result(vec![spec_hit, memo_hit]), 0);
2063        assert!(
2064            out.contains("**Identity**: Spec body."),
2065            "spec hit should still render Identity, got:\n{out}"
2066        );
2067        assert!(
2068            out.contains("**Claim**: Memo claim."),
2069            "memo hit should render Claim in the same output, got:\n{out}"
2070        );
2071    }
2072
2073    #[test]
2074    fn render_search_unknown_schema_shows_summary_dash() {
2075        let hit = make_hit("bogus--x", "Bogus", "bogus", &[]);
2076        let out = render_search_markdown(&search_result(vec![hit]), 0);
2077        assert!(
2078            out.contains("**Summary**: —"),
2079            "unknown schema should render Summary dash, got:\n{out}"
2080        );
2081    }
2082
2083    #[test]
2084    fn summary_pair_falls_back_when_schema_has_no_required_sections() {
2085        use memstead_schema::{SectionDef, TypeDefinition};
2086
2087        let schema = TypeDefinition {
2088            name: "spec".to_string(),
2089            description: "test".to_string(),
2090            when_to_use: "test".to_string(),
2091            boundaries: vec![],
2092            examples: vec![],
2093            system_message: None,
2094            sections: vec![SectionDef {
2095                key: "note".to_string(),
2096                heading: "Note".to_string(),
2097                required: false,
2098                search_weight: 1.0,
2099                catch_all: false,
2100                write_rules: vec![],
2101                description: None,
2102            }],
2103            metadata_fields: vec![],
2104            title_weight: 1.0,
2105            text_fields: vec![],
2106            hierarchy_relationship: "PART_OF".to_string(),
2107            edge_weight_overrides: indexmap::IndexMap::new(),
2108            edge_weights: indexmap::IndexMap::new(),
2109            propagating_relationships: vec![],
2110            updatable_fields: vec![],
2111            health_required_fields: vec![],
2112            staleness_threshold_days: 90,
2113            write_rules: vec![],
2114            required_outgoing: vec![],
2115        };
2116
2117        let mut sections = HashMap::new();
2118        sections.insert("note".to_string(), "a note".to_string());
2119        assert_eq!(
2120            summary_pair(Some(&schema), &sections),
2121            ("Note".to_string(), "a note".to_string()),
2122        );
2123
2124        assert_eq!(
2125            summary_pair(Some(&schema), &HashMap::new()),
2126            ("Note".to_string(), "—".to_string()),
2127        );
2128    }
2129
2130    // -----------------------------------------------------------------------
2131    // Summary line — list rendering (symmetric)
2132    // -----------------------------------------------------------------------
2133
2134    #[test]
2135    fn render_list_uses_first_required_section_for_spec() {
2136        let hit = make_hit(
2137            "specs--demo",
2138            "Demo Spec",
2139            "spec",
2140            &[
2141                ("identity", "A demo spec."),
2142                ("purpose", "Verifies rendering."),
2143            ],
2144        );
2145        let out = render_list_markdown(&list_result(vec![hit]));
2146        assert!(
2147            out.contains("**Identity**: A demo spec."),
2148            "expected Identity line for spec hit, got:\n{out}"
2149        );
2150    }
2151
2152    #[test]
2153    fn render_list_uses_first_required_section_for_memo() {
2154        let hit = make_hit("memos--d1", "Memo One", "memo", &[("claim", "Some claim.")]);
2155        let out = render_list_markdown(&list_result(vec![hit]));
2156        assert!(
2157            out.contains("**Claim**: Some claim."),
2158            "expected Claim line for memo hit, got:\n{out}"
2159        );
2160        assert!(
2161            !out.contains("**Identity**"),
2162            "memo hit must not render Identity label in list output"
2163        );
2164    }
2165
2166    #[test]
2167    fn render_list_uses_first_required_section_for_concept() {
2168        let hit = make_hit(
2169            "concepts--thing",
2170            "Thing",
2171            "concept",
2172            &[("definition", "A thing.")],
2173        );
2174        let out = render_list_markdown(&list_result(vec![hit]));
2175        assert!(
2176            out.contains("**Definition**: A thing."),
2177            "expected Definition line for concept hit, got:\n{out}"
2178        );
2179    }
2180
2181    #[test]
2182    fn render_list_missing_summary_section_shows_dash() {
2183        let hit = make_hit("memos--empty", "Empty Memo", "memo", &[]);
2184        let out = render_list_markdown(&list_result(vec![hit]));
2185        assert!(
2186            out.contains("**Claim**: —"),
2187            "expected Claim dash fallback in list output, got:\n{out}"
2188        );
2189    }
2190
2191    #[test]
2192    fn render_list_mixes_schemas_in_one_result() {
2193        let spec_hit = make_hit(
2194            "specs--s1",
2195            "Spec One",
2196            "spec",
2197            &[("identity", "Spec body.")],
2198        );
2199        let memo_hit = make_hit("memos--m1", "Memo One", "memo", &[("claim", "Memo claim.")]);
2200        let out = render_list_markdown(&list_result(vec![spec_hit, memo_hit]));
2201        assert!(
2202            out.contains("**Identity**: Spec body."),
2203            "spec hit should still render Identity in list output, got:\n{out}"
2204        );
2205        assert!(
2206            out.contains("**Claim**: Memo claim."),
2207            "memo hit should render Claim in list output, got:\n{out}"
2208        );
2209    }
2210
2211    #[test]
2212    fn render_list_unknown_schema_shows_summary_dash() {
2213        let hit = make_hit("bogus--x", "Bogus", "bogus", &[]);
2214        let out = render_list_markdown(&list_result(vec![hit]));
2215        assert!(
2216            out.contains("**Summary**: —"),
2217            "unknown schema should render Summary dash in list output, got:\n{out}"
2218        );
2219    }
2220
2221    // -----------------------------------------------------------------------
2222    // summary_pair — structured-content source of truth
2223    // -----------------------------------------------------------------------
2224
2225    #[test]
2226    fn summary_pair_for_spec_returns_identity() {
2227        let schema = type_by_name("spec");
2228        let mut sections = HashMap::new();
2229        sections.insert("identity".to_string(), "A demo spec.".to_string());
2230        assert_eq!(
2231            summary_pair(schema.as_deref(), &sections),
2232            ("Identity".to_string(), "A demo spec.".to_string()),
2233        );
2234    }
2235
2236    #[test]
2237    fn summary_pair_for_memo_returns_claim() {
2238        let schema = type_by_name("memo");
2239        let mut sections = HashMap::new();
2240        sections.insert("claim".to_string(), "Memos matter.".to_string());
2241        assert_eq!(
2242            summary_pair(schema.as_deref(), &sections),
2243            ("Claim".to_string(), "Memos matter.".to_string()),
2244        );
2245    }
2246
2247    #[test]
2248    fn summary_pair_missing_section_returns_dash() {
2249        let schema = type_by_name("memo");
2250        assert_eq!(
2251            summary_pair(schema.as_deref(), &HashMap::new()),
2252            ("Claim".to_string(), "—".to_string()),
2253        );
2254    }
2255
2256    #[test]
2257    fn summary_pair_unknown_schema_returns_summary_dash() {
2258        assert_eq!(
2259            summary_pair(None, &HashMap::new()),
2260            ("Summary".to_string(), "—".to_string()),
2261        );
2262    }
2263
2264    // -----------------------------------------------------------------------
2265    // Envelope serialization — structured-content sidecar
2266    // -----------------------------------------------------------------------
2267
2268    #[test]
2269    fn envelope_serializes_summary_fields() {
2270        let hit = make_hit(
2271            "memos--d1",
2272            "Memo One",
2273            "memo",
2274            &[("claim", "Memos matter.")],
2275        );
2276        let result = search_result(vec![hit]);
2277        let envelope = build_search_envelope(&result, 0);
2278        let value = serde_json::to_value(&envelope).expect("envelope must serialize");
2279
2280        // The top-level counters use the `_-prefixed` engine-emitted
2281        // shape so the wire signals "engine-authored metadata, not
2282        // user data".
2283        assert_eq!(value["_total"], 1);
2284        assert_eq!(value["_returned"], 1);
2285        assert_eq!(value["_offset"], 0);
2286        // Warnings field is omitted when empty (skip_serializing_if).
2287        assert!(
2288            value.get("warnings").is_none(),
2289            "empty warnings must be elided, got: {value}"
2290        );
2291
2292        let hit0 = &value["hits"][0];
2293        assert_eq!(hit0["summary_heading"], "Claim");
2294        assert_eq!(hit0["summary_value"], "Memos matter.");
2295        // Flattened SearchHit fields present.
2296        assert_eq!(hit0["id"], "memos--d1");
2297        assert_eq!(hit0["title"], "Memo One");
2298        assert_eq!(hit0["entity_type"], "memo");
2299        assert_eq!(hit0["mem"], "memos");
2300        assert_eq!(hit0["stub"], false);
2301        assert_eq!(hit0["tokens"], 10);
2302        assert!(hit0["sections"].is_object());
2303    }
2304
2305    #[test]
2306    fn envelope_roundtrips_through_structured_content() {
2307        // Mixed-schema result: one spec hit, one memo hit. Both summary pairs
2308        // must match what summary_pair produces for each schema.
2309        let spec_hit = make_hit(
2310            "specs--s1",
2311            "Spec One",
2312            "spec",
2313            &[("identity", "Spec body.")],
2314        );
2315        let memo_hit = make_hit("memos--m1", "Memo One", "memo", &[("claim", "Memo claim.")]);
2316        let result = search_result(vec![spec_hit, memo_hit]);
2317        let envelope = build_search_envelope(&result, 0);
2318        let value = serde_json::to_value(&envelope).expect("envelope must serialize");
2319
2320        let hits = value["hits"].as_array().expect("hits must be array");
2321        assert_eq!(hits.len(), 2);
2322        assert_eq!(hits[0]["summary_heading"], "Identity");
2323        assert_eq!(hits[0]["summary_value"], "Spec body.");
2324        assert_eq!(hits[1]["summary_heading"], "Claim");
2325        assert_eq!(hits[1]["summary_value"], "Memo claim.");
2326    }
2327
2328    #[test]
2329    fn list_envelope_includes_total_tokens() {
2330        let hit = make_hit(
2331            "concepts--c1",
2332            "Thing",
2333            "concept",
2334            &[("definition", "A thing.")],
2335        );
2336        let result = list_result(vec![hit]);
2337        let envelope = build_list_envelope(&result);
2338        let value = serde_json::to_value(&envelope).expect("envelope must serialize");
2339
2340        // `_`-prefixed engine-meta keys, matching the search envelope.
2341        assert_eq!(value["_total"], 1);
2342        assert_eq!(value["_total_tokens"], 10);
2343        assert!(value.get("total").is_none(), "unprefixed keys retired");
2344        assert_eq!(value["hits"][0]["summary_heading"], "Definition");
2345        assert_eq!(value["hits"][0]["summary_value"], "A thing.");
2346    }
2347
2348    #[test]
2349    fn envelope_emits_warnings_when_present() {
2350        let mut result = search_result(vec![]);
2351        // Search warnings ship as typed `WarningHint` entries (same
2352        // `{code, details, message}` envelope every other tool uses).
2353        result.warnings = vec![crate::ops::WarningHint::FieldNotFilterable {
2354            field: "foo".to_string(),
2355        }];
2356        let envelope = build_search_envelope(&result, 0);
2357        let value = serde_json::to_value(&envelope).expect("envelope must serialize");
2358        assert_eq!(value["warnings"][0]["code"], "FIELD_NOT_FILTERABLE");
2359        assert_eq!(value["warnings"][0]["details"]["field"], "foo");
2360        assert!(
2361            value["warnings"][0]["message"]
2362                .as_str()
2363                .is_some_and(|m| m.contains("not filterable"))
2364        );
2365    }
2366
2367    // -----------------------------------------------------------------------
2368    // Per-hit and per-result fields that must appear in the Markdown body.
2369    // -----------------------------------------------------------------------
2370
2371    fn tm(field: &str, snippet: &str, heading_path: Option<&[&str]>) -> TermMatch {
2372        TermMatch {
2373            field: field.to_string(),
2374            snippet: snippet.to_string(),
2375            heading_path: heading_path.map(|p| p.iter().map(|s| s.to_string()).collect()),
2376        }
2377    }
2378
2379    fn sample_facets() -> Facets {
2380        use crate::ops::SubsectionFacet;
2381        Facets {
2382            by_type: HashMap::from([
2383                ("spec".to_string(), 7),
2384                ("memo".to_string(), 3),
2385                ("decision".to_string(), 2),
2386            ]),
2387            by_mem: HashMap::from([("specs".to_string(), 10), ("memos".to_string(), 2)]),
2388            by_level: HashMap::from([("high".to_string(), 4)]),
2389            by_status: HashMap::from([("active".to_string(), 6)]),
2390            by_confidence: HashMap::from([("medium".to_string(), 3)]),
2391            by_subsection: vec![
2392                SubsectionFacet {
2393                    path: vec!["specifies".to_string(), "Response Shapes".to_string()],
2394                    count: 4,
2395                },
2396                SubsectionFacet {
2397                    path: vec!["purpose".to_string(), "Rationale".to_string()],
2398                    count: 2,
2399                },
2400            ],
2401            by_expansion: HashMap::from([("primary".to_string(), 8), ("expanded".to_string(), 4)]),
2402        }
2403    }
2404
2405    #[test]
2406    fn render_search_emits_matched_terms_line() {
2407        let mut hit = make_hit(
2408            "specs--e1",
2409            "Entity One",
2410            "spec",
2411            &[("identity", "Body text.")],
2412        );
2413        hit.matched_terms = Some(HashMap::from([
2414            (
2415                "entity".to_string(),
2416                vec![
2417                    tm("title", "...entity...", None),
2418                    tm("purpose", "...entity...", None),
2419                    tm("purpose", "...entity two...", None),
2420                ],
2421            ),
2422            ("one".to_string(), vec![tm("title", "...one...", None)]),
2423        ]));
2424        let out = render_search_markdown(&search_result(vec![hit]), 0);
2425        assert!(
2426            out.contains("**Matched terms:**"),
2427            "missing Matched terms line; got:\n{out}"
2428        );
2429        assert!(
2430            out.contains("`entity` (purpose×2, title×1)"),
2431            "entity term grouping wrong; got:\n{out}"
2432        );
2433        assert!(
2434            out.contains("`one` (title×1)"),
2435            "one term grouping wrong; got:\n{out}"
2436        );
2437    }
2438
2439    #[test]
2440    fn render_search_emits_score_breakdown_line() {
2441        let mut hit = make_hit("specs--e1", "Entity", "spec", &[("identity", "b")]);
2442        hit.score_breakdown = Some(ScoreBreakdown {
2443            bm25: 2.5,
2444            title_boost: 2.0,
2445            field_weights: HashMap::from([("body".to_string(), 0.8), ("purpose".to_string(), 0.3)]),
2446            expansion_decay: Some(0.5),
2447        });
2448        let out = render_search_markdown(&search_result(vec![hit]), 0);
2449        assert!(
2450            out.contains(
2451                "**Score:** bm25 2.5 + title 2.0 + body 0.8 + purpose 0.3 + expansion_decay ×0.5"
2452            ),
2453            "score breakdown line wrong; got:\n{out}"
2454        );
2455    }
2456
2457    #[test]
2458    fn render_search_omits_expansion_decay_when_none() {
2459        let mut hit = make_hit("specs--e1", "Entity", "spec", &[("identity", "b")]);
2460        hit.score_breakdown = Some(ScoreBreakdown {
2461            bm25: 1.5,
2462            title_boost: 1.0,
2463            field_weights: HashMap::new(),
2464            expansion_decay: None,
2465        });
2466        let out = render_search_markdown(&search_result(vec![hit]), 0);
2467        assert!(
2468            out.contains("**Score:** bm25 1.5 + title 1.0"),
2469            "base score wrong; got:\n{out}"
2470        );
2471        assert!(
2472            !out.contains("expansion_decay"),
2473            "expansion_decay must be absent when None; got:\n{out}"
2474        );
2475    }
2476
2477    #[test]
2478    fn render_search_emits_heading_path_line() {
2479        let mut hit = make_hit("specs--e1", "Entity", "spec", &[("identity", "b")]);
2480        hit.matched_terms = Some(HashMap::from([(
2481            "x".to_string(),
2482            vec![
2483                tm("purpose", "...x...", Some(&["Purpose", "Rationale"])),
2484                tm("purpose", "...x...", Some(&["Purpose", "Rationale"])), // duplicate, dedupe
2485                tm("specifies", "...x...", Some(&["Specifies", "Responses"])),
2486            ],
2487        )]));
2488        let out = render_search_markdown(&search_result(vec![hit]), 0);
2489        assert!(
2490            out.contains("**Heading path:** Purpose › Rationale; Specifies › Responses"),
2491            "heading path line wrong; got:\n{out}"
2492        );
2493    }
2494
2495    #[test]
2496    fn render_search_emits_expansion_line() {
2497        let mut hit = make_hit("specs--e2", "Entity Two", "spec", &[("identity", "b")]);
2498        hit.expansion = Some(ExpansionInfo {
2499            of: EntityId("specs--seed".to_string()),
2500            via_edge: "refines".to_string(),
2501            depth: 1,
2502        });
2503        let out = render_search_markdown(&search_result(vec![hit]), 0);
2504        assert!(
2505            out.contains("**Expansion:** from `specs--seed` via `refines` (depth 1)"),
2506            "expansion line wrong; got:\n{out}"
2507        );
2508    }
2509
2510    #[test]
2511    fn render_search_emits_facets_block() {
2512        let mut result = search_result(vec![]);
2513        result.facets = Some(sample_facets());
2514        let out = render_search_markdown(&result, 0);
2515        assert!(
2516            out.contains("## Facets"),
2517            "facets header missing; got:\n{out}"
2518        );
2519        assert!(
2520            out.contains("- **by_type:** spec=7, memo=3, decision=2"),
2521            "by_type bucket wrong; got:\n{out}"
2522        );
2523        assert!(
2524            out.contains("- **by_mem:** specs=10, memos=2"),
2525            "by_mem bucket wrong; got:\n{out}"
2526        );
2527        assert!(
2528            out.contains("- **by_level:** high=4"),
2529            "by_level bucket wrong; got:\n{out}"
2530        );
2531        assert!(
2532            out.contains("- **by_status:** active=6"),
2533            "by_status bucket wrong; got:\n{out}"
2534        );
2535        assert!(
2536            out.contains("- **by_confidence:** medium=3"),
2537            "by_confidence bucket wrong; got:\n{out}"
2538        );
2539        assert!(
2540            out.contains("- **by_expansion:** primary=8, expanded=4"),
2541            "by_expansion bucket wrong; got:\n{out}"
2542        );
2543        assert!(
2544            out.contains("- **by_subsection:**"),
2545            "by_subsection header missing; got:\n{out}"
2546        );
2547        assert!(
2548            out.contains("`specifies › Response Shapes`: 4"),
2549            "subsection facet wrong; got:\n{out}"
2550        );
2551    }
2552
2553    #[test]
2554    fn render_search_omits_facets_block_when_all_empty() {
2555        let mut result = search_result(vec![]);
2556        result.facets = Some(Facets::default());
2557        let out = render_search_markdown(&result, 0);
2558        assert!(
2559            !out.contains("## Facets"),
2560            "empty facets must not emit header; got:\n{out}"
2561        );
2562    }
2563
2564    /// Every field the search-tool description promises must be rendered
2565    /// in Markdown. This test exercises all of them in one result and
2566    /// asserts they all appear.
2567    #[test]
2568    fn search_markdown_covers_every_sidecar_field() {
2569        let mut hit = make_hit(
2570            "specs--e1",
2571            "Entity One",
2572            "spec",
2573            &[("identity", "Body text.")],
2574        );
2575        hit.matched_terms = Some(HashMap::from([(
2576            "entity".to_string(),
2577            vec![tm("title", "...entity...", Some(&["Purpose", "Rationale"]))],
2578        )]));
2579        hit.score_breakdown = Some(ScoreBreakdown {
2580            bm25: 1.5,
2581            title_boost: 1.0,
2582            field_weights: HashMap::from([("body".to_string(), 0.4)]),
2583            expansion_decay: Some(0.5),
2584        });
2585        hit.expansion = Some(ExpansionInfo {
2586            of: EntityId("specs--seed".to_string()),
2587            via_edge: "refines".to_string(),
2588            depth: 2,
2589        });
2590
2591        let mut result = search_result(vec![hit]);
2592        result.facets = Some(sample_facets());
2593
2594        let out = render_search_markdown(&result, 0);
2595        for marker in [
2596            "## Facets",
2597            "- **by_type:**",
2598            "- **by_mem:**",
2599            "- **by_level:**",
2600            "- **by_status:**",
2601            "- **by_confidence:**",
2602            "- **by_expansion:**",
2603            "- **by_subsection:**",
2604            "**Matched terms:**",
2605            "**Score:**",
2606            "**Heading path:**",
2607            "**Expansion:**",
2608        ] {
2609            assert!(
2610                out.contains(marker),
2611                "lockstep marker `{marker}` missing from search markdown; \
2612                 update render_search_markdown when adding sidecar fields. got:\n{out}"
2613            );
2614        }
2615    }
2616
2617    /// The envelope's `relationships[].source` field reads the store's
2618    /// `EdgeSource` discriminator rather than a hardcoded `"explicit"`,
2619    /// which would disagree with the stub-adoption
2620    /// response for alias-synthesised edges (and would be
2621    /// misleading because REFERENCES carries `manual_authoring:
2622    /// forbidden`).
2623    #[test]
2624    fn build_entity_envelope_source_field_reads_edge_source() {
2625        let mut entity = test_entity();
2626        let body_link_target = EntityId("specs--body-link-target".to_string());
2627        let explicit_target = EntityId("specs--explicit-target".to_string());
2628        entity.relationships = vec![
2629            crate::entity::Relationship::new("REFERENCES".to_string(), body_link_target.clone()),
2630            crate::entity::Relationship::new("USES".to_string(), explicit_target.clone()),
2631        ];
2632
2633        let edges = vec![
2634            crate::store::Edge {
2635                rel_type: "REFERENCES".to_string(),
2636                target: body_link_target.clone(),
2637                source: crate::store::EdgeSource::BodyLink,
2638            },
2639            crate::store::Edge {
2640                rel_type: "USES".to_string(),
2641                target: explicit_target.clone(),
2642                source: crate::store::EdgeSource::Explicit,
2643            },
2644        ];
2645
2646        let env = build_entity_envelope(&entity, 0, None, None, None, &edges);
2647        let relationships = env["relationships"].as_array().expect("array");
2648        let refs = relationships
2649            .iter()
2650            .find(|r| r["rel_type"] == "REFERENCES")
2651            .expect("REFERENCES present");
2652        assert_eq!(
2653            refs["source"], "body_link",
2654            "alias-synthesised edge must label body_link"
2655        );
2656        let uses = relationships
2657            .iter()
2658            .find(|r| r["rel_type"] == "USES")
2659            .expect("USES present");
2660        assert_eq!(
2661            uses["source"], "explicit",
2662            "explicit-authored edge must label explicit"
2663        );
2664    }
2665
2666    /// A relationship whose store edge is missing
2667    /// (transitional drift, store-rebuild lag) falls back to
2668    /// `"explicit"` so the envelope doesn't crash. The fallback is
2669    /// the conservative label — agents already branch on it.
2670    #[test]
2671    fn build_entity_envelope_source_field_falls_back_to_explicit_when_edge_missing() {
2672        let mut entity = test_entity();
2673        let target = EntityId("specs--unmapped".to_string());
2674        entity.relationships = vec![crate::entity::Relationship::new("USES".to_string(), target)];
2675        let edges: Vec<crate::store::Edge> = Vec::new();
2676        let env = build_entity_envelope(&entity, 0, None, None, None, &edges);
2677        let relationships = env["relationships"].as_array().expect("array");
2678        assert_eq!(relationships[0]["source"], "explicit");
2679    }
2680
2681    /// Every schema-declared frontmatter key surfaces under the nested
2682    /// `metadata` map — its single home. The four
2683    /// formerly-hoisted scalars are not at the top level; the
2684    /// read-only identity triple (mem/id/type) and underscore-prefixed
2685    /// internal keys are excluded from the nested map.
2686    #[test]
2687    fn build_entity_envelope_nested_metadata_carries_every_schema_field() {
2688        use crate::entity::MetadataValue;
2689        let mut entity = test_entity();
2690        entity.entity_type = "contract".to_string();
2691        // Pre-fix the envelope dropped every non-promoted key.
2692        entity.metadata = IndexMap::from([
2693            ("level".to_string(), MetadataValue::String("M0".to_string())),
2694            (
2695                "stability".to_string(),
2696                MetadataValue::String("stable".to_string()),
2697            ),
2698            (
2699                "created_date".to_string(),
2700                MetadataValue::String("2026-01-01".to_string()),
2701            ),
2702            (
2703                "last_modified".to_string(),
2704                MetadataValue::String("2026-05-19".to_string()),
2705            ),
2706            (
2707                "protocol".to_string(),
2708                MetadataValue::String("https".to_string()),
2709            ),
2710            (
2711                "version".to_string(),
2712                MetadataValue::String("0.1.0".to_string()),
2713            ),
2714            (
2715                "deprecation_status".to_string(),
2716                MetadataValue::String("none".to_string()),
2717            ),
2718        ]);
2719
2720        let env = build_entity_envelope(&entity, 0, None, None, None, &[]);
2721
2722        // Metadata scalars are NOT hoisted to the top level — the
2723        // nested map is their single home.
2724        assert!(
2725            env.get("level").is_none(),
2726            "level must not be hoisted top-level"
2727        );
2728        assert!(
2729            env.get("stability").is_none(),
2730            "stability must not be hoisted"
2731        );
2732        assert!(
2733            env.get("created_date").is_none(),
2734            "created_date must not be hoisted"
2735        );
2736        assert!(
2737            env.get("last_modified").is_none(),
2738            "last_modified must not be hoisted"
2739        );
2740        // `type` stays top-level as identity.
2741        assert_eq!(env["type"], "contract");
2742
2743        // Nested map carries every non-internal, non-identity frontmatter key.
2744        let metadata = env["metadata"].as_object().expect("metadata map");
2745        assert_eq!(metadata["level"], "M0");
2746        assert_eq!(metadata["stability"], "stable");
2747        assert_eq!(metadata["created_date"], "2026-01-01");
2748        assert_eq!(metadata["last_modified"], "2026-05-19");
2749        assert_eq!(metadata["protocol"], "https");
2750        assert_eq!(metadata["version"], "0.1.0");
2751        assert_eq!(metadata["deprecation_status"], "none");
2752
2753        // Internal underscore-prefixed keys and the read-only identity
2754        // triple (mem/id/type) do NOT appear inside the nested map.
2755        for k in metadata.keys() {
2756            assert!(
2757                !k.starts_with('_'),
2758                "metadata map must not carry underscore-prefixed key `{k}`"
2759            );
2760            assert!(
2761                !["mem", "id", "type"].contains(&k.as_str()),
2762                "metadata map must not carry identity key `{k}` (it lives top-level)"
2763            );
2764        }
2765    }
2766
2767    /// Stub envelopes carry an
2768    /// empty `metadata: {}` map so consumers don't branch on the
2769    /// map's presence.
2770    #[test]
2771    fn build_entity_envelope_stub_carries_empty_metadata_map() {
2772        let mut entity = test_entity();
2773        entity.stub = true;
2774        entity.stub_kind = Some(crate::entity::StubKind::ForwardReference);
2775        entity.metadata = IndexMap::new();
2776        let env = build_entity_envelope(&entity, 0, None, None, None, &[]);
2777        let metadata = env["metadata"]
2778            .as_object()
2779            .expect("metadata key present even on stubs");
2780        assert!(metadata.is_empty(), "stub metadata map must be empty");
2781    }
2782
2783    /// A user-defined schema names a
2784    /// metadata field colliding with structured envelope slots
2785    /// (`sections`, `relationships`). The colliding name surfaces
2786    /// under `metadata.sections` / `metadata.relationships` without
2787    /// disturbing the top-level structured arrays — the nested map
2788    /// decouples user namespace from engine namespace.
2789    #[test]
2790    fn build_entity_envelope_user_field_collisions_isolated_to_nested_map() {
2791        use crate::entity::MetadataValue;
2792        let mut entity = test_entity();
2793        entity.metadata = IndexMap::from([
2794            (
2795                "sections".to_string(),
2796                MetadataValue::String("user-supplied-shadow".to_string()),
2797            ),
2798            (
2799                "relationships".to_string(),
2800                MetadataValue::String("also-shadowed".to_string()),
2801            ),
2802        ]);
2803        let env = build_entity_envelope(&entity, 0, None, None, None, &[]);
2804        // Top-level structured slots stay structured.
2805        assert!(
2806            env["sections"].is_object(),
2807            "top-level sections stays a map"
2808        );
2809        assert!(
2810            env["relationships"].is_array(),
2811            "top-level relationships stays an array"
2812        );
2813        // User-supplied collisions land inside the nested map.
2814        let metadata = env["metadata"].as_object().expect("metadata map");
2815        assert_eq!(metadata["sections"], "user-supplied-shadow");
2816        assert_eq!(metadata["relationships"], "also-shadowed");
2817    }
2818
2819    /// `_tokens_unfiltered_body` on the structured envelope rides only
2820    /// when `full_tokens` is supplied (a section filter was active);
2821    /// the legacy `_tokens_full` name is not present as an alias.
2822    #[test]
2823    fn build_entity_envelope_unfiltered_body_token_field_name() {
2824        let entity = test_entity();
2825        // Filter-active path — field present under new name.
2826        let env_filtered = build_entity_envelope(&entity, 10, Some(42), None, None, &[]);
2827        assert_eq!(env_filtered["_tokens_unfiltered_body"], 42);
2828        assert!(
2829            env_filtered.get("_tokens_full").is_none(),
2830            "_tokens_full must not survive — rename is one-way"
2831        );
2832        // No-filter path — field absent under both names.
2833        let env_unfiltered = build_entity_envelope(&entity, 10, None, None, None, &[]);
2834        assert!(env_unfiltered.get("_tokens_unfiltered_body").is_none());
2835        assert!(env_unfiltered.get("_tokens_full").is_none());
2836    }
2837
2838    // ------------------------------------------------------------------
2839    // Schema verbosity (lite vs. full) — Plan 01.
2840    // ------------------------------------------------------------------
2841
2842    /// Load the embedded `software` schema (~42 rel-types, 9 entity
2843    /// types, `alias_target_rel_type: REFERENCES`) — the heaviest builtin,
2844    /// so the lite cut has something to bite into.
2845    fn software_schema() -> Arc<Schema> {
2846        memstead_schema::builtins::load_builtin_schemas()
2847            .expect("builtins load")
2848            .into_iter()
2849            .find(|s| s.manifest.name == "software")
2850            .expect("software schema is a builtin")
2851    }
2852
2853    #[test]
2854    fn schema_verbosity_wire_round_trips() {
2855        assert_eq!(
2856            SchemaVerbosity::from_wire("full"),
2857            Some(SchemaVerbosity::Full)
2858        );
2859        assert_eq!(
2860            SchemaVerbosity::from_wire("lite"),
2861            Some(SchemaVerbosity::Lite)
2862        );
2863        assert_eq!(SchemaVerbosity::from_wire("brief"), None);
2864        assert_eq!(SchemaVerbosity::from_wire(""), None);
2865        assert_eq!(SchemaVerbosity::Full.as_wire(), "full");
2866        assert_eq!(SchemaVerbosity::Lite.as_wire(), "lite");
2867        assert_eq!(SchemaVerbosity::default(), SchemaVerbosity::Full);
2868    }
2869
2870    /// A first-party schema labels its origin and serves its full prose
2871    /// under `full`. The origin field is additive and present in both
2872    /// verbosities so a consuming host can always read it.
2873    #[test]
2874    fn first_party_origin_is_labelled_and_keeps_prose() {
2875        let schema = software_schema();
2876        let full = build_schema_payload(
2877            &schema,
2878            vec!["v".into()],
2879            SchemaVerbosity::Full,
2880            OriginClass::FirstParty,
2881        );
2882        assert_eq!(full["origin"], "first-party");
2883        // First-party full keeps the prose-instruction fields.
2884        assert!(full["description"].is_string());
2885        let t = &full["types"].as_array().unwrap()[0];
2886        assert!(t.get("system_context").is_some());
2887        assert!(t.get("writing_guidance").is_some());
2888
2889        // The origin label rides the lite skeleton too.
2890        let lite = build_schema_payload(
2891            &schema,
2892            vec!["v".into()],
2893            SchemaVerbosity::Lite,
2894            OriginClass::FirstParty,
2895        );
2896        assert_eq!(lite["origin"], "first-party");
2897    }
2898
2899    /// A third-party schema is de-framed: a `full`-verbosity request is
2900    /// overridden to the structural-only skeleton, so NONE of the
2901    /// prose-instruction fields (`system_context`, `writing_guidance`,
2902    /// section `write_rules`, schema `description` / `when_to_use`,
2903    /// `default_writing_guidance`, rel `description` / `when_to_use`)
2904    /// reach a consuming agent — even though `full` was asked for. The
2905    /// structural skeleton (type/section/field/rel shape) survives so the
2906    /// mem stays understandable and queryable. This is the refusal
2907    /// complement: a `full` request cannot re-admit the prose.
2908    #[test]
2909    fn third_party_origin_forces_structural_only_even_under_full() {
2910        let schema = software_schema();
2911        let full_requested = build_schema_payload(
2912            &schema,
2913            vec!["v".into()],
2914            SchemaVerbosity::Full,
2915            OriginClass::ThirdParty,
2916        );
2917
2918        // Origin label.
2919        assert_eq!(full_requested["origin"], "third-party");
2920
2921        // Prose-bearing rich arrays are GONE despite the full request;
2922        // the structural-only summaries are present instead.
2923        assert!(
2924            full_requested.get("types").is_none(),
2925            "third-party omits the rich `types` array even under full"
2926        );
2927        assert!(
2928            full_requested.get("relationships").is_none(),
2929            "third-party omits the rich `relationships` array even under full"
2930        );
2931        assert!(
2932            full_requested["types_summary"].is_array(),
2933            "third-party serves the structural `types_summary` skeleton"
2934        );
2935        assert!(
2936            full_requested["relationships_summary"].is_array(),
2937            "third-party serves the structural `relationships_summary` skeleton"
2938        );
2939
2940        // Schema-level prose-instruction fields dropped.
2941        assert!(
2942            full_requested.get("description").is_none(),
2943            "third-party drops schema description prose"
2944        );
2945        assert!(
2946            full_requested.get("when_to_use").is_none(),
2947            "third-party drops schema when_to_use prose"
2948        );
2949        assert!(
2950            full_requested.get("default_writing_guidance").is_none(),
2951            "third-party drops default_writing_guidance prose"
2952        );
2953
2954        // Per-type prose-instruction fields dropped.
2955        for t in full_requested["types_summary"].as_array().unwrap() {
2956            assert!(
2957                t.get("system_context").is_none(),
2958                "third-party drops system_context"
2959            );
2960            assert!(
2961                t.get("writing_guidance").is_none(),
2962                "third-party drops writing_guidance"
2963            );
2964            assert!(
2965                t.get("description").is_none(),
2966                "third-party drops type description"
2967            );
2968            for s in t["sections"].as_array().unwrap() {
2969                assert!(
2970                    s.get("write_rules").is_none(),
2971                    "third-party drops section write_rules"
2972                );
2973            }
2974        }
2975        // Per-rel prose dropped.
2976        for r in full_requested["relationships_summary"].as_array().unwrap() {
2977            assert!(
2978                r.get("description").is_none(),
2979                "third-party drops rel description"
2980            );
2981            assert!(
2982                r.get("when_to_use").is_none(),
2983                "third-party drops rel when_to_use"
2984            );
2985        }
2986
2987        // A third-party schema served under `full` is byte-identical to
2988        // the same schema served under `lite` (modulo the origin label,
2989        // which is identical here) — the override fully collapses to Lite.
2990        let lite_requested = build_schema_payload(
2991            &schema,
2992            vec!["v".into()],
2993            SchemaVerbosity::Lite,
2994            OriginClass::ThirdParty,
2995        );
2996        assert_eq!(
2997            full_requested, lite_requested,
2998            "third-party full must collapse to the lite skeleton"
2999        );
3000    }
3001
3002    #[test]
3003    fn full_payload_carries_the_rich_arrays_and_prose() {
3004        let schema = software_schema();
3005        let full = build_schema_payload(
3006            &schema,
3007            vec!["v".into()],
3008            SchemaVerbosity::Full,
3009            OriginClass::FirstParty,
3010        );
3011
3012        // Full keeps today's contract: rich arrays + schema-level prose.
3013        assert!(full["types"].is_array(), "full has `types`");
3014        assert!(full["relationships"].is_array(), "full has `relationships`");
3015        assert!(
3016            full.get("types_summary").is_none(),
3017            "full omits `types_summary`"
3018        );
3019        assert!(
3020            full.get("relationships_summary").is_none(),
3021            "full omits `relationships_summary`"
3022        );
3023        assert!(
3024            full["description"].is_string(),
3025            "full keeps schema description"
3026        );
3027        assert!(
3028            full["when_to_use"].is_string(),
3029            "full keeps schema when_to_use"
3030        );
3031        assert_eq!(full["alias_target_rel_type"], "REFERENCES");
3032
3033        // A full type entry keeps the prose the lite cut drops.
3034        let t = &full["types"].as_array().unwrap()[0];
3035        assert!(t["description"].is_string());
3036        assert!(t.get("writing_guidance").is_some());
3037        assert!(t.get("system_context").is_some());
3038        // A full rel entry keeps its prose.
3039        let r = &full["relationships"].as_array().unwrap()[0];
3040        assert!(r["description"].is_string());
3041        assert!(r.get("when_to_use").is_some());
3042        assert!(r.get("default_weight").is_some());
3043    }
3044
3045    #[test]
3046    fn lite_payload_is_the_structural_skeleton_without_prose() {
3047        let schema = software_schema();
3048        let lite = build_schema_payload(
3049            &schema,
3050            vec!["v".into()],
3051            SchemaVerbosity::Lite,
3052            OriginClass::FirstParty,
3053        );
3054
3055        // Heavy arrays under the distinct lite keys; rich keys absent.
3056        let types = lite["types_summary"]
3057            .as_array()
3058            .expect("lite has `types_summary`");
3059        let rels = lite["relationships_summary"]
3060            .as_array()
3061            .expect("lite has `relationships_summary`");
3062        assert!(lite.get("types").is_none(), "lite omits rich `types`");
3063        assert!(
3064            lite.get("relationships").is_none(),
3065            "lite omits rich `relationships`"
3066        );
3067
3068        // Alias pointer + endpoint constraints survive the cut — every
3069        // flag an agent needs to author a legal write.
3070        assert_eq!(lite["alias_target_rel_type"], "REFERENCES");
3071
3072        // Schema-level prose dropped.
3073        assert!(
3074            lite.get("description").is_none(),
3075            "lite drops schema description"
3076        );
3077        assert!(
3078            lite.get("when_to_use").is_none(),
3079            "lite drops schema when_to_use"
3080        );
3081        assert!(
3082            lite.get("default_writing_guidance").is_none(),
3083            "lite drops default_writing_guidance"
3084        );
3085
3086        // Every entity-type name carries its section keys (with `required`)
3087        // and field shapes — and NO type/section prose.
3088        for t in types {
3089            assert!(t["name"].is_string());
3090            let sections = t["sections"].as_array().expect("lite type has sections");
3091            for s in sections {
3092                assert!(s["key"].is_string(), "section carries its key");
3093                assert!(s["required"].is_boolean(), "section carries required flag");
3094                assert!(
3095                    s.get("write_rules").is_none(),
3096                    "lite section drops write_rules prose"
3097                );
3098                assert!(s.get("heading").is_none(), "lite section drops heading");
3099            }
3100            assert!(
3101                t.get("description").is_none(),
3102                "lite type drops description"
3103            );
3104            assert!(
3105                t.get("writing_guidance").is_none(),
3106                "lite type drops writing_guidance"
3107            );
3108            assert!(
3109                t.get("system_context").is_none(),
3110                "lite type drops system_context"
3111            );
3112            // `propagating_relationships` rides along — it governs the
3113            // self-loop relate refusal, a write-time refusal lite must let
3114            // an agent avoid.
3115            assert!(
3116                t.get("propagating_relationships").is_some(),
3117                "lite type keeps propagating_relationships"
3118            );
3119            // Field shapes present (name + required), prose absent.
3120            if let Some(fields) = t["fields"].as_array() {
3121                for f in fields {
3122                    assert!(f["name"].is_string());
3123                    assert!(f["required"].is_boolean());
3124                    assert!(
3125                        f.get("description").is_none(),
3126                        "lite field drops description"
3127                    );
3128                }
3129            }
3130        }
3131
3132        // Every relationship name carries its allowed endpoints and the
3133        // refusal-governing flags — and NO description/when_to_use prose.
3134        for r in rels {
3135            assert!(r["name"].is_string());
3136            assert!(
3137                r.get("allowed_sources").is_some(),
3138                "lite rel has allowed_sources"
3139            );
3140            assert!(
3141                r.get("allowed_targets").is_some(),
3142                "lite rel has allowed_targets"
3143            );
3144            assert!(
3145                r.get("manual_authoring").is_some(),
3146                "lite rel keeps manual_authoring"
3147            );
3148            assert!(r.get("acyclic").is_some(), "lite rel keeps acyclic");
3149            assert!(
3150                r.get("per_edge_description").is_some(),
3151                "lite rel keeps per_edge_description"
3152            );
3153            assert!(r.get("description").is_none(), "lite rel drops description");
3154            assert!(r.get("when_to_use").is_none(), "lite rel drops when_to_use");
3155            assert!(
3156                r.get("default_weight").is_none(),
3157                "lite rel drops default_weight"
3158            );
3159        }
3160    }
3161
3162    #[test]
3163    fn lite_is_measurably_smaller_than_full() {
3164        let schema = software_schema();
3165        let full = build_schema_payload(
3166            &schema,
3167            vec!["v".into()],
3168            SchemaVerbosity::Full,
3169            OriginClass::FirstParty,
3170        );
3171        let lite = build_schema_payload(
3172            &schema,
3173            vec!["v".into()],
3174            SchemaVerbosity::Lite,
3175            OriginClass::FirstParty,
3176        );
3177        let full_len = serde_json::to_string(&full).unwrap().len();
3178        let lite_len = serde_json::to_string(&lite).unwrap().len();
3179        assert!(
3180            lite_len * 2 < full_len,
3181            "lite ({lite_len} B) must be well under half of full ({full_len} B)"
3182        );
3183    }
3184
3185    #[test]
3186    fn lite_full_carry_the_same_type_and_rel_names() {
3187        // The cut drops prose, never an entity type or a rel-type — an
3188        // agent orienting on lite sees the full vocabulary.
3189        let schema = software_schema();
3190        let full = build_schema_payload(
3191            &schema,
3192            vec!["v".into()],
3193            SchemaVerbosity::Full,
3194            OriginClass::FirstParty,
3195        );
3196        let lite = build_schema_payload(
3197            &schema,
3198            vec!["v".into()],
3199            SchemaVerbosity::Lite,
3200            OriginClass::FirstParty,
3201        );
3202
3203        let names = |arr: &serde_json::Value| -> Vec<String> {
3204            arr.as_array()
3205                .unwrap()
3206                .iter()
3207                .map(|v| v["name"].as_str().unwrap().to_string())
3208                .collect()
3209        };
3210        assert_eq!(names(&full["types"]), names(&lite["types_summary"]));
3211        assert_eq!(
3212            names(&full["relationships"]),
3213            names(&lite["relationships_summary"])
3214        );
3215    }
3216}