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 edge_weight_overrides: indexmap::IndexMap::new(),
2970 edge_weights: indexmap::IndexMap::new(),
2971 no_self_loop_relationships: vec![],
2972 legacy_propagating_relationships: None,
2973 due: None,
2974 leaf: false,
2975 updatable_fields: vec![],
2976 health_required_fields: vec![],
2977 staleness_threshold_days: 90,
2978 write_rules: vec![],
2979 required_outgoing: vec![],
2980 must_reach: vec![],
2981 signals: vec![],
2982 constraints: vec![],
2983 declared_metadata_keys: vec![],
2984 };
2985
2986 let mut sections = HashMap::new();
2987 sections.insert("note".to_string(), "a note".to_string());
2988 assert_eq!(
2989 summary_pair(Some(&schema), §ions),
2990 ("Note".to_string(), "a note".to_string()),
2991 );
2992
2993 assert_eq!(
2994 summary_pair(Some(&schema), &HashMap::new()),
2995 ("Note".to_string(), "—".to_string()),
2996 );
2997 }
2998
2999 #[test]
3004 fn render_list_uses_first_required_section_for_spec() {
3005 let hit = make_hit(
3006 "specs--demo",
3007 "Demo Spec",
3008 "spec",
3009 &[
3010 ("identity", "A demo spec."),
3011 ("purpose", "Verifies rendering."),
3012 ],
3013 );
3014 let out = render_list_markdown(&list_result(vec![hit]));
3015 assert!(
3016 out.contains("**Identity**: A demo spec."),
3017 "expected Identity line for spec hit, got:\n{out}"
3018 );
3019 }
3020
3021 #[test]
3022 fn render_list_uses_first_required_section_for_memo() {
3023 let hit = make_hit("memos--d1", "Memo One", "memo", &[("claim", "Some claim.")]);
3024 let out = render_list_markdown(&list_result(vec![hit]));
3025 assert!(
3026 out.contains("**Claim**: Some claim."),
3027 "expected Claim line for memo hit, got:\n{out}"
3028 );
3029 assert!(
3030 !out.contains("**Identity**"),
3031 "memo hit must not render Identity label in list output"
3032 );
3033 }
3034
3035 #[test]
3036 fn render_list_uses_first_required_section_for_concept() {
3037 let hit = make_hit(
3038 "concepts--thing",
3039 "Thing",
3040 "concept",
3041 &[("definition", "A thing.")],
3042 );
3043 let out = render_list_markdown(&list_result(vec![hit]));
3044 assert!(
3045 out.contains("**Definition**: A thing."),
3046 "expected Definition line for concept hit, got:\n{out}"
3047 );
3048 }
3049
3050 #[test]
3051 fn render_list_missing_summary_section_shows_dash() {
3052 let hit = make_hit("memos--empty", "Empty Memo", "memo", &[]);
3053 let out = render_list_markdown(&list_result(vec![hit]));
3054 assert!(
3055 out.contains("**Claim**: —"),
3056 "expected Claim dash fallback in list output, got:\n{out}"
3057 );
3058 }
3059
3060 #[test]
3061 fn render_list_mixes_schemas_in_one_result() {
3062 let spec_hit = make_hit(
3063 "specs--s1",
3064 "Spec One",
3065 "spec",
3066 &[("identity", "Spec body.")],
3067 );
3068 let memo_hit = make_hit("memos--m1", "Memo One", "memo", &[("claim", "Memo claim.")]);
3069 let out = render_list_markdown(&list_result(vec![spec_hit, memo_hit]));
3070 assert!(
3071 out.contains("**Identity**: Spec body."),
3072 "spec hit should still render Identity in list output, got:\n{out}"
3073 );
3074 assert!(
3075 out.contains("**Claim**: Memo claim."),
3076 "memo hit should render Claim in list output, got:\n{out}"
3077 );
3078 }
3079
3080 #[test]
3081 fn render_list_unknown_schema_shows_summary_dash() {
3082 let hit = make_hit("bogus--x", "Bogus", "bogus", &[]);
3083 let out = render_list_markdown(&list_result(vec![hit]));
3084 assert!(
3085 out.contains("**Summary**: —"),
3086 "unknown schema should render Summary dash in list output, got:\n{out}"
3087 );
3088 }
3089
3090 #[test]
3095 fn summary_pair_for_spec_returns_identity() {
3096 let schema = type_by_name("spec");
3097 let mut sections = HashMap::new();
3098 sections.insert("identity".to_string(), "A demo spec.".to_string());
3099 assert_eq!(
3100 summary_pair(schema.as_deref(), §ions),
3101 ("Identity".to_string(), "A demo spec.".to_string()),
3102 );
3103 }
3104
3105 #[test]
3106 fn summary_pair_for_memo_returns_claim() {
3107 let schema = type_by_name("memo");
3108 let mut sections = HashMap::new();
3109 sections.insert("claim".to_string(), "Memos matter.".to_string());
3110 assert_eq!(
3111 summary_pair(schema.as_deref(), §ions),
3112 ("Claim".to_string(), "Memos matter.".to_string()),
3113 );
3114 }
3115
3116 #[test]
3117 fn summary_pair_missing_section_returns_dash() {
3118 let schema = type_by_name("memo");
3119 assert_eq!(
3120 summary_pair(schema.as_deref(), &HashMap::new()),
3121 ("Claim".to_string(), "—".to_string()),
3122 );
3123 }
3124
3125 #[test]
3126 fn summary_pair_unknown_schema_returns_summary_dash() {
3127 assert_eq!(
3128 summary_pair(None, &HashMap::new()),
3129 ("Summary".to_string(), "—".to_string()),
3130 );
3131 }
3132
3133 #[test]
3138 fn envelope_serializes_summary_fields() {
3139 let hit = make_hit(
3140 "memos--d1",
3141 "Memo One",
3142 "memo",
3143 &[("claim", "Memos matter.")],
3144 );
3145 let result = search_result(vec![hit]);
3146 let envelope = build_search_envelope(&result, 0);
3147 let value = serde_json::to_value(&envelope).expect("envelope must serialize");
3148
3149 assert_eq!(value["_total"], 1);
3153 assert_eq!(value["_returned"], 1);
3154 assert_eq!(value["_offset"], 0);
3155 assert!(
3157 value.get("warnings").is_none(),
3158 "empty warnings must be elided, got: {value}"
3159 );
3160
3161 let hit0 = &value["hits"][0];
3162 assert_eq!(hit0["summary_heading"], "Claim");
3163 assert_eq!(hit0["summary_value"], "Memos matter.");
3164 assert_eq!(hit0["id"], "memos--d1");
3166 assert_eq!(hit0["title"], "Memo One");
3167 assert_eq!(hit0["entity_type"], "memo");
3168 assert_eq!(hit0["mem"], "memos");
3169 assert_eq!(hit0["stub"], false);
3170 assert_eq!(hit0["tokens"], 10);
3171 assert!(hit0["sections"].is_object());
3172 }
3173
3174 #[test]
3175 fn envelope_roundtrips_through_structured_content() {
3176 let spec_hit = make_hit(
3179 "specs--s1",
3180 "Spec One",
3181 "spec",
3182 &[("identity", "Spec body.")],
3183 );
3184 let memo_hit = make_hit("memos--m1", "Memo One", "memo", &[("claim", "Memo claim.")]);
3185 let result = search_result(vec![spec_hit, memo_hit]);
3186 let envelope = build_search_envelope(&result, 0);
3187 let value = serde_json::to_value(&envelope).expect("envelope must serialize");
3188
3189 let hits = value["hits"].as_array().expect("hits must be array");
3190 assert_eq!(hits.len(), 2);
3191 assert_eq!(hits[0]["summary_heading"], "Identity");
3192 assert_eq!(hits[0]["summary_value"], "Spec body.");
3193 assert_eq!(hits[1]["summary_heading"], "Claim");
3194 assert_eq!(hits[1]["summary_value"], "Memo claim.");
3195 }
3196
3197 #[test]
3198 fn list_envelope_includes_total_tokens() {
3199 let hit = make_hit(
3200 "concepts--c1",
3201 "Thing",
3202 "concept",
3203 &[("definition", "A thing.")],
3204 );
3205 let result = list_result(vec![hit]);
3206 let envelope = build_list_envelope(&result);
3207 let value = serde_json::to_value(&envelope).expect("envelope must serialize");
3208
3209 assert_eq!(value["_total"], 1);
3211 assert_eq!(value["_total_tokens"], 10);
3212 assert!(value.get("total").is_none(), "unprefixed keys retired");
3213 assert_eq!(value["hits"][0]["summary_heading"], "Definition");
3214 assert_eq!(value["hits"][0]["summary_value"], "A thing.");
3215 }
3216
3217 #[test]
3218 fn envelope_emits_warnings_when_present() {
3219 let mut result = search_result(vec![]);
3220 result.warnings = vec![crate::ops::WarningHint::FieldNotFilterable {
3223 field: "foo".to_string(),
3224 }];
3225 let envelope = build_search_envelope(&result, 0);
3226 let value = serde_json::to_value(&envelope).expect("envelope must serialize");
3227 assert_eq!(value["warnings"][0]["code"], "FIELD_NOT_FILTERABLE");
3228 assert_eq!(value["warnings"][0]["details"]["field"], "foo");
3229 assert!(
3230 value["warnings"][0]["message"]
3231 .as_str()
3232 .is_some_and(|m| m.contains("not filterable"))
3233 );
3234 }
3235
3236 fn tm(field: &str, snippet: &str, heading_path: Option<&[&str]>) -> TermMatch {
3241 TermMatch {
3242 field: field.to_string(),
3243 snippet: snippet.to_string(),
3244 heading_path: heading_path.map(|p| p.iter().map(|s| s.to_string()).collect()),
3245 }
3246 }
3247
3248 fn sample_facets() -> Facets {
3249 use crate::ops::SubsectionFacet;
3250 Facets {
3251 by_type: HashMap::from([
3252 ("spec".to_string(), 7),
3253 ("memo".to_string(), 3),
3254 ("decision".to_string(), 2),
3255 ]),
3256 by_mem: HashMap::from([("specs".to_string(), 10), ("memos".to_string(), 2)]),
3257 by_level: HashMap::from([("high".to_string(), 4)]),
3258 by_status: HashMap::from([("active".to_string(), 6)]),
3259 by_confidence: HashMap::from([("medium".to_string(), 3)]),
3260 by_subsection: vec![
3261 SubsectionFacet {
3262 path: vec!["specifies".to_string(), "Response Shapes".to_string()],
3263 count: 4,
3264 },
3265 SubsectionFacet {
3266 path: vec!["purpose".to_string(), "Rationale".to_string()],
3267 count: 2,
3268 },
3269 ],
3270 by_expansion: HashMap::from([("primary".to_string(), 8), ("expanded".to_string(), 4)]),
3271 }
3272 }
3273
3274 #[test]
3275 fn render_search_emits_matched_terms_line() {
3276 let mut hit = make_hit(
3277 "specs--e1",
3278 "Entity One",
3279 "spec",
3280 &[("identity", "Body text.")],
3281 );
3282 hit.matched_terms = Some(HashMap::from([
3283 (
3284 "entity".to_string(),
3285 vec![
3286 tm("title", "...entity...", None),
3287 tm("purpose", "...entity...", None),
3288 tm("purpose", "...entity two...", None),
3289 ],
3290 ),
3291 ("one".to_string(), vec![tm("title", "...one...", None)]),
3292 ]));
3293 let out = render_search_markdown(&search_result(vec![hit]), 0);
3294 assert!(
3295 out.contains("**Matched terms:**"),
3296 "missing Matched terms line; got:\n{out}"
3297 );
3298 assert!(
3299 out.contains("`entity` (purpose×2, title×1)"),
3300 "entity term grouping wrong; got:\n{out}"
3301 );
3302 assert!(
3303 out.contains("`one` (title×1)"),
3304 "one term grouping wrong; got:\n{out}"
3305 );
3306 }
3307
3308 #[test]
3309 fn render_search_emits_score_breakdown_line() {
3310 let mut hit = make_hit("specs--e1", "Entity", "spec", &[("identity", "b")]);
3311 hit.score_breakdown = Some(ScoreBreakdown {
3312 bm25: 2.5,
3313 title_boost: 2.0,
3314 field_weights: HashMap::from([("body".to_string(), 0.8), ("purpose".to_string(), 0.3)]),
3315 expansion_decay: Some(0.5),
3316 });
3317 let out = render_search_markdown(&search_result(vec![hit]), 0);
3318 assert!(
3319 out.contains(
3320 "**Score:** bm25 2.5 + title 2.0 + body 0.8 + purpose 0.3 + expansion_decay ×0.5"
3321 ),
3322 "score breakdown line wrong; got:\n{out}"
3323 );
3324 }
3325
3326 #[test]
3327 fn render_search_omits_expansion_decay_when_none() {
3328 let mut hit = make_hit("specs--e1", "Entity", "spec", &[("identity", "b")]);
3329 hit.score_breakdown = Some(ScoreBreakdown {
3330 bm25: 1.5,
3331 title_boost: 1.0,
3332 field_weights: HashMap::new(),
3333 expansion_decay: None,
3334 });
3335 let out = render_search_markdown(&search_result(vec![hit]), 0);
3336 assert!(
3337 out.contains("**Score:** bm25 1.5 + title 1.0"),
3338 "base score wrong; got:\n{out}"
3339 );
3340 assert!(
3341 !out.contains("expansion_decay"),
3342 "expansion_decay must be absent when None; got:\n{out}"
3343 );
3344 }
3345
3346 #[test]
3347 fn render_search_emits_heading_path_line() {
3348 let mut hit = make_hit("specs--e1", "Entity", "spec", &[("identity", "b")]);
3349 hit.matched_terms = Some(HashMap::from([(
3350 "x".to_string(),
3351 vec![
3352 tm("purpose", "...x...", Some(&["Purpose", "Rationale"])),
3353 tm("purpose", "...x...", Some(&["Purpose", "Rationale"])), tm("specifies", "...x...", Some(&["Specifies", "Responses"])),
3355 ],
3356 )]));
3357 let out = render_search_markdown(&search_result(vec![hit]), 0);
3358 assert!(
3359 out.contains("**Heading path:** Purpose › Rationale; Specifies › Responses"),
3360 "heading path line wrong; got:\n{out}"
3361 );
3362 }
3363
3364 #[test]
3365 fn render_search_emits_expansion_line() {
3366 let mut hit = make_hit("specs--e2", "Entity Two", "spec", &[("identity", "b")]);
3367 hit.expansion = Some(ExpansionInfo {
3368 of: EntityId("specs--seed".to_string()),
3369 via_edge: "refines".to_string(),
3370 via_direction: crate::graph::query::TraversalDirection::Out,
3371 depth: 1,
3372 });
3373 let out = render_search_markdown(&search_result(vec![hit]), 0);
3374 assert!(
3375 out.contains("**Expansion:** from `specs--seed` via `refines` [out] (depth 1)"),
3376 "expansion line reports the traversal direction beside the label; got:\n{out}"
3377 );
3378 }
3379
3380 #[test]
3381 fn render_search_emits_facets_block() {
3382 let mut result = search_result(vec![]);
3383 result.facets = Some(sample_facets());
3384 let out = render_search_markdown(&result, 0);
3385 assert!(
3386 out.contains("## Facets"),
3387 "facets header missing; got:\n{out}"
3388 );
3389 assert!(
3390 out.contains("- **by_type:** spec=7, memo=3, decision=2"),
3391 "by_type bucket wrong; got:\n{out}"
3392 );
3393 assert!(
3394 out.contains("- **by_mem:** specs=10, memos=2"),
3395 "by_mem bucket wrong; got:\n{out}"
3396 );
3397 assert!(
3398 out.contains("- **by_level:** high=4"),
3399 "by_level bucket wrong; got:\n{out}"
3400 );
3401 assert!(
3402 out.contains("- **by_status:** active=6"),
3403 "by_status bucket wrong; got:\n{out}"
3404 );
3405 assert!(
3406 out.contains("- **by_confidence:** medium=3"),
3407 "by_confidence bucket wrong; got:\n{out}"
3408 );
3409 assert!(
3410 out.contains("- **by_expansion:** primary=8, expanded=4"),
3411 "by_expansion bucket wrong; got:\n{out}"
3412 );
3413 assert!(
3414 out.contains("- **by_subsection:**"),
3415 "by_subsection header missing; got:\n{out}"
3416 );
3417 assert!(
3418 out.contains("`specifies › Response Shapes`: 4"),
3419 "subsection facet wrong; got:\n{out}"
3420 );
3421 }
3422
3423 #[test]
3424 fn render_search_omits_facets_block_when_all_empty() {
3425 let mut result = search_result(vec![]);
3426 result.facets = Some(Facets::default());
3427 let out = render_search_markdown(&result, 0);
3428 assert!(
3429 !out.contains("## Facets"),
3430 "empty facets must not emit header; got:\n{out}"
3431 );
3432 }
3433
3434 #[test]
3438 fn search_markdown_covers_every_sidecar_field() {
3439 let mut hit = make_hit(
3440 "specs--e1",
3441 "Entity One",
3442 "spec",
3443 &[("identity", "Body text.")],
3444 );
3445 hit.matched_terms = Some(HashMap::from([(
3446 "entity".to_string(),
3447 vec![tm("title", "...entity...", Some(&["Purpose", "Rationale"]))],
3448 )]));
3449 hit.score_breakdown = Some(ScoreBreakdown {
3450 bm25: 1.5,
3451 title_boost: 1.0,
3452 field_weights: HashMap::from([("body".to_string(), 0.4)]),
3453 expansion_decay: Some(0.5),
3454 });
3455 hit.expansion = Some(ExpansionInfo {
3456 of: EntityId("specs--seed".to_string()),
3457 via_edge: "refines".to_string(),
3458 via_direction: crate::graph::query::TraversalDirection::Out,
3459 depth: 2,
3460 });
3461
3462 let mut result = search_result(vec![hit]);
3463 result.facets = Some(sample_facets());
3464
3465 let out = render_search_markdown(&result, 0);
3466 for marker in [
3467 "## Facets",
3468 "- **by_type:**",
3469 "- **by_mem:**",
3470 "- **by_level:**",
3471 "- **by_status:**",
3472 "- **by_confidence:**",
3473 "- **by_expansion:**",
3474 "- **by_subsection:**",
3475 "**Matched terms:**",
3476 "**Score:**",
3477 "**Heading path:**",
3478 "**Expansion:**",
3479 ] {
3480 assert!(
3481 out.contains(marker),
3482 "lockstep marker `{marker}` missing from search markdown; \
3483 update render_search_markdown when adding sidecar fields. got:\n{out}"
3484 );
3485 }
3486 }
3487
3488 #[test]
3495 fn build_entity_envelope_source_field_reads_edge_source() {
3496 let mut entity = test_entity();
3497 let body_link_target = EntityId("specs--body-link-target".to_string());
3498 let explicit_target = EntityId("specs--explicit-target".to_string());
3499 entity.relationships = vec![
3500 crate::entity::Relationship::new("REFERENCES".to_string(), body_link_target.clone()),
3501 crate::entity::Relationship::new("USES".to_string(), explicit_target.clone()),
3502 ];
3503
3504 let edges = vec![
3505 crate::store::Edge {
3506 rel_type: "REFERENCES".to_string(),
3507 target: body_link_target.clone(),
3508 source: crate::store::EdgeSource::BodyLink,
3509 },
3510 crate::store::Edge {
3511 rel_type: "USES".to_string(),
3512 target: explicit_target.clone(),
3513 source: crate::store::EdgeSource::Explicit,
3514 },
3515 ];
3516
3517 let env = build_entity_envelope(
3518 &entity,
3519 0,
3520 None,
3521 None,
3522 None,
3523 OriginClass::FirstParty,
3524 &edges,
3525 None,
3526 None,
3527 None,
3528 );
3529 let relationships = env["relationships"].as_array().expect("array");
3530 let refs = relationships
3531 .iter()
3532 .find(|r| r["rel_type"] == "REFERENCES")
3533 .expect("REFERENCES present");
3534 assert_eq!(
3535 refs["source"], "body_link",
3536 "alias-synthesised edge must label body_link"
3537 );
3538 let uses = relationships
3539 .iter()
3540 .find(|r| r["rel_type"] == "USES")
3541 .expect("USES present");
3542 assert_eq!(
3543 uses["source"], "explicit",
3544 "explicit-authored edge must label explicit"
3545 );
3546 }
3547
3548 #[test]
3555 fn build_entity_envelope_carries_origin_direction_and_incoming() {
3556 let mut entity = test_entity();
3557 let out_target = EntityId("specs--downstream".to_string());
3558 entity.relationships = vec![crate::entity::Relationship::new(
3559 "USES".to_string(),
3560 out_target.clone(),
3561 )];
3562 let edges = vec![crate::store::Edge {
3563 rel_type: "USES".to_string(),
3564 target: out_target,
3565 source: crate::store::EdgeSource::Explicit,
3566 }];
3567 let incoming = vec![crate::store::InEdge {
3568 rel_type: "MANAGES".to_string(),
3569 from: EntityId("specs--upstream".to_string()),
3570 source: crate::store::EdgeSource::Explicit,
3571 }];
3572
3573 let env = build_entity_envelope(
3575 &entity,
3576 0,
3577 None,
3578 None,
3579 None,
3580 OriginClass::ThirdParty,
3581 &edges,
3582 None,
3583 None,
3584 None,
3585 );
3586 assert_eq!(env["origin"], "third-party", "origin is envelope-level");
3587 let rels = env["relationships"].as_array().expect("array");
3588 assert_eq!(rels.len(), 1);
3589 assert_eq!(rels[0]["direction"], "out");
3590
3591 let env = build_entity_envelope(
3594 &entity,
3595 0,
3596 None,
3597 None,
3598 None,
3599 OriginClass::FirstParty,
3600 &edges,
3601 Some(&incoming),
3602 None,
3603 None,
3604 );
3605 assert_eq!(env["origin"], "first-party");
3606 let rels = env["relationships"].as_array().expect("array");
3607 assert_eq!(rels.len(), 2);
3608 let inc = rels
3609 .iter()
3610 .find(|r| r["direction"] == "in")
3611 .expect("incoming entry present");
3612 assert_eq!(inc["rel_type"], "MANAGES");
3613 assert_eq!(inc["from"], "specs--upstream");
3614 assert!(
3615 inc.get("target").is_none(),
3616 "incoming carries from, not target"
3617 );
3618 }
3619
3620 #[test]
3625 fn build_entity_envelope_source_field_falls_back_to_explicit_when_edge_missing() {
3626 let mut entity = test_entity();
3627 let target = EntityId("specs--unmapped".to_string());
3628 entity.relationships = vec![crate::entity::Relationship::new("USES".to_string(), target)];
3629 let edges: Vec<crate::store::Edge> = Vec::new();
3630 let env = build_entity_envelope(
3631 &entity,
3632 0,
3633 None,
3634 None,
3635 None,
3636 OriginClass::FirstParty,
3637 &edges,
3638 None,
3639 None,
3640 None,
3641 );
3642 let relationships = env["relationships"].as_array().expect("array");
3643 assert_eq!(relationships[0]["source"], "explicit");
3644 }
3645
3646 #[test]
3652 fn build_entity_envelope_nested_metadata_carries_every_schema_field() {
3653 use crate::entity::MetadataValue;
3654 let mut entity = test_entity();
3655 entity.entity_type = "contract".to_string();
3656 entity.metadata = IndexMap::from([
3658 ("level".to_string(), MetadataValue::String("M0".to_string())),
3659 (
3660 "stability".to_string(),
3661 MetadataValue::String("stable".to_string()),
3662 ),
3663 (
3664 "created_date".to_string(),
3665 MetadataValue::String("2026-01-01".to_string()),
3666 ),
3667 (
3668 "last_modified".to_string(),
3669 MetadataValue::String("2026-05-19".to_string()),
3670 ),
3671 (
3672 "protocol".to_string(),
3673 MetadataValue::String("https".to_string()),
3674 ),
3675 (
3676 "version".to_string(),
3677 MetadataValue::String("0.1.0".to_string()),
3678 ),
3679 (
3680 "deprecation_status".to_string(),
3681 MetadataValue::String("none".to_string()),
3682 ),
3683 ]);
3684
3685 let env = build_entity_envelope(
3686 &entity,
3687 0,
3688 None,
3689 None,
3690 None,
3691 OriginClass::FirstParty,
3692 &[],
3693 None,
3694 None,
3695 None,
3696 );
3697
3698 assert!(
3701 env.get("level").is_none(),
3702 "level must not be hoisted top-level"
3703 );
3704 assert!(
3705 env.get("stability").is_none(),
3706 "stability must not be hoisted"
3707 );
3708 assert!(
3709 env.get("created_date").is_none(),
3710 "created_date must not be hoisted"
3711 );
3712 assert!(
3713 env.get("last_modified").is_none(),
3714 "last_modified must not be hoisted"
3715 );
3716 assert_eq!(env["entity_type"], "contract");
3720 assert!(
3721 env.get("type").is_none(),
3722 "the retired wire key must not survive"
3723 );
3724
3725 let metadata = env["metadata"].as_object().expect("metadata map");
3727 assert_eq!(metadata["level"], "M0");
3728 assert_eq!(metadata["stability"], "stable");
3729 assert_eq!(metadata["created_date"], "2026-01-01");
3730 assert_eq!(metadata["last_modified"], "2026-05-19");
3731 assert_eq!(metadata["protocol"], "https");
3732 assert_eq!(metadata["version"], "0.1.0");
3733 assert_eq!(metadata["deprecation_status"], "none");
3734
3735 for k in metadata.keys() {
3738 assert!(
3739 !k.starts_with('_'),
3740 "metadata map must not carry underscore-prefixed key `{k}`"
3741 );
3742 assert!(
3743 !["mem", "id", "type"].contains(&k.as_str()),
3744 "metadata map must not carry identity key `{k}` (it lives top-level)"
3745 );
3746 }
3747 }
3748
3749 #[test]
3753 fn build_entity_envelope_stub_carries_empty_metadata_map() {
3754 let mut entity = test_entity();
3755 entity.stub = true;
3756 entity.stub_kind = Some(crate::entity::StubKind::ForwardReference);
3757 entity.metadata = IndexMap::new();
3758 let env = build_entity_envelope(
3759 &entity,
3760 0,
3761 None,
3762 None,
3763 None,
3764 OriginClass::FirstParty,
3765 &[],
3766 None,
3767 None,
3768 None,
3769 );
3770 let metadata = env["metadata"]
3771 .as_object()
3772 .expect("metadata key present even on stubs");
3773 assert!(metadata.is_empty(), "stub metadata map must be empty");
3774 }
3775
3776 #[test]
3783 fn build_entity_envelope_user_field_collisions_isolated_to_nested_map() {
3784 use crate::entity::MetadataValue;
3785 let mut entity = test_entity();
3786 entity.metadata = IndexMap::from([
3787 (
3788 "sections".to_string(),
3789 MetadataValue::String("user-supplied-shadow".to_string()),
3790 ),
3791 (
3792 "relationships".to_string(),
3793 MetadataValue::String("also-shadowed".to_string()),
3794 ),
3795 ]);
3796 let env = build_entity_envelope(
3797 &entity,
3798 0,
3799 None,
3800 None,
3801 None,
3802 OriginClass::FirstParty,
3803 &[],
3804 None,
3805 None,
3806 None,
3807 );
3808 assert!(
3810 env["sections"].is_object(),
3811 "top-level sections stays a map"
3812 );
3813 assert!(
3814 env["relationships"].is_array(),
3815 "top-level relationships stays an array"
3816 );
3817 let metadata = env["metadata"].as_object().expect("metadata map");
3819 assert_eq!(metadata["sections"], "user-supplied-shadow");
3820 assert_eq!(metadata["relationships"], "also-shadowed");
3821 }
3822
3823 #[test]
3827 fn build_entity_envelope_unfiltered_body_token_field_name() {
3828 let entity = test_entity();
3829 let env_filtered = build_entity_envelope(
3831 &entity,
3832 10,
3833 Some(42),
3834 None,
3835 None,
3836 OriginClass::FirstParty,
3837 &[],
3838 None,
3839 None,
3840 None,
3841 );
3842 assert_eq!(env_filtered["_tokens_unfiltered_body"], 42);
3843 assert!(
3844 env_filtered.get("_tokens_full").is_none(),
3845 "_tokens_full must not survive — rename is one-way"
3846 );
3847 let env_unfiltered = build_entity_envelope(
3849 &entity,
3850 10,
3851 None,
3852 None,
3853 None,
3854 OriginClass::FirstParty,
3855 &[],
3856 None,
3857 None,
3858 None,
3859 );
3860 assert!(env_unfiltered.get("_tokens_unfiltered_body").is_none());
3861 assert!(env_unfiltered.get("_tokens_full").is_none());
3862 }
3863
3864 fn software_schema() -> Arc<Schema> {
3872 memstead_schema::builtins::load_builtin_schemas()
3873 .expect("builtins load")
3874 .into_iter()
3875 .find(|s| s.manifest.name == "software")
3876 .expect("software schema is a builtin")
3877 }
3878
3879 #[test]
3880 fn schema_verbosity_wire_round_trips() {
3881 assert_eq!(
3882 SchemaVerbosity::from_wire("full"),
3883 Some(SchemaVerbosity::Full)
3884 );
3885 assert_eq!(
3886 SchemaVerbosity::from_wire("lite"),
3887 Some(SchemaVerbosity::Lite)
3888 );
3889 assert_eq!(SchemaVerbosity::from_wire("brief"), None);
3890 assert_eq!(SchemaVerbosity::from_wire(""), None);
3891 assert_eq!(SchemaVerbosity::Full.as_wire(), "full");
3892 assert_eq!(SchemaVerbosity::Lite.as_wire(), "lite");
3893 assert_eq!(SchemaVerbosity::default(), SchemaVerbosity::Full);
3894 }
3895
3896 #[test]
3902 fn exemplar_serves_at_full_and_lite_stays_byte_unchanged() {
3903 let manifest = r#"name: servefix
3904version: 1.0.0
3905description: serving fixture
3906when_to_use: tests
3907types:
3908 - sample
3909relationships:
3910 mode: strict
3911 definitions:
3912 - name: PART_OF
3913 description: hier
3914 default_weight: 3.0
3915 - name: _default
3916 description: fallback
3917 default_weight: 1.0
3918community:
3919 resolution: 1.0
3920 seed: 42
3921"#;
3922 let base_type = r#"name: sample
3923description: t
3924when_to_use: tests
3925sections:
3926 - key: body
3927 heading: Body
3928 required: true
3929 search_weight: 10.0
3930 catch_all: true
3931 write_rules: []
3932metadata_fields:
3933 - key: status
3934 description: state
3935 field_type: string
3936 enum_values: [draft, final]
3937 optional: true
3938title_weight: 100.0
3939text_fields:
3940 - body
3941hierarchy_relationship: PART_OF
3942no_self_loop_relationships: []
3943updatable_fields:
3944 - title
3945 - body
3946health_required_fields:
3947 - body
3948staleness_threshold_days: 90
3949write_rules: []
3950"#;
3951 let with_exemplar = format!(
3952 "{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"
3953 );
3954
3955 let plain = Arc::new(
3956 memstead_schema::loader::load_schema_from_memory(
3957 manifest,
3958 &[("sample".to_string(), base_type.to_string())],
3959 )
3960 .expect("fixture loads"),
3961 );
3962 let exemplary = Arc::new(
3963 memstead_schema::loader::load_schema_from_memory(
3964 manifest,
3965 &[("sample".to_string(), with_exemplar)],
3966 )
3967 .expect("fixture loads"),
3968 );
3969
3970 let full = build_schema_payload(
3972 &exemplary,
3973 vec![],
3974 SchemaVerbosity::Full,
3975 OriginClass::FirstParty,
3976 );
3977 let ex = &full["types"][0]["exemplar"];
3978 assert_eq!(ex["title"], "A Conforming Sample", "{full}");
3979 assert_eq!(ex["metadata"]["status"], "draft");
3980 assert_eq!(ex["sections"]["body"], "One canonical body paragraph.");
3981 assert_eq!(ex["relations"][0]["target"], "parent-placeholder");
3982 assert_eq!(ex["relations"][0]["rel_type"], "PART_OF");
3983
3984 let full_plain = build_schema_payload(
3986 &plain,
3987 vec![],
3988 SchemaVerbosity::Full,
3989 OriginClass::FirstParty,
3990 );
3991 assert!(full_plain["types"][0].get("exemplar").is_none());
3992
3993 let lite_with = build_schema_payload(
3996 &exemplary,
3997 vec![],
3998 SchemaVerbosity::Lite,
3999 OriginClass::FirstParty,
4000 );
4001 let lite_without = build_schema_payload(
4002 &plain,
4003 vec![],
4004 SchemaVerbosity::Lite,
4005 OriginClass::FirstParty,
4006 );
4007 assert_eq!(
4008 serde_json::to_string(&lite_with).unwrap(),
4009 serde_json::to_string(&lite_without).unwrap(),
4010 "lite must not change when an exemplar exists"
4011 );
4012 assert!(
4013 !serde_json::to_string(&lite_with)
4014 .unwrap()
4015 .contains("exemplar"),
4016 "lite must not mention exemplars at all"
4017 );
4018 }
4019
4020 #[test]
4024 fn first_party_origin_is_labelled_and_keeps_prose() {
4025 let schema = software_schema();
4026 let full = build_schema_payload(
4027 &schema,
4028 vec!["v".into()],
4029 SchemaVerbosity::Full,
4030 OriginClass::FirstParty,
4031 );
4032 assert_eq!(full["origin"], "first-party");
4033 assert!(full["description"].is_string());
4035 let t = &full["types"].as_array().unwrap()[0];
4036 assert!(t.get("system_context").is_some());
4037 assert!(t.get("writing_guidance").is_some());
4038
4039 let lite = build_schema_payload(
4041 &schema,
4042 vec!["v".into()],
4043 SchemaVerbosity::Lite,
4044 OriginClass::FirstParty,
4045 );
4046 assert_eq!(lite["origin"], "first-party");
4047 }
4048
4049 #[test]
4054 fn constraints_and_severity_render_at_both_verbosities() {
4055 let manifest = r#"name: constrained
4056version: 1.0.0
4057description: constraint render fixture
4058when_to_use: render tests
4059types:
4060 - sample
4061relationships:
4062 mode: strict
4063 definitions:
4064 - name: PART_OF
4065 description: hier
4066 default_weight: 3.0
4067 - name: _default
4068 description: fallback
4069 default_weight: 1.0
4070community:
4071 resolution: 1.0
4072 seed: 42
4073"#;
4074 let type_yaml = r#"name: sample
4075description: t
4076when_to_use: tests
4077sections:
4078 - key: body
4079 heading: Body
4080 required: true
4081 search_weight: 10.0
4082 catch_all: true
4083 write_rules: []
4084metadata_fields:
4085 - key: status
4086 description: state
4087 field_type: string
4088 enum_values: [open, checked]
4089 optional: true
4090 - key: checked_by
4091 description: who
4092 field_type: string
4093 optional: true
4094title_weight: 100.0
4095text_fields:
4096 - body
4097hierarchy_relationship: PART_OF
4098no_self_loop_relationships: []
4099updatable_fields:
4100 - title
4101 - body
4102health_required_fields:
4103 - body
4104staleness_threshold_days: 90
4105required_outgoing:
4106 - relationships: [PART_OF]
4107 cardinality: at_least_one
4108 severity: block
4109constraints:
4110 - kind: requires_when
4111 field: checked_by
4112 when_field: status
4113 when_value: checked
4114 - kind: unique
4115 fields: [status, checked_by]
4116 - kind: enum_from_neighbour
4117 field: status
4118 rel_type: PART_OF
4119 section: body
4120 - kind: status_propagation
4121 field: status
4122 value: checked
4123 rel_type: PART_OF
4124 direction: incoming
4125write_rules: []
4126"#;
4127 let schema = Arc::new(
4128 memstead_schema::loader::load_schema_from_memory(
4129 manifest,
4130 &[("sample".to_string(), type_yaml.to_string())],
4131 )
4132 .expect("fixture loads"),
4133 );
4134
4135 let expected_constraints = serde_json::json!([
4140 {
4141 "kind": "requires_when",
4142 "field": "checked_by",
4143 "when_field": "status",
4144 "when_value": "checked",
4145 "severity": "warn",
4146 },
4147 {
4148 "kind": "unique",
4149 "fields": ["status", "checked_by"],
4150 "severity": "block",
4151 },
4152 {
4153 "kind": "enum_from_neighbour",
4154 "field": "status",
4155 "rel_type": "PART_OF",
4156 "section": "body",
4157 "severity": "warn",
4158 },
4159 {
4160 "kind": "status_propagation",
4161 "field": "status",
4162 "value": "checked",
4163 "rel_type": "PART_OF",
4164 "direction": "incoming",
4165 "severity": "warn",
4166 },
4167 ]);
4168
4169 let full = build_schema_payload(
4170 &schema,
4171 vec![],
4172 SchemaVerbosity::Full,
4173 OriginClass::FirstParty,
4174 );
4175 let t = &full["types"].as_array().unwrap()[0];
4176 assert_eq!(t["constraints"], expected_constraints);
4177 assert_eq!(t["required_outgoing"][0]["severity"], "block");
4178
4179 let lite = build_schema_payload(
4180 &schema,
4181 vec![],
4182 SchemaVerbosity::Lite,
4183 OriginClass::FirstParty,
4184 );
4185 let ts = &lite["types_summary"].as_array().unwrap()[0];
4186 assert_eq!(ts["constraints"], expected_constraints);
4187 assert_eq!(ts["required_outgoing"][0]["severity"], "block");
4188
4189 let fmt_manifest = r#"name: formatted
4192version: 1.0.0
4193description: format render fixture
4194when_to_use: render tests
4195types:
4196 - plan
4197relationships:
4198 mode: strict
4199 definitions:
4200 - name: PART_OF
4201 description: hier
4202 default_weight: 1.0
4203 - name: _default
4204 description: fallback
4205 default_weight: 1.0
4206community:
4207 resolution: 1.0
4208 seed: 42
4209"#;
4210 let fmt_type = r#"name: plan
4211description: t
4212when_to_use: tests
4213sections:
4214 - key: body
4215 heading: Body
4216 required: true
4217 search_weight: 10.0
4218 catch_all: true
4219 write_rules: []
4220 - key: meilensteine
4221 heading: Meilensteine
4222 required: false
4223 search_weight: 5.0
4224 catch_all: false
4225 write_rules: []
4226 content: "(heading(3) list(bullet))+"
4227 item_pattern: '\*\*(?<name>[^*]+)\*\*'
4228 example: |
4229 ### Phase 1
4230 - **Kickoff**
4231 format_severity: warn
4232 - key: tabelle
4233 heading: Tabelle
4234 required: false
4235 search_weight: 5.0
4236 catch_all: false
4237 write_rules: []
4238 content: "table"
4239 table:
4240 columns: [Name, Datum]
4241 column_patterns:
4242 Datum: '\d{4}-\d{2}-\d{2}'
4243 - key: belege
4244 heading: Belege
4245 required: false
4246 search_weight: 5.0
4247 catch_all: false
4248 write_rules: []
4249 content: "paragraph+"
4250 item_pattern: '(?<quelle>\S[^|]*?) \| (?<aussage>.+)'
4251metadata_fields: []
4252title_weight: 100.0
4253text_fields:
4254 - body
4255hierarchy_relationship: PART_OF
4256no_self_loop_relationships: []
4257updatable_fields:
4258 - title
4259 - body
4260health_required_fields:
4261 - body
4262staleness_threshold_days: 90
4263write_rules: []
4264"#;
4265 let fmt_schema = Arc::new(
4266 memstead_schema::loader::load_schema_from_memory(
4267 fmt_manifest,
4268 &[("plan".to_string(), fmt_type.to_string())],
4269 )
4270 .expect("format fixture loads"),
4271 );
4272 for verbosity in [SchemaVerbosity::Full, SchemaVerbosity::Lite] {
4273 let payload =
4274 build_schema_payload(&fmt_schema, vec![], verbosity, OriginClass::FirstParty);
4275 let sections_key = match verbosity {
4276 SchemaVerbosity::Full => &payload["types"][0]["sections"],
4277 SchemaVerbosity::Lite => &payload["types_summary"][0]["sections"],
4278 };
4279 let secs = sections_key.as_array().unwrap();
4280 let meilensteine = secs
4281 .iter()
4282 .find(|s| s["key"] == "meilensteine")
4283 .expect("declared section present");
4284 assert_eq!(
4285 meilensteine["content"], "(heading(3) list(bullet))+",
4286 "{verbosity:?} carries content"
4287 );
4288 assert!(
4289 meilensteine["item_pattern"]
4290 .as_str()
4291 .unwrap()
4292 .contains("name")
4293 );
4294 assert!(
4295 meilensteine["example"]
4296 .as_str()
4297 .unwrap()
4298 .contains("Kickoff")
4299 );
4300 assert_eq!(meilensteine["format_severity"], "warn");
4301 let tabelle = secs.iter().find(|s| s["key"] == "tabelle").unwrap();
4302 assert_eq!(tabelle["format_severity"], "block", "default renders");
4303 assert_eq!(tabelle["table"]["columns"][0], "Name");
4304 assert!(
4305 tabelle["table"]["column_patterns"]["Datum"]
4306 .as_str()
4307 .is_some()
4308 );
4309 let belege = secs.iter().find(|s| s["key"] == "belege").unwrap();
4310 assert_eq!(belege["content"], "paragraph+");
4311 assert!(belege["item_pattern"].as_str().unwrap().contains("quelle"));
4312 let body = secs.iter().find(|s| s["key"] == "body").unwrap();
4313 assert!(
4314 body.get("content").is_none() && body.get("format_severity").is_none(),
4315 "undeclared section keeps its pre-plan shape"
4316 );
4317 }
4318
4319 let plain_full = build_schema_payload(
4322 &software_schema(),
4323 vec![],
4324 SchemaVerbosity::Full,
4325 OriginClass::FirstParty,
4326 );
4327 let pt = &plain_full["types"].as_array().unwrap()[0];
4328 assert_eq!(pt["constraints"], serde_json::json!([]));
4329 let plain_lite = build_schema_payload(
4330 &software_schema(),
4331 vec![],
4332 SchemaVerbosity::Lite,
4333 OriginClass::FirstParty,
4334 );
4335 let pts = &plain_lite["types_summary"].as_array().unwrap()[0];
4336 assert_eq!(pts["constraints"], serde_json::json!([]));
4337 }
4338
4339 #[test]
4349 fn third_party_origin_forces_structural_only_even_under_full() {
4350 let schema = software_schema();
4351 let full_requested = build_schema_payload(
4352 &schema,
4353 vec!["v".into()],
4354 SchemaVerbosity::Full,
4355 OriginClass::ThirdParty,
4356 );
4357
4358 assert_eq!(full_requested["origin"], "third-party");
4360
4361 assert!(
4364 full_requested.get("types").is_none(),
4365 "third-party omits the rich `types` array even under full"
4366 );
4367 assert!(
4368 full_requested.get("relationships").is_none(),
4369 "third-party omits the rich `relationships` array even under full"
4370 );
4371 assert!(
4372 full_requested["types_summary"].is_array(),
4373 "third-party serves the structural `types_summary` skeleton"
4374 );
4375 assert!(
4376 full_requested["relationships_summary"].is_array(),
4377 "third-party serves the structural `relationships_summary` skeleton"
4378 );
4379
4380 assert!(
4382 full_requested.get("description").is_none(),
4383 "third-party drops schema description prose"
4384 );
4385 assert!(
4386 full_requested.get("when_to_use").is_none(),
4387 "third-party drops schema when_to_use prose"
4388 );
4389 assert!(
4390 full_requested.get("default_writing_guidance").is_none(),
4391 "third-party drops default_writing_guidance prose"
4392 );
4393
4394 for t in full_requested["types_summary"].as_array().unwrap() {
4396 assert!(
4397 t.get("system_context").is_none(),
4398 "third-party drops system_context"
4399 );
4400 assert!(
4401 t.get("writing_guidance").is_none(),
4402 "third-party drops writing_guidance"
4403 );
4404 assert!(
4405 t.get("description").is_none(),
4406 "third-party drops type description"
4407 );
4408 for s in t["sections"].as_array().unwrap() {
4409 assert!(
4410 s.get("write_rules").is_none(),
4411 "third-party drops section write_rules"
4412 );
4413 }
4414 }
4415 for r in full_requested["relationships_summary"].as_array().unwrap() {
4417 assert!(
4418 r.get("description").is_none(),
4419 "third-party drops rel description"
4420 );
4421 assert!(
4422 r.get("when_to_use").is_none(),
4423 "third-party drops rel when_to_use"
4424 );
4425 }
4426
4427 let lite_requested = build_schema_payload(
4431 &schema,
4432 vec!["v".into()],
4433 SchemaVerbosity::Lite,
4434 OriginClass::ThirdParty,
4435 );
4436 assert_eq!(
4437 full_requested, lite_requested,
4438 "third-party full must collapse to the lite skeleton"
4439 );
4440 }
4441
4442 #[test]
4443 fn full_payload_carries_the_rich_arrays_and_prose() {
4444 let schema = software_schema();
4445 let full = build_schema_payload(
4446 &schema,
4447 vec!["v".into()],
4448 SchemaVerbosity::Full,
4449 OriginClass::FirstParty,
4450 );
4451
4452 assert!(full["types"].is_array(), "full has `types`");
4454 assert!(full["relationships"].is_array(), "full has `relationships`");
4455 assert!(
4456 full.get("types_summary").is_none(),
4457 "full omits `types_summary`"
4458 );
4459 assert!(
4460 full.get("relationships_summary").is_none(),
4461 "full omits `relationships_summary`"
4462 );
4463 assert!(
4464 full["description"].is_string(),
4465 "full keeps schema description"
4466 );
4467 assert!(
4468 full["when_to_use"].is_string(),
4469 "full keeps schema when_to_use"
4470 );
4471 assert_eq!(full["alias_target_rel_type"], "REFERENCES");
4472
4473 let t = &full["types"].as_array().unwrap()[0];
4475 assert!(t["description"].is_string());
4476 assert!(t.get("writing_guidance").is_some());
4477 assert!(t.get("system_context").is_some());
4478 let r = &full["relationships"].as_array().unwrap()[0];
4480 assert!(r["description"].is_string());
4481 assert!(r.get("when_to_use").is_some());
4482 assert!(r.get("default_weight").is_some());
4483 }
4484
4485 #[test]
4494 fn required_outgoing_reported_with_cardinality_at_both_levels() {
4495 let reg = memstead_schema::SchemaRegistry::builtin();
4496 let project = reg
4497 .get("project", &semver::Version::new(0, 2, 0))
4498 .expect("project is a built-in");
4499
4500 for verbosity in [SchemaVerbosity::Full, SchemaVerbosity::Lite] {
4501 let payload =
4502 build_schema_payload(&project, vec![], verbosity, OriginClass::FirstParty);
4503 let types_key = if verbosity == SchemaVerbosity::Full {
4504 "types"
4505 } else {
4506 "types_summary"
4507 };
4508 let types = payload[types_key].as_array().expect("types array");
4509
4510 let mut saw_evidence = false;
4511 let mut saw_memo = false;
4512 for t in types {
4513 let ro = t
4514 .get("required_outgoing")
4515 .unwrap_or_else(|| panic!("type {} omits required_outgoing", t["name"]))
4516 .as_array()
4517 .expect("required_outgoing is an array for every type");
4518 if t["name"] == "evidence" {
4519 saw_evidence = true;
4520 assert_eq!(ro.len(), 1, "evidence declares one block");
4521 assert_eq!(
4522 ro[0]["relationships"],
4523 serde_json::json!(["STRENGTHENS", "WEAKENS", "VALIDATES", "CONTRADICTS"]),
4524 "relationship alternatives in declaration order"
4525 );
4526 assert_eq!(
4527 ro[0]["cardinality"], "at_least_one",
4528 "cardinality rendered as declared — the open upper bound \
4529 stays open, never a finite number"
4530 );
4531 } else if t["name"] == "memo" {
4532 saw_memo = true;
4535 assert!(ro.is_empty(), "memo declares no blocks → empty list");
4536 }
4537 }
4538 assert!(saw_evidence, "project schema carries the evidence type");
4539 assert!(saw_memo, "project schema carries the memo type");
4540
4541 let note = payload["no_self_loop_relationships_effect"]
4544 .as_str()
4545 .expect("effect note present at both verbosity levels");
4546 assert!(note.contains("self-loop"), "names the actual effect");
4547 assert!(
4548 !note.contains("propagates impact") || note.contains("does not propagate"),
4549 "claims no propagation behaviour beyond the self-loop refusal"
4550 );
4551 assert!(
4552 note.contains("status_propagation"),
4553 "deprecation pointer names the real propagation declaration"
4554 );
4555 }
4556 }
4557
4558 #[test]
4564 fn conditional_required_outgoing_trigger_visible_at_both_levels() {
4565 let manifest = r#"name: condro-render
4566version: 0.1.0
4567description: conditional required_outgoing render fixture
4568when_to_use: tests
4569types:
4570 - task
4571relationships:
4572 mode: strict
4573 definitions:
4574 - name: PART_OF
4575 description: hier
4576 default_weight: 3.0
4577 - name: _default
4578 description: fallback
4579 default_weight: 1.0
4580community:
4581 resolution: 1.0
4582 seed: 42
4583"#;
4584 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";
4585 let schema = Arc::new(
4586 memstead_schema::load_schema_from_memory(
4587 manifest,
4588 &[("task".to_string(), task_yaml.to_string())],
4589 )
4590 .expect("render fixture schema must parse"),
4591 );
4592
4593 for verbosity in [SchemaVerbosity::Full, SchemaVerbosity::Lite] {
4594 let payload = build_schema_payload(&schema, vec![], verbosity, OriginClass::FirstParty);
4595 let types_key = if verbosity == SchemaVerbosity::Full {
4596 "types"
4597 } else {
4598 "types_summary"
4599 };
4600 let task = &payload[types_key].as_array().expect("types array")[0];
4601 let ro = task["required_outgoing"].as_array().expect("blocks array");
4602 assert_eq!(ro.len(), 2);
4603 assert!(
4604 ro[0].get("when_field").is_none() && ro[0].get("when_value").is_none(),
4605 "unconditional block carries no when_* keys: {:?}",
4606 ro[0]
4607 );
4608 assert_eq!(ro[1]["when_field"], "status");
4609 assert_eq!(ro[1]["when_value"], "checked");
4610 }
4611 }
4612
4613 #[test]
4619 fn acyclic_sets_and_propagation_rel_types_visible_at_both_levels() {
4620 let manifest = r#"name: relsets-render
4621version: 0.1.0
4622description: relation-set render fixture
4623when_to_use: tests
4624types:
4625 - claim
4626relationships:
4627 mode: strict
4628 acyclic_sets:
4629 - [GROUNDS, CONCLUDES]
4630 definitions:
4631 - name: GROUNDS
4632 description: g
4633 default_weight: 3.0
4634 - name: CONCLUDES
4635 description: c
4636 default_weight: 3.0
4637 - name: PART_OF
4638 description: hier
4639 default_weight: 1.0
4640 - name: _default
4641 description: fallback
4642 default_weight: 1.0
4643community:
4644 resolution: 1.0
4645 seed: 42
4646"#;
4647 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";
4648 let schema = Arc::new(
4649 memstead_schema::load_schema_from_memory(
4650 manifest,
4651 &[("claim".to_string(), claim.to_string())],
4652 )
4653 .expect("render fixture schema must parse"),
4654 );
4655
4656 for verbosity in [SchemaVerbosity::Full, SchemaVerbosity::Lite] {
4657 let payload = build_schema_payload(&schema, vec![], verbosity, OriginClass::FirstParty);
4658 assert_eq!(
4659 payload["acyclic_sets"],
4660 serde_json::json!([["GROUNDS", "CONCLUDES"]]),
4661 "acyclic_sets present at {verbosity:?}"
4662 );
4663 let types_key = if verbosity == SchemaVerbosity::Full {
4664 "types"
4665 } else {
4666 "types_summary"
4667 };
4668 let claim = &payload[types_key].as_array().expect("types array")[0];
4669 let constraints = claim["constraints"].as_array().expect("constraints array");
4670 assert_eq!(
4671 constraints[0]["rel_types"],
4672 serde_json::json!(["GROUNDS", "CONCLUDES"])
4673 );
4674 assert!(
4675 constraints[0].get("rel_type").is_none(),
4676 "set declaration carries no single-name key: {:?}",
4677 constraints[0]
4678 );
4679 assert_eq!(constraints[1]["rel_type"], "PART_OF");
4680 assert!(
4681 constraints[1].get("rel_types").is_none(),
4682 "single-name declaration stays byte-identical: {:?}",
4683 constraints[1]
4684 );
4685 }
4686
4687 let plain = software_schema();
4689 for verbosity in [SchemaVerbosity::Full, SchemaVerbosity::Lite] {
4690 let payload = build_schema_payload(&plain, vec![], verbosity, OriginClass::FirstParty);
4691 assert!(
4692 payload.get("acyclic_sets").is_none(),
4693 "undeclared schema carries no acyclic_sets key"
4694 );
4695 }
4696 }
4697
4698 #[test]
4702 fn labelling_declaration_visible_at_both_levels_and_absent_when_undeclared() {
4703 let manifest = r#"name: labelling-render
4704version: 0.1.0
4705description: labelling render fixture
4706when_to_use: tests
4707types:
4708 - claim
4709relationships:
4710 mode: strict
4711 labelling:
4712 attack: [REBUTS]
4713 support:
4714 relationships: [GROUNDS]
4715 direction: out
4716 terminal_types: [claim]
4717 definitions:
4718 - name: REBUTS
4719 description: attack
4720 default_weight: 3.0
4721 - name: GROUNDS
4722 description: support
4723 default_weight: 3.0
4724 - name: PART_OF
4725 description: hier
4726 default_weight: 1.0
4727 - name: _default
4728 description: fallback
4729 default_weight: 1.0
4730community:
4731 resolution: 1.0
4732 seed: 42
4733"#;
4734 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";
4735 let schema = Arc::new(
4736 memstead_schema::load_schema_from_memory(
4737 manifest,
4738 &[("claim".to_string(), claim.to_string())],
4739 )
4740 .expect("render fixture schema must parse"),
4741 );
4742
4743 for verbosity in [SchemaVerbosity::Full, SchemaVerbosity::Lite] {
4744 let payload = build_schema_payload(&schema, vec![], verbosity, OriginClass::FirstParty);
4745 assert_eq!(
4746 payload["labelling"]["attack"],
4747 serde_json::json!(["REBUTS"]),
4748 "attack set present at {verbosity:?}"
4749 );
4750 assert_eq!(
4751 payload["labelling"]["support"]["relationships"],
4752 serde_json::json!(["GROUNDS"])
4753 );
4754 assert_eq!(payload["labelling"]["support"]["direction"], "out");
4755 }
4756
4757 let plain = software_schema();
4758 for verbosity in [SchemaVerbosity::Full, SchemaVerbosity::Lite] {
4759 let payload = build_schema_payload(&plain, vec![], verbosity, OriginClass::FirstParty);
4760 assert!(
4761 payload.get("labelling").is_none(),
4762 "undeclared schema carries no labelling key"
4763 );
4764 }
4765 }
4766
4767 #[test]
4771 fn signal_declarations_visible_at_both_levels_and_absent_when_undeclared() {
4772 let manifest = r#"name: signals-render
4773version: 0.1.0
4774description: signal render fixture
4775when_to_use: tests
4776types:
4777 - claim
4778 - objection
4779relationships:
4780 mode: strict
4781 definitions:
4782 - name: REBUTS
4783 description: r
4784 default_weight: 3.0
4785 - name: PART_OF
4786 description: hier
4787 default_weight: 1.0
4788 - name: _default
4789 description: fallback
4790 default_weight: 1.0
4791community:
4792 resolution: 1.0
4793 seed: 42
4794"#;
4795 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";
4796 let claim = format!(
4797 "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"
4798 );
4799 let objection = format!(
4800 "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}"
4801 );
4802 let schema = Arc::new(
4803 memstead_schema::load_schema_from_memory(
4804 manifest,
4805 &[
4806 ("claim".to_string(), claim),
4807 ("objection".to_string(), objection),
4808 ],
4809 )
4810 .expect("render fixture schema must parse"),
4811 );
4812
4813 for verbosity in [SchemaVerbosity::Full, SchemaVerbosity::Lite] {
4814 let payload = build_schema_payload(&schema, vec![], verbosity, OriginClass::FirstParty);
4815 let types_key = if verbosity == SchemaVerbosity::Full {
4816 "types"
4817 } else {
4818 "types_summary"
4819 };
4820 let types = payload[types_key].as_array().expect("types array");
4821 let claim = types
4822 .iter()
4823 .find(|t| t["name"] == "claim")
4824 .expect("claim type present");
4825 let sigs = claim["signals"].as_array().expect("signals array");
4826 assert_eq!(sigs[0]["name"], "attack_load");
4827 assert_eq!(sigs[0]["kind"], "edge_load");
4828 assert_eq!(sigs[0]["direction"], "in");
4829 assert_eq!(sigs[0]["thresholds"][1]["at_least"], 3);
4830 assert_eq!(sigs[0]["thresholds"][1]["level"], "warn");
4831 let objection = types
4832 .iter()
4833 .find(|t| t["name"] == "objection")
4834 .expect("objection type present");
4835 assert!(
4836 objection.get("signals").is_none(),
4837 "undeclared type carries no signals key"
4838 );
4839 }
4840 }
4841
4842 #[test]
4848 fn must_reach_visible_at_both_levels_and_absent_when_undeclared() {
4849 let manifest = r#"name: mustreach-render
4850version: 0.1.0
4851description: must_reach render fixture
4852when_to_use: tests
4853types:
4854 - claim
4855 - evidence
4856relationships:
4857 mode: strict
4858 definitions:
4859 - name: GROUNDS
4860 description: g
4861 default_weight: 3.0
4862 - name: PART_OF
4863 description: hier
4864 default_weight: 1.0
4865 - name: _default
4866 description: fallback
4867 default_weight: 1.0
4868community:
4869 resolution: 1.0
4870 seed: 42
4871"#;
4872 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";
4873 let claim = format!(
4874 "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"
4875 );
4876 let evidence = format!("name: evidence\ndescription: t\nwhen_to_use: tests\n{body}");
4877 let schema = Arc::new(
4878 memstead_schema::load_schema_from_memory(
4879 manifest,
4880 &[
4881 ("claim".to_string(), claim),
4882 ("evidence".to_string(), evidence),
4883 ],
4884 )
4885 .expect("render fixture schema must parse"),
4886 );
4887
4888 for verbosity in [SchemaVerbosity::Full, SchemaVerbosity::Lite] {
4889 let payload = build_schema_payload(&schema, vec![], verbosity, OriginClass::FirstParty);
4890 let types_key = if verbosity == SchemaVerbosity::Full {
4891 "types"
4892 } else {
4893 "types_summary"
4894 };
4895 let types = payload[types_key].as_array().expect("types array");
4896 let claim = types
4897 .iter()
4898 .find(|t| t["name"] == "claim")
4899 .expect("claim type present");
4900 let mr = claim["must_reach"].as_array().expect("obligations array");
4901 assert_eq!(mr.len(), 1);
4902 assert_eq!(mr[0]["relationships"], serde_json::json!(["GROUNDS"]));
4903 assert_eq!(mr[0]["direction"], "out");
4904 assert_eq!(mr[0]["terminal_types"], serde_json::json!(["evidence"]));
4905 assert_eq!(mr[0]["max_depth"], 12);
4906 let evidence = types
4907 .iter()
4908 .find(|t| t["name"] == "evidence")
4909 .expect("evidence type present");
4910 assert!(
4911 evidence.get("must_reach").is_none(),
4912 "undeclared type carries no must_reach key: {evidence:?}"
4913 );
4914 }
4915 }
4916
4917 #[test]
4918 fn lite_payload_is_the_structural_skeleton_without_prose() {
4919 let schema = software_schema();
4920 let lite = build_schema_payload(
4921 &schema,
4922 vec!["v".into()],
4923 SchemaVerbosity::Lite,
4924 OriginClass::FirstParty,
4925 );
4926
4927 let types = lite["types_summary"]
4929 .as_array()
4930 .expect("lite has `types_summary`");
4931 let rels = lite["relationships_summary"]
4932 .as_array()
4933 .expect("lite has `relationships_summary`");
4934 assert!(lite.get("types").is_none(), "lite omits rich `types`");
4935 assert!(
4936 lite.get("relationships").is_none(),
4937 "lite omits rich `relationships`"
4938 );
4939
4940 assert_eq!(lite["alias_target_rel_type"], "REFERENCES");
4943
4944 assert!(
4946 lite.get("description").is_none(),
4947 "lite drops schema description"
4948 );
4949 assert!(
4950 lite.get("when_to_use").is_none(),
4951 "lite drops schema when_to_use"
4952 );
4953 assert!(
4954 lite.get("default_writing_guidance").is_none(),
4955 "lite drops default_writing_guidance"
4956 );
4957
4958 for t in types {
4961 assert!(t["name"].is_string());
4962 let sections = t["sections"].as_array().expect("lite type has sections");
4963 for s in sections {
4964 assert!(s["key"].is_string(), "section carries its key");
4965 assert!(s["required"].is_boolean(), "section carries required flag");
4966 assert!(
4967 s.get("write_rules").is_none(),
4968 "lite section drops write_rules prose"
4969 );
4970 assert!(s.get("heading").is_none(), "lite section drops heading");
4971 }
4972 assert!(
4973 t.get("description").is_none(),
4974 "lite type drops description"
4975 );
4976 assert!(
4977 t.get("writing_guidance").is_none(),
4978 "lite type drops writing_guidance"
4979 );
4980 assert!(
4981 t.get("system_context").is_none(),
4982 "lite type drops system_context"
4983 );
4984 assert!(
4988 t.get("no_self_loop_relationships").is_some(),
4989 "lite type keeps no_self_loop_relationships"
4990 );
4991 assert!(
4995 t.get("required_outgoing").is_some_and(|v| v.is_array()),
4996 "lite type keeps required_outgoing as an array"
4997 );
4998 if let Some(fields) = t["fields"].as_array() {
5000 for f in fields {
5001 assert!(f["name"].is_string());
5002 assert!(f["required"].is_boolean());
5003 assert!(
5004 f.get("description").is_none(),
5005 "lite field drops description"
5006 );
5007 }
5008 }
5009 }
5010
5011 for r in rels {
5014 assert!(r["name"].is_string());
5015 assert!(
5016 r.get("allowed_sources").is_some(),
5017 "lite rel has allowed_sources"
5018 );
5019 assert!(
5020 r.get("allowed_targets").is_some(),
5021 "lite rel has allowed_targets"
5022 );
5023 assert!(
5024 r.get("manual_authoring").is_some(),
5025 "lite rel keeps manual_authoring"
5026 );
5027 assert!(r.get("acyclic").is_some(), "lite rel keeps acyclic");
5028 assert!(
5029 r.get("per_edge_description").is_some(),
5030 "lite rel keeps per_edge_description"
5031 );
5032 assert!(r.get("description").is_none(), "lite rel drops description");
5033 assert!(r.get("when_to_use").is_none(), "lite rel drops when_to_use");
5034 assert!(
5035 r.get("default_weight").is_none(),
5036 "lite rel drops default_weight"
5037 );
5038 }
5039 }
5040
5041 #[test]
5042 fn lite_is_measurably_smaller_than_full() {
5043 let schema = software_schema();
5044 let full = build_schema_payload(
5045 &schema,
5046 vec!["v".into()],
5047 SchemaVerbosity::Full,
5048 OriginClass::FirstParty,
5049 );
5050 let lite = build_schema_payload(
5051 &schema,
5052 vec!["v".into()],
5053 SchemaVerbosity::Lite,
5054 OriginClass::FirstParty,
5055 );
5056 let full_len = serde_json::to_string(&full).unwrap().len();
5057 let lite_len = serde_json::to_string(&lite).unwrap().len();
5058 assert!(
5059 lite_len * 2 < full_len,
5060 "lite ({lite_len} B) must be well under half of full ({full_len} B)"
5061 );
5062 }
5063
5064 #[test]
5065 fn lite_full_carry_the_same_type_and_rel_names() {
5066 let schema = software_schema();
5069 let full = build_schema_payload(
5070 &schema,
5071 vec!["v".into()],
5072 SchemaVerbosity::Full,
5073 OriginClass::FirstParty,
5074 );
5075 let lite = build_schema_payload(
5076 &schema,
5077 vec!["v".into()],
5078 SchemaVerbosity::Lite,
5079 OriginClass::FirstParty,
5080 );
5081
5082 let names = |arr: &serde_json::Value| -> Vec<String> {
5083 arr.as_array()
5084 .unwrap()
5085 .iter()
5086 .map(|v| v["name"].as_str().unwrap().to_string())
5087 .collect()
5088 };
5089 assert_eq!(names(&full["types"]), names(&lite["types_summary"]));
5090 assert_eq!(
5091 names(&full["relationships"]),
5092 names(&lite["relationships_summary"])
5093 );
5094 }
5095
5096 #[test]
5101 fn swallowed_sections_carry_a_marker_on_the_plain_read() {
5102 let mut e = test_entity();
5103 e.sections.insert(
5104 "identity".to_string(),
5105 "intro\n\n```rust\nfn main() {}".to_string(),
5106 );
5107 e.sections.insert("purpose".to_string(), String::new());
5108 let env = build_entity_envelope(
5109 &e,
5110 10,
5111 None,
5112 None,
5113 None,
5114 OriginClass::FirstParty,
5115 &[],
5116 None,
5117 None,
5118 None,
5119 );
5120 let marker = &env["_unread_sections"];
5121 assert_eq!(marker["reason"], "UNTERMINATED_FENCE");
5122 assert_eq!(marker["absorbed_into"], "identity");
5123 assert_eq!(marker["sections"], serde_json::json!(["purpose"]));
5124 }
5125
5126 #[test]
5127 fn an_ordinary_entity_carries_no_unread_marker() {
5128 for body in ["plain prose", "```rust\nfn main() {}\n```"] {
5132 let mut e = test_entity();
5133 e.sections.insert("identity".to_string(), body.to_string());
5134 e.sections.insert("purpose".to_string(), String::new());
5135 let env = build_entity_envelope(
5136 &e,
5137 10,
5138 None,
5139 None,
5140 None,
5141 OriginClass::FirstParty,
5142 &[],
5143 None,
5144 None,
5145 None,
5146 );
5147 assert!(
5148 env.get("_unread_sections").is_none(),
5149 "body {body:?} produced a marker"
5150 );
5151 }
5152 }
5153}