1use 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
24pub fn render_entity_markdown(entity: &Entity, sections_filter: Option<&[String]>) -> String {
36 render_entity_markdown_with_signals(entity, sections_filter, None, None)
37}
38
39pub fn render_entity_markdown_with_signals(
49 entity: &Entity,
50 sections_filter: Option<&[String]>,
51 signals: Option<&[crate::ops::signals::ComputedSignal]>,
52 labelling: Option<&crate::ops::labelling::LabellingView>,
53) -> String {
54 let body_text = render_entity_body(entity, sections_filter);
55
56 let mut lines = Vec::new();
58 lines.push("---".to_string());
59 lines.push(format!("_hash: {}", entity.content_hash));
60 if let Some(kind) = &entity.stub_kind {
66 match kind {
67 crate::entity::StubKind::ForwardReference => {
68 lines.push("_stub_kind: forward_reference".to_string());
69 }
70 crate::entity::StubKind::LoadTime => {
71 lines.push("_stub_kind: load_time".to_string());
72 }
73 crate::entity::StubKind::Residual {
74 since_commit,
75 readonly_referrers,
76 } => {
77 lines.push("_stub_kind: residual".to_string());
78 if !since_commit.is_empty() {
79 lines.push(format!("_stub_since_commit: {since_commit}"));
80 }
81 if !readonly_referrers.is_empty() {
82 let refs: Vec<String> =
83 readonly_referrers.iter().map(|r| r.to_string()).collect();
84 lines.push(format!("_stub_readonly_referrers: [{}]", refs.join(", ")));
85 }
86 }
87 }
88 }
89 if let Some(sigs) = signals
93 && !sigs.is_empty()
94 {
95 let headline: Vec<String> = sigs
96 .iter()
97 .map(|s| format!("{}: {} ({})", s.name, s.value, s.level_wire()))
98 .collect();
99 lines.push(format!("_signals: [{}]", headline.join(", ")));
100 }
101 if let Some(lab) = labelling {
104 lines.push(format!("_label: {}", lab.label.wire()));
105 }
106 if let Some((absorbing, _)) = entity.sections.iter().find_map(|(k, v)| {
112 crate::markdown::closing_fence_if_unterminated(v.trim()).map(|f| (k.clone(), f))
113 }) {
114 let unread: Vec<&str> = entity
115 .sections
116 .iter()
117 .filter(|(k, v)| **k != absorbing && v.trim().is_empty())
118 .map(|(k, _)| k.as_str())
119 .collect();
120 if !unread.is_empty() {
121 lines.push(format!(
122 "_unread_sections: [{}] NOT empty: an unterminated code fence in `{absorbing}` \
123 swallowed them, and their content is inside that section's body",
124 unread.join(", "),
125 ));
126 }
127 }
128 let tokens = estimate_tokens(&body_text);
129 lines.push(format!("_tokens: {tokens}"));
130
131 let is_filtered = sections_filter.is_some_and(|f| {
134 let all_keys: Vec<&String> = entity.sections.keys().collect();
135 f.len() < all_keys.len() || !all_keys.iter().all(|k| f.iter().any(|fk| fk == *k))
136 });
137 if is_filtered {
138 let full_body = render_entity_body(entity, None);
139 let full_tokens = estimate_tokens(&full_body);
140 lines.push(format!("_tokens_unfiltered_body: {full_tokens}"));
141 }
142
143 for (key, value) in &entity.metadata {
150 if key.starts_with('_')
151 || crate::runtime_validator::READ_ONLY_METADATA_KEYS.contains(&key.as_str())
152 {
153 continue;
154 }
155 lines.push(format!("{key}: {value}"));
156 }
157 lines.push("---".to_string());
158 lines.push(String::new());
159
160 lines.push(body_text);
161
162 if let Some(sigs) = signals
165 && !sigs.is_empty()
166 {
167 lines.push(String::new());
168 lines.push("## Signals".to_string());
169 lines.push(String::new());
170 for s in sigs {
171 if s.contributors.is_empty() {
172 lines.push(format!(
173 "- **{}**: {} ({})",
174 s.name,
175 s.value,
176 s.level_wire()
177 ));
178 } else {
179 let ids: Vec<String> = s.contributors.iter().map(|c| c.to_string()).collect();
180 lines.push(format!(
181 "- **{}**: {} ({}) — {}",
182 s.name,
183 s.value,
184 s.level_wire(),
185 ids.join(", ")
186 ));
187 }
188 }
189 }
190 if let Some(lab) = labelling {
195 lines.push(String::new());
196 lines.push("## Labelling".to_string());
197 lines.push(String::new());
198 lines.push(format!("- label: {}", lab.label.wire()));
199 if !lab.defeated_by.is_empty() {
200 lines.push(format!("- defeated_by: {}", lab.defeated_by.join(", ")));
201 }
202 if !lab.undecided_by.is_empty() {
203 lines.push(format!("- undecided_by: {}", lab.undecided_by.join(", ")));
204 }
205 if let Some(shape) = &lab.shape {
206 let share = match shape.terminal_share {
207 Some(s) => format!("{s:.2}"),
208 None => "null".to_string(),
209 };
210 lines.push(format!(
211 "- shape: depth {}, branching {:.2}, terminal_share {}, defeated_in_support {}, undecided_in_support {}",
212 shape.depth,
213 shape.branching,
214 share,
215 shape.defeated_in_support,
216 shape.undecided_in_support,
217 ));
218 }
219 }
220 lines.join("\n")
221}
222
223pub fn rendered_body_tokens(entity: &Entity, sections_filter: Option<&[String]>) -> usize {
230 estimate_tokens(&render_entity_body(entity, sections_filter))
231}
232
233fn render_entity_body(entity: &Entity, sections_filter: Option<&[String]>) -> String {
240 let mut body = Vec::new();
241
242 body.push(format!("# {}", entity.title));
243 body.push(String::new());
244
245 let type_def = lookup_builtin_type(&entity.entity_type);
253
254 for (key, content) in &entity.sections {
255 if let Some(filter) = sections_filter
256 && !filter.iter().any(|f| f == key)
257 {
258 continue;
259 }
260 let heading = section_heading_for(type_def.as_deref(), key);
261 body.push(format!("## {heading}"));
262 body.push(String::new());
263 body.push(content.trim().to_string());
264 body.push(String::new());
265 }
266
267 if !entity.relationships.is_empty()
268 && sections_filter.is_none_or(|f| f.iter().any(|s| s == "relationships"))
269 {
270 body.push("## Relationships".to_string());
271 body.push(String::new());
272 for rel in &entity.relationships {
273 match rel
277 .description
278 .as_deref()
279 .map(str::trim)
280 .filter(|s| !s.is_empty())
281 {
282 Some(text) => body.push(format!(
283 "- **{}**: [[{}]] \u{2014} {text}",
284 rel.rel_type, rel.target
285 )),
286 None => body.push(format!("- **{}**: [[{}]]", rel.rel_type, rel.target)),
287 }
288 }
289 body.push(String::new());
290 }
291
292 body.join("\n")
293}
294
295pub fn render_relations_markdown(
300 entity_id: &str,
301 outgoing: &[Edge],
302 incoming: &[InEdge],
303) -> String {
304 let mut lines = Vec::new();
305 lines.push(String::new());
306 lines.push("## Relations".to_string());
307 lines.push(String::new());
308
309 if outgoing.is_empty() && incoming.is_empty() {
310 lines.push(format!("(no relations for {entity_id})"));
311 lines.push(String::new());
312 return lines.join("\n");
313 }
314
315 if !outgoing.is_empty() {
316 lines.push("### Outgoing".to_string());
317 for e in outgoing {
318 lines.push(format!("- **{}** → [[{}]]", e.rel_type, e.target));
319 }
320 lines.push(String::new());
321 }
322
323 if !incoming.is_empty() {
324 lines.push("### Incoming".to_string());
325 for e in incoming {
326 lines.push(format!("- [[{}]] → **{}** → (this)", e.from, e.rel_type));
327 }
328 lines.push(String::new());
329 }
330
331 lines.join("\n")
332}
333
334pub fn render_relations_json(
337 entity_id: &str,
338 outgoing: &[Edge],
339 incoming: &[InEdge],
340) -> serde_json::Value {
341 let out: Vec<serde_json::Value> = outgoing
342 .iter()
343 .map(|e| {
344 serde_json::json!({
345 "rel_type": e.rel_type,
346 "target": e.target.to_string(),
347 "source": format!("{:?}", e.source).to_lowercase(),
348 })
349 })
350 .collect();
351
352 let inc: Vec<serde_json::Value> = incoming
353 .iter()
354 .map(|e| {
355 serde_json::json!({
356 "rel_type": e.rel_type,
357 "from": e.from.to_string(),
358 "source": format!("{:?}", e.source).to_lowercase(),
359 })
360 })
361 .collect();
362
363 serde_json::json!({
364 "entity": entity_id,
365 "outgoing": out,
366 "incoming": inc,
367 })
368}
369
370pub fn render_search_markdown(result: &SearchResult, offset: usize) -> String {
376 let mut lines = Vec::new();
377
378 lines.push("---".to_string());
379 lines.push(format!("_total: {}", result.total));
380 lines.push(format!("_returned: {}", result.returned));
381 lines.push(format!("_offset: {offset}"));
382 lines.push(format!("_total_tokens: {}", result.total_tokens));
383 lines.push("---".to_string());
384 lines.push(String::new());
385
386 if !result.warnings.is_empty() {
387 lines.push("## Filter warnings".to_string());
392 for w in &result.warnings {
393 lines.push(format!("- **{}**: {}", w.code(), w.message()));
394 }
395 lines.push(String::new());
396 }
397
398 if let Some(facets) = &result.facets
399 && let Some(block) = render_facets_block(facets)
400 {
401 lines.push(block);
402 }
403
404 for hit in &result.hits {
405 lines.push(format!(
406 "### {} — {} (_score: {:.1}, _tokens: {})",
407 hit.id, hit.title, hit.score, hit.tokens,
408 ));
409 lines.push(hit_summary_line(hit));
410 if let Some(line) = render_matched_terms_line(hit.matched_terms.as_ref()) {
411 lines.push(line);
412 }
413 if let Some(line) = render_score_breakdown_line(hit.score_breakdown.as_ref()) {
414 lines.push(line);
415 }
416 if let Some(line) = render_heading_paths_line(hit.matched_terms.as_ref()) {
417 lines.push(line);
418 }
419 if let Some(line) = render_expansion_line(hit.expansion.as_ref()) {
420 lines.push(line);
421 }
422 if let Some(snippet) = &hit.snippet {
423 lines.push(format!("> ...{snippet}..."));
424 }
425 lines.push(String::new());
426 }
427
428 lines.join("\n")
429}
430
431fn render_facets_block(facets: &Facets) -> Option<String> {
439 let blocks: Vec<(&str, String)> = [
440 ("by_type", &facets.by_type),
441 ("by_mem", &facets.by_mem),
442 ("by_level", &facets.by_level),
443 ("by_status", &facets.by_status),
444 ("by_confidence", &facets.by_confidence),
445 ("by_expansion", &facets.by_expansion),
446 ]
447 .into_iter()
448 .filter_map(|(name, bucket)| format_facet_bucket(bucket).map(|s| (name, s)))
449 .collect();
450
451 if blocks.is_empty() && facets.by_subsection.is_empty() {
452 return None;
453 }
454
455 let mut out = String::new();
456 out.push_str("## Facets\n");
457 for (name, body) in blocks {
458 out.push_str(&format!("- **{name}:** {body}\n"));
459 }
460 if !facets.by_subsection.is_empty() {
461 out.push_str("- **by_subsection:**\n");
462 for entry in &facets.by_subsection {
463 out.push_str(&format!(" - {}\n", format_subsection_facet(entry)));
464 }
465 }
466 Some(out)
467}
468
469fn format_facet_bucket(bucket: &HashMap<String, usize>) -> Option<String> {
470 if bucket.is_empty() {
471 return None;
472 }
473 let mut entries: Vec<(&String, &usize)> = bucket.iter().collect();
474 entries.sort_by(|a, b| b.1.cmp(a.1).then_with(|| a.0.cmp(b.0)));
475 Some(
476 entries
477 .iter()
478 .map(|(k, v)| format!("{k}={v}"))
479 .collect::<Vec<_>>()
480 .join(", "),
481 )
482}
483
484fn format_subsection_facet(entry: &SubsectionFacet) -> String {
485 let path = entry.path.join(" › ");
486 format!("`{path}`: {}", entry.count)
487}
488
489fn render_matched_terms_line(matched: Option<&HashMap<String, Vec<TermMatch>>>) -> Option<String> {
494 let matched = matched?;
495 if matched.is_empty() {
496 return None;
497 }
498 let mut terms: Vec<(&String, &Vec<TermMatch>)> = matched.iter().collect();
499 terms.sort_by(|a, b| a.0.cmp(b.0));
500 let groups: Vec<String> = terms
501 .iter()
502 .map(|(term, tms)| {
503 let mut field_counts: HashMap<&str, usize> = HashMap::new();
504 for tm in tms.iter() {
505 *field_counts.entry(tm.field.as_str()).or_insert(0) += 1;
506 }
507 let mut fields: Vec<(&&str, &usize)> = field_counts.iter().collect();
508 fields.sort_by(|a, b| a.0.cmp(b.0));
509 let inner: Vec<String> = fields.iter().map(|(f, n)| format!("{f}×{n}")).collect();
510 format!("`{term}` ({})", inner.join(", "))
511 })
512 .collect();
513 Some(format!("**Matched terms:** {}", groups.join(", ")))
514}
515
516fn render_score_breakdown_line(breakdown: Option<&ScoreBreakdown>) -> Option<String> {
521 let b = breakdown?;
522 let mut parts: Vec<String> = Vec::new();
523 parts.push(format!("bm25 {:.1}", b.bm25));
524 parts.push(format!("title {:.1}", b.title_boost));
525 let mut fields: Vec<(&String, &f32)> = b.field_weights.iter().collect();
526 fields.sort_by(|a, b| a.0.cmp(b.0));
527 for (k, v) in fields {
528 parts.push(format!("{k} {v:.1}"));
529 }
530 if let Some(decay) = b.expansion_decay {
531 parts.push(format!("expansion_decay ×{decay:.1}"));
532 }
533 Some(format!("**Score:** {}", parts.join(" + ")))
534}
535
536fn render_heading_paths_line(matched: Option<&HashMap<String, Vec<TermMatch>>>) -> Option<String> {
540 let matched = matched?;
541 let mut paths: Vec<Vec<String>> = Vec::new();
542 let mut term_keys: Vec<&String> = matched.keys().collect();
543 term_keys.sort();
544 for term in term_keys {
545 for tm in &matched[term] {
546 if let Some(path) = &tm.heading_path
547 && !path.is_empty()
548 && !paths.iter().any(|p| p == path)
549 {
550 paths.push(path.clone());
551 }
552 }
553 }
554 if paths.is_empty() {
555 return None;
556 }
557 let formatted: Vec<String> = paths.iter().map(|p| p.join(" › ")).collect();
558 Some(format!("**Heading path:** {}", formatted.join("; ")))
559}
560
561fn render_expansion_line(expansion: Option<&ExpansionInfo>) -> Option<String> {
565 let e = expansion?;
566 let dir = match e.via_direction {
567 crate::graph::query::TraversalDirection::Out => "out",
568 crate::graph::query::TraversalDirection::In => "in",
569 crate::graph::query::TraversalDirection::Both => "both",
572 };
573 Some(format!(
574 "**Expansion:** from `{}` via `{}` [{dir}] (depth {})",
575 e.of, e.via_edge, e.depth,
576 ))
577}
578
579pub fn render_list_markdown(result: &ListResult) -> String {
581 let mut lines = Vec::new();
582
583 lines.push("---".to_string());
584 lines.push(format!("_total: {}", result.total));
585 lines.push(format!("_returned: {}", result.returned));
586 lines.push(format!("_offset: {}", result.offset));
587 lines.push(format!("_total_tokens: {}", result.total_tokens));
588 lines.push("---".to_string());
589 lines.push(String::new());
590
591 if !result.warnings.is_empty() {
592 lines.push("## Filter warnings".to_string());
593 for w in &result.warnings {
594 lines.push(format!("- **{}**: {}", w.code(), w.message()));
595 }
596 lines.push(String::new());
597 }
598
599 for hit in &result.hits {
600 let meta = hit
601 .sections
602 .get("level")
603 .map(|l| format!("{l}, "))
604 .unwrap_or_default();
605 lines.push(format!(
606 "### {} — {} ({meta}_tokens: {})",
607 hit.id, hit.title, hit.tokens,
608 ));
609 lines.push(hit_summary_line(hit));
610 lines.push(String::new());
611 }
612
613 lines.join("\n")
614}
615
616pub fn render_community_context_section(result: &ContextResult, cluster_id: &str) -> String {
624 let mut lines = Vec::new();
625 lines.push(String::new());
626 lines.push("## Community Context".to_string());
627 lines.push(String::new());
628 lines.push(format!("**Cluster {cluster_id}**"));
629 lines.push(String::new());
630
631 if !result.neighbors.is_empty() {
632 lines.push("### Neighbors".to_string());
633 for n in &result.neighbors {
634 let dir = match n.direction {
635 Direction::Outgoing => "→",
636 Direction::Incoming => "←",
637 };
638 lines.push(format!(
639 "- {} —{}— **{}** ({})",
640 result.entity_id, dir, n.id, n.relationship,
641 ));
642 }
643 lines.push(String::new());
644 }
645
646 lines.join("\n")
647}
648
649pub fn render_context_markdown(result: &ContextResult, cluster_id: &str) -> String {
651 let mut lines = Vec::new();
652
653 lines.push("---".to_string());
654 lines.push(format!("_cluster_id: {cluster_id}"));
655 lines.push("---".to_string());
656 lines.push(String::new());
657 lines.push(format!("## Cluster {cluster_id}"));
658 lines.push(String::new());
659
660 lines.push("### Neighbors".to_string());
662 for n in &result.neighbors {
663 let dir = match n.direction {
664 Direction::Outgoing => "→",
665 Direction::Incoming => "←",
666 };
667 lines.push(format!(
668 "- {} —{}— **{}** ({})",
669 result.entity_id, dir, n.id, n.relationship,
670 ));
671 }
672 lines.push(String::new());
673
674 lines.join("\n")
675}
676
677pub fn render_overview_markdown(output: &LouvainOutput, store: &Store) -> String {
680 let mut lines = Vec::new();
681
682 let entity_count: usize = output.clusters.values().map(|c| c.entities.len()).sum();
683
684 lines.push("---".to_string());
685 lines.push(format!("_cluster_count: {}", output.count));
686 lines.push(format!("_entity_count: {entity_count}"));
687 let mod_str = if output.modularity == 0.0 {
689 "0".to_string()
690 } else {
691 format!("{:.4}", output.modularity)
692 };
693 lines.push(format!("_modularity: {mod_str}"));
694 lines.push("---".to_string());
695 lines.push(String::new());
696
697 let mut cluster_ids: Vec<&String> = output.clusters.keys().collect();
699 cluster_ids.sort();
700
701 for cluster_id in cluster_ids {
702 let info = &output.clusters[cluster_id];
703 let summary = generate_auto_summary(store, &info.entities);
704
705 lines.push(format!(
706 "## Cluster {cluster_id} ({} entities)",
707 info.entities.len(),
708 ));
709 if !summary.is_empty() {
710 lines.push(summary);
711 }
712 for entity_id in &info.entities {
713 lines.push(format!("- {entity_id}"));
714 }
715 lines.push(String::new());
716 }
717
718 lines.join("\n")
719}
720
721#[derive(Serialize)]
737pub struct SearchHitEnvelope<'a> {
738 #[serde(flatten)]
739 pub hit: &'a SearchHit,
740 pub summary_heading: String,
741 pub summary_value: String,
742}
743
744#[derive(Serialize)]
754pub struct SearchResultEnvelope<'a> {
755 #[serde(rename = "_total")]
756 pub total: usize,
757 #[serde(rename = "_returned")]
758 pub returned: usize,
759 #[serde(rename = "_offset")]
760 pub offset: usize,
761 #[serde(rename = "_total_tokens")]
765 pub total_tokens: usize,
766 pub hits: Vec<SearchHitEnvelope<'a>>,
767 #[serde(skip_serializing_if = "Option::is_none")]
772 pub facets: Option<&'a Facets>,
773 #[serde(skip_serializing_if = "Vec::is_empty")]
774 pub warnings: &'a Vec<crate::ops::WarningHint>,
775}
776
777#[derive(Serialize)]
783pub struct ListResultEnvelope<'a> {
784 #[serde(rename = "_total")]
785 pub total: usize,
786 #[serde(rename = "_returned")]
787 pub returned: usize,
788 #[serde(rename = "_offset")]
789 pub offset: usize,
790 #[serde(rename = "_total_tokens")]
791 pub total_tokens: usize,
792 pub hits: Vec<SearchHitEnvelope<'a>>,
793 #[serde(skip_serializing_if = "Vec::is_empty")]
794 pub warnings: &'a Vec<crate::ops::WarningHint>,
795}
796
797#[allow(clippy::too_many_arguments)] pub fn build_entity_envelope(
832 entity: &Entity,
833 rendered_body_tokens: usize,
834 full_tokens: Option<usize>,
835 sections_filter: Option<&[String]>,
836 schema_anchor: Option<&str>,
837 origin: OriginClass,
838 outgoing_edges: &[crate::store::Edge],
839 incoming_edges: Option<&[crate::store::InEdge]>,
840 signals: Option<&[crate::ops::signals::ComputedSignal]>,
841 labelling: Option<&crate::ops::labelling::LabellingView>,
842) -> serde_json::Value {
843 let mut envelope = serde_json::Map::new();
844 if let Some(sigs) = signals
849 && !sigs.is_empty()
850 {
851 envelope.insert(
852 "_signals".to_string(),
853 crate::ops::signals::signals_json(sigs),
854 );
855 }
856 if let Some(lab) = labelling {
861 envelope.insert("_labelling".to_string(), lab.to_json());
862 }
863 envelope.insert(
864 "_hash".to_string(),
865 serde_json::Value::String(entity.content_hash.clone()),
866 );
867 envelope.insert(
874 "origin".to_string(),
875 serde_json::Value::String(origin.as_wire().to_string()),
876 );
877 if let Some((absorbing, fence)) = entity.sections.iter().find_map(|(k, v)| {
890 crate::markdown::closing_fence_if_unterminated(v.trim()).map(|f| (k.clone(), f))
891 }) {
892 let unread: Vec<String> = entity
893 .sections
894 .iter()
895 .filter(|(k, v)| **k != absorbing && v.trim().is_empty())
896 .map(|(k, _)| k.clone())
897 .collect();
898 envelope.insert(
899 "_unread_sections".to_string(),
900 serde_json::json!({
901 "reason": "UNTERMINATED_FENCE",
902 "absorbed_into": absorbing,
903 "fence": fence,
904 "sections": unread,
905 "note": "these sections read as empty because an unterminated code fence in \
906 `absorbed_into` swallowed them: their content is inside that section's \
907 body. Repair through the engine by replacing that section; a write that \
908 does not is refused.",
909 }),
910 );
911 }
912 envelope.insert(
913 "id".to_string(),
914 serde_json::Value::String(entity.id.to_string()),
915 );
916 envelope.insert(
917 "mem".to_string(),
918 serde_json::Value::String(entity.mem.clone()),
919 );
920 envelope.insert(
927 "entity_type".to_string(),
928 serde_json::Value::String(entity.entity_type.clone()),
929 );
930 envelope.insert(
935 "title".to_string(),
936 serde_json::Value::String(entity.title.clone()),
937 );
938
939 let mut metadata = serde_json::Map::new();
955 for (key, value) in &entity.metadata {
956 if key.starts_with('_')
957 || crate::runtime_validator::READ_ONLY_METADATA_KEYS.contains(&key.as_str())
958 {
959 continue;
960 }
961 metadata.insert(
962 key.clone(),
963 serde_json::Value::String(value.to_frontmatter_string()),
964 );
965 }
966 envelope.insert("metadata".to_string(), serde_json::Value::Object(metadata));
967
968 envelope.insert(
969 "_tokens".to_string(),
970 serde_json::Value::Number(serde_json::Number::from(rendered_body_tokens)),
971 );
972 if let Some(t) = full_tokens {
973 envelope.insert(
980 "_tokens_unfiltered_body".to_string(),
981 serde_json::Value::Number(serde_json::Number::from(t)),
982 );
983 }
984 if let Some(s) = schema_anchor {
985 envelope.insert(
986 "_mem_schema".to_string(),
987 serde_json::Value::String(s.to_string()),
988 );
989 }
990
991 if let Some(kind) = &entity.stub_kind {
992 envelope.insert(
993 "_stub_kind".to_string(),
994 serde_json::to_value(kind).unwrap_or(serde_json::Value::Null),
995 );
996 }
997
998 let mut sections = serde_json::Map::new();
999 for (key, content) in &entity.sections {
1000 if let Some(filter) = sections_filter
1001 && !filter.iter().any(|f| f == key)
1002 {
1003 continue;
1004 }
1005 sections.insert(key.clone(), serde_json::Value::String(content.clone()));
1006 }
1007 envelope.insert("sections".to_string(), serde_json::Value::Object(sections));
1008
1009 let resolve_source = |rel: &crate::entity::Relationship| -> &'static str {
1020 outgoing_edges
1021 .iter()
1022 .find(|e| e.rel_type == rel.rel_type && e.target == rel.target)
1023 .map(|e| match e.source {
1024 crate::store::EdgeSource::BodyLink => "body_link",
1025 crate::store::EdgeSource::Hierarchy => "hierarchy",
1026 crate::store::EdgeSource::Explicit => "explicit",
1027 })
1028 .unwrap_or("explicit")
1029 };
1030 let mut relationships: Vec<serde_json::Value> = entity
1038 .relationships
1039 .iter()
1040 .map(|rel| {
1041 let mut obj = serde_json::Map::new();
1042 obj.insert(
1043 "rel_type".to_string(),
1044 serde_json::Value::String(rel.rel_type.clone()),
1045 );
1046 obj.insert(
1047 "target".to_string(),
1048 serde_json::Value::String(rel.target.to_string()),
1049 );
1050 obj.insert(
1051 "direction".to_string(),
1052 serde_json::Value::String("out".to_string()),
1053 );
1054 obj.insert(
1055 "source".to_string(),
1056 serde_json::Value::String(resolve_source(rel).to_string()),
1057 );
1058 if let Some(desc) = rel
1059 .description
1060 .as_deref()
1061 .map(str::trim)
1062 .filter(|s| !s.is_empty())
1063 {
1064 obj.insert(
1065 "description".to_string(),
1066 serde_json::Value::String(desc.to_string()),
1067 );
1068 }
1069 serde_json::Value::Object(obj)
1070 })
1071 .collect();
1072 if let Some(incoming) = incoming_edges {
1073 for e in incoming {
1074 let mut obj = serde_json::Map::new();
1075 obj.insert(
1076 "rel_type".to_string(),
1077 serde_json::Value::String(e.rel_type.clone()),
1078 );
1079 obj.insert(
1080 "from".to_string(),
1081 serde_json::Value::String(e.from.to_string()),
1082 );
1083 obj.insert(
1084 "direction".to_string(),
1085 serde_json::Value::String("in".to_string()),
1086 );
1087 obj.insert(
1088 "source".to_string(),
1089 serde_json::Value::String(
1090 match e.source {
1091 crate::store::EdgeSource::BodyLink => "body_link",
1092 crate::store::EdgeSource::Hierarchy => "hierarchy",
1093 crate::store::EdgeSource::Explicit => "explicit",
1094 }
1095 .to_string(),
1096 ),
1097 );
1098 relationships.push(serde_json::Value::Object(obj));
1099 }
1100 }
1101 envelope.insert(
1102 "relationships".to_string(),
1103 serde_json::Value::Array(relationships),
1104 );
1105
1106 serde_json::Value::Object(envelope)
1107}
1108
1109pub fn build_search_envelope<'a>(
1111 result: &'a SearchResult,
1112 offset: usize,
1113) -> SearchResultEnvelope<'a> {
1114 SearchResultEnvelope {
1115 total: result.total,
1116 returned: result.returned,
1117 offset,
1118 total_tokens: result.total_tokens,
1119 hits: result.hits.iter().map(build_hit_envelope).collect(),
1120 facets: result.facets.as_ref(),
1121 warnings: &result.warnings,
1122 }
1123}
1124
1125pub fn build_list_envelope(result: &ListResult) -> ListResultEnvelope<'_> {
1127 ListResultEnvelope {
1128 total: result.total,
1129 returned: result.returned,
1130 offset: result.offset,
1131 total_tokens: result.total_tokens,
1132 hits: result.hits.iter().map(build_hit_envelope).collect(),
1133 warnings: &result.warnings,
1134 }
1135}
1136
1137fn build_hit_envelope(hit: &SearchHit) -> SearchHitEnvelope<'_> {
1138 let (heading, value) = hit_summary_pair(hit);
1139 SearchHitEnvelope {
1140 hit,
1141 summary_heading: heading,
1142 summary_value: value,
1143 }
1144}
1145
1146fn hit_summary_line(hit: &SearchHit) -> String {
1156 let (heading, value) = hit_summary_pair(hit);
1157 format!("**{heading}**: {value}")
1158}
1159
1160fn hit_summary_pair(hit: &SearchHit) -> (String, String) {
1170 if let Some(summary) = &hit.summary {
1171 return (summary.heading.clone(), summary.value.clone());
1172 }
1173 summary_pair(type_by_name(&hit.entity_type).as_deref(), &hit.sections)
1174}
1175
1176fn summary_pair(
1178 schema: Option<&TypeDefinition>,
1179 sections: &HashMap<String, String>,
1180) -> (String, String) {
1181 match schema {
1182 Some(schema) => lead_section_pair(schema, |k| sections.get(k).map(String::as_str)),
1183 None => ("Summary".to_string(), "—".to_string()),
1184 }
1185}
1186
1187pub(crate) fn lead_section_pair<'a>(
1195 schema: &TypeDefinition,
1196 get_section: impl Fn(&str) -> Option<&'a str>,
1197) -> (String, String) {
1198 let Some(section) = schema
1199 .required_sections()
1200 .next()
1201 .or(schema.sections.first())
1202 else {
1203 return ("Summary".to_string(), "—".to_string());
1204 };
1205 let value = get_section(section.key.as_str()).unwrap_or("—");
1206 (section.heading.clone(), value.to_string())
1207}
1208
1209fn section_key_to_heading(key: &str) -> String {
1213 let mut chars = key.chars();
1214 match chars.next() {
1215 None => String::new(),
1216 Some(c) => {
1217 let first: String = c.to_uppercase().collect();
1218 let rest: String = chars.map(|c| if c == '_' { ' ' } else { c }).collect();
1219 format!("{first}{rest}")
1220 }
1221 }
1222}
1223
1224fn section_heading_for(type_def: Option<&TypeDefinition>, key: &str) -> String {
1231 type_def
1232 .and_then(|t| t.sections.iter().find(|s| s.key == key))
1233 .map(|s| s.heading.clone())
1234 .unwrap_or_else(|| section_key_to_heading(key))
1235}
1236
1237fn lookup_builtin_type(name: &str) -> Option<Arc<TypeDefinition>> {
1246 static CACHE: OnceLock<Vec<Arc<Schema>>> = OnceLock::new();
1247 let schemas =
1248 CACHE.get_or_init(|| memstead_schema::builtins::load_builtin_schemas().unwrap_or_default());
1249 for s in schemas {
1250 if let Some(t) = s.get_type(name) {
1251 return Some(t);
1252 }
1253 }
1254 None
1255}
1256
1257pub fn render_type_catalog_markdown() -> String {
1263 render_type_catalog_lines(all_types())
1264}
1265
1266pub fn render_type_catalog_markdown_for(schema: &Schema) -> String {
1272 let mut types: Vec<Arc<TypeDefinition>> = schema.types.values().cloned().collect();
1273 types.sort_by(|a, b| a.name.as_str().cmp(b.name.as_str()));
1274 render_type_catalog_lines(types)
1275}
1276
1277fn render_type_catalog_lines(types: Vec<Arc<TypeDefinition>>) -> String {
1278 let mut lines = vec![
1279 "# Available types".to_string(),
1280 String::new(),
1281 "Run `memstead type <name>` to see its metadata fields, sections, relationship types, and writing guidance — over MCP, `memstead_schema` takes the *schema* name and returns every type at once."
1282 .to_string(),
1283 String::new(),
1284 ];
1285 for schema in types {
1286 let required_sections = schema.required_sections().count();
1287 let total_sections = schema.sections.len();
1288 let metadata_count = schema.metadata_fields.len();
1289 lines.push(format!(
1290 "- **{}** — {} sections ({} required), {} metadata fields, staleness {}d",
1291 schema.name.as_str(),
1292 total_sections,
1293 required_sections,
1294 metadata_count,
1295 schema.staleness_threshold_days,
1296 ));
1297 }
1298 lines.push(String::new());
1299 lines.join("\n")
1300}
1301
1302pub fn render_type_info_markdown(schema: &TypeDefinition) -> String {
1304 render_type_info_markdown_in(schema, None)
1305}
1306
1307pub fn render_type_info_markdown_in(
1314 schema: &TypeDefinition,
1315 parent: Option<&memstead_schema::Schema>,
1316) -> String {
1317 let mut lines = Vec::new();
1318 lines.push(format!("# Type: {}", schema.name.as_str()));
1319 lines.push(String::new());
1320 lines.push(format!(
1321 "Staleness threshold: {} days. Hierarchy: `{}`.",
1322 schema.staleness_threshold_days, schema.hierarchy_relationship,
1323 ));
1324 lines.push(String::new());
1325
1326 lines.push("## Metadata fields".to_string());
1328 for field in &schema.metadata_fields {
1329 lines.push(format!("- {}", describe_metadata_field(field)));
1330 }
1331 lines.push(String::new());
1332
1333 lines.push("## Sections".to_string());
1335 for section in &schema.sections {
1336 let req = if section.required {
1337 "required"
1338 } else {
1339 "optional"
1340 };
1341 let catch_all = if section.catch_all { ", catch-all" } else { "" };
1342 lines.push(format!(
1343 "- **{}** ({req}{catch_all}, search_weight: {:.1})",
1344 section.key, section.search_weight,
1345 ));
1346 for rule in §ion.write_rules {
1347 lines.push(format!(" - Write rule: {rule}"));
1348 }
1349 }
1350 lines.push(String::new());
1351
1352 lines.push("## Relationship types (with edge weights)".to_string());
1354 for (rel_type, weight) in &schema.edge_weights {
1355 if rel_type == "_default" {
1356 continue;
1357 }
1358 let mut flags: Vec<&str> = Vec::new();
1359 if rel_type == &schema.hierarchy_relationship {
1360 flags.push("hierarchy");
1361 }
1362 if schema
1363 .no_self_loop_relationships
1364 .iter()
1365 .any(|r| r == rel_type)
1366 {
1367 flags.push("no-self-loop");
1368 }
1369 if let Some(p) = parent {
1374 match p.relationship_manual_authoring(rel_type) {
1375 memstead_schema::ManualAuthoring::Forbidden => {
1376 flags.push("manual authoring FORBIDDEN — emitted from body wiki-links only");
1377 }
1378 memstead_schema::ManualAuthoring::Warn => {
1379 flags.push("manual authoring warns");
1380 }
1381 memstead_schema::ManualAuthoring::Allow => {}
1382 }
1383 }
1384 let flag_str = if flags.is_empty() {
1385 String::new()
1386 } else {
1387 format!(" ({})", flags.join(", "))
1388 };
1389 lines.push(format!("- **{rel_type}**: {weight}{flag_str}"));
1390 }
1391 if let Some((_, default_weight)) = schema.edge_weights.iter().find(|(n, _)| *n == "_default") {
1393 lines.push(format!(
1394 "- _default_ (any other relationship type): {default_weight}"
1395 ));
1396 }
1397 lines.push(String::new());
1398
1399 if !schema.write_rules.is_empty() {
1401 lines.push("## Writing guidance".to_string());
1402 for rule in &schema.write_rules {
1403 lines.push(format!("- {rule}"));
1404 }
1405 lines.push(String::new());
1406 }
1407
1408 let system_msg = schema.system_message_str();
1410 if !system_msg.is_empty() {
1411 lines.push("## System context".to_string());
1412 lines.push(system_msg.to_string());
1413 lines.push(String::new());
1414 }
1415
1416 if let Some(ex) = &schema.exemplar {
1420 lines.push("## Exemplar (engine-validated)".to_string());
1421 lines.push(String::new());
1422 lines.push(format!("Title: {}", ex.title));
1423 if !ex.metadata.is_empty() {
1424 lines.push("Metadata:".to_string());
1425 for (k, v) in &ex.metadata {
1426 lines.push(format!("- {k}: {v}"));
1427 }
1428 }
1429 for (key, body) in &ex.sections {
1430 let heading = schema
1431 .section(key)
1432 .map(|s| s.heading.clone())
1433 .unwrap_or_else(|| key.clone());
1434 lines.push(format!("### {heading}"));
1435 lines.push(body.clone());
1436 }
1437 if !ex.relations.is_empty() {
1438 lines.push("Relations (placeholder targets):".to_string());
1439 for r in &ex.relations {
1440 match &r.description {
1441 Some(d) => lines.push(format!(
1442 "- {} → {} — {d}",
1443 r.rel_type_name(),
1444 r.target_slug()
1445 )),
1446 None => lines.push(format!("- {} → {}", r.rel_type_name(), r.target_slug())),
1447 }
1448 }
1449 }
1450 lines.push(String::new());
1451 }
1452
1453 lines.join("\n")
1454}
1455
1456pub fn per_edge_description_str(p: PerEdgeDescription) -> &'static str {
1462 match p {
1463 PerEdgeDescription::Forbidden => "forbidden",
1464 PerEdgeDescription::Optional => "optional",
1465 PerEdgeDescription::Required => "required",
1466 }
1467}
1468
1469pub fn manual_authoring_str(p: ManualAuthoring) -> &'static str {
1471 match p {
1472 ManualAuthoring::Allow => "allow",
1473 ManualAuthoring::Warn => "warn",
1474 ManualAuthoring::Forbidden => "forbidden",
1475 }
1476}
1477
1478#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
1494pub enum SchemaVerbosity {
1495 #[default]
1496 Full,
1497 Lite,
1498}
1499
1500impl SchemaVerbosity {
1501 pub fn from_wire(s: &str) -> Option<Self> {
1506 match s {
1507 "full" => Some(Self::Full),
1508 "lite" => Some(Self::Lite),
1509 _ => None,
1510 }
1511 }
1512
1513 pub fn as_wire(self) -> &'static str {
1515 match self {
1516 Self::Full => "full",
1517 Self::Lite => "lite",
1518 }
1519 }
1520}
1521
1522#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
1549pub enum OriginClass {
1550 FirstParty,
1552 #[default]
1555 ThirdParty,
1556}
1557
1558impl OriginClass {
1559 pub fn as_wire(self) -> &'static str {
1563 match self {
1564 Self::FirstParty => "first-party",
1565 Self::ThirdParty => "third-party",
1566 }
1567 }
1568
1569 pub fn is_third_party(self) -> bool {
1572 matches!(self, Self::ThirdParty)
1573 }
1574}
1575
1576fn append_section_format(
1597 obj: &mut serde_json::Map<String, serde_json::Value>,
1598 s: &memstead_schema::SectionDef,
1599) {
1600 if let Some(content) = &s.content {
1601 obj.insert("content".into(), serde_json::json!(content));
1602 obj.insert(
1603 "format_severity".into(),
1604 serde_json::json!(s.format_severity),
1605 );
1606 }
1607 if let Some(pattern) = &s.item_pattern {
1608 obj.insert("item_pattern".into(), serde_json::json!(pattern));
1609 }
1610 if let Some(table) = &s.table {
1611 obj.insert("table".into(), serde_json::json!(table));
1612 }
1613 if let Some(example) = &s.example {
1614 obj.insert("example".into(), serde_json::json!(example));
1615 }
1616}
1617
1618#[derive(Debug, Clone)]
1623pub struct UnknownSchemaTypes {
1624 pub unknown: Vec<String>,
1625 pub known: Vec<String>,
1626}
1627
1628fn estimate_payload_tokens(value: &serde_json::Value) -> usize {
1632 serde_json::to_string(value)
1633 .map(|s| estimate_tokens(&s))
1634 .unwrap_or(0)
1635}
1636
1637pub const DEFAULT_SCHEMA_FULL_BUDGET: usize = 15_000;
1647
1648pub fn build_schema_payload(
1649 schema: &Arc<Schema>,
1650 used_by: Vec<String>,
1651 verbosity: SchemaVerbosity,
1652 origin: OriginClass,
1653) -> serde_json::Value {
1654 build_schema_payload_scoped(schema, used_by, verbosity, origin, None, None)
1657 .expect("no type selection, no refusal")
1658}
1659
1660pub fn build_schema_payload_scoped(
1676 schema: &Arc<Schema>,
1677 used_by: Vec<String>,
1678 verbosity: SchemaVerbosity,
1679 origin: OriginClass,
1680 type_selection: Option<&[String]>,
1681 token_budget: Option<usize>,
1682) -> Result<serde_json::Value, UnknownSchemaTypes> {
1683 let manifest = &schema.manifest;
1684
1685 if let Some(sel) = type_selection {
1689 let unknown: Vec<String> = sel
1690 .iter()
1691 .filter(|t| !manifest.types.iter().any(|m| m == *t))
1692 .cloned()
1693 .collect();
1694 if !unknown.is_empty() {
1695 return Err(UnknownSchemaTypes {
1696 unknown,
1697 known: manifest.types.clone(),
1698 });
1699 }
1700 }
1701 let verbosity = if origin.is_third_party() {
1709 SchemaVerbosity::Lite
1710 } else {
1711 verbosity
1712 };
1713
1714 let relationships: Vec<serde_json::Value> = manifest
1725 .relationships
1726 .definitions
1727 .iter()
1728 .filter(|d| d.name != "_default")
1729 .map(|d| {
1730 let mut o = serde_json::json!({
1751 "name": d.name,
1752 "description": d.description,
1753 "when_to_use": d.when_to_use,
1754 "default_weight": d.default_weight,
1755 "acyclic": d.acyclic,
1756 "per_edge_description": per_edge_description_str(d.per_edge_description),
1757 "manual_authoring": manual_authoring_str(d.manual_authoring),
1758 "allowed_sources": d.source_types,
1759 "allowed_targets": d.target_types,
1760 });
1761 if d.derivation {
1767 o["derivation"] = serde_json::json!(true);
1768 }
1769 o
1770 })
1771 .collect();
1772
1773 let cross_mem_relationships: Vec<serde_json::Value> = manifest
1780 .cross_mem_relationships
1781 .iter()
1782 .map(|entry| {
1783 let definitions: Vec<serde_json::Value> = entry
1784 .definitions
1785 .iter()
1786 .filter(|d| d.name != "_default")
1787 .map(|d| {
1788 serde_json::json!({
1789 "name": d.name,
1790 "description": d.description,
1791 "when_to_use": d.when_to_use,
1792 "default_weight": d.default_weight,
1793 "source_types": d.source_types,
1794 "target_types": d.target_types,
1795 "per_edge_description": per_edge_description_str(d.per_edge_description),
1796 })
1797 })
1798 .collect();
1799 serde_json::json!({
1800 "to_schema": entry.to_schema,
1801 "definitions": definitions,
1802 })
1803 })
1804 .collect();
1805
1806 let types_full: Vec<serde_json::Value> = manifest
1809 .types
1810 .iter()
1811 .filter_map(|name| schema.types.get(name.as_str()).map(|td| (name, td)))
1812 .map(|(_, td)| {
1813 let sections: Vec<serde_json::Value> = td
1814 .sections
1815 .iter()
1816 .map(|s| {
1817 let mut obj = serde_json::json!({
1818 "key": s.key,
1819 "heading": s.heading,
1820 "required": s.required,
1821 "write_rules": s.write_rules,
1822 });
1823 append_section_format(obj.as_object_mut().unwrap(), s);
1829 obj
1830 })
1831 .collect();
1832
1833 let fields: Vec<serde_json::Value> = td
1834 .metadata_fields
1835 .iter()
1836 .map(|f| {
1837 let mut obj = serde_json::json!({
1838 "name": f.key,
1839 "description": f.description,
1840 "required": f.is_required(),
1841 });
1842 if let Some(enum_values) = &f.enum_values {
1843 obj.as_object_mut()
1844 .unwrap()
1845 .insert("enum".into(), serde_json::json!(enum_values));
1846 }
1847 if let Some(default) = &f.default_value {
1854 obj.as_object_mut()
1855 .unwrap()
1856 .insert("default".into(), serde_json::json!(default));
1857 }
1858 obj.as_object_mut().unwrap().insert(
1864 "filterable".into(),
1865 match f.filterable.as_wire_str() {
1866 Some(s) => serde_json::json!(s),
1867 None => serde_json::Value::Null,
1868 },
1869 );
1870 obj
1871 })
1872 .collect();
1873
1874 let required_outgoing: Vec<serde_json::Value> = td
1889 .required_outgoing
1890 .iter()
1891 .map(|block| {
1892 let mut b = serde_json::json!({
1893 "relationships": block.relationships,
1894 "cardinality": block.cardinality.to_string(),
1895 "severity": block.severity,
1896 });
1897 if let (Some(wf), Some(wv)) = (&block.when_field, &block.when_value) {
1902 b["when_field"] = serde_json::json!(wf);
1903 b["when_value"] = serde_json::json!(wv);
1904 }
1905 b
1906 })
1907 .collect();
1908
1909 let constraints: Vec<serde_json::Value> = td
1918 .constraints
1919 .iter()
1920 .map(|c| match c {
1921 memstead_schema::ConstraintDef::RequiresWhen {
1922 field,
1923 when_field,
1924 when_value,
1925 severity,
1926 } => serde_json::json!({
1927 "kind": "requires_when",
1928 "field": field,
1929 "when_field": when_field,
1930 "when_value": when_value,
1931 "severity": severity,
1932 }),
1933 memstead_schema::ConstraintDef::Unique { fields, severity } => {
1934 serde_json::json!({
1935 "kind": "unique",
1936 "fields": fields,
1937 "severity": severity,
1938 })
1939 }
1940 memstead_schema::ConstraintDef::EnumFromNeighbour {
1941 field,
1942 rel_type,
1943 section,
1944 severity,
1945 } => serde_json::json!({
1946 "kind": "enum_from_neighbour",
1947 "field": field,
1948 "rel_type": rel_type,
1949 "section": section,
1950 "severity": severity,
1951 }),
1952 memstead_schema::ConstraintDef::StatusPropagation {
1953 field,
1954 value,
1955 rel_type,
1956 rel_types,
1957 direction,
1958 severity,
1959 } => {
1960 let mut c = serde_json::json!({
1961 "kind": "status_propagation",
1962 "field": field,
1963 "value": value,
1964 "direction": direction,
1965 "severity": severity,
1966 });
1967 if let Some(single) = rel_type {
1971 c["rel_type"] = serde_json::json!(single);
1972 }
1973 if let Some(set) = rel_types {
1974 c["rel_types"] = serde_json::json!(set);
1975 }
1976 c
1977 }
1978 memstead_schema::ConstraintDef::TransitionRequiresChecks {
1979 field,
1980 to_value,
1981 relationships,
1982 direction,
1983 severity,
1984 } => serde_json::json!({
1985 "kind": "transition_requires_checks",
1986 "field": field,
1987 "to_value": to_value,
1988 "relationships": relationships,
1989 "direction": direction,
1990 "severity": severity,
1991 }),
1992 })
1993 .collect();
1994 let mut obj = serde_json::json!({
1995 "name": td.name,
1996 "description": td.description,
1997 "when_to_use": td.when_to_use,
1998 "sections": sections,
1999 "fields": fields,
2000 "writing_guidance": td.write_rules,
2001 "system_context": td.system_message_str(),
2002 "staleness_threshold_days": td.staleness_threshold_days,
2003 "no_self_loop_relationships": td.no_self_loop_relationships,
2004 "required_outgoing": required_outgoing,
2005 "constraints": constraints,
2006 });
2007 if !td.must_reach.is_empty() {
2013 obj["must_reach"] = serde_json::to_value(&td.must_reach)
2014 .expect("must_reach declarations serialize");
2015 }
2016 if !td.signals.is_empty() {
2022 obj["signals"] =
2023 serde_json::to_value(&td.signals).expect("signal declarations serialize");
2024 }
2025 if td.leaf {
2029 obj["leaf"] = serde_json::json!(true);
2030 }
2031 if let Some(ex) = &td.exemplar {
2045 let relations: Vec<serde_json::Value> = ex
2046 .relations
2047 .iter()
2048 .map(|r| {
2049 let mut o = serde_json::json!({
2050 "target": r.target_slug(),
2051 "rel_type": r.rel_type_name(),
2052 });
2053 if let Some(d) = &r.description {
2054 o["description"] = serde_json::json!(d);
2055 }
2056 o
2057 })
2058 .collect();
2059 obj["exemplar"] = serde_json::json!({
2060 "title": ex.title,
2061 "metadata": ex.metadata,
2062 "sections": ex.sections,
2063 "relations": relations,
2064 });
2065 }
2066 obj
2067 })
2068 .collect();
2069
2070 let mode = match manifest.relationships.mode {
2071 RelationshipMode::Strict => "strict",
2072 RelationshipMode::Open => "open",
2073 };
2074
2075 let full = verbosity == SchemaVerbosity::Full;
2076
2077 let mut payload = serde_json::json!({
2081 "ref": format!("{}@{}", manifest.name, schema.version),
2082 "relationship_mode": mode,
2083 "community": {
2084 "resolution": manifest.community.resolution,
2085 "seed": manifest.community.seed,
2086 },
2087 "used_by": used_by,
2088 "origin": origin.as_wire(),
2094 });
2095 let obj = payload.as_object_mut().unwrap();
2096
2097 if !manifest.relationships.acyclic_sets.is_empty() {
2102 obj.insert(
2103 "acyclic_sets".into(),
2104 serde_json::to_value(&manifest.relationships.acyclic_sets)
2105 .expect("acyclic_sets serialize"),
2106 );
2107 }
2108 if let Some(lab) = &manifest.relationships.labelling {
2113 obj.insert(
2114 "labelling".into(),
2115 serde_json::to_value(lab).expect("labelling declaration serializes"),
2116 );
2117 }
2118
2119 if full {
2124 obj.insert(
2125 "description".into(),
2126 serde_json::Value::String(manifest.description.clone()),
2127 );
2128 obj.insert(
2129 "when_to_use".into(),
2130 serde_json::Value::String(manifest.when_to_use.clone()),
2131 );
2132 if let Some(msg) = &manifest.system_message {
2138 obj.insert(
2139 "system_context".into(),
2140 serde_json::Value::String(msg.clone()),
2141 );
2142 }
2143 }
2144
2145 obj.insert(
2152 "no_self_loop_relationships_effect".into(),
2153 serde_json::Value::String(
2154 "Per-type `no_self_loop_relationships` governs exactly one behaviour: \
2155 memstead_relate refuses a self-loop (from == to) on a rel-type the \
2156 source type lists here. It does not propagate impact, imply an \
2157 evidence obligation, or have any other effect (the name says it \
2158 all). To declare real impact propagation, use the \
2159 `status_propagation` constraint (`constraints:` on the type), which \
2160 taints dependents of a terminal status value via a named rel-type \
2161 and direction and surfaces them as health findings."
2162 .to_string(),
2163 ),
2164 );
2165
2166 if let Some(target) = &manifest.alias_target_rel_type {
2175 obj.insert(
2176 "alias_target_rel_type".into(),
2177 serde_json::Value::String(target.clone()),
2178 );
2179 }
2180
2181 if full && let Some(dwg) = &manifest.default_writing_guidance {
2188 let mut block = serde_json::Map::new();
2189 if let Some(avoid) = &dwg.avoid {
2190 block.insert("avoid".into(), serde_json::Value::String(avoid.clone()));
2191 }
2192 if let Some(goal) = &dwg.goal {
2193 block.insert("goal".into(), serde_json::Value::String(goal.clone()));
2194 }
2195 if !block.is_empty() {
2196 obj.insert(
2197 "default_writing_guidance".into(),
2198 serde_json::Value::Object(block),
2199 );
2200 }
2201 }
2202
2203 let selected = |name: &serde_json::Value| -> bool {
2208 match type_selection {
2209 None => true,
2210 Some(sel) => name.as_str().is_some_and(|n| sel.iter().any(|s| s == n)),
2211 }
2212 };
2213 let omitted_names: Vec<serde_json::Value> = types_full
2214 .iter()
2215 .filter(|t| !selected(&t["name"]))
2216 .map(|t| t["name"].clone())
2217 .collect();
2218
2219 if full {
2220 obj.insert(
2221 "relationships".into(),
2222 serde_json::Value::Array(relationships),
2223 );
2224 if !cross_mem_relationships.is_empty() {
2228 obj.insert(
2229 "cross_mem_relationships".into(),
2230 serde_json::Value::Array(cross_mem_relationships),
2231 );
2232 }
2233 match type_selection {
2234 Some(_) => {
2235 let served: Vec<serde_json::Value> = types_full
2236 .iter()
2237 .filter(|t| selected(&t["name"]))
2238 .cloned()
2239 .collect();
2240 obj.insert("types".into(), serde_json::Value::Array(served));
2241 if !omitted_names.is_empty() {
2242 obj.insert(
2243 "types_omitted".into(),
2244 serde_json::Value::Array(omitted_names),
2245 );
2246 }
2247 }
2248 None => {
2249 obj.insert("types".into(), serde_json::Value::Array(types_full.clone()));
2250 if let Some(budget) = token_budget {
2258 let estimated = estimate_payload_tokens(&payload);
2259 if estimated > budget {
2260 let obj = payload.as_object_mut().unwrap();
2261 obj.remove("types");
2262 let all_names: Vec<serde_json::Value> =
2263 types_full.iter().map(|t| t["name"].clone()).collect();
2264 obj.insert(
2265 "types_summary".into(),
2266 serde_json::Value::Array(lite_types_projection(&types_full)),
2267 );
2268 obj.insert("types_omitted".into(), serde_json::Value::Array(all_names));
2269 obj.insert(
2270 "_schema_mode".into(),
2271 serde_json::Value::String("reduced".into()),
2272 );
2273 obj.insert("_estimated_tokens".into(), serde_json::json!(estimated));
2274 obj.insert("_token_budget".into(), serde_json::json!(budget));
2275 obj.insert(
2276 "_hint".into(),
2277 serde_json::Value::String(format!(
2278 "the full prose for all {} types (~{estimated} tokens) exceeds \
2279 the response budget ({budget}); per-type prose is served as the \
2280 lite skeleton here — request the full prose for exactly the \
2281 types you will write via `types: [\"<name>\", …]` (valid names \
2282 in `types_omitted`)",
2283 types_full.len(),
2284 )),
2285 );
2286 }
2287 }
2288 }
2289 }
2290 } else {
2291 let relationships_summary: Vec<serde_json::Value> = relationships
2301 .iter()
2302 .map(|r| {
2303 let mut o = serde_json::json!({
2304 "name": r["name"],
2305 "allowed_sources": r["allowed_sources"],
2306 "allowed_targets": r["allowed_targets"],
2307 "manual_authoring": r["manual_authoring"],
2308 "acyclic": r["acyclic"],
2309 "per_edge_description": r["per_edge_description"],
2310 });
2311 if r.get("derivation") == Some(&serde_json::json!(true)) {
2312 o["derivation"] = serde_json::json!(true);
2313 }
2314 o
2315 })
2316 .collect();
2317 obj.insert(
2318 "relationships_summary".into(),
2319 serde_json::Value::Array(relationships_summary),
2320 );
2321
2322 if !cross_mem_relationships.is_empty() {
2326 let cross_summary: Vec<serde_json::Value> = cross_mem_relationships
2327 .iter()
2328 .map(|e| {
2329 let definitions: Vec<serde_json::Value> = e["definitions"]
2330 .as_array()
2331 .map(|defs| {
2332 defs.iter()
2333 .map(|d| {
2334 serde_json::json!({
2335 "name": d["name"],
2336 "source_types": d["source_types"],
2337 "target_types": d["target_types"],
2338 })
2339 })
2340 .collect()
2341 })
2342 .unwrap_or_default();
2343 serde_json::json!({
2344 "to_schema": e["to_schema"],
2345 "definitions": definitions,
2346 })
2347 })
2348 .collect();
2349 obj.insert(
2350 "cross_mem_relationships_summary".into(),
2351 serde_json::Value::Array(cross_summary),
2352 );
2353 }
2354
2355 let served: Vec<serde_json::Value> = types_full
2359 .iter()
2360 .filter(|t| selected(&t["name"]))
2361 .cloned()
2362 .collect();
2363 obj.insert(
2364 "types_summary".into(),
2365 serde_json::Value::Array(lite_types_projection(&served)),
2366 );
2367 if !omitted_names.is_empty() {
2368 obj.insert(
2369 "types_omitted".into(),
2370 serde_json::Value::Array(omitted_names),
2371 );
2372 }
2373 }
2374
2375 Ok(payload)
2376}
2377
2378fn lite_types_projection(types_full: &[serde_json::Value]) -> Vec<serde_json::Value> {
2394 types_full
2395 .iter()
2396 .map(|t| {
2397 let sections: Vec<serde_json::Value> = t["sections"]
2398 .as_array()
2399 .map(|secs| {
2400 secs.iter()
2401 .map(|s| {
2402 let mut o = serde_json::Map::new();
2403 o.insert("key".into(), s["key"].clone());
2404 o.insert("required".into(), s["required"].clone());
2405 for k in [
2409 "content",
2410 "item_pattern",
2411 "table",
2412 "example",
2413 "format_severity",
2414 ] {
2415 if let Some(v) = s.get(k) {
2416 o.insert(k.into(), v.clone());
2417 }
2418 }
2419 serde_json::Value::Object(o)
2420 })
2421 .collect()
2422 })
2423 .unwrap_or_default();
2424 let fields: Vec<serde_json::Value> = t["fields"]
2425 .as_array()
2426 .map(|fs| {
2427 fs.iter()
2428 .map(|f| {
2429 let mut o = serde_json::Map::new();
2430 o.insert("name".into(), f["name"].clone());
2431 o.insert("required".into(), f["required"].clone());
2432 if let Some(e) = f.get("enum") {
2433 o.insert("enum".into(), e.clone());
2434 }
2435 if let Some(d) = f.get("default") {
2436 o.insert("default".into(), d.clone());
2437 }
2438 serde_json::Value::Object(o)
2439 })
2440 .collect()
2441 })
2442 .unwrap_or_default();
2443 let mut o = serde_json::json!({
2444 "name": t["name"],
2445 "sections": sections,
2446 "fields": fields,
2447 "no_self_loop_relationships": t["no_self_loop_relationships"],
2448 "required_outgoing": t["required_outgoing"],
2449 "constraints": t["constraints"],
2450 });
2451 if t.get("leaf") == Some(&serde_json::json!(true)) {
2454 o["leaf"] = serde_json::json!(true);
2455 }
2456 if let Some(mr) = t.get("must_reach") {
2460 o["must_reach"] = mr.clone();
2461 }
2462 if let Some(sig) = t.get("signals") {
2464 o["signals"] = sig.clone();
2465 }
2466 o
2467 })
2468 .collect()
2469}
2470
2471fn describe_metadata_field(field: &memstead_schema::MetadataFieldDef) -> String {
2473 let type_str = match field.field_type {
2474 FieldType::String => "String",
2475 FieldType::Number => "Number",
2476 FieldType::Date => "Date",
2477 FieldType::Boolean => "Boolean",
2478 };
2479
2480 let mut flags: Vec<&str> = Vec::new();
2481 if !field.is_required() {
2482 flags.push("optional");
2483 } else {
2484 flags.push("required");
2485 }
2486 if field.init_timestamp {
2487 flags.push("auto-init");
2488 }
2489 if field.auto_timestamp {
2490 flags.push("auto-update");
2491 }
2492 match field.serialization {
2493 Serialization::CsvArray => flags.push("csv array"),
2494 Serialization::OmitWhenFalsy => flags.push("omit when falsy"),
2495 Serialization::Default => {}
2496 }
2497
2498 let mut extras: Vec<String> = Vec::new();
2499 if let Some(values) = &field.enum_values {
2500 extras.push(format!("enum: {}", values.join(", ")));
2501 }
2502 if let Some(default) = &field.default_value {
2503 extras.push(format!("default: {default}"));
2504 }
2505 let filterable_str = match field.filterable {
2506 Filterable::None => None,
2507 Filterable::Equality => Some("filterable: equality"),
2508 Filterable::Range => Some("filterable: range"),
2509 };
2510 if let Some(f) = filterable_str {
2511 extras.push(f.to_string());
2512 }
2513
2514 let extras_str = if extras.is_empty() {
2515 String::new()
2516 } else {
2517 format!(" — {}", extras.join(" — "))
2518 };
2519
2520 format!(
2521 "**{key}**: {type_str} ({flags}){extras_str}",
2522 key = field.key,
2523 flags = flags.join(", "),
2524 )
2525}
2526
2527#[cfg(test)]
2528mod tests {
2529 use super::*;
2530 use crate::{Entity, EntityId, ListResult, SearchResult};
2531 use indexmap::IndexMap;
2532 use std::collections::HashMap;
2533
2534 fn make_hit(id: &str, title: &str, entity_type: &str, sections: &[(&str, &str)]) -> SearchHit {
2535 SearchHit {
2536 id: EntityId(id.to_string()),
2537 last_modified: None,
2538 title: title.to_string(),
2539 mem: id.split("--").next().unwrap_or("").to_string(),
2540 entity_type: entity_type.to_string(),
2541 stub: false,
2542 score: 1.0,
2543 tokens: 10,
2544 snippet: None,
2545 sections: sections
2546 .iter()
2547 .map(|(k, v)| (k.to_string(), v.to_string()))
2548 .collect(),
2549 score_breakdown: None,
2550 matched_terms: None,
2551 expansion: None,
2552 summary: None,
2555 }
2556 }
2557
2558 fn search_result(hits: Vec<SearchHit>) -> SearchResult {
2559 let returned = hits.len();
2560 let total_tokens = hits.iter().map(|h| h.tokens).sum();
2561 SearchResult {
2562 total: returned,
2563 returned,
2564 offset: 0,
2565 total_tokens,
2566 hits,
2567 facets: None,
2568 warnings: vec![],
2569 }
2570 }
2571
2572 fn list_result(hits: Vec<SearchHit>) -> ListResult {
2573 let returned = hits.len();
2574 ListResult {
2575 total: returned,
2576 returned,
2577 offset: 0,
2578 total_tokens: hits.iter().map(|h| h.tokens).sum(),
2579 hits,
2580 warnings: vec![],
2581 }
2582 }
2583
2584 fn test_entity() -> Entity {
2585 Entity {
2586 id: EntityId("specs--test-entity".to_string()),
2587 title: "Test Entity".to_string(),
2588 entity_type: "spec".to_string(),
2589 mem: "specs".to_string(),
2590 file_path: "test-entity.md".to_string(),
2591 metadata: IndexMap::new(),
2592 sections: IndexMap::from([
2593 ("identity".to_string(), "A test entity for unit tests.".to_string()),
2594 ("purpose".to_string(), "Validates render logic.".to_string()),
2595 ("specifies".to_string(), "Long section content that adds significant token weight to the full entity estimate.".to_string()),
2596 ]),
2597 relationships: vec![],
2598 content_hash: "abc123".to_string(),
2599 stub: false,
2600 stub_kind: None,
2601 heading_spans: std::collections::HashMap::new(),
2602 raw_section_headings: Vec::new(),
2603 }
2604 }
2605
2606 #[test]
2607 fn markdown_frontmatter_filters_computed_and_reserved_metadata_keys() {
2608 use crate::entity::MetadataValue;
2613 let mut entity = test_entity();
2614 entity.metadata.insert(
2615 "_hash".to_string(),
2616 MetadataValue::String("stale".to_string()),
2617 );
2618 entity.metadata.insert(
2619 "type".to_string(),
2620 MetadataValue::String("spec".to_string()),
2621 );
2622 entity
2623 .metadata
2624 .insert("level".to_string(), MetadataValue::String("M0".to_string()));
2625
2626 let md = render_entity_markdown(&entity, None);
2627 assert_eq!(
2628 md.matches("_hash:").count(),
2629 1,
2630 "one computed _hash line, no stored copy"
2631 );
2632 assert!(md.contains("_hash: abc123"), "the computed hash wins");
2633 assert!(
2634 !md.contains("stale"),
2635 "the stored _hash value never renders"
2636 );
2637 assert!(
2638 !md.contains("\ntype: "),
2639 "the reserved triple stays structural"
2640 );
2641 assert!(md.contains("level: M0"), "declared metadata still renders");
2642 }
2643
2644 #[test]
2645 fn section_key_to_heading_basic() {
2646 assert_eq!(section_key_to_heading("identity"), "Identity");
2647 assert_eq!(section_key_to_heading("current_state"), "Current state");
2648 }
2649
2650 #[test]
2651 fn render_uses_schema_declared_heading_for_non_trivial_casing() {
2652 let mut sections: IndexMap<String, String> = IndexMap::new();
2658 sections.insert("claim_a".to_string(), "Body A.".to_string());
2659 sections.insert("claim_b".to_string(), "Body B.".to_string());
2660
2661 let entity = Entity {
2662 id: EntityId("ingest--example".to_string()),
2663 title: "Example".to_string(),
2664 entity_type: "inconsistency".to_string(),
2665 mem: "ingest".to_string(),
2666 file_path: "example.md".to_string(),
2667 metadata: IndexMap::new(),
2668 sections,
2669 relationships: vec![],
2670 content_hash: "h".to_string(),
2671 stub: false,
2672 stub_kind: None,
2673 heading_spans: std::collections::HashMap::new(),
2674 raw_section_headings: Vec::new(),
2675 };
2676
2677 let md = render_entity_markdown(&entity, None);
2678 assert!(
2679 md.contains("## Claim A"),
2680 "expected schema-declared `## Claim A` heading; got:\n{md}"
2681 );
2682 assert!(
2683 md.contains("## Claim B"),
2684 "expected schema-declared `## Claim B` heading; got:\n{md}"
2685 );
2686 assert!(
2688 !md.contains("## Claim a"),
2689 "renderer must not fall back to key-derivation when the \
2690 schema declares a heading; got:\n{md}"
2691 );
2692 }
2693
2694 #[test]
2695 fn render_falls_back_to_key_derivation_for_unknown_types() {
2696 let mut sections: IndexMap<String, String> = IndexMap::new();
2700 sections.insert("identity".to_string(), "body".to_string());
2701
2702 let entity = Entity {
2703 id: EntityId("custom--example".to_string()),
2704 title: "Example".to_string(),
2705 entity_type: "not-a-builtin-type".to_string(),
2706 mem: "custom".to_string(),
2707 file_path: "example.md".to_string(),
2708 metadata: IndexMap::new(),
2709 sections,
2710 relationships: vec![],
2711 content_hash: "h".to_string(),
2712 stub: false,
2713 stub_kind: None,
2714 heading_spans: std::collections::HashMap::new(),
2715 raw_section_headings: Vec::new(),
2716 };
2717
2718 let md = render_entity_markdown(&entity, None);
2719 assert!(
2720 md.contains("## Identity"),
2721 "fallback derivation must produce `## Identity`; got:\n{md}"
2722 );
2723 }
2724
2725 #[test]
2732 fn render_entity_sections_follow_indexmap_insertion_order() {
2733 let mut sections: IndexMap<String, String> = IndexMap::new();
2734 sections.insert("specifies".to_string(), "S content.".to_string());
2735 sections.insert("purpose".to_string(), "P content.".to_string());
2736 sections.insert("identity".to_string(), "I content.".to_string());
2737
2738 let entity = Entity {
2739 id: EntityId("specs--order-test".to_string()),
2740 title: "Order Test".to_string(),
2741 entity_type: "spec".to_string(),
2742 mem: "specs".to_string(),
2743 file_path: "order-test.md".to_string(),
2744 metadata: IndexMap::new(),
2745 sections,
2746 relationships: vec![],
2747 content_hash: "abc123".to_string(),
2748 stub: false,
2749 stub_kind: None,
2750 heading_spans: std::collections::HashMap::new(),
2751 raw_section_headings: Vec::new(),
2752 };
2753
2754 let md = render_entity_markdown(&entity, None);
2755 let specifies_pos = md.find("## Specifies").expect("## Specifies must appear");
2756 let purpose_pos = md.find("## Purpose").expect("## Purpose must appear");
2757 let identity_pos = md.find("## Identity").expect("## Identity must appear");
2758
2759 assert!(
2760 specifies_pos < purpose_pos,
2761 "Specifies (inserted first) must render before Purpose; got:\n{md}"
2762 );
2763 assert!(
2764 purpose_pos < identity_pos,
2765 "Purpose (inserted second) must render before Identity; got:\n{md}"
2766 );
2767 }
2768
2769 #[test]
2775 fn tokens_reflect_filtered_output() {
2776 let entity = test_entity();
2777
2778 let full = render_entity_markdown(&entity, None);
2780 assert!(full.contains("_tokens:"), "should have _tokens");
2781 assert!(
2782 !full.contains("_tokens_unfiltered_body:"),
2783 "should NOT have _tokens_unfiltered_body when unfiltered"
2784 );
2785 assert!(
2786 !full.contains("_tokens_full:"),
2787 "old _tokens_full name must not survive — rename is one-way"
2788 );
2789
2790 let filtered = render_entity_markdown(&entity, Some(&["identity".to_string()]));
2792 assert!(filtered.contains("_tokens:"), "should have _tokens");
2793 assert!(
2794 filtered.contains("_tokens_unfiltered_body:"),
2795 "should have _tokens_unfiltered_body when filtered"
2796 );
2797 assert!(
2798 !filtered.contains("_tokens_full:"),
2799 "old _tokens_full name must not survive — rename is one-way"
2800 );
2801
2802 let full_tokens: usize = full
2804 .lines()
2805 .find(|l| l.starts_with("_tokens:"))
2806 .unwrap()
2807 .trim_start_matches("_tokens: ")
2808 .parse()
2809 .unwrap();
2810 let filtered_tokens: usize = filtered
2811 .lines()
2812 .find(|l| l.starts_with("_tokens:"))
2813 .unwrap()
2814 .trim_start_matches("_tokens: ")
2815 .parse()
2816 .unwrap();
2817 let tokens_unfiltered_body: usize = filtered
2818 .lines()
2819 .find(|l| l.starts_with("_tokens_unfiltered_body:"))
2820 .unwrap()
2821 .trim_start_matches("_tokens_unfiltered_body: ")
2822 .parse()
2823 .unwrap();
2824
2825 assert!(
2826 filtered_tokens < full_tokens,
2827 "filtered _tokens ({filtered_tokens}) should be less than full _tokens ({full_tokens})"
2828 );
2829 assert!(
2830 tokens_unfiltered_body >= full_tokens,
2831 "_tokens_unfiltered_body ({tokens_unfiltered_body}) should be >= full render _tokens ({full_tokens})"
2832 );
2833 }
2834
2835 #[test]
2840 fn render_search_uses_first_required_section_for_spec() {
2841 let hit = make_hit(
2842 "specs--demo",
2843 "Demo Spec",
2844 "spec",
2845 &[
2846 ("identity", "A demo spec."),
2847 ("purpose", "Verifies rendering."),
2848 ],
2849 );
2850 let out = render_search_markdown(&search_result(vec![hit]), 0);
2851 assert!(
2852 out.contains("**Identity**: A demo spec."),
2853 "expected Identity line for spec hit, got:\n{out}"
2854 );
2855 }
2856
2857 #[test]
2858 fn render_search_uses_first_required_section_for_memo() {
2859 let hit = make_hit(
2860 "memos--d1",
2861 "Memo One",
2862 "memo",
2863 &[("claim", "Some claim."), ("context", "Some context.")],
2864 );
2865 let out = render_search_markdown(&search_result(vec![hit]), 0);
2866 assert!(
2867 out.contains("**Claim**: Some claim."),
2868 "expected Claim line for memo hit, got:\n{out}"
2869 );
2870 assert!(
2871 !out.contains("**Identity**"),
2872 "memo hit must not render Identity label"
2873 );
2874 assert!(
2875 !out.contains("**Purpose**"),
2876 "memo hit must not render Purpose label"
2877 );
2878 }
2879
2880 #[test]
2881 fn render_search_uses_first_required_section_for_concept() {
2882 let hit = make_hit(
2883 "concepts--thing",
2884 "Thing",
2885 "concept",
2886 &[("definition", "A thing."), ("explanation", "Details.")],
2887 );
2888 let out = render_search_markdown(&search_result(vec![hit]), 0);
2889 assert!(
2890 out.contains("**Definition**: A thing."),
2891 "expected Definition line for concept hit, got:\n{out}"
2892 );
2893 }
2894
2895 #[test]
2896 fn render_search_missing_summary_section_shows_dash() {
2897 let hit = make_hit("memos--empty", "Empty Memo", "memo", &[]);
2899 let out = render_search_markdown(&search_result(vec![hit]), 0);
2900 assert!(
2901 out.contains("**Claim**: —"),
2902 "expected Claim dash fallback, got:\n{out}"
2903 );
2904 }
2905
2906 #[test]
2907 fn render_search_mixes_schemas_in_one_result() {
2908 let spec_hit = make_hit(
2909 "specs--s1",
2910 "Spec One",
2911 "spec",
2912 &[("identity", "Spec body.")],
2913 );
2914 let memo_hit = make_hit("memos--m1", "Memo One", "memo", &[("claim", "Memo claim.")]);
2915 let out = render_search_markdown(&search_result(vec![spec_hit, memo_hit]), 0);
2916 assert!(
2917 out.contains("**Identity**: Spec body."),
2918 "spec hit should still render Identity, got:\n{out}"
2919 );
2920 assert!(
2921 out.contains("**Claim**: Memo claim."),
2922 "memo hit should render Claim in the same output, got:\n{out}"
2923 );
2924 }
2925
2926 #[test]
2927 fn render_search_unknown_schema_shows_summary_dash() {
2928 let hit = make_hit("bogus--x", "Bogus", "bogus", &[]);
2929 let out = render_search_markdown(&search_result(vec![hit]), 0);
2930 assert!(
2931 out.contains("**Summary**: —"),
2932 "unknown schema should render Summary dash, got:\n{out}"
2933 );
2934 }
2935
2936 #[test]
2937 fn summary_pair_falls_back_when_schema_has_no_required_sections() {
2938 use memstead_schema::{SectionDef, TypeDefinition};
2939
2940 let schema = TypeDefinition {
2941 name: "spec".to_string(),
2942 description: "test".to_string(),
2943 when_to_use: "test".to_string(),
2944 boundaries: vec![],
2945 exemplar: None,
2946 legacy_examples: None,
2947 system_message: None,
2948 sections: vec![SectionDef {
2949 key: "note".to_string(),
2950 heading: "Note".to_string(),
2951 required: false,
2952 load_bearing: None,
2953 search_weight: 1.0,
2954 catch_all: false,
2955 write_rules: vec![],
2956 description: None,
2957 content: None,
2958 item_pattern: None,
2959 table: None,
2960 example: None,
2961 format_severity: memstead_schema::ConstraintSeverity::Block,
2962 compiled_content: None,
2963 format_problems: Vec::new(),
2964 }],
2965 metadata_fields: vec![],
2966 title_weight: 1.0,
2967 text_fields: vec![],
2968 hierarchy_relationship: "PART_OF".to_string(),
2969 last_resort: false,
2970 edge_weight_overrides: indexmap::IndexMap::new(),
2971 edge_weights: indexmap::IndexMap::new(),
2972 no_self_loop_relationships: vec![],
2973 legacy_propagating_relationships: None,
2974 due: None,
2975 leaf: false,
2976 updatable_fields: vec![],
2977 health_required_fields: vec![],
2978 staleness_threshold_days: 90,
2979 write_rules: vec![],
2980 required_outgoing: vec![],
2981 must_reach: vec![],
2982 signals: vec![],
2983 constraints: vec![],
2984 declared_metadata_keys: vec![],
2985 };
2986
2987 let mut sections = HashMap::new();
2988 sections.insert("note".to_string(), "a note".to_string());
2989 assert_eq!(
2990 summary_pair(Some(&schema), §ions),
2991 ("Note".to_string(), "a note".to_string()),
2992 );
2993
2994 assert_eq!(
2995 summary_pair(Some(&schema), &HashMap::new()),
2996 ("Note".to_string(), "—".to_string()),
2997 );
2998 }
2999
3000 #[test]
3005 fn render_list_uses_first_required_section_for_spec() {
3006 let hit = make_hit(
3007 "specs--demo",
3008 "Demo Spec",
3009 "spec",
3010 &[
3011 ("identity", "A demo spec."),
3012 ("purpose", "Verifies rendering."),
3013 ],
3014 );
3015 let out = render_list_markdown(&list_result(vec![hit]));
3016 assert!(
3017 out.contains("**Identity**: A demo spec."),
3018 "expected Identity line for spec hit, got:\n{out}"
3019 );
3020 }
3021
3022 #[test]
3023 fn render_list_uses_first_required_section_for_memo() {
3024 let hit = make_hit("memos--d1", "Memo One", "memo", &[("claim", "Some claim.")]);
3025 let out = render_list_markdown(&list_result(vec![hit]));
3026 assert!(
3027 out.contains("**Claim**: Some claim."),
3028 "expected Claim line for memo hit, got:\n{out}"
3029 );
3030 assert!(
3031 !out.contains("**Identity**"),
3032 "memo hit must not render Identity label in list output"
3033 );
3034 }
3035
3036 #[test]
3037 fn render_list_uses_first_required_section_for_concept() {
3038 let hit = make_hit(
3039 "concepts--thing",
3040 "Thing",
3041 "concept",
3042 &[("definition", "A thing.")],
3043 );
3044 let out = render_list_markdown(&list_result(vec![hit]));
3045 assert!(
3046 out.contains("**Definition**: A thing."),
3047 "expected Definition line for concept hit, got:\n{out}"
3048 );
3049 }
3050
3051 #[test]
3052 fn render_list_missing_summary_section_shows_dash() {
3053 let hit = make_hit("memos--empty", "Empty Memo", "memo", &[]);
3054 let out = render_list_markdown(&list_result(vec![hit]));
3055 assert!(
3056 out.contains("**Claim**: —"),
3057 "expected Claim dash fallback in list output, got:\n{out}"
3058 );
3059 }
3060
3061 #[test]
3062 fn render_list_mixes_schemas_in_one_result() {
3063 let spec_hit = make_hit(
3064 "specs--s1",
3065 "Spec One",
3066 "spec",
3067 &[("identity", "Spec body.")],
3068 );
3069 let memo_hit = make_hit("memos--m1", "Memo One", "memo", &[("claim", "Memo claim.")]);
3070 let out = render_list_markdown(&list_result(vec![spec_hit, memo_hit]));
3071 assert!(
3072 out.contains("**Identity**: Spec body."),
3073 "spec hit should still render Identity in list output, got:\n{out}"
3074 );
3075 assert!(
3076 out.contains("**Claim**: Memo claim."),
3077 "memo hit should render Claim in list output, got:\n{out}"
3078 );
3079 }
3080
3081 #[test]
3082 fn render_list_unknown_schema_shows_summary_dash() {
3083 let hit = make_hit("bogus--x", "Bogus", "bogus", &[]);
3084 let out = render_list_markdown(&list_result(vec![hit]));
3085 assert!(
3086 out.contains("**Summary**: —"),
3087 "unknown schema should render Summary dash in list output, got:\n{out}"
3088 );
3089 }
3090
3091 #[test]
3096 fn summary_pair_for_spec_returns_identity() {
3097 let schema = type_by_name("spec");
3098 let mut sections = HashMap::new();
3099 sections.insert("identity".to_string(), "A demo spec.".to_string());
3100 assert_eq!(
3101 summary_pair(schema.as_deref(), §ions),
3102 ("Identity".to_string(), "A demo spec.".to_string()),
3103 );
3104 }
3105
3106 #[test]
3107 fn summary_pair_for_memo_returns_claim() {
3108 let schema = type_by_name("memo");
3109 let mut sections = HashMap::new();
3110 sections.insert("claim".to_string(), "Memos matter.".to_string());
3111 assert_eq!(
3112 summary_pair(schema.as_deref(), §ions),
3113 ("Claim".to_string(), "Memos matter.".to_string()),
3114 );
3115 }
3116
3117 #[test]
3118 fn summary_pair_missing_section_returns_dash() {
3119 let schema = type_by_name("memo");
3120 assert_eq!(
3121 summary_pair(schema.as_deref(), &HashMap::new()),
3122 ("Claim".to_string(), "—".to_string()),
3123 );
3124 }
3125
3126 #[test]
3127 fn summary_pair_unknown_schema_returns_summary_dash() {
3128 assert_eq!(
3129 summary_pair(None, &HashMap::new()),
3130 ("Summary".to_string(), "—".to_string()),
3131 );
3132 }
3133
3134 #[test]
3139 fn envelope_serializes_summary_fields() {
3140 let hit = make_hit(
3141 "memos--d1",
3142 "Memo One",
3143 "memo",
3144 &[("claim", "Memos matter.")],
3145 );
3146 let result = search_result(vec![hit]);
3147 let envelope = build_search_envelope(&result, 0);
3148 let value = serde_json::to_value(&envelope).expect("envelope must serialize");
3149
3150 assert_eq!(value["_total"], 1);
3154 assert_eq!(value["_returned"], 1);
3155 assert_eq!(value["_offset"], 0);
3156 assert!(
3158 value.get("warnings").is_none(),
3159 "empty warnings must be elided, got: {value}"
3160 );
3161
3162 let hit0 = &value["hits"][0];
3163 assert_eq!(hit0["summary_heading"], "Claim");
3164 assert_eq!(hit0["summary_value"], "Memos matter.");
3165 assert_eq!(hit0["id"], "memos--d1");
3167 assert_eq!(hit0["title"], "Memo One");
3168 assert_eq!(hit0["entity_type"], "memo");
3169 assert_eq!(hit0["mem"], "memos");
3170 assert_eq!(hit0["stub"], false);
3171 assert_eq!(hit0["tokens"], 10);
3172 assert!(hit0["sections"].is_object());
3173 }
3174
3175 #[test]
3176 fn envelope_roundtrips_through_structured_content() {
3177 let spec_hit = make_hit(
3180 "specs--s1",
3181 "Spec One",
3182 "spec",
3183 &[("identity", "Spec body.")],
3184 );
3185 let memo_hit = make_hit("memos--m1", "Memo One", "memo", &[("claim", "Memo claim.")]);
3186 let result = search_result(vec![spec_hit, memo_hit]);
3187 let envelope = build_search_envelope(&result, 0);
3188 let value = serde_json::to_value(&envelope).expect("envelope must serialize");
3189
3190 let hits = value["hits"].as_array().expect("hits must be array");
3191 assert_eq!(hits.len(), 2);
3192 assert_eq!(hits[0]["summary_heading"], "Identity");
3193 assert_eq!(hits[0]["summary_value"], "Spec body.");
3194 assert_eq!(hits[1]["summary_heading"], "Claim");
3195 assert_eq!(hits[1]["summary_value"], "Memo claim.");
3196 }
3197
3198 #[test]
3199 fn list_envelope_includes_total_tokens() {
3200 let hit = make_hit(
3201 "concepts--c1",
3202 "Thing",
3203 "concept",
3204 &[("definition", "A thing.")],
3205 );
3206 let result = list_result(vec![hit]);
3207 let envelope = build_list_envelope(&result);
3208 let value = serde_json::to_value(&envelope).expect("envelope must serialize");
3209
3210 assert_eq!(value["_total"], 1);
3212 assert_eq!(value["_total_tokens"], 10);
3213 assert!(value.get("total").is_none(), "unprefixed keys retired");
3214 assert_eq!(value["hits"][0]["summary_heading"], "Definition");
3215 assert_eq!(value["hits"][0]["summary_value"], "A thing.");
3216 }
3217
3218 #[test]
3219 fn envelope_emits_warnings_when_present() {
3220 let mut result = search_result(vec![]);
3221 result.warnings = vec![crate::ops::WarningHint::FieldNotFilterable {
3224 field: "foo".to_string(),
3225 }];
3226 let envelope = build_search_envelope(&result, 0);
3227 let value = serde_json::to_value(&envelope).expect("envelope must serialize");
3228 assert_eq!(value["warnings"][0]["code"], "FIELD_NOT_FILTERABLE");
3229 assert_eq!(value["warnings"][0]["details"]["field"], "foo");
3230 assert!(
3231 value["warnings"][0]["message"]
3232 .as_str()
3233 .is_some_and(|m| m.contains("not filterable"))
3234 );
3235 }
3236
3237 fn tm(field: &str, snippet: &str, heading_path: Option<&[&str]>) -> TermMatch {
3242 TermMatch {
3243 field: field.to_string(),
3244 snippet: snippet.to_string(),
3245 heading_path: heading_path.map(|p| p.iter().map(|s| s.to_string()).collect()),
3246 }
3247 }
3248
3249 fn sample_facets() -> Facets {
3250 use crate::ops::SubsectionFacet;
3251 Facets {
3252 by_type: HashMap::from([
3253 ("spec".to_string(), 7),
3254 ("memo".to_string(), 3),
3255 ("decision".to_string(), 2),
3256 ]),
3257 by_mem: HashMap::from([("specs".to_string(), 10), ("memos".to_string(), 2)]),
3258 by_level: HashMap::from([("high".to_string(), 4)]),
3259 by_status: HashMap::from([("active".to_string(), 6)]),
3260 by_confidence: HashMap::from([("medium".to_string(), 3)]),
3261 by_subsection: vec![
3262 SubsectionFacet {
3263 path: vec!["specifies".to_string(), "Response Shapes".to_string()],
3264 count: 4,
3265 },
3266 SubsectionFacet {
3267 path: vec!["purpose".to_string(), "Rationale".to_string()],
3268 count: 2,
3269 },
3270 ],
3271 by_expansion: HashMap::from([("primary".to_string(), 8), ("expanded".to_string(), 4)]),
3272 }
3273 }
3274
3275 #[test]
3276 fn render_search_emits_matched_terms_line() {
3277 let mut hit = make_hit(
3278 "specs--e1",
3279 "Entity One",
3280 "spec",
3281 &[("identity", "Body text.")],
3282 );
3283 hit.matched_terms = Some(HashMap::from([
3284 (
3285 "entity".to_string(),
3286 vec![
3287 tm("title", "...entity...", None),
3288 tm("purpose", "...entity...", None),
3289 tm("purpose", "...entity two...", None),
3290 ],
3291 ),
3292 ("one".to_string(), vec![tm("title", "...one...", None)]),
3293 ]));
3294 let out = render_search_markdown(&search_result(vec![hit]), 0);
3295 assert!(
3296 out.contains("**Matched terms:**"),
3297 "missing Matched terms line; got:\n{out}"
3298 );
3299 assert!(
3300 out.contains("`entity` (purpose×2, title×1)"),
3301 "entity term grouping wrong; got:\n{out}"
3302 );
3303 assert!(
3304 out.contains("`one` (title×1)"),
3305 "one term grouping wrong; got:\n{out}"
3306 );
3307 }
3308
3309 #[test]
3310 fn render_search_emits_score_breakdown_line() {
3311 let mut hit = make_hit("specs--e1", "Entity", "spec", &[("identity", "b")]);
3312 hit.score_breakdown = Some(ScoreBreakdown {
3313 bm25: 2.5,
3314 title_boost: 2.0,
3315 field_weights: HashMap::from([("body".to_string(), 0.8), ("purpose".to_string(), 0.3)]),
3316 expansion_decay: Some(0.5),
3317 });
3318 let out = render_search_markdown(&search_result(vec![hit]), 0);
3319 assert!(
3320 out.contains(
3321 "**Score:** bm25 2.5 + title 2.0 + body 0.8 + purpose 0.3 + expansion_decay ×0.5"
3322 ),
3323 "score breakdown line wrong; got:\n{out}"
3324 );
3325 }
3326
3327 #[test]
3328 fn render_search_omits_expansion_decay_when_none() {
3329 let mut hit = make_hit("specs--e1", "Entity", "spec", &[("identity", "b")]);
3330 hit.score_breakdown = Some(ScoreBreakdown {
3331 bm25: 1.5,
3332 title_boost: 1.0,
3333 field_weights: HashMap::new(),
3334 expansion_decay: None,
3335 });
3336 let out = render_search_markdown(&search_result(vec![hit]), 0);
3337 assert!(
3338 out.contains("**Score:** bm25 1.5 + title 1.0"),
3339 "base score wrong; got:\n{out}"
3340 );
3341 assert!(
3342 !out.contains("expansion_decay"),
3343 "expansion_decay must be absent when None; got:\n{out}"
3344 );
3345 }
3346
3347 #[test]
3348 fn render_search_emits_heading_path_line() {
3349 let mut hit = make_hit("specs--e1", "Entity", "spec", &[("identity", "b")]);
3350 hit.matched_terms = Some(HashMap::from([(
3351 "x".to_string(),
3352 vec![
3353 tm("purpose", "...x...", Some(&["Purpose", "Rationale"])),
3354 tm("purpose", "...x...", Some(&["Purpose", "Rationale"])), tm("specifies", "...x...", Some(&["Specifies", "Responses"])),
3356 ],
3357 )]));
3358 let out = render_search_markdown(&search_result(vec![hit]), 0);
3359 assert!(
3360 out.contains("**Heading path:** Purpose › Rationale; Specifies › Responses"),
3361 "heading path line wrong; got:\n{out}"
3362 );
3363 }
3364
3365 #[test]
3366 fn render_search_emits_expansion_line() {
3367 let mut hit = make_hit("specs--e2", "Entity Two", "spec", &[("identity", "b")]);
3368 hit.expansion = Some(ExpansionInfo {
3369 of: EntityId("specs--seed".to_string()),
3370 via_edge: "refines".to_string(),
3371 via_direction: crate::graph::query::TraversalDirection::Out,
3372 depth: 1,
3373 });
3374 let out = render_search_markdown(&search_result(vec![hit]), 0);
3375 assert!(
3376 out.contains("**Expansion:** from `specs--seed` via `refines` [out] (depth 1)"),
3377 "expansion line reports the traversal direction beside the label; got:\n{out}"
3378 );
3379 }
3380
3381 #[test]
3382 fn render_search_emits_facets_block() {
3383 let mut result = search_result(vec![]);
3384 result.facets = Some(sample_facets());
3385 let out = render_search_markdown(&result, 0);
3386 assert!(
3387 out.contains("## Facets"),
3388 "facets header missing; got:\n{out}"
3389 );
3390 assert!(
3391 out.contains("- **by_type:** spec=7, memo=3, decision=2"),
3392 "by_type bucket wrong; got:\n{out}"
3393 );
3394 assert!(
3395 out.contains("- **by_mem:** specs=10, memos=2"),
3396 "by_mem bucket wrong; got:\n{out}"
3397 );
3398 assert!(
3399 out.contains("- **by_level:** high=4"),
3400 "by_level bucket wrong; got:\n{out}"
3401 );
3402 assert!(
3403 out.contains("- **by_status:** active=6"),
3404 "by_status bucket wrong; got:\n{out}"
3405 );
3406 assert!(
3407 out.contains("- **by_confidence:** medium=3"),
3408 "by_confidence bucket wrong; got:\n{out}"
3409 );
3410 assert!(
3411 out.contains("- **by_expansion:** primary=8, expanded=4"),
3412 "by_expansion bucket wrong; got:\n{out}"
3413 );
3414 assert!(
3415 out.contains("- **by_subsection:**"),
3416 "by_subsection header missing; got:\n{out}"
3417 );
3418 assert!(
3419 out.contains("`specifies › Response Shapes`: 4"),
3420 "subsection facet wrong; got:\n{out}"
3421 );
3422 }
3423
3424 #[test]
3425 fn render_search_omits_facets_block_when_all_empty() {
3426 let mut result = search_result(vec![]);
3427 result.facets = Some(Facets::default());
3428 let out = render_search_markdown(&result, 0);
3429 assert!(
3430 !out.contains("## Facets"),
3431 "empty facets must not emit header; got:\n{out}"
3432 );
3433 }
3434
3435 #[test]
3439 fn search_markdown_covers_every_sidecar_field() {
3440 let mut hit = make_hit(
3441 "specs--e1",
3442 "Entity One",
3443 "spec",
3444 &[("identity", "Body text.")],
3445 );
3446 hit.matched_terms = Some(HashMap::from([(
3447 "entity".to_string(),
3448 vec![tm("title", "...entity...", Some(&["Purpose", "Rationale"]))],
3449 )]));
3450 hit.score_breakdown = Some(ScoreBreakdown {
3451 bm25: 1.5,
3452 title_boost: 1.0,
3453 field_weights: HashMap::from([("body".to_string(), 0.4)]),
3454 expansion_decay: Some(0.5),
3455 });
3456 hit.expansion = Some(ExpansionInfo {
3457 of: EntityId("specs--seed".to_string()),
3458 via_edge: "refines".to_string(),
3459 via_direction: crate::graph::query::TraversalDirection::Out,
3460 depth: 2,
3461 });
3462
3463 let mut result = search_result(vec![hit]);
3464 result.facets = Some(sample_facets());
3465
3466 let out = render_search_markdown(&result, 0);
3467 for marker in [
3468 "## Facets",
3469 "- **by_type:**",
3470 "- **by_mem:**",
3471 "- **by_level:**",
3472 "- **by_status:**",
3473 "- **by_confidence:**",
3474 "- **by_expansion:**",
3475 "- **by_subsection:**",
3476 "**Matched terms:**",
3477 "**Score:**",
3478 "**Heading path:**",
3479 "**Expansion:**",
3480 ] {
3481 assert!(
3482 out.contains(marker),
3483 "lockstep marker `{marker}` missing from search markdown; \
3484 update render_search_markdown when adding sidecar fields. got:\n{out}"
3485 );
3486 }
3487 }
3488
3489 #[test]
3496 fn build_entity_envelope_source_field_reads_edge_source() {
3497 let mut entity = test_entity();
3498 let body_link_target = EntityId("specs--body-link-target".to_string());
3499 let explicit_target = EntityId("specs--explicit-target".to_string());
3500 entity.relationships = vec![
3501 crate::entity::Relationship::new("REFERENCES".to_string(), body_link_target.clone()),
3502 crate::entity::Relationship::new("USES".to_string(), explicit_target.clone()),
3503 ];
3504
3505 let edges = vec![
3506 crate::store::Edge {
3507 rel_type: "REFERENCES".to_string(),
3508 target: body_link_target.clone(),
3509 source: crate::store::EdgeSource::BodyLink,
3510 },
3511 crate::store::Edge {
3512 rel_type: "USES".to_string(),
3513 target: explicit_target.clone(),
3514 source: crate::store::EdgeSource::Explicit,
3515 },
3516 ];
3517
3518 let env = build_entity_envelope(
3519 &entity,
3520 0,
3521 None,
3522 None,
3523 None,
3524 OriginClass::FirstParty,
3525 &edges,
3526 None,
3527 None,
3528 None,
3529 );
3530 let relationships = env["relationships"].as_array().expect("array");
3531 let refs = relationships
3532 .iter()
3533 .find(|r| r["rel_type"] == "REFERENCES")
3534 .expect("REFERENCES present");
3535 assert_eq!(
3536 refs["source"], "body_link",
3537 "alias-synthesised edge must label body_link"
3538 );
3539 let uses = relationships
3540 .iter()
3541 .find(|r| r["rel_type"] == "USES")
3542 .expect("USES present");
3543 assert_eq!(
3544 uses["source"], "explicit",
3545 "explicit-authored edge must label explicit"
3546 );
3547 }
3548
3549 #[test]
3556 fn build_entity_envelope_carries_origin_direction_and_incoming() {
3557 let mut entity = test_entity();
3558 let out_target = EntityId("specs--downstream".to_string());
3559 entity.relationships = vec![crate::entity::Relationship::new(
3560 "USES".to_string(),
3561 out_target.clone(),
3562 )];
3563 let edges = vec![crate::store::Edge {
3564 rel_type: "USES".to_string(),
3565 target: out_target,
3566 source: crate::store::EdgeSource::Explicit,
3567 }];
3568 let incoming = vec![crate::store::InEdge {
3569 rel_type: "MANAGES".to_string(),
3570 from: EntityId("specs--upstream".to_string()),
3571 source: crate::store::EdgeSource::Explicit,
3572 }];
3573
3574 let env = build_entity_envelope(
3576 &entity,
3577 0,
3578 None,
3579 None,
3580 None,
3581 OriginClass::ThirdParty,
3582 &edges,
3583 None,
3584 None,
3585 None,
3586 );
3587 assert_eq!(env["origin"], "third-party", "origin is envelope-level");
3588 let rels = env["relationships"].as_array().expect("array");
3589 assert_eq!(rels.len(), 1);
3590 assert_eq!(rels[0]["direction"], "out");
3591
3592 let env = build_entity_envelope(
3595 &entity,
3596 0,
3597 None,
3598 None,
3599 None,
3600 OriginClass::FirstParty,
3601 &edges,
3602 Some(&incoming),
3603 None,
3604 None,
3605 );
3606 assert_eq!(env["origin"], "first-party");
3607 let rels = env["relationships"].as_array().expect("array");
3608 assert_eq!(rels.len(), 2);
3609 let inc = rels
3610 .iter()
3611 .find(|r| r["direction"] == "in")
3612 .expect("incoming entry present");
3613 assert_eq!(inc["rel_type"], "MANAGES");
3614 assert_eq!(inc["from"], "specs--upstream");
3615 assert!(
3616 inc.get("target").is_none(),
3617 "incoming carries from, not target"
3618 );
3619 }
3620
3621 #[test]
3626 fn build_entity_envelope_source_field_falls_back_to_explicit_when_edge_missing() {
3627 let mut entity = test_entity();
3628 let target = EntityId("specs--unmapped".to_string());
3629 entity.relationships = vec![crate::entity::Relationship::new("USES".to_string(), target)];
3630 let edges: Vec<crate::store::Edge> = Vec::new();
3631 let env = build_entity_envelope(
3632 &entity,
3633 0,
3634 None,
3635 None,
3636 None,
3637 OriginClass::FirstParty,
3638 &edges,
3639 None,
3640 None,
3641 None,
3642 );
3643 let relationships = env["relationships"].as_array().expect("array");
3644 assert_eq!(relationships[0]["source"], "explicit");
3645 }
3646
3647 #[test]
3653 fn build_entity_envelope_nested_metadata_carries_every_schema_field() {
3654 use crate::entity::MetadataValue;
3655 let mut entity = test_entity();
3656 entity.entity_type = "contract".to_string();
3657 entity.metadata = IndexMap::from([
3659 ("level".to_string(), MetadataValue::String("M0".to_string())),
3660 (
3661 "stability".to_string(),
3662 MetadataValue::String("stable".to_string()),
3663 ),
3664 (
3665 "created_date".to_string(),
3666 MetadataValue::String("2026-01-01".to_string()),
3667 ),
3668 (
3669 "last_modified".to_string(),
3670 MetadataValue::String("2026-05-19".to_string()),
3671 ),
3672 (
3673 "protocol".to_string(),
3674 MetadataValue::String("https".to_string()),
3675 ),
3676 (
3677 "version".to_string(),
3678 MetadataValue::String("0.1.0".to_string()),
3679 ),
3680 (
3681 "deprecation_status".to_string(),
3682 MetadataValue::String("none".to_string()),
3683 ),
3684 ]);
3685
3686 let env = build_entity_envelope(
3687 &entity,
3688 0,
3689 None,
3690 None,
3691 None,
3692 OriginClass::FirstParty,
3693 &[],
3694 None,
3695 None,
3696 None,
3697 );
3698
3699 assert!(
3702 env.get("level").is_none(),
3703 "level must not be hoisted top-level"
3704 );
3705 assert!(
3706 env.get("stability").is_none(),
3707 "stability must not be hoisted"
3708 );
3709 assert!(
3710 env.get("created_date").is_none(),
3711 "created_date must not be hoisted"
3712 );
3713 assert!(
3714 env.get("last_modified").is_none(),
3715 "last_modified must not be hoisted"
3716 );
3717 assert_eq!(env["entity_type"], "contract");
3721 assert!(
3722 env.get("type").is_none(),
3723 "the retired wire key must not survive"
3724 );
3725
3726 let metadata = env["metadata"].as_object().expect("metadata map");
3728 assert_eq!(metadata["level"], "M0");
3729 assert_eq!(metadata["stability"], "stable");
3730 assert_eq!(metadata["created_date"], "2026-01-01");
3731 assert_eq!(metadata["last_modified"], "2026-05-19");
3732 assert_eq!(metadata["protocol"], "https");
3733 assert_eq!(metadata["version"], "0.1.0");
3734 assert_eq!(metadata["deprecation_status"], "none");
3735
3736 for k in metadata.keys() {
3739 assert!(
3740 !k.starts_with('_'),
3741 "metadata map must not carry underscore-prefixed key `{k}`"
3742 );
3743 assert!(
3744 !["mem", "id", "type"].contains(&k.as_str()),
3745 "metadata map must not carry identity key `{k}` (it lives top-level)"
3746 );
3747 }
3748 }
3749
3750 #[test]
3754 fn build_entity_envelope_stub_carries_empty_metadata_map() {
3755 let mut entity = test_entity();
3756 entity.stub = true;
3757 entity.stub_kind = Some(crate::entity::StubKind::ForwardReference);
3758 entity.metadata = IndexMap::new();
3759 let env = build_entity_envelope(
3760 &entity,
3761 0,
3762 None,
3763 None,
3764 None,
3765 OriginClass::FirstParty,
3766 &[],
3767 None,
3768 None,
3769 None,
3770 );
3771 let metadata = env["metadata"]
3772 .as_object()
3773 .expect("metadata key present even on stubs");
3774 assert!(metadata.is_empty(), "stub metadata map must be empty");
3775 }
3776
3777 #[test]
3784 fn build_entity_envelope_user_field_collisions_isolated_to_nested_map() {
3785 use crate::entity::MetadataValue;
3786 let mut entity = test_entity();
3787 entity.metadata = IndexMap::from([
3788 (
3789 "sections".to_string(),
3790 MetadataValue::String("user-supplied-shadow".to_string()),
3791 ),
3792 (
3793 "relationships".to_string(),
3794 MetadataValue::String("also-shadowed".to_string()),
3795 ),
3796 ]);
3797 let env = build_entity_envelope(
3798 &entity,
3799 0,
3800 None,
3801 None,
3802 None,
3803 OriginClass::FirstParty,
3804 &[],
3805 None,
3806 None,
3807 None,
3808 );
3809 assert!(
3811 env["sections"].is_object(),
3812 "top-level sections stays a map"
3813 );
3814 assert!(
3815 env["relationships"].is_array(),
3816 "top-level relationships stays an array"
3817 );
3818 let metadata = env["metadata"].as_object().expect("metadata map");
3820 assert_eq!(metadata["sections"], "user-supplied-shadow");
3821 assert_eq!(metadata["relationships"], "also-shadowed");
3822 }
3823
3824 #[test]
3828 fn build_entity_envelope_unfiltered_body_token_field_name() {
3829 let entity = test_entity();
3830 let env_filtered = build_entity_envelope(
3832 &entity,
3833 10,
3834 Some(42),
3835 None,
3836 None,
3837 OriginClass::FirstParty,
3838 &[],
3839 None,
3840 None,
3841 None,
3842 );
3843 assert_eq!(env_filtered["_tokens_unfiltered_body"], 42);
3844 assert!(
3845 env_filtered.get("_tokens_full").is_none(),
3846 "_tokens_full must not survive — rename is one-way"
3847 );
3848 let env_unfiltered = build_entity_envelope(
3850 &entity,
3851 10,
3852 None,
3853 None,
3854 None,
3855 OriginClass::FirstParty,
3856 &[],
3857 None,
3858 None,
3859 None,
3860 );
3861 assert!(env_unfiltered.get("_tokens_unfiltered_body").is_none());
3862 assert!(env_unfiltered.get("_tokens_full").is_none());
3863 }
3864
3865 fn software_schema() -> Arc<Schema> {
3873 memstead_schema::builtins::load_builtin_schemas()
3874 .expect("builtins load")
3875 .into_iter()
3876 .find(|s| s.manifest.name == "software")
3877 .expect("software schema is a builtin")
3878 }
3879
3880 #[test]
3881 fn schema_verbosity_wire_round_trips() {
3882 assert_eq!(
3883 SchemaVerbosity::from_wire("full"),
3884 Some(SchemaVerbosity::Full)
3885 );
3886 assert_eq!(
3887 SchemaVerbosity::from_wire("lite"),
3888 Some(SchemaVerbosity::Lite)
3889 );
3890 assert_eq!(SchemaVerbosity::from_wire("brief"), None);
3891 assert_eq!(SchemaVerbosity::from_wire(""), None);
3892 assert_eq!(SchemaVerbosity::Full.as_wire(), "full");
3893 assert_eq!(SchemaVerbosity::Lite.as_wire(), "lite");
3894 assert_eq!(SchemaVerbosity::default(), SchemaVerbosity::Full);
3895 }
3896
3897 #[test]
3903 fn exemplar_serves_at_full_and_lite_stays_byte_unchanged() {
3904 let manifest = r#"name: servefix
3905version: 1.0.0
3906description: serving fixture
3907when_to_use: tests
3908types:
3909 - sample
3910relationships:
3911 mode: strict
3912 definitions:
3913 - name: PART_OF
3914 description: hier
3915 default_weight: 3.0
3916 - name: _default
3917 description: fallback
3918 default_weight: 1.0
3919community:
3920 resolution: 1.0
3921 seed: 42
3922"#;
3923 let base_type = r#"name: sample
3924description: t
3925when_to_use: tests
3926sections:
3927 - key: body
3928 heading: Body
3929 required: true
3930 search_weight: 10.0
3931 catch_all: true
3932 write_rules: []
3933metadata_fields:
3934 - key: status
3935 description: state
3936 field_type: string
3937 enum_values: [draft, final]
3938 optional: true
3939title_weight: 100.0
3940text_fields:
3941 - body
3942hierarchy_relationship: PART_OF
3943no_self_loop_relationships: []
3944updatable_fields:
3945 - title
3946 - body
3947health_required_fields:
3948 - body
3949staleness_threshold_days: 90
3950write_rules: []
3951"#;
3952 let with_exemplar = format!(
3953 "{base_type}exemplar:\n title: A Conforming Sample\n metadata:\n status: draft\n sections:\n body: \"One canonical body paragraph.\"\n relations:\n - to: parent-placeholder\n type: PART_OF\n"
3954 );
3955
3956 let plain = Arc::new(
3957 memstead_schema::loader::load_schema_from_memory(
3958 manifest,
3959 &[("sample".to_string(), base_type.to_string())],
3960 )
3961 .expect("fixture loads"),
3962 );
3963 let exemplary = Arc::new(
3964 memstead_schema::loader::load_schema_from_memory(
3965 manifest,
3966 &[("sample".to_string(), with_exemplar)],
3967 )
3968 .expect("fixture loads"),
3969 );
3970
3971 let full = build_schema_payload(
3973 &exemplary,
3974 vec![],
3975 SchemaVerbosity::Full,
3976 OriginClass::FirstParty,
3977 );
3978 let ex = &full["types"][0]["exemplar"];
3979 assert_eq!(ex["title"], "A Conforming Sample", "{full}");
3980 assert_eq!(ex["metadata"]["status"], "draft");
3981 assert_eq!(ex["sections"]["body"], "One canonical body paragraph.");
3982 assert_eq!(ex["relations"][0]["target"], "parent-placeholder");
3983 assert_eq!(ex["relations"][0]["rel_type"], "PART_OF");
3984
3985 let full_plain = build_schema_payload(
3987 &plain,
3988 vec![],
3989 SchemaVerbosity::Full,
3990 OriginClass::FirstParty,
3991 );
3992 assert!(full_plain["types"][0].get("exemplar").is_none());
3993
3994 let lite_with = build_schema_payload(
3997 &exemplary,
3998 vec![],
3999 SchemaVerbosity::Lite,
4000 OriginClass::FirstParty,
4001 );
4002 let lite_without = build_schema_payload(
4003 &plain,
4004 vec![],
4005 SchemaVerbosity::Lite,
4006 OriginClass::FirstParty,
4007 );
4008 assert_eq!(
4009 serde_json::to_string(&lite_with).unwrap(),
4010 serde_json::to_string(&lite_without).unwrap(),
4011 "lite must not change when an exemplar exists"
4012 );
4013 assert!(
4014 !serde_json::to_string(&lite_with)
4015 .unwrap()
4016 .contains("exemplar"),
4017 "lite must not mention exemplars at all"
4018 );
4019 }
4020
4021 #[test]
4025 fn first_party_origin_is_labelled_and_keeps_prose() {
4026 let schema = software_schema();
4027 let full = build_schema_payload(
4028 &schema,
4029 vec!["v".into()],
4030 SchemaVerbosity::Full,
4031 OriginClass::FirstParty,
4032 );
4033 assert_eq!(full["origin"], "first-party");
4034 assert!(full["description"].is_string());
4036 let t = &full["types"].as_array().unwrap()[0];
4037 assert!(t.get("system_context").is_some());
4038 assert!(t.get("writing_guidance").is_some());
4039
4040 let lite = build_schema_payload(
4042 &schema,
4043 vec!["v".into()],
4044 SchemaVerbosity::Lite,
4045 OriginClass::FirstParty,
4046 );
4047 assert_eq!(lite["origin"], "first-party");
4048 }
4049
4050 #[test]
4055 fn constraints_and_severity_render_at_both_verbosities() {
4056 let manifest = r#"name: constrained
4057version: 1.0.0
4058description: constraint render fixture
4059when_to_use: render tests
4060types:
4061 - sample
4062relationships:
4063 mode: strict
4064 definitions:
4065 - name: PART_OF
4066 description: hier
4067 default_weight: 3.0
4068 - name: _default
4069 description: fallback
4070 default_weight: 1.0
4071community:
4072 resolution: 1.0
4073 seed: 42
4074"#;
4075 let type_yaml = r#"name: sample
4076description: t
4077when_to_use: tests
4078sections:
4079 - key: body
4080 heading: Body
4081 required: true
4082 search_weight: 10.0
4083 catch_all: true
4084 write_rules: []
4085metadata_fields:
4086 - key: status
4087 description: state
4088 field_type: string
4089 enum_values: [open, checked]
4090 optional: true
4091 - key: checked_by
4092 description: who
4093 field_type: string
4094 optional: true
4095title_weight: 100.0
4096text_fields:
4097 - body
4098hierarchy_relationship: PART_OF
4099no_self_loop_relationships: []
4100updatable_fields:
4101 - title
4102 - body
4103health_required_fields:
4104 - body
4105staleness_threshold_days: 90
4106required_outgoing:
4107 - relationships: [PART_OF]
4108 cardinality: at_least_one
4109 severity: block
4110constraints:
4111 - kind: requires_when
4112 field: checked_by
4113 when_field: status
4114 when_value: checked
4115 - kind: unique
4116 fields: [status, checked_by]
4117 - kind: enum_from_neighbour
4118 field: status
4119 rel_type: PART_OF
4120 section: body
4121 - kind: status_propagation
4122 field: status
4123 value: checked
4124 rel_type: PART_OF
4125 direction: incoming
4126write_rules: []
4127"#;
4128 let schema = Arc::new(
4129 memstead_schema::loader::load_schema_from_memory(
4130 manifest,
4131 &[("sample".to_string(), type_yaml.to_string())],
4132 )
4133 .expect("fixture loads"),
4134 );
4135
4136 let expected_constraints = serde_json::json!([
4141 {
4142 "kind": "requires_when",
4143 "field": "checked_by",
4144 "when_field": "status",
4145 "when_value": "checked",
4146 "severity": "warn",
4147 },
4148 {
4149 "kind": "unique",
4150 "fields": ["status", "checked_by"],
4151 "severity": "block",
4152 },
4153 {
4154 "kind": "enum_from_neighbour",
4155 "field": "status",
4156 "rel_type": "PART_OF",
4157 "section": "body",
4158 "severity": "warn",
4159 },
4160 {
4161 "kind": "status_propagation",
4162 "field": "status",
4163 "value": "checked",
4164 "rel_type": "PART_OF",
4165 "direction": "incoming",
4166 "severity": "warn",
4167 },
4168 ]);
4169
4170 let full = build_schema_payload(
4171 &schema,
4172 vec![],
4173 SchemaVerbosity::Full,
4174 OriginClass::FirstParty,
4175 );
4176 let t = &full["types"].as_array().unwrap()[0];
4177 assert_eq!(t["constraints"], expected_constraints);
4178 assert_eq!(t["required_outgoing"][0]["severity"], "block");
4179
4180 let lite = build_schema_payload(
4181 &schema,
4182 vec![],
4183 SchemaVerbosity::Lite,
4184 OriginClass::FirstParty,
4185 );
4186 let ts = &lite["types_summary"].as_array().unwrap()[0];
4187 assert_eq!(ts["constraints"], expected_constraints);
4188 assert_eq!(ts["required_outgoing"][0]["severity"], "block");
4189
4190 let fmt_manifest = r#"name: formatted
4193version: 1.0.0
4194description: format render fixture
4195when_to_use: render tests
4196types:
4197 - plan
4198relationships:
4199 mode: strict
4200 definitions:
4201 - name: PART_OF
4202 description: hier
4203 default_weight: 1.0
4204 - name: _default
4205 description: fallback
4206 default_weight: 1.0
4207community:
4208 resolution: 1.0
4209 seed: 42
4210"#;
4211 let fmt_type = r#"name: plan
4212description: t
4213when_to_use: tests
4214sections:
4215 - key: body
4216 heading: Body
4217 required: true
4218 search_weight: 10.0
4219 catch_all: true
4220 write_rules: []
4221 - key: meilensteine
4222 heading: Meilensteine
4223 required: false
4224 search_weight: 5.0
4225 catch_all: false
4226 write_rules: []
4227 content: "(heading(3) list(bullet))+"
4228 item_pattern: '\*\*(?<name>[^*]+)\*\*'
4229 example: |
4230 ### Phase 1
4231 - **Kickoff**
4232 format_severity: warn
4233 - key: tabelle
4234 heading: Tabelle
4235 required: false
4236 search_weight: 5.0
4237 catch_all: false
4238 write_rules: []
4239 content: "table"
4240 table:
4241 columns: [Name, Datum]
4242 column_patterns:
4243 Datum: '\d{4}-\d{2}-\d{2}'
4244 - key: belege
4245 heading: Belege
4246 required: false
4247 search_weight: 5.0
4248 catch_all: false
4249 write_rules: []
4250 content: "paragraph+"
4251 item_pattern: '(?<quelle>\S[^|]*?) \| (?<aussage>.+)'
4252metadata_fields: []
4253title_weight: 100.0
4254text_fields:
4255 - body
4256hierarchy_relationship: PART_OF
4257no_self_loop_relationships: []
4258updatable_fields:
4259 - title
4260 - body
4261health_required_fields:
4262 - body
4263staleness_threshold_days: 90
4264write_rules: []
4265"#;
4266 let fmt_schema = Arc::new(
4267 memstead_schema::loader::load_schema_from_memory(
4268 fmt_manifest,
4269 &[("plan".to_string(), fmt_type.to_string())],
4270 )
4271 .expect("format fixture loads"),
4272 );
4273 for verbosity in [SchemaVerbosity::Full, SchemaVerbosity::Lite] {
4274 let payload =
4275 build_schema_payload(&fmt_schema, vec![], verbosity, OriginClass::FirstParty);
4276 let sections_key = match verbosity {
4277 SchemaVerbosity::Full => &payload["types"][0]["sections"],
4278 SchemaVerbosity::Lite => &payload["types_summary"][0]["sections"],
4279 };
4280 let secs = sections_key.as_array().unwrap();
4281 let meilensteine = secs
4282 .iter()
4283 .find(|s| s["key"] == "meilensteine")
4284 .expect("declared section present");
4285 assert_eq!(
4286 meilensteine["content"], "(heading(3) list(bullet))+",
4287 "{verbosity:?} carries content"
4288 );
4289 assert!(
4290 meilensteine["item_pattern"]
4291 .as_str()
4292 .unwrap()
4293 .contains("name")
4294 );
4295 assert!(
4296 meilensteine["example"]
4297 .as_str()
4298 .unwrap()
4299 .contains("Kickoff")
4300 );
4301 assert_eq!(meilensteine["format_severity"], "warn");
4302 let tabelle = secs.iter().find(|s| s["key"] == "tabelle").unwrap();
4303 assert_eq!(tabelle["format_severity"], "block", "default renders");
4304 assert_eq!(tabelle["table"]["columns"][0], "Name");
4305 assert!(
4306 tabelle["table"]["column_patterns"]["Datum"]
4307 .as_str()
4308 .is_some()
4309 );
4310 let belege = secs.iter().find(|s| s["key"] == "belege").unwrap();
4311 assert_eq!(belege["content"], "paragraph+");
4312 assert!(belege["item_pattern"].as_str().unwrap().contains("quelle"));
4313 let body = secs.iter().find(|s| s["key"] == "body").unwrap();
4314 assert!(
4315 body.get("content").is_none() && body.get("format_severity").is_none(),
4316 "undeclared section keeps its pre-plan shape"
4317 );
4318 }
4319
4320 let plain_full = build_schema_payload(
4323 &software_schema(),
4324 vec![],
4325 SchemaVerbosity::Full,
4326 OriginClass::FirstParty,
4327 );
4328 let pt = &plain_full["types"].as_array().unwrap()[0];
4329 assert_eq!(pt["constraints"], serde_json::json!([]));
4330 let plain_lite = build_schema_payload(
4331 &software_schema(),
4332 vec![],
4333 SchemaVerbosity::Lite,
4334 OriginClass::FirstParty,
4335 );
4336 let pts = &plain_lite["types_summary"].as_array().unwrap()[0];
4337 assert_eq!(pts["constraints"], serde_json::json!([]));
4338 }
4339
4340 #[test]
4350 fn third_party_origin_forces_structural_only_even_under_full() {
4351 let schema = software_schema();
4352 let full_requested = build_schema_payload(
4353 &schema,
4354 vec!["v".into()],
4355 SchemaVerbosity::Full,
4356 OriginClass::ThirdParty,
4357 );
4358
4359 assert_eq!(full_requested["origin"], "third-party");
4361
4362 assert!(
4365 full_requested.get("types").is_none(),
4366 "third-party omits the rich `types` array even under full"
4367 );
4368 assert!(
4369 full_requested.get("relationships").is_none(),
4370 "third-party omits the rich `relationships` array even under full"
4371 );
4372 assert!(
4373 full_requested["types_summary"].is_array(),
4374 "third-party serves the structural `types_summary` skeleton"
4375 );
4376 assert!(
4377 full_requested["relationships_summary"].is_array(),
4378 "third-party serves the structural `relationships_summary` skeleton"
4379 );
4380
4381 assert!(
4383 full_requested.get("description").is_none(),
4384 "third-party drops schema description prose"
4385 );
4386 assert!(
4387 full_requested.get("when_to_use").is_none(),
4388 "third-party drops schema when_to_use prose"
4389 );
4390 assert!(
4391 full_requested.get("default_writing_guidance").is_none(),
4392 "third-party drops default_writing_guidance prose"
4393 );
4394
4395 for t in full_requested["types_summary"].as_array().unwrap() {
4397 assert!(
4398 t.get("system_context").is_none(),
4399 "third-party drops system_context"
4400 );
4401 assert!(
4402 t.get("writing_guidance").is_none(),
4403 "third-party drops writing_guidance"
4404 );
4405 assert!(
4406 t.get("description").is_none(),
4407 "third-party drops type description"
4408 );
4409 for s in t["sections"].as_array().unwrap() {
4410 assert!(
4411 s.get("write_rules").is_none(),
4412 "third-party drops section write_rules"
4413 );
4414 }
4415 }
4416 for r in full_requested["relationships_summary"].as_array().unwrap() {
4418 assert!(
4419 r.get("description").is_none(),
4420 "third-party drops rel description"
4421 );
4422 assert!(
4423 r.get("when_to_use").is_none(),
4424 "third-party drops rel when_to_use"
4425 );
4426 }
4427
4428 let lite_requested = build_schema_payload(
4432 &schema,
4433 vec!["v".into()],
4434 SchemaVerbosity::Lite,
4435 OriginClass::ThirdParty,
4436 );
4437 assert_eq!(
4438 full_requested, lite_requested,
4439 "third-party full must collapse to the lite skeleton"
4440 );
4441 }
4442
4443 #[test]
4444 fn full_payload_carries_the_rich_arrays_and_prose() {
4445 let schema = software_schema();
4446 let full = build_schema_payload(
4447 &schema,
4448 vec!["v".into()],
4449 SchemaVerbosity::Full,
4450 OriginClass::FirstParty,
4451 );
4452
4453 assert!(full["types"].is_array(), "full has `types`");
4455 assert!(full["relationships"].is_array(), "full has `relationships`");
4456 assert!(
4457 full.get("types_summary").is_none(),
4458 "full omits `types_summary`"
4459 );
4460 assert!(
4461 full.get("relationships_summary").is_none(),
4462 "full omits `relationships_summary`"
4463 );
4464 assert!(
4465 full["description"].is_string(),
4466 "full keeps schema description"
4467 );
4468 assert!(
4469 full["when_to_use"].is_string(),
4470 "full keeps schema when_to_use"
4471 );
4472 assert_eq!(full["alias_target_rel_type"], "REFERENCES");
4473
4474 let t = &full["types"].as_array().unwrap()[0];
4476 assert!(t["description"].is_string());
4477 assert!(t.get("writing_guidance").is_some());
4478 assert!(t.get("system_context").is_some());
4479 let r = &full["relationships"].as_array().unwrap()[0];
4481 assert!(r["description"].is_string());
4482 assert!(r.get("when_to_use").is_some());
4483 assert!(r.get("default_weight").is_some());
4484 }
4485
4486 #[test]
4495 fn required_outgoing_reported_with_cardinality_at_both_levels() {
4496 let reg = memstead_schema::SchemaRegistry::builtin();
4497 let project = reg
4498 .get("project", &semver::Version::new(0, 2, 0))
4499 .expect("project is a built-in");
4500
4501 for verbosity in [SchemaVerbosity::Full, SchemaVerbosity::Lite] {
4502 let payload =
4503 build_schema_payload(&project, vec![], verbosity, OriginClass::FirstParty);
4504 let types_key = if verbosity == SchemaVerbosity::Full {
4505 "types"
4506 } else {
4507 "types_summary"
4508 };
4509 let types = payload[types_key].as_array().expect("types array");
4510
4511 let mut saw_evidence = false;
4512 let mut saw_memo = false;
4513 for t in types {
4514 let ro = t
4515 .get("required_outgoing")
4516 .unwrap_or_else(|| panic!("type {} omits required_outgoing", t["name"]))
4517 .as_array()
4518 .expect("required_outgoing is an array for every type");
4519 if t["name"] == "evidence" {
4520 saw_evidence = true;
4521 assert_eq!(ro.len(), 1, "evidence declares one block");
4522 assert_eq!(
4523 ro[0]["relationships"],
4524 serde_json::json!(["STRENGTHENS", "WEAKENS", "VALIDATES", "CONTRADICTS"]),
4525 "relationship alternatives in declaration order"
4526 );
4527 assert_eq!(
4528 ro[0]["cardinality"], "at_least_one",
4529 "cardinality rendered as declared — the open upper bound \
4530 stays open, never a finite number"
4531 );
4532 } else if t["name"] == "memo" {
4533 saw_memo = true;
4536 assert!(ro.is_empty(), "memo declares no blocks → empty list");
4537 }
4538 }
4539 assert!(saw_evidence, "project schema carries the evidence type");
4540 assert!(saw_memo, "project schema carries the memo type");
4541
4542 let note = payload["no_self_loop_relationships_effect"]
4545 .as_str()
4546 .expect("effect note present at both verbosity levels");
4547 assert!(note.contains("self-loop"), "names the actual effect");
4548 assert!(
4549 !note.contains("propagates impact") || note.contains("does not propagate"),
4550 "claims no propagation behaviour beyond the self-loop refusal"
4551 );
4552 assert!(
4553 note.contains("status_propagation"),
4554 "deprecation pointer names the real propagation declaration"
4555 );
4556 }
4557 }
4558
4559 #[test]
4565 fn conditional_required_outgoing_trigger_visible_at_both_levels() {
4566 let manifest = r#"name: condro-render
4567version: 0.1.0
4568description: conditional required_outgoing render fixture
4569when_to_use: tests
4570types:
4571 - task
4572relationships:
4573 mode: strict
4574 definitions:
4575 - name: PART_OF
4576 description: hier
4577 default_weight: 3.0
4578 - name: _default
4579 description: fallback
4580 default_weight: 1.0
4581community:
4582 resolution: 1.0
4583 seed: 42
4584"#;
4585 let task_yaml = "name: task\ndescription: t\nwhen_to_use: tests\nsections:\n - key: body\n heading: Body\n required: true\n search_weight: 10.0\n catch_all: true\n write_rules: []\nmetadata_fields:\n - key: status\n description: workflow state\n field_type: string\n enum_values: [open, checked]\ntitle_weight: 100.0\ntext_fields:\n - body\nhierarchy_relationship: PART_OF\nno_self_loop_relationships: []\nupdatable_fields:\n - title\n - body\n - status\nhealth_required_fields:\n - body\nstaleness_threshold_days: 90\nwrite_rules: []\nrequired_outgoing:\n - relationships: [PART_OF]\n cardinality: at_least_one\n - relationships: [PART_OF]\n cardinality: at_least_one\n severity: block\n when_field: status\n when_value: checked\n";
4586 let schema = Arc::new(
4587 memstead_schema::load_schema_from_memory(
4588 manifest,
4589 &[("task".to_string(), task_yaml.to_string())],
4590 )
4591 .expect("render fixture schema must parse"),
4592 );
4593
4594 for verbosity in [SchemaVerbosity::Full, SchemaVerbosity::Lite] {
4595 let payload = build_schema_payload(&schema, vec![], verbosity, OriginClass::FirstParty);
4596 let types_key = if verbosity == SchemaVerbosity::Full {
4597 "types"
4598 } else {
4599 "types_summary"
4600 };
4601 let task = &payload[types_key].as_array().expect("types array")[0];
4602 let ro = task["required_outgoing"].as_array().expect("blocks array");
4603 assert_eq!(ro.len(), 2);
4604 assert!(
4605 ro[0].get("when_field").is_none() && ro[0].get("when_value").is_none(),
4606 "unconditional block carries no when_* keys: {:?}",
4607 ro[0]
4608 );
4609 assert_eq!(ro[1]["when_field"], "status");
4610 assert_eq!(ro[1]["when_value"], "checked");
4611 }
4612 }
4613
4614 #[test]
4620 fn acyclic_sets_and_propagation_rel_types_visible_at_both_levels() {
4621 let manifest = r#"name: relsets-render
4622version: 0.1.0
4623description: relation-set render fixture
4624when_to_use: tests
4625types:
4626 - claim
4627relationships:
4628 mode: strict
4629 acyclic_sets:
4630 - [GROUNDS, CONCLUDES]
4631 definitions:
4632 - name: GROUNDS
4633 description: g
4634 default_weight: 3.0
4635 - name: CONCLUDES
4636 description: c
4637 default_weight: 3.0
4638 - name: PART_OF
4639 description: hier
4640 default_weight: 1.0
4641 - name: _default
4642 description: fallback
4643 default_weight: 1.0
4644community:
4645 resolution: 1.0
4646 seed: 42
4647"#;
4648 let claim = "name: claim\ndescription: t\nwhen_to_use: tests\nsections:\n - key: body\n heading: Body\n required: true\n search_weight: 10.0\n catch_all: true\n write_rules: []\nmetadata_fields:\n - key: standing\n description: s\n field_type: string\n enum_values: [active, withdrawn]\ntitle_weight: 100.0\ntext_fields:\n - body\nhierarchy_relationship: PART_OF\nno_self_loop_relationships: []\nupdatable_fields:\n - title\n - body\n - standing\nhealth_required_fields:\n - body\nstaleness_threshold_days: 90\nwrite_rules: []\nconstraints:\n - kind: status_propagation\n field: standing\n value: withdrawn\n rel_types: [GROUNDS, CONCLUDES]\n direction: incoming\n - kind: status_propagation\n field: standing\n value: withdrawn\n rel_type: PART_OF\n direction: outgoing\n";
4649 let schema = Arc::new(
4650 memstead_schema::load_schema_from_memory(
4651 manifest,
4652 &[("claim".to_string(), claim.to_string())],
4653 )
4654 .expect("render fixture schema must parse"),
4655 );
4656
4657 for verbosity in [SchemaVerbosity::Full, SchemaVerbosity::Lite] {
4658 let payload = build_schema_payload(&schema, vec![], verbosity, OriginClass::FirstParty);
4659 assert_eq!(
4660 payload["acyclic_sets"],
4661 serde_json::json!([["GROUNDS", "CONCLUDES"]]),
4662 "acyclic_sets present at {verbosity:?}"
4663 );
4664 let types_key = if verbosity == SchemaVerbosity::Full {
4665 "types"
4666 } else {
4667 "types_summary"
4668 };
4669 let claim = &payload[types_key].as_array().expect("types array")[0];
4670 let constraints = claim["constraints"].as_array().expect("constraints array");
4671 assert_eq!(
4672 constraints[0]["rel_types"],
4673 serde_json::json!(["GROUNDS", "CONCLUDES"])
4674 );
4675 assert!(
4676 constraints[0].get("rel_type").is_none(),
4677 "set declaration carries no single-name key: {:?}",
4678 constraints[0]
4679 );
4680 assert_eq!(constraints[1]["rel_type"], "PART_OF");
4681 assert!(
4682 constraints[1].get("rel_types").is_none(),
4683 "single-name declaration stays byte-identical: {:?}",
4684 constraints[1]
4685 );
4686 }
4687
4688 let plain = software_schema();
4690 for verbosity in [SchemaVerbosity::Full, SchemaVerbosity::Lite] {
4691 let payload = build_schema_payload(&plain, vec![], verbosity, OriginClass::FirstParty);
4692 assert!(
4693 payload.get("acyclic_sets").is_none(),
4694 "undeclared schema carries no acyclic_sets key"
4695 );
4696 }
4697 }
4698
4699 #[test]
4703 fn labelling_declaration_visible_at_both_levels_and_absent_when_undeclared() {
4704 let manifest = r#"name: labelling-render
4705version: 0.1.0
4706description: labelling render fixture
4707when_to_use: tests
4708types:
4709 - claim
4710relationships:
4711 mode: strict
4712 labelling:
4713 attack: [REBUTS]
4714 support:
4715 relationships: [GROUNDS]
4716 direction: out
4717 terminal_types: [claim]
4718 definitions:
4719 - name: REBUTS
4720 description: attack
4721 default_weight: 3.0
4722 - name: GROUNDS
4723 description: support
4724 default_weight: 3.0
4725 - name: PART_OF
4726 description: hier
4727 default_weight: 1.0
4728 - name: _default
4729 description: fallback
4730 default_weight: 1.0
4731community:
4732 resolution: 1.0
4733 seed: 42
4734"#;
4735 let claim = "name: claim\ndescription: t\nwhen_to_use: tests\nmetadata_fields: []\nsections:\n - key: body\n heading: Body\n required: true\n search_weight: 10.0\n catch_all: true\n write_rules: []\ntitle_weight: 100.0\ntext_fields:\n - body\nhierarchy_relationship: PART_OF\nno_self_loop_relationships: []\nupdatable_fields:\n - title\n - body\nhealth_required_fields:\n - body\nstaleness_threshold_days: 90\nwrite_rules: []\n";
4736 let schema = Arc::new(
4737 memstead_schema::load_schema_from_memory(
4738 manifest,
4739 &[("claim".to_string(), claim.to_string())],
4740 )
4741 .expect("render fixture schema must parse"),
4742 );
4743
4744 for verbosity in [SchemaVerbosity::Full, SchemaVerbosity::Lite] {
4745 let payload = build_schema_payload(&schema, vec![], verbosity, OriginClass::FirstParty);
4746 assert_eq!(
4747 payload["labelling"]["attack"],
4748 serde_json::json!(["REBUTS"]),
4749 "attack set present at {verbosity:?}"
4750 );
4751 assert_eq!(
4752 payload["labelling"]["support"]["relationships"],
4753 serde_json::json!(["GROUNDS"])
4754 );
4755 assert_eq!(payload["labelling"]["support"]["direction"], "out");
4756 }
4757
4758 let plain = software_schema();
4759 for verbosity in [SchemaVerbosity::Full, SchemaVerbosity::Lite] {
4760 let payload = build_schema_payload(&plain, vec![], verbosity, OriginClass::FirstParty);
4761 assert!(
4762 payload.get("labelling").is_none(),
4763 "undeclared schema carries no labelling key"
4764 );
4765 }
4766 }
4767
4768 #[test]
4772 fn signal_declarations_visible_at_both_levels_and_absent_when_undeclared() {
4773 let manifest = r#"name: signals-render
4774version: 0.1.0
4775description: signal render fixture
4776when_to_use: tests
4777types:
4778 - claim
4779 - objection
4780relationships:
4781 mode: strict
4782 definitions:
4783 - name: REBUTS
4784 description: r
4785 default_weight: 3.0
4786 - name: PART_OF
4787 description: hier
4788 default_weight: 1.0
4789 - name: _default
4790 description: fallback
4791 default_weight: 1.0
4792community:
4793 resolution: 1.0
4794 seed: 42
4795"#;
4796 let body = "sections:\n - key: body\n heading: Body\n required: true\n search_weight: 10.0\n catch_all: true\n write_rules: []\ntitle_weight: 100.0\ntext_fields:\n - body\nhierarchy_relationship: PART_OF\nno_self_loop_relationships: []\nupdatable_fields:\n - title\n - body\nhealth_required_fields:\n - body\nstaleness_threshold_days: 90\nwrite_rules: []\n";
4797 let claim = format!(
4798 "name: claim\ndescription: t\nwhen_to_use: tests\nmetadata_fields: []\n{body}signals:\n - name: attack_load\n kind: edge_load\n relationships: [REBUTS]\n direction: in\n thresholds:\n - at_least: 1\n level: notice\n - at_least: 3\n level: warn\n"
4799 );
4800 let objection = format!(
4801 "name: objection\ndescription: t\nwhen_to_use: tests\nmetadata_fields:\n - key: state\n description: s\n field_type: string\n enum_values: [open, closed]\n{body}"
4802 );
4803 let schema = Arc::new(
4804 memstead_schema::load_schema_from_memory(
4805 manifest,
4806 &[
4807 ("claim".to_string(), claim),
4808 ("objection".to_string(), objection),
4809 ],
4810 )
4811 .expect("render fixture schema must parse"),
4812 );
4813
4814 for verbosity in [SchemaVerbosity::Full, SchemaVerbosity::Lite] {
4815 let payload = build_schema_payload(&schema, vec![], verbosity, OriginClass::FirstParty);
4816 let types_key = if verbosity == SchemaVerbosity::Full {
4817 "types"
4818 } else {
4819 "types_summary"
4820 };
4821 let types = payload[types_key].as_array().expect("types array");
4822 let claim = types
4823 .iter()
4824 .find(|t| t["name"] == "claim")
4825 .expect("claim type present");
4826 let sigs = claim["signals"].as_array().expect("signals array");
4827 assert_eq!(sigs[0]["name"], "attack_load");
4828 assert_eq!(sigs[0]["kind"], "edge_load");
4829 assert_eq!(sigs[0]["direction"], "in");
4830 assert_eq!(sigs[0]["thresholds"][1]["at_least"], 3);
4831 assert_eq!(sigs[0]["thresholds"][1]["level"], "warn");
4832 let objection = types
4833 .iter()
4834 .find(|t| t["name"] == "objection")
4835 .expect("objection type present");
4836 assert!(
4837 objection.get("signals").is_none(),
4838 "undeclared type carries no signals key"
4839 );
4840 }
4841 }
4842
4843 #[test]
4849 fn must_reach_visible_at_both_levels_and_absent_when_undeclared() {
4850 let manifest = r#"name: mustreach-render
4851version: 0.1.0
4852description: must_reach render fixture
4853when_to_use: tests
4854types:
4855 - claim
4856 - evidence
4857relationships:
4858 mode: strict
4859 definitions:
4860 - name: GROUNDS
4861 description: g
4862 default_weight: 3.0
4863 - name: PART_OF
4864 description: hier
4865 default_weight: 1.0
4866 - name: _default
4867 description: fallback
4868 default_weight: 1.0
4869community:
4870 resolution: 1.0
4871 seed: 42
4872"#;
4873 let body = "sections:\n - key: body\n heading: Body\n required: true\n search_weight: 10.0\n catch_all: true\n write_rules: []\nmetadata_fields: []\ntitle_weight: 100.0\ntext_fields:\n - body\nhierarchy_relationship: PART_OF\nno_self_loop_relationships: []\nupdatable_fields:\n - title\n - body\nhealth_required_fields:\n - body\nstaleness_threshold_days: 90\nwrite_rules: []\n";
4874 let claim = format!(
4875 "name: claim\ndescription: t\nwhen_to_use: tests\n{body}must_reach:\n - relationships: [GROUNDS]\n direction: out\n terminal_types: [evidence]\n max_depth: 12\n"
4876 );
4877 let evidence = format!("name: evidence\ndescription: t\nwhen_to_use: tests\n{body}");
4878 let schema = Arc::new(
4879 memstead_schema::load_schema_from_memory(
4880 manifest,
4881 &[
4882 ("claim".to_string(), claim),
4883 ("evidence".to_string(), evidence),
4884 ],
4885 )
4886 .expect("render fixture schema must parse"),
4887 );
4888
4889 for verbosity in [SchemaVerbosity::Full, SchemaVerbosity::Lite] {
4890 let payload = build_schema_payload(&schema, vec![], verbosity, OriginClass::FirstParty);
4891 let types_key = if verbosity == SchemaVerbosity::Full {
4892 "types"
4893 } else {
4894 "types_summary"
4895 };
4896 let types = payload[types_key].as_array().expect("types array");
4897 let claim = types
4898 .iter()
4899 .find(|t| t["name"] == "claim")
4900 .expect("claim type present");
4901 let mr = claim["must_reach"].as_array().expect("obligations array");
4902 assert_eq!(mr.len(), 1);
4903 assert_eq!(mr[0]["relationships"], serde_json::json!(["GROUNDS"]));
4904 assert_eq!(mr[0]["direction"], "out");
4905 assert_eq!(mr[0]["terminal_types"], serde_json::json!(["evidence"]));
4906 assert_eq!(mr[0]["max_depth"], 12);
4907 let evidence = types
4908 .iter()
4909 .find(|t| t["name"] == "evidence")
4910 .expect("evidence type present");
4911 assert!(
4912 evidence.get("must_reach").is_none(),
4913 "undeclared type carries no must_reach key: {evidence:?}"
4914 );
4915 }
4916 }
4917
4918 #[test]
4919 fn lite_payload_is_the_structural_skeleton_without_prose() {
4920 let schema = software_schema();
4921 let lite = build_schema_payload(
4922 &schema,
4923 vec!["v".into()],
4924 SchemaVerbosity::Lite,
4925 OriginClass::FirstParty,
4926 );
4927
4928 let types = lite["types_summary"]
4930 .as_array()
4931 .expect("lite has `types_summary`");
4932 let rels = lite["relationships_summary"]
4933 .as_array()
4934 .expect("lite has `relationships_summary`");
4935 assert!(lite.get("types").is_none(), "lite omits rich `types`");
4936 assert!(
4937 lite.get("relationships").is_none(),
4938 "lite omits rich `relationships`"
4939 );
4940
4941 assert_eq!(lite["alias_target_rel_type"], "REFERENCES");
4944
4945 assert!(
4947 lite.get("description").is_none(),
4948 "lite drops schema description"
4949 );
4950 assert!(
4951 lite.get("when_to_use").is_none(),
4952 "lite drops schema when_to_use"
4953 );
4954 assert!(
4955 lite.get("default_writing_guidance").is_none(),
4956 "lite drops default_writing_guidance"
4957 );
4958
4959 for t in types {
4962 assert!(t["name"].is_string());
4963 let sections = t["sections"].as_array().expect("lite type has sections");
4964 for s in sections {
4965 assert!(s["key"].is_string(), "section carries its key");
4966 assert!(s["required"].is_boolean(), "section carries required flag");
4967 assert!(
4968 s.get("write_rules").is_none(),
4969 "lite section drops write_rules prose"
4970 );
4971 assert!(s.get("heading").is_none(), "lite section drops heading");
4972 }
4973 assert!(
4974 t.get("description").is_none(),
4975 "lite type drops description"
4976 );
4977 assert!(
4978 t.get("writing_guidance").is_none(),
4979 "lite type drops writing_guidance"
4980 );
4981 assert!(
4982 t.get("system_context").is_none(),
4983 "lite type drops system_context"
4984 );
4985 assert!(
4989 t.get("no_self_loop_relationships").is_some(),
4990 "lite type keeps no_self_loop_relationships"
4991 );
4992 assert!(
4996 t.get("required_outgoing").is_some_and(|v| v.is_array()),
4997 "lite type keeps required_outgoing as an array"
4998 );
4999 if let Some(fields) = t["fields"].as_array() {
5001 for f in fields {
5002 assert!(f["name"].is_string());
5003 assert!(f["required"].is_boolean());
5004 assert!(
5005 f.get("description").is_none(),
5006 "lite field drops description"
5007 );
5008 }
5009 }
5010 }
5011
5012 for r in rels {
5015 assert!(r["name"].is_string());
5016 assert!(
5017 r.get("allowed_sources").is_some(),
5018 "lite rel has allowed_sources"
5019 );
5020 assert!(
5021 r.get("allowed_targets").is_some(),
5022 "lite rel has allowed_targets"
5023 );
5024 assert!(
5025 r.get("manual_authoring").is_some(),
5026 "lite rel keeps manual_authoring"
5027 );
5028 assert!(r.get("acyclic").is_some(), "lite rel keeps acyclic");
5029 assert!(
5030 r.get("per_edge_description").is_some(),
5031 "lite rel keeps per_edge_description"
5032 );
5033 assert!(r.get("description").is_none(), "lite rel drops description");
5034 assert!(r.get("when_to_use").is_none(), "lite rel drops when_to_use");
5035 assert!(
5036 r.get("default_weight").is_none(),
5037 "lite rel drops default_weight"
5038 );
5039 }
5040 }
5041
5042 #[test]
5043 fn lite_is_measurably_smaller_than_full() {
5044 let schema = software_schema();
5045 let full = build_schema_payload(
5046 &schema,
5047 vec!["v".into()],
5048 SchemaVerbosity::Full,
5049 OriginClass::FirstParty,
5050 );
5051 let lite = build_schema_payload(
5052 &schema,
5053 vec!["v".into()],
5054 SchemaVerbosity::Lite,
5055 OriginClass::FirstParty,
5056 );
5057 let full_len = serde_json::to_string(&full).unwrap().len();
5058 let lite_len = serde_json::to_string(&lite).unwrap().len();
5059 assert!(
5060 lite_len * 2 < full_len,
5061 "lite ({lite_len} B) must be well under half of full ({full_len} B)"
5062 );
5063 }
5064
5065 #[test]
5066 fn lite_full_carry_the_same_type_and_rel_names() {
5067 let schema = software_schema();
5070 let full = build_schema_payload(
5071 &schema,
5072 vec!["v".into()],
5073 SchemaVerbosity::Full,
5074 OriginClass::FirstParty,
5075 );
5076 let lite = build_schema_payload(
5077 &schema,
5078 vec!["v".into()],
5079 SchemaVerbosity::Lite,
5080 OriginClass::FirstParty,
5081 );
5082
5083 let names = |arr: &serde_json::Value| -> Vec<String> {
5084 arr.as_array()
5085 .unwrap()
5086 .iter()
5087 .map(|v| v["name"].as_str().unwrap().to_string())
5088 .collect()
5089 };
5090 assert_eq!(names(&full["types"]), names(&lite["types_summary"]));
5091 assert_eq!(
5092 names(&full["relationships"]),
5093 names(&lite["relationships_summary"])
5094 );
5095 }
5096
5097 #[test]
5102 fn swallowed_sections_carry_a_marker_on_the_plain_read() {
5103 let mut e = test_entity();
5104 e.sections.insert(
5105 "identity".to_string(),
5106 "intro\n\n```rust\nfn main() {}".to_string(),
5107 );
5108 e.sections.insert("purpose".to_string(), String::new());
5109 let env = build_entity_envelope(
5110 &e,
5111 10,
5112 None,
5113 None,
5114 None,
5115 OriginClass::FirstParty,
5116 &[],
5117 None,
5118 None,
5119 None,
5120 );
5121 let marker = &env["_unread_sections"];
5122 assert_eq!(marker["reason"], "UNTERMINATED_FENCE");
5123 assert_eq!(marker["absorbed_into"], "identity");
5124 assert_eq!(marker["sections"], serde_json::json!(["purpose"]));
5125 }
5126
5127 #[test]
5128 fn an_ordinary_entity_carries_no_unread_marker() {
5129 for body in ["plain prose", "```rust\nfn main() {}\n```"] {
5133 let mut e = test_entity();
5134 e.sections.insert("identity".to_string(), body.to_string());
5135 e.sections.insert("purpose".to_string(), String::new());
5136 let env = build_entity_envelope(
5137 &e,
5138 10,
5139 None,
5140 None,
5141 None,
5142 OriginClass::FirstParty,
5143 &[],
5144 None,
5145 None,
5146 None,
5147 );
5148 assert!(
5149 env.get("_unread_sections").is_none(),
5150 "body {body:?} produced a marker"
5151 );
5152 }
5153 }
5154}