provable_contracts/ontology/extract/
lean.rs1use std::collections::{BTreeMap, BTreeSet};
21use std::path::{Path, PathBuf};
22
23use crate::ontology::rdf::{iri, ont, Graph, Term, PROV_ENTITY, RDF_TYPE};
24use crate::proof_status::{camel_case, first_camel_word, LEAN_THEOREM_BASES};
25
26#[must_use]
28pub fn lean(name: &str) -> String {
29 ont(&format!("lean/{name}"))
30}
31
32#[derive(Debug, Clone, PartialEq, Eq)]
34pub struct Statement {
35 pub domain: String,
36 pub stem: String,
37 pub name: String,
38 pub file: String,
40 pub sorry_free: bool,
41}
42
43impl Statement {
44 #[must_use]
46 pub fn module(&self) -> String {
47 format!("ProvableContracts.Theorems.{}.{}", self.domain, self.stem)
48 }
49
50 #[must_use]
53 pub fn accepted_names(&self) -> BTreeSet<String> {
54 let mut names = BTreeSet::new();
55 for label in [self.domain.as_str(), self.stem.as_str()] {
56 names.insert(format!("Theorems.{label}"));
57 names.insert(label.to_string());
58 names.insert(label.to_lowercase());
59 }
60 let camel = camel_case(&self.name);
61 names.insert(format!("Theorems.{camel}"));
62 names.insert(camel.clone());
63 let first = first_camel_word(&camel);
64 if first.len() >= 3 {
65 names.insert(format!("Theorems.{first}"));
66 names.insert(first);
67 }
68 names
69 }
70}
71
72#[derive(Debug, Clone, Default, PartialEq, Eq)]
74pub struct LeanStats {
75 pub base: Option<String>,
77 pub files: usize,
78 pub statements: usize,
79 pub sorry_free: usize,
80 pub modeled: usize,
82 pub refs_unresolved: Vec<(String, String)>,
84}
85
86#[must_use]
88pub fn base_under(root: &Path) -> Option<PathBuf> {
89 LEAN_THEOREM_BASES
90 .iter()
91 .map(|b| root.join(b))
92 .find(|p| p.join("ProvableContracts/Theorems").is_dir())
93}
94
95#[must_use]
97pub fn theorems_in(content: &str) -> Vec<String> {
98 let mut out = Vec::new();
99 for line in content.lines() {
100 let Some(pos) = line.find("theorem ") else {
101 continue;
102 };
103 let name: String = line[pos + 8..]
104 .chars()
105 .take_while(|c| c.is_alphanumeric() || *c == '_')
106 .collect();
107 if !name.is_empty() {
108 out.push(name);
109 }
110 }
111 out
112}
113
114#[must_use]
116pub fn statements_of(domain: &str, stem: &str, rel: &str, content: &str) -> Vec<Statement> {
117 let sorry_free = !content.contains("sorry");
118 theorems_in(content)
119 .into_iter()
120 .map(|name| Statement {
121 domain: domain.to_string(),
122 stem: stem.to_string(),
123 name,
124 file: rel.to_string(),
125 sorry_free,
126 })
127 .collect()
128}
129
130fn walk(root: &Path, base: &Path, stats: &mut LeanStats) -> Vec<Statement> {
132 let theorems = base.join("ProvableContracts/Theorems");
133 let mut domains: Vec<PathBuf> = std::fs::read_dir(&theorems)
134 .map(|rd| {
135 rd.flatten()
136 .map(|e| e.path())
137 .filter(|p| p.is_dir())
138 .collect()
139 })
140 .unwrap_or_default();
141 domains.sort();
142 let mut out = Vec::new();
143 for domain_dir in domains {
144 let domain = domain_dir
145 .file_name()
146 .unwrap_or_default()
147 .to_string_lossy()
148 .to_string();
149 let mut files: Vec<PathBuf> = std::fs::read_dir(&domain_dir)
150 .map(|rd| {
151 rd.flatten()
152 .map(|e| e.path())
153 .filter(|p| p.extension().is_some_and(|e| e == "lean"))
154 .collect()
155 })
156 .unwrap_or_default();
157 files.sort();
158 for file in files {
159 let Ok(content) = std::fs::read_to_string(&file) else {
160 continue;
161 };
162 stats.files += 1;
163 let stem = file
164 .file_stem()
165 .unwrap_or_default()
166 .to_string_lossy()
167 .to_string();
168 let rel = file
169 .strip_prefix(root)
170 .unwrap_or(&file)
171 .to_string_lossy()
172 .replace('\\', "/");
173 out.extend(statements_of(&domain, &stem, &rel, &content));
174 }
175 }
176 out
177}
178
179#[must_use]
182pub fn references_of(doc: &serde_yaml::Value) -> Vec<String> {
183 let mut refs = Vec::new();
184 let mut push = |v: Option<&serde_yaml::Value>| {
185 if let Some(s) = v.and_then(serde_yaml::Value::as_str) {
186 let s = s.trim().trim_matches('"');
187 if !s.is_empty() && s != "none" && !s.starts_with("none ") {
188 refs.push(s.to_string());
189 }
190 }
191 };
192 if let Some(eqs) = doc.get("equations").and_then(serde_yaml::Value::as_mapping) {
193 for (_, eq) in eqs {
194 push(eq.get("lean_theorem"));
195 }
196 }
197 if let Some(obs) = doc
198 .get("proof_obligations")
199 .and_then(serde_yaml::Value::as_sequence)
200 {
201 for ob in obs {
202 push(ob.get("lean_theorem"));
203 }
204 }
205 refs.sort();
206 refs.dedup();
207 refs
208}
209
210#[must_use]
213pub fn reference_matches(reference: &str, accepted: &BTreeSet<String>) -> bool {
214 accepted.contains(reference)
215 || accepted.contains(reference.strip_prefix("Theorems.").unwrap_or(reference))
216 || accepted.contains(&reference.to_lowercase())
217}
218
219#[must_use]
221pub fn statement_iri(s: &Statement) -> String {
222 iri("world", &format!("{}.{}.{}", s.domain, s.stem, s.name))
223}
224
225pub fn emit(g: &mut Graph, s: &Statement, model_of: &[String]) {
227 let n = statement_iri(s);
228 g.insert(n.clone(), RDF_TYPE, Term::iri(ont("Statement")));
229 g.insert(n.clone(), RDF_TYPE, Term::iri(PROV_ENTITY));
230 g.insert(n.clone(), lean("name"), Term::string(&s.name));
231 g.insert(n.clone(), lean("domain"), Term::string(&s.domain));
232 g.insert(n.clone(), lean("file"), Term::string(&s.file));
233 g.insert(n.clone(), lean("module"), Term::string(s.module()));
234 g.insert(n.clone(), lean("sorryFree"), Term::boolean(s.sorry_free));
235 g.insert(
236 n.clone(),
237 lean("discharge"),
238 Term::string(if s.sorry_free { "grounded" } else { "admitted" }),
239 );
240 for stem in model_of {
241 g.insert(n.clone(), lean("modelOf"), Term::iri(iri("contract", stem)));
242 }
243}
244
245pub fn extract(contract_dir: &Path, g: &mut Graph) -> LeanStats {
247 let root_buf = super::repo_root(contract_dir);
248 let root = root_buf.as_path();
249 let mut stats = LeanStats::default();
250 let Some(base) = base_under(root) else {
251 return stats;
252 };
253 stats.base = Some(
254 base.strip_prefix(root)
255 .unwrap_or(&base)
256 .to_string_lossy()
257 .replace('\\', "/"),
258 );
259 let statements = walk(root, &base, &mut stats);
260 let accepted: Vec<BTreeSet<String>> =
261 statements.iter().map(Statement::accepted_names).collect();
262 let mut model_of: BTreeMap<usize, BTreeSet<String>> = BTreeMap::new();
264 for (stem, _rel, doc) in super::pv_contract::documents(contract_dir) {
265 for reference in references_of(&doc) {
266 let mut hit = false;
267 for (i, names) in accepted.iter().enumerate() {
268 if reference_matches(&reference, names) {
269 model_of.entry(i).or_default().insert(stem.clone());
270 hit = true;
271 }
272 }
273 if !hit {
274 stats.refs_unresolved.push((stem.clone(), reference));
275 }
276 }
277 }
278 for (i, s) in statements.iter().enumerate() {
279 let contracts: Vec<String> = model_of
280 .get(&i)
281 .map(|set| set.iter().cloned().collect())
282 .unwrap_or_default();
283 stats.statements += 1;
284 if s.sorry_free {
285 stats.sorry_free += 1;
286 }
287 if !contracts.is_empty() {
288 stats.modeled += 1;
289 }
290 emit(g, s, &contracts);
291 }
292 stats
293}
294
295#[must_use]
299pub fn positive_control() -> bool {
300 let admitted = statements_of(
301 "Softmax",
302 "Core",
303 "x.lean",
304 "theorem softmax_sums : True := by sorry\n",
305 );
306 let grounded = statements_of(
307 "Softmax",
308 "Core",
309 "x.lean",
310 "theorem softmax_sums : True := trivial\n",
311 );
312 let (Some(a), Some(g)) = (admitted.first(), grounded.first()) else {
313 return false;
314 };
315 let names = g.accepted_names();
316 !a.sorry_free
317 && g.sorry_free
318 && reference_matches("Theorems.Softmax", &names)
319 && reference_matches("Theorems.SoftmaxSums", &names)
320 && !reference_matches("Theorems.NothingOfTheSort", &names)
321}
322
323#[cfg(test)]
324mod tests {
325 use super::*;
326
327 #[test]
328 fn theorems_and_sorry_are_read_as_ont_2a_reads_them() {
329 let src = "namespace X\ntheorem relu_nonneg (x : Float) : True := trivial\n theorem two_words : True := by\n sorry\n";
330 let st = statements_of("Relu", "Core", "lean/Relu/Core.lean", src);
331 assert_eq!(st.len(), 2);
332 assert_eq!(st[0].name, "relu_nonneg");
333 assert!(
334 !st[0].sorry_free,
335 "a file with sorry grounds nothing, whatever line the sorry is on"
336 );
337 let names = st[0].accepted_names();
338 for n in [
339 "Theorems.Relu",
340 "Relu",
341 "relu",
342 "Theorems.Core",
343 "Theorems.ReluNonneg",
344 "ReluNonneg",
345 ] {
346 assert!(names.contains(n), "{n} missing from {names:?}");
347 }
348 assert!(reference_matches("Theorems.ReluNonneg", &names));
349 assert!(reference_matches("relu", &names));
350 assert!(!reference_matches("Theorems.Gelu", &names));
351 }
352
353 #[test]
354 fn references_come_from_equations_and_obligations_and_none_is_not_one() {
355 let doc: serde_yaml::Value = serde_yaml::from_str(
356 "equations:\n a:\n lean_theorem: Theorems.A\n b:\n lean_theorem: none — L4 not declared\nproof_obligations:\n - lean_theorem: \"Theorems.B\"\n - lean_theorem: none\n - id: x\n",
357 )
358 .expect("yaml");
359 assert_eq!(
360 references_of(&doc),
361 vec!["Theorems.A".to_string(), "Theorems.B".to_string()]
362 );
363 }
364
365 #[test]
366 fn the_positive_control_fires() {
367 assert!(positive_control());
368 }
369
370 #[test]
371 fn a_tree_without_a_lean_base_yields_no_triples_and_says_so() {
372 let dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
373 .join("../../tests/fixtures/ont/relations-ok");
374 let mut g = Graph::new();
375 let stats = extract(&dir, &mut g);
376 assert_eq!(stats.base, None);
377 assert_eq!(stats.statements, 0);
378 assert!(g.is_empty());
379 }
380
381 #[test]
382 fn the_real_tree_is_extracted_deterministically_with_model_of_edges() {
383 let dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../contracts");
385 let mut g = Graph::new();
386 let stats = extract(&dir, &mut g);
387 assert_eq!(
388 stats.base.as_deref(),
389 Some("crates/aprender-contracts-staging/lean")
390 );
391 assert!(stats.statements > 100, "{stats:?}");
392 assert!(stats.modeled > 0, "{stats:?}");
393 let nt = g.to_ntriples();
394 assert!(nt.contains("/world/"), "{}", &nt[..200]);
395 assert!(nt.contains("lean/modelOf> <https://ont.paiml.dev/v1alpha1/contract/"));
396 assert!(!nt.contains("_:"));
397 let mut g2 = Graph::new();
398 extract(&dir, &mut g2);
399 assert_eq!(nt, g2.to_ntriples());
400 }
401}