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 load_bearing: None,
2789 search_weight: 1.0,
2790 catch_all: false,
2791 write_rules: vec![],
2792 description: None,
2793 content: None,
2794 item_pattern: None,
2795 table: None,
2796 example: None,
2797 format_severity: memstead_schema::ConstraintSeverity::Block,
2798 compiled_content: None,
2799 format_problems: Vec::new(),
2800 }],
2801 metadata_fields: vec![],
2802 title_weight: 1.0,
2803 text_fields: vec![],
2804 hierarchy_relationship: "PART_OF".to_string(),
2805 edge_weight_overrides: indexmap::IndexMap::new(),
2806 edge_weights: indexmap::IndexMap::new(),
2807 no_self_loop_relationships: vec![],
2808 legacy_propagating_relationships: None,
2809 due: None,
2810 leaf: false,
2811 updatable_fields: vec![],
2812 health_required_fields: vec![],
2813 staleness_threshold_days: 90,
2814 write_rules: vec![],
2815 required_outgoing: vec![],
2816 must_reach: vec![],
2817 signals: vec![],
2818 constraints: vec![],
2819 declared_metadata_keys: vec![],
2820 };
2821
2822 let mut sections = HashMap::new();
2823 sections.insert("note".to_string(), "a note".to_string());
2824 assert_eq!(
2825 summary_pair(Some(&schema), §ions),
2826 ("Note".to_string(), "a note".to_string()),
2827 );
2828
2829 assert_eq!(
2830 summary_pair(Some(&schema), &HashMap::new()),
2831 ("Note".to_string(), "—".to_string()),
2832 );
2833 }
2834
2835 #[test]
2840 fn render_list_uses_first_required_section_for_spec() {
2841 let hit = make_hit(
2842 "specs--demo",
2843 "Demo Spec",
2844 "spec",
2845 &[
2846 ("identity", "A demo spec."),
2847 ("purpose", "Verifies rendering."),
2848 ],
2849 );
2850 let out = render_list_markdown(&list_result(vec![hit]));
2851 assert!(
2852 out.contains("**Identity**: A demo spec."),
2853 "expected Identity line for spec hit, got:\n{out}"
2854 );
2855 }
2856
2857 #[test]
2858 fn render_list_uses_first_required_section_for_memo() {
2859 let hit = make_hit("memos--d1", "Memo One", "memo", &[("claim", "Some claim.")]);
2860 let out = render_list_markdown(&list_result(vec![hit]));
2861 assert!(
2862 out.contains("**Claim**: Some claim."),
2863 "expected Claim line for memo hit, got:\n{out}"
2864 );
2865 assert!(
2866 !out.contains("**Identity**"),
2867 "memo hit must not render Identity label in list output"
2868 );
2869 }
2870
2871 #[test]
2872 fn render_list_uses_first_required_section_for_concept() {
2873 let hit = make_hit(
2874 "concepts--thing",
2875 "Thing",
2876 "concept",
2877 &[("definition", "A thing.")],
2878 );
2879 let out = render_list_markdown(&list_result(vec![hit]));
2880 assert!(
2881 out.contains("**Definition**: A thing."),
2882 "expected Definition line for concept hit, got:\n{out}"
2883 );
2884 }
2885
2886 #[test]
2887 fn render_list_missing_summary_section_shows_dash() {
2888 let hit = make_hit("memos--empty", "Empty Memo", "memo", &[]);
2889 let out = render_list_markdown(&list_result(vec![hit]));
2890 assert!(
2891 out.contains("**Claim**: —"),
2892 "expected Claim dash fallback in list output, got:\n{out}"
2893 );
2894 }
2895
2896 #[test]
2897 fn render_list_mixes_schemas_in_one_result() {
2898 let spec_hit = make_hit(
2899 "specs--s1",
2900 "Spec One",
2901 "spec",
2902 &[("identity", "Spec body.")],
2903 );
2904 let memo_hit = make_hit("memos--m1", "Memo One", "memo", &[("claim", "Memo claim.")]);
2905 let out = render_list_markdown(&list_result(vec![spec_hit, memo_hit]));
2906 assert!(
2907 out.contains("**Identity**: Spec body."),
2908 "spec hit should still render Identity in list output, got:\n{out}"
2909 );
2910 assert!(
2911 out.contains("**Claim**: Memo claim."),
2912 "memo hit should render Claim in list output, got:\n{out}"
2913 );
2914 }
2915
2916 #[test]
2917 fn render_list_unknown_schema_shows_summary_dash() {
2918 let hit = make_hit("bogus--x", "Bogus", "bogus", &[]);
2919 let out = render_list_markdown(&list_result(vec![hit]));
2920 assert!(
2921 out.contains("**Summary**: —"),
2922 "unknown schema should render Summary dash in list output, got:\n{out}"
2923 );
2924 }
2925
2926 #[test]
2931 fn summary_pair_for_spec_returns_identity() {
2932 let schema = type_by_name("spec");
2933 let mut sections = HashMap::new();
2934 sections.insert("identity".to_string(), "A demo spec.".to_string());
2935 assert_eq!(
2936 summary_pair(schema.as_deref(), §ions),
2937 ("Identity".to_string(), "A demo spec.".to_string()),
2938 );
2939 }
2940
2941 #[test]
2942 fn summary_pair_for_memo_returns_claim() {
2943 let schema = type_by_name("memo");
2944 let mut sections = HashMap::new();
2945 sections.insert("claim".to_string(), "Memos matter.".to_string());
2946 assert_eq!(
2947 summary_pair(schema.as_deref(), §ions),
2948 ("Claim".to_string(), "Memos matter.".to_string()),
2949 );
2950 }
2951
2952 #[test]
2953 fn summary_pair_missing_section_returns_dash() {
2954 let schema = type_by_name("memo");
2955 assert_eq!(
2956 summary_pair(schema.as_deref(), &HashMap::new()),
2957 ("Claim".to_string(), "—".to_string()),
2958 );
2959 }
2960
2961 #[test]
2962 fn summary_pair_unknown_schema_returns_summary_dash() {
2963 assert_eq!(
2964 summary_pair(None, &HashMap::new()),
2965 ("Summary".to_string(), "—".to_string()),
2966 );
2967 }
2968
2969 #[test]
2974 fn envelope_serializes_summary_fields() {
2975 let hit = make_hit(
2976 "memos--d1",
2977 "Memo One",
2978 "memo",
2979 &[("claim", "Memos matter.")],
2980 );
2981 let result = search_result(vec![hit]);
2982 let envelope = build_search_envelope(&result, 0);
2983 let value = serde_json::to_value(&envelope).expect("envelope must serialize");
2984
2985 assert_eq!(value["_total"], 1);
2989 assert_eq!(value["_returned"], 1);
2990 assert_eq!(value["_offset"], 0);
2991 assert!(
2993 value.get("warnings").is_none(),
2994 "empty warnings must be elided, got: {value}"
2995 );
2996
2997 let hit0 = &value["hits"][0];
2998 assert_eq!(hit0["summary_heading"], "Claim");
2999 assert_eq!(hit0["summary_value"], "Memos matter.");
3000 assert_eq!(hit0["id"], "memos--d1");
3002 assert_eq!(hit0["title"], "Memo One");
3003 assert_eq!(hit0["entity_type"], "memo");
3004 assert_eq!(hit0["mem"], "memos");
3005 assert_eq!(hit0["stub"], false);
3006 assert_eq!(hit0["tokens"], 10);
3007 assert!(hit0["sections"].is_object());
3008 }
3009
3010 #[test]
3011 fn envelope_roundtrips_through_structured_content() {
3012 let spec_hit = make_hit(
3015 "specs--s1",
3016 "Spec One",
3017 "spec",
3018 &[("identity", "Spec body.")],
3019 );
3020 let memo_hit = make_hit("memos--m1", "Memo One", "memo", &[("claim", "Memo claim.")]);
3021 let result = search_result(vec![spec_hit, memo_hit]);
3022 let envelope = build_search_envelope(&result, 0);
3023 let value = serde_json::to_value(&envelope).expect("envelope must serialize");
3024
3025 let hits = value["hits"].as_array().expect("hits must be array");
3026 assert_eq!(hits.len(), 2);
3027 assert_eq!(hits[0]["summary_heading"], "Identity");
3028 assert_eq!(hits[0]["summary_value"], "Spec body.");
3029 assert_eq!(hits[1]["summary_heading"], "Claim");
3030 assert_eq!(hits[1]["summary_value"], "Memo claim.");
3031 }
3032
3033 #[test]
3034 fn list_envelope_includes_total_tokens() {
3035 let hit = make_hit(
3036 "concepts--c1",
3037 "Thing",
3038 "concept",
3039 &[("definition", "A thing.")],
3040 );
3041 let result = list_result(vec![hit]);
3042 let envelope = build_list_envelope(&result);
3043 let value = serde_json::to_value(&envelope).expect("envelope must serialize");
3044
3045 assert_eq!(value["_total"], 1);
3047 assert_eq!(value["_total_tokens"], 10);
3048 assert!(value.get("total").is_none(), "unprefixed keys retired");
3049 assert_eq!(value["hits"][0]["summary_heading"], "Definition");
3050 assert_eq!(value["hits"][0]["summary_value"], "A thing.");
3051 }
3052
3053 #[test]
3054 fn envelope_emits_warnings_when_present() {
3055 let mut result = search_result(vec![]);
3056 result.warnings = vec![crate::ops::WarningHint::FieldNotFilterable {
3059 field: "foo".to_string(),
3060 }];
3061 let envelope = build_search_envelope(&result, 0);
3062 let value = serde_json::to_value(&envelope).expect("envelope must serialize");
3063 assert_eq!(value["warnings"][0]["code"], "FIELD_NOT_FILTERABLE");
3064 assert_eq!(value["warnings"][0]["details"]["field"], "foo");
3065 assert!(
3066 value["warnings"][0]["message"]
3067 .as_str()
3068 .is_some_and(|m| m.contains("not filterable"))
3069 );
3070 }
3071
3072 fn tm(field: &str, snippet: &str, heading_path: Option<&[&str]>) -> TermMatch {
3077 TermMatch {
3078 field: field.to_string(),
3079 snippet: snippet.to_string(),
3080 heading_path: heading_path.map(|p| p.iter().map(|s| s.to_string()).collect()),
3081 }
3082 }
3083
3084 fn sample_facets() -> Facets {
3085 use crate::ops::SubsectionFacet;
3086 Facets {
3087 by_type: HashMap::from([
3088 ("spec".to_string(), 7),
3089 ("memo".to_string(), 3),
3090 ("decision".to_string(), 2),
3091 ]),
3092 by_mem: HashMap::from([("specs".to_string(), 10), ("memos".to_string(), 2)]),
3093 by_level: HashMap::from([("high".to_string(), 4)]),
3094 by_status: HashMap::from([("active".to_string(), 6)]),
3095 by_confidence: HashMap::from([("medium".to_string(), 3)]),
3096 by_subsection: vec![
3097 SubsectionFacet {
3098 path: vec!["specifies".to_string(), "Response Shapes".to_string()],
3099 count: 4,
3100 },
3101 SubsectionFacet {
3102 path: vec!["purpose".to_string(), "Rationale".to_string()],
3103 count: 2,
3104 },
3105 ],
3106 by_expansion: HashMap::from([("primary".to_string(), 8), ("expanded".to_string(), 4)]),
3107 }
3108 }
3109
3110 #[test]
3111 fn render_search_emits_matched_terms_line() {
3112 let mut hit = make_hit(
3113 "specs--e1",
3114 "Entity One",
3115 "spec",
3116 &[("identity", "Body text.")],
3117 );
3118 hit.matched_terms = Some(HashMap::from([
3119 (
3120 "entity".to_string(),
3121 vec![
3122 tm("title", "...entity...", None),
3123 tm("purpose", "...entity...", None),
3124 tm("purpose", "...entity two...", None),
3125 ],
3126 ),
3127 ("one".to_string(), vec![tm("title", "...one...", None)]),
3128 ]));
3129 let out = render_search_markdown(&search_result(vec![hit]), 0);
3130 assert!(
3131 out.contains("**Matched terms:**"),
3132 "missing Matched terms line; got:\n{out}"
3133 );
3134 assert!(
3135 out.contains("`entity` (purpose×2, title×1)"),
3136 "entity term grouping wrong; got:\n{out}"
3137 );
3138 assert!(
3139 out.contains("`one` (title×1)"),
3140 "one term grouping wrong; got:\n{out}"
3141 );
3142 }
3143
3144 #[test]
3145 fn render_search_emits_score_breakdown_line() {
3146 let mut hit = make_hit("specs--e1", "Entity", "spec", &[("identity", "b")]);
3147 hit.score_breakdown = Some(ScoreBreakdown {
3148 bm25: 2.5,
3149 title_boost: 2.0,
3150 field_weights: HashMap::from([("body".to_string(), 0.8), ("purpose".to_string(), 0.3)]),
3151 expansion_decay: Some(0.5),
3152 });
3153 let out = render_search_markdown(&search_result(vec![hit]), 0);
3154 assert!(
3155 out.contains(
3156 "**Score:** bm25 2.5 + title 2.0 + body 0.8 + purpose 0.3 + expansion_decay ×0.5"
3157 ),
3158 "score breakdown line wrong; got:\n{out}"
3159 );
3160 }
3161
3162 #[test]
3163 fn render_search_omits_expansion_decay_when_none() {
3164 let mut hit = make_hit("specs--e1", "Entity", "spec", &[("identity", "b")]);
3165 hit.score_breakdown = Some(ScoreBreakdown {
3166 bm25: 1.5,
3167 title_boost: 1.0,
3168 field_weights: HashMap::new(),
3169 expansion_decay: None,
3170 });
3171 let out = render_search_markdown(&search_result(vec![hit]), 0);
3172 assert!(
3173 out.contains("**Score:** bm25 1.5 + title 1.0"),
3174 "base score wrong; got:\n{out}"
3175 );
3176 assert!(
3177 !out.contains("expansion_decay"),
3178 "expansion_decay must be absent when None; got:\n{out}"
3179 );
3180 }
3181
3182 #[test]
3183 fn render_search_emits_heading_path_line() {
3184 let mut hit = make_hit("specs--e1", "Entity", "spec", &[("identity", "b")]);
3185 hit.matched_terms = Some(HashMap::from([(
3186 "x".to_string(),
3187 vec![
3188 tm("purpose", "...x...", Some(&["Purpose", "Rationale"])),
3189 tm("purpose", "...x...", Some(&["Purpose", "Rationale"])), tm("specifies", "...x...", Some(&["Specifies", "Responses"])),
3191 ],
3192 )]));
3193 let out = render_search_markdown(&search_result(vec![hit]), 0);
3194 assert!(
3195 out.contains("**Heading path:** Purpose › Rationale; Specifies › Responses"),
3196 "heading path line wrong; got:\n{out}"
3197 );
3198 }
3199
3200 #[test]
3201 fn render_search_emits_expansion_line() {
3202 let mut hit = make_hit("specs--e2", "Entity Two", "spec", &[("identity", "b")]);
3203 hit.expansion = Some(ExpansionInfo {
3204 of: EntityId("specs--seed".to_string()),
3205 via_edge: "refines".to_string(),
3206 via_direction: crate::graph::query::TraversalDirection::Out,
3207 depth: 1,
3208 });
3209 let out = render_search_markdown(&search_result(vec![hit]), 0);
3210 assert!(
3211 out.contains("**Expansion:** from `specs--seed` via `refines` [out] (depth 1)"),
3212 "expansion line reports the traversal direction beside the label; got:\n{out}"
3213 );
3214 }
3215
3216 #[test]
3217 fn render_search_emits_facets_block() {
3218 let mut result = search_result(vec![]);
3219 result.facets = Some(sample_facets());
3220 let out = render_search_markdown(&result, 0);
3221 assert!(
3222 out.contains("## Facets"),
3223 "facets header missing; got:\n{out}"
3224 );
3225 assert!(
3226 out.contains("- **by_type:** spec=7, memo=3, decision=2"),
3227 "by_type bucket wrong; got:\n{out}"
3228 );
3229 assert!(
3230 out.contains("- **by_mem:** specs=10, memos=2"),
3231 "by_mem bucket wrong; got:\n{out}"
3232 );
3233 assert!(
3234 out.contains("- **by_level:** high=4"),
3235 "by_level bucket wrong; got:\n{out}"
3236 );
3237 assert!(
3238 out.contains("- **by_status:** active=6"),
3239 "by_status bucket wrong; got:\n{out}"
3240 );
3241 assert!(
3242 out.contains("- **by_confidence:** medium=3"),
3243 "by_confidence bucket wrong; got:\n{out}"
3244 );
3245 assert!(
3246 out.contains("- **by_expansion:** primary=8, expanded=4"),
3247 "by_expansion bucket wrong; got:\n{out}"
3248 );
3249 assert!(
3250 out.contains("- **by_subsection:**"),
3251 "by_subsection header missing; got:\n{out}"
3252 );
3253 assert!(
3254 out.contains("`specifies › Response Shapes`: 4"),
3255 "subsection facet wrong; got:\n{out}"
3256 );
3257 }
3258
3259 #[test]
3260 fn render_search_omits_facets_block_when_all_empty() {
3261 let mut result = search_result(vec![]);
3262 result.facets = Some(Facets::default());
3263 let out = render_search_markdown(&result, 0);
3264 assert!(
3265 !out.contains("## Facets"),
3266 "empty facets must not emit header; got:\n{out}"
3267 );
3268 }
3269
3270 #[test]
3274 fn search_markdown_covers_every_sidecar_field() {
3275 let mut hit = make_hit(
3276 "specs--e1",
3277 "Entity One",
3278 "spec",
3279 &[("identity", "Body text.")],
3280 );
3281 hit.matched_terms = Some(HashMap::from([(
3282 "entity".to_string(),
3283 vec![tm("title", "...entity...", Some(&["Purpose", "Rationale"]))],
3284 )]));
3285 hit.score_breakdown = Some(ScoreBreakdown {
3286 bm25: 1.5,
3287 title_boost: 1.0,
3288 field_weights: HashMap::from([("body".to_string(), 0.4)]),
3289 expansion_decay: Some(0.5),
3290 });
3291 hit.expansion = Some(ExpansionInfo {
3292 of: EntityId("specs--seed".to_string()),
3293 via_edge: "refines".to_string(),
3294 via_direction: crate::graph::query::TraversalDirection::Out,
3295 depth: 2,
3296 });
3297
3298 let mut result = search_result(vec![hit]);
3299 result.facets = Some(sample_facets());
3300
3301 let out = render_search_markdown(&result, 0);
3302 for marker in [
3303 "## Facets",
3304 "- **by_type:**",
3305 "- **by_mem:**",
3306 "- **by_level:**",
3307 "- **by_status:**",
3308 "- **by_confidence:**",
3309 "- **by_expansion:**",
3310 "- **by_subsection:**",
3311 "**Matched terms:**",
3312 "**Score:**",
3313 "**Heading path:**",
3314 "**Expansion:**",
3315 ] {
3316 assert!(
3317 out.contains(marker),
3318 "lockstep marker `{marker}` missing from search markdown; \
3319 update render_search_markdown when adding sidecar fields. got:\n{out}"
3320 );
3321 }
3322 }
3323
3324 #[test]
3331 fn build_entity_envelope_source_field_reads_edge_source() {
3332 let mut entity = test_entity();
3333 let body_link_target = EntityId("specs--body-link-target".to_string());
3334 let explicit_target = EntityId("specs--explicit-target".to_string());
3335 entity.relationships = vec![
3336 crate::entity::Relationship::new("REFERENCES".to_string(), body_link_target.clone()),
3337 crate::entity::Relationship::new("USES".to_string(), explicit_target.clone()),
3338 ];
3339
3340 let edges = vec![
3341 crate::store::Edge {
3342 rel_type: "REFERENCES".to_string(),
3343 target: body_link_target.clone(),
3344 source: crate::store::EdgeSource::BodyLink,
3345 },
3346 crate::store::Edge {
3347 rel_type: "USES".to_string(),
3348 target: explicit_target.clone(),
3349 source: crate::store::EdgeSource::Explicit,
3350 },
3351 ];
3352
3353 let env = build_entity_envelope(
3354 &entity,
3355 0,
3356 None,
3357 None,
3358 None,
3359 OriginClass::FirstParty,
3360 &edges,
3361 None,
3362 None,
3363 None,
3364 );
3365 let relationships = env["relationships"].as_array().expect("array");
3366 let refs = relationships
3367 .iter()
3368 .find(|r| r["rel_type"] == "REFERENCES")
3369 .expect("REFERENCES present");
3370 assert_eq!(
3371 refs["source"], "body_link",
3372 "alias-synthesised edge must label body_link"
3373 );
3374 let uses = relationships
3375 .iter()
3376 .find(|r| r["rel_type"] == "USES")
3377 .expect("USES present");
3378 assert_eq!(
3379 uses["source"], "explicit",
3380 "explicit-authored edge must label explicit"
3381 );
3382 }
3383
3384 #[test]
3391 fn build_entity_envelope_carries_origin_direction_and_incoming() {
3392 let mut entity = test_entity();
3393 let out_target = EntityId("specs--downstream".to_string());
3394 entity.relationships = vec![crate::entity::Relationship::new(
3395 "USES".to_string(),
3396 out_target.clone(),
3397 )];
3398 let edges = vec![crate::store::Edge {
3399 rel_type: "USES".to_string(),
3400 target: out_target,
3401 source: crate::store::EdgeSource::Explicit,
3402 }];
3403 let incoming = vec![crate::store::InEdge {
3404 rel_type: "MANAGES".to_string(),
3405 from: EntityId("specs--upstream".to_string()),
3406 source: crate::store::EdgeSource::Explicit,
3407 }];
3408
3409 let env = build_entity_envelope(
3411 &entity,
3412 0,
3413 None,
3414 None,
3415 None,
3416 OriginClass::ThirdParty,
3417 &edges,
3418 None,
3419 None,
3420 None,
3421 );
3422 assert_eq!(env["origin"], "third-party", "origin is envelope-level");
3423 let rels = env["relationships"].as_array().expect("array");
3424 assert_eq!(rels.len(), 1);
3425 assert_eq!(rels[0]["direction"], "out");
3426
3427 let env = build_entity_envelope(
3430 &entity,
3431 0,
3432 None,
3433 None,
3434 None,
3435 OriginClass::FirstParty,
3436 &edges,
3437 Some(&incoming),
3438 None,
3439 None,
3440 );
3441 assert_eq!(env["origin"], "first-party");
3442 let rels = env["relationships"].as_array().expect("array");
3443 assert_eq!(rels.len(), 2);
3444 let inc = rels
3445 .iter()
3446 .find(|r| r["direction"] == "in")
3447 .expect("incoming entry present");
3448 assert_eq!(inc["rel_type"], "MANAGES");
3449 assert_eq!(inc["from"], "specs--upstream");
3450 assert!(
3451 inc.get("target").is_none(),
3452 "incoming carries from, not target"
3453 );
3454 }
3455
3456 #[test]
3461 fn build_entity_envelope_source_field_falls_back_to_explicit_when_edge_missing() {
3462 let mut entity = test_entity();
3463 let target = EntityId("specs--unmapped".to_string());
3464 entity.relationships = vec![crate::entity::Relationship::new("USES".to_string(), target)];
3465 let edges: Vec<crate::store::Edge> = Vec::new();
3466 let env = build_entity_envelope(
3467 &entity,
3468 0,
3469 None,
3470 None,
3471 None,
3472 OriginClass::FirstParty,
3473 &edges,
3474 None,
3475 None,
3476 None,
3477 );
3478 let relationships = env["relationships"].as_array().expect("array");
3479 assert_eq!(relationships[0]["source"], "explicit");
3480 }
3481
3482 #[test]
3488 fn build_entity_envelope_nested_metadata_carries_every_schema_field() {
3489 use crate::entity::MetadataValue;
3490 let mut entity = test_entity();
3491 entity.entity_type = "contract".to_string();
3492 entity.metadata = IndexMap::from([
3494 ("level".to_string(), MetadataValue::String("M0".to_string())),
3495 (
3496 "stability".to_string(),
3497 MetadataValue::String("stable".to_string()),
3498 ),
3499 (
3500 "created_date".to_string(),
3501 MetadataValue::String("2026-01-01".to_string()),
3502 ),
3503 (
3504 "last_modified".to_string(),
3505 MetadataValue::String("2026-05-19".to_string()),
3506 ),
3507 (
3508 "protocol".to_string(),
3509 MetadataValue::String("https".to_string()),
3510 ),
3511 (
3512 "version".to_string(),
3513 MetadataValue::String("0.1.0".to_string()),
3514 ),
3515 (
3516 "deprecation_status".to_string(),
3517 MetadataValue::String("none".to_string()),
3518 ),
3519 ]);
3520
3521 let env = build_entity_envelope(
3522 &entity,
3523 0,
3524 None,
3525 None,
3526 None,
3527 OriginClass::FirstParty,
3528 &[],
3529 None,
3530 None,
3531 None,
3532 );
3533
3534 assert!(
3537 env.get("level").is_none(),
3538 "level must not be hoisted top-level"
3539 );
3540 assert!(
3541 env.get("stability").is_none(),
3542 "stability must not be hoisted"
3543 );
3544 assert!(
3545 env.get("created_date").is_none(),
3546 "created_date must not be hoisted"
3547 );
3548 assert!(
3549 env.get("last_modified").is_none(),
3550 "last_modified must not be hoisted"
3551 );
3552 assert_eq!(env["type"], "contract");
3554
3555 let metadata = env["metadata"].as_object().expect("metadata map");
3557 assert_eq!(metadata["level"], "M0");
3558 assert_eq!(metadata["stability"], "stable");
3559 assert_eq!(metadata["created_date"], "2026-01-01");
3560 assert_eq!(metadata["last_modified"], "2026-05-19");
3561 assert_eq!(metadata["protocol"], "https");
3562 assert_eq!(metadata["version"], "0.1.0");
3563 assert_eq!(metadata["deprecation_status"], "none");
3564
3565 for k in metadata.keys() {
3568 assert!(
3569 !k.starts_with('_'),
3570 "metadata map must not carry underscore-prefixed key `{k}`"
3571 );
3572 assert!(
3573 !["mem", "id", "type"].contains(&k.as_str()),
3574 "metadata map must not carry identity key `{k}` (it lives top-level)"
3575 );
3576 }
3577 }
3578
3579 #[test]
3583 fn build_entity_envelope_stub_carries_empty_metadata_map() {
3584 let mut entity = test_entity();
3585 entity.stub = true;
3586 entity.stub_kind = Some(crate::entity::StubKind::ForwardReference);
3587 entity.metadata = IndexMap::new();
3588 let env = build_entity_envelope(
3589 &entity,
3590 0,
3591 None,
3592 None,
3593 None,
3594 OriginClass::FirstParty,
3595 &[],
3596 None,
3597 None,
3598 None,
3599 );
3600 let metadata = env["metadata"]
3601 .as_object()
3602 .expect("metadata key present even on stubs");
3603 assert!(metadata.is_empty(), "stub metadata map must be empty");
3604 }
3605
3606 #[test]
3613 fn build_entity_envelope_user_field_collisions_isolated_to_nested_map() {
3614 use crate::entity::MetadataValue;
3615 let mut entity = test_entity();
3616 entity.metadata = IndexMap::from([
3617 (
3618 "sections".to_string(),
3619 MetadataValue::String("user-supplied-shadow".to_string()),
3620 ),
3621 (
3622 "relationships".to_string(),
3623 MetadataValue::String("also-shadowed".to_string()),
3624 ),
3625 ]);
3626 let env = build_entity_envelope(
3627 &entity,
3628 0,
3629 None,
3630 None,
3631 None,
3632 OriginClass::FirstParty,
3633 &[],
3634 None,
3635 None,
3636 None,
3637 );
3638 assert!(
3640 env["sections"].is_object(),
3641 "top-level sections stays a map"
3642 );
3643 assert!(
3644 env["relationships"].is_array(),
3645 "top-level relationships stays an array"
3646 );
3647 let metadata = env["metadata"].as_object().expect("metadata map");
3649 assert_eq!(metadata["sections"], "user-supplied-shadow");
3650 assert_eq!(metadata["relationships"], "also-shadowed");
3651 }
3652
3653 #[test]
3657 fn build_entity_envelope_unfiltered_body_token_field_name() {
3658 let entity = test_entity();
3659 let env_filtered = build_entity_envelope(
3661 &entity,
3662 10,
3663 Some(42),
3664 None,
3665 None,
3666 OriginClass::FirstParty,
3667 &[],
3668 None,
3669 None,
3670 None,
3671 );
3672 assert_eq!(env_filtered["_tokens_unfiltered_body"], 42);
3673 assert!(
3674 env_filtered.get("_tokens_full").is_none(),
3675 "_tokens_full must not survive — rename is one-way"
3676 );
3677 let env_unfiltered = build_entity_envelope(
3679 &entity,
3680 10,
3681 None,
3682 None,
3683 None,
3684 OriginClass::FirstParty,
3685 &[],
3686 None,
3687 None,
3688 None,
3689 );
3690 assert!(env_unfiltered.get("_tokens_unfiltered_body").is_none());
3691 assert!(env_unfiltered.get("_tokens_full").is_none());
3692 }
3693
3694 fn software_schema() -> Arc<Schema> {
3702 memstead_schema::builtins::load_builtin_schemas()
3703 .expect("builtins load")
3704 .into_iter()
3705 .find(|s| s.manifest.name == "software")
3706 .expect("software schema is a builtin")
3707 }
3708
3709 #[test]
3710 fn schema_verbosity_wire_round_trips() {
3711 assert_eq!(
3712 SchemaVerbosity::from_wire("full"),
3713 Some(SchemaVerbosity::Full)
3714 );
3715 assert_eq!(
3716 SchemaVerbosity::from_wire("lite"),
3717 Some(SchemaVerbosity::Lite)
3718 );
3719 assert_eq!(SchemaVerbosity::from_wire("brief"), None);
3720 assert_eq!(SchemaVerbosity::from_wire(""), None);
3721 assert_eq!(SchemaVerbosity::Full.as_wire(), "full");
3722 assert_eq!(SchemaVerbosity::Lite.as_wire(), "lite");
3723 assert_eq!(SchemaVerbosity::default(), SchemaVerbosity::Full);
3724 }
3725
3726 #[test]
3732 fn exemplar_serves_at_full_and_lite_stays_byte_unchanged() {
3733 let manifest = r#"name: servefix
3734version: 1.0.0
3735description: serving fixture
3736when_to_use: tests
3737types:
3738 - sample
3739relationships:
3740 mode: strict
3741 definitions:
3742 - name: PART_OF
3743 description: hier
3744 default_weight: 3.0
3745 - name: _default
3746 description: fallback
3747 default_weight: 1.0
3748community:
3749 resolution: 1.0
3750 seed: 42
3751"#;
3752 let base_type = r#"name: sample
3753description: t
3754when_to_use: tests
3755sections:
3756 - key: body
3757 heading: Body
3758 required: true
3759 search_weight: 10.0
3760 catch_all: true
3761 write_rules: []
3762metadata_fields:
3763 - key: status
3764 description: state
3765 field_type: string
3766 enum_values: [draft, final]
3767 optional: true
3768title_weight: 100.0
3769text_fields:
3770 - body
3771hierarchy_relationship: PART_OF
3772no_self_loop_relationships: []
3773updatable_fields:
3774 - title
3775 - body
3776health_required_fields:
3777 - body
3778staleness_threshold_days: 90
3779write_rules: []
3780"#;
3781 let with_exemplar = format!(
3782 "{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"
3783 );
3784
3785 let plain = Arc::new(
3786 memstead_schema::loader::load_schema_from_memory(
3787 manifest,
3788 &[("sample".to_string(), base_type.to_string())],
3789 )
3790 .expect("fixture loads"),
3791 );
3792 let exemplary = Arc::new(
3793 memstead_schema::loader::load_schema_from_memory(
3794 manifest,
3795 &[("sample".to_string(), with_exemplar)],
3796 )
3797 .expect("fixture loads"),
3798 );
3799
3800 let full = build_schema_payload(
3802 &exemplary,
3803 vec![],
3804 SchemaVerbosity::Full,
3805 OriginClass::FirstParty,
3806 );
3807 let ex = &full["types"][0]["exemplar"];
3808 assert_eq!(ex["title"], "A Conforming Sample", "{full}");
3809 assert_eq!(ex["metadata"]["status"], "draft");
3810 assert_eq!(ex["sections"]["body"], "One canonical body paragraph.");
3811 assert_eq!(ex["relations"][0]["to"], "parent-placeholder");
3812 assert_eq!(ex["relations"][0]["type"], "PART_OF");
3813
3814 let full_plain = build_schema_payload(
3816 &plain,
3817 vec![],
3818 SchemaVerbosity::Full,
3819 OriginClass::FirstParty,
3820 );
3821 assert!(full_plain["types"][0].get("exemplar").is_none());
3822
3823 let lite_with = build_schema_payload(
3826 &exemplary,
3827 vec![],
3828 SchemaVerbosity::Lite,
3829 OriginClass::FirstParty,
3830 );
3831 let lite_without = build_schema_payload(
3832 &plain,
3833 vec![],
3834 SchemaVerbosity::Lite,
3835 OriginClass::FirstParty,
3836 );
3837 assert_eq!(
3838 serde_json::to_string(&lite_with).unwrap(),
3839 serde_json::to_string(&lite_without).unwrap(),
3840 "lite must not change when an exemplar exists"
3841 );
3842 assert!(
3843 !serde_json::to_string(&lite_with)
3844 .unwrap()
3845 .contains("exemplar"),
3846 "lite must not mention exemplars at all"
3847 );
3848 }
3849
3850 #[test]
3854 fn first_party_origin_is_labelled_and_keeps_prose() {
3855 let schema = software_schema();
3856 let full = build_schema_payload(
3857 &schema,
3858 vec!["v".into()],
3859 SchemaVerbosity::Full,
3860 OriginClass::FirstParty,
3861 );
3862 assert_eq!(full["origin"], "first-party");
3863 assert!(full["description"].is_string());
3865 let t = &full["types"].as_array().unwrap()[0];
3866 assert!(t.get("system_context").is_some());
3867 assert!(t.get("writing_guidance").is_some());
3868
3869 let lite = build_schema_payload(
3871 &schema,
3872 vec!["v".into()],
3873 SchemaVerbosity::Lite,
3874 OriginClass::FirstParty,
3875 );
3876 assert_eq!(lite["origin"], "first-party");
3877 }
3878
3879 #[test]
3884 fn constraints_and_severity_render_at_both_verbosities() {
3885 let manifest = r#"name: constrained
3886version: 1.0.0
3887description: constraint render fixture
3888when_to_use: render tests
3889types:
3890 - sample
3891relationships:
3892 mode: strict
3893 definitions:
3894 - name: PART_OF
3895 description: hier
3896 default_weight: 3.0
3897 - name: _default
3898 description: fallback
3899 default_weight: 1.0
3900community:
3901 resolution: 1.0
3902 seed: 42
3903"#;
3904 let type_yaml = r#"name: sample
3905description: t
3906when_to_use: tests
3907sections:
3908 - key: body
3909 heading: Body
3910 required: true
3911 search_weight: 10.0
3912 catch_all: true
3913 write_rules: []
3914metadata_fields:
3915 - key: status
3916 description: state
3917 field_type: string
3918 enum_values: [open, checked]
3919 optional: true
3920 - key: checked_by
3921 description: who
3922 field_type: string
3923 optional: true
3924title_weight: 100.0
3925text_fields:
3926 - body
3927hierarchy_relationship: PART_OF
3928no_self_loop_relationships: []
3929updatable_fields:
3930 - title
3931 - body
3932health_required_fields:
3933 - body
3934staleness_threshold_days: 90
3935required_outgoing:
3936 - relationships: [PART_OF]
3937 cardinality: at_least_one
3938 severity: block
3939constraints:
3940 - kind: requires_when
3941 field: checked_by
3942 when_field: status
3943 when_value: checked
3944 - kind: unique
3945 fields: [status, checked_by]
3946 - kind: enum_from_neighbour
3947 field: status
3948 rel_type: PART_OF
3949 section: body
3950 - kind: status_propagation
3951 field: status
3952 value: checked
3953 rel_type: PART_OF
3954 direction: incoming
3955write_rules: []
3956"#;
3957 let schema = Arc::new(
3958 memstead_schema::loader::load_schema_from_memory(
3959 manifest,
3960 &[("sample".to_string(), type_yaml.to_string())],
3961 )
3962 .expect("fixture loads"),
3963 );
3964
3965 let expected_constraints = serde_json::json!([
3970 {
3971 "kind": "requires_when",
3972 "field": "checked_by",
3973 "when_field": "status",
3974 "when_value": "checked",
3975 "severity": "warn",
3976 },
3977 {
3978 "kind": "unique",
3979 "fields": ["status", "checked_by"],
3980 "severity": "block",
3981 },
3982 {
3983 "kind": "enum_from_neighbour",
3984 "field": "status",
3985 "rel_type": "PART_OF",
3986 "section": "body",
3987 "severity": "warn",
3988 },
3989 {
3990 "kind": "status_propagation",
3991 "field": "status",
3992 "value": "checked",
3993 "rel_type": "PART_OF",
3994 "direction": "incoming",
3995 "severity": "warn",
3996 },
3997 ]);
3998
3999 let full = build_schema_payload(
4000 &schema,
4001 vec![],
4002 SchemaVerbosity::Full,
4003 OriginClass::FirstParty,
4004 );
4005 let t = &full["types"].as_array().unwrap()[0];
4006 assert_eq!(t["constraints"], expected_constraints);
4007 assert_eq!(t["required_outgoing"][0]["severity"], "block");
4008
4009 let lite = build_schema_payload(
4010 &schema,
4011 vec![],
4012 SchemaVerbosity::Lite,
4013 OriginClass::FirstParty,
4014 );
4015 let ts = &lite["types_summary"].as_array().unwrap()[0];
4016 assert_eq!(ts["constraints"], expected_constraints);
4017 assert_eq!(ts["required_outgoing"][0]["severity"], "block");
4018
4019 let fmt_manifest = r#"name: formatted
4022version: 1.0.0
4023description: format render fixture
4024when_to_use: render tests
4025types:
4026 - plan
4027relationships:
4028 mode: strict
4029 definitions:
4030 - name: PART_OF
4031 description: hier
4032 default_weight: 1.0
4033 - name: _default
4034 description: fallback
4035 default_weight: 1.0
4036community:
4037 resolution: 1.0
4038 seed: 42
4039"#;
4040 let fmt_type = r#"name: plan
4041description: t
4042when_to_use: tests
4043sections:
4044 - key: body
4045 heading: Body
4046 required: true
4047 search_weight: 10.0
4048 catch_all: true
4049 write_rules: []
4050 - key: meilensteine
4051 heading: Meilensteine
4052 required: false
4053 search_weight: 5.0
4054 catch_all: false
4055 write_rules: []
4056 content: "(heading(3) list(bullet))+"
4057 item_pattern: '\*\*(?<name>[^*]+)\*\*'
4058 example: |
4059 ### Phase 1
4060 - **Kickoff**
4061 format_severity: warn
4062 - key: tabelle
4063 heading: Tabelle
4064 required: false
4065 search_weight: 5.0
4066 catch_all: false
4067 write_rules: []
4068 content: "table"
4069 table:
4070 columns: [Name, Datum]
4071 column_patterns:
4072 Datum: '\d{4}-\d{2}-\d{2}'
4073 - key: belege
4074 heading: Belege
4075 required: false
4076 search_weight: 5.0
4077 catch_all: false
4078 write_rules: []
4079 content: "paragraph+"
4080 item_pattern: '(?<quelle>\S[^|]*?) \| (?<aussage>.+)'
4081metadata_fields: []
4082title_weight: 100.0
4083text_fields:
4084 - body
4085hierarchy_relationship: PART_OF
4086no_self_loop_relationships: []
4087updatable_fields:
4088 - title
4089 - body
4090health_required_fields:
4091 - body
4092staleness_threshold_days: 90
4093write_rules: []
4094"#;
4095 let fmt_schema = Arc::new(
4096 memstead_schema::loader::load_schema_from_memory(
4097 fmt_manifest,
4098 &[("plan".to_string(), fmt_type.to_string())],
4099 )
4100 .expect("format fixture loads"),
4101 );
4102 for verbosity in [SchemaVerbosity::Full, SchemaVerbosity::Lite] {
4103 let payload =
4104 build_schema_payload(&fmt_schema, vec![], verbosity, OriginClass::FirstParty);
4105 let sections_key = match verbosity {
4106 SchemaVerbosity::Full => &payload["types"][0]["sections"],
4107 SchemaVerbosity::Lite => &payload["types_summary"][0]["sections"],
4108 };
4109 let secs = sections_key.as_array().unwrap();
4110 let meilensteine = secs
4111 .iter()
4112 .find(|s| s["key"] == "meilensteine")
4113 .expect("declared section present");
4114 assert_eq!(
4115 meilensteine["content"], "(heading(3) list(bullet))+",
4116 "{verbosity:?} carries content"
4117 );
4118 assert!(
4119 meilensteine["item_pattern"]
4120 .as_str()
4121 .unwrap()
4122 .contains("name")
4123 );
4124 assert!(
4125 meilensteine["example"]
4126 .as_str()
4127 .unwrap()
4128 .contains("Kickoff")
4129 );
4130 assert_eq!(meilensteine["format_severity"], "warn");
4131 let tabelle = secs.iter().find(|s| s["key"] == "tabelle").unwrap();
4132 assert_eq!(tabelle["format_severity"], "block", "default renders");
4133 assert_eq!(tabelle["table"]["columns"][0], "Name");
4134 assert!(
4135 tabelle["table"]["column_patterns"]["Datum"]
4136 .as_str()
4137 .is_some()
4138 );
4139 let belege = secs.iter().find(|s| s["key"] == "belege").unwrap();
4140 assert_eq!(belege["content"], "paragraph+");
4141 assert!(belege["item_pattern"].as_str().unwrap().contains("quelle"));
4142 let body = secs.iter().find(|s| s["key"] == "body").unwrap();
4143 assert!(
4144 body.get("content").is_none() && body.get("format_severity").is_none(),
4145 "undeclared section keeps its pre-plan shape"
4146 );
4147 }
4148
4149 let plain_full = build_schema_payload(
4152 &software_schema(),
4153 vec![],
4154 SchemaVerbosity::Full,
4155 OriginClass::FirstParty,
4156 );
4157 let pt = &plain_full["types"].as_array().unwrap()[0];
4158 assert_eq!(pt["constraints"], serde_json::json!([]));
4159 let plain_lite = build_schema_payload(
4160 &software_schema(),
4161 vec![],
4162 SchemaVerbosity::Lite,
4163 OriginClass::FirstParty,
4164 );
4165 let pts = &plain_lite["types_summary"].as_array().unwrap()[0];
4166 assert_eq!(pts["constraints"], serde_json::json!([]));
4167 }
4168
4169 #[test]
4179 fn third_party_origin_forces_structural_only_even_under_full() {
4180 let schema = software_schema();
4181 let full_requested = build_schema_payload(
4182 &schema,
4183 vec!["v".into()],
4184 SchemaVerbosity::Full,
4185 OriginClass::ThirdParty,
4186 );
4187
4188 assert_eq!(full_requested["origin"], "third-party");
4190
4191 assert!(
4194 full_requested.get("types").is_none(),
4195 "third-party omits the rich `types` array even under full"
4196 );
4197 assert!(
4198 full_requested.get("relationships").is_none(),
4199 "third-party omits the rich `relationships` array even under full"
4200 );
4201 assert!(
4202 full_requested["types_summary"].is_array(),
4203 "third-party serves the structural `types_summary` skeleton"
4204 );
4205 assert!(
4206 full_requested["relationships_summary"].is_array(),
4207 "third-party serves the structural `relationships_summary` skeleton"
4208 );
4209
4210 assert!(
4212 full_requested.get("description").is_none(),
4213 "third-party drops schema description prose"
4214 );
4215 assert!(
4216 full_requested.get("when_to_use").is_none(),
4217 "third-party drops schema when_to_use prose"
4218 );
4219 assert!(
4220 full_requested.get("default_writing_guidance").is_none(),
4221 "third-party drops default_writing_guidance prose"
4222 );
4223
4224 for t in full_requested["types_summary"].as_array().unwrap() {
4226 assert!(
4227 t.get("system_context").is_none(),
4228 "third-party drops system_context"
4229 );
4230 assert!(
4231 t.get("writing_guidance").is_none(),
4232 "third-party drops writing_guidance"
4233 );
4234 assert!(
4235 t.get("description").is_none(),
4236 "third-party drops type description"
4237 );
4238 for s in t["sections"].as_array().unwrap() {
4239 assert!(
4240 s.get("write_rules").is_none(),
4241 "third-party drops section write_rules"
4242 );
4243 }
4244 }
4245 for r in full_requested["relationships_summary"].as_array().unwrap() {
4247 assert!(
4248 r.get("description").is_none(),
4249 "third-party drops rel description"
4250 );
4251 assert!(
4252 r.get("when_to_use").is_none(),
4253 "third-party drops rel when_to_use"
4254 );
4255 }
4256
4257 let lite_requested = build_schema_payload(
4261 &schema,
4262 vec!["v".into()],
4263 SchemaVerbosity::Lite,
4264 OriginClass::ThirdParty,
4265 );
4266 assert_eq!(
4267 full_requested, lite_requested,
4268 "third-party full must collapse to the lite skeleton"
4269 );
4270 }
4271
4272 #[test]
4273 fn full_payload_carries_the_rich_arrays_and_prose() {
4274 let schema = software_schema();
4275 let full = build_schema_payload(
4276 &schema,
4277 vec!["v".into()],
4278 SchemaVerbosity::Full,
4279 OriginClass::FirstParty,
4280 );
4281
4282 assert!(full["types"].is_array(), "full has `types`");
4284 assert!(full["relationships"].is_array(), "full has `relationships`");
4285 assert!(
4286 full.get("types_summary").is_none(),
4287 "full omits `types_summary`"
4288 );
4289 assert!(
4290 full.get("relationships_summary").is_none(),
4291 "full omits `relationships_summary`"
4292 );
4293 assert!(
4294 full["description"].is_string(),
4295 "full keeps schema description"
4296 );
4297 assert!(
4298 full["when_to_use"].is_string(),
4299 "full keeps schema when_to_use"
4300 );
4301 assert_eq!(full["alias_target_rel_type"], "REFERENCES");
4302
4303 let t = &full["types"].as_array().unwrap()[0];
4305 assert!(t["description"].is_string());
4306 assert!(t.get("writing_guidance").is_some());
4307 assert!(t.get("system_context").is_some());
4308 let r = &full["relationships"].as_array().unwrap()[0];
4310 assert!(r["description"].is_string());
4311 assert!(r.get("when_to_use").is_some());
4312 assert!(r.get("default_weight").is_some());
4313 }
4314
4315 #[test]
4324 fn required_outgoing_reported_with_cardinality_at_both_levels() {
4325 let reg = memstead_schema::SchemaRegistry::builtin();
4326 let project = reg
4327 .get("project", &semver::Version::new(0, 2, 0))
4328 .expect("project is a built-in");
4329
4330 for verbosity in [SchemaVerbosity::Full, SchemaVerbosity::Lite] {
4331 let payload =
4332 build_schema_payload(&project, vec![], verbosity, OriginClass::FirstParty);
4333 let types_key = if verbosity == SchemaVerbosity::Full {
4334 "types"
4335 } else {
4336 "types_summary"
4337 };
4338 let types = payload[types_key].as_array().expect("types array");
4339
4340 let mut saw_evidence = false;
4341 let mut saw_memo = false;
4342 for t in types {
4343 let ro = t
4344 .get("required_outgoing")
4345 .unwrap_or_else(|| panic!("type {} omits required_outgoing", t["name"]))
4346 .as_array()
4347 .expect("required_outgoing is an array for every type");
4348 if t["name"] == "evidence" {
4349 saw_evidence = true;
4350 assert_eq!(ro.len(), 1, "evidence declares one block");
4351 assert_eq!(
4352 ro[0]["relationships"],
4353 serde_json::json!(["STRENGTHENS", "WEAKENS", "VALIDATES", "CONTRADICTS"]),
4354 "relationship alternatives in declaration order"
4355 );
4356 assert_eq!(
4357 ro[0]["cardinality"], "at_least_one",
4358 "cardinality rendered as declared — the open upper bound \
4359 stays open, never a finite number"
4360 );
4361 } else if t["name"] == "memo" {
4362 saw_memo = true;
4365 assert!(ro.is_empty(), "memo declares no blocks → empty list");
4366 }
4367 }
4368 assert!(saw_evidence, "project schema carries the evidence type");
4369 assert!(saw_memo, "project schema carries the memo type");
4370
4371 let note = payload["no_self_loop_relationships_effect"]
4374 .as_str()
4375 .expect("effect note present at both verbosity levels");
4376 assert!(note.contains("self-loop"), "names the actual effect");
4377 assert!(
4378 !note.contains("propagates impact") || note.contains("does not propagate"),
4379 "claims no propagation behaviour beyond the self-loop refusal"
4380 );
4381 assert!(
4382 note.contains("status_propagation"),
4383 "deprecation pointer names the real propagation declaration"
4384 );
4385 }
4386 }
4387
4388 #[test]
4394 fn conditional_required_outgoing_trigger_visible_at_both_levels() {
4395 let manifest = r#"name: condro-render
4396version: 0.1.0
4397description: conditional required_outgoing render fixture
4398when_to_use: tests
4399types:
4400 - task
4401relationships:
4402 mode: strict
4403 definitions:
4404 - name: PART_OF
4405 description: hier
4406 default_weight: 3.0
4407 - name: _default
4408 description: fallback
4409 default_weight: 1.0
4410community:
4411 resolution: 1.0
4412 seed: 42
4413"#;
4414 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";
4415 let schema = Arc::new(
4416 memstead_schema::load_schema_from_memory(
4417 manifest,
4418 &[("task".to_string(), task_yaml.to_string())],
4419 )
4420 .expect("render fixture schema must parse"),
4421 );
4422
4423 for verbosity in [SchemaVerbosity::Full, SchemaVerbosity::Lite] {
4424 let payload = build_schema_payload(&schema, vec![], verbosity, OriginClass::FirstParty);
4425 let types_key = if verbosity == SchemaVerbosity::Full {
4426 "types"
4427 } else {
4428 "types_summary"
4429 };
4430 let task = &payload[types_key].as_array().expect("types array")[0];
4431 let ro = task["required_outgoing"].as_array().expect("blocks array");
4432 assert_eq!(ro.len(), 2);
4433 assert!(
4434 ro[0].get("when_field").is_none() && ro[0].get("when_value").is_none(),
4435 "unconditional block carries no when_* keys: {:?}",
4436 ro[0]
4437 );
4438 assert_eq!(ro[1]["when_field"], "status");
4439 assert_eq!(ro[1]["when_value"], "checked");
4440 }
4441 }
4442
4443 #[test]
4449 fn acyclic_sets_and_propagation_rel_types_visible_at_both_levels() {
4450 let manifest = r#"name: relsets-render
4451version: 0.1.0
4452description: relation-set render fixture
4453when_to_use: tests
4454types:
4455 - claim
4456relationships:
4457 mode: strict
4458 acyclic_sets:
4459 - [GROUNDS, CONCLUDES]
4460 definitions:
4461 - name: GROUNDS
4462 description: g
4463 default_weight: 3.0
4464 - name: CONCLUDES
4465 description: c
4466 default_weight: 3.0
4467 - name: PART_OF
4468 description: hier
4469 default_weight: 1.0
4470 - name: _default
4471 description: fallback
4472 default_weight: 1.0
4473community:
4474 resolution: 1.0
4475 seed: 42
4476"#;
4477 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";
4478 let schema = Arc::new(
4479 memstead_schema::load_schema_from_memory(
4480 manifest,
4481 &[("claim".to_string(), claim.to_string())],
4482 )
4483 .expect("render fixture schema must parse"),
4484 );
4485
4486 for verbosity in [SchemaVerbosity::Full, SchemaVerbosity::Lite] {
4487 let payload = build_schema_payload(&schema, vec![], verbosity, OriginClass::FirstParty);
4488 assert_eq!(
4489 payload["acyclic_sets"],
4490 serde_json::json!([["GROUNDS", "CONCLUDES"]]),
4491 "acyclic_sets present at {verbosity:?}"
4492 );
4493 let types_key = if verbosity == SchemaVerbosity::Full {
4494 "types"
4495 } else {
4496 "types_summary"
4497 };
4498 let claim = &payload[types_key].as_array().expect("types array")[0];
4499 let constraints = claim["constraints"].as_array().expect("constraints array");
4500 assert_eq!(
4501 constraints[0]["rel_types"],
4502 serde_json::json!(["GROUNDS", "CONCLUDES"])
4503 );
4504 assert!(
4505 constraints[0].get("rel_type").is_none(),
4506 "set declaration carries no single-name key: {:?}",
4507 constraints[0]
4508 );
4509 assert_eq!(constraints[1]["rel_type"], "PART_OF");
4510 assert!(
4511 constraints[1].get("rel_types").is_none(),
4512 "single-name declaration stays byte-identical: {:?}",
4513 constraints[1]
4514 );
4515 }
4516
4517 let plain = software_schema();
4519 for verbosity in [SchemaVerbosity::Full, SchemaVerbosity::Lite] {
4520 let payload = build_schema_payload(&plain, vec![], verbosity, OriginClass::FirstParty);
4521 assert!(
4522 payload.get("acyclic_sets").is_none(),
4523 "undeclared schema carries no acyclic_sets key"
4524 );
4525 }
4526 }
4527
4528 #[test]
4532 fn labelling_declaration_visible_at_both_levels_and_absent_when_undeclared() {
4533 let manifest = r#"name: labelling-render
4534version: 0.1.0
4535description: labelling render fixture
4536when_to_use: tests
4537types:
4538 - claim
4539relationships:
4540 mode: strict
4541 labelling:
4542 attack: [REBUTS]
4543 support:
4544 relationships: [GROUNDS]
4545 direction: out
4546 terminal_types: [claim]
4547 definitions:
4548 - name: REBUTS
4549 description: attack
4550 default_weight: 3.0
4551 - name: GROUNDS
4552 description: support
4553 default_weight: 3.0
4554 - name: PART_OF
4555 description: hier
4556 default_weight: 1.0
4557 - name: _default
4558 description: fallback
4559 default_weight: 1.0
4560community:
4561 resolution: 1.0
4562 seed: 42
4563"#;
4564 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";
4565 let schema = Arc::new(
4566 memstead_schema::load_schema_from_memory(
4567 manifest,
4568 &[("claim".to_string(), claim.to_string())],
4569 )
4570 .expect("render fixture schema must parse"),
4571 );
4572
4573 for verbosity in [SchemaVerbosity::Full, SchemaVerbosity::Lite] {
4574 let payload = build_schema_payload(&schema, vec![], verbosity, OriginClass::FirstParty);
4575 assert_eq!(
4576 payload["labelling"]["attack"],
4577 serde_json::json!(["REBUTS"]),
4578 "attack set present at {verbosity:?}"
4579 );
4580 assert_eq!(
4581 payload["labelling"]["support"]["relationships"],
4582 serde_json::json!(["GROUNDS"])
4583 );
4584 assert_eq!(payload["labelling"]["support"]["direction"], "out");
4585 }
4586
4587 let plain = software_schema();
4588 for verbosity in [SchemaVerbosity::Full, SchemaVerbosity::Lite] {
4589 let payload = build_schema_payload(&plain, vec![], verbosity, OriginClass::FirstParty);
4590 assert!(
4591 payload.get("labelling").is_none(),
4592 "undeclared schema carries no labelling key"
4593 );
4594 }
4595 }
4596
4597 #[test]
4601 fn signal_declarations_visible_at_both_levels_and_absent_when_undeclared() {
4602 let manifest = r#"name: signals-render
4603version: 0.1.0
4604description: signal render fixture
4605when_to_use: tests
4606types:
4607 - claim
4608 - objection
4609relationships:
4610 mode: strict
4611 definitions:
4612 - name: REBUTS
4613 description: r
4614 default_weight: 3.0
4615 - name: PART_OF
4616 description: hier
4617 default_weight: 1.0
4618 - name: _default
4619 description: fallback
4620 default_weight: 1.0
4621community:
4622 resolution: 1.0
4623 seed: 42
4624"#;
4625 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";
4626 let claim = format!(
4627 "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"
4628 );
4629 let objection = format!(
4630 "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}"
4631 );
4632 let schema = Arc::new(
4633 memstead_schema::load_schema_from_memory(
4634 manifest,
4635 &[
4636 ("claim".to_string(), claim),
4637 ("objection".to_string(), objection),
4638 ],
4639 )
4640 .expect("render fixture schema must parse"),
4641 );
4642
4643 for verbosity in [SchemaVerbosity::Full, SchemaVerbosity::Lite] {
4644 let payload = build_schema_payload(&schema, vec![], verbosity, OriginClass::FirstParty);
4645 let types_key = if verbosity == SchemaVerbosity::Full {
4646 "types"
4647 } else {
4648 "types_summary"
4649 };
4650 let types = payload[types_key].as_array().expect("types array");
4651 let claim = types
4652 .iter()
4653 .find(|t| t["name"] == "claim")
4654 .expect("claim type present");
4655 let sigs = claim["signals"].as_array().expect("signals array");
4656 assert_eq!(sigs[0]["name"], "attack_load");
4657 assert_eq!(sigs[0]["kind"], "edge_load");
4658 assert_eq!(sigs[0]["direction"], "in");
4659 assert_eq!(sigs[0]["thresholds"][1]["at_least"], 3);
4660 assert_eq!(sigs[0]["thresholds"][1]["level"], "warn");
4661 let objection = types
4662 .iter()
4663 .find(|t| t["name"] == "objection")
4664 .expect("objection type present");
4665 assert!(
4666 objection.get("signals").is_none(),
4667 "undeclared type carries no signals key"
4668 );
4669 }
4670 }
4671
4672 #[test]
4678 fn must_reach_visible_at_both_levels_and_absent_when_undeclared() {
4679 let manifest = r#"name: mustreach-render
4680version: 0.1.0
4681description: must_reach render fixture
4682when_to_use: tests
4683types:
4684 - claim
4685 - evidence
4686relationships:
4687 mode: strict
4688 definitions:
4689 - name: GROUNDS
4690 description: g
4691 default_weight: 3.0
4692 - name: PART_OF
4693 description: hier
4694 default_weight: 1.0
4695 - name: _default
4696 description: fallback
4697 default_weight: 1.0
4698community:
4699 resolution: 1.0
4700 seed: 42
4701"#;
4702 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";
4703 let claim = format!(
4704 "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"
4705 );
4706 let evidence = format!("name: evidence\ndescription: t\nwhen_to_use: tests\n{body}");
4707 let schema = Arc::new(
4708 memstead_schema::load_schema_from_memory(
4709 manifest,
4710 &[
4711 ("claim".to_string(), claim),
4712 ("evidence".to_string(), evidence),
4713 ],
4714 )
4715 .expect("render fixture schema must parse"),
4716 );
4717
4718 for verbosity in [SchemaVerbosity::Full, SchemaVerbosity::Lite] {
4719 let payload = build_schema_payload(&schema, vec![], verbosity, OriginClass::FirstParty);
4720 let types_key = if verbosity == SchemaVerbosity::Full {
4721 "types"
4722 } else {
4723 "types_summary"
4724 };
4725 let types = payload[types_key].as_array().expect("types array");
4726 let claim = types
4727 .iter()
4728 .find(|t| t["name"] == "claim")
4729 .expect("claim type present");
4730 let mr = claim["must_reach"].as_array().expect("obligations array");
4731 assert_eq!(mr.len(), 1);
4732 assert_eq!(mr[0]["relationships"], serde_json::json!(["GROUNDS"]));
4733 assert_eq!(mr[0]["direction"], "out");
4734 assert_eq!(mr[0]["terminal_types"], serde_json::json!(["evidence"]));
4735 assert_eq!(mr[0]["max_depth"], 12);
4736 let evidence = types
4737 .iter()
4738 .find(|t| t["name"] == "evidence")
4739 .expect("evidence type present");
4740 assert!(
4741 evidence.get("must_reach").is_none(),
4742 "undeclared type carries no must_reach key: {evidence:?}"
4743 );
4744 }
4745 }
4746
4747 #[test]
4748 fn lite_payload_is_the_structural_skeleton_without_prose() {
4749 let schema = software_schema();
4750 let lite = build_schema_payload(
4751 &schema,
4752 vec!["v".into()],
4753 SchemaVerbosity::Lite,
4754 OriginClass::FirstParty,
4755 );
4756
4757 let types = lite["types_summary"]
4759 .as_array()
4760 .expect("lite has `types_summary`");
4761 let rels = lite["relationships_summary"]
4762 .as_array()
4763 .expect("lite has `relationships_summary`");
4764 assert!(lite.get("types").is_none(), "lite omits rich `types`");
4765 assert!(
4766 lite.get("relationships").is_none(),
4767 "lite omits rich `relationships`"
4768 );
4769
4770 assert_eq!(lite["alias_target_rel_type"], "REFERENCES");
4773
4774 assert!(
4776 lite.get("description").is_none(),
4777 "lite drops schema description"
4778 );
4779 assert!(
4780 lite.get("when_to_use").is_none(),
4781 "lite drops schema when_to_use"
4782 );
4783 assert!(
4784 lite.get("default_writing_guidance").is_none(),
4785 "lite drops default_writing_guidance"
4786 );
4787
4788 for t in types {
4791 assert!(t["name"].is_string());
4792 let sections = t["sections"].as_array().expect("lite type has sections");
4793 for s in sections {
4794 assert!(s["key"].is_string(), "section carries its key");
4795 assert!(s["required"].is_boolean(), "section carries required flag");
4796 assert!(
4797 s.get("write_rules").is_none(),
4798 "lite section drops write_rules prose"
4799 );
4800 assert!(s.get("heading").is_none(), "lite section drops heading");
4801 }
4802 assert!(
4803 t.get("description").is_none(),
4804 "lite type drops description"
4805 );
4806 assert!(
4807 t.get("writing_guidance").is_none(),
4808 "lite type drops writing_guidance"
4809 );
4810 assert!(
4811 t.get("system_context").is_none(),
4812 "lite type drops system_context"
4813 );
4814 assert!(
4818 t.get("no_self_loop_relationships").is_some(),
4819 "lite type keeps no_self_loop_relationships"
4820 );
4821 assert!(
4825 t.get("required_outgoing").is_some_and(|v| v.is_array()),
4826 "lite type keeps required_outgoing as an array"
4827 );
4828 if let Some(fields) = t["fields"].as_array() {
4830 for f in fields {
4831 assert!(f["name"].is_string());
4832 assert!(f["required"].is_boolean());
4833 assert!(
4834 f.get("description").is_none(),
4835 "lite field drops description"
4836 );
4837 }
4838 }
4839 }
4840
4841 for r in rels {
4844 assert!(r["name"].is_string());
4845 assert!(
4846 r.get("allowed_sources").is_some(),
4847 "lite rel has allowed_sources"
4848 );
4849 assert!(
4850 r.get("allowed_targets").is_some(),
4851 "lite rel has allowed_targets"
4852 );
4853 assert!(
4854 r.get("manual_authoring").is_some(),
4855 "lite rel keeps manual_authoring"
4856 );
4857 assert!(r.get("acyclic").is_some(), "lite rel keeps acyclic");
4858 assert!(
4859 r.get("per_edge_description").is_some(),
4860 "lite rel keeps per_edge_description"
4861 );
4862 assert!(r.get("description").is_none(), "lite rel drops description");
4863 assert!(r.get("when_to_use").is_none(), "lite rel drops when_to_use");
4864 assert!(
4865 r.get("default_weight").is_none(),
4866 "lite rel drops default_weight"
4867 );
4868 }
4869 }
4870
4871 #[test]
4872 fn lite_is_measurably_smaller_than_full() {
4873 let schema = software_schema();
4874 let full = build_schema_payload(
4875 &schema,
4876 vec!["v".into()],
4877 SchemaVerbosity::Full,
4878 OriginClass::FirstParty,
4879 );
4880 let lite = build_schema_payload(
4881 &schema,
4882 vec!["v".into()],
4883 SchemaVerbosity::Lite,
4884 OriginClass::FirstParty,
4885 );
4886 let full_len = serde_json::to_string(&full).unwrap().len();
4887 let lite_len = serde_json::to_string(&lite).unwrap().len();
4888 assert!(
4889 lite_len * 2 < full_len,
4890 "lite ({lite_len} B) must be well under half of full ({full_len} B)"
4891 );
4892 }
4893
4894 #[test]
4895 fn lite_full_carry_the_same_type_and_rel_names() {
4896 let schema = software_schema();
4899 let full = build_schema_payload(
4900 &schema,
4901 vec!["v".into()],
4902 SchemaVerbosity::Full,
4903 OriginClass::FirstParty,
4904 );
4905 let lite = build_schema_payload(
4906 &schema,
4907 vec!["v".into()],
4908 SchemaVerbosity::Lite,
4909 OriginClass::FirstParty,
4910 );
4911
4912 let names = |arr: &serde_json::Value| -> Vec<String> {
4913 arr.as_array()
4914 .unwrap()
4915 .iter()
4916 .map(|v| v["name"].as_str().unwrap().to_string())
4917 .collect()
4918 };
4919 assert_eq!(names(&full["types"]), names(&lite["types_summary"]));
4920 assert_eq!(
4921 names(&full["relationships"]),
4922 names(&lite["relationships_summary"])
4923 );
4924 }
4925}