1use regex::Regex;
2use serde_json::{json, Value};
3use std::collections::{HashMap, HashSet};
4use std::path::Path;
5use std::sync::OnceLock;
6
7const RATIONALE_MIN_CHARS: usize = 80;
8const RATIONALE_MIN_WORDS: usize = 8;
9
10const VALID_SEMANTIC_FILE_TYPES: &[&str] =
11 &["code", "document", "paper", "image", "rationale", "concept"];
12
13fn semantic_id_re() -> &'static Regex {
14 static RE: OnceLock<Regex> = OnceLock::new();
15 RE.get_or_init(|| Regex::new(r"^[A-Za-z0-9._:-]+$").unwrap())
16}
17
18pub struct SemanticLimits {
19 pub max_bytes: usize,
20 pub max_nodes: usize,
21 pub max_edges: usize,
22 pub max_hyperedges: usize,
23 pub max_hyperedge_nodes: usize,
24 pub max_id_length: usize,
25}
26
27impl Default for SemanticLimits {
28 fn default() -> Self {
29 Self {
30 max_bytes: 25 * 1024 * 1024,
31 max_nodes: 10_000,
32 max_edges: 100_000,
33 max_hyperedges: 10_000,
34 max_hyperedge_nodes: 256,
35 max_id_length: 256,
36 }
37 }
38}
39
40pub fn validate_semantic_fragment(fragment: &Value) -> Vec<String> {
41 validate_semantic_fragment_with_limits(fragment, &SemanticLimits::default())
42}
43
44pub fn validate_semantic_fragment_with_limits(
45 fragment: &Value,
46 limits: &SemanticLimits,
47) -> Vec<String> {
48 let obj = match fragment.as_object() {
49 Some(o) => o,
50 None => return vec!["fragment must be a JSON object".to_string()],
51 };
52
53 let mut errors: Vec<String> = Vec::new();
54
55 let payload = serde_json::to_string(fragment).unwrap_or_default();
56 let byte_len = payload.len();
57 if byte_len > limits.max_bytes {
58 errors.push(format!(
59 "payload is {} bytes; max is {}",
60 byte_len, limits.max_bytes
61 ));
62 }
63
64 let empty_arr = vec![];
65 let nodes = match obj.get("nodes") {
66 Some(v) => match v.as_array() {
67 Some(arr) => {
68 if arr.len() > limits.max_nodes {
69 errors.push(format!(
70 "nodes has {} entries; max is {}",
71 arr.len(),
72 limits.max_nodes
73 ));
74 }
75 arr
76 }
77 None => {
78 errors.push("nodes must be a list".to_string());
79 &empty_arr
80 }
81 },
82 None => &empty_arr,
83 };
84
85 let edges = match obj.get("edges") {
86 Some(v) => match v.as_array() {
87 Some(arr) => {
88 if arr.len() > limits.max_edges {
89 errors.push(format!(
90 "edges has {} entries; max is {}",
91 arr.len(),
92 limits.max_edges
93 ));
94 }
95 arr
96 }
97 None => {
98 errors.push("edges must be a list".to_string());
99 &empty_arr
100 }
101 },
102 None => &empty_arr,
103 };
104
105 for (i, node) in nodes.iter().enumerate() {
106 match node.as_object() {
107 None => {
108 errors.push(format!("nodes[{i}] must be an object"));
109 continue;
110 }
111 Some(n) => {
112 validate_semantic_id(&mut errors, &format!("nodes[{i}].id"), n.get("id"), limits);
113 if let Some(ft) = n.get("file_type").and_then(|v| v.as_str()) {
114 if !VALID_SEMANTIC_FILE_TYPES.contains(&ft) {
115 errors.push(format!("nodes[{i}].file_type {ft:?} is not one of {:?}", {
116 let mut v: Vec<&&str> = VALID_SEMANTIC_FILE_TYPES.iter().collect();
117 v.sort();
118 v
119 }));
120 }
121 }
122 }
123 }
124 }
125
126 for (i, edge) in edges.iter().enumerate() {
127 match edge.as_object() {
128 None => {
129 errors.push(format!("edges[{i}] must be an object"));
130 continue;
131 }
132 Some(e) => {
133 validate_semantic_id(
134 &mut errors,
135 &format!("edges[{i}].source"),
136 e.get("source"),
137 limits,
138 );
139 validate_semantic_id(
140 &mut errors,
141 &format!("edges[{i}].target"),
142 e.get("target"),
143 limits,
144 );
145 }
146 }
147 }
148
149 let hyperedges = match obj.get("hyperedges") {
150 None | Some(Value::Null) => &empty_arr,
151 Some(v) => match v.as_array() {
152 Some(arr) => {
153 if arr.len() > limits.max_hyperedges {
154 errors.push(format!(
155 "hyperedges has {} entries; max is {}",
156 arr.len(),
157 limits.max_hyperedges
158 ));
159 }
160 arr
161 }
162 None => {
163 errors.push("hyperedges must be a list".to_string());
164 &empty_arr
165 }
166 },
167 };
168
169 for (i, he) in hyperedges.iter().enumerate() {
170 match he.as_object() {
171 None => {
172 errors.push(format!("hyperedges[{i}] must be an object"));
173 continue;
174 }
175 Some(h) => {
176 validate_semantic_id(
177 &mut errors,
178 &format!("hyperedges[{i}].id"),
179 h.get("id"),
180 limits,
181 );
182 match h.get("nodes") {
183 None => {
184 errors.push(format!("hyperedges[{i}].nodes must be a list"));
185 }
186 Some(v) => match v.as_array() {
187 None => {
188 errors.push(format!("hyperedges[{i}].nodes must be a list"));
189 }
190 Some(he_nodes) => {
191 if he_nodes.len() > limits.max_hyperedge_nodes {
192 errors.push(format!(
193 "hyperedges[{i}].nodes has {} entries; max is {}",
194 he_nodes.len(),
195 limits.max_hyperedge_nodes
196 ));
197 }
198 for (j, r) in he_nodes.iter().enumerate() {
199 validate_semantic_id(
200 &mut errors,
201 &format!("hyperedges[{i}].nodes[{j}]"),
202 Some(r),
203 limits,
204 );
205 }
206 }
207 },
208 }
209 }
210 }
211 }
212
213 errors
214}
215
216fn validate_semantic_id(
217 errors: &mut Vec<String>,
218 field: &str,
219 value: Option<&Value>,
220 limits: &SemanticLimits,
221) {
222 match value {
223 None | Some(Value::Null) => {
224 errors.push(format!("{field} must be a string"));
225 }
226 Some(v) => match v.as_str() {
227 None => {
228 errors.push(format!("{field} must be a string"));
229 }
230 Some(s) => {
231 if s.is_empty() {
232 errors.push(format!("{field} must not be empty"));
233 return;
234 }
235 if s.len() > limits.max_id_length {
236 errors.push(format!(
237 "{field} is {} chars; max is {}",
238 s.len(),
239 limits.max_id_length
240 ));
241 }
242 if s.contains('/') || s.contains('\\') || s.contains("..") {
243 errors.push(format!("{field} must not contain path separators or '..'"));
244 }
245 if !semantic_id_re().is_match(s) {
246 errors.push(format!("{field} contains unsupported characters"));
247 }
248 }
249 },
250 }
251}
252
253pub fn load_validated_semantic_fragment(path: &Path) -> (Option<Value>, Vec<String>) {
254 let limits = SemanticLimits::default();
255 load_validated_semantic_fragment_with_limits(path, &limits)
256}
257
258pub fn load_validated_semantic_fragment_with_limits(
259 path: &Path,
260 limits: &SemanticLimits,
261) -> (Option<Value>, Vec<String>) {
262 let size = match std::fs::metadata(path) {
263 Err(e) => {
264 return (
265 None,
266 vec![format!("could not stat {}: {}", path.display(), e)],
267 )
268 }
269 Ok(m) => m.len() as usize,
270 };
271 if size > limits.max_bytes {
272 return (
273 None,
274 vec![format!(
275 "payload is {} bytes; max is {}",
276 size, limits.max_bytes
277 )],
278 );
279 }
280 let text = match std::fs::read_to_string(path) {
281 Err(e) => {
282 return (
283 None,
284 vec![format!("could not read {}: {}", path.display(), e)],
285 )
286 }
287 Ok(t) => t,
288 };
289 let fragment: Value = match serde_json::from_str(&text) {
290 Err(e) => return (None, vec![format!("invalid JSON: {}", e)]),
291 Ok(v) => v,
292 };
293 let errors = validate_semantic_fragment_with_limits(&fragment, limits);
294 if errors.is_empty() {
295 (Some(fragment), vec![])
296 } else {
297 (None, errors)
298 }
299}
300
301pub fn sanitize_semantic_fragment(fragment: &mut Value) -> &mut Value {
302 let obj = match fragment.as_object_mut() {
303 Some(o) => o,
304 None => return fragment,
305 };
306
307 let invalid_ft: HashSet<&str> = ["rationale", "concept"].iter().copied().collect();
308
309 let nodes: Vec<Value> = obj
310 .get("nodes")
311 .and_then(|v| v.as_array())
312 .cloned()
313 .unwrap_or_default();
314 let edges: Vec<Value> = obj
315 .get("edges")
316 .and_then(|v| v.as_array())
317 .cloned()
318 .unwrap_or_default();
319 let hyperedges: Vec<Value> = obj
320 .get("hyperedges")
321 .and_then(|v| v.as_array())
322 .cloned()
323 .unwrap_or_default();
324
325 let mut node_by_id: HashMap<String, Value> = HashMap::new();
326 for n in &nodes {
327 if let Some(id) = n.get("id").and_then(|v| v.as_str()) {
328 if !id.is_empty() {
329 node_by_id.insert(id.to_string(), n.clone());
330 }
331 }
332 }
333
334 let rationale_for_sources: HashSet<String> = edges
335 .iter()
336 .filter(|e| e.get("relation").and_then(|v| v.as_str()) == Some("rationale_for"))
337 .filter_map(|e| {
338 e.get("source")
339 .and_then(|v| v.as_str())
340 .map(|s| s.to_string())
341 })
342 .collect();
343
344 let mut rationale_candidates: Vec<Value> = Vec::new();
345 let mut remove_ids: HashSet<String> = HashSet::new();
346 let mut keep_nodes: Vec<Value> = Vec::new();
347
348 for n in nodes {
349 let nid = match n.get("id").and_then(|v| v.as_str()) {
350 Some(id) if !id.is_empty() => id.to_string(),
351 _ => continue,
352 };
353 let ft = n.get("file_type").and_then(|v| v.as_str()).unwrap_or("");
354 let label = n.get("label").and_then(|v| v.as_str()).unwrap_or("");
355 if invalid_ft.contains(ft) {
356 if is_sentence_like_rationale_label(label) {
357 rationale_candidates.push(n.clone());
358 }
359 remove_ids.insert(nid);
360 continue;
361 }
362 if rationale_for_sources.contains(&nid) && is_sentence_like_rationale_label(label) {
363 rationale_candidates.push(n.clone());
364 remove_ids.insert(nid);
365 continue;
366 }
367 keep_nodes.push(n);
368 }
369
370 let mut rationale_attrs: HashMap<String, Vec<String>> = HashMap::new();
371 for rn in &rationale_candidates {
372 let rn_id = match rn.get("id").and_then(|v| v.as_str()) {
373 Some(id) => id,
374 None => continue,
375 };
376 let text = rn
377 .get("label")
378 .and_then(|v| v.as_str())
379 .unwrap_or("")
380 .trim()
381 .to_string();
382 for e in &edges {
383 if e.get("relation").and_then(|v| v.as_str()) != Some("rationale_for") {
384 continue;
385 }
386 if e.get("source").and_then(|v| v.as_str()) != Some(rn_id) {
387 continue;
388 }
389 let target_id = match e.get("target").and_then(|v| v.as_str()) {
390 Some(t) => t.to_string(),
391 None => continue,
392 };
393 if !node_by_id.contains_key(&target_id) || remove_ids.contains(&target_id) {
394 continue;
395 }
396 rationale_attrs
397 .entry(target_id)
398 .or_default()
399 .push(text.clone());
400 }
401 }
402
403 for n in &mut keep_nodes {
404 let nid = n
405 .get("id")
406 .and_then(|v| v.as_str())
407 .unwrap_or("")
408 .to_string();
409 if let Some(texts) = rationale_attrs.get(&nid) {
410 append_rationale_attr(n, texts);
411 }
412 }
413
414 let keep_edges: Vec<Value> = edges
415 .into_iter()
416 .filter(|e| {
417 let src = e.get("source").and_then(|v| v.as_str()).unwrap_or("");
418 let tgt = e.get("target").and_then(|v| v.as_str()).unwrap_or("");
419 !remove_ids.contains(src) && !remove_ids.contains(tgt)
420 })
421 .collect();
422
423 let surviving_ids: HashSet<String> = keep_nodes
424 .iter()
425 .filter_map(|n| n.get("id").and_then(|v| v.as_str()).map(|s| s.to_string()))
426 .filter(|s| !s.is_empty())
427 .collect();
428
429 let keep_hyperedges: Vec<Value> = hyperedges
430 .into_iter()
431 .filter_map(|he| {
432 let he_obj = he.as_object()?;
433 let he_nodes = he_obj.get("nodes")?.as_array()?;
434 let filtered: Vec<Value> = he_nodes
435 .iter()
436 .filter(|r| {
437 r.as_str()
438 .map(|s| surviving_ids.contains(s))
439 .unwrap_or(false)
440 })
441 .cloned()
442 .collect();
443 if filtered.len() < 2 {
444 return None;
445 }
446 let mut new_he = he_obj.clone();
447 new_he.insert("nodes".to_string(), Value::Array(filtered));
448 Some(Value::Object(new_he))
449 })
450 .collect();
451
452 let obj = fragment.as_object_mut().unwrap();
453 obj.insert("nodes".to_string(), Value::Array(keep_nodes));
454 obj.insert("edges".to_string(), Value::Array(keep_edges));
455 obj.insert("hyperedges".to_string(), Value::Array(keep_hyperedges));
456 fragment
457}
458
459fn is_sentence_like_rationale_label(label: &str) -> bool {
460 if label.is_empty() {
461 return false;
462 }
463 let label = label.trim();
464 if label.len() < RATIONALE_MIN_CHARS {
465 let word_count = label.split_whitespace().count();
466 if word_count < RATIONALE_MIN_WORDS {
467 return false;
468 }
469 }
470 label.contains('.') || label.contains('!') || label.contains('?') || label.contains(':')
471}
472
473fn append_rationale_attr(node: &mut Value, texts: &[String]) {
474 let new_text = texts.join("\n\n").trim().to_string();
475 if let Some(obj) = node.as_object_mut() {
476 let existing = obj
477 .get("rationale")
478 .and_then(|v| v.as_str())
479 .unwrap_or("")
480 .to_string();
481 let combined = if existing.is_empty() {
482 new_text
483 } else {
484 format!("{}\n\n{}", existing, new_text)
485 };
486 obj.insert("rationale".to_string(), json!(combined));
487 }
488}
489
490#[cfg(test)]
491mod tests {
492 use super::*;
493 use serde_json::json;
494
495 fn valid_fragment() -> Value {
496 json!({
497 "nodes": [{"id": "module_func", "label": "func", "file_type": "code"}],
498 "edges": [{"source": "module_func", "target": "other_node"}],
499 "hyperedges": [],
500 })
501 }
502
503 #[test]
504 fn test_validate_semantic_fragment_accepts_valid() {
505 assert_eq!(
506 validate_semantic_fragment(&valid_fragment()),
507 Vec::<String>::new()
508 );
509 }
510
511 #[test]
512 fn test_validate_semantic_fragment_rejects_non_object() {
513 let errors = validate_semantic_fragment(&json!(["not", "an", "object"]));
514 assert!(errors.iter().any(|e| e.to_lowercase().contains("object")));
515 }
516
517 #[test]
518 fn test_validate_semantic_fragment_rejects_oversize_payload() {
519 let limits = SemanticLimits {
520 max_bytes: 64,
521 ..SemanticLimits::default()
522 };
523 let mut fragment = valid_fragment();
524 fragment["nodes"][0]["label"] = json!("x".repeat(128));
525 let errors = validate_semantic_fragment_with_limits(&fragment, &limits);
526 assert!(errors.iter().any(|e| e.to_lowercase().contains("payload")));
527 }
528
529 #[test]
530 fn test_validate_semantic_fragment_rejects_too_many_nodes() {
531 let limits = SemanticLimits {
532 max_nodes: 1,
533 ..SemanticLimits::default()
534 };
535 let mut fragment = valid_fragment();
536 fragment["nodes"]
537 .as_array_mut()
538 .unwrap()
539 .push(json!({"id": "extra", "label": "extra", "file_type": "code"}));
540 let errors = validate_semantic_fragment_with_limits(&fragment, &limits);
541 assert!(errors.iter().any(|e| e.to_lowercase().contains("nodes")));
542 }
543
544 #[test]
545 fn test_validate_semantic_fragment_rejects_too_many_edges() {
546 let limits = SemanticLimits {
547 max_edges: 0,
548 ..SemanticLimits::default()
549 };
550 let errors = validate_semantic_fragment_with_limits(&valid_fragment(), &limits);
551 assert!(errors.iter().any(|e| e.to_lowercase().contains("edges")));
552 }
553
554 #[test]
555 fn test_validate_semantic_fragment_rejects_path_separator_in_id() {
556 let mut fragment = valid_fragment();
557 fragment["nodes"][0]["id"] = json!("../etc/passwd");
558 let errors = validate_semantic_fragment(&fragment);
559 assert!(errors.iter().any(|e| e.contains("nodes[0].id")));
560 }
561
562 #[test]
563 fn test_validate_semantic_fragment_rejects_invalid_file_type() {
564 let mut fragment = valid_fragment();
565 fragment["nodes"][0]["file_type"] = json!("executable");
566 let errors = validate_semantic_fragment(&fragment);
567 assert!(errors.iter().any(|e| e.contains("file_type")));
568 }
569
570 #[test]
571 fn test_validate_semantic_fragment_accepts_rationale_file_type() {
572 let mut fragment = valid_fragment();
573 fragment["nodes"][0]["file_type"] = json!("rationale");
574 let errors = validate_semantic_fragment(&fragment);
575 assert!(
576 !errors.iter().any(|e| e.contains("file_type")),
577 "'rationale' must be accepted; errors: {errors:?}"
578 );
579 }
580
581 #[test]
582 fn test_validate_semantic_fragment_accepts_concept_file_type() {
583 let mut fragment = valid_fragment();
584 fragment["nodes"][0]["file_type"] = json!("concept");
585 let errors = validate_semantic_fragment(&fragment);
586 assert!(
587 !errors.iter().any(|e| e.contains("file_type")),
588 "'concept' must be accepted; errors: {errors:?}"
589 );
590 }
591
592 #[test]
593 fn test_load_validated_semantic_fragment_accepts_valid() {
594 let dir = tempfile::tempdir().unwrap();
595 let path = dir.path().join("chunk.json");
596 std::fs::write(&path, serde_json::to_string(&valid_fragment()).unwrap()).unwrap();
597 let (fragment, errors) = load_validated_semantic_fragment(&path);
598 assert_eq!(errors, Vec::<String>::new());
599 assert!(fragment.is_some());
600 }
601
602 #[test]
603 fn test_load_validated_semantic_fragment_rejects_oversize_before_parse() {
604 let limits = SemanticLimits {
605 max_bytes: 64,
606 ..SemanticLimits::default()
607 };
608 let dir = tempfile::tempdir().unwrap();
609 let path = dir.path().join("chunk.json");
610 let content: String = std::iter::repeat_n('"', 128).collect::<String>();
611 std::fs::write(&path, format!("[{}]", content)).unwrap();
612 let (fragment, errors) = load_validated_semantic_fragment_with_limits(&path, &limits);
613 assert!(fragment.is_none());
614 assert!(errors.iter().any(|e| e.to_lowercase().contains("payload")));
615 }
616
617 #[test]
618 fn test_load_validated_semantic_fragment_rejects_invalid_json() {
619 let dir = tempfile::tempdir().unwrap();
620 let path = dir.path().join("bad.json");
621 std::fs::write(&path, "{not valid json").unwrap();
622 let (fragment, errors) = load_validated_semantic_fragment(&path);
623 assert!(fragment.is_none());
624 assert!(errors
625 .iter()
626 .any(|e| e.to_lowercase().contains("invalid json")));
627 }
628
629 #[test]
630 fn test_validate_hyperedge_rejects_bad_id() {
631 let mut fragment = valid_fragment();
632 fragment["hyperedges"] = json!([
633 {"id": "../escape", "label": "x", "nodes": ["module_func", "module_func"]}
634 ]);
635 let errors = validate_semantic_fragment(&fragment);
636 assert!(errors.iter().any(|e| e.contains("hyperedges[0].id")));
637 }
638
639 #[test]
640 fn test_validate_hyperedge_rejects_bad_node_ref() {
641 let mut fragment = valid_fragment();
642 fragment["hyperedges"] = json!([
643 {"id": "valid_he", "label": "x", "nodes": ["module_func", "../bad_ref"]}
644 ]);
645 let errors = validate_semantic_fragment(&fragment);
646 assert!(errors.iter().any(|e| e.contains("hyperedges[0].nodes[1]")));
647 }
648
649 #[test]
650 fn test_validate_hyperedge_requires_list() {
651 let mut fragment = valid_fragment();
652 fragment["hyperedges"] = json!([{"id": "valid_he", "label": "x", "nodes": "not a list"}]);
653 let errors = validate_semantic_fragment(&fragment);
654 assert!(errors.iter().any(|e| e.contains("hyperedges[0].nodes")));
655 }
656
657 #[test]
658 fn test_validate_hyperedge_caps_count() {
659 let limits = SemanticLimits {
660 max_hyperedges: 1,
661 ..SemanticLimits::default()
662 };
663 let mut fragment = valid_fragment();
664 fragment["hyperedges"] = json!([
665 {"id": "he_0", "label": "x", "nodes": ["module_func", "module_func"]},
666 {"id": "he_1", "label": "x", "nodes": ["module_func", "module_func"]},
667 {"id": "he_2", "label": "x", "nodes": ["module_func", "module_func"]},
668 ]);
669 let errors = validate_semantic_fragment_with_limits(&fragment, &limits);
670 assert!(errors.iter().any(|e| e.contains("hyperedges has 3")));
671 }
672
673 #[test]
674 fn test_sanitize_drops_rationale_filetype_node() {
675 let mut fragment = json!({
676 "nodes": [
677 {"id": "real_node", "label": "Real", "file_type": "code"},
678 {"id": "garbage", "label": "junk", "file_type": "rationale"},
679 ],
680 "edges": [],
681 "hyperedges": [],
682 });
683 sanitize_semantic_fragment(&mut fragment);
684 let ids: HashSet<&str> = fragment["nodes"]
685 .as_array()
686 .unwrap()
687 .iter()
688 .filter_map(|n| n["id"].as_str())
689 .collect();
690 assert!(ids.contains("real_node"));
691 assert!(!ids.contains("garbage"));
692 }
693
694 #[test]
695 fn test_sanitize_converts_sentence_rationale_node_to_attribute() {
696 let mut fragment = json!({
697 "nodes": [
698 {"id": "real_node", "label": "Real", "file_type": "code"},
699 {
700 "id": "why_node",
701 "label": "We chose tree-sitter because the deterministic parser is faster than regex-based extraction.",
702 "file_type": "rationale",
703 },
704 ],
705 "edges": [{"source": "why_node", "target": "real_node", "relation": "rationale_for"}],
706 "hyperedges": [],
707 });
708 sanitize_semantic_fragment(&mut fragment);
709 let ids: HashSet<&str> = fragment["nodes"]
710 .as_array()
711 .unwrap()
712 .iter()
713 .filter_map(|n| n["id"].as_str())
714 .collect();
715 assert!(!ids.contains("why_node"));
716 let target = fragment["nodes"]
717 .as_array()
718 .unwrap()
719 .iter()
720 .find(|n| n["id"].as_str() == Some("real_node"))
721 .unwrap();
722 assert!(target["rationale"]
723 .as_str()
724 .unwrap_or("")
725 .contains("tree-sitter"));
726 }
727
728 #[test]
729 fn test_sanitize_converts_allowed_filetype_sentence_via_rationale_for_edge() {
730 let long_label = "Decision: this node has sentence-like rationale text but uses an \
731 allowed file_type, so it should not survive as a standalone graph node.";
732 let mut fragment = json!({
733 "nodes": [
734 {"id": "real_node", "label": "Real", "file_type": "code"},
735 {"id": "sentence_node", "label": long_label, "file_type": "document"},
736 ],
737 "edges": [{"source": "sentence_node", "target": "real_node", "relation": "rationale_for"}],
738 "hyperedges": [],
739 });
740 sanitize_semantic_fragment(&mut fragment);
741 let ids: HashSet<&str> = fragment["nodes"]
742 .as_array()
743 .unwrap()
744 .iter()
745 .filter_map(|n| n["id"].as_str())
746 .collect();
747 assert!(!ids.contains("sentence_node"));
748 let target = fragment["nodes"]
749 .as_array()
750 .unwrap()
751 .iter()
752 .find(|n| n["id"].as_str() == Some("real_node"))
753 .unwrap();
754 assert!(target["rationale"]
755 .as_str()
756 .unwrap_or("")
757 .contains("Decision"));
758 }
759
760 #[test]
761 fn test_sanitize_keeps_short_concept_named_node_with_punctuation() {
762 let mut fragment = json!({
763 "nodes": [
764 {"id": "a_b", "label": "a.b.c", "file_type": "document"},
765 {"id": "anchor", "label": "Anchor", "file_type": "code"},
766 ],
767 "edges": [{"source": "a_b", "target": "anchor", "relation": "rationale_for"}],
768 "hyperedges": [],
769 });
770 sanitize_semantic_fragment(&mut fragment);
771 let ids: HashSet<&str> = fragment["nodes"]
772 .as_array()
773 .unwrap()
774 .iter()
775 .filter_map(|n| n["id"].as_str())
776 .collect();
777 assert!(ids.contains("a_b"));
778 assert!(ids.contains("anchor"));
779 }
780
781 #[test]
782 fn test_sanitize_filters_hyperedges_after_node_removal() {
783 let mut fragment = json!({
784 "nodes": [
785 {"id": "real_node", "label": "Real", "file_type": "code"},
786 {"id": "other", "label": "Other", "file_type": "code"},
787 {"id": "garbage", "label": "junk", "file_type": "rationale"},
788 ],
789 "edges": [],
790 "hyperedges": [
791 {"id": "group_a", "label": "Group A", "nodes": ["garbage", "real_node", "other"], "relation": "participate_in"},
792 {"id": "group_b", "label": "Group B (only one survivor)", "nodes": ["garbage", "real_node"], "relation": "participate_in"},
793 ],
794 });
795 sanitize_semantic_fragment(&mut fragment);
796 let he_ids: HashSet<&str> = fragment["hyperedges"]
797 .as_array()
798 .unwrap()
799 .iter()
800 .filter_map(|he| he["id"].as_str())
801 .collect();
802 assert!(he_ids.contains("group_a"));
803 assert!(!he_ids.contains("group_b"));
804 let group_a = fragment["hyperedges"]
805 .as_array()
806 .unwrap()
807 .iter()
808 .find(|he| he["id"].as_str() == Some("group_a"))
809 .unwrap();
810 let nodes: HashSet<&str> = group_a["nodes"]
811 .as_array()
812 .unwrap()
813 .iter()
814 .filter_map(|v| v.as_str())
815 .collect();
816 assert!(!nodes.contains("garbage"));
817 assert_eq!(nodes, ["real_node", "other"].iter().copied().collect());
818 }
819
820 #[test]
821 fn test_sanitize_drops_hyperedge_with_only_unknown_refs() {
822 let mut fragment = json!({
823 "nodes": [{"id": "real_node", "label": "Real", "file_type": "code"}],
824 "edges": [],
825 "hyperedges": [{"id": "phantom", "label": "Phantom", "nodes": ["ghost1", "ghost2"]}],
826 });
827 sanitize_semantic_fragment(&mut fragment);
828 assert_eq!(
829 fragment["hyperedges"].as_array().unwrap(),
830 &Vec::<Value>::new()
831 );
832 }
833
834 #[test]
835 fn test_sanitize_boundary_sentence_threshold() {
836 let long_label = "Note: alpha beta gamma delta epsilon zeta eta";
838 let mut fragment = json!({
839 "nodes": [
840 {"id": "anchor", "label": "Anchor", "file_type": "code"},
841 {"id": "n1", "label": long_label, "file_type": "rationale"},
842 ],
843 "edges": [{"source": "n1", "target": "anchor", "relation": "rationale_for"}],
844 "hyperedges": [],
845 });
846 sanitize_semantic_fragment(&mut fragment);
847 let ids: HashSet<&str> = fragment["nodes"]
848 .as_array()
849 .unwrap()
850 .iter()
851 .filter_map(|n| n["id"].as_str())
852 .collect();
853 assert_eq!(ids, ["anchor"].iter().copied().collect());
854 let anchor = fragment["nodes"]
855 .as_array()
856 .unwrap()
857 .iter()
858 .find(|n| n["id"].as_str() == Some("anchor"))
859 .unwrap();
860 assert!(anchor["rationale"].as_str().unwrap_or("").contains("alpha"));
861
862 let short_label = "alpha beta gamma delta epsilon zeta eta";
864 let mut fragment2 = json!({
865 "nodes": [
866 {"id": "anchor", "label": "Anchor", "file_type": "code"},
867 {"id": "n2", "label": short_label, "file_type": "rationale"},
868 ],
869 "edges": [],
870 "hyperedges": [],
871 });
872 sanitize_semantic_fragment(&mut fragment2);
873 let ids2: HashSet<&str> = fragment2["nodes"]
874 .as_array()
875 .unwrap()
876 .iter()
877 .filter_map(|n| n["id"].as_str())
878 .collect();
879 assert_eq!(ids2, ["anchor"].iter().copied().collect());
880 let anchor2 = fragment2["nodes"]
881 .as_array()
882 .unwrap()
883 .iter()
884 .find(|n| n["id"].as_str() == Some("anchor"))
885 .unwrap();
886 assert!(anchor2.get("rationale").is_none());
887 }
888
889 #[test]
890 fn test_sanitize_rationale_only_propagates_through_rationale_for_edges() {
891 let mut fragment = json!({
892 "nodes": [
893 {"id": "rationale_target", "label": "Rationale Target", "file_type": "code"},
894 {"id": "unrelated_target", "label": "Unrelated Target", "file_type": "code"},
895 {
896 "id": "why_node",
897 "label": "Decision: we chose tree-sitter because the deterministic parser is faster than regex-based extraction.",
898 "file_type": "rationale",
899 },
900 ],
901 "edges": [
902 {"source": "why_node", "target": "rationale_target", "relation": "rationale_for"},
903 {"source": "why_node", "target": "unrelated_target", "relation": "references"},
904 ],
905 "hyperedges": [],
906 });
907 sanitize_semantic_fragment(&mut fragment);
908 let id_map: HashMap<&str, &Value> = fragment["nodes"]
909 .as_array()
910 .unwrap()
911 .iter()
912 .filter_map(|n| n["id"].as_str().map(|id| (id, n)))
913 .collect();
914 assert!(!id_map.contains_key("why_node"));
915 assert!(id_map["rationale_target"]["rationale"]
916 .as_str()
917 .unwrap_or("")
918 .contains("tree-sitter"));
919 assert!(id_map["unrelated_target"].get("rationale").is_none());
920 }
921}