Skip to main content

codesynapse_core/
semantic_cleanup.rs

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        // 8 words + colon → sentence-like, gets removed and becomes rationale
837        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        // 7 words, no terminal punctuation → not sentence-like, but still removed (rationale ft) without attribute
863        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}