Skip to main content

provable_contracts/
binding.rs

1//! Binding registry — maps contract equations to implementations.
2//!
3//! A `BindingRegistry` connects kernel contract equations (defined in
4//! YAML) to the actual Rust functions that implement them in a target
5//! crate (e.g. aprender). This enables:
6//!
7//! - **Audit**: `pv audit --binding` reports which obligations have
8//!   implementations and which are gaps.
9//! - **Wired tests**: `pv probar --binding` generates property tests
10//!   that call real functions instead of `unimplemented!()`.
11
12use std::path::Path;
13
14use serde::{Deserialize, Serialize};
15
16use crate::error::ContractError;
17
18/// Top-level binding registry parsed from YAML.
19#[derive(Debug, Clone, Serialize, Deserialize)]
20pub struct BindingRegistry {
21    pub version: String,
22    pub target_crate: String,
23    /// Developer-declared critical path functions (Section 28).
24    /// CD2 completeness = `critical_path` entries with bindings / len.
25    #[serde(default)]
26    pub critical_path: Vec<String>,
27    #[serde(default)]
28    pub bindings: Vec<KernelBinding>,
29}
30
31/// A single binding: one contract equation mapped to one implementation.
32#[derive(Debug, Clone, Serialize, Deserialize)]
33pub struct KernelBinding {
34    /// Contract YAML filename (e.g. "softmax-kernel-v1.yaml").
35    pub contract: String,
36    /// Equation name within the contract (e.g. "softmax").
37    pub equation: String,
38    /// Full Rust module path (e.g. `aprender::nn::functional::softmax`).
39    #[serde(default)]
40    pub module_path: Option<String>,
41    /// Function or method name.
42    #[serde(default)]
43    pub function: Option<String>,
44    /// Full Rust signature string.
45    #[serde(default)]
46    pub signature: Option<String>,
47    /// Implementation status.
48    pub status: ImplStatus,
49    /// Free-form notes.
50    #[serde(default)]
51    pub notes: Option<String>,
52}
53
54/// Implementation status of a binding.
55#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
56#[serde(rename_all = "snake_case")]
57pub enum ImplStatus {
58    /// Fully implemented and ready for use.
59    Implemented,
60    /// Partially implemented with known gaps.
61    Partial,
62    /// Not yet implemented.
63    NotImplemented,
64    /// Planned but not started — skipped by enforcement checks.
65    Pending,
66}
67
68/// Display implementation status as a `snake_case` string
69impl std::fmt::Display for ImplStatus {
70    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
71        let s = match self {
72            Self::Implemented => "implemented",
73            Self::Partial => "partial",
74            Self::NotImplemented => "not_implemented",
75            Self::Pending => "pending",
76        };
77        write!(f, "{s}")
78    }
79}
80
81/// Parse a binding registry YAML file.
82///
83/// # Errors
84///
85/// Returns [`ContractError::Io`] if the file cannot be read,
86/// or [`ContractError::Yaml`] if the YAML is malformed.
87pub fn parse_binding(path: &Path) -> Result<BindingRegistry, ContractError> {
88    let content = std::fs::read_to_string(path)?;
89    parse_binding_str(&content)
90}
91
92/// Parse a binding registry from a YAML string.
93pub fn parse_binding_str(yaml: &str) -> Result<BindingRegistry, ContractError> {
94    let registry: BindingRegistry = serde_yaml::from_str(yaml)?;
95    Ok(registry)
96}
97
98/// Normalize a contract identifier by stripping `.yaml`/`.yml` extension.
99///
100/// Both binding entries (`contract: foo-v1.yaml`) and file stems (`foo-v1`)
101/// are normalized to the bare stem so comparisons work regardless of whether
102/// the caller used a filename or stem.
103pub fn normalize_contract_id(id: &str) -> &str {
104    id.strip_suffix(".yaml")
105        .or_else(|| id.strip_suffix(".yml"))
106        .unwrap_or(id)
107}
108
109impl BindingRegistry {
110    /// Find all bindings matching a contract (normalizes both sides).
111    pub fn bindings_for(&self, contract_id: &str) -> Vec<&KernelBinding> {
112        let needle = normalize_contract_id(contract_id);
113        self.bindings
114            .iter()
115            .filter(|b| normalize_contract_id(&b.contract) == needle)
116            .collect()
117    }
118
119    /// Find a specific binding by contract + equation (normalizes contract).
120    pub fn find_binding(&self, contract_id: &str, equation: &str) -> Option<&KernelBinding> {
121        let needle = normalize_contract_id(contract_id);
122        self.bindings
123            .iter()
124            .find(|b| normalize_contract_id(&b.contract) == needle && b.equation == equation)
125    }
126
127    /// L5 verification: return a copy of this registry in which every binding
128    /// marked `implemented` whose `function` is NOT actually defined in the
129    /// source tree under `source_root` is downgraded to `not_implemented`.
130    ///
131    /// This turns the L5 predicate "all bindings **verified** as implemented"
132    /// (see [`crate::proof_status`]) into a fact instead of a self-declared YAML
133    /// flag: a binding only survives as `implemented` if a real `fn <function>`
134    /// exists in source. Rename or delete the function and the binding is
135    /// downgraded, dropping the contract below L5 — the check is falsifiable.
136    ///
137    /// The source tree is scanned once (all `.rs` files, skipping build/vcs
138    /// dirs) and the resulting function-name set is reused for every binding.
139    #[must_use]
140    pub fn verified(&self, source_root: &Path) -> BindingRegistry {
141        let fn_names = collect_fn_names(source_root);
142        let bindings = self
143            .bindings
144            .iter()
145            .map(|b| {
146                let mut b = b.clone();
147                if b.status == ImplStatus::Implemented && !b.function_defined_in(&fn_names) {
148                    b.status = ImplStatus::NotImplemented;
149                }
150                b
151            })
152            .collect();
153        BindingRegistry {
154            version: self.version.clone(),
155            target_crate: self.target_crate.clone(),
156            critical_path: self.critical_path.clone(),
157            bindings,
158        }
159    }
160}
161
162impl KernelBinding {
163    /// True when this binding's `function` is present in the given set of
164    /// function names discovered in source. A binding with no `function` field
165    /// cannot be verified and returns `false`.
166    #[must_use]
167    pub fn function_defined_in(&self, fn_names: &std::collections::HashSet<String>) -> bool {
168        self.function
169            .as_deref()
170            .is_some_and(|f| fn_names.contains(f))
171    }
172}
173
174/// Collect every Rust function name (`fn <name>`) defined in `.rs` files under
175/// `root`, skipping `target/`, `.git/`, `.lake/`, and `node_modules/`. Used by
176/// [`BindingRegistry::verified`] to check bindings point to real code.
177#[must_use]
178pub fn collect_fn_names(root: &Path) -> std::collections::HashSet<String> {
179    let mut names = std::collections::HashSet::new();
180    let mut stack = vec![root.to_path_buf()];
181    while let Some(dir) = stack.pop() {
182        let Ok(entries) = std::fs::read_dir(&dir) else {
183            continue;
184        };
185        for entry in entries.flatten() {
186            let path = entry.path();
187            if path.is_dir() {
188                let skip = matches!(
189                    path.file_name().and_then(|n| n.to_str()),
190                    Some("target" | ".git" | ".lake" | "node_modules")
191                );
192                if !skip {
193                    stack.push(path);
194                }
195            } else if path.extension().is_some_and(|e| e == "rs") {
196                if let Ok(content) = std::fs::read_to_string(&path) {
197                    extract_fn_names(&content, &mut names);
198                }
199            }
200        }
201    }
202    names
203}
204
205/// Extract `fn <name>` identifiers from a Rust source string into `names`.
206fn extract_fn_names(content: &str, names: &mut std::collections::HashSet<String>) {
207    for line in content.lines() {
208        let mut rest = line;
209        while let Some(pos) = rest.find("fn ") {
210            // Require `fn ` to start a word (preceded by start/space) to avoid
211            // matching identifiers like `my_fn `.
212            let ok_boundary = pos == 0
213                || rest[..pos]
214                    .chars()
215                    .next_back()
216                    .is_some_and(|c| !c.is_alphanumeric() && c != '_');
217            let after = &rest[pos + 3..];
218            if ok_boundary {
219                let name: String = after
220                    .chars()
221                    .take_while(|c| c.is_alphanumeric() || *c == '_')
222                    .collect();
223                if !name.is_empty() {
224                    names.insert(name);
225                }
226            }
227            rest = after;
228        }
229    }
230}
231
232#[cfg(test)]
233mod tests {
234    use super::*;
235
236    #[test]
237    fn parse_minimal_binding() {
238        let yaml = r#"
239version: "1.0.0"
240target_crate: aprender
241bindings: []
242"#;
243        let reg = parse_binding_str(yaml).unwrap();
244        assert_eq!(reg.version, "1.0.0");
245        assert_eq!(reg.target_crate, "aprender");
246        assert!(reg.bindings.is_empty());
247    }
248
249    #[test]
250    fn parse_binding_with_entries() {
251        let yaml = r#"
252version: "1.0.0"
253target_crate: aprender
254bindings:
255  - contract: softmax-kernel-v1.yaml
256    equation: softmax
257    module_path: "aprender::nn::functional::softmax"
258    function: softmax
259    signature: "fn softmax(x: &Tensor, dim: i32) -> Tensor"
260    status: implemented
261  - contract: activation-kernel-v1.yaml
262    equation: silu
263    status: not_implemented
264    notes: "Not yet available"
265"#;
266        let reg = parse_binding_str(yaml).unwrap();
267        assert_eq!(reg.bindings.len(), 2);
268        assert_eq!(reg.bindings[0].equation, "softmax");
269        assert_eq!(reg.bindings[0].status, ImplStatus::Implemented);
270        assert!(reg.bindings[0].module_path.is_some());
271        assert_eq!(reg.bindings[1].equation, "silu");
272        assert_eq!(reg.bindings[1].status, ImplStatus::NotImplemented);
273        assert!(reg.bindings[1].module_path.is_none());
274    }
275
276    #[test]
277    fn parse_partial_status() {
278        let yaml = r#"
279version: "1.0.0"
280target_crate: test
281bindings:
282  - contract: test.yaml
283    equation: f
284    module_path: "test::f"
285    function: f
286    status: partial
287    notes: "Only scalar path"
288"#;
289        let reg = parse_binding_str(yaml).unwrap();
290        assert_eq!(reg.bindings[0].status, ImplStatus::Partial);
291    }
292
293    #[test]
294    fn impl_status_display() {
295        assert_eq!(ImplStatus::Implemented.to_string(), "implemented");
296        assert_eq!(ImplStatus::Partial.to_string(), "partial");
297        assert_eq!(ImplStatus::NotImplemented.to_string(), "not_implemented");
298        assert_eq!(ImplStatus::Pending.to_string(), "pending");
299    }
300
301    #[test]
302    fn parse_invalid_binding_yaml() {
303        let result = parse_binding_str("not: [valid: {{");
304        assert!(result.is_err());
305    }
306
307    #[test]
308    fn parse_binding_from_file() {
309        let path = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
310            .join("../../contracts/aprender/binding.yaml");
311        let reg = parse_binding(&path).unwrap();
312        assert_eq!(reg.target_crate, "aprender");
313        assert!(!reg.bindings.is_empty());
314    }
315
316    #[test]
317    fn parse_binding_nonexistent_file() {
318        let result = parse_binding(std::path::Path::new("/nonexistent/binding.yaml"));
319        assert!(result.is_err());
320    }
321
322    // ── L5 binding-verification feature ──
323
324    #[test]
325    fn extract_fn_names_finds_definitions() {
326        let mut names = std::collections::HashSet::new();
327        extract_fn_names(
328            "pub fn to_anthropic(m: &Message) -> Value {\n  async fn helper() {}\n",
329            &mut names,
330        );
331        assert!(names.contains("to_anthropic"));
332        assert!(names.contains("helper"));
333    }
334
335    #[test]
336    fn extract_fn_names_respects_word_boundary() {
337        let mut names = std::collections::HashSet::new();
338        // `my_fn foo` must NOT register `foo` (the `fn ` is inside `my_fn `).
339        extract_fn_names("let my_fn foo = 1;", &mut names);
340        assert!(!names.contains("foo"));
341    }
342
343    #[test]
344    fn function_defined_in_checks_membership() {
345        let names: std::collections::HashSet<String> =
346            ["to_anthropic".to_string()].into_iter().collect();
347        let bound = KernelBinding {
348            contract: "c-v1.yaml".into(),
349            equation: "e".into(),
350            module_path: None,
351            function: Some("to_anthropic".into()),
352            signature: None,
353            status: ImplStatus::Implemented,
354            notes: None,
355        };
356        assert!(bound.function_defined_in(&names));
357
358        let missing = KernelBinding {
359            function: Some("does_not_exist".into()),
360            ..bound.clone()
361        };
362        assert!(!missing.function_defined_in(&names));
363
364        // No function field → cannot be verified.
365        let no_fn = KernelBinding {
366            function: None,
367            ..bound
368        };
369        assert!(!no_fn.function_defined_in(&names));
370    }
371
372    #[test]
373    fn verified_downgrades_phantom_implemented_bindings() {
374        // A temp source tree with exactly one real function.
375        let dir = std::env::temp_dir().join(format!("bindver_{}", std::process::id()));
376        let _ = std::fs::create_dir_all(&dir);
377        std::fs::write(dir.join("lib.rs"), "pub fn real_one() {}\n").unwrap();
378
379        let reg = BindingRegistry {
380            version: "1.0.0".into(),
381            target_crate: "t".into(),
382            critical_path: vec![],
383            bindings: vec![
384                KernelBinding {
385                    contract: "c-v1.yaml".into(),
386                    equation: "a".into(),
387                    module_path: None,
388                    function: Some("real_one".into()),
389                    signature: None,
390                    status: ImplStatus::Implemented,
391                    notes: None,
392                },
393                KernelBinding {
394                    contract: "c-v1.yaml".into(),
395                    equation: "b".into(),
396                    module_path: None,
397                    function: Some("phantom".into()),
398                    signature: None,
399                    status: ImplStatus::Implemented,
400                    notes: None,
401                },
402            ],
403        };
404
405        let v = reg.verified(&dir);
406        // Real fn stays implemented; phantom is downgraded.
407        assert_eq!(v.bindings[0].status, ImplStatus::Implemented);
408        assert_eq!(v.bindings[1].status, ImplStatus::NotImplemented);
409        let _ = std::fs::remove_dir_all(&dir);
410    }
411}