Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
cargo test -p aprender-contracts-cli --test ont_entity_properties
180 changes: 180 additions & 0 deletions crates/aprender-contracts-cli/tests/ont_entity_properties.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,180 @@
//! PMAT-3529 — the two defects apex measured on `pv 0.68.1`, on the CLI.
//!
//! 1. Σ carries the CONTRACT schema's `metadata:` block. `pv validate` requires `metadata.{version,
//! description}` on a contract and `contracts/ontology.yaml` IS a contract, so before this one file could
//! not satisfy both readers: `pv validate` rc 0 and `pv lint --gate sigma` exit 3 over the same bytes.
//! 2. `entity.properties.<k>` becomes `<entityType>:<k>` on the contract node, so a `shape:` can constrain the
//! entity's own properties — apex's nine pre-registered degrees of freedom, which live in the contract
//! because the contract IS the pre-registration (APEX-001 EV-6 hashes it).
//!
//! The three fixtures are apex's EV-21 falsifiers: conforming, a value outside `in:`, and a tenth key under
//! `closed: true`. DISCRIMINATION: `entity-props-ok/` must PASS (a build that emits no property triples fails
//! it on `minCount`), and the two others must FAIL naming the PROPERTY (a build that emits them under `ont:`
//! or drops the path from the message fails those).

use std::path::{Path, PathBuf};
use std::process::Command;

fn pv_bin() -> PathBuf {
PathBuf::from(env!("CARGO_BIN_EXE_pv"))
}

struct Run {
code: i32,
stdout: String,
stderr: String,
}

fn pv_in(cwd: &Path, args: &[&str]) -> Run {
let out = Command::new(pv_bin())
.current_dir(cwd)
.args(args)
.output()
.expect("failed to spawn pv");
Run {
code: out.status.code().unwrap_or(-1),
stdout: String::from_utf8_lossy(&out.stdout).into_owned(),
stderr: String::from_utf8_lossy(&out.stderr).into_owned(),
}
}

fn show(r: &Run) -> String {
format!(
"exit {}\n--- stdout\n{}\n--- stderr\n{}",
r.code, r.stdout, r.stderr
)
}

fn fixture(name: &str) -> PathBuf {
Path::new(env!("CARGO_MANIFEST_DIR"))
.join("../../tests/fixtures/ont")
.join(name)
}

fn json_of(r: &Run) -> serde_json::Value {
serde_json::from_str(&r.stdout).unwrap_or_else(|e| panic!("stdout is JSON: {e}\n{}", show(r)))
}

fn gate(name: &str, which: &str) -> Run {
pv_in(
&fixture(name),
&["lint", "contracts", "--gate", which, "--format", "json"],
)
}

fn messages(v: &serde_json::Value) -> Vec<String> {
v["findings"]
.as_array()
.map(|a| {
a.iter()
.filter_map(|f| f["message"].as_str().map(ToString::to_string))
.collect()
})
.unwrap_or_default()
}

#[test]
fn the_sigma_gate_accepts_the_contract_schemas_metadata_block_that_validate_requires() {
// The same file, both readers: this is the contradiction the ticket exists to end.
let dir = fixture("entity-props-ok");
let sigma = gate("entity-props-ok", "sigma");
assert_eq!(
sigma.code,
0,
"the sigma gate reads Σ with metadata\n{}",
show(&sigma)
);
let validate = pv_in(&dir, &["validate", "contracts/ontology.yaml"]);
assert_eq!(
validate.code,
0,
"pv validate accepts the same file\n{}",
show(&validate)
);
}

#[test]
fn an_unclaimed_metadata_block_is_still_refused_by_name() {
// The anti-decoration rule is answered, not weakened: drop the `readers` entry and Σ is malformed again.
let scratch = tempfile::tempdir().expect("scratch");
let src = fixture("entity-props-ok").join("contracts");
let dst = scratch.path().join("contracts");
std::fs::create_dir_all(&dst).expect("mkdir");
for entry in std::fs::read_dir(&src).expect("read fixture").flatten() {
let name = entry.file_name();
let text = std::fs::read_to_string(entry.path()).expect("read");
let text = if name == "ontology.yaml" {
text.replace(
" metadata: schema/types.rs (the contract schema, not Σ)\n",
"",
)
} else {
text
};
std::fs::write(dst.join(name), text).expect("write");
}
let r = pv_in(
scratch.path(),
&["lint", "contracts", "--gate", "sigma", "--format", "json"],
);
assert_eq!(r.code, 3, "a Σ key nothing claims is exit 3\n{}", show(&r));
assert!(
r.stderr.contains("metadata") && r.stderr.contains("no reader"),
"the refusal names the key\n{}",
show(&r)
);
}

