Skip to main content

lean_ctx/core/knowledge/
snapshot.rs

1//! `KnowledgeSnapshot` — the single in-memory view of a project's durable
2//! knowledge that every *outbound* rendering shares.
3//!
4//! Before this existed, the Context Package builder read `knowledge.json` +
5//! `relations.json` inline and shaped its own `KnowledgeLayer`. Adding a second
6//! portable format (Open Knowledge Format, see [`super::okf`]) would have meant a
7//! *second* extractor reading the same stores with subtly different rules — the
8//! exact fragmentation the "one model, many renderings" positioning warns
9//! against. Instead both the ctxpkg `KnowledgeLayer` and the OKF Markdown bundle
10//! are rendered from this one snapshot, so they can never drift on *what counts
11//! as the project's knowledge*.
12//!
13//! The snapshot carries the full fact history (ctxpkg preserves superseded facts
14//! for fidelity); [`KnowledgeSnapshot::current_facts`] is the human-facing view
15//! portable exports use.
16
17use crate::core::knowledge_relations::{KnowledgeEdge, KnowledgeRelationGraph};
18use lean_ctx_protocol::KnowledgeObjectV1;
19use serde::{Deserialize, Serialize};
20
21use super::types::{ConsolidatedInsight, KnowledgeFact, ProjectKnowledge, ProjectPattern};
22
23/// Receipt-safe reference to a selected portable knowledge object.
24#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
25pub struct KnowledgeRef {
26    pub object_id: String,
27    pub evidence_digest: String,
28    pub policy_ref: String,
29}
30
31/// A consistent read of a project's knowledge (facts + patterns + insights) and
32/// its relation graph, taken at one point in time. The single source of truth
33/// for outbound renderings (ctxpkg, OKF).
34#[derive(Debug, Clone)]
35pub struct KnowledgeSnapshot {
36    pub project_root: String,
37    pub project_hash: String,
38    /// All facts, including superseded ones (ctxpkg keeps the history).
39    pub facts: Vec<KnowledgeFact>,
40    pub patterns: Vec<ProjectPattern>,
41    /// Consolidated insights (the project's `history`).
42    pub insights: Vec<ConsolidatedInsight>,
43    /// Typed relations between facts (`relations.json`).
44    pub relations: Vec<KnowledgeEdge>,
45    /// Task identity for portable retrieval snapshots.
46    pub task_id: Option<String>,
47    /// Retrieval-policy version used to select portable objects.
48    pub policy_version: Option<String>,
49    /// Stable references to portable objects selected for the task.
50    pub knowledge_refs: Vec<KnowledgeRef>,
51    /// Deterministic digest of the task, policy, and selected references.
52    pub snapshot_hash: Option<String>,
53}
54
55impl KnowledgeSnapshot {
56    /// Loads a project's knowledge and relation graph from disk into one
57    /// snapshot. Missing stores yield empty collections rather than an error, so
58    /// callers can treat "no knowledge yet" via [`KnowledgeSnapshot::is_empty`].
59    pub fn collect(project_root: &str) -> Self {
60        let knowledge = ProjectKnowledge::load_or_create(project_root);
61        let relations = KnowledgeRelationGraph::load(&knowledge.project_hash)
62            .map(|g| g.edges)
63            .unwrap_or_default();
64        Self::from_project(&knowledge, relations)
65    }
66
67    /// Builds a snapshot from an already-loaded `ProjectKnowledge` plus its
68    /// relation edges. Keeps the collection logic in one place for callers that
69    /// already hold the knowledge (e.g. inside a lock).
70    pub fn from_project(knowledge: &ProjectKnowledge, relations: Vec<KnowledgeEdge>) -> Self {
71        Self {
72            project_root: knowledge.project_root.clone(),
73            project_hash: knowledge.project_hash.clone(),
74            facts: knowledge.facts.clone(),
75            patterns: knowledge.patterns.clone(),
76            insights: knowledge.history.clone(),
77            relations,
78            task_id: None,
79            policy_version: None,
80            knowledge_refs: Vec::new(),
81            snapshot_hash: None,
82        }
83    }
84
85    /// Build a deterministic task-local snapshot from portable knowledge objects.
86    pub fn from_items(task_id: &str, policy_version: &str, items: &[KnowledgeObjectV1]) -> Self {
87        let knowledge_refs = items
88            .iter()
89            .map(|item| KnowledgeRef {
90                object_id: item.object_id().to_owned(),
91                evidence_digest: item.evidence_digest.clone(),
92                policy_ref: item.policy_ref.clone(),
93            })
94            .collect::<Vec<_>>();
95        let mut hasher = blake3::Hasher::new();
96        for part in std::iter::once(task_id)
97            .chain(std::iter::once(policy_version))
98            .chain(knowledge_refs.iter().flat_map(|reference| {
99                [
100                    reference.object_id.as_str(),
101                    reference.evidence_digest.as_str(),
102                    reference.policy_ref.as_str(),
103                ]
104            }))
105        {
106            hasher.update(&(part.len() as u64).to_le_bytes());
107            hasher.update(part.as_bytes());
108        }
109        let snapshot_hash = hasher.finalize().to_hex().to_string();
110        Self {
111            project_root: String::new(),
112            project_hash: snapshot_hash.clone(),
113            facts: Vec::new(),
114            patterns: Vec::new(),
115            insights: Vec::new(),
116            relations: Vec::new(),
117            task_id: Some(task_id.to_owned()),
118            policy_version: Some(policy_version.to_owned()),
119            knowledge_refs,
120            snapshot_hash: Some(snapshot_hash),
121        }
122    }
123
124    /// True when there is nothing worth exporting (no facts, patterns, or
125    /// insights). Relations alone never make a bundle — they are edges between
126    /// facts that, without endpoints, carry no standalone meaning.
127    pub fn is_empty(&self) -> bool {
128        self.facts.is_empty()
129            && self.patterns.is_empty()
130            && self.insights.is_empty()
131            && self.knowledge_refs.is_empty()
132    }
133
134    /// The current (non-superseded, temporally valid) facts — the human-facing
135    /// view portable exports render. Superseded history stays in [`Self::facts`]
136    /// for ctxpkg fidelity but would only confuse a hand-edited OKF bundle.
137    pub fn current_facts(&self) -> Vec<&KnowledgeFact> {
138        self.facts.iter().filter(|f| f.is_current()).collect()
139    }
140}