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