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}