1use crate::components::parse_prov;
13use crate::RealityGraph;
14use scc_core::{Entity, Flow, Invariant, Provenance, Relationship};
15use scc_store::Store;
16use std::collections::{HashMap, HashSet};
17
18#[derive(Debug, Clone)]
21pub struct TrustPolicy {
22 pub allow_extracted: bool,
23 pub allow_resolved: bool,
24 pub allow_observed: bool,
25 pub allow_declared: bool,
26 pub allow_inferred: bool,
27 pub min_inferred_confidence: f64,
28}
29
30impl Default for TrustPolicy {
31 fn default() -> Self {
32 TrustPolicy {
33 allow_extracted: true,
34 allow_resolved: true,
35 allow_observed: true,
36 allow_declared: true,
37 allow_inferred: true,
38 min_inferred_confidence: 0.85,
39 }
40 }
41}
42
43impl TrustPolicy {
44 pub fn with_inferred_floor(mut self, floor: f64) -> Self {
48 self.min_inferred_confidence = floor;
49 self
50 }
51
52 pub fn allows(&self, prov: Provenance, confidence: f64) -> bool {
53 match prov {
54 Provenance::Stale => false,
55 Provenance::Extracted => self.allow_extracted,
56 Provenance::Resolved => self.allow_resolved,
57 Provenance::Observed => self.allow_observed,
58 Provenance::Declared => self.allow_declared,
59 Provenance::Inferred => {
60 self.allow_inferred && confidence >= self.min_inferred_confidence
61 }
62 }
63 }
64}
65pub struct TrustedGraphView<'a> {
69 pub graph: &'a RealityGraph,
70 stale_paths: HashSet<String>,
71 stale_evidence: HashSet<String>,
73 stale_entities: HashSet<String>,
75 policy: TrustPolicy,
76}
77
78impl<'a> TrustedGraphView<'a> {
79 pub fn new(
80 graph: &'a RealityGraph,
81 store: &'a Store,
82 stale_paths: &[String],
83 policy: TrustPolicy,
84 ) -> TrustedGraphView<'a> {
85 let stale_paths: HashSet<String> = stale_paths.iter().cloned().collect();
86 let evidence_paths: HashMap<String, String> = store
88 .all_evidence()
89 .ok()
90 .map(|evs| {
91 evs.into_iter()
92 .filter_map(|e| e.path.map(|p| (e.id, p)))
93 .collect()
94 })
95 .unwrap_or_default();
96
97 let mut stale_evidence: HashSet<String> = HashSet::new();
98 for (id, path) in &evidence_paths {
99 if stale_paths.contains(path) {
100 stale_evidence.insert(id.clone());
101 }
102 }
103
104 let mut stale_entities: HashSet<String> = HashSet::new();
105 for e in graph.entities.values() {
106 if e.evidence.iter().any(|ev| stale_evidence.contains(ev)) {
107 stale_entities.insert(e.id.clone());
108 }
109 }
110
111 TrustedGraphView {
112 graph,
113 stale_paths,
114 stale_evidence,
115 stale_entities,
116 policy,
117 }
118 }
119
120 pub fn policy(&self) -> &TrustPolicy {
121 &self.policy
122 }
123
124 pub fn is_stale_path(&self, path: &str) -> bool {
125 self.stale_paths.contains(path)
126 }
127
128 pub fn stale_paths(&self) -> Vec<String> {
129 let mut v: Vec<String> = self.stale_paths.iter().cloned().collect();
130 v.sort();
131 v
132 }
133
134 pub fn is_stale_evidence(&self, id: &str) -> bool {
135 self.stale_evidence.contains(id)
136 }
137
138 pub fn is_stale_entity(&self, id: &str) -> bool {
139 self.stale_entities.contains(id)
140 }
141
142 fn rel_allowed(&self, r: &Relationship) -> bool {
143 if r.evidence.iter().any(|ev| self.stale_evidence.contains(ev)) {
144 return false;
145 }
146 self.policy.allows(r.provenance, r.confidence)
147 }
148
149 pub fn entity(&self, id: &str) -> Option<&Entity> {
153 self.graph
154 .entities
155 .get(id)
156 .filter(|_| !self.is_stale_entity(id))
157 }
158
159 pub fn name_of(&self, id: &str) -> String {
163 self.graph
164 .entities
165 .get(id)
166 .map(|e| e.name.clone())
167 .unwrap_or_else(|| id.rsplit('/').next().unwrap_or(id).to_string())
168 }
169
170 pub fn entities(&self) -> impl Iterator<Item = &Entity> {
173 self.graph
174 .entities
175 .values()
176 .filter(|e| !self.is_stale_entity(&e.id))
177 }
178
179 pub fn entities_of_kind(&self, kind: &str) -> Vec<&Entity> {
181 let mut v: Vec<&Entity> = self
182 .graph
183 .entities
184 .values()
185 .filter(|e| e.kind == kind && !self.is_stale_entity(&e.id))
186 .collect();
187 v.sort_by(|a, b| a.name.cmp(&b.name));
188 v
189 }
190
191 pub fn out_edges(&self, id: &str) -> Vec<&Relationship> {
194 self.graph
195 .out
196 .get(id)
197 .map(|v| v.iter().filter(|r| self.rel_allowed(r)).collect())
198 .unwrap_or_default()
199 }
200
201 pub fn in_edges(&self, id: &str) -> Vec<&Relationship> {
202 self.graph
203 .inn
204 .get(id)
205 .map(|v| v.iter().filter(|r| self.rel_allowed(r)).collect())
206 .unwrap_or_default()
207 }
208
209 pub fn out_pred(&self, id: &str, predicate: &str) -> Vec<&Relationship> {
210 self.graph
211 .out
212 .get(id)
213 .map(|v| {
214 v.iter()
215 .filter(|r| r.predicate == predicate && self.rel_allowed(r))
216 .collect()
217 })
218 .unwrap_or_default()
219 }
220
221 pub fn in_pred(&self, id: &str, predicate: &str) -> Vec<&Relationship> {
222 self.graph
223 .inn
224 .get(id)
225 .map(|v| {
226 v.iter()
227 .filter(|r| r.predicate == predicate && self.rel_allowed(r))
228 .collect()
229 })
230 .unwrap_or_default()
231 }
232
233 pub fn all_rels(&self) -> Vec<&Relationship> {
235 let mut v: Vec<&Relationship> = self
236 .graph
237 .out
238 .values()
239 .flatten()
240 .filter(|r| self.rel_allowed(r))
241 .collect();
242 v.sort_by(|a, b| a.id.cmp(&b.id));
243 v
244 }
245
246 fn claim_allowed(&self, prov: Option<Provenance>) -> bool {
253 match prov {
254 None => true,
255 Some(p) => self.policy.allows(p, p.default_confidence()),
256 }
257 }
258
259 pub fn flows(&self) -> Vec<Flow> {
265 let mut out: Vec<Flow> = Vec::new();
266 for f in &self.graph.flows {
267 let stale = f
268 .steps
269 .iter()
270 .any(|s| s.evidence.iter().any(|ev| self.stale_evidence.contains(ev)));
271 if stale {
272 continue;
273 }
274 let mut steps: Vec<scc_core::FlowStep> = Vec::new();
275 for s in &f.steps {
276 if !self.claim_allowed(s.provenance) {
277 continue;
278 }
279 steps.push(s.clone());
280 }
281 if steps.is_empty() {
282 continue;
283 }
284 let mut nf = f.clone();
285 nf.steps = steps;
286 out.push(nf);
287 }
288 out
289 }
290
291 pub fn invariants(&self) -> Vec<Invariant> {
293 let mut out: Vec<Invariant> = Vec::new();
294 for i in &self.graph.invariants {
295 let stale = i.evidence.iter().any(|ev| self.stale_evidence.contains(ev));
296 if stale {
297 continue;
298 }
299 if !self.claim_allowed(i.provenance) {
300 continue;
301 }
302 out.push(i.clone());
303 }
304 out
305 }
306
307 pub fn components(&self) -> Vec<Entity> {
312 let mut out: Vec<Entity> = Vec::new();
313 for c in &self.graph.components {
314 let stale = c
315 .evidence
316 .iter()
317 .any(|ev| self.stale_evidence.contains(ev));
318 if stale {
319 continue;
320 }
321 let mut nc = c.clone();
322 let attr_claims = |v: &serde_json::Value, prov_key: &str| -> Option<serde_json::Value> {
323 let arr = v.as_array()?;
324 let kept: Vec<&serde_json::Value> = arr
325 .iter()
326 .filter(|claim| {
327 let prov = claim
328 .get(prov_key)
329 .and_then(|p| p.as_str())
330 .map(parse_prov);
331 let conf = claim
332 .get("confidence")
333 .and_then(|c| c.as_f64())
334 .unwrap_or(1.0);
335 match prov {
336 None => true,
337 Some(p) => self.policy.allows(p, conf),
338 }
339 })
340 .collect();
341 if kept.len() == arr.len() {
342 None } else {
344 Some(serde_json::Value::Array(kept.into_iter().cloned().collect()))
345 }
346 };
347 for key in ["responsibility", "owns", "depends_on"] {
348 if let Some(v) = nc.attributes.get(key).cloned() {
349 if let Some(filtered) = attr_claims(&v, "provenance") {
350 nc.attributes.insert(key.to_string(), filtered);
351 }
352 }
353 }
354 out.push(nc);
355 }
356 out
357 }
358
359 pub fn stale_warnings(&self) -> Vec<String> {
363 let mut v: Vec<String> = Vec::new();
364 let mut paths: Vec<&String> = self.stale_paths.iter().collect();
365 paths.sort();
366 for p in paths {
367 v.push(format!("{p} changed since indexing — its facts are excluded (STALE)"));
368 }
369 v
370 }
371}
372
373#[cfg(test)]
374mod tests {
375 use super::*;
376 use scc_core::{Evidence, EvidenceType};
377 use scc_store::Store;
378
379 fn setup() -> (Store, tempfile::TempDir) {
380 let dir = tempfile::TempDir::new().unwrap();
381 let root = dir.path().join("repo");
382 std::fs::create_dir_all(&root).unwrap();
383 let store = Store::open(&dir.path().join("scc.db"), &root).unwrap();
384 (store, dir)
385 }
386
387 fn entity(id: &str, kind: &str, evidence: Vec<&str>) -> scc_core::Entity {
388 let evidence: Vec<String> = evidence.into_iter().map(|s| s.to_string()).collect();
389 let _ = kind;
390 let mut e = scc_core::Entity::new(id, "component", id.rsplit('/').next().unwrap_or(id));
391 e.evidence = evidence;
392 e
393 }
394
395 fn view<'a>(
396 store: &'a Store,
397 graph: &'a RealityGraph,
398 stale: &[String],
399 ) -> TrustedGraphView<'a> {
400 TrustedGraphView::new(graph, store, stale, TrustPolicy::default())
401 }
402
403 #[test]
404 fn stale_facts_are_excluded_and_warned() {
405 let (store, _d) = setup();
406 let ev = Evidence {
407 id: "evidence:1".into(),
408 r#type: EvidenceType::Source,
409 path: Some("main.py".into()),
410 symbol: None,
411 start_line: None,
412 end_line: None,
413 revision: None,
414 content_hash: None,
415 extractor: Some("test".into()),
416 extractor_version: None,
417 };
418 store.insert_evidence(&ev).unwrap();
419
420 let sym = entity("repo://r/symbol/a", "symbol", vec![]);
422 let rel = scc_core::Relationship::new(
423 "rel:1",
424 sym.id.clone(),
425 scc_core::predicates::CALLS,
426 "repo://r/symbol/b",
427 Provenance::Extracted,
428 )
429 .with_evidence(vec!["evidence:1".to_string()]);
430 let mut graph = RealityGraph {
431 repo_id: "r".into(),
432 entities: [(sym.id.clone(), sym.clone())].into_iter().collect(),
433 out: [(sym.id.clone(), vec![rel.clone()])].into_iter().collect(),
434 inn: [(rel.object.clone(), vec![rel])].into_iter().collect(),
435 components: vec![],
436 flows: vec![],
437 invariants: vec![],
438 };
439 graph.components = vec![entity(
440 "repo://r/component/c",
441 "component",
442 vec!["evidence:1"],
443 )];
444
445 let v = view(&store, &graph, &[]);
447 assert_eq!(v.out_edges(&sym.id).len(), 1);
448 assert!(v.entity(&sym.id).is_some());
449 assert_eq!(v.components().len(), 1);
450 assert!(v.stale_warnings().is_empty());
451
452 let v = view(&store, &graph, &["main.py".to_string()]);
456 assert!(v.out_edges(&sym.id).is_empty());
457 assert!(v.entity(&sym.id).is_some());
458 assert!(v.components().is_empty());
459 let warns = v.stale_warnings();
460 assert_eq!(warns.len(), 1);
461 assert!(warns[0].contains("main.py"));
462 }
463
464 #[test]
465 fn policy_filters_derived_claims_not_just_relationships() {
466 let (store, _d) = setup();
470 let mut flow = Flow {
471 id: "repo://r/flow/f1".into(),
472 kind: scc_core::FlowKind::Lifecycle,
473 name: "svc-lifecycle".into(),
474 trigger: None,
475 steps: vec![
476 scc_core::FlowStep {
477 id: "step:1".into(),
478 order: 1,
479 actor: "repo://r/component/svc".into(),
480 operation: "svc".into(),
481 condition: None,
482 r#async: None,
483 timeout_ms: None,
484 retry_policy: None,
485 failure_outcome: None,
486 provenance: Some(Provenance::Inferred),
487 evidence: vec![],
488 },
489 scc_core::FlowStep {
490 id: "step:2".into(),
491 order: 2,
492 actor: "repo://r/component/svc".into(),
493 operation: "advance".into(),
494 condition: None,
495 r#async: None,
496 timeout_ms: None,
497 retry_policy: None,
498 failure_outcome: None,
499 provenance: Some(Provenance::Inferred),
500 evidence: vec![],
501 },
502 ],
503 attributes: Default::default(),
504 };
505 let _ = &mut flow;
506
507 let mut comp = entity("repo://r/component/svc", "component", vec![]);
509 comp.attributes.insert(
510 "responsibility".into(),
511 serde_json::json!([
512 {"text": "Handles GET /api/x", "provenance": "RESOLVED", "confidence": 1.0},
513 {"text": "Hosts the svc code module", "provenance": "INFERRED", "confidence": 0.5}
514 ]),
515 );
516 let graph = RealityGraph {
517 repo_id: "r".into(),
518 entities: Default::default(),
519 out: Default::default(),
520 inn: Default::default(),
521 components: vec![comp.clone()],
522 flows: vec![flow.clone()],
523 invariants: vec![],
524 };
525
526 let v = view(&store, &graph, &[]);
528 let flows = v.flows();
529 assert!(flows.is_empty(), "inferred lifecycle must not survive: {flows:?}");
530 let comps = v.components();
531 assert_eq!(comps.len(), 1);
532 let resp = comps[0].attributes["responsibility"].as_array().unwrap();
533 assert_eq!(resp.len(), 1, "low-confidence inferred claim filtered: {resp:?}");
534 assert_eq!(resp[0]["provenance"], "RESOLVED");
535
536 let v = TrustedGraphView::new(
538 &graph,
539 &store,
540 &[],
541 TrustPolicy::default().with_inferred_floor(0.0),
542 );
543 assert_eq!(v.flows().len(), 1, "floor 0 keeps the inferred lifecycle");
544 assert_eq!(
545 v.components()[0].attributes["responsibility"].as_array().unwrap().len(),
546 2
547 );
548 }
549
550 #[test]
551 fn policy_gates_inferred_confidence() {
552 let (store, _d) = setup();
553 let sym = entity("repo://r/symbol/a", "symbol", vec![]);
554 let low = scc_core::Relationship::new(
555 "rel:low",
556 sym.id.clone(),
557 scc_core::predicates::CALLS,
558 "repo://r/symbol/b",
559 Provenance::Inferred,
560 )
561 .with_confidence(0.5);
562 let high = scc_core::Relationship::new(
563 "rel:high",
564 sym.id.clone(),
565 scc_core::predicates::CALLS,
566 "repo://r/symbol/c",
567 Provenance::Inferred,
568 )
569 .with_confidence(0.9);
570 let graph = RealityGraph {
571 repo_id: "r".into(),
572 entities: [(sym.id.clone(), sym.clone())].into_iter().collect(),
573 out: [(sym.id.clone(), vec![low.clone(), high.clone()])].into_iter().collect(),
574 inn: [(low.object.clone(), vec![low]), (high.object.clone(), vec![high])]
575 .into_iter()
576 .collect(),
577 components: vec![],
578 flows: vec![],
579 invariants: vec![],
580 };
581
582 let v = view(&store, &graph, &[]);
584 let edges = v.out_edges(&sym.id);
585 assert_eq!(edges.len(), 1);
586 assert_eq!(edges[0].id, "rel:high");
587
588 let v = TrustedGraphView::new(
590 &graph,
591 &store,
592 &[],
593 TrustPolicy::default().with_inferred_floor(0.0),
594 );
595 assert_eq!(v.out_edges(&sym.id).len(), 2);
596 }
597}