use std::path::Path;
use crate::ontology::rdf::{iri, ont, Graph, Term, PROV_ENTITY, RDF_TYPE};
#[must_use]
pub fn extract(contract_dir: &Path) -> Graph {
let mut g = Graph::new();
for (stem, rel, doc) in documents(contract_dir) {
extract_one(&mut g, &stem, &rel, &doc);
}
g
}
#[must_use]
pub fn documents(contract_dir: &Path) -> Vec<(String, String, serde_yaml::Value)> {
let sigma_path = contract_dir.join("ontology.yaml");
let mut files = Vec::new();
crate::lint::collect_yaml_files(contract_dir, &mut files);
files.sort();
let mut out = Vec::new();
for file in &files {
if file == &sigma_path {
continue;
}
let Ok(raw) = std::fs::read_to_string(file) else {
continue;
};
let Ok(doc) = serde_yaml::from_str::<serde_yaml::Value>(&raw) else {
continue;
};
let stem = file
.file_stem()
.and_then(|s| s.to_str())
.unwrap_or_default()
.to_string();
let rel = file
.strip_prefix(contract_dir.parent().unwrap_or(contract_dir))
.unwrap_or(file)
.to_string_lossy()
.replace('\\', "/");
out.push((stem, rel, doc));
}
out
}
pub fn extract_one(g: &mut Graph, stem: &str, file: &str, doc: &serde_yaml::Value) {
let s = iri("contract", stem);
g.insert(s.clone(), RDF_TYPE, Term::iri(ont("Contract")));
g.insert(s.clone(), RDF_TYPE, Term::iri(PROV_ENTITY));
g.insert(s.clone(), ont("id"), Term::string(stem));
g.insert(s.clone(), ont("file"), Term::string(file));
for (key, pred) in [
("name", "name"),
("version", "version"),
("status", "status"),
] {
if let Some(v) = scalar(doc.get(key)) {
g.insert(s.clone(), ont(pred), Term::string(v));
}
}
let kind = scalar(doc.get("metadata").and_then(|m| m.get("kind")));
if let Some(kind) = &kind {
g.insert(s.clone(), ont("kind"), Term::string(kind.as_str()));
}
if kind.as_deref().is_none_or(|k| k == "kernel") {
g.insert(s.clone(), RDF_TYPE, Term::iri(ont("Kernel")));
}
if let Some(level) = scalar(doc.get("evidence").and_then(|e| e.get("level"))) {
g.insert(s.clone(), ont("evidenceLevel"), Term::string(level));
}
if let Some(entity) = doc.get("entity") {
let entity_type = scalar(entity.get("type"));
if let Some(t) = entity_type.clone() {
g.insert(s.clone(), RDF_TYPE, Term::iri(entity_class(&t)));
g.insert(s.clone(), ont("entityType"), Term::string(t));
}
if let Some(r) = scalar(entity.get("ref")) {
g.insert(s.clone(), ont("entityRef"), Term::string(r));
}
emit_entity_properties(g, &s, entity_type.as_deref(), entity);
}
if let Some(rel) = doc.get("relations").and_then(serde_yaml::Value::as_mapping) {
for (role, targets) in rel {
let (Some(role), Some(list)) = (role.as_str(), targets.as_sequence()) else {
continue;
};
for t in list.iter().filter_map(serde_yaml::Value::as_str) {
let t = t.trim();
let t = t.strip_prefix("contracts/").unwrap_or(t);
let t = t.strip_suffix(".yaml").unwrap_or(t);
g.insert(s.clone(), ont(role), Term::iri(iri("contract", t)));
}
}
}
}
#[must_use]
pub fn positive_control() -> bool {
const STEM: &str = "__pc_extract__";
let Ok(doc) = serde_yaml::from_str::<serde_yaml::Value>(
"metadata: {kind: kernel}\nrelations: {depends_on: [contracts/__pc_target__-v1.yaml]}\nentity: {type: __pc__}\n",
) else {
return false;
};
let s = iri("contract", STEM);
let mut g = Graph::new();
extract_one(&mut g, STEM, "contracts/__pc_extract__.yaml", &doc);
let kind = g
.objects(&s, &ont("kind"))
.iter()
.any(|t| t.as_literal().is_some_and(|l| l.0 == "kernel"));
let edge = g
.objects(&s, &ont("depends_on"))
.iter()
.any(|t| t.as_iri() == Some(iri("contract", "__pc_target__-v1").as_str()));
let class = g
.objects(&s, RDF_TYPE)
.iter()
.any(|t| t.as_iri() == Some(entity_class("__pc__").as_str()));
let mut planted = doc;
if let Some(m) = planted.as_mapping_mut() {
m.remove("metadata");
m.remove("entity");
}
let mut g2 = Graph::new();
extract_one(&mut g2, STEM, "contracts/__pc_extract__.yaml", &planted);
let class_absent = !g2
.objects(&s, RDF_TYPE)
.iter()
.any(|t| t.as_iri() == Some(entity_class("__pc__").as_str()));
kind && edge && class && class_absent && g2.objects(&s, &ont("kind")).is_empty()
}
fn emit_entity_properties(
g: &mut Graph,
subject: &str,
entity_type: Option<&str>,
entity: &serde_yaml::Value,
) {
let (Some(ty), Some(props)) = (
entity_type,
entity
.get("properties")
.and_then(serde_yaml::Value::as_mapping),
) else {
return;
};
for (key, value) in props {
let (Some(key), Some(v)) = (key.as_str(), scalar(Some(value))) else {
continue;
};
g.insert(
subject.to_string(),
entity_predicate(ty, key),
Term::string(v),
);
}
}
#[must_use]
pub fn entity_predicate(entity_type: &str, key: &str) -> String {
format!("{}{entity_type}/{key}", crate::ontology::rdf::ONT_BASE)
}
#[must_use]
pub fn entity_class(entity_type: &str) -> String {
format!("{}entity/{entity_type}", crate::ontology::rdf::ONT_BASE)
}
pub(crate) fn scalar(v: Option<&serde_yaml::Value>) -> Option<String> {
match v? {
serde_yaml::Value::String(s) => Some(s.clone()),
serde_yaml::Value::Number(n) => Some(n.to_string()),
serde_yaml::Value::Bool(b) => Some(b.to_string()),
_ => None,
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_contract_becomes_a_typed_subject_with_what_it_says_and_nothing_more() {
let doc: serde_yaml::Value = serde_yaml::from_str(
"name: n\nversion: '1.2.0'\nstatus: active\nmetadata:\n kind: pattern\nrelations:\n depends_on: [b, contracts/c.yaml]\nentity:\n type: pv-contract\n ref: contracts/\n",
)
.unwrap();
let mut g = Graph::new();
extract_one(&mut g, "a", "contracts/a.yaml", &doc);
let s = iri("contract", "a");
assert_eq!(g.instances_of(&ont("Contract")), vec![s.as_str()]);
assert_eq!(g.objects(&s, &ont("id"))[0].as_literal().unwrap().0, "a");
assert_eq!(
g.objects(&s, &ont("kind"))[0].as_literal().unwrap().0,
"pattern"
);
assert_eq!(g.objects(&s, &ont("depends_on")).len(), 2);
assert_eq!(
g.objects(&s, &ont("depends_on"))[1].as_iri().unwrap(),
iri("contract", "c")
);
assert_eq!(g.objects(&s, &ont("entityType")).len(), 1);
assert!(g.objects(&s, &ont("evidenceLevel")).is_empty());
assert!(!g.to_ntriples().contains("_:"));
}
#[test]
fn only_an_absent_or_kernel_kind_is_typed_ont_kernel() {
let typed = |yaml: &str| {
let doc: serde_yaml::Value = serde_yaml::from_str(yaml).expect("yaml");
let mut g = Graph::new();
extract_one(&mut g, "a", "contracts/a.yaml", &doc);
g.instances_of(&ont("Kernel")).len()
};
assert_eq!(typed("name: n\n"), 1);
assert_eq!(typed("metadata:\n kind: kernel\n"), 1);
assert_eq!(typed("metadata:\n kind: pattern\n"), 0);
}
#[test]
fn the_predicate_is_the_expansion_of_the_prefixed_form() {
assert_eq!(
entity_predicate("study", "scale"),
crate::ontology::shapes::expand("study:scale")
);
assert_eq!(
entity_predicate("study", "scale"),
"https://ont.paiml.dev/v1alpha1/study/scale"
);
assert_ne!(entity_predicate("study", "scale"), ont("scale"));
}
#[test]
fn an_entitys_own_properties_become_typed_predicates_on_the_contract_node() {
let doc: serde_yaml::Value = serde_yaml::from_str(
"entity:\n type: study\n ref: study-shape-v1\n properties:\n scale: linear\n vintage: '2026-09-12'\n bins: 7\n logged: true\n nested: {a: 1}\n listed: [a, b]\n",
)
.expect("yaml");
let mut g = Graph::new();
extract_one(
&mut g,
"study-shape-v1",
"contracts/study-shape-v1.yaml",
&doc,
);
let s = iri("contract", "study-shape-v1");
let p = |k: &str| crate::ontology::shapes::expand(&format!("study:{k}"));
assert_eq!(
g.objects(&s, &p("scale"))[0]
.as_literal()
.expect("literal")
.0,
"linear"
);
assert_eq!(
g.objects(&s, &p("vintage"))[0]
.as_literal()
.expect("literal")
.0,
"2026-09-12"
);
assert_eq!(
g.objects(&s, &p("bins"))[0]
.as_literal()
.expect("literal")
.0,
"7"
);
assert_eq!(
g.objects(&s, &p("logged"))[0]
.as_literal()
.expect("literal")
.0,
"true"
);
assert!(g.objects(&s, &p("nested")).is_empty());
assert!(g.objects(&s, &p("listed")).is_empty());
assert!(g.objects(&s, &ont("scale")).is_empty());
}
#[test]
fn properties_without_an_entity_type_are_not_guessed_into_a_namespace() {
let doc: serde_yaml::Value =
serde_yaml::from_str("entity:\n ref: x\n properties:\n scale: linear\n")
.expect("yaml");
let mut g = Graph::new();
extract_one(&mut g, "a", "contracts/a.yaml", &doc);
let s = iri("contract", "a");
assert!(
g.predicates_of(&s).iter().all(|p| !p.ends_with("scale")),
"{:?}",
g.to_ntriples()
);
}
#[test]
fn the_entity_class_is_the_expansion_of_the_prefixed_form() {
use crate::ontology::shapes::expand;
assert_eq!(entity_class("study"), expand("entity:study"));
assert_eq!(entity_class("study"), expand("ont:entity/study"));
assert_eq!(
entity_class("pv-contract"),
"https://ont.paiml.dev/v1alpha1/entity/pv-contract"
);
assert_ne!(entity_class("study"), ont("Contract"));
for t in ["a b", "x/y", "q?r#s", "é", "100%", "a:b"] {
assert_eq!(
entity_class(t),
expand(&format!("entity:{t}")),
"entity type {t:?}"
);
}
}
#[test]
fn a_contract_is_an_instance_of_its_entity_type_class_and_of_no_other() {
let mut g = Graph::new();
for (stem, ty) in [("s", "study"), ("c1", "claim"), ("c2", "claim")] {
let doc: serde_yaml::Value =
serde_yaml::from_str(&format!("entity:\n type: {ty}\n")).expect("yaml");
extract_one(&mut g, stem, &format!("contracts/{stem}.yaml"), &doc);
}
let none: serde_yaml::Value = serde_yaml::from_str("name: n\n").expect("yaml");
extract_one(&mut g, "bare", "contracts/bare.yaml", &none);
let at = |t: &str| crate::ontology::shapes::instances_closed(&g, &entity_class(t));
assert_eq!(at("study"), vec![iri("contract", "s")]);
assert_eq!(
at("claim"),
vec![iri("contract", "c1"), iri("contract", "c2")]
);
assert_eq!(g.instances_of(&ont("Contract")).len(), 4);
let bare = iri("contract", "bare");
assert!(
g.objects(&bare, RDF_TYPE)
.iter()
.filter_map(|t| t.as_iri())
.all(|t| !t.contains("/v1alpha1/entity/")),
"{}",
g.to_ntriples()
);
}
#[test]
fn two_extractions_of_a_fixture_corpus_are_byte_identical() {
let dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
.join("../../tests/fixtures/ont/relations-ok");
let a = extract(&dir).to_ntriples();
let b = extract(&dir).to_ntriples();
assert_eq!(a, b);
assert!(a.contains("/contract/a> <https://ont.paiml.dev/v1alpha1/contradicts> <https://ont.paiml.dev/v1alpha1/contract/d>"), "{a}");
}
#[test]
fn the_positive_control_fires() {
assert!(positive_control());
}
}