#[test]
fn a_conforming_study_contract_passes_with_its_own_properties_as_focus_predicates() {
let r = gate("entity-props-ok", "shapes");
assert_eq!(r.code, 0, "{}", show(&r));
let v = json_of(&r);
assert_eq!(v["verdict"], "Pass", "{}", show(&r));
assert_eq!(v["focus_nodes_n"], 1, "{}", show(&r));
assert_eq!(v["violations"], 0, "{}", show(&r));
}

#[test]
fn a_value_outside_the_in_list_fails_naming_the_property_and_the_value() {
// apex EV-21 FALSIFY-ONT-021.
let r = gate("entity-props-bad-value", "shapes");
assert_eq!(r.code, 1, "{}", show(&r));
let v = json_of(&r);
assert_eq!(v["verdict"], "Fail", "{}", show(&r));
let msgs = messages(&v);
assert!(
msgs.iter().any(|m| m.contains("ont:study/scale")
&& m.contains("quadratic")
&& m.contains("is not one of")),
"the violation names the property and the value: {msgs:?}"
);
}

#[test]
fn a_property_the_shape_does_not_declare_fails_on_closed_naming_it() {
// apex EV-21 FALSIFY-ONT-021b.
let r = gate("entity-props-undeclared-key", "shapes");
assert_eq!(r.code, 1, "{}", show(&r));
let v = json_of(&r);
let msgs = messages(&v);
assert!(
msgs.iter()
.any(|m| m.contains("(closed)") && m.contains("ont:study/subgroup")),
"the violation names the undeclared property: {msgs:?}"
);
}

#[test]
fn the_properties_are_namespaced_by_the_entity_type_and_land_in_the_extraction() {
let dir = fixture("entity-props-ok");
let r = pv_in(&dir, &["extract", "contracts"]);
assert_eq!(r.code, 0, "{}", show(&r));
let nt = std::fs::read_to_string(dir.join("contracts/contracts.nt")).expect("contracts.nt");
assert!(nt.contains("/study/scale> \"linear\""), "{nt}");
assert!(nt.contains("/study/vintage> \"2026-09-12\""), "{nt}");
// Never under ont:, which would let a shape over one entity type constrain another's `scale`.
assert!(!nt.contains("/v1alpha1/scale>"), "{nt}");
let _ = std::fs::remove_file(dir.join("contracts/contracts.nt"));
let _ = std::fs::remove_file(dir.join("contracts/shapes.ttl"));
}
140 changes: 139 additions & 1 deletion crates/aprender-contracts/src/ontology/extract/pv_contract.rs
Original file line number Diff line number Diff line change
Expand Up @@ -71,12 +71,14 @@ pub fn extract_one(g: &mut Graph, stem: &str, file: &str, doc: &serde_yaml::Valu
g.insert(s.clone(), ont("evidenceLevel"), Term::string(level));
}
if let Some(entity) = doc.get("entity") {
if let Some(t) = scalar(entity.get("type")) {
let entity_type = scalar(entity.get("type"));
if let Some(t) = entity_type.clone() {
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 {
Expand All @@ -93,6 +95,58 @@ pub fn extract_one(g: &mut Graph, stem: &str, file: &str, doc: &serde_yaml::Valu
}
}

/// §4.2 `entity: {type, ref, properties}` — the entity's OWN properties, as `<type>:<key>` on the contract node.
///
/// A contract may carry the properties of the thing it is a contract FOR, and for a pre-registration the
/// contract IS the thing: apex's nine researcher degrees of freedom live at `entity.properties` because
/// APEX-001 EV-6 hashes the contract, and moving them to a sidecar document would change what is locked.
/// Before this they were read by nothing, so a `shape:` over them had no predicate to constrain and every
/// `minCount: 1` fired for the wrong reason — a shape failing because the extractor was silent, which is the
/// vacuity R-2 exists to end (measured on apex, 2026-09-19).
///
/// The predicate is namespaced by the ENTITY TYPE (`study:scale`, not `ont:scale`): two entity types may both
/// carry a `scale`, and one predicate for both would let a shape over one constrain the other. A contract with
/// no `entity.type` gets no property triples — there is no namespace to put them in, and inventing one would
/// be an inference. Nested mappings and sequences are not emitted at v1alpha1: the subset has no path
/// expressions, so a predicate no shape could reach is decoration.
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),
);
}
}

