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 {
145 lines.push(format!("{key}: {value}"));
146 }
147 lines.push("---".to_string());
148 lines.push(String::new());
149
150 lines.push(body_text);
151
152 if let Some(sigs) = signals
155 && !sigs.is_empty()
156 {
157 lines.push(String::new());
158 lines.push("## Signals".to_string());
159 lines.push(String::new());
160 for s in sigs {
161 if s.contributors.is_empty() {
162 lines.push(format!(
163 "- **{}**: {} ({})",
164 s.name,
165 s.value,
166 s.level_wire()
167 ));
168 } else {
169 let ids: Vec<String> = s.contributors.iter().map(|c| c.to_string()).collect();
170 lines.push(format!(
171 "- **{}**: {} ({}) — {}",
172 s.name,
173 s.value,
174 s.level_wire(),
175 ids.join(", ")
176 ));
177 }
178 }
179 }
180 if let Some(lab) = labelling {
185 lines.push(String::new());
186 lines.push("## Labelling".to_string());
187 lines.push(String::new());
188 lines.push(format!("- label: {}", lab.label.wire()));
189 if !lab.defeated_by.is_empty() {
190 lines.push(format!("- defeated_by: {}", lab.defeated_by.join(", ")));
191 }
192 if !lab.undecided_by.is_empty() {
193 lines.push(format!("- undecided_by: {}", lab.undecided_by.join(", ")));
194 }
195 if let Some(shape) = &lab.shape {
196 let share = match shape.terminal_share {
197 Some(s) => format!("{s:.2}"),
198 None => "null".to_string(),
199 };
200 lines.push(format!(
201 "- shape: depth {}, branching {:.2}, terminal_share {}, defeated_in_support {}, undecided_in_support {}",
202 shape.depth,
203 shape.branching,
204 share,
205 shape.defeated_in_support,
206 shape.undecided_in_support,
207 ));
208 }
209 }
210 lines.join("\n")
211}
212
213pub fn rendered_body_tokens(entity: &Entity, sections_filter: Option<&[String]>) -> usize {
220 estimate_tokens(&render_entity_body(entity, sections_filter))
221}
222
223fn render_entity_body(entity: &Entity, sections_filter: Option<&[String]>) -> String {
230 let mut body = Vec::new();
231
232 body.push(format!("# {}", entity.title));
233 body.push(String::new());
234
235 let type_def = lookup_builtin_type(&entity.entity_type);
243
244 for (key, content) in &entity.sections {
245 if let Some(filter) = sections_filter
246 && !filter.iter().any(|f| f == key)
247 {
248 continue;
249 }
250 let heading = section_heading_for(type_def.as_deref(), key);
251 body.push(format!("## {heading}"));
252 body.push(String::new());
253 body.push(content.trim().to_string());
254 body.push(String::new());
255 }
256
257 if !entity.relationships.is_empty()
258 && sections_filter.is_none_or(|f| f.iter().any(|s| s == "relationships"))
259 {
260 body.push("## Relationships".to_string());
261 body.push(String::new());
262 for rel in &entity.relationships {
263 match rel
267 .description
268 .as_deref()
269 .map(str::trim)
270 .filter(|s| !s.is_empty())
271 {
272 Some(text) => body.push(format!(
273 "- **{}**: [[{}]] \u{2014} {text}",
274 rel.rel_type, rel.target
275 )),
276 None => body.push(format!("- **{}**: [[{}]]", rel.rel_type, rel.target)),
277 }
278 }
279 body.push(String::new());
280 }
281
282 body.join("\n")
283}
284
285pub fn render_relations_markdown(
290 entity_id: &str,
291 outgoing: &[Edge],
292 incoming: &[InEdge],
293) -> String {
294 let mut lines = Vec::new();
295 lines.push(String::new());
296 lines.push("## Relations".to_string());
297 lines.push(String::new());
298
299 if outgoing.is_empty() && incoming.is_empty() {
300 lines.push(format!("(no relations for {entity_id})"));
301 lines.push(String::new());
302 return lines.join("\n");
303 }
304
305 if !outgoing.is_empty() {
306 lines.push("### Outgoing".to_string());
307 for e in outgoing {
308 lines.push(format!("- **{}** → [[{}]]", e.rel_type, e.target));
309 }
310 lines.push(String::new());
311 }
312
313 if !incoming.is_empty() {
314 lines.push("### Incoming".to_string());
315 for e in incoming {
316 lines.push(format!("- [[{}]] → **{}** → (this)", e.from, e.rel_type));
317 }
318 lines.push(String::new());
319 }
320
321 lines.join("\n")
322}
323
324pub fn render_relations_json(
327 entity_id: &str,
328 outgoing: &[Edge],
329 incoming: &[InEdge],
330) -> serde_json::Value {
331 let out: Vec<serde_json::Value> = outgoing
332 .iter()
333 .map(|e| {
334 serde_json::json!({
335 "rel_type": e.rel_type,
336 "target": e.target.to_string(),
337 "source": format!("{:?}", e.source).to_lowercase(),
338 })
339 })
340 .collect();
341
342 let inc: Vec<serde_json::Value> = incoming
343 .iter()
344 .map(|e| {
345 serde_json::json!({
346 "rel_type": e.rel_type,
347 "from": e.from.to_string(),
348 "source": format!("{:?}", e.source).to_lowercase(),
349 })
350 })
351 .collect();
352
353 serde_json::json!({
354 "entity": entity_id,
355 "outgoing": out,
356 "incoming": inc,
357 })
358}
359
360pub fn render_search_markdown(result: &SearchResult, offset: usize) -> String {
366 let mut lines = Vec::new();
367
368 lines.push("---".to_string());
369 lines.push(format!("_total: {}", result.total));
370 lines.push(format!("_returned: {}", result.returned));
371 lines.push(format!("_offset: {offset}"));
372 lines.push(format!("_total_tokens: {}", result.total_tokens));
373 lines.push("---".to_string());
374 lines.push(String::new());
375
376 if !result.warnings.is_empty() {
377 lines.push("## Filter warnings".to_string());
382 for w in &result.warnings {
383 lines.push(format!("- **{}**: {}", w.code(), w.message()));
384 }
385 lines.push(String::new());
386 }
387
388 if let Some(facets) = &result.facets
389 && let Some(block) = render_facets_block(facets)
390 {
391 lines.push(block);
392 }
393
394 for hit in &result.hits {
395 lines.push(format!(
396 "### {} — {} (_score: {:.1}, _tokens: {})",
397 hit.id, hit.title, hit.score, hit.tokens,
398 ));
399 lines.push(hit_summary_line(hit));
400 if let Some(line) = render_matched_terms_line(hit.matched_terms.as_ref()) {
401 lines.push(line);
402 }
403 if let Some(line) = render_score_breakdown_line(hit.score_breakdown.as_ref()) {
404 lines.push(line);
405 }
406 if let Some(line) = render_heading_paths_line(hit.matched_terms.as_ref()) {
407 lines.push(line);
408 }
409 if let Some(line) = render_expansion_line(hit.expansion.as_ref()) {
410 lines.push(line);
411 }
412 if let Some(snippet) = &hit.snippet {
413 lines.push(format!("> ...{snippet}..."));
414 }
415 lines.push(String::new());
416 }
417
418 lines.join("\n")
419}
420
421fn render_facets_block(facets: &Facets) -> Option<String> {
429 let blocks: Vec<(&str, String)> = [
430 ("by_type", &facets.by_type),
431 ("by_mem", &facets.by_mem),
432 ("by_level", &facets.by_level),
433 ("by_status", &facets.by_status),
434 ("by_confidence", &facets.by_confidence),
435 ("by_expansion", &facets.by_expansion),
436 ]
437 .into_iter()
438 .filter_map(|(name, bucket)| format_facet_bucket(bucket).map(|s| (name, s)))
439 .collect();
440
441 if blocks.is_empty() && facets.by_subsection.is_empty() {
442 return None;
443 }
444
445 let mut out = String::new();
446 out.push_str("## Facets\n");
447 for (name, body) in blocks {
448 out.push_str(&format!("- **{name}:** {body}\n"));
449 }
450 if !facets.by_subsection.is_empty() {
451 out.push_str("- **by_subsection:**\n");
452 for entry in &facets.by_subsection {
453 out.push_str(&format!(" - {}\n", format_subsection_facet(entry)));
454 }
455 }
456 Some(out)
457}
458
459fn format_facet_bucket(bucket: &HashMap<String, usize>) -> Option<String> {
460 if bucket.is_empty() {
461 return None;
462 }
463 let mut entries: Vec<(&String, &usize)> = bucket.iter().collect();
464 entries.sort_by(|a, b| b.1.cmp(a.1).then_with(|| a.0.cmp(b.0)));
465 Some(
466 entries
467 .iter()
468 .map(|(k, v)| format!("{k}={v}"))
469 .collect::<Vec<_>>()
470 .join(", "),
471 )
472}
473
474fn format_subsection_facet(entry: &SubsectionFacet) -> String {
475 let path = entry.path.join(" › ");
476 format!("`{path}`: {}", entry.count)
477}
478
479fn render_matched_terms_line(matched: Option<&HashMap<String, Vec<TermMatch>>>) -> Option<String> {
484 let matched = matched?;
485 if matched.is_empty() {
486 return None;
487 }
488 let mut terms: Vec<(&String, &Vec<TermMatch>)> = matched.iter().collect();
489 terms.sort_by(|a, b| a.0.cmp(b.0));
490 let groups: Vec<String> = terms
491 .iter()
492 .map(|(term, tms)| {
493 let mut field_counts: HashMap<&str, usize> = HashMap::new();
494 for tm in tms.iter() {
495 *field_counts.entry(tm.field.as_str()).or_insert(0) += 1;
496 }
497 let mut fields: Vec<(&&str, &usize)> = field_counts.iter().collect();
498 fields.sort_by(|a, b| a.0.cmp(b.0));
499 let inner: Vec<String> = fields.iter().map(|(f, n)| format!("{f}×{n}")).collect();
500 format!("`{term}` ({})", inner.join(", "))
501 })
502 .collect();
503 Some(format!("**Matched terms:** {}", groups.join(", ")))
504}
505
506fn render_score_breakdown_line(breakdown: Option<&ScoreBreakdown>) -> Option<String> {
511 let b = breakdown?;
512 let mut parts: Vec<String> = Vec::new();
513 parts.push(format!("bm25 {:.1}", b.bm25));
514 parts.push(format!("title {:.1}", b.title_boost));
515 let mut fields: Vec<(&String, &f32)> = b.field_weights.iter().collect();
516 fields.sort_by(|a, b| a.0.cmp(b.0));
517 for (k, v) in fields {
518 parts.push(format!("{k} {v:.1}"));
519 }
520 if let Some(decay) = b.expansion_decay {
521 parts.push(format!("expansion_decay ×{decay:.1}"));
522 }
523 Some(format!("**Score:** {}", parts.join(" + ")))
524}
525
526fn render_heading_paths_line(matched: Option<&HashMap<String, Vec<TermMatch>>>) -> Option<String> {
530 let matched = matched?;
531 let mut paths: Vec<Vec<String>> = Vec::new();
532 let mut term_keys: Vec<&String> = matched.keys().collect();
533 term_keys.sort();
534 for term in term_keys {
535 for tm in &matched[term] {
536 if let Some(path) = &tm.heading_path
537 && !path.is_empty()
538 && !paths.iter().any(|p| p == path)
539 {
540 paths.push(path.clone());
541 }
542 }
543 }
544 if paths.is_empty() {
545 return None;
546 }
547 let formatted: Vec<String> = paths.iter().map(|p| p.join(" › ")).collect();
548 Some(format!("**Heading path:** {}", formatted.join("; ")))
549}
550
551fn render_expansion_line(expansion: Option<&ExpansionInfo>) -> Option<String> {
555 let e = expansion?;
556 let dir = match e.via_direction {
557 crate::graph::query::TraversalDirection::Out => "out",
558 crate::graph::query::TraversalDirection::In => "in",
559 crate::graph::query::TraversalDirection::Both => "both",
562 };
563 Some(format!(
564 "**Expansion:** from `{}` via `{}` [{dir}] (depth {})",
565 e.of, e.via_edge, e.depth,
566 ))
567}
568
569pub fn render_list_markdown(result: &ListResult) -> String {
571 let mut lines = Vec::new();
572
573 lines.push("---".to_string());
574 lines.push(format!("_total: {}", result.total));
575 lines.push(format!("_returned: {}", result.returned));
576 lines.push(format!("_offset: {}", result.offset));
577 lines.push(format!("_total_tokens: {}", result.total_tokens));
578 lines.push("---".to_string());
579 lines.push(String::new());
580
581 if !result.warnings.is_empty() {
582 lines.push("## Filter warnings".to_string());
583 for w in &result.warnings {
584 lines.push(format!("- **{}**: {}", w.code(), w.message()));
585 }
586 lines.push(String::new());
587 }
588
589 for hit in &result.hits {
590 let meta = hit
591 .sections
592 .get("level")
593 .map(|l| format!("{l}, "))
594 .unwrap_or_default();
595 lines.push(format!(
596 "### {} — {} ({meta}_tokens: {})",
597 hit.id, hit.title, hit.tokens,
598 ));
599 lines.push(hit_summary_line(hit));
600 lines.push(String::new());
601 }
602
603 lines.join("\n")
604}
605
606pub fn render_community_context_section(result: &ContextResult, cluster_id: &str) -> String {
614 let mut lines = Vec::new();
615 lines.push(String::new());
616 lines.push("## Community Context".to_string());
617 lines.push(String::new());
618 lines.push(format!("**Cluster {cluster_id}**"));
619 lines.push(String::new());
620
621 if !result.neighbors.is_empty() {
622 lines.push("### Neighbors".to_string());
623 for n in &result.neighbors {
624 let dir = match n.direction {
625 Direction::Outgoing => "→",
626 Direction::Incoming => "←",
627 };
628 lines.push(format!(
629 "- {} —{}— **{}** ({})",
630 result.entity_id, dir, n.id, n.relationship,
631 ));
632 }
633 lines.push(String::new());
634 }
635
636 lines.join("\n")
637}
638
639pub fn render_context_markdown(result: &ContextResult, cluster_id: &str) -> String {
641 let mut lines = Vec::new();
642
643 lines.push("---".to_string());
644 lines.push(format!("_cluster_id: {cluster_id}"));
645 lines.push("---".to_string());
646 lines.push(String::new());
647 lines.push(format!("## Cluster {cluster_id}"));
648 lines.push(String::new());
649
650 lines.push("### Neighbors".to_string());
652 for n in &result.neighbors {
653 let dir = match n.direction {
654 Direction::Outgoing => "→",
655 Direction::Incoming => "←",
656 };
657 lines.push(format!(
658 "- {} —{}— **{}** ({})",
659 result.entity_id, dir, n.id, n.relationship,
660 ));
661 }
662 lines.push(String::new());
663
664 lines.join("\n")
665}
666
667pub fn render_overview_markdown(output: &LouvainOutput, store: &Store) -> String {
670 let mut lines = Vec::new();
671
672 let entity_count: usize = output.clusters.values().map(|c| c.entities.len()).sum();
673
674 lines.push("---".to_string());
675 lines.push(format!("_cluster_count: {}", output.count));
676 lines.push(format!("_entity_count: {entity_count}"));
677 let mod_str = if output.modularity == 0.0 {
679 "0".to_string()
680 } else {
681 format!("{:.4}", output.modularity)
682 };
683 lines.push(format!("_modularity: {mod_str}"));
684 lines.push("---".to_string());
685 lines.push(String::new());
686
687 let mut cluster_ids: Vec<&String> = output.clusters.keys().collect();
689 cluster_ids.sort();
690
691 for cluster_id in cluster_ids {
692 let info = &output.clusters[cluster_id];
693 let summary = generate_auto_summary(store, &info.entities);
694
695 lines.push(format!(
696 "## Cluster {cluster_id} ({} entities)",
697 info.entities.len(),
698 ));
699 if !summary.is_empty() {
700 lines.push(summary);
701 }
702 for entity_id in &info.entities {
703 lines.push(format!("- {entity_id}"));
704 }
705 lines.push(String::new());
706 }
707
708 lines.join("\n")
709}
710
711#[derive(Serialize)]
727pub struct SearchHitEnvelope<'a> {
728 #[serde(flatten)]
729 pub hit: &'a SearchHit,
730 pub summary_heading: String,
731 pub summary_value: String,
732}
733
734#[derive(Serialize)]
744pub struct SearchResultEnvelope<'a> {
745 #[serde(rename = "_total")]
746 pub total: usize,
747 #[serde(rename = "_returned")]
748 pub returned: usize,
749 #[serde(rename = "_offset")]
750 pub offset: usize,
751 #[serde(rename = "_total_tokens")]
755 pub total_tokens: usize,
756 pub hits: Vec<SearchHitEnvelope<'a>>,
757 #[serde(skip_serializing_if = "Option::is_none")]
762 pub facets: Option<&'a Facets>,
763 #[serde(skip_serializing_if = "Vec::is_empty")]
764 pub warnings: &'a Vec<crate::ops::WarningHint>,
765}
766
767#[derive(Serialize)]
773pub struct ListResultEnvelope<'a> {
774 #[serde(rename = "_total")]
775 pub total: usize,
776 #[serde(rename = "_returned")]
777 pub returned: usize,
778 #[serde(rename = "_offset")]
779 pub offset: usize,
780 #[serde(rename = "_total_tokens")]
781 pub total_tokens: usize,
782 pub hits: Vec<SearchHitEnvelope<'a>>,
783 #[serde(skip_serializing_if = "Vec::is_empty")]
784 pub warnings: &'a Vec<crate::ops::WarningHint>,
785}
786
787#[allow(clippy::too_many_arguments)] pub fn build_entity_envelope(
822 entity: &Entity,
823 rendered_body_tokens: usize,
824 full_tokens: Option<usize>,
825 sections_filter: Option<&[String]>,
826 schema_anchor: Option<&str>,
827 origin: OriginClass,
828 outgoing_edges: &[crate::store::Edge],
829 incoming_edges: Option<&[crate::store::InEdge]>,
830 signals: Option<&[crate::ops::signals::ComputedSignal]>,
831 labelling: Option<&crate::ops::labelling::LabellingView>,
832) -> serde_json::Value {
833 let mut envelope = serde_json::Map::new();
834 if let Some(sigs) = signals
839 && !sigs.is_empty()
840 {
841 envelope.insert(
842 "_signals".to_string(),
843 crate::ops::signals::signals_json(sigs),
844 );
845 }
846 if let Some(lab) = labelling {
851 envelope.insert("_labelling".to_string(), lab.to_json());
852 }
853 envelope.insert(
854 "_hash".to_string(),
855 serde_json::Value::String(entity.content_hash.clone()),
856 );
857 envelope.insert(
864 "origin".to_string(),
865 serde_json::Value::String(origin.as_wire().to_string()),
866 );
867 if let Some((absorbing, fence)) = entity.sections.iter().find_map(|(k, v)| {
880 crate::markdown::closing_fence_if_unterminated(v.trim()).map(|f| (k.clone(), f))
881 }) {
882 let unread: Vec<String> = entity
883 .sections
884 .iter()
885 .filter(|(k, v)| **k != absorbing && v.trim().is_empty())
886 .map(|(k, _)| k.clone())
887 .collect();
888 envelope.insert(
889 "_unread_sections".to_string(),
890 serde_json::json!({
891 "reason": "UNTERMINATED_FENCE",
892 "absorbed_into": absorbing,
893 "fence": fence,
894 "sections": unread,
895 "note": "these sections read as empty because an unterminated code fence in \
896 `absorbed_into` swallowed them: their content is inside that section's \
897 body. Repair through the engine by replacing that section; a write that \
898 does not is refused.",
899 }),
900 );
901 }
902 envelope.insert(
903 "id".to_string(),
904 serde_json::Value::String(entity.id.to_string()),
905 );
906 envelope.insert(
907 "mem".to_string(),
908 serde_json::Value::String(entity.mem.clone()),
909 );
910 envelope.insert(
917 "entity_type".to_string(),
918 serde_json::Value::String(entity.entity_type.clone()),
919 );
920 envelope.insert(
925 "title".to_string(),
926 serde_json::Value::String(entity.title.clone()),
927 );
928
929 let mut metadata = serde_json::Map::new();
945 for (key, value) in &entity.metadata {
946 if key.starts_with('_')
947 || crate::runtime_validator::READ_ONLY_METADATA_KEYS.contains(&key.as_str())
948 {
949 continue;
950 }
951 metadata.insert(
952 key.clone(),
953 serde_json::Value::String(value.to_frontmatter_string()),
954 );
955 }
956 envelope.insert("metadata".to_string(), serde_json::Value::Object(metadata));
957
958 envelope.insert(
959 "_tokens".to_string(),
960 serde_json::Value::Number(serde_json::Number::from(rendered_body_tokens)),
961 );
962 if let Some(t) = full_tokens {
963 envelope.insert(
970 "_tokens_unfiltered_body".to_string(),
971 serde_json::Value::Number(serde_json::Number::from(t)),
972 );
973 }
974 if let Some(s) = schema_anchor {
975 envelope.insert(
976 "_mem_schema".to_string(),
977 serde_json::Value::String(s.to_string()),
978 );
979 }
980
981 if let Some(kind) = &entity.stub_kind {
982 envelope.insert(
983 "_stub_kind".to_string(),
984 serde_json::to_value(kind).unwrap_or(serde_json::Value::Null),
985 );
986 }
987
988 let mut sections = serde_json::Map::new();
989 for (key, content) in &entity.sections {
990 if let Some(filter) = sections_filter
991 && !filter.iter().any(|f| f == key)
992 {
993 continue;
994 }
995 sections.insert(key.clone(), serde_json::Value::String(content.clone()));
996 }
997 envelope.insert("sections".to_string(), serde_json::Value::Object(sections));
998
999 let resolve_source = |rel: &crate::entity::Relationship| -> &'static str {
1010 outgoing_edges
1011 .iter()
1012 .find(|e| e.rel_type == rel.rel_type && e.target == rel.target)
1013 .map(|e| match e.source {
1014 crate::store::EdgeSource::BodyLink => "body_link",
1015 crate::store::EdgeSource::Hierarchy => "hierarchy",
1016 crate::store::EdgeSource::Explicit => "explicit",
1017 })
1018 .unwrap_or("explicit")
1019 };
1020 let mut relationships: Vec<serde_json::Value> = entity
1028 .relationships
1029 .iter()
1030 .map(|rel| {
1031 let mut obj = serde_json::Map::new();
1032 obj.insert(
1033 "rel_type".to_string(),
1034 serde_json::Value::String(rel.rel_type.clone()),
1035 );
1036 obj.insert(
1037 "target".to_string(),
1038 serde_json::Value::String(rel.target.to_string()),
1039 );
1040 obj.insert(
1041 "direction".to_string(),
1042 serde_json::Value::String("out".to_string()),
1043 );
1044 obj.insert(
1045 "source".to_string(),
1046 serde_json::Value::String(resolve_source(rel).to_string()),
1047 );
1048 if let Some(desc) = rel
1049 .description
1050 .as_deref()
1051 .map(str::trim)
1052 .filter(|s| !s.is_empty())
1053 {
1054 obj.insert(
1055 "description".to_string(),
1056 serde_json::Value::String(desc.to_string()),
1057 );
1058 }
1059 serde_json::Value::Object(obj)
1060 })
1061 .collect();
1062 if let Some(incoming) = incoming_edges {
1063 for e in incoming {
1064 let mut obj = serde_json::Map::new();
1065 obj.insert(
1066 "rel_type".to_string(),
1067 serde_json::Value::String(e.rel_type.clone()),
1068 );
1069 obj.insert(
1070 "from".to_string(),
1071 serde_json::Value::String(e.from.to_string()),
1072 );
1073 obj.insert(
1074 "direction".to_string(),
1075 serde_json::Value::String("in".to_string()),
1076 );
1077 obj.insert(
1078 "source".to_string(),
1079 serde_json::Value::String(
1080 match e.source {
1081 crate::store::EdgeSource::BodyLink => "body_link",
1082 crate::store::EdgeSource::Hierarchy => "hierarchy",
1083 crate::store::EdgeSource::Explicit => "explicit",
1084 }
1085 .to_string(),
1086 ),
1087 );
1088 relationships.push(serde_json::Value::Object(obj));
1089 }
1090 }
1091 envelope.insert(
1092 "relationships".to_string(),
1093 serde_json::Value::Array(relationships),
1094 );
1095
1096 serde_json::Value::Object(envelope)
1097}
1098
1099pub fn build_search_envelope<'a>(
1101 result: &'a SearchResult,
1102 offset: usize,
1103) -> SearchResultEnvelope<'a> {
1104 SearchResultEnvelope {
1105 total: result.total,
1106 returned: result.returned,
1107 offset,
1108 total_tokens: result.total_tokens,
1109 hits: result.hits.iter().map(build_hit_envelope).collect(),
1110 facets: result.facets.as_ref(),
1111 warnings: &result.warnings,
1112 }
1113}
1114
1115pub fn build_list_envelope(result: &ListResult) -> ListResultEnvelope<'_> {
1117 ListResultEnvelope {
1118 total: result.total,
1119 returned: result.returned,
1120 offset: result.offset,
1121 total_tokens: result.total_tokens,
1122 hits: result.hits.iter().map(build_hit_envelope).collect(),
1123 warnings: &result.warnings,
1124 }
1125}
1126
1127fn build_hit_envelope(hit: &SearchHit) -> SearchHitEnvelope<'_> {
1128 let (heading, value) = hit_summary_pair(hit);
1129 SearchHitEnvelope {
1130 hit,
1131 summary_heading: heading,
1132 summary_value: value,
1133 }
1134}
1135
1136fn hit_summary_line(hit: &SearchHit) -> String {
1146 let (heading, value) = hit_summary_pair(hit);
1147 format!("**{heading}**: {value}")
1148}
1149
1150fn hit_summary_pair(hit: &SearchHit) -> (String, String) {
1160 if let Some(summary) = &hit.summary {
1161 return (summary.heading.clone(), summary.value.clone());
1162 }
1163 summary_pair(type_by_name(&hit.entity_type).as_deref(), &hit.sections)
1164}
1165
1166fn summary_pair(
1168 schema: Option<&TypeDefinition>,
1169 sections: &HashMap<String, String>,
1170) -> (String, String) {
1171 match schema {
1172 Some(schema) => lead_section_pair(schema, |k| sections.get(k).map(String::as_str)),
1173 None => ("Summary".to_string(), "—".to_string()),
1174 }
1175}
1176
1177pub(crate) fn lead_section_pair<'a>(
1185 schema: &TypeDefinition,
1186 get_section: impl Fn(&str) -> Option<&'a str>,
1187) -> (String, String) {
1188 let Some(section) = schema
1189 .required_sections()
1190 .next()
1191 .or(schema.sections.first())
1192 else {
1193 return ("Summary".to_string(), "—".to_string());
1194 };
1195 let value = get_section(section.key.as_str()).unwrap_or("—");
1196 (section.heading.clone(), value.to_string())
1197}
1198
1199fn section_key_to_heading(key: &str) -> String {
1203 let mut chars = key.chars();
1204 match chars.next() {
1205 None => String::new(),
1206 Some(c) => {
1207 let first: String = c.to_uppercase().collect();
1208 let rest: String = chars.map(|c| if c == '_' { ' ' } else { c }).collect();
1209 format!("{first}{rest}")
1210 }
1211 }
1212}
1213
1214fn section_heading_for(type_def: Option<&TypeDefinition>, key: &str) -> String {
1221 type_def
1222 .and_then(|t| t.sections.iter().find(|s| s.key == key))
1223 .map(|s| s.heading.clone())
1224 .unwrap_or_else(|| section_key_to_heading(key))
1225}
1226
1227fn lookup_builtin_type(name: &str) -> Option<Arc<TypeDefinition>> {
1236 static CACHE: OnceLock<Vec<Arc<Schema>>> = OnceLock::new();
1237 let schemas =
1238 CACHE.get_or_init(|| memstead_schema::builtins::load_builtin_schemas().unwrap_or_default());
1239 for s in schemas {
1240 if let Some(t) = s.get_type(name) {
1241 return Some(t);
1242 }
1243 }
1244 None
1245}
1246
1247pub fn render_type_catalog_markdown() -> String {
1253 render_type_catalog_lines(all_types())
1254}
1255
1256pub fn render_type_catalog_markdown_for(schema: &Schema) -> String {
1262 let mut types: Vec<Arc<TypeDefinition>> = schema.types.values().cloned().collect();
1263 types.sort_by(|a, b| a.name.as_str().cmp(b.name.as_str()));
1264 render_type_catalog_lines(types)
1265}
1266
1267fn render_type_catalog_lines(types: Vec<Arc<TypeDefinition>>) -> String {
1268 let mut lines = vec![
1269 "# Available types".to_string(),
1270 String::new(),
1271 "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."
1272 .to_string(),
1273 String::new(),
1274 ];
1275 for schema in types {
1276 let required_sections = schema.required_sections().count();
1277 let total_sections = schema.sections.len();
1278 let metadata_count = schema.metadata_fields.len();
1279 lines.push(format!(
1280 "- **{}** — {} sections ({} required), {} metadata fields, staleness {}d",
1281 schema.name.as_str(),
1282 total_sections,
1283 required_sections,
1284 metadata_count,
1285 schema.staleness_threshold_days,
1286 ));
1287 }
1288 lines.push(String::new());
1289 lines.join("\n")
1290}
1291
1292pub fn render_type_info_markdown(schema: &TypeDefinition) -> String {
1294 render_type_info_markdown_in(schema, None)
1295}
1296
1297pub fn render_type_info_markdown_in(
1304 schema: &TypeDefinition,
1305 parent: Option<&memstead_schema::Schema>,
1306) -> String {
1307 let mut lines = Vec::new();
1308 lines.push(format!("# Type: {}", schema.name.as_str()));
1309 lines.push(String::new());
1310 lines.push(format!(
1311 "Staleness threshold: {} days. Hierarchy: `{}`.",
1312 schema.staleness_threshold_days, schema.hierarchy_relationship,
1313 ));
1314 lines.push(String::new());
1315
1316 lines.push("## Metadata fields".to_string());
1318 for field in &schema.metadata_fields {
1319 lines.push(format!("- {}", describe_metadata_field(field)));
1320 }
1321 lines.push(String::new());
1322
1323 lines.push("## Sections".to_string());
1325 for section in &schema.sections {
1326 let req = if section.required {
1327 "required"
1328 } else {
1329 "optional"
1330 };
1331 let catch_all = if section.catch_all { ", catch-all" } else { "" };
1332 lines.push(format!(
1333 "- **{}** ({req}{catch_all}, search_weight: {:.1})",
1334 section.key, section.search_weight,
1335 ));
1336 for rule in §ion.write_rules {
1337 lines.push(format!(" - Write rule: {rule}"));
1338 }
1339 }
1340 lines.push(String::new());
1341
1342 lines.push("## Relationship types (with edge weights)".to_string());
1344 for (rel_type, weight) in &schema.edge_weights {
1345 if rel_type == "_default" {
1346 continue;
1347 }
1348 let mut flags: Vec<&str> = Vec::new();
1349 if rel_type == &schema.hierarchy_relationship {
1350 flags.push("hierarchy");
1351 }
1352 if schema
1353 .no_self_loop_relationships
1354 .iter()
1355 .any(|r| r == rel_type)
1356 {
1357 flags.push("no-self-loop");
1358 }
1359 if let Some(p) = parent {
1364 match p.relationship_manual_authoring(rel_type) {
1365 memstead_schema::ManualAuthoring::Forbidden => {
1366 flags.push("manual authoring FORBIDDEN — emitted from body wiki-links only");
1367 }
1368 memstead_schema::ManualAuthoring::Warn => {
1369 flags.push("manual authoring warns");
1370 }
1371 memstead_schema::ManualAuthoring::Allow => {}
1372 }
1373 }
1374 let flag_str = if flags.is_empty() {
1375 String::new()
1376 } else {
1377 format!(" ({})", flags.join(", "))
1378 };
1379 lines.push(format!("- **{rel_type}**: {weight}{flag_str}"));
1380 }
1381 if let Some((_, default_weight)) = schema.edge_weights.iter().find(|(n, _)| *n == "_default") {
1383 lines.push(format!(
1384 "- _default_ (any other relationship type): {default_weight}"
1385 ));
1386 }
1387 lines.push(String::new());
1388
1389 if !schema.write_rules.is_empty() {
1391 lines.push("## Writing guidance".to_string());
1392 for rule in &schema.write_rules {
1393 lines.push(format!("- {rule}"));
1394 }
1395 lines.push(String::new());
1396 }
1397
1398 let system_msg = schema.system_message_str();
1400 if !system_msg.is_empty() {
1401 lines.push("## System context".to_string());
1402 lines.push(system_msg.to_string());
1403 lines.push(String::new());
1404 }
1405
1406 if let Some(ex) = &schema.exemplar {
1410 lines.push("## Exemplar (engine-validated)".to_string());
1411 lines.push(String::new());
1412 lines.push(format!("Title: {}", ex.title));
1413 if !ex.metadata.is_empty() {
1414 lines.push("Metadata:".to_string());
1415 for (k, v) in &ex.metadata {
1416 lines.push(format!("- {k}: {v}"));
1417 }
1418 }
1419 for (key, body) in &ex.sections {
1420 let heading = schema
1421 .section(key)
1422 .map(|s| s.heading.clone())
1423 .unwrap_or_else(|| key.clone());
1424 lines.push(format!("### {heading}"));
1425 lines.push(body.clone());
1426 }
1427 if !ex.relations.is_empty() {
1428 lines.push("Relations (placeholder targets):".to_string());
1429 for r in &ex.relations {
1430 match &r.description {
1431 Some(d) => lines.push(format!(
1432 "- {} → {} — {d}",
1433 r.rel_type_name(),
1434 r.target_slug()
1435 )),
1436 None => lines.push(format!("- {} → {}", r.rel_type_name(), r.target_slug())),
1437 }
1438 }
1439 }
1440 lines.push(String::new());
1441 }
1442
1443 lines.join("\n")
1444}
1445
1446pub fn per_edge_description_str(p: PerEdgeDescription) -> &'static str {
1452 match p {
1453 PerEdgeDescription::Forbidden => "forbidden",
1454 PerEdgeDescription::Optional => "optional",
1455 PerEdgeDescription::Required => "required",
1456 }
1457}
1458
1459pub fn manual_authoring_str(p: ManualAuthoring) -> &'static str {
1461 match p {
1462 ManualAuthoring::Allow => "allow",
1463 ManualAuthoring::Warn => "warn",
1464 ManualAuthoring::Forbidden => "forbidden",
1465 }
1466}
1467
1468#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
1484pub enum SchemaVerbosity {
1485 #[default]
1486 Full,
1487 Lite,
1488}
1489
1490impl SchemaVerbosity {
1491 pub fn from_wire(s: &str) -> Option<Self> {
1496 match s {
1497 "full" => Some(Self::Full),
1498 "lite" => Some(Self::Lite),
1499 _ => None,
1500 }
1501 }
1502
1503 pub fn as_wire(self) -> &'static str {
1505 match self {
1506 Self::Full => "full",
1507 Self::Lite => "lite",
1508 }
1509 }
1510}
1511
1512#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
1539pub enum OriginClass {
1540 FirstParty,
1542 #[default]
1545 ThirdParty,
1546}
1547
1548impl OriginClass {
1549 pub fn as_wire(self) -> &'static str {
1553 match self {
1554 Self::FirstParty => "first-party",
1555 Self::ThirdParty => "third-party",
1556 }
1557 }
1558
1559 pub fn is_third_party(self) -> bool {
1562 matches!(self, Self::ThirdParty)
1563 }
1564}
1565
1566fn append_section_format(
1587 obj: &mut serde_json::Map<String, serde_json::Value>,
1588 s: &memstead_schema::SectionDef,
1589) {
1590 if let Some(content) = &s.content {
1591 obj.insert("content".into(), serde_json::json!(content));
1592 obj.insert(
1593 "format_severity".into(),
1594 serde_json::json!(s.format_severity),
1595 );
1596 }
1597 if let Some(pattern) = &s.item_pattern {
1598 obj.insert("item_pattern".into(), serde_json::json!(pattern));
1599 }
1600 if let Some(table) = &s.table {
1601 obj.insert("table".into(), serde_json::json!(table));
1602 }
1603 if let Some(example) = &s.example {
1604 obj.insert("example".into(), serde_json::json!(example));
1605 }
1606}
1607
1608#[derive(Debug, Clone)]
1613pub struct UnknownSchemaTypes {
1614 pub unknown: Vec<String>,
1615 pub known: Vec<String>,
1616}
1617
1618fn estimate_payload_tokens(value: &serde_json::Value) -> usize {
1622 serde_json::to_string(value)
1623 .map(|s| estimate_tokens(&s))
1624 .unwrap_or(0)
1625}
1626
1627pub const DEFAULT_SCHEMA_FULL_BUDGET: usize = 15_000;
1637
1638pub fn build_schema_payload(
1639 schema: &Arc<Schema>,
1640 used_by: Vec<String>,
1641 verbosity: SchemaVerbosity,
1642 origin: OriginClass,
1643) -> serde_json::Value {
1644 build_schema_payload_scoped(schema, used_by, verbosity, origin, None, None)
1647 .expect("no type selection, no refusal")
1648}
1649
1650pub fn build_schema_payload_scoped(
1666 schema: &Arc<Schema>,
1667 used_by: Vec<String>,
1668 verbosity: SchemaVerbosity,
1669 origin: OriginClass,
1670 type_selection: Option<&[String]>,
1671 token_budget: Option<usize>,
1672) -> Result<serde_json::Value, UnknownSchemaTypes> {
1673 let manifest = &schema.manifest;
1674
1675 if let Some(sel) = type_selection {
1679 let unknown: Vec<String> = sel
1680 .iter()
1681 .filter(|t| !manifest.types.iter().any(|m| m == *t))
1682 .cloned()
1683 .collect();
1684 if !unknown.is_empty() {
1685 return Err(UnknownSchemaTypes {
1686 unknown,
1687 known: manifest.types.clone(),
1688 });
1689 }
1690 }
1691 let verbosity = if origin.is_third_party() {
1699 SchemaVerbosity::Lite
1700 } else {
1701 verbosity
1702 };
1703
1704 let relationships: Vec<serde_json::Value> = manifest
1715 .relationships
1716 .definitions
1717 .iter()
1718 .filter(|d| d.name != "_default")
1719 .map(|d| {
1720 let mut o = serde_json::json!({
1741 "name": d.name,
1742 "description": d.description,
1743 "when_to_use": d.when_to_use,
1744 "default_weight": d.default_weight,
1745 "acyclic": d.acyclic,
1746 "per_edge_description": per_edge_description_str(d.per_edge_description),
1747 "manual_authoring": manual_authoring_str(d.manual_authoring),
1748 "allowed_sources": d.source_types,
1749 "allowed_targets": d.target_types,
1750 });
1751 if d.derivation {
1757 o["derivation"] = serde_json::json!(true);
1758 }
1759 o
1760 })
1761 .collect();
1762
1763 let cross_mem_relationships: Vec<serde_json::Value> = manifest
1770 .cross_mem_relationships
1771 .iter()
1772 .map(|entry| {
1773 let definitions: Vec<serde_json::Value> = entry
1774 .definitions
1775 .iter()
1776 .filter(|d| d.name != "_default")
1777 .map(|d| {
1778 serde_json::json!({
1779 "name": d.name,
1780 "description": d.description,
1781 "when_to_use": d.when_to_use,
1782 "default_weight": d.default_weight,
1783 "source_types": d.source_types,
1784 "target_types": d.target_types,
1785 "per_edge_description": per_edge_description_str(d.per_edge_description),
1786 })
1787 })
1788 .collect();
1789 serde_json::json!({
1790 "to_schema": entry.to_schema,
1791 "definitions": definitions,
1792 })
1793 })
1794 .collect();
1795
1796 let types_full: Vec<serde_json::Value> = manifest
1799 .types
1800 .iter()
1801 .filter_map(|name| schema.types.get(name.as_str()).map(|td| (name, td)))
1802 .map(|(_, td)| {
1803 let sections: Vec<serde_json::Value> = td
1804 .sections
1805 .iter()
1806 .map(|s| {
1807 let mut obj = serde_json::json!({
1808 "key": s.key,
1809 "heading": s.heading,
1810 "required": s.required,
1811 "write_rules": s.write_rules,
1812 });
1813 append_section_format(obj.as_object_mut().unwrap(), s);
1819 obj
1820 })
1821 .collect();
1822
1823 let fields: Vec<serde_json::Value> = td
1824 .metadata_fields
1825 .iter()
1826 .map(|f| {
1827 let mut obj = serde_json::json!({
1828 "name": f.key,
1829 "description": f.description,
1830 "required": f.is_required(),
1831 });
1832 if let Some(enum_values) = &f.enum_values {
1833 obj.as_object_mut()
1834 .unwrap()
1835 .insert("enum".into(), serde_json::json!(enum_values));
1836 }
1837 if let Some(default) = &f.default_value {
1844 obj.as_object_mut()
1845 .unwrap()
1846 .insert("default".into(), serde_json::json!(default));
1847 }
1848 obj.as_object_mut().unwrap().insert(
1854 "filterable".into(),
1855 match f.filterable.as_wire_str() {
1856 Some(s) => serde_json::json!(s),
1857 None => serde_json::Value::Null,
1858 },
1859 );
1860 obj
1861 })
1862 .collect();
1863
1864 let required_outgoing: Vec<serde_json::Value> = td
1879 .required_outgoing
1880 .iter()
1881 .map(|block| {
1882 let mut b = serde_json::json!({
1883 "relationships": block.relationships,
1884 "cardinality": block.cardinality.to_string(),
1885 "severity": block.severity,
1886 });
1887 if let (Some(wf), Some(wv)) = (&block.when_field, &block.when_value) {
1892 b["when_field"] = serde_json::json!(wf);
1893 b["when_value"] = serde_json::json!(wv);
1894 }
1895 b
1896 })
1897 .collect();
1898
1899 let constraints: Vec<serde_json::Value> = td
1908 .constraints
1909 .iter()
1910 .map(|c| match c {
1911 memstead_schema::ConstraintDef::RequiresWhen {
1912 field,
1913 when_field,
1914 when_value,
1915 severity,
1916 } => serde_json::json!({
1917 "kind": "requires_when",
1918 "field": field,
1919 "when_field": when_field,
1920 "when_value": when_value,
1921 "severity": severity,
1922 }),
1923 memstead_schema::ConstraintDef::Unique { fields, severity } => {
1924 serde_json::json!({
1925 "kind": "unique",
1926 "fields": fields,
1927 "severity": severity,
1928 })
1929 }
1930 memstead_schema::ConstraintDef::EnumFromNeighbour {
1931 field,
1932 rel_type,
1933 section,
1934 severity,
1935 } => serde_json::json!({
1936 "kind": "enum_from_neighbour",
1937 "field": field,
1938 "rel_type": rel_type,
1939 "section": section,
1940 "severity": severity,
1941 }),
1942 memstead_schema::ConstraintDef::StatusPropagation {
1943 field,
1944 value,
1945 rel_type,
1946 rel_types,
1947 direction,
1948 severity,
1949 } => {
1950 let mut c = serde_json::json!({
1951 "kind": "status_propagation",
1952 "field": field,
1953 "value": value,
1954 "direction": direction,
1955 "severity": severity,
1956 });
1957 if let Some(single) = rel_type {
1961 c["rel_type"] = serde_json::json!(single);
1962 }
1963 if let Some(set) = rel_types {
1964 c["rel_types"] = serde_json::json!(set);
1965 }
1966 c
1967 }
1968 memstead_schema::ConstraintDef::TransitionRequiresChecks {
1969 field,
1970 to_value,
1971 relationships,
1972 direction,
1973 severity,
1974 } => serde_json::json!({
1975 "kind": "transition_requires_checks",
1976 "field": field,
1977 "to_value": to_value,
1978 "relationships": relationships,
1979 "direction": direction,
1980 "severity": severity,
1981 }),
1982 })
1983 .collect();
1984 let mut obj = serde_json::json!({
1985 "name": td.name,
1986 "description": td.description,
1987 "when_to_use": td.when_to_use,
1988 "sections": sections,
1989 "fields": fields,
1990 "writing_guidance": td.write_rules,
1991 "system_context": td.system_message_str(),
1992 "staleness_threshold_days": td.staleness_threshold_days,
1993 "no_self_loop_relationships": td.no_self_loop_relationships,
1994 "required_outgoing": required_outgoing,
1995 "constraints": constraints,
1996 });
1997 if !td.must_reach.is_empty() {
2003 obj["must_reach"] = serde_json::to_value(&td.must_reach)
2004 .expect("must_reach declarations serialize");
2005 }
2006 if !td.signals.is_empty() {
2012 obj["signals"] =
2013 serde_json::to_value(&td.signals).expect("signal declarations serialize");
2014 }
2015 if td.leaf {
2019 obj["leaf"] = serde_json::json!(true);
2020 }
2021 if let Some(ex) = &td.exemplar {
2035 let relations: Vec<serde_json::Value> = ex
2036 .relations
2037 .iter()
2038 .map(|r| {
2039 let mut o = serde_json::json!({
2040 "target": r.target_slug(),
2041 "rel_type": r.rel_type_name(),
2042 });
2043 if let Some(d) = &r.description {
2044 o["description"] = serde_json::json!(d);
2045 }
2046 o
2047 })
2048 .collect();
2049 obj["exemplar"] = serde_json::json!({
2050 "title": ex.title,
2051 "metadata": ex.metadata,
2052 "sections": ex.sections,
2053 "relations": relations,
2054 });
2055 }
2056 obj
2057 })
2058 .collect();
2059
2060 let mode = match manifest.relationships.mode {
2061 RelationshipMode::Strict => "strict",
2062 RelationshipMode::Open => "open",
2063 };
2064
2065 let full = verbosity == SchemaVerbosity::Full;
2066
2067 let mut payload = serde_json::json!({
2071 "ref": format!("{}@{}", manifest.name, schema.version),
2072 "relationship_mode": mode,
2073 "community": {
2074 "resolution": manifest.community.resolution,
2075 "seed": manifest.community.seed,
2076 },
2077 "used_by": used_by,
2078 "origin": origin.as_wire(),
2084 });
2085 let obj = payload.as_object_mut().unwrap();
2086
2087 if !manifest.relationships.acyclic_sets.is_empty() {
2092 obj.insert(
2093 "acyclic_sets".into(),
2094 serde_json::to_value(&manifest.relationships.acyclic_sets)
2095 .expect("acyclic_sets serialize"),
2096 );
2097 }
2098 if let Some(lab) = &manifest.relationships.labelling {
2103 obj.insert(
2104 "labelling".into(),
2105 serde_json::to_value(lab).expect("labelling declaration serializes"),
2106 );
2107 }
2108
2109 if full {
2114 obj.insert(
2115 "description".into(),
2116 serde_json::Value::String(manifest.description.clone()),
2117 );
2118 obj.insert(
2119 "when_to_use".into(),
2120 serde_json::Value::String(manifest.when_to_use.clone()),
2121 );
2122 if let Some(msg) = &manifest.system_message {
2128 obj.insert(
2129 "system_context".into(),
2130 serde_json::Value::String(msg.clone()),
2131 );
2132 }
2133 }
2134
2135 obj.insert(
2142 "no_self_loop_relationships_effect".into(),
2143 serde_json::Value::String(
2144 "Per-type `no_self_loop_relationships` governs exactly one behaviour: \
2145 memstead_relate refuses a self-loop (from == to) on a rel-type the \
2146 source type lists here. It does not propagate impact, imply an \
2147 evidence obligation, or have any other effect (the name says it \
2148 all). To declare real impact propagation, use the \
2149 `status_propagation` constraint (`constraints:` on the type), which \
2150 taints dependents of a terminal status value via a named rel-type \
2151 and direction and surfaces them as health findings."
2152 .to_string(),
2153 ),
2154 );
2155
2156 if let Some(target) = &manifest.alias_target_rel_type {
2165 obj.insert(
2166 "alias_target_rel_type".into(),
2167 serde_json::Value::String(target.clone()),
2168 );
2169 }
2170
2171 if full && let Some(dwg) = &manifest.default_writing_guidance {
2178 let mut block = serde_json::Map::new();
2179 if let Some(avoid) = &dwg.avoid {
2180 block.insert("avoid".into(), serde_json::Value::String(avoid.clone()));
2181 }
2182 if let Some(goal) = &dwg.goal {
2183 block.insert("goal".into(), serde_json::Value::String(goal.clone()));
2184 }
2185 if !block.is_empty() {
2186 obj.insert(
2187 "default_writing_guidance".into(),
2188 serde_json::Value::Object(block),
2189 );
2190 }
2191 }
2192
2193 let selected = |name: &serde_json::Value| -> bool {
2198 match type_selection {
2199 None => true,
2200 Some(sel) => name.as_str().is_some_and(|n| sel.iter().any(|s| s == n)),
2201 }
2202 };
2203 let omitted_names: Vec<serde_json::Value> = types_full
2204 .iter()
2205 .filter(|t| !selected(&t["name"]))
2206 .map(|t| t["name"].clone())
2207 .collect();
2208
2209 if full {
2210 obj.insert(
2211 "relationships".into(),
2212 serde_json::Value::Array(relationships),
2213 );
2214 if !cross_mem_relationships.is_empty() {
2218 obj.insert(
2219 "cross_mem_relationships".into(),
2220 serde_json::Value::Array(cross_mem_relationships),
2221 );
2222 }
2223 match type_selection {
2224 Some(_) => {
2225 let served: Vec<serde_json::Value> = types_full
2226 .iter()
2227 .filter(|t| selected(&t["name"]))
2228 .cloned()
2229 .collect();
2230 obj.insert("types".into(), serde_json::Value::Array(served));
2231 if !omitted_names.is_empty() {
2232 obj.insert(
2233 "types_omitted".into(),
2234 serde_json::Value::Array(omitted_names),
2235 );
2236 }
2237 }
2238 None => {
2239 obj.insert("types".into(), serde_json::Value::Array(types_full.clone()));
2240 if let Some(budget) = token_budget {
2248 let estimated = estimate_payload_tokens(&payload);
2249 if estimated > budget {
2250 let obj = payload.as_object_mut().unwrap();
2251 obj.remove("types");
2252 let all_names: Vec<serde_json::Value> =
2253 types_full.iter().map(|t| t["name"].clone()).collect();
2254 obj.insert(
2255 "types_summary".into(),
2256 serde_json::Value::Array(lite_types_projection(&types_full)),
2257 );
2258 obj.insert("types_omitted".into(), serde_json::Value::Array(all_names));
2259 obj.insert(
2260 "_schema_mode".into(),
2261 serde_json::Value::String("reduced".into()),
2262 );
2263 obj.insert("_estimated_tokens".into(), serde_json::json!(estimated));
2264 obj.insert("_token_budget".into(), serde_json::json!(budget));
2265 obj.insert(
2266 "_hint".into(),
2267 serde_json::Value::String(format!(
2268 "the full prose for all {} types (~{estimated} tokens) exceeds \
2269 the response budget ({budget}); per-type prose is served as the \
2270 lite skeleton here — request the full prose for exactly the \
2271 types you will write via `types: [\"<name>\", …]` (valid names \
2272 in `types_omitted`)",
2273 types_full.len(),
2274 )),
2275 );
2276 }
2277 }
2278 }
2279 }
2280 } else {
2281 let relationships_summary: Vec<serde_json::Value> = relationships
2291 .iter()
2292 .map(|r| {
2293 let mut o = serde_json::json!({
2294 "name": r["name"],
2295 "allowed_sources": r["allowed_sources"],
2296 "allowed_targets": r["allowed_targets"],
2297 "manual_authoring": r["manual_authoring"],
2298 "acyclic": r["acyclic"],
2299 "per_edge_description": r["per_edge_description"],
2300 });
2301 if r.get("derivation") == Some(&serde_json::json!(true)) {
2302 o["derivation"] = serde_json::json!(true);
2303 }
2304 o
2305 })
2306 .collect();
2307 obj.insert(
2308 "relationships_summary".into(),
2309 serde_json::Value::Array(relationships_summary),
2310 );
2311
2312 if !cross_mem_relationships.is_empty() {
2316 let cross_summary: Vec<serde_json::Value> = cross_mem_relationships
2317 .iter()
2318 .map(|e| {
2319 let definitions: Vec<serde_json::Value> = e["definitions"]
2320 .as_array()
2321 .map(|defs| {
2322 defs.iter()
2323 .map(|d| {
2324 serde_json::json!({
2325 "name": d["name"],
2326 "source_types": d["source_types"],
2327 "target_types": d["target_types"],
2328 })
2329 })
2330 .collect()
2331 })
2332 .unwrap_or_default();
2333 serde_json::json!({
2334 "to_schema": e["to_schema"],
2335 "definitions": definitions,
2336 })
2337 })
2338 .collect();
2339 obj.insert(
2340 "cross_mem_relationships_summary".into(),
2341 serde_json::Value::Array(cross_summary),
2342 );
2343 }
2344
2345 let served: Vec<serde_json::Value> = types_full
2349 .iter()
2350 .filter(|t| selected(&t["name"]))
2351 .cloned()
2352 .collect();
2353 obj.insert(
2354 "types_summary".into(),
2355 serde_json::Value::Array(lite_types_projection(&served)),
2356 );
2357 if !omitted_names.is_empty() {
2358 obj.insert(
2359 "types_omitted".into(),
2360 serde_json::Value::Array(omitted_names),
2361 );
2362 }
2363 }
2364
2365 Ok(payload)
2366}
2367
2368fn lite_types_projection(types_full: &[serde_json::Value]) -> Vec<serde_json::Value> {
2384 types_full
2385 .iter()
2386 .map(|t| {
2387 let sections: Vec<serde_json::Value> = t["sections"]
2388 .as_array()
2389 .map(|secs| {
2390 secs.iter()
2391 .map(|s| {
2392 let mut o = serde_json::Map::new();
2393 o.insert("key".into(), s["key"].clone());
2394 o.insert("required".into(), s["required"].clone());
2395 for k in [
2399 "content",
2400 "item_pattern",
2401 "table",
2402 "example",
2403 "format_severity",
2404 ] {
2405 if let Some(v) = s.get(k) {
2406 o.insert(k.into(), v.clone());
2407 }
2408 }
2409 serde_json::Value::Object(o)
2410 })
2411 .collect()
2412 })
2413 .unwrap_or_default();
2414 let fields: Vec<serde_json::Value> = t["fields"]
2415 .as_array()
2416 .map(|fs| {
2417 fs.iter()
2418 .map(|f| {
2419 let mut o = serde_json::Map::new();
2420 o.insert("name".into(), f["name"].clone());
2421 o.insert("required".into(), f["required"].clone());
2422 if let Some(e) = f.get("enum") {
2423 o.insert("enum".into(), e.clone());
2424 }
2425 if let Some(d) = f.get("default") {
2426 o.insert("default".into(), d.clone());
2427 }
2428 serde_json::Value::Object(o)
2429 })
2430 .collect()
2431 })
2432 .unwrap_or_default();
2433 let mut o = serde_json::json!({
2434 "name": t["name"],
2435 "sections": sections,
2436 "fields": fields,
2437 "no_self_loop_relationships": t["no_self_loop_relationships"],
2438 "required_outgoing": t["required_outgoing"],
2439 "constraints": t["constraints"],
2440 });
2441 if t.get("leaf") == Some(&serde_json::json!(true)) {
2444 o["leaf"] = serde_json::json!(true);
2445 }
2446 if let Some(mr) = t.get("must_reach") {
2450 o["must_reach"] = mr.clone();
2451 }
2452 if let Some(sig) = t.get("signals") {
2454 o["signals"] = sig.clone();
2455 }
2456 o
2457 })
2458 .collect()
2459}
2460
2461fn describe_metadata_field(field: &memstead_schema::MetadataFieldDef) -> String {
2463 let type_str = match field.field_type {
2464 FieldType::String => "String",
2465 FieldType::Number => "Number",
2466 FieldType::Date => "Date",
2467 FieldType::Boolean => "Boolean",
2468 };
2469
2470 let mut flags: Vec<&str> = Vec::new();
2471 if !field.is_required() {
2472 flags.push("optional");
2473 } else {
2474 flags.push("required");
2475 }
2476 if field.init_timestamp {
2477 flags.push("auto-init");
2478 }
2479 if field.auto_timestamp {
2480 flags.push("auto-update");
2481 }
2482 match field.serialization {
2483 Serialization::CsvArray => flags.push("csv array"),
2484 Serialization::OmitWhenFalsy => flags.push("omit when falsy"),
2485 Serialization::Default => {}
2486 }
2487
2488 let mut extras: Vec<String> = Vec::new();
2489 if let Some(values) = &field.enum_values {
2490 extras.push(format!("enum: {}", values.join(", ")));
2491 }
2492 if let Some(default) = &field.default_value {
2493 extras.push(format!("default: {default}"));
2494 }
2495 let filterable_str = match field.filterable {
2496 Filterable::None => None,
2497 Filterable::Equality => Some("filterable: equality"),
2498 Filterable::Range => Some("filterable: range"),
2499 };
2500 if let Some(f) = filterable_str {
2501 extras.push(f.to_string());
2502 }
2503
2504 let extras_str = if extras.is_empty() {
2505 String::new()
2506 } else {
2507 format!(" — {}", extras.join(" — "))
2508 };
2509
2510 format!(
2511 "**{key}**: {type_str} ({flags}){extras_str}",
2512 key = field.key,
2513 flags = flags.join(", "),
2514 )
2515}
2516
2517#[cfg(test)]
2518mod tests {
2519 use super::*;
2520 use crate::{Entity, EntityId, ListResult, SearchResult};
2521 use indexmap::IndexMap;
2522 use std::collections::HashMap;
2523
2524 fn make_hit(id: &str, title: &str, entity_type: &str, sections: &[(&str, &str)]) -> SearchHit {
2525 SearchHit {
2526 id: EntityId(id.to_string()),
2527 last_modified: None,
2528 title: title.to_string(),
2529 mem: id.split("--").next().unwrap_or("").to_string(),
2530 entity_type: entity_type.to_string(),
2531 stub: false,
2532 score: 1.0,
2533 tokens: 10,
2534 snippet: None,
2535 sections: sections
2536 .iter()
2537 .map(|(k, v)| (k.to_string(), v.to_string()))
2538 .collect(),
2539 score_breakdown: None,
2540 matched_terms: None,
2541 expansion: None,
2542 summary: None,
2545 }
2546 }
2547
2548 fn search_result(hits: Vec<SearchHit>) -> SearchResult {
2549 let returned = hits.len();
2550 let total_tokens = hits.iter().map(|h| h.tokens).sum();
2551 SearchResult {
2552 total: returned,
2553 returned,
2554 offset: 0,
2555 total_tokens,
2556 hits,
2557 facets: None,
2558 warnings: vec![],
2559 }
2560 }
2561
2562 fn list_result(hits: Vec<SearchHit>) -> ListResult {
2563 let returned = hits.len();
2564 ListResult {
2565 total: returned,
2566 returned,
2567 offset: 0,
2568 total_tokens: hits.iter().map(|h| h.tokens).sum(),
2569 hits,
2570 warnings: vec![],
2571 }
2572 }
2573
2574 fn test_entity() -> Entity {
2575 Entity {
2576 id: EntityId("specs--test-entity".to_string()),
2577 title: "Test Entity".to_string(),
2578 entity_type: "spec".to_string(),
2579 mem: "specs".to_string(),
2580 file_path: "test-entity.md".to_string(),
2581 metadata: IndexMap::new(),
2582 sections: IndexMap::from([
2583 ("identity".to_string(), "A test entity for unit tests.".to_string()),
2584 ("purpose".to_string(), "Validates render logic.".to_string()),
2585 ("specifies".to_string(), "Long section content that adds significant token weight to the full entity estimate.".to_string()),
2586 ]),
2587 relationships: vec![],
2588 content_hash: "abc123".to_string(),
2589 stub: false,
2590 stub_kind: None,
2591 heading_spans: std::collections::HashMap::new(),
2592 raw_section_headings: Vec::new(),
2593 }
2594 }
2595
2596 #[test]
2597 fn section_key_to_heading_basic() {
2598 assert_eq!(section_key_to_heading("identity"), "Identity");
2599 assert_eq!(section_key_to_heading("current_state"), "Current state");
2600 }
2601
2602 #[test]
2603 fn render_uses_schema_declared_heading_for_non_trivial_casing() {
2604 let mut sections: IndexMap<String, String> = IndexMap::new();
2610 sections.insert("claim_a".to_string(), "Body A.".to_string());
2611 sections.insert("claim_b".to_string(), "Body B.".to_string());
2612
2613 let entity = Entity {
2614 id: EntityId("ingest--example".to_string()),
2615 title: "Example".to_string(),
2616 entity_type: "inconsistency".to_string(),
2617 mem: "ingest".to_string(),
2618 file_path: "example.md".to_string(),
2619 metadata: IndexMap::new(),
2620 sections,
2621 relationships: vec![],
2622 content_hash: "h".to_string(),
2623 stub: false,
2624 stub_kind: None,
2625 heading_spans: std::collections::HashMap::new(),
2626 raw_section_headings: Vec::new(),
2627 };
2628
2629 let md = render_entity_markdown(&entity, None);
2630 assert!(
2631 md.contains("## Claim A"),
2632 "expected schema-declared `## Claim A` heading; got:\n{md}"
2633 );
2634 assert!(
2635 md.contains("## Claim B"),
2636 "expected schema-declared `## Claim B` heading; got:\n{md}"
2637 );
2638 assert!(
2640 !md.contains("## Claim a"),
2641 "renderer must not fall back to key-derivation when the \
2642 schema declares a heading; got:\n{md}"
2643 );
2644 }
2645
2646 #[test]
2647 fn render_falls_back_to_key_derivation_for_unknown_types() {
2648 let mut sections: IndexMap<String, String> = IndexMap::new();
2652 sections.insert("identity".to_string(), "body".to_string());
2653
2654 let entity = Entity {
2655 id: EntityId("custom--example".to_string()),
2656 title: "Example".to_string(),
2657 entity_type: "not-a-builtin-type".to_string(),
2658 mem: "custom".to_string(),
2659 file_path: "example.md".to_string(),
2660 metadata: IndexMap::new(),
2661 sections,
2662 relationships: vec![],
2663 content_hash: "h".to_string(),
2664 stub: false,
2665 stub_kind: None,
2666 heading_spans: std::collections::HashMap::new(),
2667 raw_section_headings: Vec::new(),
2668 };
2669
2670 let md = render_entity_markdown(&entity, None);
2671 assert!(
2672 md.contains("## Identity"),
2673 "fallback derivation must produce `## Identity`; got:\n{md}"
2674 );
2675 }
2676
2677 #[test]
2684 fn render_entity_sections_follow_indexmap_insertion_order() {
2685 let mut sections: IndexMap<String, String> = IndexMap::new();
2686 sections.insert("specifies".to_string(), "S content.".to_string());
2687 sections.insert("purpose".to_string(), "P content.".to_string());
2688 sections.insert("identity".to_string(), "I content.".to_string());
2689
2690 let entity = Entity {
2691 id: EntityId("specs--order-test".to_string()),
2692 title: "Order Test".to_string(),
2693 entity_type: "spec".to_string(),
2694 mem: "specs".to_string(),
2695 file_path: "order-test.md".to_string(),
2696 metadata: IndexMap::new(),
2697 sections,
2698 relationships: vec![],
2699 content_hash: "abc123".to_string(),
2700 stub: false,
2701 stub_kind: None,
2702 heading_spans: std::collections::HashMap::new(),
2703 raw_section_headings: Vec::new(),
2704 };
2705
2706 let md = render_entity_markdown(&entity, None);
2707 let specifies_pos = md.find("## Specifies").expect("## Specifies must appear");
2708 let purpose_pos = md.find("## Purpose").expect("## Purpose must appear");
2709 let identity_pos = md.find("## Identity").expect("## Identity must appear");
2710
2711 assert!(
2712 specifies_pos < purpose_pos,
2713 "Specifies (inserted first) must render before Purpose; got:\n{md}"
2714 );
2715 assert!(
2716 purpose_pos < identity_pos,
2717 "Purpose (inserted second) must render before Identity; got:\n{md}"
2718 );
2719 }
2720
2721 #[test]
2727 fn tokens_reflect_filtered_output() {
2728 let entity = test_entity();
2729
2730 let full = render_entity_markdown(&entity, None);
2732 assert!(full.contains("_tokens:"), "should have _tokens");
2733 assert!(
2734 !full.contains("_tokens_unfiltered_body:"),
2735 "should NOT have _tokens_unfiltered_body when unfiltered"
2736 );
2737 assert!(
2738 !full.contains("_tokens_full:"),
2739 "old _tokens_full name must not survive — rename is one-way"
2740 );
2741
2742 let filtered = render_entity_markdown(&entity, Some(&["identity".to_string()]));
2744 assert!(filtered.contains("_tokens:"), "should have _tokens");
2745 assert!(
2746 filtered.contains("_tokens_unfiltered_body:"),
2747 "should have _tokens_unfiltered_body when filtered"
2748 );
2749 assert!(
2750 !filtered.contains("_tokens_full:"),
2751 "old _tokens_full name must not survive — rename is one-way"
2752 );
2753
2754 let full_tokens: usize = full
2756 .lines()
2757 .find(|l| l.starts_with("_tokens:"))
2758 .unwrap()
2759 .trim_start_matches("_tokens: ")
2760 .parse()
2761 .unwrap();
2762 let filtered_tokens: usize = filtered
2763 .lines()
2764 .find(|l| l.starts_with("_tokens:"))
2765 .unwrap()
2766 .trim_start_matches("_tokens: ")
2767 .parse()
2768 .unwrap();
2769 let tokens_unfiltered_body: usize = filtered
2770 .lines()
2771 .find(|l| l.starts_with("_tokens_unfiltered_body:"))
2772 .unwrap()
2773 .trim_start_matches("_tokens_unfiltered_body: ")
2774 .parse()
2775 .unwrap();
2776
2777 assert!(
2778 filtered_tokens < full_tokens,
2779 "filtered _tokens ({filtered_tokens}) should be less than full _tokens ({full_tokens})"
2780 );
2781 assert!(
2782 tokens_unfiltered_body >= full_tokens,
2783 "_tokens_unfiltered_body ({tokens_unfiltered_body}) should be >= full render _tokens ({full_tokens})"
2784 );
2785 }
2786
2787 #[test]
2792 fn render_search_uses_first_required_section_for_spec() {
2793 let hit = make_hit(
2794 "specs--demo",
2795 "Demo Spec",
2796 "spec",
2797 &[
2798 ("identity", "A demo spec."),
2799 ("purpose", "Verifies rendering."),
2800 ],
2801 );
2802 let out = render_search_markdown(&search_result(vec![hit]), 0);
2803 assert!(
2804 out.contains("**Identity**: A demo spec."),
2805 "expected Identity line for spec hit, got:\n{out}"
2806 );
2807 }
2808
2809 #[test]
2810 fn render_search_uses_first_required_section_for_memo() {
2811 let hit = make_hit(
2812 "memos--d1",
2813 "Memo One",
2814 "memo",
2815 &[("claim", "Some claim."), ("context", "Some context.")],
2816 );
2817 let out = render_search_markdown(&search_result(vec![hit]), 0);
2818 assert!(
2819 out.contains("**Claim**: Some claim."),
2820 "expected Claim line for memo hit, got:\n{out}"
2821 );
2822 assert!(
2823 !out.contains("**Identity**"),
2824 "memo hit must not render Identity label"
2825 );
2826 assert!(
2827 !out.contains("**Purpose**"),
2828 "memo hit must not render Purpose label"
2829 );
2830 }
2831
2832 #[test]
2833 fn render_search_uses_first_required_section_for_concept() {
2834 let hit = make_hit(
2835 "concepts--thing",
2836 "Thing",
2837 "concept",
2838 &[("definition", "A thing."), ("explanation", "Details.")],
2839 );
2840 let out = render_search_markdown(&search_result(vec![hit]), 0);
2841 assert!(
2842 out.contains("**Definition**: A thing."),
2843 "expected Definition line for concept hit, got:\n{out}"
2844 );
2845 }
2846
2847 #[test]
2848 fn render_search_missing_summary_section_shows_dash() {
2849 let hit = make_hit("memos--empty", "Empty Memo", "memo", &[]);
2851 let out = render_search_markdown(&search_result(vec![hit]), 0);
2852 assert!(
2853 out.contains("**Claim**: —"),
2854 "expected Claim dash fallback, got:\n{out}"
2855 );
2856 }
2857
2858 #[test]
2859 fn render_search_mixes_schemas_in_one_result() {
2860 let spec_hit = make_hit(
2861 "specs--s1",
2862 "Spec One",
2863 "spec",
2864 &[("identity", "Spec body.")],
2865 );
2866 let memo_hit = make_hit("memos--m1", "Memo One", "memo", &[("claim", "Memo claim.")]);
2867 let out = render_search_markdown(&search_result(vec![spec_hit, memo_hit]), 0);
2868 assert!(
2869 out.contains("**Identity**: Spec body."),
2870 "spec hit should still render Identity, got:\n{out}"
2871 );
2872 assert!(
2873 out.contains("**Claim**: Memo claim."),
2874 "memo hit should render Claim in the same output, got:\n{out}"
2875 );
2876 }
2877
2878 #[test]
2879 fn render_search_unknown_schema_shows_summary_dash() {
2880 let hit = make_hit("bogus--x", "Bogus", "bogus", &[]);
2881 let out = render_search_markdown(&search_result(vec![hit]), 0);
2882 assert!(
2883 out.contains("**Summary**: —"),
2884 "unknown schema should render Summary dash, got:\n{out}"
2885 );
2886 }
2887
2888 #[test]
2889 fn summary_pair_falls_back_when_schema_has_no_required_sections() {
2890 use memstead_schema::{SectionDef, TypeDefinition};
2891
2892 let schema = TypeDefinition {
2893 name: "spec".to_string(),
2894 description: "test".to_string(),
2895 when_to_use: "test".to_string(),
2896 boundaries: vec![],
2897 exemplar: None,
2898 legacy_examples: None,
2899 system_message: None,
2900 sections: vec![SectionDef {
2901 key: "note".to_string(),
2902 heading: "Note".to_string(),
2903 required: false,
2904 load_bearing: None,
2905 search_weight: 1.0,
2906 catch_all: false,
2907 write_rules: vec![],
2908 description: None,
2909 content: None,
2910 item_pattern: None,
2911 table: None,
2912 example: None,
2913 format_severity: memstead_schema::ConstraintSeverity::Block,
2914 compiled_content: None,
2915 format_problems: Vec::new(),
2916 }],
2917 metadata_fields: vec![],
2918 title_weight: 1.0,
2919 text_fields: vec![],
2920 hierarchy_relationship: "PART_OF".to_string(),
2921 edge_weight_overrides: indexmap::IndexMap::new(),
2922 edge_weights: indexmap::IndexMap::new(),
2923 no_self_loop_relationships: vec![],
2924 legacy_propagating_relationships: None,
2925 due: None,
2926 leaf: false,
2927 updatable_fields: vec![],
2928 health_required_fields: vec![],
2929 staleness_threshold_days: 90,
2930 write_rules: vec![],
2931 required_outgoing: vec![],
2932 must_reach: vec![],
2933 signals: vec![],
2934 constraints: vec![],
2935 declared_metadata_keys: vec![],
2936 };
2937
2938 let mut sections = HashMap::new();
2939 sections.insert("note".to_string(), "a note".to_string());
2940 assert_eq!(
2941 summary_pair(Some(&schema), §ions),
2942 ("Note".to_string(), "a note".to_string()),
2943 );
2944
2945 assert_eq!(
2946 summary_pair(Some(&schema), &HashMap::new()),
2947 ("Note".to_string(), "—".to_string()),
2948 );
2949 }
2950
2951 #[test]
2956 fn render_list_uses_first_required_section_for_spec() {
2957 let hit = make_hit(
2958 "specs--demo",
2959 "Demo Spec",
2960 "spec",
2961 &[
2962 ("identity", "A demo spec."),
2963 ("purpose", "Verifies rendering."),
2964 ],
2965 );
2966 let out = render_list_markdown(&list_result(vec![hit]));
2967 assert!(
2968 out.contains("**Identity**: A demo spec."),
2969 "expected Identity line for spec hit, got:\n{out}"
2970 );
2971 }
2972
2973 #[test]
2974 fn render_list_uses_first_required_section_for_memo() {
2975 let hit = make_hit("memos--d1", "Memo One", "memo", &[("claim", "Some claim.")]);
2976 let out = render_list_markdown(&list_result(vec![hit]));
2977 assert!(
2978 out.contains("**Claim**: Some claim."),
2979 "expected Claim line for memo hit, got:\n{out}"
2980 );
2981 assert!(
2982 !out.contains("**Identity**"),
2983 "memo hit must not render Identity label in list output"
2984 );
2985 }
2986
2987 #[test]
2988 fn render_list_uses_first_required_section_for_concept() {
2989 let hit = make_hit(
2990 "concepts--thing",
2991 "Thing",
2992 "concept",
2993 &[("definition", "A thing.")],
2994 );
2995 let out = render_list_markdown(&list_result(vec![hit]));
2996 assert!(
2997 out.contains("**Definition**: A thing."),
2998 "expected Definition line for concept hit, got:\n{out}"
2999 );
3000 }
3001
3002 #[test]
3003 fn render_list_missing_summary_section_shows_dash() {
3004 let hit = make_hit("memos--empty", "Empty Memo", "memo", &[]);
3005 let out = render_list_markdown(&list_result(vec![hit]));
3006 assert!(
3007 out.contains("**Claim**: —"),
3008 "expected Claim dash fallback in list output, got:\n{out}"
3009 );
3010 }
3011
3012 #[test]
3013 fn render_list_mixes_schemas_in_one_result() {
3014 let spec_hit = make_hit(
3015 "specs--s1",
3016 "Spec One",
3017 "spec",
3018 &[("identity", "Spec body.")],
3019 );
3020 let memo_hit = make_hit("memos--m1", "Memo One", "memo", &[("claim", "Memo claim.")]);
3021 let out = render_list_markdown(&list_result(vec![spec_hit, memo_hit]));
3022 assert!(
3023 out.contains("**Identity**: Spec body."),
3024 "spec hit should still render Identity in list output, got:\n{out}"
3025 );
3026 assert!(
3027 out.contains("**Claim**: Memo claim."),
3028 "memo hit should render Claim in list output, got:\n{out}"
3029 );
3030 }
3031
3032 #[test]
3033 fn render_list_unknown_schema_shows_summary_dash() {
3034 let hit = make_hit("bogus--x", "Bogus", "bogus", &[]);
3035 let out = render_list_markdown(&list_result(vec![hit]));
3036 assert!(
3037 out.contains("**Summary**: —"),
3038 "unknown schema should render Summary dash in list output, got:\n{out}"
3039 );
3040 }
3041
3042 #[test]
3047 fn summary_pair_for_spec_returns_identity() {
3048 let schema = type_by_name("spec");
3049 let mut sections = HashMap::new();
3050 sections.insert("identity".to_string(), "A demo spec.".to_string());
3051 assert_eq!(
3052 summary_pair(schema.as_deref(), §ions),
3053 ("Identity".to_string(), "A demo spec.".to_string()),
3054 );
3055 }
3056
3057 #[test]
3058 fn summary_pair_for_memo_returns_claim() {
3059 let schema = type_by_name("memo");
3060 let mut sections = HashMap::new();
3061 sections.insert("claim".to_string(), "Memos matter.".to_string());
3062 assert_eq!(
3063 summary_pair(schema.as_deref(), §ions),
3064 ("Claim".to_string(), "Memos matter.".to_string()),
3065 );
3066 }
3067
3068 #[test]
3069 fn summary_pair_missing_section_returns_dash() {
3070 let schema = type_by_name("memo");
3071 assert_eq!(
3072 summary_pair(schema.as_deref(), &HashMap::new()),
3073 ("Claim".to_string(), "—".to_string()),
3074 );
3075 }
3076
3077 #[test]
3078 fn summary_pair_unknown_schema_returns_summary_dash() {
3079 assert_eq!(
3080 summary_pair(None, &HashMap::new()),
3081 ("Summary".to_string(), "—".to_string()),
3082 );
3083 }
3084
3085 #[test]
3090 fn envelope_serializes_summary_fields() {
3091 let hit = make_hit(
3092 "memos--d1",
3093 "Memo One",
3094 "memo",
3095 &[("claim", "Memos matter.")],
3096 );
3097 let result = search_result(vec![hit]);
3098 let envelope = build_search_envelope(&result, 0);
3099 let value = serde_json::to_value(&envelope).expect("envelope must serialize");
3100
3101 assert_eq!(value["_total"], 1);
3105 assert_eq!(value["_returned"], 1);
3106 assert_eq!(value["_offset"], 0);
3107 assert!(
3109 value.get("warnings").is_none(),
3110 "empty warnings must be elided, got: {value}"
3111 );
3112
3113 let hit0 = &value["hits"][0];
3114 assert_eq!(hit0["summary_heading"], "Claim");
3115 assert_eq!(hit0["summary_value"], "Memos matter.");
3116 assert_eq!(hit0["id"], "memos--d1");
3118 assert_eq!(hit0["title"], "Memo One");
3119 assert_eq!(hit0["entity_type"], "memo");
3120 assert_eq!(hit0["mem"], "memos");
3121 assert_eq!(hit0["stub"], false);
3122 assert_eq!(hit0["tokens"], 10);
3123 assert!(hit0["sections"].is_object());
3124 }
3125
3126 #[test]
3127 fn envelope_roundtrips_through_structured_content() {
3128 let spec_hit = make_hit(
3131 "specs--s1",
3132 "Spec One",
3133 "spec",
3134 &[("identity", "Spec body.")],
3135 );
3136 let memo_hit = make_hit("memos--m1", "Memo One", "memo", &[("claim", "Memo claim.")]);
3137 let result = search_result(vec![spec_hit, memo_hit]);
3138 let envelope = build_search_envelope(&result, 0);
3139 let value = serde_json::to_value(&envelope).expect("envelope must serialize");
3140
3141 let hits = value["hits"].as_array().expect("hits must be array");
3142 assert_eq!(hits.len(), 2);
3143 assert_eq!(hits[0]["summary_heading"], "Identity");
3144 assert_eq!(hits[0]["summary_value"], "Spec body.");
3145 assert_eq!(hits[1]["summary_heading"], "Claim");
3146 assert_eq!(hits[1]["summary_value"], "Memo claim.");
3147 }
3148
3149 #[test]
3150 fn list_envelope_includes_total_tokens() {
3151 let hit = make_hit(
3152 "concepts--c1",
3153 "Thing",
3154 "concept",
3155 &[("definition", "A thing.")],
3156 );
3157 let result = list_result(vec![hit]);
3158 let envelope = build_list_envelope(&result);
3159 let value = serde_json::to_value(&envelope).expect("envelope must serialize");
3160
3161 assert_eq!(value["_total"], 1);
3163 assert_eq!(value["_total_tokens"], 10);
3164 assert!(value.get("total").is_none(), "unprefixed keys retired");
3165 assert_eq!(value["hits"][0]["summary_heading"], "Definition");
3166 assert_eq!(value["hits"][0]["summary_value"], "A thing.");
3167 }
3168
3169 #[test]
3170 fn envelope_emits_warnings_when_present() {
3171 let mut result = search_result(vec![]);
3172 result.warnings = vec![crate::ops::WarningHint::FieldNotFilterable {
3175 field: "foo".to_string(),
3176 }];
3177 let envelope = build_search_envelope(&result, 0);
3178 let value = serde_json::to_value(&envelope).expect("envelope must serialize");
3179 assert_eq!(value["warnings"][0]["code"], "FIELD_NOT_FILTERABLE");
3180 assert_eq!(value["warnings"][0]["details"]["field"], "foo");
3181 assert!(
3182 value["warnings"][0]["message"]
3183 .as_str()
3184 .is_some_and(|m| m.contains("not filterable"))
3185 );
3186 }
3187
3188 fn tm(field: &str, snippet: &str, heading_path: Option<&[&str]>) -> TermMatch {
3193 TermMatch {
3194 field: field.to_string(),
3195 snippet: snippet.to_string(),
3196 heading_path: heading_path.map(|p| p.iter().map(|s| s.to_string()).collect()),
3197 }
3198 }
3199
3200 fn sample_facets() -> Facets {
3201 use crate::ops::SubsectionFacet;
3202 Facets {
3203 by_type: HashMap::from([
3204 ("spec".to_string(), 7),
3205 ("memo".to_string(), 3),
3206 ("decision".to_string(), 2),
3207 ]),
3208 by_mem: HashMap::from([("specs".to_string(), 10), ("memos".to_string(), 2)]),
3209 by_level: HashMap::from([("high".to_string(), 4)]),
3210 by_status: HashMap::from([("active".to_string(), 6)]),
3211 by_confidence: HashMap::from([("medium".to_string(), 3)]),
3212 by_subsection: vec![
3213 SubsectionFacet {
3214 path: vec!["specifies".to_string(), "Response Shapes".to_string()],
3215 count: 4,
3216 },
3217 SubsectionFacet {
3218 path: vec!["purpose".to_string(), "Rationale".to_string()],
3219 count: 2,
3220 },
3221 ],
3222 by_expansion: HashMap::from([("primary".to_string(), 8), ("expanded".to_string(), 4)]),
3223 }
3224 }
3225
3226 #[test]
3227 fn render_search_emits_matched_terms_line() {
3228 let mut hit = make_hit(
3229 "specs--e1",
3230 "Entity One",
3231 "spec",
3232 &[("identity", "Body text.")],
3233 );
3234 hit.matched_terms = Some(HashMap::from([
3235 (
3236 "entity".to_string(),
3237 vec![
3238 tm("title", "...entity...", None),
3239 tm("purpose", "...entity...", None),
3240 tm("purpose", "...entity two...", None),
3241 ],
3242 ),
3243 ("one".to_string(), vec![tm("title", "...one...", None)]),
3244 ]));
3245 let out = render_search_markdown(&search_result(vec![hit]), 0);
3246 assert!(
3247 out.contains("**Matched terms:**"),
3248 "missing Matched terms line; got:\n{out}"
3249 );
3250 assert!(
3251 out.contains("`entity` (purpose×2, title×1)"),
3252 "entity term grouping wrong; got:\n{out}"
3253 );
3254 assert!(
3255 out.contains("`one` (title×1)"),
3256 "one term grouping wrong; got:\n{out}"
3257 );
3258 }
3259
3260 #[test]
3261 fn render_search_emits_score_breakdown_line() {
3262 let mut hit = make_hit("specs--e1", "Entity", "spec", &[("identity", "b")]);
3263 hit.score_breakdown = Some(ScoreBreakdown {
3264 bm25: 2.5,
3265 title_boost: 2.0,
3266 field_weights: HashMap::from([("body".to_string(), 0.8), ("purpose".to_string(), 0.3)]),
3267 expansion_decay: Some(0.5),
3268 });
3269 let out = render_search_markdown(&search_result(vec![hit]), 0);
3270 assert!(
3271 out.contains(
3272 "**Score:** bm25 2.5 + title 2.0 + body 0.8 + purpose 0.3 + expansion_decay ×0.5"
3273 ),
3274 "score breakdown line wrong; got:\n{out}"
3275 );
3276 }
3277
3278 #[test]
3279 fn render_search_omits_expansion_decay_when_none() {
3280 let mut hit = make_hit("specs--e1", "Entity", "spec", &[("identity", "b")]);
3281 hit.score_breakdown = Some(ScoreBreakdown {
3282 bm25: 1.5,
3283 title_boost: 1.0,
3284 field_weights: HashMap::new(),
3285 expansion_decay: None,
3286 });
3287 let out = render_search_markdown(&search_result(vec![hit]), 0);
3288 assert!(
3289 out.contains("**Score:** bm25 1.5 + title 1.0"),
3290 "base score wrong; got:\n{out}"
3291 );
3292 assert!(
3293 !out.contains("expansion_decay"),
3294 "expansion_decay must be absent when None; got:\n{out}"
3295 );
3296 }
3297
3298 #[test]
3299 fn render_search_emits_heading_path_line() {
3300 let mut hit = make_hit("specs--e1", "Entity", "spec", &[("identity", "b")]);
3301 hit.matched_terms = Some(HashMap::from([(
3302 "x".to_string(),
3303 vec![
3304 tm("purpose", "...x...", Some(&["Purpose", "Rationale"])),
3305 tm("purpose", "...x...", Some(&["Purpose", "Rationale"])), tm("specifies", "...x...", Some(&["Specifies", "Responses"])),
3307 ],
3308 )]));
3309 let out = render_search_markdown(&search_result(vec![hit]), 0);
3310 assert!(
3311 out.contains("**Heading path:** Purpose › Rationale; Specifies › Responses"),
3312 "heading path line wrong; got:\n{out}"
3313 );
3314 }
3315
3316 #[test]
3317 fn render_search_emits_expansion_line() {
3318 let mut hit = make_hit("specs--e2", "Entity Two", "spec", &[("identity", "b")]);
3319 hit.expansion = Some(ExpansionInfo {
3320 of: EntityId("specs--seed".to_string()),
3321 via_edge: "refines".to_string(),
3322 via_direction: crate::graph::query::TraversalDirection::Out,
3323 depth: 1,
3324 });
3325 let out = render_search_markdown(&search_result(vec![hit]), 0);
3326 assert!(
3327 out.contains("**Expansion:** from `specs--seed` via `refines` [out] (depth 1)"),
3328 "expansion line reports the traversal direction beside the label; got:\n{out}"
3329 );
3330 }
3331
3332 #[test]
3333 fn render_search_emits_facets_block() {
3334 let mut result = search_result(vec![]);
3335 result.facets = Some(sample_facets());
3336 let out = render_search_markdown(&result, 0);
3337 assert!(
3338 out.contains("## Facets"),
3339 "facets header missing; got:\n{out}"
3340 );
3341 assert!(
3342 out.contains("- **by_type:** spec=7, memo=3, decision=2"),
3343 "by_type bucket wrong; got:\n{out}"
3344 );
3345 assert!(
3346 out.contains("- **by_mem:** specs=10, memos=2"),
3347 "by_mem bucket wrong; got:\n{out}"
3348 );
3349 assert!(
3350 out.contains("- **by_level:** high=4"),
3351 "by_level bucket wrong; got:\n{out}"
3352 );
3353 assert!(
3354 out.contains("- **by_status:** active=6"),
3355 "by_status bucket wrong; got:\n{out}"
3356 );
3357 assert!(
3358 out.contains("- **by_confidence:** medium=3"),
3359 "by_confidence bucket wrong; got:\n{out}"
3360 );
3361 assert!(
3362 out.contains("- **by_expansion:** primary=8, expanded=4"),
3363 "by_expansion bucket wrong; got:\n{out}"
3364 );
3365 assert!(
3366 out.contains("- **by_subsection:**"),
3367 "by_subsection header missing; got:\n{out}"
3368 );
3369 assert!(
3370 out.contains("`specifies › Response Shapes`: 4"),
3371 "subsection facet wrong; got:\n{out}"
3372 );
3373 }
3374
3375 #[test]
3376 fn render_search_omits_facets_block_when_all_empty() {
3377 let mut result = search_result(vec![]);
3378 result.facets = Some(Facets::default());
3379 let out = render_search_markdown(&result, 0);
3380 assert!(
3381 !out.contains("## Facets"),
3382 "empty facets must not emit header; got:\n{out}"
3383 );
3384 }
3385
3386 #[test]
3390 fn search_markdown_covers_every_sidecar_field() {
3391 let mut hit = make_hit(
3392 "specs--e1",
3393 "Entity One",
3394 "spec",
3395 &[("identity", "Body text.")],
3396 );
3397 hit.matched_terms = Some(HashMap::from([(
3398 "entity".to_string(),
3399 vec![tm("title", "...entity...", Some(&["Purpose", "Rationale"]))],
3400 )]));
3401 hit.score_breakdown = Some(ScoreBreakdown {
3402 bm25: 1.5,
3403 title_boost: 1.0,
3404 field_weights: HashMap::from([("body".to_string(), 0.4)]),
3405 expansion_decay: Some(0.5),
3406 });
3407 hit.expansion = Some(ExpansionInfo {
3408 of: EntityId("specs--seed".to_string()),
3409 via_edge: "refines".to_string(),
3410 via_direction: crate::graph::query::TraversalDirection::Out,
3411 depth: 2,
3412 });
3413
3414 let mut result = search_result(vec![hit]);
3415 result.facets = Some(sample_facets());
3416
3417 let out = render_search_markdown(&result, 0);
3418 for marker in [
3419 "## Facets",
3420 "- **by_type:**",
3421 "- **by_mem:**",
3422 "- **by_level:**",
3423 "- **by_status:**",
3424 "- **by_confidence:**",
3425 "- **by_expansion:**",
3426 "- **by_subsection:**",
3427 "**Matched terms:**",
3428 "**Score:**",
3429 "**Heading path:**",
3430 "**Expansion:**",
3431 ] {
3432 assert!(
3433 out.contains(marker),
3434 "lockstep marker `{marker}` missing from search markdown; \
3435 update render_search_markdown when adding sidecar fields. got:\n{out}"
3436 );
3437 }
3438 }
3439
3440 #[test]
3447 fn build_entity_envelope_source_field_reads_edge_source() {
3448 let mut entity = test_entity();
3449 let body_link_target = EntityId("specs--body-link-target".to_string());
3450 let explicit_target = EntityId("specs--explicit-target".to_string());
3451 entity.relationships = vec![
3452 crate::entity::Relationship::new("REFERENCES".to_string(), body_link_target.clone()),
3453 crate::entity::Relationship::new("USES".to_string(), explicit_target.clone()),
3454 ];
3455
3456 let edges = vec![
3457 crate::store::Edge {
3458 rel_type: "REFERENCES".to_string(),
3459 target: body_link_target.clone(),
3460 source: crate::store::EdgeSource::BodyLink,
3461 },
3462 crate::store::Edge {
3463 rel_type: "USES".to_string(),
3464 target: explicit_target.clone(),
3465 source: crate::store::EdgeSource::Explicit,
3466 },
3467 ];
3468
3469 let env = build_entity_envelope(
3470 &entity,
3471 0,
3472 None,
3473 None,
3474 None,
3475 OriginClass::FirstParty,
3476 &edges,
3477 None,
3478 None,
3479 None,
3480 );
3481 let relationships = env["relationships"].as_array().expect("array");
3482 let refs = relationships
3483 .iter()
3484 .find(|r| r["rel_type"] == "REFERENCES")
3485 .expect("REFERENCES present");
3486 assert_eq!(
3487 refs["source"], "body_link",
3488 "alias-synthesised edge must label body_link"
3489 );
3490 let uses = relationships
3491 .iter()
3492 .find(|r| r["rel_type"] == "USES")
3493 .expect("USES present");
3494 assert_eq!(
3495 uses["source"], "explicit",
3496 "explicit-authored edge must label explicit"
3497 );
3498 }
3499
3500 #[test]
3507 fn build_entity_envelope_carries_origin_direction_and_incoming() {
3508 let mut entity = test_entity();
3509 let out_target = EntityId("specs--downstream".to_string());
3510 entity.relationships = vec![crate::entity::Relationship::new(
3511 "USES".to_string(),
3512 out_target.clone(),
3513 )];
3514 let edges = vec![crate::store::Edge {
3515 rel_type: "USES".to_string(),
3516 target: out_target,
3517 source: crate::store::EdgeSource::Explicit,
3518 }];
3519 let incoming = vec![crate::store::InEdge {
3520 rel_type: "MANAGES".to_string(),
3521 from: EntityId("specs--upstream".to_string()),
3522 source: crate::store::EdgeSource::Explicit,
3523 }];
3524
3525 let env = build_entity_envelope(
3527 &entity,
3528 0,
3529 None,
3530 None,
3531 None,
3532 OriginClass::ThirdParty,
3533 &edges,
3534 None,
3535 None,
3536 None,
3537 );
3538 assert_eq!(env["origin"], "third-party", "origin is envelope-level");
3539 let rels = env["relationships"].as_array().expect("array");
3540 assert_eq!(rels.len(), 1);
3541 assert_eq!(rels[0]["direction"], "out");
3542
3543 let env = build_entity_envelope(
3546 &entity,
3547 0,
3548 None,
3549 None,
3550 None,
3551 OriginClass::FirstParty,
3552 &edges,
3553 Some(&incoming),
3554 None,
3555 None,
3556 );
3557 assert_eq!(env["origin"], "first-party");
3558 let rels = env["relationships"].as_array().expect("array");
3559 assert_eq!(rels.len(), 2);
3560 let inc = rels
3561 .iter()
3562 .find(|r| r["direction"] == "in")
3563 .expect("incoming entry present");
3564 assert_eq!(inc["rel_type"], "MANAGES");
3565 assert_eq!(inc["from"], "specs--upstream");
3566 assert!(
3567 inc.get("target").is_none(),
3568 "incoming carries from, not target"
3569 );
3570 }
3571
3572 #[test]
3577 fn build_entity_envelope_source_field_falls_back_to_explicit_when_edge_missing() {
3578 let mut entity = test_entity();
3579 let target = EntityId("specs--unmapped".to_string());
3580 entity.relationships = vec![crate::entity::Relationship::new("USES".to_string(), target)];
3581 let edges: Vec<crate::store::Edge> = Vec::new();
3582 let env = build_entity_envelope(
3583 &entity,
3584 0,
3585 None,
3586 None,
3587 None,
3588 OriginClass::FirstParty,
3589 &edges,
3590 None,
3591 None,
3592 None,
3593 );
3594 let relationships = env["relationships"].as_array().expect("array");
3595 assert_eq!(relationships[0]["source"], "explicit");
3596 }
3597
3598 #[test]
3604 fn build_entity_envelope_nested_metadata_carries_every_schema_field() {
3605 use crate::entity::MetadataValue;
3606 let mut entity = test_entity();
3607 entity.entity_type = "contract".to_string();
3608 entity.metadata = IndexMap::from([
3610 ("level".to_string(), MetadataValue::String("M0".to_string())),
3611 (
3612 "stability".to_string(),
3613 MetadataValue::String("stable".to_string()),
3614 ),
3615 (
3616 "created_date".to_string(),
3617 MetadataValue::String("2026-01-01".to_string()),
3618 ),
3619 (
3620 "last_modified".to_string(),
3621 MetadataValue::String("2026-05-19".to_string()),
3622 ),
3623 (
3624 "protocol".to_string(),
3625 MetadataValue::String("https".to_string()),
3626 ),
3627 (
3628 "version".to_string(),
3629 MetadataValue::String("0.1.0".to_string()),
3630 ),
3631 (
3632 "deprecation_status".to_string(),
3633 MetadataValue::String("none".to_string()),
3634 ),
3635 ]);
3636
3637 let env = build_entity_envelope(
3638 &entity,
3639 0,
3640 None,
3641 None,
3642 None,
3643 OriginClass::FirstParty,
3644 &[],
3645 None,
3646 None,
3647 None,
3648 );
3649
3650 assert!(
3653 env.get("level").is_none(),
3654 "level must not be hoisted top-level"
3655 );
3656 assert!(
3657 env.get("stability").is_none(),
3658 "stability must not be hoisted"
3659 );
3660 assert!(
3661 env.get("created_date").is_none(),
3662 "created_date must not be hoisted"
3663 );
3664 assert!(
3665 env.get("last_modified").is_none(),
3666 "last_modified must not be hoisted"
3667 );
3668 assert_eq!(env["entity_type"], "contract");
3672 assert!(
3673 env.get("type").is_none(),
3674 "the retired wire key must not survive"
3675 );
3676
3677 let metadata = env["metadata"].as_object().expect("metadata map");
3679 assert_eq!(metadata["level"], "M0");
3680 assert_eq!(metadata["stability"], "stable");
3681 assert_eq!(metadata["created_date"], "2026-01-01");
3682 assert_eq!(metadata["last_modified"], "2026-05-19");
3683 assert_eq!(metadata["protocol"], "https");
3684 assert_eq!(metadata["version"], "0.1.0");
3685 assert_eq!(metadata["deprecation_status"], "none");
3686
3687 for k in metadata.keys() {
3690 assert!(
3691 !k.starts_with('_'),
3692 "metadata map must not carry underscore-prefixed key `{k}`"
3693 );
3694 assert!(
3695 !["mem", "id", "type"].contains(&k.as_str()),
3696 "metadata map must not carry identity key `{k}` (it lives top-level)"
3697 );
3698 }
3699 }
3700
3701 #[test]
3705 fn build_entity_envelope_stub_carries_empty_metadata_map() {
3706 let mut entity = test_entity();
3707 entity.stub = true;
3708 entity.stub_kind = Some(crate::entity::StubKind::ForwardReference);
3709 entity.metadata = IndexMap::new();
3710 let env = build_entity_envelope(
3711 &entity,
3712 0,
3713 None,
3714 None,
3715 None,
3716 OriginClass::FirstParty,
3717 &[],
3718 None,
3719 None,
3720 None,
3721 );
3722 let metadata = env["metadata"]
3723 .as_object()
3724 .expect("metadata key present even on stubs");
3725 assert!(metadata.is_empty(), "stub metadata map must be empty");
3726 }
3727
3728 #[test]
3735 fn build_entity_envelope_user_field_collisions_isolated_to_nested_map() {
3736 use crate::entity::MetadataValue;
3737 let mut entity = test_entity();
3738 entity.metadata = IndexMap::from([
3739 (
3740 "sections".to_string(),
3741 MetadataValue::String("user-supplied-shadow".to_string()),
3742 ),
3743 (
3744 "relationships".to_string(),
3745 MetadataValue::String("also-shadowed".to_string()),
3746 ),
3747 ]);
3748 let env = build_entity_envelope(
3749 &entity,
3750 0,
3751 None,
3752 None,
3753 None,
3754 OriginClass::FirstParty,
3755 &[],
3756 None,
3757 None,
3758 None,
3759 );
3760 assert!(
3762 env["sections"].is_object(),
3763 "top-level sections stays a map"
3764 );
3765 assert!(
3766 env["relationships"].is_array(),
3767 "top-level relationships stays an array"
3768 );
3769 let metadata = env["metadata"].as_object().expect("metadata map");
3771 assert_eq!(metadata["sections"], "user-supplied-shadow");
3772 assert_eq!(metadata["relationships"], "also-shadowed");
3773 }
3774
3775 #[test]
3779 fn build_entity_envelope_unfiltered_body_token_field_name() {
3780 let entity = test_entity();
3781 let env_filtered = build_entity_envelope(
3783 &entity,
3784 10,
3785 Some(42),
3786 None,
3787 None,
3788 OriginClass::FirstParty,
3789 &[],
3790 None,
3791 None,
3792 None,
3793 );
3794 assert_eq!(env_filtered["_tokens_unfiltered_body"], 42);
3795 assert!(
3796 env_filtered.get("_tokens_full").is_none(),
3797 "_tokens_full must not survive — rename is one-way"
3798 );
3799 let env_unfiltered = build_entity_envelope(
3801 &entity,
3802 10,
3803 None,
3804 None,
3805 None,
3806 OriginClass::FirstParty,
3807 &[],
3808 None,
3809 None,
3810 None,
3811 );
3812 assert!(env_unfiltered.get("_tokens_unfiltered_body").is_none());
3813 assert!(env_unfiltered.get("_tokens_full").is_none());
3814 }
3815
3816 fn software_schema() -> Arc<Schema> {
3824 memstead_schema::builtins::load_builtin_schemas()
3825 .expect("builtins load")
3826 .into_iter()
3827 .find(|s| s.manifest.name == "software")
3828 .expect("software schema is a builtin")
3829 }
3830
3831 #[test]
3832 fn schema_verbosity_wire_round_trips() {
3833 assert_eq!(
3834 SchemaVerbosity::from_wire("full"),
3835 Some(SchemaVerbosity::Full)
3836 );
3837 assert_eq!(
3838 SchemaVerbosity::from_wire("lite"),
3839 Some(SchemaVerbosity::Lite)
3840 );
3841 assert_eq!(SchemaVerbosity::from_wire("brief"), None);
3842 assert_eq!(SchemaVerbosity::from_wire(""), None);
3843 assert_eq!(SchemaVerbosity::Full.as_wire(), "full");
3844 assert_eq!(SchemaVerbosity::Lite.as_wire(), "lite");
3845 assert_eq!(SchemaVerbosity::default(), SchemaVerbosity::Full);
3846 }
3847
3848 #[test]
3854 fn exemplar_serves_at_full_and_lite_stays_byte_unchanged() {
3855 let manifest = r#"name: servefix
3856version: 1.0.0
3857description: serving fixture
3858when_to_use: tests
3859types:
3860 - sample
3861relationships:
3862 mode: strict
3863 definitions:
3864 - name: PART_OF
3865 description: hier
3866 default_weight: 3.0
3867 - name: _default
3868 description: fallback
3869 default_weight: 1.0
3870community:
3871 resolution: 1.0
3872 seed: 42
3873"#;
3874 let base_type = r#"name: sample
3875description: t
3876when_to_use: tests
3877sections:
3878 - key: body
3879 heading: Body
3880 required: true
3881 search_weight: 10.0
3882 catch_all: true
3883 write_rules: []
3884metadata_fields:
3885 - key: status
3886 description: state
3887 field_type: string
3888 enum_values: [draft, final]
3889 optional: true
3890title_weight: 100.0
3891text_fields:
3892 - body
3893hierarchy_relationship: PART_OF
3894no_self_loop_relationships: []
3895updatable_fields:
3896 - title
3897 - body
3898health_required_fields:
3899 - body
3900staleness_threshold_days: 90
3901write_rules: []
3902"#;
3903 let with_exemplar = format!(
3904 "{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"
3905 );
3906
3907 let plain = Arc::new(
3908 memstead_schema::loader::load_schema_from_memory(
3909 manifest,
3910 &[("sample".to_string(), base_type.to_string())],
3911 )
3912 .expect("fixture loads"),
3913 );
3914 let exemplary = Arc::new(
3915 memstead_schema::loader::load_schema_from_memory(
3916 manifest,
3917 &[("sample".to_string(), with_exemplar)],
3918 )
3919 .expect("fixture loads"),
3920 );
3921
3922 let full = build_schema_payload(
3924 &exemplary,
3925 vec![],
3926 SchemaVerbosity::Full,
3927 OriginClass::FirstParty,
3928 );
3929 let ex = &full["types"][0]["exemplar"];
3930 assert_eq!(ex["title"], "A Conforming Sample", "{full}");
3931 assert_eq!(ex["metadata"]["status"], "draft");
3932 assert_eq!(ex["sections"]["body"], "One canonical body paragraph.");
3933 assert_eq!(ex["relations"][0]["target"], "parent-placeholder");
3934 assert_eq!(ex["relations"][0]["rel_type"], "PART_OF");
3935
3936 let full_plain = build_schema_payload(
3938 &plain,
3939 vec![],
3940 SchemaVerbosity::Full,
3941 OriginClass::FirstParty,
3942 );
3943 assert!(full_plain["types"][0].get("exemplar").is_none());
3944
3945 let lite_with = build_schema_payload(
3948 &exemplary,
3949 vec![],
3950 SchemaVerbosity::Lite,
3951 OriginClass::FirstParty,
3952 );
3953 let lite_without = build_schema_payload(
3954 &plain,
3955 vec![],
3956 SchemaVerbosity::Lite,
3957 OriginClass::FirstParty,
3958 );
3959 assert_eq!(
3960 serde_json::to_string(&lite_with).unwrap(),
3961 serde_json::to_string(&lite_without).unwrap(),
3962 "lite must not change when an exemplar exists"
3963 );
3964 assert!(
3965 !serde_json::to_string(&lite_with)
3966 .unwrap()
3967 .contains("exemplar"),
3968 "lite must not mention exemplars at all"
3969 );
3970 }
3971
3972 #[test]
3976 fn first_party_origin_is_labelled_and_keeps_prose() {
3977 let schema = software_schema();
3978 let full = build_schema_payload(
3979 &schema,
3980 vec!["v".into()],
3981 SchemaVerbosity::Full,
3982 OriginClass::FirstParty,
3983 );
3984 assert_eq!(full["origin"], "first-party");
3985 assert!(full["description"].is_string());
3987 let t = &full["types"].as_array().unwrap()[0];
3988 assert!(t.get("system_context").is_some());
3989 assert!(t.get("writing_guidance").is_some());
3990
3991 let lite = build_schema_payload(
3993 &schema,
3994 vec!["v".into()],
3995 SchemaVerbosity::Lite,
3996 OriginClass::FirstParty,
3997 );
3998 assert_eq!(lite["origin"], "first-party");
3999 }
4000
4001 #[test]
4006 fn constraints_and_severity_render_at_both_verbosities() {
4007 let manifest = r#"name: constrained
4008version: 1.0.0
4009description: constraint render fixture
4010when_to_use: render tests
4011types:
4012 - sample
4013relationships:
4014 mode: strict
4015 definitions:
4016 - name: PART_OF
4017 description: hier
4018 default_weight: 3.0
4019 - name: _default
4020 description: fallback
4021 default_weight: 1.0
4022community:
4023 resolution: 1.0
4024 seed: 42
4025"#;
4026 let type_yaml = r#"name: sample
4027description: t
4028when_to_use: tests
4029sections:
4030 - key: body
4031 heading: Body
4032 required: true
4033 search_weight: 10.0
4034 catch_all: true
4035 write_rules: []
4036metadata_fields:
4037 - key: status
4038 description: state
4039 field_type: string
4040 enum_values: [open, checked]
4041 optional: true
4042 - key: checked_by
4043 description: who
4044 field_type: string
4045 optional: true
4046title_weight: 100.0
4047text_fields:
4048 - body
4049hierarchy_relationship: PART_OF
4050no_self_loop_relationships: []
4051updatable_fields:
4052 - title
4053 - body
4054health_required_fields:
4055 - body
4056staleness_threshold_days: 90
4057required_outgoing:
4058 - relationships: [PART_OF]
4059 cardinality: at_least_one
4060 severity: block
4061constraints:
4062 - kind: requires_when
4063 field: checked_by
4064 when_field: status
4065 when_value: checked
4066 - kind: unique
4067 fields: [status, checked_by]
4068 - kind: enum_from_neighbour
4069 field: status
4070 rel_type: PART_OF
4071 section: body
4072 - kind: status_propagation
4073 field: status
4074 value: checked
4075 rel_type: PART_OF
4076 direction: incoming
4077write_rules: []
4078"#;
4079 let schema = Arc::new(
4080 memstead_schema::loader::load_schema_from_memory(
4081 manifest,
4082 &[("sample".to_string(), type_yaml.to_string())],
4083 )
4084 .expect("fixture loads"),
4085 );
4086
4087 let expected_constraints = serde_json::json!([
4092 {
4093 "kind": "requires_when",
4094 "field": "checked_by",
4095 "when_field": "status",
4096 "when_value": "checked",
4097 "severity": "warn",
4098 },
4099 {
4100 "kind": "unique",
4101 "fields": ["status", "checked_by"],
4102 "severity": "block",
4103 },
4104 {
4105 "kind": "enum_from_neighbour",
4106 "field": "status",
4107 "rel_type": "PART_OF",
4108 "section": "body",
4109 "severity": "warn",
4110 },
4111 {
4112 "kind": "status_propagation",
4113 "field": "status",
4114 "value": "checked",
4115 "rel_type": "PART_OF",
4116 "direction": "incoming",
4117 "severity": "warn",
4118 },
4119 ]);
4120
4121 let full = build_schema_payload(
4122 &schema,
4123 vec![],
4124 SchemaVerbosity::Full,
4125 OriginClass::FirstParty,
4126 );
4127 let t = &full["types"].as_array().unwrap()[0];
4128 assert_eq!(t["constraints"], expected_constraints);
4129 assert_eq!(t["required_outgoing"][0]["severity"], "block");
4130
4131 let lite = build_schema_payload(
4132 &schema,
4133 vec![],
4134 SchemaVerbosity::Lite,
4135 OriginClass::FirstParty,
4136 );
4137 let ts = &lite["types_summary"].as_array().unwrap()[0];
4138 assert_eq!(ts["constraints"], expected_constraints);
4139 assert_eq!(ts["required_outgoing"][0]["severity"], "block");
4140
4141 let fmt_manifest = r#"name: formatted
4144version: 1.0.0
4145description: format render fixture
4146when_to_use: render tests
4147types:
4148 - plan
4149relationships:
4150 mode: strict
4151 definitions:
4152 - name: PART_OF
4153 description: hier
4154 default_weight: 1.0
4155 - name: _default
4156 description: fallback
4157 default_weight: 1.0
4158community:
4159 resolution: 1.0
4160 seed: 42
4161"#;
4162 let fmt_type = r#"name: plan
4163description: t
4164when_to_use: tests
4165sections:
4166 - key: body
4167 heading: Body
4168 required: true
4169 search_weight: 10.0
4170 catch_all: true
4171 write_rules: []
4172 - key: meilensteine
4173 heading: Meilensteine
4174 required: false
4175 search_weight: 5.0
4176 catch_all: false
4177 write_rules: []
4178 content: "(heading(3) list(bullet))+"
4179 item_pattern: '\*\*(?<name>[^*]+)\*\*'
4180 example: |
4181 ### Phase 1
4182 - **Kickoff**
4183 format_severity: warn
4184 - key: tabelle
4185 heading: Tabelle
4186 required: false
4187 search_weight: 5.0
4188 catch_all: false
4189 write_rules: []
4190 content: "table"
4191 table:
4192 columns: [Name, Datum]
4193 column_patterns:
4194 Datum: '\d{4}-\d{2}-\d{2}'
4195 - key: belege
4196 heading: Belege
4197 required: false
4198 search_weight: 5.0
4199 catch_all: false
4200 write_rules: []
4201 content: "paragraph+"
4202 item_pattern: '(?<quelle>\S[^|]*?) \| (?<aussage>.+)'
4203metadata_fields: []
4204title_weight: 100.0
4205text_fields:
4206 - body
4207hierarchy_relationship: PART_OF
4208no_self_loop_relationships: []
4209updatable_fields:
4210 - title
4211 - body
4212health_required_fields:
4213 - body
4214staleness_threshold_days: 90
4215write_rules: []
4216"#;
4217 let fmt_schema = Arc::new(
4218 memstead_schema::loader::load_schema_from_memory(
4219 fmt_manifest,
4220 &[("plan".to_string(), fmt_type.to_string())],
4221 )
4222 .expect("format fixture loads"),
4223 );
4224 for verbosity in [SchemaVerbosity::Full, SchemaVerbosity::Lite] {
4225 let payload =
4226 build_schema_payload(&fmt_schema, vec![], verbosity, OriginClass::FirstParty);
4227 let sections_key = match verbosity {
4228 SchemaVerbosity::Full => &payload["types"][0]["sections"],
4229 SchemaVerbosity::Lite => &payload["types_summary"][0]["sections"],
4230 };
4231 let secs = sections_key.as_array().unwrap();
4232 let meilensteine = secs
4233 .iter()
4234 .find(|s| s["key"] == "meilensteine")
4235 .expect("declared section present");
4236 assert_eq!(
4237 meilensteine["content"], "(heading(3) list(bullet))+",
4238 "{verbosity:?} carries content"
4239 );
4240 assert!(
4241 meilensteine["item_pattern"]
4242 .as_str()
4243 .unwrap()
4244 .contains("name")
4245 );
4246 assert!(
4247 meilensteine["example"]
4248 .as_str()
4249 .unwrap()
4250 .contains("Kickoff")
4251 );
4252 assert_eq!(meilensteine["format_severity"], "warn");
4253 let tabelle = secs.iter().find(|s| s["key"] == "tabelle").unwrap();
4254 assert_eq!(tabelle["format_severity"], "block", "default renders");
4255 assert_eq!(tabelle["table"]["columns"][0], "Name");
4256 assert!(
4257 tabelle["table"]["column_patterns"]["Datum"]
4258 .as_str()
4259 .is_some()
4260 );
4261 let belege = secs.iter().find(|s| s["key"] == "belege").unwrap();
4262 assert_eq!(belege["content"], "paragraph+");
4263 assert!(belege["item_pattern"].as_str().unwrap().contains("quelle"));
4264 let body = secs.iter().find(|s| s["key"] == "body").unwrap();
4265 assert!(
4266 body.get("content").is_none() && body.get("format_severity").is_none(),
4267 "undeclared section keeps its pre-plan shape"
4268 );
4269 }
4270
4271 let plain_full = build_schema_payload(
4274 &software_schema(),
4275 vec![],
4276 SchemaVerbosity::Full,
4277 OriginClass::FirstParty,
4278 );
4279 let pt = &plain_full["types"].as_array().unwrap()[0];
4280 assert_eq!(pt["constraints"], serde_json::json!([]));
4281 let plain_lite = build_schema_payload(
4282 &software_schema(),
4283 vec![],
4284 SchemaVerbosity::Lite,
4285 OriginClass::FirstParty,
4286 );
4287 let pts = &plain_lite["types_summary"].as_array().unwrap()[0];
4288 assert_eq!(pts["constraints"], serde_json::json!([]));
4289 }
4290
4291 #[test]
4301 fn third_party_origin_forces_structural_only_even_under_full() {
4302 let schema = software_schema();
4303 let full_requested = build_schema_payload(
4304 &schema,
4305 vec!["v".into()],
4306 SchemaVerbosity::Full,
4307 OriginClass::ThirdParty,
4308 );
4309
4310 assert_eq!(full_requested["origin"], "third-party");
4312
4313 assert!(
4316 full_requested.get("types").is_none(),
4317 "third-party omits the rich `types` array even under full"
4318 );
4319 assert!(
4320 full_requested.get("relationships").is_none(),
4321 "third-party omits the rich `relationships` array even under full"
4322 );
4323 assert!(
4324 full_requested["types_summary"].is_array(),
4325 "third-party serves the structural `types_summary` skeleton"
4326 );
4327 assert!(
4328 full_requested["relationships_summary"].is_array(),
4329 "third-party serves the structural `relationships_summary` skeleton"
4330 );
4331
4332 assert!(
4334 full_requested.get("description").is_none(),
4335 "third-party drops schema description prose"
4336 );
4337 assert!(
4338 full_requested.get("when_to_use").is_none(),
4339 "third-party drops schema when_to_use prose"
4340 );
4341 assert!(
4342 full_requested.get("default_writing_guidance").is_none(),
4343 "third-party drops default_writing_guidance prose"
4344 );
4345
4346 for t in full_requested["types_summary"].as_array().unwrap() {
4348 assert!(
4349 t.get("system_context").is_none(),
4350 "third-party drops system_context"
4351 );
4352 assert!(
4353 t.get("writing_guidance").is_none(),
4354 "third-party drops writing_guidance"
4355 );
4356 assert!(
4357 t.get("description").is_none(),
4358 "third-party drops type description"
4359 );
4360 for s in t["sections"].as_array().unwrap() {
4361 assert!(
4362 s.get("write_rules").is_none(),
4363 "third-party drops section write_rules"
4364 );
4365 }
4366 }
4367 for r in full_requested["relationships_summary"].as_array().unwrap() {
4369 assert!(
4370 r.get("description").is_none(),
4371 "third-party drops rel description"
4372 );
4373 assert!(
4374 r.get("when_to_use").is_none(),
4375 "third-party drops rel when_to_use"
4376 );
4377 }
4378
4379 let lite_requested = build_schema_payload(
4383 &schema,
4384 vec!["v".into()],
4385 SchemaVerbosity::Lite,
4386 OriginClass::ThirdParty,
4387 );
4388 assert_eq!(
4389 full_requested, lite_requested,
4390 "third-party full must collapse to the lite skeleton"
4391 );
4392 }
4393
4394 #[test]
4395 fn full_payload_carries_the_rich_arrays_and_prose() {
4396 let schema = software_schema();
4397 let full = build_schema_payload(
4398 &schema,
4399 vec!["v".into()],
4400 SchemaVerbosity::Full,
4401 OriginClass::FirstParty,
4402 );
4403
4404 assert!(full["types"].is_array(), "full has `types`");
4406 assert!(full["relationships"].is_array(), "full has `relationships`");
4407 assert!(
4408 full.get("types_summary").is_none(),
4409 "full omits `types_summary`"
4410 );
4411 assert!(
4412 full.get("relationships_summary").is_none(),
4413 "full omits `relationships_summary`"
4414 );
4415 assert!(
4416 full["description"].is_string(),
4417 "full keeps schema description"
4418 );
4419 assert!(
4420 full["when_to_use"].is_string(),
4421 "full keeps schema when_to_use"
4422 );
4423 assert_eq!(full["alias_target_rel_type"], "REFERENCES");
4424
4425 let t = &full["types"].as_array().unwrap()[0];
4427 assert!(t["description"].is_string());
4428 assert!(t.get("writing_guidance").is_some());
4429 assert!(t.get("system_context").is_some());
4430 let r = &full["relationships"].as_array().unwrap()[0];
4432 assert!(r["description"].is_string());
4433 assert!(r.get("when_to_use").is_some());
4434 assert!(r.get("default_weight").is_some());
4435 }
4436
4437 #[test]
4446 fn required_outgoing_reported_with_cardinality_at_both_levels() {
4447 let reg = memstead_schema::SchemaRegistry::builtin();
4448 let project = reg
4449 .get("project", &semver::Version::new(0, 2, 0))
4450 .expect("project is a built-in");
4451
4452 for verbosity in [SchemaVerbosity::Full, SchemaVerbosity::Lite] {
4453 let payload =
4454 build_schema_payload(&project, vec![], verbosity, OriginClass::FirstParty);
4455 let types_key = if verbosity == SchemaVerbosity::Full {
4456 "types"
4457 } else {
4458 "types_summary"
4459 };
4460 let types = payload[types_key].as_array().expect("types array");
4461
4462 let mut saw_evidence = false;
4463 let mut saw_memo = false;
4464 for t in types {
4465 let ro = t
4466 .get("required_outgoing")
4467 .unwrap_or_else(|| panic!("type {} omits required_outgoing", t["name"]))
4468 .as_array()
4469 .expect("required_outgoing is an array for every type");
4470 if t["name"] == "evidence" {
4471 saw_evidence = true;
4472 assert_eq!(ro.len(), 1, "evidence declares one block");
4473 assert_eq!(
4474 ro[0]["relationships"],
4475 serde_json::json!(["STRENGTHENS", "WEAKENS", "VALIDATES", "CONTRADICTS"]),
4476 "relationship alternatives in declaration order"
4477 );
4478 assert_eq!(
4479 ro[0]["cardinality"], "at_least_one",
4480 "cardinality rendered as declared — the open upper bound \
4481 stays open, never a finite number"
4482 );
4483 } else if t["name"] == "memo" {
4484 saw_memo = true;
4487 assert!(ro.is_empty(), "memo declares no blocks → empty list");
4488 }
4489 }
4490 assert!(saw_evidence, "project schema carries the evidence type");
4491 assert!(saw_memo, "project schema carries the memo type");
4492
4493 let note = payload["no_self_loop_relationships_effect"]
4496 .as_str()
4497 .expect("effect note present at both verbosity levels");
4498 assert!(note.contains("self-loop"), "names the actual effect");
4499 assert!(
4500 !note.contains("propagates impact") || note.contains("does not propagate"),
4501 "claims no propagation behaviour beyond the self-loop refusal"
4502 );
4503 assert!(
4504 note.contains("status_propagation"),
4505 "deprecation pointer names the real propagation declaration"
4506 );
4507 }
4508 }
4509
4510 #[test]
4516 fn conditional_required_outgoing_trigger_visible_at_both_levels() {
4517 let manifest = r#"name: condro-render
4518version: 0.1.0
4519description: conditional required_outgoing render fixture
4520when_to_use: tests
4521types:
4522 - task
4523relationships:
4524 mode: strict
4525 definitions:
4526 - name: PART_OF
4527 description: hier
4528 default_weight: 3.0
4529 - name: _default
4530 description: fallback
4531 default_weight: 1.0
4532community:
4533 resolution: 1.0
4534 seed: 42
4535"#;
4536 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";
4537 let schema = Arc::new(
4538 memstead_schema::load_schema_from_memory(
4539 manifest,
4540 &[("task".to_string(), task_yaml.to_string())],
4541 )
4542 .expect("render fixture schema must parse"),
4543 );
4544
4545 for verbosity in [SchemaVerbosity::Full, SchemaVerbosity::Lite] {
4546 let payload = build_schema_payload(&schema, vec![], verbosity, OriginClass::FirstParty);
4547 let types_key = if verbosity == SchemaVerbosity::Full {
4548 "types"
4549 } else {
4550 "types_summary"
4551 };
4552 let task = &payload[types_key].as_array().expect("types array")[0];
4553 let ro = task["required_outgoing"].as_array().expect("blocks array");
4554 assert_eq!(ro.len(), 2);
4555 assert!(
4556 ro[0].get("when_field").is_none() && ro[0].get("when_value").is_none(),
4557 "unconditional block carries no when_* keys: {:?}",
4558 ro[0]
4559 );
4560 assert_eq!(ro[1]["when_field"], "status");
4561 assert_eq!(ro[1]["when_value"], "checked");
4562 }
4563 }
4564
4565 #[test]
4571 fn acyclic_sets_and_propagation_rel_types_visible_at_both_levels() {
4572 let manifest = r#"name: relsets-render
4573version: 0.1.0
4574description: relation-set render fixture
4575when_to_use: tests
4576types:
4577 - claim
4578relationships:
4579 mode: strict
4580 acyclic_sets:
4581 - [GROUNDS, CONCLUDES]
4582 definitions:
4583 - name: GROUNDS
4584 description: g
4585 default_weight: 3.0
4586 - name: CONCLUDES
4587 description: c
4588 default_weight: 3.0
4589 - name: PART_OF
4590 description: hier
4591 default_weight: 1.0
4592 - name: _default
4593 description: fallback
4594 default_weight: 1.0
4595community:
4596 resolution: 1.0
4597 seed: 42
4598"#;
4599 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";
4600 let schema = Arc::new(
4601 memstead_schema::load_schema_from_memory(
4602 manifest,
4603 &[("claim".to_string(), claim.to_string())],
4604 )
4605 .expect("render fixture schema must parse"),
4606 );
4607
4608 for verbosity in [SchemaVerbosity::Full, SchemaVerbosity::Lite] {
4609 let payload = build_schema_payload(&schema, vec![], verbosity, OriginClass::FirstParty);
4610 assert_eq!(
4611 payload["acyclic_sets"],
4612 serde_json::json!([["GROUNDS", "CONCLUDES"]]),
4613 "acyclic_sets present at {verbosity:?}"
4614 );
4615 let types_key = if verbosity == SchemaVerbosity::Full {
4616 "types"
4617 } else {
4618 "types_summary"
4619 };
4620 let claim = &payload[types_key].as_array().expect("types array")[0];
4621 let constraints = claim["constraints"].as_array().expect("constraints array");
4622 assert_eq!(
4623 constraints[0]["rel_types"],
4624 serde_json::json!(["GROUNDS", "CONCLUDES"])
4625 );
4626 assert!(
4627 constraints[0].get("rel_type").is_none(),
4628 "set declaration carries no single-name key: {:?}",
4629 constraints[0]
4630 );
4631 assert_eq!(constraints[1]["rel_type"], "PART_OF");
4632 assert!(
4633 constraints[1].get("rel_types").is_none(),
4634 "single-name declaration stays byte-identical: {:?}",
4635 constraints[1]
4636 );
4637 }
4638
4639 let plain = software_schema();
4641 for verbosity in [SchemaVerbosity::Full, SchemaVerbosity::Lite] {
4642 let payload = build_schema_payload(&plain, vec![], verbosity, OriginClass::FirstParty);
4643 assert!(
4644 payload.get("acyclic_sets").is_none(),
4645 "undeclared schema carries no acyclic_sets key"
4646 );
4647 }
4648 }
4649
4650 #[test]
4654 fn labelling_declaration_visible_at_both_levels_and_absent_when_undeclared() {
4655 let manifest = r#"name: labelling-render
4656version: 0.1.0
4657description: labelling render fixture
4658when_to_use: tests
4659types:
4660 - claim
4661relationships:
4662 mode: strict
4663 labelling:
4664 attack: [REBUTS]
4665 support:
4666 relationships: [GROUNDS]
4667 direction: out
4668 terminal_types: [claim]
4669 definitions:
4670 - name: REBUTS
4671 description: attack
4672 default_weight: 3.0
4673 - name: GROUNDS
4674 description: support
4675 default_weight: 3.0
4676 - name: PART_OF
4677 description: hier
4678 default_weight: 1.0
4679 - name: _default
4680 description: fallback
4681 default_weight: 1.0
4682community:
4683 resolution: 1.0
4684 seed: 42
4685"#;
4686 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";
4687 let schema = Arc::new(
4688 memstead_schema::load_schema_from_memory(
4689 manifest,
4690 &[("claim".to_string(), claim.to_string())],
4691 )
4692 .expect("render fixture schema must parse"),
4693 );
4694
4695 for verbosity in [SchemaVerbosity::Full, SchemaVerbosity::Lite] {
4696 let payload = build_schema_payload(&schema, vec![], verbosity, OriginClass::FirstParty);
4697 assert_eq!(
4698 payload["labelling"]["attack"],
4699 serde_json::json!(["REBUTS"]),
4700 "attack set present at {verbosity:?}"
4701 );
4702 assert_eq!(
4703 payload["labelling"]["support"]["relationships"],
4704 serde_json::json!(["GROUNDS"])
4705 );
4706 assert_eq!(payload["labelling"]["support"]["direction"], "out");
4707 }
4708
4709 let plain = software_schema();
4710 for verbosity in [SchemaVerbosity::Full, SchemaVerbosity::Lite] {
4711 let payload = build_schema_payload(&plain, vec![], verbosity, OriginClass::FirstParty);
4712 assert!(
4713 payload.get("labelling").is_none(),
4714 "undeclared schema carries no labelling key"
4715 );
4716 }
4717 }
4718
4719 #[test]
4723 fn signal_declarations_visible_at_both_levels_and_absent_when_undeclared() {
4724 let manifest = r#"name: signals-render
4725version: 0.1.0
4726description: signal render fixture
4727when_to_use: tests
4728types:
4729 - claim
4730 - objection
4731relationships:
4732 mode: strict
4733 definitions:
4734 - name: REBUTS
4735 description: r
4736 default_weight: 3.0
4737 - name: PART_OF
4738 description: hier
4739 default_weight: 1.0
4740 - name: _default
4741 description: fallback
4742 default_weight: 1.0
4743community:
4744 resolution: 1.0
4745 seed: 42
4746"#;
4747 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";
4748 let claim = format!(
4749 "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"
4750 );
4751 let objection = format!(
4752 "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}"
4753 );
4754 let schema = Arc::new(
4755 memstead_schema::load_schema_from_memory(
4756 manifest,
4757 &[
4758 ("claim".to_string(), claim),
4759 ("objection".to_string(), objection),
4760 ],
4761 )
4762 .expect("render fixture schema must parse"),
4763 );
4764
4765 for verbosity in [SchemaVerbosity::Full, SchemaVerbosity::Lite] {
4766 let payload = build_schema_payload(&schema, vec![], verbosity, OriginClass::FirstParty);
4767 let types_key = if verbosity == SchemaVerbosity::Full {
4768 "types"
4769 } else {
4770 "types_summary"
4771 };
4772 let types = payload[types_key].as_array().expect("types array");
4773 let claim = types
4774 .iter()
4775 .find(|t| t["name"] == "claim")
4776 .expect("claim type present");
4777 let sigs = claim["signals"].as_array().expect("signals array");
4778 assert_eq!(sigs[0]["name"], "attack_load");
4779 assert_eq!(sigs[0]["kind"], "edge_load");
4780 assert_eq!(sigs[0]["direction"], "in");
4781 assert_eq!(sigs[0]["thresholds"][1]["at_least"], 3);
4782 assert_eq!(sigs[0]["thresholds"][1]["level"], "warn");
4783 let objection = types
4784 .iter()
4785 .find(|t| t["name"] == "objection")
4786 .expect("objection type present");
4787 assert!(
4788 objection.get("signals").is_none(),
4789 "undeclared type carries no signals key"
4790 );
4791 }
4792 }
4793
4794 #[test]
4800 fn must_reach_visible_at_both_levels_and_absent_when_undeclared() {
4801 let manifest = r#"name: mustreach-render
4802version: 0.1.0
4803description: must_reach render fixture
4804when_to_use: tests
4805types:
4806 - claim
4807 - evidence
4808relationships:
4809 mode: strict
4810 definitions:
4811 - name: GROUNDS
4812 description: g
4813 default_weight: 3.0
4814 - name: PART_OF
4815 description: hier
4816 default_weight: 1.0
4817 - name: _default
4818 description: fallback
4819 default_weight: 1.0
4820community:
4821 resolution: 1.0
4822 seed: 42
4823"#;
4824 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";
4825 let claim = format!(
4826 "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"
4827 );
4828 let evidence = format!("name: evidence\ndescription: t\nwhen_to_use: tests\n{body}");
4829 let schema = Arc::new(
4830 memstead_schema::load_schema_from_memory(
4831 manifest,
4832 &[
4833 ("claim".to_string(), claim),
4834 ("evidence".to_string(), evidence),
4835 ],
4836 )
4837 .expect("render fixture schema must parse"),
4838 );
4839
4840 for verbosity in [SchemaVerbosity::Full, SchemaVerbosity::Lite] {
4841 let payload = build_schema_payload(&schema, vec![], verbosity, OriginClass::FirstParty);
4842 let types_key = if verbosity == SchemaVerbosity::Full {
4843 "types"
4844 } else {
4845 "types_summary"
4846 };
4847 let types = payload[types_key].as_array().expect("types array");
4848 let claim = types
4849 .iter()
4850 .find(|t| t["name"] == "claim")
4851 .expect("claim type present");
4852 let mr = claim["must_reach"].as_array().expect("obligations array");
4853 assert_eq!(mr.len(), 1);
4854 assert_eq!(mr[0]["relationships"], serde_json::json!(["GROUNDS"]));
4855 assert_eq!(mr[0]["direction"], "out");
4856 assert_eq!(mr[0]["terminal_types"], serde_json::json!(["evidence"]));
4857 assert_eq!(mr[0]["max_depth"], 12);
4858 let evidence = types
4859 .iter()
4860 .find(|t| t["name"] == "evidence")
4861 .expect("evidence type present");
4862 assert!(
4863 evidence.get("must_reach").is_none(),
4864 "undeclared type carries no must_reach key: {evidence:?}"
4865 );
4866 }
4867 }
4868
4869 #[test]
4870 fn lite_payload_is_the_structural_skeleton_without_prose() {
4871 let schema = software_schema();
4872 let lite = build_schema_payload(
4873 &schema,
4874 vec!["v".into()],
4875 SchemaVerbosity::Lite,
4876 OriginClass::FirstParty,
4877 );
4878
4879 let types = lite["types_summary"]
4881 .as_array()
4882 .expect("lite has `types_summary`");
4883 let rels = lite["relationships_summary"]
4884 .as_array()
4885 .expect("lite has `relationships_summary`");
4886 assert!(lite.get("types").is_none(), "lite omits rich `types`");
4887 assert!(
4888 lite.get("relationships").is_none(),
4889 "lite omits rich `relationships`"
4890 );
4891
4892 assert_eq!(lite["alias_target_rel_type"], "REFERENCES");
4895
4896 assert!(
4898 lite.get("description").is_none(),
4899 "lite drops schema description"
4900 );
4901 assert!(
4902 lite.get("when_to_use").is_none(),
4903 "lite drops schema when_to_use"
4904 );
4905 assert!(
4906 lite.get("default_writing_guidance").is_none(),
4907 "lite drops default_writing_guidance"
4908 );
4909
4910 for t in types {
4913 assert!(t["name"].is_string());
4914 let sections = t["sections"].as_array().expect("lite type has sections");
4915 for s in sections {
4916 assert!(s["key"].is_string(), "section carries its key");
4917 assert!(s["required"].is_boolean(), "section carries required flag");
4918 assert!(
4919 s.get("write_rules").is_none(),
4920 "lite section drops write_rules prose"
4921 );
4922 assert!(s.get("heading").is_none(), "lite section drops heading");
4923 }
4924 assert!(
4925 t.get("description").is_none(),
4926 "lite type drops description"
4927 );
4928 assert!(
4929 t.get("writing_guidance").is_none(),
4930 "lite type drops writing_guidance"
4931 );
4932 assert!(
4933 t.get("system_context").is_none(),
4934 "lite type drops system_context"
4935 );
4936 assert!(
4940 t.get("no_self_loop_relationships").is_some(),
4941 "lite type keeps no_self_loop_relationships"
4942 );
4943 assert!(
4947 t.get("required_outgoing").is_some_and(|v| v.is_array()),
4948 "lite type keeps required_outgoing as an array"
4949 );
4950 if let Some(fields) = t["fields"].as_array() {
4952 for f in fields {
4953 assert!(f["name"].is_string());
4954 assert!(f["required"].is_boolean());
4955 assert!(
4956 f.get("description").is_none(),
4957 "lite field drops description"
4958 );
4959 }
4960 }
4961 }
4962
4963 for r in rels {
4966 assert!(r["name"].is_string());
4967 assert!(
4968 r.get("allowed_sources").is_some(),
4969 "lite rel has allowed_sources"
4970 );
4971 assert!(
4972 r.get("allowed_targets").is_some(),
4973 "lite rel has allowed_targets"
4974 );
4975 assert!(
4976 r.get("manual_authoring").is_some(),
4977 "lite rel keeps manual_authoring"
4978 );
4979 assert!(r.get("acyclic").is_some(), "lite rel keeps acyclic");
4980 assert!(
4981 r.get("per_edge_description").is_some(),
4982 "lite rel keeps per_edge_description"
4983 );
4984 assert!(r.get("description").is_none(), "lite rel drops description");
4985 assert!(r.get("when_to_use").is_none(), "lite rel drops when_to_use");
4986 assert!(
4987 r.get("default_weight").is_none(),
4988 "lite rel drops default_weight"
4989 );
4990 }
4991 }
4992
4993 #[test]
4994 fn lite_is_measurably_smaller_than_full() {
4995 let schema = software_schema();
4996 let full = build_schema_payload(
4997 &schema,
4998 vec!["v".into()],
4999 SchemaVerbosity::Full,
5000 OriginClass::FirstParty,
5001 );
5002 let lite = build_schema_payload(
5003 &schema,
5004 vec!["v".into()],
5005 SchemaVerbosity::Lite,
5006 OriginClass::FirstParty,
5007 );
5008 let full_len = serde_json::to_string(&full).unwrap().len();
5009 let lite_len = serde_json::to_string(&lite).unwrap().len();
5010 assert!(
5011 lite_len * 2 < full_len,
5012 "lite ({lite_len} B) must be well under half of full ({full_len} B)"
5013 );
5014 }
5015
5016 #[test]
5017 fn lite_full_carry_the_same_type_and_rel_names() {
5018 let schema = software_schema();
5021 let full = build_schema_payload(
5022 &schema,
5023 vec!["v".into()],
5024 SchemaVerbosity::Full,
5025 OriginClass::FirstParty,
5026 );
5027 let lite = build_schema_payload(
5028 &schema,
5029 vec!["v".into()],
5030 SchemaVerbosity::Lite,
5031 OriginClass::FirstParty,
5032 );
5033
5034 let names = |arr: &serde_json::Value| -> Vec<String> {
5035 arr.as_array()
5036 .unwrap()
5037 .iter()
5038 .map(|v| v["name"].as_str().unwrap().to_string())
5039 .collect()
5040 };
5041 assert_eq!(names(&full["types"]), names(&lite["types_summary"]));
5042 assert_eq!(
5043 names(&full["relationships"]),
5044 names(&lite["relationships_summary"])
5045 );
5046 }
5047
5048 #[test]
5053 fn swallowed_sections_carry_a_marker_on_the_plain_read() {
5054 let mut e = test_entity();
5055 e.sections.insert(
5056 "identity".to_string(),
5057 "intro\n\n```rust\nfn main() {}".to_string(),
5058 );
5059 e.sections.insert("purpose".to_string(), String::new());
5060 let env = build_entity_envelope(
5061 &e,
5062 10,
5063 None,
5064 None,
5065 None,
5066 OriginClass::FirstParty,
5067 &[],
5068 None,
5069 None,
5070 None,
5071 );
5072 let marker = &env["_unread_sections"];
5073 assert_eq!(marker["reason"], "UNTERMINATED_FENCE");
5074 assert_eq!(marker["absorbed_into"], "identity");
5075 assert_eq!(marker["sections"], serde_json::json!(["purpose"]));
5076 }
5077
5078 #[test]
5079 fn an_ordinary_entity_carries_no_unread_marker() {
5080 for body in ["plain prose", "```rust\nfn main() {}\n```"] {
5084 let mut e = test_entity();
5085 e.sections.insert("identity".to_string(), body.to_string());
5086 e.sections.insert("purpose".to_string(), String::new());
5087 let env = build_entity_envelope(
5088 &e,
5089 10,
5090 None,
5091 None,
5092 None,
5093 OriginClass::FirstParty,
5094 &[],
5095 None,
5096 None,
5097 None,
5098 );
5099 assert!(
5100 env.get("_unread_sections").is_none(),
5101 "body {body:?} produced a marker"
5102 );
5103 }
5104 }
5105}