Skip to main content

lean_ctx_server/tools/ctx_knowledge/
freshness.rs

1// SPDX-License-Identifier: Apache-2.0
2//! Freshness of a project's knowledge: which facts are not current truth
3//! anymore, and the primitives a weekly clean-up runs (`lean-ctx knowledge
4//! stale | verify | dedupe | outdated | conflicts --json`, contract
5//! `local-read/1`).
6//!
7//! - Anchored facts are resolved against the checkout on disk (file, symbol,
8//!   content digest) through [`GuardedResolver`]: only files this instance may
9//!   read for knowledge are read, so a pulled fact's anchor never turns the
10//!   checkout into an oracle. The live state is the one source of truth for
11//!   anchors; the stored *to verify* mark (what `knowledge list`, AGENTS.md
12//!   and Hive read) is reconciled with it whenever it is checked.
13//! - [`mark_edited`] reconciles the facts anchored to a file when an agent
14//!   edits it through this instance (cheap, no model): marked while the code
15//!   does not hold, unmarked once a revert restores it. [`stale`] reconciles
16//!   every anchored fact before it lists them.
17//! - Ids and timestamps follow [`super::fact_ids`], the rules `knowledge
18//!   list` uses: a current fact is `<category>/<key>` (a second one on the
19//!   same key, and every superseded version, `…@<version>`).
20//! - Every listing is deterministic: fixed order, ties broken by id; the only
21//!   clock input is `outdated`'s cut-off, printed with the result.
22
23use 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;
43/// About a hundred years: any older cut-off lists every fact anyway.
44pub const MAX_OUTDATED_DAYS: u32 = 36_500;
45
46/// A refusal with its `local-read/1` error code.
47#[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
62/// `<category>/<key>`: how a judged pair names a fact.
63fn pair_name(fact: &KnowledgeFact) -> String {
64    format!("{}/{}", fact.category, fact.key)
65}
66
67/// The id of every fact of `facts`, by position: current facts as
68/// [`current_ids`] names them, superseded versions by [`version_id`].
69pub 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/// The fact reference every listing carries (INTERFACE.md "Fact reference").
88#[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
109/// Where a fact stands now: its review value and the reasons for it.
110struct 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
140/// The worst live anchor reason of `fact` over all its anchors: gone, then
141/// moved, then changed; `None` when every anchor holds.
142fn 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
161/// Bring the stored anchor mark of `fact` in line with `live` (the one
162/// source of truth for anchors): mark it to verify while its code does not
163/// hold, clear the mark once it holds again (an edit that was reverted).
164/// A contradiction or source mark needs a person and is left alone.
165/// Returns whether the stored review changed.
166fn 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
182/// Reconcile every current anchored fact `wanted` selects, under the store
183/// lock; returns the store as written and how many marks changed. Nothing
184/// is written when every stored mark already matches the live state.
185fn 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// ---------------------------------------------------------------- stale
295
296#[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
314/// Current facts that are not current truth: gone, to verify, contradicted.
315///
316/// The live anchor check is the one source of truth: before listing, a
317/// stored anchor mark that disagrees with it is written back (set for code
318/// that changed outside this instance, cleared for code that was restored),
319/// so `knowledge list`, AGENTS.md and Hive show what this listing shows.
320/// When the store cannot be written the listing still reports the live
321/// state.
322pub 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
364/// Reconcile the current facts anchored to `path` (absolute or
365/// project-relative) after an agent's edit through this instance: *to
366/// verify* while their anchored code no longer holds, unmarked again once
367/// it does (the edit was reverted). Returns how many marks changed. A
368/// contradicted fact keeps its mark (it needs a person, not a re-check).
369pub 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// ---------------------------------------------------------------- verify
381
382#[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
407/// Re-capture every anchor of `fact` where its code is now, reading only
408/// admitted files; a gone anchor refuses the whole verify.
409fn 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
451/// "Checked, still true": re-anchor, clear an anchor or source mark, confirm.
452pub 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// ---------------------------------------------------------------- dedupe
486
487#[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
529/// Words that flip or bound a claim. Two texts that differ in one of them,
530/// or in a number, say different things however similar they read
531/// ("never retry" / "always retry", "3 retries" / "5 retries").
532const 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
579/// Whether two normalized texts differ in a polarity word or a number: a
580/// near match between them is never a duplicate.
581fn 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
594/// Pairs a person or agent judged apart (`contradicts`, `unrelated`): never
595/// merged, whatever their wording.
596fn 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
609/// Duplicate groups among the current own facts of one category each.
610/// Survivor order: most confirmations, longest text, oldest, smallest id.
611fn 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        // This instance's own facts only (`unverified`: written before
621        // origins existed); provider and derived facts belong to their source.
622        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
681/// Merge each plan into its survivor: union of anchors, confirmations
682/// added; the others archived as superseded by it, so every merged text
683/// stays in the history (`outdated`, `knowledge list --state superseded`).
684/// The survivor's text is never rewritten: a merge loses no wording.
685fn 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
746/// Exact and near duplicates; merged only with `apply`. Ids name the facts
747/// as they were before the merge.
748pub 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            // The survivor as it is now.
770            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// ---------------------------------------------------------------- outdated
798
799#[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
820/// Midnight UTC of (`now`'s day − `days`): the same all day long.
821fn 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
826/// The position of the current fact that replaced `fact`: a recorded
827/// `supersedes` verdict, a successor naming its version, or the current
828/// fact under the same name.
829fn 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
855/// Superseded facts, and current facts nobody confirmed since the cut-off.
856/// `days` above [`MAX_OUTDATED_DAYS`] is `invalid_argument` (a cut-off
857/// before year 0 would not be a date).
858pub 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// ---------------------------------------------------------------- conflicts
913
914#[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
931/// Contradictions to show the person: judged pairs that are both still
932/// current, two current facts under one name with different text, and
933/// contradicted facts whose partner is gone.
934pub 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
1005/// The line recall/search end with when they showed a fact that is not
1006/// current truth (stable text: a state-triggered suffix).
1007pub 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;