/// The predicate IRI for one entity property: `<ONT_BASE><entityType>/<key>`.
///
/// Spelled here rather than left to `expand("<ty>:<key>")` because the equality is the whole rule and two
/// independent readers took the prefixed form for a literal IRI (quorum PMAT-3529, rounds 2 and 3). It IS what
/// `expand` produces — §3.6's prefix rule maps any unregistered `p:name` into `<ONT_BASE>p/name` — and
/// [`tests::the_predicate_is_the_expansion_of_the_prefixed_form`] pins the two together, so a change to either
/// side is a failing test and not a silent divergence.
#[must_use]
pub fn entity_predicate(entity_type: &str, key: &str) -> String {
format!("{}{entity_type}/{key}", crate::ontology::rdf::ONT_BASE)
}

/// A YAML scalar as a string: strings as they are, numbers and booleans by their YAML spelling. Mappings and
/// sequences are not scalars and produce no triple.
fn scalar(v: Option<&serde_yaml::Value>) -> Option<String> {
Expand Down Expand Up @@ -134,6 +188,90 @@ mod tests {
assert!(!g.to_ntriples().contains("_:"));
}

/// The prefixed form and the IRI are the SAME predicate — §3.6's rule maps any unregistered `p:name` into
/// `<ONT_BASE>p/name`. Pinned here because a shape writes `path: study:scale` while the extractor writes the
/// IRI, and a reader who does not know the rule sees two different things (quorum PMAT-3529, rounds 2/3).
#[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"
);
// …and never the bare ont: local, which another entity type's `scale` would share.
assert_ne!(entity_predicate("study", "scale"), ont("scale"));
}

#[test]
fn an_entitys_own_properties_become_typed_predicates_on_the_contract_node() {
// apex EV-21: the nine degrees of freedom live in the contract, because the contract IS the
// pre-registration. `study:scale` &c. so a shape can constrain them by path.
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"
);
// Not emitted at v1alpha1: the subset has no path expressions, so these would be unreachable.
assert!(g.objects(&s, &p("nested")).is_empty());
assert!(g.objects(&s, &p("listed")).is_empty());
// The namespace is the ENTITY TYPE, never ont: — two types may both carry `scale`.
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 two_extractions_of_a_fixture_corpus_are_byte_identical() {
let dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
Expand Down
11 changes: 7 additions & 4 deletions crates/aprender-contracts/src/ontology/shapes.rs
Original file line number Diff line number Diff line change
Expand Up @@ -578,11 +578,14 @@ fn check_lexical(
if let Some(allowed) = &p.r#in {
let ok = allowed.iter().any(|a| a == lexical || expand(a) == lexical);
if !ok {
// The PATH is in the message, not only in the result's `path` field: a reader who gets one line
// ("quadratic is not one of …") cannot act on it without being told which property said it, and a
// shape with nine properties produces nine indistinguishable lines (measured on apex's EV-21).
push(
p.severity,
path,
"in",
format!("{lexical} is not one of {allowed:?}"),
format!("{}: {lexical} is not one of {allowed:?}", short(&p.path)),
);
}
}
Expand All @@ -592,7 +595,7 @@ fn check_lexical(
p.severity,
path,
"pattern",
format!("{lexical:?} does not match /{src}/"),
format!("{}: {lexical:?} does not match /{src}/", short(&p.path)),
);
}
}
Expand All @@ -602,15 +605,15 @@ fn check_lexical(
p.severity,
path,
"minLength",
format!("length {len} is below minLength"),
format!("{}: length {len} is below minLength", short(&p.path)),
);
}
if p.max_length.is_some_and(|m| len > m) {
push(
p.severity,
path,
"maxLength",
format!("length {len} is above maxLength"),
format!("{}: length {len} is above maxLength", short(&p.path)),
);
}
}
Expand Down
Loading
Loading