Skip to main content

lean_ctx/core/value_gate/
mod.rs

1//! End-to-end task value assessment: execution cost → outcome → CPAO.
2
3pub 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/// Stateless orchestrator for the value-gate loop.
14#[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/// Assessment produced after one execution and deterministic outcome check.
23#[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
54/// Run the complete task → cost → outcome → CPAO path.
55pub 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
65/// Calculate value without mutating process-global stores.
66pub 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
74/// Pure fixture assessment, without reading or writing production evidence.
75pub 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
131/// Persist one already-calculated assessment and its causal learning signal.
132pub(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/// Feeds every completed Value Gate assessment back into adaptive policy selection.
157#[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    // ExecutionCost holds post-compression token usage only. No baseline means
166    // no defensible percentage, so preserve the feedback event with 0.0 savings.
167    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}