lean_ctx/core/value_gate/
mod.rs1pub mod cost_tracker;
4pub mod cpao;
5pub mod outcome_evaluator;
6pub mod report;
7pub mod store;
8
9pub use cost_tracker::ExecutionCost;
10pub use outcome_evaluator::{OutcomeSignal, TaskOutcome};
11pub use store::ValueGateStore;
12
13#[derive(Debug, Clone, Copy, Default)]
15pub struct ValueGate;
16
17pub fn store() -> &'static store::ValueGateStore {
18 static STORE: std::sync::OnceLock<store::ValueGateStore> = std::sync::OnceLock::new();
19 STORE.get_or_init(store::ValueGateStore::default)
20}
21
22#[derive(Debug, Clone, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
24pub struct ValueAssessment {
25 pub task_id: String,
26 pub model: String,
27 pub total_tokens: u64,
28 pub cost_micros: u64,
29 pub outcome_accepted: bool,
30 pub cpao_micros: Option<u64>,
31 pub evidence: Vec<String>,
32 pub timestamp: String,
33}
34
35impl ValueGate {
36 pub fn assess(
37 &self,
38 task_id: &str,
39 execution_cost: &ExecutionCost,
40 outcome: &TaskOutcome,
41 ) -> ValueAssessment {
42 assess_task(task_id, execution_cost, outcome)
43 }
44
45 pub fn evaluate_task(
46 task_id: &str,
47 execution_cost: &ExecutionCost,
48 outcome: &TaskOutcome,
49 ) -> ValueAssessment {
50 evaluate_task(task_id, execution_cost, outcome)
51 }
52}
53
54pub fn evaluate_task(
56 task_id: &str,
57 execution_cost: &ExecutionCost,
58 outcome: &TaskOutcome,
59) -> ValueAssessment {
60 let assessment = assess_task(task_id, execution_cost, outcome);
61 record_assessment(&assessment);
62 assessment
63}
64
65pub fn assess_task(
67 task_id: &str,
68 execution_cost: &ExecutionCost,
69 outcome: &TaskOutcome,
70) -> ValueAssessment {
71 assess_task_with_context(task_id, execution_cost, outcome, true)
72}
73
74pub fn assess_simulated_task(
76 task_id: &str,
77 execution_cost: &ExecutionCost,
78 outcome: &TaskOutcome,
79) -> ValueAssessment {
80 assess_task_with_context(task_id, execution_cost, outcome, false)
81}
82
83fn assess_task_with_context(
84 task_id: &str,
85 execution_cost: &ExecutionCost,
86 outcome: &TaskOutcome,
87 include_causal_context: bool,
88) -> ValueAssessment {
89 let task_id_matches = outcome.task_id == task_id;
90 let outcome_accepted = task_id_matches && outcome_evaluator::evaluate(outcome);
91 let cost_micros = execution_cost.estimated_cost_micros;
92 let cpao_micros = cpao::cost_per_accepted_outcome(&[cost_micros], &[outcome_accepted]);
93 let mut evidence = vec![
94 format!("task_id_matches={task_id_matches}"),
95 format!("execution_cost_micros={cost_micros}"),
96 format!("outcome_completed={}", outcome.completed),
97 format!("outcome_accepted={outcome_accepted}"),
98 ];
99 evidence.extend(
100 outcome
101 .signals
102 .iter()
103 .map(|signal| format!("signal={signal:?}")),
104 );
105 if include_causal_context {
106 let causal_chunks = crate::core::causal_attribution::chunks_for_session(task_id);
107 evidence.push(format!("causal_chunks_present={}", causal_chunks.len()));
108 evidence.extend(causal_chunks.iter().map(|chunk| {
109 format!(
110 "causal_chunk id={} source={}",
111 chunk.content_hash, chunk.source
112 )
113 }));
114 }
115
116 ValueAssessment {
117 task_id: task_id.to_owned(),
118 model: execution_cost.model.clone(),
119 total_tokens: execution_cost
120 .input_tokens
121 .saturating_add(execution_cost.output_tokens)
122 .saturating_add(execution_cost.cache_read_tokens),
123 cost_micros,
124 outcome_accepted,
125 cpao_micros,
126 evidence,
127 timestamp: chrono::Utc::now().to_rfc3339(),
128 }
129}
130
131pub(crate) fn record_assessment(assessment: &ValueAssessment) {
133 let causal_outcome = if assessment.outcome_accepted {
134 crate::core::causal_attribution::Outcome::Success
135 } else {
136 crate::core::causal_attribution::Outcome::Failure
137 };
138 if let Err(error) = crate::core::causal_attribution::record_outcome(
139 &assessment.task_id,
140 crate::core::causal_attribution::OutcomeSignal {
141 session_id: assessment.task_id.clone(),
142 outcome: causal_outcome,
143 evidence: format!(
144 "value_gate outcome_accepted={}",
145 assessment.outcome_accepted
146 ),
147 },
148 ) {
149 tracing::debug!(%error, task_id = assessment.task_id, "causal attribution ValueGate recording failed");
150 }
151 store().record(assessment);
152 #[cfg(feature = "enterprise")]
153 record_adaptive_policy_outcome(&assessment.task_id, assessment);
154}
155
156#[cfg(feature = "enterprise")]
158fn record_adaptive_policy_outcome(task_id: &str, assessment: &ValueAssessment) {
159 let task_class = crate::core::task_spine::TaskSpine::current()
160 .filter(|envelope| envelope.task_id.as_str() == task_id)
161 .and_then(|envelope| envelope.task_class)
162 .unwrap_or_else(|| "chat".to_owned());
163 let policy = crate::core::adaptive_policy::best_policy_for(&task_class);
164
165 crate::core::adaptive_policy::record_value_gate_outcome(
168 task_class,
169 policy,
170 assessment.outcome_accepted,
171 0.0,
172 );
173}
174
175#[cfg(test)]
176mod tests {
177 use super::*;
178
179 #[test]
180 fn value_gate_e2e() {
181 let _isolation = crate::core::data_dir::isolated_data_dir();
182 let cost = ExecutionCost {
183 input_tokens: 1_000_000,
184 output_tokens: 100_000,
185 cache_read_tokens: 0,
186 model: "gpt-4o".into(),
187 provider: "openai".into(),
188 estimated_cost_micros: cost_tracker::calculate_cost(1_000_000, 100_000, 0, "gpt-4o"),
189 };
190 let outcome = TaskOutcome {
191 task_id: "task-e2e".into(),
192 completed: true,
193 signals: vec![OutcomeSignal::BuildSucceeded, OutcomeSignal::UserAccepted],
194 };
195 let assessment = evaluate_task("task-e2e", &cost, &outcome);
196 assert_eq!(assessment.task_id, "task-e2e");
197 assert!(assessment.outcome_accepted);
198 assert_eq!(assessment.cpao_micros, Some(3_500_000));
199 assert!(
200 !assessment.timestamp.is_empty(),
201 "`assessment.timestamp` is empty"
202 );
203 assert!(
204 !assessment.evidence.is_empty(),
205 "`assessment.evidence` is empty"
206 );
207 }
208
209 #[test]
210 fn rejects_outcome_for_another_task() {
211 let _isolation = crate::core::data_dir::isolated_data_dir();
212 let cost = ExecutionCost {
213 input_tokens: 1,
214 output_tokens: 1,
215 cache_read_tokens: 0,
216 model: "gpt-4o".into(),
217 provider: "openai".into(),
218 estimated_cost_micros: 1,
219 };
220 let outcome = TaskOutcome {
221 task_id: "other-task".into(),
222 completed: true,
223 signals: vec![OutcomeSignal::TestsPassed],
224 };
225 let assessment = evaluate_task("expected-task", &cost, &outcome);
226 assert!(!assessment.outcome_accepted);
227 assert_eq!(assessment.cpao_micros, None);
228 assert!(
229 assessment
230 .evidence
231 .contains(&"task_id_matches=false".into())
232 );
233 }
234
235 #[test]
236 #[ignore = "requires enterprise implementation (GitLab)"]
237 fn value_gate_attributes_chunks_present_for_accepted_task() {
238 let _isolation = crate::core::data_dir::isolated_data_dir();
239 let envelope = crate::core::task_spine::TaskSpine::create_envelope(
240 "attribute context",
241 "value-gate-causal",
242 "test-agent",
243 );
244 let task_id = envelope.task_id.as_str().to_owned();
245 crate::core::causal_attribution::record_chunk(
246 &task_id,
247 crate::core::causal_attribution::ContextChunkRecord::new(
248 "useful tool result",
249 "ctx_read src/lib.rs",
250 4,
251 1,
252 ),
253 )
254 .unwrap();
255 let cost = ExecutionCost {
256 input_tokens: 10,
257 output_tokens: 1,
258 cache_read_tokens: 0,
259 model: "test".into(),
260 provider: "test".into(),
261 estimated_cost_micros: 1,
262 };
263 let outcome = TaskOutcome {
264 task_id: task_id.clone(),
265 completed: true,
266 signals: vec![OutcomeSignal::BuildSucceeded],
267 };
268
269 let assessment = evaluate_task(&task_id, &cost, &outcome);
270 assert!(assessment.outcome_accepted);
271 assert!(
272 assessment
273 .evidence
274 .iter()
275 .any(|item| item == "causal_chunks_present=1")
276 );
277 assert!(
278 assessment
279 .evidence
280 .iter()
281 .any(|item| item.contains("causal_chunk") && item.contains("ctx_read src/lib.rs"))
282 );
283 }
284
285 #[test]
286 #[ignore = "requires enterprise implementation (GitLab)"]
287 fn value_gate_records_adaptive_policy_feedback() {
288 let _data = crate::core::data_dir::isolated_data_dir();
289 let mut envelope = crate::core::task_spine::TaskSpine::create_envelope(
290 "debug a failing build",
291 "value-gate-feedback",
292 "test-agent",
293 );
294 envelope.task_class = Some("debugging".into());
295 let task_id = envelope.task_id.as_str().to_owned();
296 crate::core::task_spine::TaskSpine::set(envelope);
297
298 let cost = ExecutionCost {
299 input_tokens: 100,
300 output_tokens: 20,
301 cache_read_tokens: 0,
302 model: "gpt-4o".into(),
303 provider: "test".into(),
304 estimated_cost_micros: 1,
305 };
306 let outcome = TaskOutcome {
307 task_id: task_id.clone(),
308 completed: true,
309 signals: vec![OutcomeSignal::BuildSucceeded],
310 };
311
312 let assessment = evaluate_task(&task_id, &cost, &outcome);
313 let path = crate::core::paths::data_dir()
314 .unwrap()
315 .join("policy_outcomes.jsonl");
316 let recorded: crate::core::adaptive_policy::PolicyOutcome = serde_json::from_str(
317 std::fs::read_to_string(path)
318 .unwrap()
319 .lines()
320 .last()
321 .unwrap(),
322 )
323 .unwrap();
324
325 assert_eq!(recorded.task_class, "debugging");
326 assert_eq!(
327 recorded.policy_used,
328 crate::core::adaptive_policy::select_policy("debugging")
329 );
330 assert_eq!(recorded.session_success, Some(assessment.outcome_accepted));
331 assert_eq!(recorded.savings_pct, 0.0);
332 }
333}