Skip to main content

lean_ctx/core/ocla/adapters/
coverage.rs

1//! Deterministic coverage evidence for the OSS capability adapter boundary.
2
3use serde::{Deserialize, Serialize};
4
5/// Schema version for [`CapabilityCoverageReportV1`].
6pub const CAPABILITY_COVERAGE_SCHEMA_VERSION: u32 = 1;
7
8/// Required behavior covered by the Phase-C adapter matrix.
9#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)]
10#[serde(rename_all = "snake_case")]
11pub enum CapabilityCoverageScenario {
12    NativeSuccess,
13    ExternalSuccess,
14    PolicyRejection,
15    ExternalTimeout,
16    ExternalDisabled,
17}
18
19impl CapabilityCoverageScenario {
20    const REQUIRED: [Self; 5] = [
21        Self::NativeSuccess,
22        Self::ExternalSuccess,
23        Self::PolicyRejection,
24        Self::ExternalTimeout,
25        Self::ExternalDisabled,
26    ];
27
28    const fn expected_result(self) -> CapabilityCoverageResult {
29        match self {
30            Self::NativeSuccess | Self::ExternalSuccess => CapabilityCoverageResult::Succeeded,
31            Self::PolicyRejection => CapabilityCoverageResult::RejectedByPolicy,
32            Self::ExternalTimeout => CapabilityCoverageResult::TimedOut,
33            Self::ExternalDisabled => CapabilityCoverageResult::Disabled,
34        }
35    }
36}
37
38/// Payload-free outcome observed for one capability coverage scenario.
39#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)]
40#[serde(rename_all = "snake_case")]
41pub enum CapabilityCoverageResult {
42    Succeeded,
43    RejectedByPolicy,
44    TimedOut,
45    Disabled,
46}
47
48/// One deterministic coverage assertion for a versioned capability.
49#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
50#[serde(deny_unknown_fields)]
51pub struct CapabilityCoverageCaseV1 {
52    pub scenario: CapabilityCoverageScenario,
53    pub capability_id: String,
54    pub capability_version: String,
55    pub observed: CapabilityCoverageResult,
56}
57
58impl CapabilityCoverageCaseV1 {
59    /// Construct one coverage case without retaining invocation payloads.
60    #[must_use]
61    pub fn new(
62        scenario: CapabilityCoverageScenario,
63        capability_id: impl Into<String>,
64        capability_version: impl Into<String>,
65        observed: CapabilityCoverageResult,
66    ) -> Self {
67        Self {
68            scenario,
69            capability_id: capability_id.into(),
70            capability_version: capability_version.into(),
71            observed,
72        }
73    }
74
75    /// Whether the observed result satisfies this scenario's required behavior.
76    #[must_use]
77    pub fn passed(&self) -> bool {
78        self.scenario.expected_result() == self.observed
79    }
80}
81
82/// Machine-readable, deterministic Phase-C coverage evidence.
83#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
84#[serde(deny_unknown_fields)]
85pub struct CapabilityCoverageReportV1 {
86    pub schema_version: u32,
87    pub cases: Vec<CapabilityCoverageCaseV1>,
88}
89
90impl CapabilityCoverageReportV1 {
91    /// Build a report in its caller-defined, deterministic case order.
92    #[must_use]
93    pub fn new(cases: Vec<CapabilityCoverageCaseV1>) -> Self {
94        Self {
95            schema_version: CAPABILITY_COVERAGE_SCHEMA_VERSION,
96            cases,
97        }
98    }
99
100    /// True only when every required scenario appears once, in canonical order, and passes.
101    #[must_use]
102    pub fn is_complete(&self) -> bool {
103        self.schema_version == CAPABILITY_COVERAGE_SCHEMA_VERSION
104            && self.cases.len() == CapabilityCoverageScenario::REQUIRED.len()
105            && self
106                .cases
107                .iter()
108                .zip(CapabilityCoverageScenario::REQUIRED)
109                .all(|(case, required)| case.scenario == required && case.passed())
110    }
111
112    /// Serialize a payload-free report for reproducible evidence bundles.
113    pub fn to_json(&self) -> Result<String, serde_json::Error> {
114        serde_json::to_string(self)
115    }
116}
117
118#[cfg(test)]
119mod report_tests {
120    use super::*;
121
122    #[test]
123    fn canonical_report_requires_each_passing_scenario_once() {
124        let cases = CapabilityCoverageScenario::REQUIRED.map(|scenario| {
125            CapabilityCoverageCaseV1::new(
126                scenario,
127                "capability://example/test",
128                "1.0.0",
129                scenario.expected_result(),
130            )
131        });
132        let report = CapabilityCoverageReportV1::new(cases.into());
133
134        assert!(report.is_complete());
135        assert_eq!(
136            report.to_json().expect("serializable report"),
137            report.to_json().expect("deterministic report")
138        );
139
140        let incomplete = CapabilityCoverageReportV1::new(Vec::new());
141        assert!(!incomplete.is_complete());
142    }
143}
144
145#[cfg(all(test, unix))]
146mod tests {
147    use std::ffi::OsString;
148    use std::fs;
149    use std::sync::Arc;
150
151    use super::*;
152    use crate::core::ocla::adapters::external_process::ExternalProcessAdapter;
153    use crate::core::ocla::adapters::{AdapterRegistry, NativeContextAdapter};
154    use crate::core::ocla::invocation::{
155        CapabilityAdapter, CapabilityInput, CapabilityInvocation, PolicyConstraints,
156    };
157
158    const EXTERNAL_MANIFEST: &str = include_str!(
159        "../../../../../docs/contracts/ocla/capability-manifests/example/word-count-optimizer-v1.json"
160    );
161
162    fn native_invocation(adapter: &NativeContextAdapter) -> CapabilityInvocation {
163        CapabilityInvocation {
164            task_id: "capability-coverage/native-success".into(),
165            capability_id: adapter.manifest().capability_id.as_str().into(),
166            capability_version: adapter.manifest().version.clone(),
167            input: CapabilityInput::ContextRequest {
168                paths: vec!["fixture.rs".into()],
169                mode: "aggressive".into(),
170                budget_tokens: Some(100),
171            },
172            policy_constraints: PolicyConstraints::default(),
173            // No deadline: the native success path is under test, and a cold
174            // tokenizer on a loaded host can exceed any fixed budget.
175            timeout_ms: 0,
176        }
177    }
178
179    fn external_invocation(timeout_ms: u64) -> CapabilityInvocation {
180        CapabilityInvocation {
181            task_id: "capability-coverage/external-success".into(),
182            capability_id: "capability://example/word-count-optimizer".into(),
183            capability_version: "1.0.0".into(),
184            input: CapabilityInput::ShellCommand {
185                command: "hello world foo".into(),
186                workdir: None,
187            },
188            policy_constraints: PolicyConstraints::default(),
189            timeout_ms,
190        }
191    }
192
193    fn write_manifest(temp: &tempfile::TempDir) -> std::path::PathBuf {
194        let path = temp.path().join("word-count-manifest.json");
195        fs::write(&path, EXTERNAL_MANIFEST).expect("external manifest fixture");
196        path
197    }
198
199    fn stdin_consuming_adapter(temp: &tempfile::TempDir) -> ExternalProcessAdapter {
200        // Keep the child alive until the host closes stdin; an output-only
201        // fixture can exit before `write_all` and race into EPIPE on Linux.
202        ExternalProcessAdapter::discover(
203            write_manifest(temp),
204            "/bin/sh",
205            [
206                OsString::from("-c"),
207                OsString::from(
208                    "while IFS= read -r _; do :; done; \
209                     printf '%s' '{\"word_count\":3,\"char_count\":15,\"line_count\":1}'",
210                ),
211            ],
212        )
213        .expect("bounded external adapter")
214    }
215
216    #[test]
217    fn coverage_matrix_proves_registry_native_and_external_safety_behavior() {
218        let temp = tempfile::tempdir().expect("fixture directory");
219        fs::write(
220            temp.path().join("fixture.rs"),
221            "// fixture documentation\nfn main() { let value = 1; }\n",
222        )
223        .expect("native fixture");
224
225        let native = Arc::new(NativeContextAdapter::with_root(temp.path()));
226        let external = Arc::new(stdin_consuming_adapter(&temp));
227        let registry = AdapterRegistry::new();
228        registry
229            .register_arc(native.clone())
230            .expect("native registration");
231        registry
232            .register_arc(external.clone())
233            .expect("external registration");
234
235        let native_id = native.manifest().capability_id.as_str().to_owned();
236        let native_version = native.manifest().version.clone();
237        let external_id = external.manifest().capability_id.as_str().to_owned();
238        let external_version = external.manifest().version.clone();
239        let registered_native = registry
240            .lookup(&native_id, &native_version)
241            .expect("registered native adapter");
242        let registered_external = registry
243            .lookup(&external_id, &external_version)
244            .expect("registered external adapter");
245
246        assert!(registered_native.invoke(native_invocation(&native)).is_ok());
247        // A generous deadline for the success path (process spawn on a loaded
248        // host); the timeout path below keeps its 1 ms deadline.
249        assert!(
250            registered_external
251                .invoke(external_invocation(30_000))
252                .is_ok()
253        );
254
255        let mut rejected_invocation = native_invocation(&native);
256        rejected_invocation.policy_constraints.allowed_paths = vec!["other.rs".into()];
257        let rejected = registered_native
258            .invoke(rejected_invocation)
259            .expect_err("native adapter must enforce path policy");
260        assert!(rejected.to_string().contains("outside policy allowlist"));
261
262        let timeout_adapter = ExternalProcessAdapter::discover(
263            write_manifest(&temp),
264            "/bin/sleep",
265            [OsString::from("1")],
266        )
267        .expect("bounded timeout adapter");
268        let timed_out = timeout_adapter
269            .invoke(external_invocation(1))
270            .expect_err("external process must honor timeout");
271        assert!(timed_out.to_string().contains("exceeded its timeout"));
272
273        external.disable();
274        let disabled = registered_external
275            .invoke(external_invocation(1_000))
276            .expect_err("disabled adapter must remain unavailable through registry");
277        assert!(disabled.to_string().contains("is disabled"));
278        assert_eq!(registry.len(), 2, "disable must not unregister the adapter");
279        assert_eq!(
280            registry
281                .health_check_all()
282                .expect("registry health")
283                .into_iter()
284                .filter(|health| !health.healthy)
285                .count(),
286            1,
287            "only the disabled external adapter is unhealthy"
288        );
289
290        let report = CapabilityCoverageReportV1::new(vec![
291            CapabilityCoverageCaseV1::new(
292                CapabilityCoverageScenario::NativeSuccess,
293                native_id.clone(),
294                native_version.clone(),
295                CapabilityCoverageResult::Succeeded,
296            ),
297            CapabilityCoverageCaseV1::new(
298                CapabilityCoverageScenario::ExternalSuccess,
299                external_id.clone(),
300                external_version.clone(),
301                CapabilityCoverageResult::Succeeded,
302            ),
303            CapabilityCoverageCaseV1::new(
304                CapabilityCoverageScenario::PolicyRejection,
305                native_id,
306                native_version,
307                CapabilityCoverageResult::RejectedByPolicy,
308            ),
309            CapabilityCoverageCaseV1::new(
310                CapabilityCoverageScenario::ExternalTimeout,
311                external_id.clone(),
312                external_version.clone(),
313                CapabilityCoverageResult::TimedOut,
314            ),
315            CapabilityCoverageCaseV1::new(
316                CapabilityCoverageScenario::ExternalDisabled,
317                external_id,
318                external_version,
319                CapabilityCoverageResult::Disabled,
320            ),
321        ]);
322
323        assert!(report.is_complete());
324        let json = report.to_json().expect("serializable coverage evidence");
325        assert_eq!(json, report.to_json().expect("deterministic report"));
326        assert_eq!(
327            serde_json::from_str::<CapabilityCoverageReportV1>(&json)
328                .expect("machine-readable report"),
329            report
330        );
331    }
332}