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