Skip to main content

lean_ctx/core/
context_proof.rs

1use chrono::TimeZone;
2use serde::Serialize;
3use std::path::{Path, PathBuf};
4use std::sync::atomic::{AtomicU64, Ordering};
5
6static PROOFS_COLLECTED: AtomicU64 = AtomicU64::new(0);
7static PROOFS_WRITTEN: AtomicU64 = AtomicU64::new(0);
8static LAST_WRITTEN_UNIX_MS: AtomicU64 = AtomicU64::new(0);
9
10#[derive(Debug, Clone, Serialize)]
11pub struct ProofStatsSnapshot {
12    pub collected: u64,
13    pub written: u64,
14    pub last_written_at: Option<String>,
15}
16
17pub fn proof_stats_snapshot() -> ProofStatsSnapshot {
18    let collected = PROOFS_COLLECTED.load(Ordering::Relaxed);
19    let written = PROOFS_WRITTEN.load(Ordering::Relaxed);
20    let last_ms = LAST_WRITTEN_UNIX_MS.load(Ordering::Relaxed);
21    let last_written_at = if last_ms == 0 {
22        None
23    } else {
24        chrono::Utc
25            .timestamp_millis_opt(last_ms as i64)
26            .single()
27            .map(|t| t.to_rfc3339())
28    };
29    ProofStatsSnapshot {
30        collected,
31        written,
32        last_written_at,
33    }
34}
35
36#[derive(Debug, Clone, Serialize)]
37pub struct ContextProofV1 {
38    pub schema_version: u32,
39    pub created_at: String,
40    pub lean_ctx_version: String,
41    pub session_id: Option<String>,
42    pub project: ProjectIdentity,
43    pub role: RoleIdentity,
44    pub profile: ProfileIdentity,
45    pub budgets: crate::core::budget_tracker::BudgetSnapshot,
46    pub slo: crate::core::slo::SloSnapshot,
47    pub pipeline: crate::core::pipeline::PipelineStats,
48    pub verification: crate::core::output_verification::VerificationSnapshot,
49    pub ledger: LedgerSummary,
50    pub evidence: Vec<EvidenceReceipt>,
51}
52
53#[derive(Debug, Clone, Serialize)]
54pub struct ProjectIdentity {
55    pub project_root_hash: Option<String>,
56    pub project_identity_hash: Option<String>,
57}
58
59#[derive(Debug, Clone, Serialize)]
60pub struct RoleIdentity {
61    pub name: String,
62    pub policy_md5: String,
63}
64
65#[derive(Debug, Clone, Serialize)]
66pub struct ProfileIdentity {
67    pub name: String,
68    pub policy_md5: String,
69}
70
71#[derive(Debug, Clone, Serialize)]
72pub struct LedgerSummary {
73    pub window_size: usize,
74    pub entries: usize,
75    pub total_tokens_sent: usize,
76    pub total_tokens_saved: usize,
77    pub compression_ratio: f64,
78    pub top_files_by_sent_tokens: Vec<LedgerFileSummary>,
79}
80
81#[derive(Debug, Clone, Serialize)]
82pub struct LedgerFileSummary {
83    pub path: String,
84    pub mode: String,
85    pub sent_tokens: usize,
86    pub original_tokens: usize,
87}
88
89#[derive(Debug, Clone, Serialize)]
90pub struct EvidenceReceipt {
91    pub tool: Option<String>,
92    pub input_md5: Option<String>,
93    pub output_md5: Option<String>,
94    pub agent_id: Option<String>,
95    pub client_name: Option<String>,
96    pub timestamp: String,
97}
98
99#[derive(Debug, Clone, Copy, Default)]
100pub struct ProofOptions {
101    pub max_evidence: usize,
102    pub max_ledger_files: usize,
103}
104
105#[derive(Debug, Clone, Default)]
106pub struct ProofSources {
107    pub project_root: Option<String>,
108    pub session: Option<crate::core::session::SessionState>,
109    pub pipeline: Option<crate::core::pipeline::PipelineStats>,
110    pub ledger: Option<crate::core::context_ledger::ContextLedger>,
111}
112
113pub fn collect_v1(sources: ProofSources, opts: ProofOptions) -> ContextProofV1 {
114    PROOFS_COLLECTED.fetch_add(1, Ordering::Relaxed);
115    let created_at = chrono::Utc::now().to_rfc3339();
116
117    let role_name = crate::core::roles::active_role_name();
118    let role = crate::core::roles::active_role();
119
120    let profile_name = crate::core::profiles::active_profile_name();
121    let profile = crate::core::profiles::active_profile();
122
123    let role_policy_md5 =
124        crate::core::hasher::hash_str(&serde_json::to_string(&role).unwrap_or_default());
125    let profile_policy_md5 =
126        crate::core::hasher::hash_str(&serde_json::to_string(&profile).unwrap_or_default());
127
128    let project_root = sources.project_root.clone().or_else(|| {
129        sources
130            .session
131            .as_ref()
132            .and_then(|s| s.project_root.clone())
133    });
134
135    let (project_root_hash, project_identity_hash) = if let Some(ref root) = project_root {
136        let root_hash = crate::core::project_hash::hash_project_root(root);
137        let identity = crate::core::project_hash::project_identity(root);
138        let identity_hash = identity.as_deref().map(crate::core::hasher::hash_str);
139        (Some(root_hash), identity_hash)
140    } else {
141        (None, None)
142    };
143
144    let budgets = crate::core::budget_tracker::BudgetTracker::global().check();
145    let slo = crate::core::slo::evaluate_quiet();
146    let verification = crate::core::output_verification::stats_snapshot();
147
148    let pipeline = sources
149        .pipeline
150        .unwrap_or_else(crate::core::pipeline::PipelineStats::load);
151
152    let ledger = sources
153        .ledger
154        .unwrap_or_else(crate::core::context_ledger::ContextLedger::load);
155
156    let top_files = ledger
157        .files_by_token_cost()
158        .into_iter()
159        .take(opts.max_ledger_files.max(1))
160        .filter_map(|(path, _)| ledger.entries.iter().find(|e| e.path == path).cloned())
161        .map(|e| LedgerFileSummary {
162            path: e.path,
163            mode: e.mode,
164            sent_tokens: e.sent_tokens,
165            original_tokens: e.original_tokens,
166        })
167        .collect::<Vec<_>>();
168
169    let ledger_summary = LedgerSummary {
170        window_size: ledger.window_size,
171        entries: ledger.entries.len(),
172        total_tokens_sent: ledger.total_tokens_sent,
173        total_tokens_saved: ledger.total_tokens_saved,
174        compression_ratio: ledger.compression_ratio(),
175        top_files_by_sent_tokens: top_files,
176    };
177
178    let session = sources.session.or_else(|| {
179        project_root
180            .as_deref()
181            .and_then(crate::core::session::SessionState::load_latest_for_project_root)
182            .or_else(crate::core::session::SessionState::load_latest)
183    });
184
185    let (session_id, evidence) = if let Some(s) = session {
186        let mut receipts = s
187            .evidence
188            .into_iter()
189            .filter(|e| matches!(e.kind, crate::core::session::EvidenceKind::ToolCall))
190            .rev()
191            .take(opts.max_evidence.max(1))
192            .map(|e| EvidenceReceipt {
193                tool: e.tool,
194                input_md5: e.input_md5,
195                output_md5: e.output_md5,
196                agent_id: e.agent_id,
197                client_name: e.client_name,
198                timestamp: e.timestamp.to_rfc3339(),
199            })
200            .collect::<Vec<_>>();
201        receipts.reverse();
202        (Some(s.id), receipts)
203    } else {
204        (None, Vec::new())
205    };
206
207    ContextProofV1 {
208        schema_version: crate::core::contracts::CONTEXT_PROOF_V1_SCHEMA_VERSION,
209        created_at,
210        lean_ctx_version: env!("CARGO_PKG_VERSION").to_string(),
211        session_id,
212        project: ProjectIdentity {
213            project_root_hash,
214            project_identity_hash,
215        },
216        role: RoleIdentity {
217            name: role_name,
218            policy_md5: role_policy_md5,
219        },
220        profile: ProfileIdentity {
221            name: profile_name,
222            policy_md5: profile_policy_md5,
223        },
224        budgets,
225        slo,
226        pipeline,
227        verification,
228        ledger: ledger_summary,
229        evidence,
230    }
231}
232
233pub fn write_project_proof(
234    project_root: &Path,
235    proof: &ContextProofV1,
236    filename: Option<&str>,
237) -> Result<PathBuf, String> {
238    let proofs_dir = project_root.join(".lean-ctx").join("proofs");
239    std::fs::create_dir_all(&proofs_dir).map_err(|e| e.to_string())?;
240
241    let ts = chrono::Utc::now().format("%Y-%m-%d_%H%M%S");
242    let name = filename.map_or_else(
243        || format!("context-proof-v1_{ts}.json"),
244        std::string::ToString::to_string,
245    );
246    let path = proofs_dir.join(name);
247
248    let json = serde_json::to_string_pretty(proof).map_err(|e| e.to_string())?;
249    // Proof artifacts may be attached to CI logs; always redact (even for admin).
250    let json = crate::core::redaction::redact_text(&json);
251    crate::config_io::write_atomic(&path, &json)?;
252
253    PROOFS_WRITTEN.fetch_add(1, Ordering::Relaxed);
254    let ms = std::time::SystemTime::now()
255        .duration_since(std::time::SystemTime::UNIX_EPOCH)
256        .map_or(0, |d| d.as_millis() as u64);
257    LAST_WRITTEN_UNIX_MS.store(ms, Ordering::Relaxed);
258
259    Ok(path)
260}
261
262#[cfg(test)]
263mod tests {
264    use super::*;
265
266    #[test]
267    fn proof_has_required_fields() {
268        let proof = collect_v1(
269            ProofSources {
270                project_root: Some(".".to_string()),
271                ..Default::default()
272            },
273            ProofOptions {
274                max_evidence: 5,
275                max_ledger_files: 3,
276            },
277        );
278        let v = serde_json::to_value(&proof).unwrap();
279        assert_eq!(v["schema_version"], 1);
280        assert!(v["created_at"].as_str().unwrap_or_default().contains('T'));
281        assert!(v["lean_ctx_version"].as_str().unwrap_or_default().len() >= 3);
282    }
283
284    #[test]
285    fn write_project_proof_creates_file() {
286        let dir = tempfile::tempdir().unwrap();
287        let proof = collect_v1(
288            ProofSources {
289                project_root: Some(dir.path().to_string_lossy().to_string()),
290                ..Default::default()
291            },
292            ProofOptions {
293                max_evidence: 3,
294                max_ledger_files: 2,
295            },
296        );
297        let path = write_project_proof(dir.path(), &proof, None).unwrap();
298        assert!(path.exists());
299        let content = std::fs::read_to_string(path).unwrap();
300        assert!(content.contains("\"schema_version\": 1"));
301    }
302}