1use std::collections::{BTreeMap, BTreeSet};
24use std::path::Path;
25
26use chrono::{DateTime, Utc};
27use serde::Serialize;
28
29use lean_ctx::core::knowledge::anchors::{self, AnchorState, GuardedResolver, MAX_ANCHORS};
30use lean_ctx::core::knowledge::{
31 FactOrigin, FactReviewReason, FactReviewState, JudgedPair, KnowledgeFact, ProjectKnowledge,
32 fact_version_id_v1, string_similarity,
33};
34
35use super::fact_ids::{current_ids, rfc3339 as ts, version_id};
36
37pub const CONTRACT: &str = "local-read/1";
38pub const DEFAULT_LIMIT: usize = 200;
39pub const MAX_LIMIT: usize = 1000;
40pub const DEFAULT_DEDUPE_THRESHOLD: f32 = 0.9;
41pub const MIN_DEDUPE_THRESHOLD: f32 = 0.5;
42pub const DEFAULT_OUTDATED_DAYS: u32 = 90;
43pub const MAX_OUTDATED_DAYS: u32 = 36_500;
45
46#[derive(Debug, Clone, PartialEq, Eq)]
48pub struct FreshnessError {
49 pub code: &'static str,
50 pub message: String,
51}
52
53impl FreshnessError {
54 fn new(code: &'static str, message: impl Into<String>) -> Self {
55 Self {
56 code,
57 message: message.into(),
58 }
59 }
60}
61
62fn pair_name(fact: &KnowledgeFact) -> String {
64 format!("{}/{}", fact.category, fact.key)
65}
66
67pub fn ids_of(facts: &[KnowledgeFact]) -> Vec<String> {
70 let current: Vec<usize> = (0..facts.len())
71 .filter(|&i| facts[i].is_current())
72 .collect();
73 let refs: Vec<&KnowledgeFact> = current.iter().map(|&i| &facts[i]).collect();
74 let mut ids: Vec<String> = facts.iter().map(version_id).collect();
75 for (i, id) in current.into_iter().zip(current_ids(&refs)) {
76 ids[i] = id;
77 }
78 ids
79}
80
81#[derive(Debug, Clone, Serialize)]
82pub struct AnchorRef {
83 pub path: String,
84 pub symbol: Option<String>,
85}
86
87#[derive(Debug, Clone, Serialize)]
89pub struct FactRef {
90 pub id: String,
91 pub category: String,
92 pub key: String,
93 pub text: String,
94 pub confidence: f32,
95 pub anchors: Vec<AnchorRef>,
96 pub review: Option<&'static str>,
97 pub last_confirmed: String,
98 pub updated: String,
99}
100
101#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
102pub struct Reason {
103 pub code: &'static str,
104 pub path: Option<String>,
105 pub symbol: Option<String>,
106 pub moved_to: Option<String>,
107}
108
109struct Assessment {
111 review: Option<&'static str>,
112 reasons: Vec<Reason>,
113}
114
115fn reason_code(reason: FactReviewReason) -> &'static str {
116 match reason {
117 FactReviewReason::AnchorChanged => "anchor_changed",
118 FactReviewReason::AnchorMoved => "anchor_moved",
119 FactReviewReason::AnchorMissing => "anchor_missing",
120 FactReviewReason::Contradiction => "contradiction",
121 FactReviewReason::SourceRemoved => "source_removed",
122 }
123}
124
125const fn is_anchor_reason(reason: FactReviewReason) -> bool {
126 matches!(
127 reason,
128 FactReviewReason::AnchorChanged
129 | FactReviewReason::AnchorMoved
130 | FactReviewReason::AnchorMissing
131 )
132}
133
134fn contradicted(fact: &KnowledgeFact) -> bool {
135 fact.review
136 .as_ref()
137 .is_some_and(|r| r.state == FactReviewState::Contradicted)
138}
139
140fn live_anchor_reason(
143 fact: &KnowledgeFact,
144 guard: &mut GuardedResolver<'_>,
145) -> Option<FactReviewReason> {
146 let reasons: Vec<FactReviewReason> = fact
147 .anchors
148 .iter()
149 .filter_map(|a| guard.resolve(a))
150 .filter_map(|state| FactReviewReason::of_anchor(&state))
151 .collect();
152 [
153 FactReviewReason::AnchorMissing,
154 FactReviewReason::AnchorMoved,
155 FactReviewReason::AnchorChanged,
156 ]
157 .into_iter()
158 .find(|r| reasons.contains(r))
159}
160
161fn reconcile(fact: &mut KnowledgeFact, live: Option<FactReviewReason>, now: DateTime<Utc>) -> bool {
167 if fact
168 .review
169 .as_ref()
170 .is_some_and(|r| !is_anchor_reason(r.reason))
171 {
172 return false;
173 }
174 let before = fact.review.clone();
175 match live {
176 Some(reason) => fact.mark_review(FactReviewState::ToVerify, reason, now),
177 None => fact.clear_review(),
178 }
179 fact.review != before
180}
181
182fn reconcile_store(
186 project_root: &str,
187 wanted: impl Fn(&KnowledgeFact) -> bool,
188) -> Result<(ProjectKnowledge, usize), String> {
189 let root = Path::new(project_root);
190 let selected = |f: &KnowledgeFact| f.is_current() && !f.anchors.is_empty() && wanted(f);
191 let knowledge = load(project_root);
192 let mut guard = GuardedResolver::local(root);
193 let now = Utc::now();
194 let differs = knowledge.facts.iter().filter(|f| selected(f)).any(|f| {
195 let mut probe = f.clone();
196 reconcile(&mut probe, live_anchor_reason(f, &mut guard), now)
197 });
198 if !differs {
199 return Ok((knowledge, 0));
200 }
201 let (written, changed) = ProjectKnowledge::mutate_locked(project_root, |knowledge| {
202 let mut guard = GuardedResolver::local(root);
203 let mut changed = 0;
204 for fact in knowledge.facts.iter_mut().filter(|f| selected(f)) {
205 let live = live_anchor_reason(fact, &mut guard);
206 changed += usize::from(reconcile(fact, live, now));
207 }
208 changed
209 })?;
210 Ok((written, changed))
211}
212
213fn assess(fact: &KnowledgeFact, guard: &mut GuardedResolver<'_>) -> Assessment {
214 let mut reasons = Vec::new();
215 let (mut gone, mut to_verify) = (false, false);
216 for anchor in &fact.anchors {
217 let state = guard.resolve(anchor);
218 let Some(reason) = state.as_ref().and_then(FactReviewReason::of_anchor) else {
219 continue;
220 };
221 match reason {
222 FactReviewReason::AnchorMissing => gone = true,
223 _ => to_verify = true,
224 }
225 reasons.push(Reason {
226 code: reason_code(reason),
227 path: Some(anchor.path.clone()),
228 symbol: anchor.symbol.clone(),
229 moved_to: match state {
230 Some(AnchorState::Moved { path }) => Some(path),
231 _ => None,
232 },
233 });
234 }
235 if let Some(review) = &fact.review
236 && !is_anchor_reason(review.reason)
237 {
238 if review.reason == FactReviewReason::SourceRemoved {
239 to_verify = true;
240 }
241 reasons.push(Reason {
242 code: reason_code(review.reason),
243 path: None,
244 symbol: None,
245 moved_to: None,
246 });
247 }
248 let review = if gone {
249 Some("gone")
250 } else if to_verify {
251 Some("to_verify")
252 } else if contradicted(fact) {
253 Some("contradicted")
254 } else {
255 None
256 };
257 Assessment { review, reasons }
258}
259
260fn fact_ref(fact: &KnowledgeFact, id: &str, review: Option<&'static str>) -> FactRef {
261 FactRef {
262 id: id.to_owned(),
263 category: fact.category.clone(),
264 key: fact.key.clone(),
265 text: fact.value.clone(),
266 confidence: fact.confidence,
267 anchors: fact
268 .anchors
269 .iter()
270 .map(|a| AnchorRef {
271 path: a.path.clone(),
272 symbol: a.symbol.clone(),
273 })
274 .collect(),
275 review,
276 last_confirmed: ts(fact.last_confirmed),
277 updated: ts(fact.created_at.max(fact.last_confirmed)),
278 }
279}
280
281fn assessed_ref(fact: &KnowledgeFact, id: &str, guard: &mut GuardedResolver<'_>) -> FactRef {
282 let review = assess(fact, guard).review;
283 fact_ref(fact, id, review)
284}
285
286fn load(project_root: &str) -> ProjectKnowledge {
287 ProjectKnowledge::load(project_root).unwrap_or_else(|| ProjectKnowledge::new(project_root))
288}
289
290fn clamp_limit(limit: Option<usize>) -> usize {
291 limit.unwrap_or(DEFAULT_LIMIT).clamp(1, MAX_LIMIT)
292}
293
294#[derive(Debug, Serialize)]
297pub struct StaleFact {
298 pub fact: FactRef,
299 pub since: Option<String>,
300 pub reasons: Vec<Reason>,
301}
302
303#[derive(Debug, Serialize)]
304pub struct StaleReport {
305 pub contract: &'static str,
306 pub kind: &'static str,
307 pub project_root: String,
308 pub total: usize,
309 pub returned: usize,
310 pub truncated: bool,
311 pub facts: Vec<StaleFact>,
312}
313
314pub fn stale(project_root: &str, limit: Option<usize>) -> StaleReport {
323 let knowledge = reconcile_store(project_root, |_| true)
324 .map_or_else(|_| load(project_root), |(knowledge, _)| knowledge);
325 let ids = ids_of(&knowledge.facts);
326 let mut guard = GuardedResolver::local(Path::new(project_root));
327 let mut found: Vec<(u8, StaleFact)> = knowledge
328 .facts
329 .iter()
330 .zip(&ids)
331 .filter(|(f, _)| f.is_current())
332 .filter_map(|(fact, id)| {
333 let assessment = assess(fact, &mut guard);
334 let review = assessment.review?;
335 let rank = match review {
336 "gone" => 0,
337 "to_verify" => 1,
338 _ => 2,
339 };
340 Some((
341 rank,
342 StaleFact {
343 since: fact.review.as_ref().map(|r| ts(r.since)),
344 fact: fact_ref(fact, id, Some(review)),
345 reasons: assessment.reasons,
346 },
347 ))
348 })
349 .collect();
350 found.sort_by(|a, b| a.0.cmp(&b.0).then_with(|| a.1.fact.id.cmp(&b.1.fact.id)));
351 let total = found.len();
352 found.truncate(clamp_limit(limit));
353 StaleReport {
354 contract: CONTRACT,
355 kind: "knowledge.stale",
356 project_root: project_root.to_owned(),
357 total,
358 returned: found.len(),
359 truncated: found.len() < total,
360 facts: found.into_iter().map(|(_, f)| f).collect(),
361 }
362}
363
364pub fn mark_edited(project_root: &str, path: &str) -> usize {
370 let root = Path::new(project_root);
371 let Some(relative) = anchors::anchor_path(root, path) else {
372 return 0;
373 };
374 reconcile_store(project_root, |fact| {
375 fact.anchors.iter().any(|a| a.path == relative)
376 })
377 .map_or(0, |(_, changed)| changed)
378}
379
380#[derive(Debug, Serialize)]
383pub struct Reanchored {
384 pub changed: usize,
385 pub moved: usize,
386 pub unchanged: usize,
387}
388
389#[derive(Debug, Serialize)]
390pub struct VerifyReport {
391 pub contract: &'static str,
392 pub kind: &'static str,
393 pub project_root: String,
394 pub fact: FactRef,
395 pub reanchored: Reanchored,
396 pub cleared: Option<&'static str>,
397}
398
399fn find_current(facts: &[KnowledgeFact], id: &str) -> Result<usize, FreshnessError> {
400 ids_of(facts)
401 .iter()
402 .position(|candidate| candidate == id)
403 .filter(|&i| facts[i].is_current())
404 .ok_or_else(|| FreshnessError::new("not_found", format!("no current fact {id}")))
405}
406
407fn reanchor(root: &Path, fact: &mut KnowledgeFact) -> Result<Reanchored, FreshnessError> {
410 let mut guard = GuardedResolver::local(root);
411 let mut counts = Reanchored {
412 changed: 0,
413 moved: 0,
414 unchanged: 0,
415 };
416 let mut renewed = Vec::with_capacity(fact.anchors.len());
417 for anchor in &fact.anchors {
418 let named = match &anchor.symbol {
419 Some(symbol) => format!("{}::{symbol}", anchor.path),
420 None => anchor.path.clone(),
421 };
422 let gone = || {
423 FreshnessError::new(
424 "anchor_gone",
425 format!("{named} is gone: rewrite or remove the fact instead"),
426 )
427 };
428 let capture =
429 |path: &str| anchors::capture(root, path, anchor.symbol.as_deref()).map_err(|_| gone());
430 let current = match guard.resolve(anchor) {
431 Some(AnchorState::Current) => {
432 counts.unchanged += 1;
433 anchor.clone()
434 }
435 Some(AnchorState::Changed) => {
436 counts.changed += 1;
437 capture(&anchor.path)?
438 }
439 Some(AnchorState::Moved { path }) => {
440 counts.moved += 1;
441 capture(&path)?
442 }
443 Some(AnchorState::Missing) | None => return Err(gone()),
444 };
445 renewed.push(current);
446 }
447 fact.anchors = anchors::canonical(renewed);
448 Ok(counts)
449}
450
451pub fn verify(project_root: &str, id: &str) -> Result<VerifyReport, FreshnessError> {
453 let root = Path::new(project_root);
454 let outcome = ProjectKnowledge::mutate_locked(project_root, |knowledge| {
455 let index = find_current(&knowledge.facts, id)?;
456 let fact = &mut knowledge.facts[index];
457 let reanchored = reanchor(root, fact)?;
458 let cleared = fact
459 .review
460 .as_ref()
461 .filter(|r| r.state == FactReviewState::ToVerify)
462 .map(|r| reason_code(r.reason));
463 if cleared.is_some() {
464 fact.clear_review();
465 }
466 fact.last_confirmed = Utc::now();
467 fact.confirmation_count = fact.confirmation_count.saturating_add(1);
468 Ok((fact.clone(), reanchored, cleared))
469 });
470 let (fact, reanchored, cleared) = match outcome {
471 Ok((_, result)) => result?,
472 Err(error) => return Err(FreshnessError::new("unavailable", error)),
473 };
474 let mut guard = GuardedResolver::local(root);
475 Ok(VerifyReport {
476 contract: CONTRACT,
477 kind: "knowledge.verify",
478 project_root: project_root.to_owned(),
479 fact: assessed_ref(&fact, id, &mut guard),
480 reanchored,
481 cleared,
482 })
483}
484
485#[derive(Debug, Serialize)]
488pub struct DedupeMember {
489 pub fact: FactRef,
490 #[serde(rename = "match")]
491 pub kind: &'static str,
492 pub similarity: f64,
493}
494
495#[derive(Debug, Serialize)]
496pub struct DedupeGroup {
497 pub category: String,
498 pub keep: FactRef,
499 pub merge: Vec<DedupeMember>,
500}
501
502#[derive(Debug, Serialize)]
503pub struct DedupeReport {
504 pub contract: &'static str,
505 pub kind: &'static str,
506 pub project_root: String,
507 pub applied: bool,
508 pub threshold: f64,
509 pub groups: Vec<DedupeGroup>,
510 pub merged: usize,
511}
512
513struct GroupPlan {
514 keep: usize,
515 merge: Vec<(usize, &'static str, f32)>,
516}
517
518fn normalized(text: &str) -> String {
519 text.split_whitespace()
520 .collect::<Vec<_>>()
521 .join(" ")
522 .to_lowercase()
523}
524
525fn round2(value: f32) -> f64 {
526 (f64::from(value) * 100.0).round() / 100.0
527}
528
529const POLARITY_WORDS: &[&str] = &[
533 "not",
534 "no",
535 "never",
536 "always",
537 "none",
538 "nothing",
539 "nobody",
540 "neither",
541 "nor",
542 "cannot",
543 "can't",
544 "don't",
545 "doesn't",
546 "isn't",
547 "aren't",
548 "wasn't",
549 "won't",
550 "shouldn't",
551 "mustn't",
552 "without",
553 "only",
554 "all",
555 "every",
556 "any",
557 "must",
558 "may",
559 "should",
560 "true",
561 "false",
562 "yes",
563 "on",
564 "off",
565 "enable",
566 "enabled",
567 "disable",
568 "disabled",
569 "allow",
570 "allowed",
571 "deny",
572 "denied",
573 "forbid",
574 "forbidden",
575 "required",
576 "optional",
577];
578
579fn differ_in_meaning(a: &str, b: &str) -> bool {
582 let words = |text: &str| -> BTreeSet<String> {
583 text.replace('\u{2019}', "'")
584 .split(|c: char| !(c.is_alphanumeric() || c == '\''))
585 .filter(|w| !w.is_empty())
586 .map(str::to_owned)
587 .collect()
588 };
589 let (left, right) = (words(a), words(b));
590 left.symmetric_difference(&right)
591 .any(|w| POLARITY_WORDS.contains(&w.as_str()) || w.chars().any(|c| c.is_ascii_digit()))
592}
593
594fn judged_apart(pairs: &[JudgedPair]) -> BTreeSet<(String, String)> {
597 pairs
598 .iter()
599 .filter(|p| matches!(p.verdict.as_str(), "contradicts" | "unrelated"))
600 .flat_map(|p| {
601 [
602 (p.key_a.clone(), p.key_b.clone()),
603 (p.key_b.clone(), p.key_a.clone()),
604 ]
605 })
606 .collect()
607}
608
609fn plan_dedupe(
612 facts: &[KnowledgeFact],
613 ids: &[String],
614 pairs: &[JudgedPair],
615 threshold: f32,
616) -> Vec<GroupPlan> {
617 let apart = judged_apart(pairs);
618 let mut by_category: BTreeMap<&str, Vec<usize>> = BTreeMap::new();
619 for (i, fact) in facts.iter().enumerate() {
620 let own = matches!(fact.origin, FactOrigin::Local | FactOrigin::Unverified);
623 if fact.is_current() && own && !contradicted(fact) {
624 by_category
625 .entry(fact.category.as_str())
626 .or_default()
627 .push(i);
628 }
629 }
630 let mut plans = Vec::new();
631 for mut members in by_category.into_values() {
632 members.sort_by(|&a, &b| {
633 let (fa, fb) = (&facts[a], &facts[b]);
634 fb.confirmation_count
635 .cmp(&fa.confirmation_count)
636 .then_with(|| fb.value.len().cmp(&fa.value.len()))
637 .then_with(|| fa.created_at.cmp(&fb.created_at))
638 .then_with(|| ids[a].cmp(&ids[b]))
639 });
640 let texts: Vec<String> = members
641 .iter()
642 .map(|&i| normalized(&facts[i].value))
643 .collect();
644 let names: Vec<String> = members.iter().map(|&i| pair_name(&facts[i])).collect();
645 let mut taken = vec![false; members.len()];
646 for a in 0..members.len() {
647 if taken[a] {
648 continue;
649 }
650 let mut merge = Vec::new();
651 for b in a + 1..members.len() {
652 if taken[b] || apart.contains(&(names[a].clone(), names[b].clone())) {
653 continue;
654 }
655 let found = if texts[a] == texts[b] {
656 Some(("exact", 1.0))
657 } else if differ_in_meaning(&texts[a], &texts[b]) {
658 None
659 } else {
660 let similarity = string_similarity(&texts[a], &texts[b]);
661 (similarity >= threshold).then_some(("near", similarity))
662 };
663 if let Some((kind, similarity)) = found {
664 taken[b] = true;
665 merge.push((members[b], kind, similarity));
666 }
667 }
668 if !merge.is_empty() {
669 merge.sort_by(|x, y| ids[x.0].cmp(&ids[y.0]));
670 plans.push(GroupPlan {
671 keep: members[a],
672 merge,
673 });
674 }
675 }
676 }
677 plans.sort_by(|x, y| ids[x.keep].cmp(&ids[y.keep]));
678 plans
679}
680
681fn apply_dedupe(knowledge: &mut ProjectKnowledge, plans: &[GroupPlan]) -> usize {
686 let now = Utc::now();
687 let mut merged = 0;
688 for plan in plans {
689 let keep_name = pair_name(&knowledge.facts[plan.keep]);
690 let mut anchor_union = knowledge.facts[plan.keep].anchors.clone();
691 let mut confirmations = 0u32;
692 let mut last_confirmed = knowledge.facts[plan.keep].last_confirmed;
693 for &(index, _, _) in &plan.merge {
694 let fact = &mut knowledge.facts[index];
695 fact.valid_until = Some(now);
696 fact.valid_from = fact.valid_from.or(Some(fact.created_at));
697 anchor_union.extend(fact.anchors.iter().cloned());
698 confirmations = confirmations.saturating_add(fact.confirmation_count.max(1));
699 last_confirmed = last_confirmed.max(fact.last_confirmed);
700 let merged_name = pair_name(fact);
701 knowledge.judged_pairs.push(JudgedPair {
702 key_a: keep_name.clone(),
703 key_b: merged_name,
704 verdict: "supersedes".to_owned(),
705 judged_at: now,
706 });
707 merged += 1;
708 }
709 let keep = &mut knowledge.facts[plan.keep];
710 let mut anchors = anchors::canonical(anchor_union);
711 anchors.truncate(MAX_ANCHORS);
712 keep.anchors = anchors;
713 keep.confirmation_count = keep.confirmation_count.saturating_add(confirmations);
714 keep.last_confirmed = last_confirmed;
715 }
716 if merged > 0 {
717 knowledge.rebuild_index();
718 }
719 merged
720}
721
722fn dedupe_groups(
723 facts: &[KnowledgeFact],
724 ids: &[String],
725 plans: &[GroupPlan],
726 guard: &mut GuardedResolver<'_>,
727) -> Vec<DedupeGroup> {
728 plans
729 .iter()
730 .map(|plan| DedupeGroup {
731 category: facts[plan.keep].category.clone(),
732 keep: assessed_ref(&facts[plan.keep], &ids[plan.keep], guard),
733 merge: plan
734 .merge
735 .iter()
736 .map(|&(index, kind, similarity)| DedupeMember {
737 fact: assessed_ref(&facts[index], &ids[index], guard),
738 kind,
739 similarity: round2(similarity),
740 })
741 .collect(),
742 })
743 .collect()
744}
745
746pub fn dedupe(
749 project_root: &str,
750 threshold: Option<f32>,
751 apply: bool,
752) -> Result<DedupeReport, FreshnessError> {
753 let threshold = threshold.unwrap_or(DEFAULT_DEDUPE_THRESHOLD);
754 if !(MIN_DEDUPE_THRESHOLD..=1.0).contains(&threshold) {
755 return Err(FreshnessError::new(
756 "invalid_argument",
757 format!("threshold must be in [{MIN_DEDUPE_THRESHOLD}, 1.0]"),
758 ));
759 }
760 let root = Path::new(project_root);
761 let (groups, merged) = if apply {
762 let outcome = ProjectKnowledge::mutate_locked(project_root, |knowledge| {
763 let ids = ids_of(&knowledge.facts);
764 let plans = plan_dedupe(&knowledge.facts, &ids, &knowledge.judged_pairs, threshold);
765 let before = knowledge.facts.clone();
766 let merged = apply_dedupe(knowledge, &plans);
767 let mut guard = GuardedResolver::local(root);
768 let mut groups = dedupe_groups(&before, &ids, &plans, &mut guard);
769 for (group, plan) in groups.iter_mut().zip(&plans) {
771 group.keep = assessed_ref(&knowledge.facts[plan.keep], &ids[plan.keep], &mut guard);
772 }
773 (groups, merged)
774 });
775 match outcome {
776 Ok((_, result)) => result,
777 Err(error) => return Err(FreshnessError::new("unavailable", error)),
778 }
779 } else {
780 let knowledge = load(project_root);
781 let ids = ids_of(&knowledge.facts);
782 let plans = plan_dedupe(&knowledge.facts, &ids, &knowledge.judged_pairs, threshold);
783 let mut guard = GuardedResolver::local(root);
784 (dedupe_groups(&knowledge.facts, &ids, &plans, &mut guard), 0)
785 };
786 Ok(DedupeReport {
787 contract: CONTRACT,
788 kind: "knowledge.dedupe",
789 project_root: project_root.to_owned(),
790 applied: apply,
791 threshold: round2(threshold),
792 groups,
793 merged,
794 })
795}
796
797#[derive(Debug, Serialize)]
800pub struct OutdatedFact {
801 pub fact: FactRef,
802 pub reason: &'static str,
803 pub superseded_by: Option<String>,
804 pub valid_until: Option<String>,
805}
806
807#[derive(Debug, Serialize)]
808pub struct OutdatedReport {
809 pub contract: &'static str,
810 pub kind: &'static str,
811 pub project_root: String,
812 pub days: u32,
813 pub cutoff: String,
814 pub total: usize,
815 pub returned: usize,
816 pub truncated: bool,
817 pub facts: Vec<OutdatedFact>,
818}
819
820fn cutoff_of(now: DateTime<Utc>, days: u32) -> DateTime<Utc> {
822 let day = now.date_naive() - chrono::Duration::days(i64::from(days));
823 DateTime::from_naive_utc_and_offset(day.and_hms_opt(0, 0, 0).unwrap_or_default(), Utc)
824}
825
826fn successor_of(knowledge: &ProjectKnowledge, fact: &KnowledgeFact) -> Option<usize> {
830 let name = pair_name(fact);
831 let current_named = |wanted: &str| {
832 knowledge
833 .facts
834 .iter()
835 .position(|f| f.is_current() && pair_name(f) == wanted)
836 };
837 if let Some(index) = knowledge
838 .judged_pairs
839 .iter()
840 .filter(|p| p.verdict == "supersedes" && p.key_b == name)
841 .filter_map(|p| current_named(&p.key_a).map(|i| (p.judged_at, i)))
842 .max_by_key(|(at, _)| *at)
843 .map(|(_, i)| i)
844 {
845 return Some(index);
846 }
847 let version = fact_version_id_v1(fact);
848 knowledge
849 .facts
850 .iter()
851 .position(|f| f.is_current() && f.supersedes.as_deref() == Some(version.as_str()))
852 .or_else(|| current_named(&name))
853}
854
855pub fn outdated(
859 project_root: &str,
860 days: Option<u32>,
861 limit: Option<usize>,
862 now: DateTime<Utc>,
863) -> Result<OutdatedReport, FreshnessError> {
864 let days = days.unwrap_or(DEFAULT_OUTDATED_DAYS);
865 if days > MAX_OUTDATED_DAYS {
866 return Err(FreshnessError::new(
867 "invalid_argument",
868 format!("--days must be 0-{MAX_OUTDATED_DAYS}"),
869 ));
870 }
871 let cutoff = cutoff_of(now, days);
872 let knowledge = load(project_root);
873 let ids = ids_of(&knowledge.facts);
874 let mut guard = GuardedResolver::local(Path::new(project_root));
875 let mut found: Vec<(u8, OutdatedFact)> = Vec::new();
876 for (fact, id) in knowledge.facts.iter().zip(&ids) {
877 let entry = if let Some(until) = fact.valid_until {
878 OutdatedFact {
879 fact: fact_ref(fact, id, None),
880 reason: "superseded",
881 superseded_by: successor_of(&knowledge, fact).map(|i| ids[i].clone()),
882 valid_until: Some(ts(until)),
883 }
884 } else if fact.last_confirmed < cutoff {
885 OutdatedFact {
886 fact: assessed_ref(fact, id, &mut guard),
887 reason: "unverified",
888 superseded_by: None,
889 valid_until: None,
890 }
891 } else {
892 continue;
893 };
894 found.push((u8::from(entry.reason == "unverified"), entry));
895 }
896 found.sort_by(|a, b| a.0.cmp(&b.0).then_with(|| a.1.fact.id.cmp(&b.1.fact.id)));
897 let total = found.len();
898 found.truncate(clamp_limit(limit));
899 Ok(OutdatedReport {
900 contract: CONTRACT,
901 kind: "knowledge.outdated",
902 project_root: project_root.to_owned(),
903 days,
904 cutoff: ts(cutoff),
905 total,
906 returned: found.len(),
907 truncated: found.len() < total,
908 facts: found.into_iter().map(|(_, f)| f).collect(),
909 })
910}
911
912#[derive(Debug, Serialize)]
915pub struct Conflict {
916 pub a: FactRef,
917 pub b: FactRef,
918 pub source: &'static str,
919 pub judged_at: Option<String>,
920}
921
922#[derive(Debug, Serialize)]
923pub struct ConflictsReport {
924 pub contract: &'static str,
925 pub kind: &'static str,
926 pub project_root: String,
927 pub conflicts: Vec<Conflict>,
928 pub unpaired: Vec<FactRef>,
929}
930
931pub fn conflicts(project_root: &str) -> ConflictsReport {
935 let knowledge = load(project_root);
936 let facts = &knowledge.facts;
937 let ids = ids_of(facts);
938 let mut guard = GuardedResolver::local(Path::new(project_root));
939 let mut by_name: BTreeMap<String, Vec<usize>> = BTreeMap::new();
940 for (i, fact) in facts.iter().enumerate().filter(|(_, f)| f.is_current()) {
941 by_name.entry(pair_name(fact)).or_default().push(i);
942 }
943 let mut pairs: BTreeMap<(usize, usize), (&'static str, Option<DateTime<Utc>>)> =
944 BTreeMap::new();
945 for pair in knowledge
946 .judged_pairs
947 .iter()
948 .filter(|p| p.verdict == "contradicts" && p.key_a != p.key_b)
949 {
950 let (Some([a]), Some([b])) = (
951 by_name.get(&pair.key_a).map(Vec::as_slice),
952 by_name.get(&pair.key_b).map(Vec::as_slice),
953 ) else {
954 continue;
955 };
956 let key = if ids[*a] < ids[*b] {
957 (*a, *b)
958 } else {
959 (*b, *a)
960 };
961 let slot = pairs.entry(key).or_insert(("judged", Some(pair.judged_at)));
962 slot.1 = slot.1.max(Some(pair.judged_at));
963 }
964 for members in by_name.values() {
965 let mut members = members.clone();
966 members.sort_by_key(|&i| facts[i].created_at);
967 if let [first, second, ..] = members.as_slice()
968 && normalized(&facts[*first].value) != normalized(&facts[*second].value)
969 {
970 let key = if ids[*first] < ids[*second] {
971 (*first, *second)
972 } else {
973 (*second, *first)
974 };
975 pairs.entry(key).or_insert(("same_key", None));
976 }
977 }
978 let paired: BTreeSet<usize> = pairs.keys().flat_map(|&(a, b)| [a, b]).collect();
979 let mut out: Vec<Conflict> = pairs
980 .into_iter()
981 .map(|((a, b), (source, at))| Conflict {
982 a: assessed_ref(&facts[a], &ids[a], &mut guard),
983 b: assessed_ref(&facts[b], &ids[b], &mut guard),
984 source,
985 judged_at: at.map(ts),
986 })
987 .collect();
988 out.sort_by(|x, y| x.a.id.cmp(&y.a.id).then_with(|| x.b.id.cmp(&y.b.id)));
989 let mut unpaired: Vec<FactRef> = facts
990 .iter()
991 .enumerate()
992 .filter(|(i, f)| f.is_current() && contradicted(f) && !paired.contains(i))
993 .map(|(i, f)| assessed_ref(f, &ids[i], &mut guard))
994 .collect();
995 unpaired.sort_by(|x, y| x.id.cmp(&y.id));
996 ConflictsReport {
997 contract: CONTRACT,
998 kind: "knowledge.conflicts",
999 project_root: project_root.to_owned(),
1000 conflicts: out,
1001 unpaired,
1002 }
1003}
1004
1005pub const NOT_TRUTH_NOTE: &str = "note: facts tagged [to verify]/[contradicted] are not current truth; check the code, then ctx_knowledge action=repin (or lean-ctx knowledge verify <id>).";
1008
1009#[cfg(test)]
1010#[path = "freshness_tests.rs"]
1011mod tests;