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 let tokens = estimate_tokens(&body_text);
107 lines.push(format!("_tokens: {tokens}"));
108
109 let is_filtered = sections_filter.is_some_and(|f| {
112 let all_keys: Vec<&String> = entity.sections.keys().collect();
113 f.len() < all_keys.len() || !all_keys.iter().all(|k| f.iter().any(|fk| fk == *k))
114 });
115 if is_filtered {
116 let full_body = render_entity_body(entity, None);
117 let full_tokens = estimate_tokens(&full_body);
118 lines.push(format!("_tokens_unfiltered_body: {full_tokens}"));
119 }
120
121 for (key, value) in &entity.metadata {
123 lines.push(format!("{key}: {value}"));
124 }
125 lines.push("---".to_string());
126 lines.push(String::new());
127
128 lines.push(body_text);
129
130 if let Some(sigs) = signals
133 && !sigs.is_empty()
134 {
135 lines.push(String::new());
136 lines.push("## Signals".to_string());
137 lines.push(String::new());
138 for s in sigs {
139 if s.contributors.is_empty() {
140 lines.push(format!(
141 "- **{}**: {} ({})",
142 s.name,
143 s.value,
144 s.level_wire()
145 ));
146 } else {
147 let ids: Vec<String> = s.contributors.iter().map(|c| c.to_string()).collect();
148 lines.push(format!(
149 "- **{}**: {} ({}) — {}",
150 s.name,
151 s.value,
152 s.level_wire(),
153 ids.join(", ")
154 ));
155 }
156 }
157 }
158 if let Some(lab) = labelling {
163 lines.push(String::new());
164 lines.push("## Labelling".to_string());
165 lines.push(String::new());
166 lines.push(format!("- label: {}", lab.label.wire()));
167 if !lab.defeated_by.is_empty() {
168 lines.push(format!("- defeated_by: {}", lab.defeated_by.join(", ")));
169 }
170 if !lab.undecided_by.is_empty() {
171 lines.push(format!("- undecided_by: {}", lab.undecided_by.join(", ")));
172 }
173 if let Some(shape) = &lab.shape {
174 let share = match shape.terminal_share {
175 Some(s) => format!("{s:.2}"),
176 None => "null".to_string(),
177 };
178 lines.push(format!(
179 "- shape: depth {}, branching {:.2}, terminal_share {}, defeated_in_support {}, undecided_in_support {}",
180 shape.depth,
181 shape.branching,
182 share,
183 shape.defeated_in_support,
184 shape.undecided_in_support,
185 ));
186 }
187 }
188 lines.join("\n")
189}
190
191pub fn rendered_body_tokens(entity: &Entity, sections_filter: Option<&[String]>) -> usize {
198 estimate_tokens(&render_entity_body(entity, sections_filter))
199}
200
201fn render_entity_body(entity: &Entity, sections_filter: Option<&[String]>) -> String {
208 let mut body = Vec::new();
209
210 body.push(format!("# {}", entity.title));
211 body.push(String::new());
212
213 let type_def = lookup_builtin_type(&entity.entity_type);
221
222 for (key, content) in &entity.sections {
223 if let Some(filter) = sections_filter
224 && !filter.iter().any(|f| f == key)
225 {
226 continue;
227 }
228 let heading = section_heading_for(type_def.as_deref(), key);
229 body.push(format!("## {heading}"));
230 body.push(String::new());
231 body.push(content.trim().to_string());
232 body.push(String::new());
233 }
234
235 if !entity.relationships.is_empty()
236 && sections_filter.is_none_or(|f| f.iter().any(|s| s == "relationships"))
237 {
238 body.push("## Relationships".to_string());
239 body.push(String::new());
240 for rel in &entity.relationships {
241 match rel
245 .description
246 .as_deref()
247 .map(str::trim)
248 .filter(|s| !s.is_empty())
249 {
250 Some(text) => body.push(format!(
251 "- **{}**: [[{}]] \u{2014} {text}",
252 rel.rel_type, rel.target
253 )),
254 None => body.push(format!("- **{}**: [[{}]]", rel.rel_type, rel.target)),
255 }
256 }
257 body.push(String::new());
258 }
259
260 body.join("\n")
261}
262
263pub fn render_relations_markdown(
268 entity_id: &str,
269 outgoing: &[Edge],
270 incoming: &[InEdge],
271) -> String {
272 let mut lines = Vec::new();
273 lines.push(String::new());
274 lines.push("## Relations".to_string());
275 lines.push(String::new());
276
277 if outgoing.is_empty() && incoming.is_empty() {
278 lines.push(format!("(no relations for {entity_id})"));
279 lines.push(String::new());
280 return lines.join("\n");
281 }
282
283 if !outgoing.is_empty() {
284 lines.push("### Outgoing".to_string());
285 for e in outgoing {
286 lines.push(format!("- **{}** → [[{}]]", e.rel_type, e.target));
287 }
288 lines.push(String::new());
289 }
290
291 if !incoming.is_empty() {
292 lines.push("### Incoming".to_string());
293 for e in incoming {
294 lines.push(format!("- [[{}]] → **{}** → (this)", e.from, e.rel_type));
295 }
296 lines.push(String::new());
297 }
298
299 lines.join("\n")
300}
301
302pub fn render_relations_json(
305 entity_id: &str,
306 outgoing: &[Edge],
307 incoming: &[InEdge],
308) -> serde_json::Value {
309 let out: Vec<serde_json::Value> = outgoing
310 .iter()
311 .map(|e| {
312 serde_json::json!({
313 "type": e.rel_type,
314 "target": e.target.to_string(),
315 "source": format!("{:?}", e.source).to_lowercase(),
316 })
317 })
318 .collect();
319
320 let inc: Vec<serde_json::Value> = incoming
321 .iter()
322 .map(|e| {
323 serde_json::json!({
324 "type": e.rel_type,
325 "from": e.from.to_string(),
326 "source": format!("{:?}", e.source).to_lowercase(),
327 })
328 })
329 .collect();
330
331 serde_json::json!({
332 "entity": entity_id,
333 "outgoing": out,
334 "incoming": inc,
335 })
336}
337
338pub fn render_search_markdown(result: &SearchResult, offset: usize) -> String {
344 let mut lines = Vec::new();
345
346 lines.push("---".to_string());
347 lines.push(format!("_total: {}", result.total));
348 lines.push(format!("_returned: {}", result.returned));
349 lines.push(format!("_offset: {offset}"));
350 lines.push(format!("_total_tokens: {}", result.total_tokens));
351 lines.push("---".to_string());
352 lines.push(String::new());
353
354 if !result.warnings.is_empty() {
355 lines.push("## Filter warnings".to_string());
360 for w in &result.warnings {
361 lines.push(format!("- **{}**: {}", w.code(), w.message()));
362 }
363 lines.push(String::new());
364 }
365
366 if let Some(facets) = &result.facets
367 && let Some(block) = render_facets_block(facets)
368 {
369 lines.push(block);
370 }
371
372 for hit in &result.hits {
373 lines.push(format!(
374 "### {} — {} (_score: {:.1}, _tokens: {})",
375 hit.id, hit.title, hit.score, hit.tokens,
376 ));
377 lines.push(hit_summary_line(hit));
378 if let Some(line) = render_matched_terms_line(hit.matched_terms.as_ref()) {
379 lines.push(line);
380 }
381 if let Some(line) = render_score_breakdown_line(hit.score_breakdown.as_ref()) {
382 lines.push(line);
383 }
384 if let Some(line) = render_heading_paths_line(hit.matched_terms.as_ref()) {
385 lines.push(line);
386 }
387 if let Some(line) = render_expansion_line(hit.expansion.as_ref()) {
388 lines.push(line);
389 }
390 if let Some(snippet) = &hit.snippet {
391 lines.push(format!("> ...{snippet}..."));
392 }
393 lines.push(String::new());
394 }
395
396 lines.join("\n")
397}
398
399fn render_facets_block(facets: &Facets) -> Option<String> {
407 let blocks: Vec<(&str, String)> = [
408 ("by_type", &facets.by_type),
409 ("by_mem", &facets.by_mem),
410 ("by_level", &facets.by_level),
411 ("by_status", &facets.by_status),
412 ("by_confidence", &facets.by_confidence),
413 ("by_expansion", &facets.by_expansion),
414 ]
415 .into_iter()
416 .filter_map(|(name, bucket)| format_facet_bucket(bucket).map(|s| (name, s)))
417 .collect();
418
419 if blocks.is_empty() && facets.by_subsection.is_empty() {
420 return None;
421 }
422
423 let mut out = String::new();
424 out.push_str("## Facets\n");
425 for (name, body) in blocks {
426 out.push_str(&format!("- **{name}:** {body}\n"));
427 }
428 if !facets.by_subsection.is_empty() {
429 out.push_str("- **by_subsection:**\n");
430 for entry in &facets.by_subsection {
431 out.push_str(&format!(" - {}\n", format_subsection_facet(entry)));
432 }
433 }
434 Some(out)
435}
436
437fn format_facet_bucket(bucket: &HashMap<String, usize>) -> Option<String> {
438 if bucket.is_empty() {
439 return None;
440 }
441 let mut entries: Vec<(&String, &usize)> = bucket.iter().collect();
442 entries.sort_by(|a, b| b.1.cmp(a.1).then_with(|| a.0.cmp(b.0)));
443 Some(
444 entries
445 .iter()
446 .map(|(k, v)| format!("{k}={v}"))
447 .collect::<Vec<_>>()
448 .join(", "),
449 )
450}
451
452fn format_subsection_facet(entry: &SubsectionFacet) -> String {
453 let path = entry.path.join(" › ");
454 format!("`{path}`: {}", entry.count)
455}
456
457fn render_matched_terms_line(matched: Option<&HashMap<String, Vec<TermMatch>>>) -> Option<String> {
462 let matched = matched?;
463 if matched.is_empty() {
464 return None;
465 }
466 let mut terms: Vec<(&String, &Vec<TermMatch>)> = matched.iter().collect();
467 terms.sort_by(|a, b| a.0.cmp(b.0));
468 let groups: Vec<String> = terms
469 .iter()
470 .map(|(term, tms)| {
471 let mut field_counts: HashMap<&str, usize> = HashMap::new();
472 for tm in tms.iter() {
473 *field_counts.entry(tm.field.as_str()).or_insert(0) += 1;
474 }
475 let mut fields: Vec<(&&str, &usize)> = field_counts.iter().collect();
476 fields.sort_by(|a, b| a.0.cmp(b.0));
477 let inner: Vec<String> = fields.iter().map(|(f, n)| format!("{f}×{n}")).collect();
478 format!("`{term}` ({})", inner.join(", "))
479 })
480 .collect();
481 Some(format!("**Matched terms:** {}", groups.join(", ")))
482}
483
484fn render_score_breakdown_line(breakdown: Option<&ScoreBreakdown>) -> Option<String> {
489 let b = breakdown?;
490 let mut parts: Vec<String> = Vec::new();
491 parts.push(format!("bm25 {:.1}", b.bm25));
492 parts.push(format!("title {:.1}", b.title_boost));
493 let mut fields: Vec<(&String, &f32)> = b.field_weights.iter().collect();
494 fields.sort_by(|a, b| a.0.cmp(b.0));
495 for (k, v) in fields {
496 parts.push(format!("{k} {v:.1}"));
497 }
498 if let Some(decay) = b.expansion_decay {
499 parts.push(format!("expansion_decay ×{decay:.1}"));
500 }
501 Some(format!("**Score:** {}", parts.join(" + ")))
502}
503
504fn render_heading_paths_line(matched: Option<&HashMap<String, Vec<TermMatch>>>) -> Option<String> {
508 let matched = matched?;
509 let mut paths: Vec<Vec<String>> = Vec::new();
510 let mut term_keys: Vec<&String> = matched.keys().collect();
511 term_keys.sort();
512 for term in term_keys {
513 for tm in &matched[term] {
514 if let Some(path) = &tm.heading_path
515 && !path.is_empty()
516 && !paths.iter().any(|p| p == path)
517 {
518 paths.push(path.clone());
519 }
520 }
521 }
522 if paths.is_empty() {
523 return None;
524 }
525 let formatted: Vec<String> = paths.iter().map(|p| p.join(" › ")).collect();
526 Some(format!("**Heading path:** {}", formatted.join("; ")))
527}
528
529fn render_expansion_line(expansion: Option<&ExpansionInfo>) -> Option<String> {
533 let e = expansion?;
534 let dir = match e.via_direction {
535 crate::graph::query::TraversalDirection::Out => "out",
536 crate::graph::query::TraversalDirection::In => "in",
537 crate::graph::query::TraversalDirection::Both => "both",
540 };
541 Some(format!(
542 "**Expansion:** from `{}` via `{}` [{dir}] (depth {})",
543 e.of, e.via_edge, e.depth,
544 ))
545}
546
547pub fn render_list_markdown(result: &ListResult) -> String {
549 let mut lines = Vec::new();
550
551 lines.push("---".to_string());
552 lines.push(format!("_total: {}", result.total));
553 lines.push(format!("_returned: {}", result.returned));
554 lines.push(format!("_offset: {}", result.offset));
555 lines.push(format!("_total_tokens: {}", result.total_tokens));
556 lines.push("---".to_string());
557 lines.push(String::new());
558
559 if !result.warnings.is_empty() {
560 lines.push("## Filter warnings".to_string());
561 for w in &result.warnings {
562 lines.push(format!("- **{}**: {}", w.code(), w.message()));
563 }
564 lines.push(String::new());
565 }
566
567 for hit in &result.hits {
568 let meta = hit
569 .sections
570 .get("level")
571 .map(|l| format!("{l}, "))
572 .unwrap_or_default();
573 lines.push(format!(
574 "### {} — {} ({meta}_tokens: {})",
575 hit.id, hit.title, hit.tokens,
576 ));
577 lines.push(hit_summary_line(hit));
578 lines.push(String::new());
579 }
580
581 lines.join("\n")
582}
583
584pub fn render_community_context_section(result: &ContextResult, cluster_id: &str) -> String {
592 let mut lines = Vec::new();
593 lines.push(String::new());
594 lines.push("## Community Context".to_string());
595 lines.push(String::new());
596 lines.push(format!("**Cluster {cluster_id}**"));
597 lines.push(String::new());
598
599 if !result.neighbors.is_empty() {
600 lines.push("### Neighbors".to_string());
601 for n in &result.neighbors {
602 let dir = match n.direction {
603 Direction::Outgoing => "→",
604 Direction::Incoming => "←",
605 };
606 lines.push(format!(
607 "- {} —{}— **{}** ({})",
608 result.entity_id, dir, n.id, n.relationship,
609 ));
610 }
611 lines.push(String::new());
612 }
613
614 lines.join("\n")
615}
616
617pub fn render_context_markdown(result: &ContextResult, cluster_id: &str) -> String {
619 let mut lines = Vec::new();
620
621 lines.push("---".to_string());
622 lines.push(format!("_cluster_id: {cluster_id}"));
623 lines.push("---".to_string());
624 lines.push(String::new());
625 lines.push(format!("## Cluster {cluster_id}"));
626 lines.push(String::new());
627
628 lines.push("### Neighbors".to_string());
630 for n in &result.neighbors {
631 let dir = match n.direction {
632 Direction::Outgoing => "→",
633 Direction::Incoming => "←",
634 };
635 lines.push(format!(
636 "- {} —{}— **{}** ({})",
637 result.entity_id, dir, n.id, n.relationship,
638 ));
639 }
640 lines.push(String::new());
641
642 lines.join("\n")
643}
644
645pub fn render_overview_markdown(output: &LouvainOutput, store: &Store) -> String {
648 let mut lines = Vec::new();
649
650 let entity_count: usize = output.clusters.values().map(|c| c.entities.len()).sum();
651
652 lines.push("---".to_string());
653 lines.push(format!("_cluster_count: {}", output.count));
654 lines.push(format!("_entity_count: {entity_count}"));
655 let mod_str = if output.modularity == 0.0 {
657 "0".to_string()
658 } else {
659 format!("{:.4}", output.modularity)
660 };
661 lines.push(format!("_modularity: {mod_str}"));
662 lines.push("---".to_string());
663 lines.push(String::new());
664
665 let mut cluster_ids: Vec<&String> = output.clusters.keys().collect();
667 cluster_ids.sort();
668
669 for cluster_id in cluster_ids {
670 let info = &output.clusters[cluster_id];
671 let summary = generate_auto_summary(store, &info.entities);
672
673 lines.push(format!(
674 "## Cluster {cluster_id} ({} entities)",
675 info.entities.len(),
676 ));
677 if !summary.is_empty() {
678 lines.push(summary);
679 }
680 for entity_id in &info.entities {
681 lines.push(format!("- {entity_id}"));
682 }
683 lines.push(String::new());
684 }
685
686 lines.join("\n")
687}
688
689#[derive(Serialize)]
705pub struct SearchHitEnvelope<'a> {
706 #[serde(flatten)]
707 pub hit: &'a SearchHit,
708 pub summary_heading: String,
709 pub summary_value: String,
710}
711
712#[derive(Serialize)]
722pub struct SearchResultEnvelope<'a> {
723 #[serde(rename = "_total")]
724 pub total: usize,
725 #[serde(rename = "_returned")]
726 pub returned: usize,
727 #[serde(rename = "_offset")]
728 pub offset: usize,
729 #[serde(rename = "_total_tokens")]
733 pub total_tokens: usize,
734 pub hits: Vec<SearchHitEnvelope<'a>>,
735 #[serde(skip_serializing_if = "Option::is_none")]
740 pub facets: Option<&'a Facets>,
741 #[serde(skip_serializing_if = "Vec::is_empty")]
742 pub warnings: &'a Vec<crate::ops::WarningHint>,
743}
744
745#[derive(Serialize)]
751pub struct ListResultEnvelope<'a> {
752 #[serde(rename = "_total")]
753 pub total: usize,
754 #[serde(rename = "_returned")]
755 pub returned: usize,
756 #[serde(rename = "_offset")]
757 pub offset: usize,
758 #[serde(rename = "_total_tokens")]
759 pub total_tokens: usize,
760 pub hits: Vec<SearchHitEnvelope<'a>>,
761 #[serde(skip_serializing_if = "Vec::is_empty")]
762 pub warnings: &'a Vec<crate::ops::WarningHint>,
763}
764
765#[allow(clippy::too_many_arguments)] pub fn build_entity_envelope(
800 entity: &Entity,
801 rendered_body_tokens: usize,
802 full_tokens: Option<usize>,
803 sections_filter: Option<&[String]>,
804 schema_anchor: Option<&str>,
805 origin: OriginClass,
806 outgoing_edges: &[crate::store::Edge],
807 incoming_edges: Option<&[crate::store::InEdge]>,
808 signals: Option<&[crate::ops::signals::ComputedSignal]>,
809 labelling: Option<&crate::ops::labelling::LabellingView>,
810) -> serde_json::Value {
811 let mut envelope = serde_json::Map::new();
812 if let Some(sigs) = signals
817 && !sigs.is_empty()
818 {
819 envelope.insert(
820 "_signals".to_string(),
821 crate::ops::signals::signals_json(sigs),
822 );
823 }
824 if let Some(lab) = labelling {
829 envelope.insert("_labelling".to_string(), lab.to_json());
830 }
831 envelope.insert(
832 "_hash".to_string(),
833 serde_json::Value::String(entity.content_hash.clone()),
834 );
835 envelope.insert(
842 "origin".to_string(),
843 serde_json::Value::String(origin.as_wire().to_string()),
844 );
845 envelope.insert(
846 "id".to_string(),
847 serde_json::Value::String(entity.id.to_string()),
848 );
849 envelope.insert(
850 "mem".to_string(),
851 serde_json::Value::String(entity.mem.clone()),
852 );
853 envelope.insert(
854 "type".to_string(),
855 serde_json::Value::String(entity.entity_type.clone()),
856 );
857 envelope.insert(
862 "title".to_string(),
863 serde_json::Value::String(entity.title.clone()),
864 );
865
866 let mut metadata = serde_json::Map::new();
882 for (key, value) in &entity.metadata {
883 if key.starts_with('_')
884 || crate::runtime_validator::READ_ONLY_METADATA_KEYS.contains(&key.as_str())
885 {
886 continue;
887 }
888 metadata.insert(
889 key.clone(),
890 serde_json::Value::String(value.to_frontmatter_string()),
891 );
892 }
893 envelope.insert("metadata".to_string(), serde_json::Value::Object(metadata));
894
895 envelope.insert(
896 "_tokens".to_string(),
897 serde_json::Value::Number(serde_json::Number::from(rendered_body_tokens)),
898 );
899 if let Some(t) = full_tokens {
900 envelope.insert(
907 "_tokens_unfiltered_body".to_string(),
908 serde_json::Value::Number(serde_json::Number::from(t)),
909 );
910 }
911 if let Some(s) = schema_anchor {
912 envelope.insert(
913 "_mem_schema".to_string(),
914 serde_json::Value::String(s.to_string()),
915 );
916 }
917
918 if let Some(kind) = &entity.stub_kind {
919 envelope.insert(
920 "_stub_kind".to_string(),
921 serde_json::to_value(kind).unwrap_or(serde_json::Value::Null),
922 );
923 }
924
925 let mut sections = serde_json::Map::new();
926 for (key, content) in &entity.sections {
927 if let Some(filter) = sections_filter
928 && !filter.iter().any(|f| f == key)
929 {
930 continue;
931 }
932 sections.insert(key.clone(), serde_json::Value::String(content.clone()));
933 }
934 envelope.insert("sections".to_string(), serde_json::Value::Object(sections));
935
936 let resolve_source = |rel: &crate::entity::Relationship| -> &'static str {
947 outgoing_edges
948 .iter()
949 .find(|e| e.rel_type == rel.rel_type && e.target == rel.target)
950 .map(|e| match e.source {
951 crate::store::EdgeSource::BodyLink => "body_link",
952 crate::store::EdgeSource::Hierarchy => "hierarchy",
953 crate::store::EdgeSource::Explicit => "explicit",
954 })
955 .unwrap_or("explicit")
956 };
957 let mut relationships: Vec<serde_json::Value> = entity
965 .relationships
966 .iter()
967 .map(|rel| {
968 let mut obj = serde_json::Map::new();
969 obj.insert(
970 "rel_type".to_string(),
971 serde_json::Value::String(rel.rel_type.clone()),
972 );
973 obj.insert(
974 "target".to_string(),
975 serde_json::Value::String(rel.target.to_string()),
976 );
977 obj.insert(
978 "direction".to_string(),
979 serde_json::Value::String("out".to_string()),
980 );
981 obj.insert(
982 "source".to_string(),
983 serde_json::Value::String(resolve_source(rel).to_string()),
984 );
985 if let Some(desc) = rel
986 .description
987 .as_deref()
988 .map(str::trim)
989 .filter(|s| !s.is_empty())
990 {
991 obj.insert(
992 "description".to_string(),
993 serde_json::Value::String(desc.to_string()),
994 );
995 }
996 serde_json::Value::Object(obj)
997 })
998 .collect();
999 if let Some(incoming) = incoming_edges {
1000 for e in incoming {
1001 let mut obj = serde_json::Map::new();
1002 obj.insert(
1003 "rel_type".to_string(),
1004 serde_json::Value::String(e.rel_type.clone()),
1005 );
1006 obj.insert(
1007 "from".to_string(),
1008 serde_json::Value::String(e.from.to_string()),
1009 );
1010 obj.insert(
1011 "direction".to_string(),
1012 serde_json::Value::String("in".to_string()),
1013 );
1014 obj.insert(
1015 "source".to_string(),
1016 serde_json::Value::String(
1017 match e.source {
1018 crate::store::EdgeSource::BodyLink => "body_link",
1019 crate::store::EdgeSource::Hierarchy => "hierarchy",
1020 crate::store::EdgeSource::Explicit => "explicit",
1021 }
1022 .to_string(),
1023 ),
1024 );
1025 relationships.push(serde_json::Value::Object(obj));
1026 }
1027 }
1028 envelope.insert(
1029 "relationships".to_string(),
1030 serde_json::Value::Array(relationships),
1031 );
1032
1033 serde_json::Value::Object(envelope)
1034}
1035
1036pub fn build_search_envelope<'a>(
1038 result: &'a SearchResult,
1039 offset: usize,
1040) -> SearchResultEnvelope<'a> {
1041 SearchResultEnvelope {
1042 total: result.total,
1043 returned: result.returned,
1044 offset,
1045 total_tokens: result.total_tokens,
1046 hits: result.hits.iter().map(build_hit_envelope).collect(),
1047 facets: result.facets.as_ref(),
1048 warnings: &result.warnings,
1049 }
1050}
1051
1052pub fn build_list_envelope(result: &ListResult) -> ListResultEnvelope<'_> {
1054 ListResultEnvelope {
1055 total: result.total,
1056 returned: result.returned,
1057 offset: result.offset,
1058 total_tokens: result.total_tokens,
1059 hits: result.hits.iter().map(build_hit_envelope).collect(),
1060 warnings: &result.warnings,
1061 }
1062}
1063
1064fn build_hit_envelope(hit: &SearchHit) -> SearchHitEnvelope<'_> {
1065 let (heading, value) = hit_summary_pair(hit);
1066 SearchHitEnvelope {
1067 hit,
1068 summary_heading: heading,
1069 summary_value: value,
1070 }
1071}
1072
1073fn hit_summary_line(hit: &SearchHit) -> String {
1083 let (heading, value) = hit_summary_pair(hit);
1084 format!("**{heading}**: {value}")
1085}
1086
1087fn hit_summary_pair(hit: &SearchHit) -> (String, String) {
1097 if let Some(summary) = &hit.summary {
1098 return (summary.heading.clone(), summary.value.clone());
1099 }
1100 summary_pair(type_by_name(&hit.entity_type).as_deref(), &hit.sections)
1101}
1102
1103fn summary_pair(
1105 schema: Option<&TypeDefinition>,
1106 sections: &HashMap<String, String>,
1107) -> (String, String) {
1108 match schema {
1109 Some(schema) => lead_section_pair(schema, |k| sections.get(k).map(String::as_str)),
1110 None => ("Summary".to_string(), "—".to_string()),
1111 }
1112}
1113
1114pub(crate) fn lead_section_pair<'a>(
1122 schema: &TypeDefinition,
1123 get_section: impl Fn(&str) -> Option<&'a str>,
1124) -> (String, String) {
1125 let Some(section) = schema
1126 .required_sections()
1127 .next()
1128 .or(schema.sections.first())
1129 else {
1130 return ("Summary".to_string(), "—".to_string());
1131 };
1132 let value = get_section(section.key.as_str()).unwrap_or("—");
1133 (section.heading.clone(), value.to_string())
1134}
1135
1136fn section_key_to_heading(key: &str) -> String {
1140 let mut chars = key.chars();
1141 match chars.next() {
1142 None => String::new(),
1143 Some(c) => {
1144 let first: String = c.to_uppercase().collect();
1145 let rest: String = chars.map(|c| if c == '_' { ' ' } else { c }).collect();
1146 format!("{first}{rest}")
1147 }
1148 }
1149}
1150
1151fn section_heading_for(type_def: Option<&TypeDefinition>, key: &str) -> String {
1158 type_def
1159 .and_then(|t| t.sections.iter().find(|s| s.key == key))
1160 .map(|s| s.heading.clone())
1161 .unwrap_or_else(|| section_key_to_heading(key))
1162}
1163
1164fn lookup_builtin_type(name: &str) -> Option<Arc<TypeDefinition>> {
1173 static CACHE: OnceLock<Vec<Arc<Schema>>> = OnceLock::new();
1174 let schemas =
1175 CACHE.get_or_init(|| memstead_schema::builtins::load_builtin_schemas().unwrap_or_default());
1176 for s in schemas {
1177 if let Some(t) = s.get_type(name) {
1178 return Some(t);
1179 }
1180 }
1181 None
1182}
1183
1184pub fn render_type_catalog_markdown() -> String {
1190 render_type_catalog_lines(all_types())
1191}
1192
1193pub fn render_type_catalog_markdown_for(schema: &Schema) -> String {
1199 let mut types: Vec<Arc<TypeDefinition>> = schema.types.values().cloned().collect();
1200 types.sort_by(|a, b| a.name.as_str().cmp(b.name.as_str()));
1201 render_type_catalog_lines(types)
1202}
1203
1204fn render_type_catalog_lines(types: Vec<Arc<TypeDefinition>>) -> String {
1205 let mut lines = vec![
1206 "# Available types".to_string(),
1207 String::new(),
1208 "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."
1209 .to_string(),
1210 String::new(),
1211 ];
1212 for schema in types {
1213 let required_sections = schema.required_sections().count();
1214 let total_sections = schema.sections.len();
1215 let metadata_count = schema.metadata_fields.len();
1216 lines.push(format!(
1217 "- **{}** — {} sections ({} required), {} metadata fields, staleness {}d",
1218 schema.name.as_str(),
1219 total_sections,
1220 required_sections,
1221 metadata_count,
1222 schema.staleness_threshold_days,
1223 ));
1224 }
1225 lines.push(String::new());
1226 lines.join("\n")
1227}
1228
1229pub fn render_type_info_markdown(schema: &TypeDefinition) -> String {
1231 let mut lines = Vec::new();
1232 lines.push(format!("# Type: {}", schema.name.as_str()));
1233 lines.push(String::new());
1234 lines.push(format!(
1235 "Staleness threshold: {} days. Hierarchy: `{}`.",
1236 schema.staleness_threshold_days, schema.hierarchy_relationship,
1237 ));
1238 lines.push(String::new());
1239
1240 lines.push("## Metadata fields".to_string());
1242 for field in &schema.metadata_fields {
1243 lines.push(format!("- {}", describe_metadata_field(field)));
1244 }
1245 lines.push(String::new());
1246
1247 lines.push("## Sections".to_string());
1249 for section in &schema.sections {
1250 let req = if section.required {
1251 "required"
1252 } else {
1253 "optional"
1254 };
1255 let catch_all = if section.catch_all { ", catch-all" } else { "" };
1256 lines.push(format!(
1257 "- **{}** ({req}{catch_all}, search_weight: {:.1})",
1258 section.key, section.search_weight,
1259 ));
1260 for rule in §ion.write_rules {
1261 lines.push(format!(" - Write rule: {rule}"));
1262 }
1263 }
1264 lines.push(String::new());
1265
1266 lines.push("## Relationship types (with edge weights)".to_string());
1268 for (rel_type, weight) in &schema.edge_weights {
1269 if rel_type == "_default" {
1270 continue;
1271 }
1272 let mut flags: Vec<&str> = Vec::new();
1273 if rel_type == &schema.hierarchy_relationship {
1274 flags.push("hierarchy");
1275 }
1276 if schema
1277 .no_self_loop_relationships
1278 .iter()
1279 .any(|r| r == rel_type)
1280 {
1281 flags.push("no-self-loop");
1282 }
1283 let flag_str = if flags.is_empty() {
1284 String::new()
1285 } else {
1286 format!(" ({})", flags.join(", "))
1287 };
1288 lines.push(format!("- **{rel_type}**: {weight}{flag_str}"));
1289 }
1290 if let Some((_, default_weight)) = schema.edge_weights.iter().find(|(n, _)| *n == "_default") {
1292 lines.push(format!(
1293 "- _default_ (any other relationship type): {default_weight}"
1294 ));
1295 }
1296 lines.push(String::new());
1297
1298 if !schema.write_rules.is_empty() {
1300 lines.push("## Writing guidance".to_string());
1301 for rule in &schema.write_rules {
1302 lines.push(format!("- {rule}"));
1303 }
1304 lines.push(String::new());
1305 }
1306
1307 let system_msg = schema.system_message_str();
1309 if !system_msg.is_empty() {
1310 lines.push("## System context".to_string());
1311 lines.push(system_msg.to_string());
1312 lines.push(String::new());
1313 }
1314
1315 if let Some(ex) = &schema.exemplar {
1319 lines.push("## Exemplar (engine-validated)".to_string());
1320 lines.push(String::new());
1321 lines.push(format!("Title: {}", ex.title));
1322 if !ex.metadata.is_empty() {
1323 lines.push("Metadata:".to_string());
1324 for (k, v) in &ex.metadata {
1325 lines.push(format!("- {k}: {v}"));
1326 }
1327 }
1328 for (key, body) in &ex.sections {
1329 let heading = schema
1330 .section(key)
1331 .map(|s| s.heading.clone())
1332 .unwrap_or_else(|| key.clone());
1333 lines.push(format!("### {heading}"));
1334 lines.push(body.clone());
1335 }
1336 if !ex.relations.is_empty() {
1337 lines.push("Relations (placeholder targets):".to_string());
1338 for r in &ex.relations {
1339 match &r.description {
1340 Some(d) => lines.push(format!("- {} → {} — {d}", r.rel_type, r.to)),
1341 None => lines.push(format!("- {} → {}", r.rel_type, r.to)),
1342 }
1343 }
1344 }
1345 lines.push(String::new());
1346 }
1347
1348 lines.join("\n")
1349}
1350
1351pub fn per_edge_description_str(p: PerEdgeDescription) -> &'static str {
1357 match p {
1358 PerEdgeDescription::Forbidden => "forbidden",
1359 PerEdgeDescription::Optional => "optional",
1360 PerEdgeDescription::Required => "required",
1361 }
1362}
1363
1364pub fn manual_authoring_str(p: ManualAuthoring) -> &'static str {
1366 match p {
1367 ManualAuthoring::Allow => "allow",
1368 ManualAuthoring::Warn => "warn",
1369 ManualAuthoring::Forbidden => "forbidden",
1370 }
1371}
1372
1373#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
1389pub enum SchemaVerbosity {
1390 #[default]
1391 Full,
1392 Lite,
1393}
1394
1395impl SchemaVerbosity {
1396 pub fn from_wire(s: &str) -> Option<Self> {
1401 match s {
1402 "full" => Some(Self::Full),
1403 "lite" => Some(Self::Lite),
1404 _ => None,
1405 }
1406 }
1407
1408 pub fn as_wire(self) -> &'static str {
1410 match self {
1411 Self::Full => "full",
1412 Self::Lite => "lite",
1413 }
1414 }
1415}
1416
1417#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
1444pub enum OriginClass {
1445 FirstParty,
1447 #[default]
1450 ThirdParty,
1451}
1452
1453impl OriginClass {
1454 pub fn as_wire(self) -> &'static str {
1458 match self {
1459 Self::FirstParty => "first-party",
1460 Self::ThirdParty => "third-party",
1461 }
1462 }
1463
1464 pub fn is_third_party(self) -> bool {
1467 matches!(self, Self::ThirdParty)
1468 }
1469}
1470
1471fn append_section_format(
1492 obj: &mut serde_json::Map<String, serde_json::Value>,
1493 s: &memstead_schema::SectionDef,
1494) {
1495 if let Some(content) = &s.content {
1496 obj.insert("content".into(), serde_json::json!(content));
1497 obj.insert(
1498 "format_severity".into(),
1499 serde_json::json!(s.format_severity),
1500 );
1501 }
1502 if let Some(pattern) = &s.item_pattern {
1503 obj.insert("item_pattern".into(), serde_json::json!(pattern));
1504 }
1505 if let Some(table) = &s.table {
1506 obj.insert("table".into(), serde_json::json!(table));
1507 }
1508 if let Some(example) = &s.example {
1509 obj.insert("example".into(), serde_json::json!(example));
1510 }
1511}
1512
1513#[derive(Debug, Clone)]
1518pub struct UnknownSchemaTypes {
1519 pub unknown: Vec<String>,
1520 pub known: Vec<String>,
1521}
1522
1523fn estimate_payload_tokens(value: &serde_json::Value) -> usize {
1527 serde_json::to_string(value)
1528 .map(|s| estimate_tokens(&s))
1529 .unwrap_or(0)
1530}
1531
1532pub const DEFAULT_SCHEMA_FULL_BUDGET: usize = 15_000;
1542
1543pub fn build_schema_payload(
1544 schema: &Arc<Schema>,
1545 used_by: Vec<String>,
1546 verbosity: SchemaVerbosity,
1547 origin: OriginClass,
1548) -> serde_json::Value {
1549 build_schema_payload_scoped(schema, used_by, verbosity, origin, None, None)
1552 .expect("no type selection, no refusal")
1553}
1554
1555pub fn build_schema_payload_scoped(
1571 schema: &Arc<Schema>,
1572 used_by: Vec<String>,
1573 verbosity: SchemaVerbosity,
1574 origin: OriginClass,
1575 type_selection: Option<&[String]>,
1576 token_budget: Option<usize>,
1577) -> Result<serde_json::Value, UnknownSchemaTypes> {
1578 let manifest = &schema.manifest;
1579
1580 if let Some(sel) = type_selection {
1584 let unknown: Vec<String> = sel
1585 .iter()
1586 .filter(|t| !manifest.types.iter().any(|m| m == *t))
1587 .cloned()
1588 .collect();
1589 if !unknown.is_empty() {
1590 return Err(UnknownSchemaTypes {
1591 unknown,
1592 known: manifest.types.clone(),
1593 });
1594 }
1595 }
1596 let verbosity = if origin.is_third_party() {
1604 SchemaVerbosity::Lite
1605 } else {
1606 verbosity
1607 };
1608
1609 let relationships: Vec<serde_json::Value> = manifest
1620 .relationships
1621 .definitions
1622 .iter()
1623 .filter(|d| d.name != "_default")
1624 .map(|d| {
1625 let mut o = serde_json::json!({
1646 "name": d.name,
1647 "description": d.description,
1648 "when_to_use": d.when_to_use,
1649 "default_weight": d.default_weight,
1650 "acyclic": d.acyclic,
1651 "per_edge_description": per_edge_description_str(d.per_edge_description),
1652 "manual_authoring": manual_authoring_str(d.manual_authoring),
1653 "allowed_sources": d.source_types,
1654 "allowed_targets": d.target_types,
1655 });
1656 if d.derivation {
1662 o["derivation"] = serde_json::json!(true);
1663 }
1664 o
1665 })
1666 .collect();
1667
1668 let cross_mem_relationships: Vec<serde_json::Value> = manifest
1675 .cross_mem_relationships
1676 .iter()
1677 .map(|entry| {
1678 let definitions: Vec<serde_json::Value> = entry
1679 .definitions
1680 .iter()
1681 .filter(|d| d.name != "_default")
1682 .map(|d| {
1683 serde_json::json!({
1684 "name": d.name,
1685 "description": d.description,
1686 "when_to_use": d.when_to_use,
1687 "default_weight": d.default_weight,
1688 "source_types": d.source_types,
1689 "target_types": d.target_types,
1690 "per_edge_description": per_edge_description_str(d.per_edge_description),
1691 })
1692 })
1693 .collect();
1694 serde_json::json!({
1695 "to_schema": entry.to_schema,
1696 "definitions": definitions,
1697 })
1698 })
1699 .collect();
1700
1701 let types_full: Vec<serde_json::Value> = manifest
1704 .types
1705 .iter()
1706 .filter_map(|name| schema.types.get(name.as_str()).map(|td| (name, td)))
1707 .map(|(_, td)| {
1708 let sections: Vec<serde_json::Value> = td
1709 .sections
1710 .iter()
1711 .map(|s| {
1712 let mut obj = serde_json::json!({
1713 "key": s.key,
1714 "heading": s.heading,
1715 "required": s.required,
1716 "write_rules": s.write_rules,
1717 });
1718 append_section_format(obj.as_object_mut().unwrap(), s);
1724 obj
1725 })
1726 .collect();
1727
1728 let fields: Vec<serde_json::Value> = td
1729 .metadata_fields
1730 .iter()
1731 .map(|f| {
1732 let mut obj = serde_json::json!({
1733 "name": f.key,
1734 "description": f.description,
1735 "required": f.is_required(),
1736 });
1737 if let Some(enum_values) = &f.enum_values {
1738 obj.as_object_mut()
1739 .unwrap()
1740 .insert("enum".into(), serde_json::json!(enum_values));
1741 }
1742 if let Some(default) = &f.default_value {
1749 obj.as_object_mut()
1750 .unwrap()
1751 .insert("default".into(), serde_json::json!(default));
1752 }
1753 obj.as_object_mut().unwrap().insert(
1759 "filterable".into(),
1760 match f.filterable.as_wire_str() {
1761 Some(s) => serde_json::json!(s),
1762 None => serde_json::Value::Null,
1763 },
1764 );
1765 obj
1766 })
1767 .collect();
1768
1769 let required_outgoing: Vec<serde_json::Value> = td
1784 .required_outgoing
1785 .iter()
1786 .map(|block| {
1787 let mut b = serde_json::json!({
1788 "relationships": block.relationships,
1789 "cardinality": block.cardinality.to_string(),
1790 "severity": block.severity,
1791 });
1792 if let (Some(wf), Some(wv)) = (&block.when_field, &block.when_value) {
1797 b["when_field"] = serde_json::json!(wf);
1798 b["when_value"] = serde_json::json!(wv);
1799 }
1800 b
1801 })
1802 .collect();
1803
1804 let constraints: Vec<serde_json::Value> = td
1813 .constraints
1814 .iter()
1815 .map(|c| match c {
1816 memstead_schema::ConstraintDef::RequiresWhen {
1817 field,
1818 when_field,
1819 when_value,
1820 severity,
1821 } => serde_json::json!({
1822 "kind": "requires_when",
1823 "field": field,
1824 "when_field": when_field,
1825 "when_value": when_value,
1826 "severity": severity,
1827 }),
1828 memstead_schema::ConstraintDef::Unique { fields, severity } => {
1829 serde_json::json!({
1830 "kind": "unique",
1831 "fields": fields,
1832 "severity": severity,
1833 })
1834 }
1835 memstead_schema::ConstraintDef::EnumFromNeighbour {
1836 field,
1837 rel_type,
1838 section,
1839 severity,
1840 } => serde_json::json!({
1841 "kind": "enum_from_neighbour",
1842 "field": field,
1843 "rel_type": rel_type,
1844 "section": section,
1845 "severity": severity,
1846 }),
1847 memstead_schema::ConstraintDef::StatusPropagation {
1848 field,
1849 value,
1850 rel_type,
1851 rel_types,
1852 direction,
1853 severity,
1854 } => {
1855 let mut c = serde_json::json!({
1856 "kind": "status_propagation",
1857 "field": field,
1858 "value": value,
1859 "direction": direction,
1860 "severity": severity,
1861 });
1862 if let Some(single) = rel_type {
1866 c["rel_type"] = serde_json::json!(single);
1867 }
1868 if let Some(set) = rel_types {
1869 c["rel_types"] = serde_json::json!(set);
1870 }
1871 c
1872 }
1873 })
1874 .collect();
1875 let mut obj = serde_json::json!({
1876 "name": td.name,
1877 "description": td.description,
1878 "when_to_use": td.when_to_use,
1879 "sections": sections,
1880 "fields": fields,
1881 "writing_guidance": td.write_rules,
1882 "system_context": td.system_message_str(),
1883 "staleness_threshold_days": td.staleness_threshold_days,
1884 "no_self_loop_relationships": td.no_self_loop_relationships,
1885 "required_outgoing": required_outgoing,
1886 "constraints": constraints,
1887 });
1888 if !td.must_reach.is_empty() {
1894 obj["must_reach"] = serde_json::to_value(&td.must_reach)
1895 .expect("must_reach declarations serialize");
1896 }
1897 if !td.signals.is_empty() {
1903 obj["signals"] =
1904 serde_json::to_value(&td.signals).expect("signal declarations serialize");
1905 }
1906 if td.leaf {
1910 obj["leaf"] = serde_json::json!(true);
1911 }
1912 if let Some(ex) = &td.exemplar {
1919 let relations: Vec<serde_json::Value> = ex
1920 .relations
1921 .iter()
1922 .map(|r| {
1923 let mut o = serde_json::json!({
1924 "to": r.to,
1925 "type": r.rel_type,
1926 });
1927 if let Some(d) = &r.description {
1928 o["description"] = serde_json::json!(d);
1929 }
1930 o
1931 })
1932 .collect();
1933 obj["exemplar"] = serde_json::json!({
1934 "title": ex.title,
1935 "metadata": ex.metadata,
1936 "sections": ex.sections,
1937 "relations": relations,
1938 });
1939 }
1940 obj
1941 })
1942 .collect();
1943
1944 let mode = match manifest.relationships.mode {
1945 RelationshipMode::Strict => "strict",
1946 RelationshipMode::Open => "open",
1947 };
1948
1949 let full = verbosity == SchemaVerbosity::Full;
1950
1951 let mut payload = serde_json::json!({
1955 "ref": format!("{}@{}", manifest.name, schema.version),
1956 "relationship_mode": mode,
1957 "community": {
1958 "resolution": manifest.community.resolution,
1959 "seed": manifest.community.seed,
1960 },
1961 "used_by": used_by,
1962 "origin": origin.as_wire(),
1968 });
1969 let obj = payload.as_object_mut().unwrap();
1970
1971 if !manifest.relationships.acyclic_sets.is_empty() {
1976 obj.insert(
1977 "acyclic_sets".into(),
1978 serde_json::to_value(&manifest.relationships.acyclic_sets)
1979 .expect("acyclic_sets serialize"),
1980 );
1981 }
1982 if let Some(lab) = &manifest.relationships.labelling {
1987 obj.insert(
1988 "labelling".into(),
1989 serde_json::to_value(lab).expect("labelling declaration serializes"),
1990 );
1991 }
1992
1993 if full {
1998 obj.insert(
1999 "description".into(),
2000 serde_json::Value::String(manifest.description.clone()),
2001 );
2002 obj.insert(
2003 "when_to_use".into(),
2004 serde_json::Value::String(manifest.when_to_use.clone()),
2005 );
2006 if let Some(msg) = &manifest.system_message {
2012 obj.insert(
2013 "system_context".into(),
2014 serde_json::Value::String(msg.clone()),
2015 );
2016 }
2017 }
2018
2019 obj.insert(
2026 "no_self_loop_relationships_effect".into(),
2027 serde_json::Value::String(
2028 "Per-type `no_self_loop_relationships` governs exactly one behaviour: \
2029 memstead_relate refuses a self-loop (from == to) on a rel-type the \
2030 source type lists here. It does not propagate impact, imply an \
2031 evidence obligation, or have any other effect (the name says it \
2032 all). To declare real impact propagation, use the \
2033 `status_propagation` constraint (`constraints:` on the type), which \
2034 taints dependents of a terminal status value via a named rel-type \
2035 and direction and surfaces them as health findings."
2036 .to_string(),
2037 ),
2038 );
2039
2040 if let Some(target) = &manifest.alias_target_rel_type {
2049 obj.insert(
2050 "alias_target_rel_type".into(),
2051 serde_json::Value::String(target.clone()),
2052 );
2053 }
2054
2055 if full && let Some(dwg) = &manifest.default_writing_guidance {
2062 let mut block = serde_json::Map::new();
2063 if let Some(avoid) = &dwg.avoid {
2064 block.insert("avoid".into(), serde_json::Value::String(avoid.clone()));
2065 }
2066 if let Some(goal) = &dwg.goal {
2067 block.insert("goal".into(), serde_json::Value::String(goal.clone()));
2068 }
2069 if !block.is_empty() {
2070 obj.insert(
2071 "default_writing_guidance".into(),
2072 serde_json::Value::Object(block),
2073 );
2074 }
2075 }
2076
2077 let selected = |name: &serde_json::Value| -> bool {
2082 match type_selection {
2083 None => true,
2084 Some(sel) => name.as_str().is_some_and(|n| sel.iter().any(|s| s == n)),
2085 }
2086 };
2087 let omitted_names: Vec<serde_json::Value> = types_full
2088 .iter()
2089 .filter(|t| !selected(&t["name"]))
2090 .map(|t| t["name"].clone())
2091 .collect();
2092
2093 if full {
2094 obj.insert(
2095 "relationships".into(),
2096 serde_json::Value::Array(relationships),
2097 );
2098 if !cross_mem_relationships.is_empty() {
2102 obj.insert(
2103 "cross_mem_relationships".into(),
2104 serde_json::Value::Array(cross_mem_relationships),
2105 );
2106 }
2107 match type_selection {
2108 Some(_) => {
2109 let served: Vec<serde_json::Value> = types_full
2110 .iter()
2111 .filter(|t| selected(&t["name"]))
2112 .cloned()
2113 .collect();
2114 obj.insert("types".into(), serde_json::Value::Array(served));
2115 if !omitted_names.is_empty() {
2116 obj.insert(
2117 "types_omitted".into(),
2118 serde_json::Value::Array(omitted_names),
2119 );
2120 }
2121 }
2122 None => {
2123 obj.insert("types".into(), serde_json::Value::Array(types_full.clone()));
2124 if let Some(budget) = token_budget {
2132 let estimated = estimate_payload_tokens(&payload);
2133 if estimated > budget {
2134 let obj = payload.as_object_mut().unwrap();
2135 obj.remove("types");
2136 let all_names: Vec<serde_json::Value> =
2137 types_full.iter().map(|t| t["name"].clone()).collect();
2138 obj.insert(
2139 "types_summary".into(),
2140 serde_json::Value::Array(lite_types_projection(&types_full)),
2141 );
2142 obj.insert("types_omitted".into(), serde_json::Value::Array(all_names));
2143 obj.insert(
2144 "_schema_mode".into(),
2145 serde_json::Value::String("reduced".into()),
2146 );
2147 obj.insert("_estimated_tokens".into(), serde_json::json!(estimated));
2148 obj.insert("_token_budget".into(), serde_json::json!(budget));
2149 obj.insert(
2150 "_hint".into(),
2151 serde_json::Value::String(format!(
2152 "the full prose for all {} types (~{estimated} tokens) exceeds \
2153 the response budget ({budget}); per-type prose is served as the \
2154 lite skeleton here — request the full prose for exactly the \
2155 types you will write via `types: [\"<name>\", …]` (valid names \
2156 in `types_omitted`)",
2157 types_full.len(),
2158 )),
2159 );
2160 }
2161 }
2162 }
2163 }
2164 } else {
2165 let relationships_summary: Vec<serde_json::Value> = relationships
2175 .iter()
2176 .map(|r| {
2177 let mut o = serde_json::json!({
2178 "name": r["name"],
2179 "allowed_sources": r["allowed_sources"],
2180 "allowed_targets": r["allowed_targets"],
2181 "manual_authoring": r["manual_authoring"],
2182 "acyclic": r["acyclic"],
2183 "per_edge_description": r["per_edge_description"],
2184 });
2185 if r.get("derivation") == Some(&serde_json::json!(true)) {
2186 o["derivation"] = serde_json::json!(true);
2187 }
2188 o
2189 })
2190 .collect();
2191 obj.insert(
2192 "relationships_summary".into(),
2193 serde_json::Value::Array(relationships_summary),
2194 );
2195
2196 if !cross_mem_relationships.is_empty() {
2200 let cross_summary: Vec<serde_json::Value> = cross_mem_relationships
2201 .iter()
2202 .map(|e| {
2203 let definitions: Vec<serde_json::Value> = e["definitions"]
2204 .as_array()
2205 .map(|defs| {
2206 defs.iter()
2207 .map(|d| {
2208 serde_json::json!({
2209 "name": d["name"],
2210 "source_types": d["source_types"],
2211 "target_types": d["target_types"],
2212 })
2213 })
2214 .collect()
2215 })
2216 .unwrap_or_default();
2217 serde_json::json!({
2218 "to_schema": e["to_schema"],
2219 "definitions": definitions,
2220 })
2221 })
2222 .collect();
2223 obj.insert(
2224 "cross_mem_relationships_summary".into(),
2225 serde_json::Value::Array(cross_summary),
2226 );
2227 }
2228
2229 let served: Vec<serde_json::Value> = types_full
2233 .iter()
2234 .filter(|t| selected(&t["name"]))
2235 .cloned()
2236 .collect();
2237 obj.insert(
2238 "types_summary".into(),
2239 serde_json::Value::Array(lite_types_projection(&served)),
2240 );
2241 if !omitted_names.is_empty() {
2242 obj.insert(
2243 "types_omitted".into(),
2244 serde_json::Value::Array(omitted_names),
2245 );
2246 }
2247 }
2248
2249 Ok(payload)
2250}
2251
2252fn lite_types_projection(types_full: &[serde_json::Value]) -> Vec<serde_json::Value> {
2268 types_full
2269 .iter()
2270 .map(|t| {
2271 let sections: Vec<serde_json::Value> = t["sections"]
2272 .as_array()
2273 .map(|secs| {
2274 secs.iter()
2275 .map(|s| {
2276 let mut o = serde_json::Map::new();
2277 o.insert("key".into(), s["key"].clone());
2278 o.insert("required".into(), s["required"].clone());
2279 for k in [
2283 "content",
2284 "item_pattern",
2285 "table",
2286 "example",
2287 "format_severity",
2288 ] {
2289 if let Some(v) = s.get(k) {
2290 o.insert(k.into(), v.clone());
2291 }
2292 }
2293 serde_json::Value::Object(o)
2294 })
2295 .collect()
2296 })
2297 .unwrap_or_default();
2298 let fields: Vec<serde_json::Value> = t["fields"]
2299 .as_array()
2300 .map(|fs| {
2301 fs.iter()
2302 .map(|f| {
2303 let mut o = serde_json::Map::new();
2304 o.insert("name".into(), f["name"].clone());
2305 o.insert("required".into(), f["required"].clone());
2306 if let Some(e) = f.get("enum") {
2307 o.insert("enum".into(), e.clone());
2308 }
2309 if let Some(d) = f.get("default") {
2310 o.insert("default".into(), d.clone());
2311 }
2312 serde_json::Value::Object(o)
2313 })
2314 .collect()
2315 })
2316 .unwrap_or_default();
2317 let mut o = serde_json::json!({
2318 "name": t["name"],
2319 "sections": sections,
2320 "fields": fields,
2321 "no_self_loop_relationships": t["no_self_loop_relationships"],
2322 "required_outgoing": t["required_outgoing"],
2323 "constraints": t["constraints"],
2324 });
2325 if t.get("leaf") == Some(&serde_json::json!(true)) {
2328 o["leaf"] = serde_json::json!(true);
2329 }
2330 if let Some(mr) = t.get("must_reach") {
2334 o["must_reach"] = mr.clone();
2335 }
2336 if let Some(sig) = t.get("signals") {
2338 o["signals"] = sig.clone();
2339 }
2340 o
2341 })
2342 .collect()
2343}
2344
2345fn describe_metadata_field(field: &memstead_schema::MetadataFieldDef) -> String {
2347 let type_str = match field.field_type {
2348 FieldType::String => "String",
2349 FieldType::Number => "Number",
2350 FieldType::Date => "Date",
2351 FieldType::Boolean => "Boolean",
2352 };
2353
2354 let mut flags: Vec<&str> = Vec::new();
2355 if !field.is_required() {
2356 flags.push("optional");
2357 } else {
2358 flags.push("required");
2359 }
2360 if field.init_timestamp {
2361 flags.push("auto-init");
2362 }
2363 if field.auto_timestamp {
2364 flags.push("auto-update");
2365 }
2366 match field.serialization {
2367 Serialization::CsvArray => flags.push("csv array"),
2368 Serialization::OmitWhenFalsy => flags.push("omit when falsy"),
2369 Serialization::Default => {}
2370 }
2371
2372 let mut extras: Vec<String> = Vec::new();
2373 if let Some(values) = &field.enum_values {
2374 extras.push(format!("enum: {}", values.join(", ")));
2375 }
2376 if let Some(default) = &field.default_value {
2377 extras.push(format!("default: {default}"));
2378 }
2379 let filterable_str = match field.filterable {
2380 Filterable::None => None,
2381 Filterable::Equality => Some("filterable: equality"),
2382 Filterable::Range => Some("filterable: range"),
2383 };
2384 if let Some(f) = filterable_str {
2385 extras.push(f.to_string());
2386 }
2387
2388 let extras_str = if extras.is_empty() {
2389 String::new()
2390 } else {
2391 format!(" — {}", extras.join(" — "))
2392 };
2393
2394 format!(
2395 "**{key}**: {type_str} ({flags}){extras_str}",
2396 key = field.key,
2397 flags = flags.join(", "),
2398 )
2399}
2400
2401#[cfg(test)]
2402mod tests {
2403 use super::*;
2404 use crate::{Entity, EntityId, ListResult, SearchResult};
2405 use indexmap::IndexMap;
2406 use std::collections::HashMap;
2407
2408 fn make_hit(id: &str, title: &str, entity_type: &str, sections: &[(&str, &str)]) -> SearchHit {
2409 SearchHit {
2410 id: EntityId(id.to_string()),
2411 last_modified: None,
2412 title: title.to_string(),
2413 mem: id.split("--").next().unwrap_or("").to_string(),
2414 entity_type: entity_type.to_string(),
2415 stub: false,
2416 score: 1.0,
2417 tokens: 10,
2418 snippet: None,
2419 sections: sections
2420 .iter()
2421 .map(|(k, v)| (k.to_string(), v.to_string()))
2422 .collect(),
2423 score_breakdown: None,
2424 matched_terms: None,
2425 expansion: None,
2426 summary: None,
2429 }
2430 }
2431
2432 fn search_result(hits: Vec<SearchHit>) -> SearchResult {
2433 let returned = hits.len();
2434 let total_tokens = hits.iter().map(|h| h.tokens).sum();
2435 SearchResult {
2436 total: returned,
2437 returned,
2438 offset: 0,
2439 total_tokens,
2440 hits,
2441 facets: None,
2442 warnings: vec![],
2443 }
2444 }
2445
2446 fn list_result(hits: Vec<SearchHit>) -> ListResult {
2447 let returned = hits.len();
2448 ListResult {
2449 total: returned,
2450 returned,
2451 offset: 0,
2452 total_tokens: hits.iter().map(|h| h.tokens).sum(),
2453 hits,
2454 warnings: vec![],
2455 }
2456 }
2457
2458 fn test_entity() -> Entity {
2459 Entity {
2460 id: EntityId("specs--test-entity".to_string()),
2461 title: "Test Entity".to_string(),
2462 entity_type: "spec".to_string(),
2463 mem: "specs".to_string(),
2464 file_path: "test-entity.md".to_string(),
2465 metadata: IndexMap::new(),
2466 sections: IndexMap::from([
2467 ("identity".to_string(), "A test entity for unit tests.".to_string()),
2468 ("purpose".to_string(), "Validates render logic.".to_string()),
2469 ("specifies".to_string(), "Long section content that adds significant token weight to the full entity estimate.".to_string()),
2470 ]),
2471 relationships: vec![],
2472 content_hash: "abc123".to_string(),
2473 stub: false,
2474 stub_kind: None,
2475 heading_spans: std::collections::HashMap::new(),
2476 raw_section_headings: Vec::new(),
2477 }
2478 }
2479
2480 #[test]
2481 fn section_key_to_heading_basic() {
2482 assert_eq!(section_key_to_heading("identity"), "Identity");
2483 assert_eq!(section_key_to_heading("current_state"), "Current state");
2484 }
2485
2486 #[test]
2487 fn render_uses_schema_declared_heading_for_non_trivial_casing() {
2488 let mut sections: IndexMap<String, String> = IndexMap::new();
2494 sections.insert("claim_a".to_string(), "Body A.".to_string());
2495 sections.insert("claim_b".to_string(), "Body B.".to_string());
2496
2497 let entity = Entity {
2498 id: EntityId("ingest--example".to_string()),
2499 title: "Example".to_string(),
2500 entity_type: "inconsistency".to_string(),
2501 mem: "ingest".to_string(),
2502 file_path: "example.md".to_string(),
2503 metadata: IndexMap::new(),
2504 sections,
2505 relationships: vec![],
2506 content_hash: "h".to_string(),
2507 stub: false,
2508 stub_kind: None,
2509 heading_spans: std::collections::HashMap::new(),
2510 raw_section_headings: Vec::new(),
2511 };
2512
2513 let md = render_entity_markdown(&entity, None);
2514 assert!(
2515 md.contains("## Claim A"),
2516 "expected schema-declared `## Claim A` heading; got:\n{md}"
2517 );
2518 assert!(
2519 md.contains("## Claim B"),
2520 "expected schema-declared `## Claim B` heading; got:\n{md}"
2521 );
2522 assert!(
2524 !md.contains("## Claim a"),
2525 "renderer must not fall back to key-derivation when the \
2526 schema declares a heading; got:\n{md}"
2527 );
2528 }
2529
2530 #[test]
2531 fn render_falls_back_to_key_derivation_for_unknown_types() {
2532 let mut sections: IndexMap<String, String> = IndexMap::new();
2536 sections.insert("identity".to_string(), "body".to_string());
2537
2538 let entity = Entity {
2539 id: EntityId("custom--example".to_string()),
2540 title: "Example".to_string(),
2541 entity_type: "not-a-builtin-type".to_string(),
2542 mem: "custom".to_string(),
2543 file_path: "example.md".to_string(),
2544 metadata: IndexMap::new(),
2545 sections,
2546 relationships: vec![],
2547 content_hash: "h".to_string(),
2548 stub: false,
2549 stub_kind: None,
2550 heading_spans: std::collections::HashMap::new(),
2551 raw_section_headings: Vec::new(),
2552 };
2553
2554 let md = render_entity_markdown(&entity, None);
2555 assert!(
2556 md.contains("## Identity"),
2557 "fallback derivation must produce `## Identity`; got:\n{md}"
2558 );
2559 }
2560
2561 #[test]
2568 fn render_entity_sections_follow_indexmap_insertion_order() {
2569 let mut sections: IndexMap<String, String> = IndexMap::new();
2570 sections.insert("specifies".to_string(), "S content.".to_string());
2571 sections.insert("purpose".to_string(), "P content.".to_string());
2572 sections.insert("identity".to_string(), "I content.".to_string());
2573
2574 let entity = Entity {
2575 id: EntityId("specs--order-test".to_string()),
2576 title: "Order Test".to_string(),
2577 entity_type: "spec".to_string(),
2578 mem: "specs".to_string(),
2579 file_path: "order-test.md".to_string(),
2580 metadata: IndexMap::new(),
2581 sections,
2582 relationships: vec![],
2583 content_hash: "abc123".to_string(),
2584 stub: false,
2585 stub_kind: None,
2586 heading_spans: std::collections::HashMap::new(),
2587 raw_section_headings: Vec::new(),
2588 };
2589
2590 let md = render_entity_markdown(&entity, None);
2591 let specifies_pos = md.find("## Specifies").expect("## Specifies must appear");
2592 let purpose_pos = md.find("## Purpose").expect("## Purpose must appear");
2593 let identity_pos = md.find("## Identity").expect("## Identity must appear");
2594
2595 assert!(
2596 specifies_pos < purpose_pos,
2597 "Specifies (inserted first) must render before Purpose; got:\n{md}"
2598 );
2599 assert!(
2600 purpose_pos < identity_pos,
2601 "Purpose (inserted second) must render before Identity; got:\n{md}"
2602 );
2603 }
2604
2605 #[test]
2611 fn tokens_reflect_filtered_output() {
2612 let entity = test_entity();
2613
2614 let full = render_entity_markdown(&entity, None);
2616 assert!(full.contains("_tokens:"), "should have _tokens");
2617 assert!(
2618 !full.contains("_tokens_unfiltered_body:"),
2619 "should NOT have _tokens_unfiltered_body when unfiltered"
2620 );
2621 assert!(
2622 !full.contains("_tokens_full:"),
2623 "old _tokens_full name must not survive — rename is one-way"
2624 );
2625
2626 let filtered = render_entity_markdown(&entity, Some(&["identity".to_string()]));
2628 assert!(filtered.contains("_tokens:"), "should have _tokens");
2629 assert!(
2630 filtered.contains("_tokens_unfiltered_body:"),
2631 "should have _tokens_unfiltered_body when filtered"
2632 );
2633 assert!(
2634 !filtered.contains("_tokens_full:"),
2635 "old _tokens_full name must not survive — rename is one-way"
2636 );
2637
2638 let full_tokens: usize = full
2640 .lines()
2641 .find(|l| l.starts_with("_tokens:"))
2642 .unwrap()
2643 .trim_start_matches("_tokens: ")
2644 .parse()
2645 .unwrap();
2646 let filtered_tokens: usize = filtered
2647 .lines()
2648 .find(|l| l.starts_with("_tokens:"))
2649 .unwrap()
2650 .trim_start_matches("_tokens: ")
2651 .parse()
2652 .unwrap();
2653 let tokens_unfiltered_body: usize = filtered
2654 .lines()
2655 .find(|l| l.starts_with("_tokens_unfiltered_body:"))
2656 .unwrap()
2657 .trim_start_matches("_tokens_unfiltered_body: ")
2658 .parse()
2659 .unwrap();
2660
2661 assert!(
2662 filtered_tokens < full_tokens,
2663 "filtered _tokens ({filtered_tokens}) should be less than full _tokens ({full_tokens})"
2664 );
2665 assert!(
2666 tokens_unfiltered_body >= full_tokens,
2667 "_tokens_unfiltered_body ({tokens_unfiltered_body}) should be >= full render _tokens ({full_tokens})"
2668 );
2669 }
2670
2671 #[test]
2676 fn render_search_uses_first_required_section_for_spec() {
2677 let hit = make_hit(
2678 "specs--demo",
2679 "Demo Spec",
2680 "spec",
2681 &[
2682 ("identity", "A demo spec."),
2683 ("purpose", "Verifies rendering."),
2684 ],
2685 );
2686 let out = render_search_markdown(&search_result(vec![hit]), 0);
2687 assert!(
2688 out.contains("**Identity**: A demo spec."),
2689 "expected Identity line for spec hit, got:\n{out}"
2690 );
2691 }
2692
2693 #[test]
2694 fn render_search_uses_first_required_section_for_memo() {
2695 let hit = make_hit(
2696 "memos--d1",
2697 "Memo One",
2698 "memo",
2699 &[("claim", "Some claim."), ("context", "Some context.")],
2700 );
2701 let out = render_search_markdown(&search_result(vec![hit]), 0);
2702 assert!(
2703 out.contains("**Claim**: Some claim."),
2704 "expected Claim line for memo hit, got:\n{out}"
2705 );
2706 assert!(
2707 !out.contains("**Identity**"),
2708 "memo hit must not render Identity label"
2709 );
2710 assert!(
2711 !out.contains("**Purpose**"),
2712 "memo hit must not render Purpose label"
2713 );
2714 }
2715
2716 #[test]
2717 fn render_search_uses_first_required_section_for_concept() {
2718 let hit = make_hit(
2719 "concepts--thing",
2720 "Thing",
2721 "concept",
2722 &[("definition", "A thing."), ("explanation", "Details.")],
2723 );
2724 let out = render_search_markdown(&search_result(vec![hit]), 0);
2725 assert!(
2726 out.contains("**Definition**: A thing."),
2727 "expected Definition line for concept hit, got:\n{out}"
2728 );
2729 }
2730
2731 #[test]
2732 fn render_search_missing_summary_section_shows_dash() {
2733 let hit = make_hit("memos--empty", "Empty Memo", "memo", &[]);
2735 let out = render_search_markdown(&search_result(vec![hit]), 0);
2736 assert!(
2737 out.contains("**Claim**: —"),
2738 "expected Claim dash fallback, got:\n{out}"
2739 );
2740 }
2741
2742 #[test]
2743 fn render_search_mixes_schemas_in_one_result() {
2744 let spec_hit = make_hit(
2745 "specs--s1",
2746 "Spec One",
2747 "spec",
2748 &[("identity", "Spec body.")],
2749 );
2750 let memo_hit = make_hit("memos--m1", "Memo One", "memo", &[("claim", "Memo claim.")]);
2751 let out = render_search_markdown(&search_result(vec![spec_hit, memo_hit]), 0);
2752 assert!(
2753 out.contains("**Identity**: Spec body."),
2754 "spec hit should still render Identity, got:\n{out}"
2755 );
2756 assert!(
2757 out.contains("**Claim**: Memo claim."),
2758 "memo hit should render Claim in the same output, got:\n{out}"
2759 );
2760 }
2761
2762 #[test]
2763 fn render_search_unknown_schema_shows_summary_dash() {
2764 let hit = make_hit("bogus--x", "Bogus", "bogus", &[]);
2765 let out = render_search_markdown(&search_result(vec![hit]), 0);
2766 assert!(
2767 out.contains("**Summary**: —"),
2768 "unknown schema should render Summary dash, got:\n{out}"
2769 );
2770 }
2771
2772 #[test]
2773 fn summary_pair_falls_back_when_schema_has_no_required_sections() {
2774 use memstead_schema::{SectionDef, TypeDefinition};
2775
2776 let schema = TypeDefinition {
2777 name: "spec".to_string(),
2778 description: "test".to_string(),
2779 when_to_use: "test".to_string(),
2780 boundaries: vec![],
2781 exemplar: None,
2782 legacy_examples: None,
2783 system_message: None,
2784 sections: vec![SectionDef {
2785 key: "note".to_string(),
2786 heading: "Note".to_string(),
2787 required: false,
2788 search_weight: 1.0,
2789 catch_all: false,
2790 write_rules: vec![],
2791 description: None,
2792 content: None,
2793 item_pattern: None,
2794 table: None,
2795 example: None,
2796 format_severity: memstead_schema::ConstraintSeverity::Block,
2797 compiled_content: None,
2798 format_problems: Vec::new(),
2799 }],
2800 metadata_fields: vec![],
2801 title_weight: 1.0,
2802 text_fields: vec![],
2803 hierarchy_relationship: "PART_OF".to_string(),
2804 edge_weight_overrides: indexmap::IndexMap::new(),
2805 edge_weights: indexmap::IndexMap::new(),
2806 no_self_loop_relationships: vec![],
2807 legacy_propagating_relationships: None,
2808 due: None,
2809 leaf: false,
2810 updatable_fields: vec![],
2811 health_required_fields: vec![],
2812 staleness_threshold_days: 90,
2813 write_rules: vec![],
2814 required_outgoing: vec![],
2815 must_reach: vec![],
2816 signals: vec![],
2817 constraints: vec![],
2818 declared_metadata_keys: vec![],
2819 };
2820
2821 let mut sections = HashMap::new();
2822 sections.insert("note".to_string(), "a note".to_string());
2823 assert_eq!(
2824 summary_pair(Some(&schema), §ions),
2825 ("Note".to_string(), "a note".to_string()),
2826 );
2827
2828 assert_eq!(
2829 summary_pair(Some(&schema), &HashMap::new()),
2830 ("Note".to_string(), "—".to_string()),
2831 );
2832 }
2833
2834 #[test]
2839 fn render_list_uses_first_required_section_for_spec() {
2840 let hit = make_hit(
2841 "specs--demo",
2842 "Demo Spec",
2843 "spec",
2844 &[
2845 ("identity", "A demo spec."),
2846 ("purpose", "Verifies rendering."),
2847 ],
2848 );
2849 let out = render_list_markdown(&list_result(vec![hit]));
2850 assert!(
2851 out.contains("**Identity**: A demo spec."),
2852 "expected Identity line for spec hit, got:\n{out}"
2853 );
2854 }
2855
2856 #[test]
2857 fn render_list_uses_first_required_section_for_memo() {
2858 let hit = make_hit("memos--d1", "Memo One", "memo", &[("claim", "Some claim.")]);
2859 let out = render_list_markdown(&list_result(vec![hit]));
2860 assert!(
2861 out.contains("**Claim**: Some claim."),
2862 "expected Claim line for memo hit, got:\n{out}"
2863 );
2864 assert!(
2865 !out.contains("**Identity**"),
2866 "memo hit must not render Identity label in list output"
2867 );
2868 }
2869
2870 #[test]
2871 fn render_list_uses_first_required_section_for_concept() {
2872 let hit = make_hit(
2873 "concepts--thing",
2874 "Thing",
2875 "concept",
2876 &[("definition", "A thing.")],
2877 );
2878 let out = render_list_markdown(&list_result(vec![hit]));
2879 assert!(
2880 out.contains("**Definition**: A thing."),
2881 "expected Definition line for concept hit, got:\n{out}"
2882 );
2883 }
2884
2885 #[test]
2886 fn render_list_missing_summary_section_shows_dash() {
2887 let hit = make_hit("memos--empty", "Empty Memo", "memo", &[]);
2888 let out = render_list_markdown(&list_result(vec![hit]));
2889 assert!(
2890 out.contains("**Claim**: —"),
2891 "expected Claim dash fallback in list output, got:\n{out}"
2892 );
2893 }
2894
2895 #[test]
2896 fn render_list_mixes_schemas_in_one_result() {
2897 let spec_hit = make_hit(
2898 "specs--s1",
2899 "Spec One",
2900 "spec",
2901 &[("identity", "Spec body.")],
2902 );
2903 let memo_hit = make_hit("memos--m1", "Memo One", "memo", &[("claim", "Memo claim.")]);
2904 let out = render_list_markdown(&list_result(vec![spec_hit, memo_hit]));
2905 assert!(
2906 out.contains("**Identity**: Spec body."),
2907 "spec hit should still render Identity in list output, got:\n{out}"
2908 );
2909 assert!(
2910 out.contains("**Claim**: Memo claim."),
2911 "memo hit should render Claim in list output, got:\n{out}"
2912 );
2913 }
2914
2915 #[test]
2916 fn render_list_unknown_schema_shows_summary_dash() {
2917 let hit = make_hit("bogus--x", "Bogus", "bogus", &[]);
2918 let out = render_list_markdown(&list_result(vec![hit]));
2919 assert!(
2920 out.contains("**Summary**: —"),
2921 "unknown schema should render Summary dash in list output, got:\n{out}"
2922 );
2923 }
2924
2925 #[test]
2930 fn summary_pair_for_spec_returns_identity() {
2931 let schema = type_by_name("spec");
2932 let mut sections = HashMap::new();
2933 sections.insert("identity".to_string(), "A demo spec.".to_string());
2934 assert_eq!(
2935 summary_pair(schema.as_deref(), §ions),
2936 ("Identity".to_string(), "A demo spec.".to_string()),
2937 );
2938 }
2939
2940 #[test]
2941 fn summary_pair_for_memo_returns_claim() {
2942 let schema = type_by_name("memo");
2943 let mut sections = HashMap::new();
2944 sections.insert("claim".to_string(), "Memos matter.".to_string());
2945 assert_eq!(
2946 summary_pair(schema.as_deref(), §ions),
2947 ("Claim".to_string(), "Memos matter.".to_string()),
2948 );
2949 }
2950
2951 #[test]
2952 fn summary_pair_missing_section_returns_dash() {
2953 let schema = type_by_name("memo");
2954 assert_eq!(
2955 summary_pair(schema.as_deref(), &HashMap::new()),
2956 ("Claim".to_string(), "—".to_string()),
2957 );
2958 }
2959
2960 #[test]
2961 fn summary_pair_unknown_schema_returns_summary_dash() {
2962 assert_eq!(
2963 summary_pair(None, &HashMap::new()),
2964 ("Summary".to_string(), "—".to_string()),
2965 );
2966 }
2967
2968 #[test]
2973 fn envelope_serializes_summary_fields() {
2974 let hit = make_hit(
2975 "memos--d1",
2976 "Memo One",
2977 "memo",
2978 &[("claim", "Memos matter.")],
2979 );
2980 let result = search_result(vec![hit]);
2981 let envelope = build_search_envelope(&result, 0);
2982 let value = serde_json::to_value(&envelope).expect("envelope must serialize");
2983
2984 assert_eq!(value["_total"], 1);
2988 assert_eq!(value["_returned"], 1);
2989 assert_eq!(value["_offset"], 0);
2990 assert!(
2992 value.get("warnings").is_none(),
2993 "empty warnings must be elided, got: {value}"
2994 );
2995
2996 let hit0 = &value["hits"][0];
2997 assert_eq!(hit0["summary_heading"], "Claim");
2998 assert_eq!(hit0["summary_value"], "Memos matter.");
2999 assert_eq!(hit0["id"], "memos--d1");
3001 assert_eq!(hit0["title"], "Memo One");
3002 assert_eq!(hit0["entity_type"], "memo");
3003 assert_eq!(hit0["mem"], "memos");
3004 assert_eq!(hit0["stub"], false);
3005 assert_eq!(hit0["tokens"], 10);
3006 assert!(hit0["sections"].is_object());
3007 }
3008
3009 #[test]
3010 fn envelope_roundtrips_through_structured_content() {
3011 let spec_hit = make_hit(
3014 "specs--s1",
3015 "Spec One",
3016 "spec",
3017 &[("identity", "Spec body.")],
3018 );
3019 let memo_hit = make_hit("memos--m1", "Memo One", "memo", &[("claim", "Memo claim.")]);
3020 let result = search_result(vec![spec_hit, memo_hit]);
3021 let envelope = build_search_envelope(&result, 0);
3022 let value = serde_json::to_value(&envelope).expect("envelope must serialize");
3023
3024 let hits = value["hits"].as_array().expect("hits must be array");
3025 assert_eq!(hits.len(), 2);
3026 assert_eq!(hits[0]["summary_heading"], "Identity");
3027 assert_eq!(hits[0]["summary_value"], "Spec body.");
3028 assert_eq!(hits[1]["summary_heading"], "Claim");
3029 assert_eq!(hits[1]["summary_value"], "Memo claim.");
3030 }
3031
3032 #[test]
3033 fn list_envelope_includes_total_tokens() {
3034 let hit = make_hit(
3035 "concepts--c1",
3036 "Thing",
3037 "concept",
3038 &[("definition", "A thing.")],
3039 );
3040 let result = list_result(vec![hit]);
3041 let envelope = build_list_envelope(&result);
3042 let value = serde_json::to_value(&envelope).expect("envelope must serialize");
3043
3044 assert_eq!(value["_total"], 1);
3046 assert_eq!(value["_total_tokens"], 10);
3047 assert!(value.get("total").is_none(), "unprefixed keys retired");
3048 assert_eq!(value["hits"][0]["summary_heading"], "Definition");
3049 assert_eq!(value["hits"][0]["summary_value"], "A thing.");
3050 }
3051
3052 #[test]
3053 fn envelope_emits_warnings_when_present() {
3054 let mut result = search_result(vec![]);
3055 result.warnings = vec![crate::ops::WarningHint::FieldNotFilterable {
3058 field: "foo".to_string(),
3059 }];
3060 let envelope = build_search_envelope(&result, 0);
3061 let value = serde_json::to_value(&envelope).expect("envelope must serialize");
3062 assert_eq!(value["warnings"][0]["code"], "FIELD_NOT_FILTERABLE");
3063 assert_eq!(value["warnings"][0]["details"]["field"], "foo");
3064 assert!(
3065 value["warnings"][0]["message"]
3066 .as_str()
3067 .is_some_and(|m| m.contains("not filterable"))
3068 );
3069 }
3070
3071 fn tm(field: &str, snippet: &str, heading_path: Option<&[&str]>) -> TermMatch {
3076 TermMatch {
3077 field: field.to_string(),
3078 snippet: snippet.to_string(),
3079 heading_path: heading_path.map(|p| p.iter().map(|s| s.to_string()).collect()),
3080 }
3081 }
3082
3083 fn sample_facets() -> Facets {
3084 use crate::ops::SubsectionFacet;
3085 Facets {
3086 by_type: HashMap::from([
3087 ("spec".to_string(), 7),
3088 ("memo".to_string(), 3),
3089 ("decision".to_string(), 2),
3090 ]),
3091 by_mem: HashMap::from([("specs".to_string(), 10), ("memos".to_string(), 2)]),
3092 by_level: HashMap::from([("high".to_string(), 4)]),
3093 by_status: HashMap::from([("active".to_string(), 6)]),
3094 by_confidence: HashMap::from([("medium".to_string(), 3)]),
3095 by_subsection: vec![
3096 SubsectionFacet {
3097 path: vec!["specifies".to_string(), "Response Shapes".to_string()],
3098 count: 4,
3099 },
3100 SubsectionFacet {
3101 path: vec!["purpose".to_string(), "Rationale".to_string()],
3102 count: 2,
3103 },
3104 ],
3105 by_expansion: HashMap::from([("primary".to_string(), 8), ("expanded".to_string(), 4)]),
3106 }
3107 }
3108
3109 #[test]
3110 fn render_search_emits_matched_terms_line() {
3111 let mut hit = make_hit(
3112 "specs--e1",
3113 "Entity One",
3114 "spec",
3115 &[("identity", "Body text.")],
3116 );
3117 hit.matched_terms = Some(HashMap::from([
3118 (
3119 "entity".to_string(),
3120 vec![
3121 tm("title", "...entity...", None),
3122 tm("purpose", "...entity...", None),
3123 tm("purpose", "...entity two...", None),
3124 ],
3125 ),
3126 ("one".to_string(), vec![tm("title", "...one...", None)]),
3127 ]));
3128 let out = render_search_markdown(&search_result(vec![hit]), 0);
3129 assert!(
3130 out.contains("**Matched terms:**"),
3131 "missing Matched terms line; got:\n{out}"
3132 );
3133 assert!(
3134 out.contains("`entity` (purpose×2, title×1)"),
3135 "entity term grouping wrong; got:\n{out}"
3136 );
3137 assert!(
3138 out.contains("`one` (title×1)"),
3139 "one term grouping wrong; got:\n{out}"
3140 );
3141 }
3142
3143 #[test]
3144 fn render_search_emits_score_breakdown_line() {
3145 let mut hit = make_hit("specs--e1", "Entity", "spec", &[("identity", "b")]);
3146 hit.score_breakdown = Some(ScoreBreakdown {
3147 bm25: 2.5,
3148 title_boost: 2.0,
3149 field_weights: HashMap::from([("body".to_string(), 0.8), ("purpose".to_string(), 0.3)]),
3150 expansion_decay: Some(0.5),
3151 });
3152 let out = render_search_markdown(&search_result(vec![hit]), 0);
3153 assert!(
3154 out.contains(
3155 "**Score:** bm25 2.5 + title 2.0 + body 0.8 + purpose 0.3 + expansion_decay ×0.5"
3156 ),
3157 "score breakdown line wrong; got:\n{out}"
3158 );
3159 }
3160
3161 #[test]
3162 fn render_search_omits_expansion_decay_when_none() {
3163 let mut hit = make_hit("specs--e1", "Entity", "spec", &[("identity", "b")]);
3164 hit.score_breakdown = Some(ScoreBreakdown {
3165 bm25: 1.5,
3166 title_boost: 1.0,
3167 field_weights: HashMap::new(),
3168 expansion_decay: None,
3169 });
3170 let out = render_search_markdown(&search_result(vec![hit]), 0);
3171 assert!(
3172 out.contains("**Score:** bm25 1.5 + title 1.0"),
3173 "base score wrong; got:\n{out}"
3174 );
3175 assert!(
3176 !out.contains("expansion_decay"),
3177 "expansion_decay must be absent when None; got:\n{out}"
3178 );
3179 }
3180
3181 #[test]
3182 fn render_search_emits_heading_path_line() {
3183 let mut hit = make_hit("specs--e1", "Entity", "spec", &[("identity", "b")]);
3184 hit.matched_terms = Some(HashMap::from([(
3185 "x".to_string(),
3186 vec![
3187 tm("purpose", "...x...", Some(&["Purpose", "Rationale"])),
3188 tm("purpose", "...x...", Some(&["Purpose", "Rationale"])), tm("specifies", "...x...", Some(&["Specifies", "Responses"])),
3190 ],
3191 )]));
3192 let out = render_search_markdown(&search_result(vec![hit]), 0);
3193 assert!(
3194 out.contains("**Heading path:** Purpose › Rationale; Specifies › Responses"),
3195 "heading path line wrong; got:\n{out}"
3196 );
3197 }
3198
3199 #[test]
3200 fn render_search_emits_expansion_line() {
3201 let mut hit = make_hit("specs--e2", "Entity Two", "spec", &[("identity", "b")]);
3202 hit.expansion = Some(ExpansionInfo {
3203 of: EntityId("specs--seed".to_string()),
3204 via_edge: "refines".to_string(),
3205 via_direction: crate::graph::query::TraversalDirection::Out,
3206 depth: 1,
3207 });
3208 let out = render_search_markdown(&search_result(vec![hit]), 0);
3209 assert!(
3210 out.contains("**Expansion:** from `specs--seed` via `refines` [out] (depth 1)"),
3211 "expansion line reports the traversal direction beside the label; got:\n{out}"
3212 );
3213 }
3214
3215 #[test]
3216 fn render_search_emits_facets_block() {
3217 let mut result = search_result(vec![]);
3218 result.facets = Some(sample_facets());
3219 let out = render_search_markdown(&result, 0);
3220 assert!(
3221 out.contains("## Facets"),
3222 "facets header missing; got:\n{out}"
3223 );
3224 assert!(
3225 out.contains("- **by_type:** spec=7, memo=3, decision=2"),
3226 "by_type bucket wrong; got:\n{out}"
3227 );
3228 assert!(
3229 out.contains("- **by_mem:** specs=10, memos=2"),
3230 "by_mem bucket wrong; got:\n{out}"
3231 );
3232 assert!(
3233 out.contains("- **by_level:** high=4"),
3234 "by_level bucket wrong; got:\n{out}"
3235 );
3236 assert!(
3237 out.contains("- **by_status:** active=6"),
3238 "by_status bucket wrong; got:\n{out}"
3239 );
3240 assert!(
3241 out.contains("- **by_confidence:** medium=3"),
3242 "by_confidence bucket wrong; got:\n{out}"
3243 );
3244 assert!(
3245 out.contains("- **by_expansion:** primary=8, expanded=4"),
3246 "by_expansion bucket wrong; got:\n{out}"
3247 );
3248 assert!(
3249 out.contains("- **by_subsection:**"),
3250 "by_subsection header missing; got:\n{out}"
3251 );
3252 assert!(
3253 out.contains("`specifies › Response Shapes`: 4"),
3254 "subsection facet wrong; got:\n{out}"
3255 );
3256 }
3257
3258 #[test]
3259 fn render_search_omits_facets_block_when_all_empty() {
3260 let mut result = search_result(vec![]);
3261 result.facets = Some(Facets::default());
3262 let out = render_search_markdown(&result, 0);
3263 assert!(
3264 !out.contains("## Facets"),
3265 "empty facets must not emit header; got:\n{out}"
3266 );
3267 }
3268
3269 #[test]
3273 fn search_markdown_covers_every_sidecar_field() {
3274 let mut hit = make_hit(
3275 "specs--e1",
3276 "Entity One",
3277 "spec",
3278 &[("identity", "Body text.")],
3279 );
3280 hit.matched_terms = Some(HashMap::from([(
3281 "entity".to_string(),
3282 vec![tm("title", "...entity...", Some(&["Purpose", "Rationale"]))],
3283 )]));
3284 hit.score_breakdown = Some(ScoreBreakdown {
3285 bm25: 1.5,
3286 title_boost: 1.0,
3287 field_weights: HashMap::from([("body".to_string(), 0.4)]),
3288 expansion_decay: Some(0.5),
3289 });
3290 hit.expansion = Some(ExpansionInfo {
3291 of: EntityId("specs--seed".to_string()),
3292 via_edge: "refines".to_string(),
3293 via_direction: crate::graph::query::TraversalDirection::Out,
3294 depth: 2,
3295 });
3296
3297 let mut result = search_result(vec![hit]);
3298 result.facets = Some(sample_facets());
3299
3300 let out = render_search_markdown(&result, 0);
3301 for marker in [
3302 "## Facets",
3303 "- **by_type:**",
3304 "- **by_mem:**",
3305 "- **by_level:**",
3306 "- **by_status:**",
3307 "- **by_confidence:**",
3308 "- **by_expansion:**",
3309 "- **by_subsection:**",
3310 "**Matched terms:**",
3311 "**Score:**",
3312 "**Heading path:**",
3313 "**Expansion:**",
3314 ] {
3315 assert!(
3316 out.contains(marker),
3317 "lockstep marker `{marker}` missing from search markdown; \
3318 update render_search_markdown when adding sidecar fields. got:\n{out}"
3319 );
3320 }
3321 }
3322
3323 #[test]
3330 fn build_entity_envelope_source_field_reads_edge_source() {
3331 let mut entity = test_entity();
3332 let body_link_target = EntityId("specs--body-link-target".to_string());
3333 let explicit_target = EntityId("specs--explicit-target".to_string());
3334 entity.relationships = vec![
3335 crate::entity::Relationship::new("REFERENCES".to_string(), body_link_target.clone()),
3336 crate::entity::Relationship::new("USES".to_string(), explicit_target.clone()),
3337 ];
3338
3339 let edges = vec![
3340 crate::store::Edge {
3341 rel_type: "REFERENCES".to_string(),
3342 target: body_link_target.clone(),
3343 source: crate::store::EdgeSource::BodyLink,
3344 },
3345 crate::store::Edge {
3346 rel_type: "USES".to_string(),
3347 target: explicit_target.clone(),
3348 source: crate::store::EdgeSource::Explicit,
3349 },
3350 ];
3351
3352 let env = build_entity_envelope(
3353 &entity,
3354 0,
3355 None,
3356 None,
3357 None,
3358 OriginClass::FirstParty,
3359 &edges,
3360 None,
3361 None,
3362 None,
3363 );
3364 let relationships = env["relationships"].as_array().expect("array");
3365 let refs = relationships
3366 .iter()
3367 .find(|r| r["rel_type"] == "REFERENCES")
3368 .expect("REFERENCES present");
3369 assert_eq!(
3370 refs["source"], "body_link",
3371 "alias-synthesised edge must label body_link"
3372 );
3373 let uses = relationships
3374 .iter()
3375 .find(|r| r["rel_type"] == "USES")
3376 .expect("USES present");
3377 assert_eq!(
3378 uses["source"], "explicit",
3379 "explicit-authored edge must label explicit"
3380 );
3381 }
3382
3383 #[test]
3390 fn build_entity_envelope_carries_origin_direction_and_incoming() {
3391 let mut entity = test_entity();
3392 let out_target = EntityId("specs--downstream".to_string());
3393 entity.relationships = vec![crate::entity::Relationship::new(
3394 "USES".to_string(),
3395 out_target.clone(),
3396 )];
3397 let edges = vec![crate::store::Edge {
3398 rel_type: "USES".to_string(),
3399 target: out_target,
3400 source: crate::store::EdgeSource::Explicit,
3401 }];
3402 let incoming = vec![crate::store::InEdge {
3403 rel_type: "MANAGES".to_string(),
3404 from: EntityId("specs--upstream".to_string()),
3405 source: crate::store::EdgeSource::Explicit,
3406 }];
3407
3408 let env = build_entity_envelope(
3410 &entity,
3411 0,
3412 None,
3413 None,
3414 None,
3415 OriginClass::ThirdParty,
3416 &edges,
3417 None,
3418 None,
3419 None,
3420 );
3421 assert_eq!(env["origin"], "third-party", "origin is envelope-level");
3422 let rels = env["relationships"].as_array().expect("array");
3423 assert_eq!(rels.len(), 1);
3424 assert_eq!(rels[0]["direction"], "out");
3425
3426 let env = build_entity_envelope(
3429 &entity,
3430 0,
3431 None,
3432 None,
3433 None,
3434 OriginClass::FirstParty,
3435 &edges,
3436 Some(&incoming),
3437 None,
3438 None,
3439 );
3440 assert_eq!(env["origin"], "first-party");
3441 let rels = env["relationships"].as_array().expect("array");
3442 assert_eq!(rels.len(), 2);
3443 let inc = rels
3444 .iter()
3445 .find(|r| r["direction"] == "in")
3446 .expect("incoming entry present");
3447 assert_eq!(inc["rel_type"], "MANAGES");
3448 assert_eq!(inc["from"], "specs--upstream");
3449 assert!(
3450 inc.get("target").is_none(),
3451 "incoming carries from, not target"
3452 );
3453 }
3454
3455 #[test]
3460 fn build_entity_envelope_source_field_falls_back_to_explicit_when_edge_missing() {
3461 let mut entity = test_entity();
3462 let target = EntityId("specs--unmapped".to_string());
3463 entity.relationships = vec![crate::entity::Relationship::new("USES".to_string(), target)];
3464 let edges: Vec<crate::store::Edge> = Vec::new();
3465 let env = build_entity_envelope(
3466 &entity,
3467 0,
3468 None,
3469 None,
3470 None,
3471 OriginClass::FirstParty,
3472 &edges,
3473 None,
3474 None,
3475 None,
3476 );
3477 let relationships = env["relationships"].as_array().expect("array");
3478 assert_eq!(relationships[0]["source"], "explicit");
3479 }
3480
3481 #[test]
3487 fn build_entity_envelope_nested_metadata_carries_every_schema_field() {
3488 use crate::entity::MetadataValue;
3489 let mut entity = test_entity();
3490 entity.entity_type = "contract".to_string();
3491 entity.metadata = IndexMap::from([
3493 ("level".to_string(), MetadataValue::String("M0".to_string())),
3494 (
3495 "stability".to_string(),
3496 MetadataValue::String("stable".to_string()),
3497 ),
3498 (
3499 "created_date".to_string(),
3500 MetadataValue::String("2026-01-01".to_string()),
3501 ),
3502 (
3503 "last_modified".to_string(),
3504 MetadataValue::String("2026-05-19".to_string()),
3505 ),
3506 (
3507 "protocol".to_string(),
3508 MetadataValue::String("https".to_string()),
3509 ),
3510 (
3511 "version".to_string(),
3512 MetadataValue::String("0.1.0".to_string()),
3513 ),
3514 (
3515 "deprecation_status".to_string(),
3516 MetadataValue::String("none".to_string()),
3517 ),
3518 ]);
3519
3520 let env = build_entity_envelope(
3521 &entity,
3522 0,
3523 None,
3524 None,
3525 None,
3526 OriginClass::FirstParty,
3527 &[],
3528 None,
3529 None,
3530 None,
3531 );
3532
3533 assert!(
3536 env.get("level").is_none(),
3537 "level must not be hoisted top-level"
3538 );
3539 assert!(
3540 env.get("stability").is_none(),
3541 "stability must not be hoisted"
3542 );
3543 assert!(
3544 env.get("created_date").is_none(),
3545 "created_date must not be hoisted"
3546 );
3547 assert!(
3548 env.get("last_modified").is_none(),
3549 "last_modified must not be hoisted"
3550 );
3551 assert_eq!(env["type"], "contract");
3553
3554 let metadata = env["metadata"].as_object().expect("metadata map");
3556 assert_eq!(metadata["level"], "M0");
3557 assert_eq!(metadata["stability"], "stable");
3558 assert_eq!(metadata["created_date"], "2026-01-01");
3559 assert_eq!(metadata["last_modified"], "2026-05-19");
3560 assert_eq!(metadata["protocol"], "https");
3561 assert_eq!(metadata["version"], "0.1.0");
3562 assert_eq!(metadata["deprecation_status"], "none");
3563
3564 for k in metadata.keys() {
3567 assert!(
3568 !k.starts_with('_'),
3569 "metadata map must not carry underscore-prefixed key `{k}`"
3570 );
3571 assert!(
3572 !["mem", "id", "type"].contains(&k.as_str()),
3573 "metadata map must not carry identity key `{k}` (it lives top-level)"
3574 );
3575 }
3576 }
3577
3578 #[test]
3582 fn build_entity_envelope_stub_carries_empty_metadata_map() {
3583 let mut entity = test_entity();
3584 entity.stub = true;
3585 entity.stub_kind = Some(crate::entity::StubKind::ForwardReference);
3586 entity.metadata = IndexMap::new();
3587 let env = build_entity_envelope(
3588 &entity,
3589 0,
3590 None,
3591 None,
3592 None,
3593 OriginClass::FirstParty,
3594 &[],
3595 None,
3596 None,
3597 None,
3598 );
3599 let metadata = env["metadata"]
3600 .as_object()
3601 .expect("metadata key present even on stubs");
3602 assert!(metadata.is_empty(), "stub metadata map must be empty");
3603 }
3604
3605 #[test]
3612 fn build_entity_envelope_user_field_collisions_isolated_to_nested_map() {
3613 use crate::entity::MetadataValue;
3614 let mut entity = test_entity();
3615 entity.metadata = IndexMap::from([
3616 (
3617 "sections".to_string(),
3618 MetadataValue::String("user-supplied-shadow".to_string()),
3619 ),
3620 (
3621 "relationships".to_string(),
3622 MetadataValue::String("also-shadowed".to_string()),
3623 ),
3624 ]);
3625 let env = build_entity_envelope(
3626 &entity,
3627 0,
3628 None,
3629 None,
3630 None,
3631 OriginClass::FirstParty,
3632 &[],
3633 None,
3634 None,
3635 None,
3636 );
3637 assert!(
3639 env["sections"].is_object(),
3640 "top-level sections stays a map"
3641 );
3642 assert!(
3643 env["relationships"].is_array(),
3644 "top-level relationships stays an array"
3645 );
3646 let metadata = env["metadata"].as_object().expect("metadata map");
3648 assert_eq!(metadata["sections"], "user-supplied-shadow");
3649 assert_eq!(metadata["relationships"], "also-shadowed");
3650 }
3651
3652 #[test]
3656 fn build_entity_envelope_unfiltered_body_token_field_name() {
3657 let entity = test_entity();
3658 let env_filtered = build_entity_envelope(
3660 &entity,
3661 10,
3662 Some(42),
3663 None,
3664 None,
3665 OriginClass::FirstParty,
3666 &[],
3667 None,
3668 None,
3669 None,
3670 );
3671 assert_eq!(env_filtered["_tokens_unfiltered_body"], 42);
3672 assert!(
3673 env_filtered.get("_tokens_full").is_none(),
3674 "_tokens_full must not survive — rename is one-way"
3675 );
3676 let env_unfiltered = build_entity_envelope(
3678 &entity,
3679 10,
3680 None,
3681 None,
3682 None,
3683 OriginClass::FirstParty,
3684 &[],
3685 None,
3686 None,
3687 None,
3688 );
3689 assert!(env_unfiltered.get("_tokens_unfiltered_body").is_none());
3690 assert!(env_unfiltered.get("_tokens_full").is_none());
3691 }
3692
3693 fn software_schema() -> Arc<Schema> {
3701 memstead_schema::builtins::load_builtin_schemas()
3702 .expect("builtins load")
3703 .into_iter()
3704 .find(|s| s.manifest.name == "software")
3705 .expect("software schema is a builtin")
3706 }
3707
3708 #[test]
3709 fn schema_verbosity_wire_round_trips() {
3710 assert_eq!(
3711 SchemaVerbosity::from_wire("full"),
3712 Some(SchemaVerbosity::Full)
3713 );
3714 assert_eq!(
3715 SchemaVerbosity::from_wire("lite"),
3716 Some(SchemaVerbosity::Lite)
3717 );
3718 assert_eq!(SchemaVerbosity::from_wire("brief"), None);
3719 assert_eq!(SchemaVerbosity::from_wire(""), None);
3720 assert_eq!(SchemaVerbosity::Full.as_wire(), "full");
3721 assert_eq!(SchemaVerbosity::Lite.as_wire(), "lite");
3722 assert_eq!(SchemaVerbosity::default(), SchemaVerbosity::Full);
3723 }
3724
3725 #[test]
3731 fn exemplar_serves_at_full_and_lite_stays_byte_unchanged() {
3732 let manifest = r#"name: servefix
3733version: 1.0.0
3734description: serving fixture
3735when_to_use: tests
3736types:
3737 - sample
3738relationships:
3739 mode: strict
3740 definitions:
3741 - name: PART_OF
3742 description: hier
3743 default_weight: 3.0
3744 - name: _default
3745 description: fallback
3746 default_weight: 1.0
3747community:
3748 resolution: 1.0
3749 seed: 42
3750"#;
3751 let base_type = r#"name: sample
3752description: t
3753when_to_use: tests
3754sections:
3755 - key: body
3756 heading: Body
3757 required: true
3758 search_weight: 10.0
3759 catch_all: true
3760 write_rules: []
3761metadata_fields:
3762 - key: status
3763 description: state
3764 field_type: string
3765 enum_values: [draft, final]
3766 optional: true
3767title_weight: 100.0
3768text_fields:
3769 - body
3770hierarchy_relationship: PART_OF
3771no_self_loop_relationships: []
3772updatable_fields:
3773 - title
3774 - body
3775health_required_fields:
3776 - body
3777staleness_threshold_days: 90
3778write_rules: []
3779"#;
3780 let with_exemplar = format!(
3781 "{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"
3782 );
3783
3784 let plain = Arc::new(
3785 memstead_schema::loader::load_schema_from_memory(
3786 manifest,
3787 &[("sample".to_string(), base_type.to_string())],
3788 )
3789 .expect("fixture loads"),
3790 );
3791 let exemplary = Arc::new(
3792 memstead_schema::loader::load_schema_from_memory(
3793 manifest,
3794 &[("sample".to_string(), with_exemplar)],
3795 )
3796 .expect("fixture loads"),
3797 );
3798
3799 let full = build_schema_payload(
3801 &exemplary,
3802 vec![],
3803 SchemaVerbosity::Full,
3804 OriginClass::FirstParty,
3805 );
3806 let ex = &full["types"][0]["exemplar"];
3807 assert_eq!(ex["title"], "A Conforming Sample", "{full}");
3808 assert_eq!(ex["metadata"]["status"], "draft");
3809 assert_eq!(ex["sections"]["body"], "One canonical body paragraph.");
3810 assert_eq!(ex["relations"][0]["to"], "parent-placeholder");
3811 assert_eq!(ex["relations"][0]["type"], "PART_OF");
3812
3813 let full_plain = build_schema_payload(
3815 &plain,
3816 vec![],
3817 SchemaVerbosity::Full,
3818 OriginClass::FirstParty,
3819 );
3820 assert!(full_plain["types"][0].get("exemplar").is_none());
3821
3822 let lite_with = build_schema_payload(
3825 &exemplary,
3826 vec![],
3827 SchemaVerbosity::Lite,
3828 OriginClass::FirstParty,
3829 );
3830 let lite_without = build_schema_payload(
3831 &plain,
3832 vec![],
3833 SchemaVerbosity::Lite,
3834 OriginClass::FirstParty,
3835 );
3836 assert_eq!(
3837 serde_json::to_string(&lite_with).unwrap(),
3838 serde_json::to_string(&lite_without).unwrap(),
3839 "lite must not change when an exemplar exists"
3840 );
3841 assert!(
3842 !serde_json::to_string(&lite_with)
3843 .unwrap()
3844 .contains("exemplar"),
3845 "lite must not mention exemplars at all"
3846 );
3847 }
3848
3849 #[test]
3853 fn first_party_origin_is_labelled_and_keeps_prose() {
3854 let schema = software_schema();
3855 let full = build_schema_payload(
3856 &schema,
3857 vec!["v".into()],
3858 SchemaVerbosity::Full,
3859 OriginClass::FirstParty,
3860 );
3861 assert_eq!(full["origin"], "first-party");
3862 assert!(full["description"].is_string());
3864 let t = &full["types"].as_array().unwrap()[0];
3865 assert!(t.get("system_context").is_some());
3866 assert!(t.get("writing_guidance").is_some());
3867
3868 let lite = build_schema_payload(
3870 &schema,
3871 vec!["v".into()],
3872 SchemaVerbosity::Lite,
3873 OriginClass::FirstParty,
3874 );
3875 assert_eq!(lite["origin"], "first-party");
3876 }
3877
3878 #[test]
3883 fn constraints_and_severity_render_at_both_verbosities() {
3884 let manifest = r#"name: constrained
3885version: 1.0.0
3886description: constraint render fixture
3887when_to_use: render tests
3888types:
3889 - sample
3890relationships:
3891 mode: strict
3892 definitions:
3893 - name: PART_OF
3894 description: hier
3895 default_weight: 3.0
3896 - name: _default
3897 description: fallback
3898 default_weight: 1.0
3899community:
3900 resolution: 1.0
3901 seed: 42
3902"#;
3903 let type_yaml = r#"name: sample
3904description: t
3905when_to_use: tests
3906sections:
3907 - key: body
3908 heading: Body
3909 required: true
3910 search_weight: 10.0
3911 catch_all: true
3912 write_rules: []
3913metadata_fields:
3914 - key: status
3915 description: state
3916 field_type: string
3917 enum_values: [open, checked]
3918 optional: true
3919 - key: checked_by
3920 description: who
3921 field_type: string
3922 optional: true
3923title_weight: 100.0
3924text_fields:
3925 - body
3926hierarchy_relationship: PART_OF
3927no_self_loop_relationships: []
3928updatable_fields:
3929 - title
3930 - body
3931health_required_fields:
3932 - body
3933staleness_threshold_days: 90
3934required_outgoing:
3935 - relationships: [PART_OF]
3936 cardinality: at_least_one
3937 severity: block
3938constraints:
3939 - kind: requires_when
3940 field: checked_by
3941 when_field: status
3942 when_value: checked
3943 - kind: unique
3944 fields: [status, checked_by]
3945 - kind: enum_from_neighbour
3946 field: status
3947 rel_type: PART_OF
3948 section: body
3949 - kind: status_propagation
3950 field: status
3951 value: checked
3952 rel_type: PART_OF
3953 direction: incoming
3954write_rules: []
3955"#;
3956 let schema = Arc::new(
3957 memstead_schema::loader::load_schema_from_memory(
3958 manifest,
3959 &[("sample".to_string(), type_yaml.to_string())],
3960 )
3961 .expect("fixture loads"),
3962 );
3963
3964 let expected_constraints = serde_json::json!([
3969 {
3970 "kind": "requires_when",
3971 "field": "checked_by",
3972 "when_field": "status",
3973 "when_value": "checked",
3974 "severity": "warn",
3975 },
3976 {
3977 "kind": "unique",
3978 "fields": ["status", "checked_by"],
3979 "severity": "block",
3980 },
3981 {
3982 "kind": "enum_from_neighbour",
3983 "field": "status",
3984 "rel_type": "PART_OF",
3985 "section": "body",
3986 "severity": "warn",
3987 },
3988 {
3989 "kind": "status_propagation",
3990 "field": "status",
3991 "value": "checked",
3992 "rel_type": "PART_OF",
3993 "direction": "incoming",
3994 "severity": "warn",
3995 },
3996 ]);
3997
3998 let full = build_schema_payload(
3999 &schema,
4000 vec![],
4001 SchemaVerbosity::Full,
4002 OriginClass::FirstParty,
4003 );
4004 let t = &full["types"].as_array().unwrap()[0];
4005 assert_eq!(t["constraints"], expected_constraints);
4006 assert_eq!(t["required_outgoing"][0]["severity"], "block");
4007
4008 let lite = build_schema_payload(
4009 &schema,
4010 vec![],
4011 SchemaVerbosity::Lite,
4012 OriginClass::FirstParty,
4013 );
4014 let ts = &lite["types_summary"].as_array().unwrap()[0];
4015 assert_eq!(ts["constraints"], expected_constraints);
4016 assert_eq!(ts["required_outgoing"][0]["severity"], "block");
4017
4018 let fmt_manifest = r#"name: formatted
4021version: 1.0.0
4022description: format render fixture
4023when_to_use: render tests
4024types:
4025 - plan
4026relationships:
4027 mode: strict
4028 definitions:
4029 - name: PART_OF
4030 description: hier
4031 default_weight: 1.0
4032 - name: _default
4033 description: fallback
4034 default_weight: 1.0
4035community:
4036 resolution: 1.0
4037 seed: 42
4038"#;
4039 let fmt_type = r#"name: plan
4040description: t
4041when_to_use: tests
4042sections:
4043 - key: body
4044 heading: Body
4045 required: true
4046 search_weight: 10.0
4047 catch_all: true
4048 write_rules: []
4049 - key: meilensteine
4050 heading: Meilensteine
4051 required: false
4052 search_weight: 5.0
4053 catch_all: false
4054 write_rules: []
4055 content: "(heading(3) list(bullet))+"
4056 item_pattern: '\*\*(?<name>[^*]+)\*\*'
4057 example: |
4058 ### Phase 1
4059 - **Kickoff**
4060 format_severity: warn
4061 - key: tabelle
4062 heading: Tabelle
4063 required: false
4064 search_weight: 5.0
4065 catch_all: false
4066 write_rules: []
4067 content: "table"
4068 table:
4069 columns: [Name, Datum]
4070 column_patterns:
4071 Datum: '\d{4}-\d{2}-\d{2}'
4072 - key: belege
4073 heading: Belege
4074 required: false
4075 search_weight: 5.0
4076 catch_all: false
4077 write_rules: []
4078 content: "paragraph+"
4079 item_pattern: '(?<quelle>\S[^|]*?) \| (?<aussage>.+)'
4080metadata_fields: []
4081title_weight: 100.0
4082text_fields:
4083 - body
4084hierarchy_relationship: PART_OF
4085no_self_loop_relationships: []
4086updatable_fields:
4087 - title
4088 - body
4089health_required_fields:
4090 - body
4091staleness_threshold_days: 90
4092write_rules: []
4093"#;
4094 let fmt_schema = Arc::new(
4095 memstead_schema::loader::load_schema_from_memory(
4096 fmt_manifest,
4097 &[("plan".to_string(), fmt_type.to_string())],
4098 )
4099 .expect("format fixture loads"),
4100 );
4101 for verbosity in [SchemaVerbosity::Full, SchemaVerbosity::Lite] {
4102 let payload =
4103 build_schema_payload(&fmt_schema, vec![], verbosity, OriginClass::FirstParty);
4104 let sections_key = match verbosity {
4105 SchemaVerbosity::Full => &payload["types"][0]["sections"],
4106 SchemaVerbosity::Lite => &payload["types_summary"][0]["sections"],
4107 };
4108 let secs = sections_key.as_array().unwrap();
4109 let meilensteine = secs
4110 .iter()
4111 .find(|s| s["key"] == "meilensteine")
4112 .expect("declared section present");
4113 assert_eq!(
4114 meilensteine["content"], "(heading(3) list(bullet))+",
4115 "{verbosity:?} carries content"
4116 );
4117 assert!(
4118 meilensteine["item_pattern"]
4119 .as_str()
4120 .unwrap()
4121 .contains("name")
4122 );
4123 assert!(
4124 meilensteine["example"]
4125 .as_str()
4126 .unwrap()
4127 .contains("Kickoff")
4128 );
4129 assert_eq!(meilensteine["format_severity"], "warn");
4130 let tabelle = secs.iter().find(|s| s["key"] == "tabelle").unwrap();
4131 assert_eq!(tabelle["format_severity"], "block", "default renders");
4132 assert_eq!(tabelle["table"]["columns"][0], "Name");
4133 assert!(
4134 tabelle["table"]["column_patterns"]["Datum"]
4135 .as_str()
4136 .is_some()
4137 );
4138 let belege = secs.iter().find(|s| s["key"] == "belege").unwrap();
4139 assert_eq!(belege["content"], "paragraph+");
4140 assert!(belege["item_pattern"].as_str().unwrap().contains("quelle"));
4141 let body = secs.iter().find(|s| s["key"] == "body").unwrap();
4142 assert!(
4143 body.get("content").is_none() && body.get("format_severity").is_none(),
4144 "undeclared section keeps its pre-plan shape"
4145 );
4146 }
4147
4148 let plain_full = build_schema_payload(
4151 &software_schema(),
4152 vec![],
4153 SchemaVerbosity::Full,
4154 OriginClass::FirstParty,
4155 );
4156 let pt = &plain_full["types"].as_array().unwrap()[0];
4157 assert_eq!(pt["constraints"], serde_json::json!([]));
4158 let plain_lite = build_schema_payload(
4159 &software_schema(),
4160 vec![],
4161 SchemaVerbosity::Lite,
4162 OriginClass::FirstParty,
4163 );
4164 let pts = &plain_lite["types_summary"].as_array().unwrap()[0];
4165 assert_eq!(pts["constraints"], serde_json::json!([]));
4166 }
4167
4168 #[test]
4178 fn third_party_origin_forces_structural_only_even_under_full() {
4179 let schema = software_schema();
4180 let full_requested = build_schema_payload(
4181 &schema,
4182 vec!["v".into()],
4183 SchemaVerbosity::Full,
4184 OriginClass::ThirdParty,
4185 );
4186
4187 assert_eq!(full_requested["origin"], "third-party");
4189
4190 assert!(
4193 full_requested.get("types").is_none(),
4194 "third-party omits the rich `types` array even under full"
4195 );
4196 assert!(
4197 full_requested.get("relationships").is_none(),
4198 "third-party omits the rich `relationships` array even under full"
4199 );
4200 assert!(
4201 full_requested["types_summary"].is_array(),
4202 "third-party serves the structural `types_summary` skeleton"
4203 );
4204 assert!(
4205 full_requested["relationships_summary"].is_array(),
4206 "third-party serves the structural `relationships_summary` skeleton"
4207 );
4208
4209 assert!(
4211 full_requested.get("description").is_none(),
4212 "third-party drops schema description prose"
4213 );
4214 assert!(
4215 full_requested.get("when_to_use").is_none(),
4216 "third-party drops schema when_to_use prose"
4217 );
4218 assert!(
4219 full_requested.get("default_writing_guidance").is_none(),
4220 "third-party drops default_writing_guidance prose"
4221 );
4222
4223 for t in full_requested["types_summary"].as_array().unwrap() {
4225 assert!(
4226 t.get("system_context").is_none(),
4227 "third-party drops system_context"
4228 );
4229 assert!(
4230 t.get("writing_guidance").is_none(),
4231 "third-party drops writing_guidance"
4232 );
4233 assert!(
4234 t.get("description").is_none(),
4235 "third-party drops type description"
4236 );
4237 for s in t["sections"].as_array().unwrap() {
4238 assert!(
4239 s.get("write_rules").is_none(),
4240 "third-party drops section write_rules"
4241 );
4242 }
4243 }
4244 for r in full_requested["relationships_summary"].as_array().unwrap() {
4246 assert!(
4247 r.get("description").is_none(),
4248 "third-party drops rel description"
4249 );
4250 assert!(
4251 r.get("when_to_use").is_none(),
4252 "third-party drops rel when_to_use"
4253 );
4254 }
4255
4256 let lite_requested = build_schema_payload(
4260 &schema,
4261 vec!["v".into()],
4262 SchemaVerbosity::Lite,
4263 OriginClass::ThirdParty,
4264 );
4265 assert_eq!(
4266 full_requested, lite_requested,
4267 "third-party full must collapse to the lite skeleton"
4268 );
4269 }
4270
4271 #[test]
4272 fn full_payload_carries_the_rich_arrays_and_prose() {
4273 let schema = software_schema();
4274 let full = build_schema_payload(
4275 &schema,
4276 vec!["v".into()],
4277 SchemaVerbosity::Full,
4278 OriginClass::FirstParty,
4279 );
4280
4281 assert!(full["types"].is_array(), "full has `types`");
4283 assert!(full["relationships"].is_array(), "full has `relationships`");
4284 assert!(
4285 full.get("types_summary").is_none(),
4286 "full omits `types_summary`"
4287 );
4288 assert!(
4289 full.get("relationships_summary").is_none(),
4290 "full omits `relationships_summary`"
4291 );
4292 assert!(
4293 full["description"].is_string(),
4294 "full keeps schema description"
4295 );
4296 assert!(
4297 full["when_to_use"].is_string(),
4298 "full keeps schema when_to_use"
4299 );
4300 assert_eq!(full["alias_target_rel_type"], "REFERENCES");
4301
4302 let t = &full["types"].as_array().unwrap()[0];
4304 assert!(t["description"].is_string());
4305 assert!(t.get("writing_guidance").is_some());
4306 assert!(t.get("system_context").is_some());
4307 let r = &full["relationships"].as_array().unwrap()[0];
4309 assert!(r["description"].is_string());
4310 assert!(r.get("when_to_use").is_some());
4311 assert!(r.get("default_weight").is_some());
4312 }
4313
4314 #[test]
4323 fn required_outgoing_reported_with_cardinality_at_both_levels() {
4324 let reg = memstead_schema::SchemaRegistry::builtin();
4325 let project = reg
4326 .get("project", &semver::Version::new(0, 2, 0))
4327 .expect("project is a built-in");
4328
4329 for verbosity in [SchemaVerbosity::Full, SchemaVerbosity::Lite] {
4330 let payload =
4331 build_schema_payload(&project, vec![], verbosity, OriginClass::FirstParty);
4332 let types_key = if verbosity == SchemaVerbosity::Full {
4333 "types"
4334 } else {
4335 "types_summary"
4336 };
4337 let types = payload[types_key].as_array().expect("types array");
4338
4339 let mut saw_evidence = false;
4340 let mut saw_memo = false;
4341 for t in types {
4342 let ro = t
4343 .get("required_outgoing")
4344 .unwrap_or_else(|| panic!("type {} omits required_outgoing", t["name"]))
4345 .as_array()
4346 .expect("required_outgoing is an array for every type");
4347 if t["name"] == "evidence" {
4348 saw_evidence = true;
4349 assert_eq!(ro.len(), 1, "evidence declares one block");
4350 assert_eq!(
4351 ro[0]["relationships"],
4352 serde_json::json!(["STRENGTHENS", "WEAKENS", "VALIDATES", "CONTRADICTS"]),
4353 "relationship alternatives in declaration order"
4354 );
4355 assert_eq!(
4356 ro[0]["cardinality"], "at_least_one",
4357 "cardinality rendered as declared — the open upper bound \
4358 stays open, never a finite number"
4359 );
4360 } else if t["name"] == "memo" {
4361 saw_memo = true;
4364 assert!(ro.is_empty(), "memo declares no blocks → empty list");
4365 }
4366 }
4367 assert!(saw_evidence, "project schema carries the evidence type");
4368 assert!(saw_memo, "project schema carries the memo type");
4369
4370 let note = payload["no_self_loop_relationships_effect"]
4373 .as_str()
4374 .expect("effect note present at both verbosity levels");
4375 assert!(note.contains("self-loop"), "names the actual effect");
4376 assert!(
4377 !note.contains("propagates impact") || note.contains("does not propagate"),
4378 "claims no propagation behaviour beyond the self-loop refusal"
4379 );
4380 assert!(
4381 note.contains("status_propagation"),
4382 "deprecation pointer names the real propagation declaration"
4383 );
4384 }
4385 }
4386
4387 #[test]
4393 fn conditional_required_outgoing_trigger_visible_at_both_levels() {
4394 let manifest = r#"name: condro-render
4395version: 0.1.0
4396description: conditional required_outgoing render fixture
4397when_to_use: tests
4398types:
4399 - task
4400relationships:
4401 mode: strict
4402 definitions:
4403 - name: PART_OF
4404 description: hier
4405 default_weight: 3.0
4406 - name: _default
4407 description: fallback
4408 default_weight: 1.0
4409community:
4410 resolution: 1.0
4411 seed: 42
4412"#;
4413 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";
4414 let schema = Arc::new(
4415 memstead_schema::load_schema_from_memory(
4416 manifest,
4417 &[("task".to_string(), task_yaml.to_string())],
4418 )
4419 .expect("render fixture schema must parse"),
4420 );
4421
4422 for verbosity in [SchemaVerbosity::Full, SchemaVerbosity::Lite] {
4423 let payload = build_schema_payload(&schema, vec![], verbosity, OriginClass::FirstParty);
4424 let types_key = if verbosity == SchemaVerbosity::Full {
4425 "types"
4426 } else {
4427 "types_summary"
4428 };
4429 let task = &payload[types_key].as_array().expect("types array")[0];
4430 let ro = task["required_outgoing"].as_array().expect("blocks array");
4431 assert_eq!(ro.len(), 2);
4432 assert!(
4433 ro[0].get("when_field").is_none() && ro[0].get("when_value").is_none(),
4434 "unconditional block carries no when_* keys: {:?}",
4435 ro[0]
4436 );
4437 assert_eq!(ro[1]["when_field"], "status");
4438 assert_eq!(ro[1]["when_value"], "checked");
4439 }
4440 }
4441
4442 #[test]
4448 fn acyclic_sets_and_propagation_rel_types_visible_at_both_levels() {
4449 let manifest = r#"name: relsets-render
4450version: 0.1.0
4451description: relation-set render fixture
4452when_to_use: tests
4453types:
4454 - claim
4455relationships:
4456 mode: strict
4457 acyclic_sets:
4458 - [GROUNDS, CONCLUDES]
4459 definitions:
4460 - name: GROUNDS
4461 description: g
4462 default_weight: 3.0
4463 - name: CONCLUDES
4464 description: c
4465 default_weight: 3.0
4466 - name: PART_OF
4467 description: hier
4468 default_weight: 1.0
4469 - name: _default
4470 description: fallback
4471 default_weight: 1.0
4472community:
4473 resolution: 1.0
4474 seed: 42
4475"#;
4476 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";
4477 let schema = Arc::new(
4478 memstead_schema::load_schema_from_memory(
4479 manifest,
4480 &[("claim".to_string(), claim.to_string())],
4481 )
4482 .expect("render fixture schema must parse"),
4483 );
4484
4485 for verbosity in [SchemaVerbosity::Full, SchemaVerbosity::Lite] {
4486 let payload = build_schema_payload(&schema, vec![], verbosity, OriginClass::FirstParty);
4487 assert_eq!(
4488 payload["acyclic_sets"],
4489 serde_json::json!([["GROUNDS", "CONCLUDES"]]),
4490 "acyclic_sets present at {verbosity:?}"
4491 );
4492 let types_key = if verbosity == SchemaVerbosity::Full {
4493 "types"
4494 } else {
4495 "types_summary"
4496 };
4497 let claim = &payload[types_key].as_array().expect("types array")[0];
4498 let constraints = claim["constraints"].as_array().expect("constraints array");
4499 assert_eq!(
4500 constraints[0]["rel_types"],
4501 serde_json::json!(["GROUNDS", "CONCLUDES"])
4502 );
4503 assert!(
4504 constraints[0].get("rel_type").is_none(),
4505 "set declaration carries no single-name key: {:?}",
4506 constraints[0]
4507 );
4508 assert_eq!(constraints[1]["rel_type"], "PART_OF");
4509 assert!(
4510 constraints[1].get("rel_types").is_none(),
4511 "single-name declaration stays byte-identical: {:?}",
4512 constraints[1]
4513 );
4514 }
4515
4516 let plain = software_schema();
4518 for verbosity in [SchemaVerbosity::Full, SchemaVerbosity::Lite] {
4519 let payload = build_schema_payload(&plain, vec![], verbosity, OriginClass::FirstParty);
4520 assert!(
4521 payload.get("acyclic_sets").is_none(),
4522 "undeclared schema carries no acyclic_sets key"
4523 );
4524 }
4525 }
4526
4527 #[test]
4531 fn labelling_declaration_visible_at_both_levels_and_absent_when_undeclared() {
4532 let manifest = r#"name: labelling-render
4533version: 0.1.0
4534description: labelling render fixture
4535when_to_use: tests
4536types:
4537 - claim
4538relationships:
4539 mode: strict
4540 labelling:
4541 attack: [REBUTS]
4542 support:
4543 relationships: [GROUNDS]
4544 direction: out
4545 terminal_types: [claim]
4546 definitions:
4547 - name: REBUTS
4548 description: attack
4549 default_weight: 3.0
4550 - name: GROUNDS
4551 description: support
4552 default_weight: 3.0
4553 - name: PART_OF
4554 description: hier
4555 default_weight: 1.0
4556 - name: _default
4557 description: fallback
4558 default_weight: 1.0
4559community:
4560 resolution: 1.0
4561 seed: 42
4562"#;
4563 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";
4564 let schema = Arc::new(
4565 memstead_schema::load_schema_from_memory(
4566 manifest,
4567 &[("claim".to_string(), claim.to_string())],
4568 )
4569 .expect("render fixture schema must parse"),
4570 );
4571
4572 for verbosity in [SchemaVerbosity::Full, SchemaVerbosity::Lite] {
4573 let payload = build_schema_payload(&schema, vec![], verbosity, OriginClass::FirstParty);
4574 assert_eq!(
4575 payload["labelling"]["attack"],
4576 serde_json::json!(["REBUTS"]),
4577 "attack set present at {verbosity:?}"
4578 );
4579 assert_eq!(
4580 payload["labelling"]["support"]["relationships"],
4581 serde_json::json!(["GROUNDS"])
4582 );
4583 assert_eq!(payload["labelling"]["support"]["direction"], "out");
4584 }
4585
4586 let plain = software_schema();
4587 for verbosity in [SchemaVerbosity::Full, SchemaVerbosity::Lite] {
4588 let payload = build_schema_payload(&plain, vec![], verbosity, OriginClass::FirstParty);
4589 assert!(
4590 payload.get("labelling").is_none(),
4591 "undeclared schema carries no labelling key"
4592 );
4593 }
4594 }
4595
4596 #[test]
4600 fn signal_declarations_visible_at_both_levels_and_absent_when_undeclared() {
4601 let manifest = r#"name: signals-render
4602version: 0.1.0
4603description: signal render fixture
4604when_to_use: tests
4605types:
4606 - claim
4607 - objection
4608relationships:
4609 mode: strict
4610 definitions:
4611 - name: REBUTS
4612 description: r
4613 default_weight: 3.0
4614 - name: PART_OF
4615 description: hier
4616 default_weight: 1.0
4617 - name: _default
4618 description: fallback
4619 default_weight: 1.0
4620community:
4621 resolution: 1.0
4622 seed: 42
4623"#;
4624 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";
4625 let claim = format!(
4626 "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"
4627 );
4628 let objection = format!(
4629 "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}"
4630 );
4631 let schema = Arc::new(
4632 memstead_schema::load_schema_from_memory(
4633 manifest,
4634 &[
4635 ("claim".to_string(), claim),
4636 ("objection".to_string(), objection),
4637 ],
4638 )
4639 .expect("render fixture schema must parse"),
4640 );
4641
4642 for verbosity in [SchemaVerbosity::Full, SchemaVerbosity::Lite] {
4643 let payload = build_schema_payload(&schema, vec![], verbosity, OriginClass::FirstParty);
4644 let types_key = if verbosity == SchemaVerbosity::Full {
4645 "types"
4646 } else {
4647 "types_summary"
4648 };
4649 let types = payload[types_key].as_array().expect("types array");
4650 let claim = types
4651 .iter()
4652 .find(|t| t["name"] == "claim")
4653 .expect("claim type present");
4654 let sigs = claim["signals"].as_array().expect("signals array");
4655 assert_eq!(sigs[0]["name"], "attack_load");
4656 assert_eq!(sigs[0]["kind"], "edge_load");
4657 assert_eq!(sigs[0]["direction"], "in");
4658 assert_eq!(sigs[0]["thresholds"][1]["at_least"], 3);
4659 assert_eq!(sigs[0]["thresholds"][1]["level"], "warn");
4660 let objection = types
4661 .iter()
4662 .find(|t| t["name"] == "objection")
4663 .expect("objection type present");
4664 assert!(
4665 objection.get("signals").is_none(),
4666 "undeclared type carries no signals key"
4667 );
4668 }
4669 }
4670
4671 #[test]
4677 fn must_reach_visible_at_both_levels_and_absent_when_undeclared() {
4678 let manifest = r#"name: mustreach-render
4679version: 0.1.0
4680description: must_reach render fixture
4681when_to_use: tests
4682types:
4683 - claim
4684 - evidence
4685relationships:
4686 mode: strict
4687 definitions:
4688 - name: GROUNDS
4689 description: g
4690 default_weight: 3.0
4691 - name: PART_OF
4692 description: hier
4693 default_weight: 1.0
4694 - name: _default
4695 description: fallback
4696 default_weight: 1.0
4697community:
4698 resolution: 1.0
4699 seed: 42
4700"#;
4701 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";
4702 let claim = format!(
4703 "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"
4704 );
4705 let evidence = format!("name: evidence\ndescription: t\nwhen_to_use: tests\n{body}");
4706 let schema = Arc::new(
4707 memstead_schema::load_schema_from_memory(
4708 manifest,
4709 &[
4710 ("claim".to_string(), claim),
4711 ("evidence".to_string(), evidence),
4712 ],
4713 )
4714 .expect("render fixture schema must parse"),
4715 );
4716
4717 for verbosity in [SchemaVerbosity::Full, SchemaVerbosity::Lite] {
4718 let payload = build_schema_payload(&schema, vec![], verbosity, OriginClass::FirstParty);
4719 let types_key = if verbosity == SchemaVerbosity::Full {
4720 "types"
4721 } else {
4722 "types_summary"
4723 };
4724 let types = payload[types_key].as_array().expect("types array");
4725 let claim = types
4726 .iter()
4727 .find(|t| t["name"] == "claim")
4728 .expect("claim type present");
4729 let mr = claim["must_reach"].as_array().expect("obligations array");
4730 assert_eq!(mr.len(), 1);
4731 assert_eq!(mr[0]["relationships"], serde_json::json!(["GROUNDS"]));
4732 assert_eq!(mr[0]["direction"], "out");
4733 assert_eq!(mr[0]["terminal_types"], serde_json::json!(["evidence"]));
4734 assert_eq!(mr[0]["max_depth"], 12);
4735 let evidence = types
4736 .iter()
4737 .find(|t| t["name"] == "evidence")
4738 .expect("evidence type present");
4739 assert!(
4740 evidence.get("must_reach").is_none(),
4741 "undeclared type carries no must_reach key: {evidence:?}"
4742 );
4743 }
4744 }
4745
4746 #[test]
4747 fn lite_payload_is_the_structural_skeleton_without_prose() {
4748 let schema = software_schema();
4749 let lite = build_schema_payload(
4750 &schema,
4751 vec!["v".into()],
4752 SchemaVerbosity::Lite,
4753 OriginClass::FirstParty,
4754 );
4755
4756 let types = lite["types_summary"]
4758 .as_array()
4759 .expect("lite has `types_summary`");
4760 let rels = lite["relationships_summary"]
4761 .as_array()
4762 .expect("lite has `relationships_summary`");
4763 assert!(lite.get("types").is_none(), "lite omits rich `types`");
4764 assert!(
4765 lite.get("relationships").is_none(),
4766 "lite omits rich `relationships`"
4767 );
4768
4769 assert_eq!(lite["alias_target_rel_type"], "REFERENCES");
4772
4773 assert!(
4775 lite.get("description").is_none(),
4776 "lite drops schema description"
4777 );
4778 assert!(
4779 lite.get("when_to_use").is_none(),
4780 "lite drops schema when_to_use"
4781 );
4782 assert!(
4783 lite.get("default_writing_guidance").is_none(),
4784 "lite drops default_writing_guidance"
4785 );
4786
4787 for t in types {
4790 assert!(t["name"].is_string());
4791 let sections = t["sections"].as_array().expect("lite type has sections");
4792 for s in sections {
4793 assert!(s["key"].is_string(), "section carries its key");
4794 assert!(s["required"].is_boolean(), "section carries required flag");
4795 assert!(
4796 s.get("write_rules").is_none(),
4797 "lite section drops write_rules prose"
4798 );
4799 assert!(s.get("heading").is_none(), "lite section drops heading");
4800 }
4801 assert!(
4802 t.get("description").is_none(),
4803 "lite type drops description"
4804 );
4805 assert!(
4806 t.get("writing_guidance").is_none(),
4807 "lite type drops writing_guidance"
4808 );
4809 assert!(
4810 t.get("system_context").is_none(),
4811 "lite type drops system_context"
4812 );
4813 assert!(
4817 t.get("no_self_loop_relationships").is_some(),
4818 "lite type keeps no_self_loop_relationships"
4819 );
4820 assert!(
4824 t.get("required_outgoing").is_some_and(|v| v.is_array()),
4825 "lite type keeps required_outgoing as an array"
4826 );
4827 if let Some(fields) = t["fields"].as_array() {
4829 for f in fields {
4830 assert!(f["name"].is_string());
4831 assert!(f["required"].is_boolean());
4832 assert!(
4833 f.get("description").is_none(),
4834 "lite field drops description"
4835 );
4836 }
4837 }
4838 }
4839
4840 for r in rels {
4843 assert!(r["name"].is_string());
4844 assert!(
4845 r.get("allowed_sources").is_some(),
4846 "lite rel has allowed_sources"
4847 );
4848 assert!(
4849 r.get("allowed_targets").is_some(),
4850 "lite rel has allowed_targets"
4851 );
4852 assert!(
4853 r.get("manual_authoring").is_some(),
4854 "lite rel keeps manual_authoring"
4855 );
4856 assert!(r.get("acyclic").is_some(), "lite rel keeps acyclic");
4857 assert!(
4858 r.get("per_edge_description").is_some(),
4859 "lite rel keeps per_edge_description"
4860 );
4861 assert!(r.get("description").is_none(), "lite rel drops description");
4862 assert!(r.get("when_to_use").is_none(), "lite rel drops when_to_use");
4863 assert!(
4864 r.get("default_weight").is_none(),
4865 "lite rel drops default_weight"
4866 );
4867 }
4868 }
4869
4870 #[test]
4871 fn lite_is_measurably_smaller_than_full() {
4872 let schema = software_schema();
4873 let full = build_schema_payload(
4874 &schema,
4875 vec!["v".into()],
4876 SchemaVerbosity::Full,
4877 OriginClass::FirstParty,
4878 );
4879 let lite = build_schema_payload(
4880 &schema,
4881 vec!["v".into()],
4882 SchemaVerbosity::Lite,
4883 OriginClass::FirstParty,
4884 );
4885 let full_len = serde_json::to_string(&full).unwrap().len();
4886 let lite_len = serde_json::to_string(&lite).unwrap().len();
4887 assert!(
4888 lite_len * 2 < full_len,
4889 "lite ({lite_len} B) must be well under half of full ({full_len} B)"
4890 );
4891 }
4892
4893 #[test]
4894 fn lite_full_carry_the_same_type_and_rel_names() {
4895 let schema = software_schema();
4898 let full = build_schema_payload(
4899 &schema,
4900 vec!["v".into()],
4901 SchemaVerbosity::Full,
4902 OriginClass::FirstParty,
4903 );
4904 let lite = build_schema_payload(
4905 &schema,
4906 vec!["v".into()],
4907 SchemaVerbosity::Lite,
4908 OriginClass::FirstParty,
4909 );
4910
4911 let names = |arr: &serde_json::Value| -> Vec<String> {
4912 arr.as_array()
4913 .unwrap()
4914 .iter()
4915 .map(|v| v["name"].as_str().unwrap().to_string())
4916 .collect()
4917 };
4918 assert_eq!(names(&full["types"]), names(&lite["types_summary"]));
4919 assert_eq!(
4920 names(&full["relationships"]),
4921 names(&lite["relationships_summary"])
4922 );
4923 }
4924}