diff --git a/ci/explicit-test-commands.d/341-aprender-contracts-cli-ont-entity-properties.cmd b/ci/explicit-test-commands.d/341-aprender-contracts-cli-ont-entity-properties.cmd new file mode 100644 index 0000000000..14f09b4f5c --- /dev/null +++ b/ci/explicit-test-commands.d/341-aprender-contracts-cli-ont-entity-properties.cmd @@ -0,0 +1 @@ +cargo test -p aprender-contracts-cli --test ont_entity_properties diff --git a/crates/aprender-contracts-cli/tests/ont_entity_properties.rs b/crates/aprender-contracts-cli/tests/ont_entity_properties.rs new file mode 100644 index 0000000000..35c4f0e09f --- /dev/null +++ b/crates/aprender-contracts-cli/tests/ont_entity_properties.rs @@ -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.` becomes `:` 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 { + 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")); +} diff --git a/crates/aprender-contracts/src/ontology/extract/pv_contract.rs b/crates/aprender-contracts/src/ontology/extract/pv_contract.rs index 72669c2888..93f9d1e935 100644 --- a/crates/aprender-contracts/src/ontology/extract/pv_contract.rs +++ b/crates/aprender-contracts/src/ontology/extract/pv_contract.rs @@ -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 { @@ -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 `:` 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: `/`. +/// +/// Spelled here rather than left to `expand(":")` 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 `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 { @@ -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 + /// `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")) diff --git a/crates/aprender-contracts/src/ontology/shapes.rs b/crates/aprender-contracts/src/ontology/shapes.rs index 5fe6432513..a4c8ea5e39 100644 --- a/crates/aprender-contracts/src/ontology/shapes.rs +++ b/crates/aprender-contracts/src/ontology/shapes.rs @@ -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)), ); } } @@ -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)), ); } } @@ -602,7 +605,7 @@ 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) { @@ -610,7 +613,7 @@ fn check_lexical( p.severity, path, "maxLength", - format!("length {len} is above maxLength"), + format!("{}: length {len} is above maxLength", short(&p.path)), ); } } diff --git a/crates/aprender-contracts/src/ontology/sigma.rs b/crates/aprender-contracts/src/ontology/sigma.rs index 0863db97bd..49cc5123aa 100644 --- a/crates/aprender-contracts/src/ontology/sigma.rs +++ b/crates/aprender-contracts/src/ontology/sigma.rs @@ -45,6 +45,19 @@ pub struct Sigma { /// Which reader claims each Σ key. The anti-decoration rule: a key nobody reads is refused (exit 3). #[serde(default)] pub readers: BTreeMap, + /// The CONTRACT schema's own block, carried here because Σ is a contract too. + /// + /// `pv validate` REQUIRES `metadata.{version, description}` on a contract, and this file is a contract — + /// so a corpus that keeps ONE `contracts/ontology.yaml` for both readers had `pv validate` at rc 0 and + /// `pv lint --gate sigma` at exit 3 over the same bytes (measured on apex 2026-09-19, `pv 0.68.1`: + /// ``unknown field `metadata` … at line 26``). One binary contradicting itself about one key is not a + /// corpus defect, so the schema admits the block. + /// + /// Σ does not INTERPRET it: the value is opaque YAML, no Σ rule reads a field of it, and `readers` must + /// still claim the key like every other — the anti-decoration rule is not weakened, it is answered + /// ("the contract schema reads it, not Σ"). + #[serde(default)] + pub metadata: Option, } #[derive(Debug, Clone, Deserialize, PartialEq, Eq)] @@ -174,7 +187,7 @@ impl fmt::Display for SigmaError { impl std::error::Error for SigmaError {} /// The Σ keys that must be claimed by a reader when they are present and non-empty. -pub const READABLE_KEYS: [&str; 8] = [ +pub const READABLE_KEYS: [&str; 9] = [ "concepts", "roles", "symbols", @@ -183,6 +196,8 @@ pub const READABLE_KEYS: [&str; 8] = [ "entity_types", "extractors", "not_expressible", + // The contract schema owns this one; Σ only carries it (see `Sigma::metadata`). + "metadata", ]; impl Sigma { @@ -289,6 +304,9 @@ impl Sigma { if !self.not_expressible.is_empty() { keys.insert("not_expressible"); } + if self.metadata.is_some() { + keys.insert("metadata"); + } keys } @@ -353,6 +371,39 @@ readers: assert!(s.check_integrity().is_ok(), "{:?}", s.check_integrity()); } + /// Σ is a CONTRACT, and `pv validate` requires `metadata.{version, description}` on a contract. Before this + /// the Σ schema refused the block outright, so one file could not satisfy both readers: apex measured + /// `pv validate` rc 0 and `pv lint --gate sigma` exit 3 over the same `contracts/ontology.yaml` + /// (2026-09-19, pv 0.68.1). The block parses, is opaque to every Σ rule, and still has to be claimed. + #[test] + fn sigma_carries_the_contract_schemas_own_metadata_block_and_reads_nothing_of_it() { + let y = good().replace( + "readers:\n", + "metadata:\n version: 1.0.0\n description: \"apex's entity-type registry\"\n references:\n - 'APEX-001 §5 row EV-19'\nreaders:\n metadata: schema/types.rs (the contract schema, not Σ)\n", + ); + let s = Sigma::from_yaml(&y).expect("Σ with metadata parses"); + assert!(s.metadata.is_some()); + assert!(s.check_integrity().is_ok(), "{:?}", s.check_integrity()); + // Opaque: no Σ rule reads a field of it, and the rest of Σ is unchanged by its presence. + let without = Sigma::from_yaml(good()).expect("Σ parses"); + assert_eq!(s.concepts, without.concepts); + assert_eq!(s.entity_types, without.entity_types); + } + + /// …and the anti-decoration rule is answered, not weakened: an unclaimed `metadata:` is still refused. + #[test] + fn an_unclaimed_metadata_block_is_refused_like_every_other_key() { + let y = good().replace( + "readers:\n", + "metadata:\n version: 1.0.0\n description: d\nreaders:\n", + ); + let s = Sigma::from_yaml(&y).expect("Σ parses"); + match s.check_integrity() { + Err(SigmaError::KeyWithoutReader { key }) => assert_eq!(key, "metadata"), + other => panic!("an unclaimed metadata: must be refused, got {other:?}"), + } + } + #[test] fn an_entity_type_naming_no_extractor_is_malformed() { let y = good().replace( diff --git a/docs/audits/quorum-PMAT-3529.json b/docs/audits/quorum-PMAT-3529.json new file mode 100644 index 0000000000..cd5d9f38e1 --- /dev/null +++ b/docs/audits/quorum-PMAT-3529.json @@ -0,0 +1,214 @@ +{ + "ticket": "PMAT-3529", + "base": "origin/main", + "base_resolved": "origin/main", + "base_note": "no origin/origin/main exists; judged against the local ref", + "head": "e1b93b8c2bf42ea5fe3a800fed7ca3b943bf09da", + "diff_sha256": "a347c04f008c3586f1968f45cc23587a45050392ace20ce9e44e28b12e80765e", + "width": 3, + "executor": "agy", + "prompt_mode": "inline", + "prompt_bytes": 45561, + "author": { + "model": "claude-opus-5", + "family": "claude", + "source": "flag" + }, + "agreed": true, + "lanes": [ + { + "lane": 1, + "status": "SUCCESS", + "verdict": "PASS", + "summary": "The diff accurately matches the specifications in PMAT-3529. The sigma schema parses the opaque `metadata` block without applying its own rules to it, keeping `pv validate` and `pv lint` from contradicting each other. It includes tests verifying that an unclaimed metadata block still exits with a 3. In the pv_contract extractor, entity properties correctly use the `:` prefix to ensure scope isolation between different types, skipping any properties if `entity.type` is missing or the value is a nested mapping/sequence. Furthermore, lexical validation violations (in, pattern, minLength, maxLength) have been explicitly updated to prepend the `path` string on the error messages. All ticket requirements are backed by matching tests and fixtures.", + "findings": [ + { + "claim": "The SAME contracts/ontology.yaml passes pv validate AND pv lint --gate sigma, and removing claim brings exit 3 back naming the key", + "file": "crates/aprender-contracts-cli/tests/ont_entity_properties.rs", + "fix": "", + "grounding": "cited", + "line": 77 + }, + { + "claim": "entity.properties. becomes the predicate : (IRI: ONT_BASE + /) on the contract node", + "file": "crates/aprender-contracts/src/ontology/extract/pv_contract.rs", + "fix": "", + "grounding": "cited", + "line": 146 + }, + { + "claim": "The predicate IRI carries the entity type as its vocabulary segment and NEVER the bare ont:scale", + "file": "crates/aprender-contracts/src/ontology/extract/pv_contract.rs", + "fix": "", + "grounding": "cited", + "line": 204 + }, + { + "claim": "No entity.type means no property triples, and nested mappings/sequences are not emitted", + "file": "crates/aprender-contracts/src/ontology/extract/pv_contract.rs", + "fix": "", + "grounding": "cited", + "line": 268 + }, + { + "claim": "A lexical violation names its PATH in the message (in, pattern, minLength, maxLength)", + "file": "crates/aprender-contracts/src/ontology/shapes.rs", + "fix": "", + "grounding": "cited", + "line": 585 + } + ], + "raw_bytes": 5389, + "err_bytes": 0, + "envelope_status": "SUCCESS", + "verdict_source": "structured_output", + "grounding_check": "parity", + "model": "gemini-3.1-pro-high", + "model_measured": "gemini-3.1-pro-high", + "model_source": "measured", + "family": "gemini" + }, + { + "lane": 2, + "status": "SUCCESS", + "verdict": "PASS", + "summary": "The diff successfully implements all requirements of PMAT-3529. Sigma now carries the metadata block as opaque YAML, requiring it to be claimed in `readers` to satisfy the anti-decoration rule. Entity properties are extracted as `:` scoped correctly to avoid leaking across types, while nested mappings and sequences are safely ignored. Lexical violations (in, pattern, minLength, maxLength) now correctly prepend the property path to their error messages for clarity. All tests and falsifiers from the ticket have been correctly verified.", + "findings": [ + { + "claim": "The SAME contracts/ontology.yaml passes `pv validate` AND `pv lint --gate sigma`", + "file": "crates/aprender-contracts-cli/tests/ont_entity_properties.rs", + "fix": "", + "grounding": "cited", + "line": 77 + }, + { + "claim": "entity.properties. becomes the predicate : (IRI: ONT_BASE + /) on the contract node", + "file": "crates/aprender-contracts/src/ontology/extract/pv_contract.rs", + "fix": "", + "grounding": "cited", + "line": 146 + }, + { + "claim": "The predicate IRI carries the entity type as its vocabulary segment and NEVER the bare ont:scale", + "file": "crates/aprender-contracts/src/ontology/extract/pv_contract.rs", + "fix": "", + "grounding": "cited", + "line": 204 + }, + { + "claim": "No entity.type means no property triples; nested mappings and sequences are not emitted", + "file": "crates/aprender-contracts/src/ontology/extract/pv_contract.rs", + "fix": "", + "grounding": "cited", + "line": 268 + }, + { + "claim": "A lexical violation names its PATH in the message (in, pattern, minLength, maxLength)", + "file": "crates/aprender-contracts/src/ontology/shapes.rs", + "fix": "", + "grounding": "cited", + "line": 585 + } + ], + "raw_bytes": 4857, + "err_bytes": 0, + "envelope_status": "SUCCESS", + "verdict_source": "structured_output", + "grounding_check": "parity", + "model": "gemini-3.1-pro-low", + "model_measured": "gemini-3.1-pro-low", + "model_source": "measured", + "family": "gemini" + }, + { + "lane": 3, + "status": "SUCCESS", + "verdict": "PASS", + "summary": "The diff accurately and completely implements all requirements of ticket PMAT-3529 with zero scope creep, no weakened gates, and fully backed test coverage:\n1. `Sigma` in `crates/aprender-contracts/src/ontology/sigma.rs` supports an optional, opaque `metadata` block and adds `\"metadata\"` to `READABLE_KEYS`. Unclaimed metadata blocks continue to trigger exit 3 (anti-decoration rule preserved).\n2. `emit_entity_properties` in `crates/aprender-contracts/src/ontology/extract/pv_contract.rs` extracts scalar values from `entity.properties.` into `/` triples. When no `entity.type` is present or for complex nested/listed structures, no triples are emitted.\n3. `check_lexical` in `crates/aprender-contracts/src/ontology/shapes.rs` updates error formatting for `in`, `pattern`, `minLength`, and `maxLength` violations to prefix the message with `short(&p.path)`.\n4. CLI integration test `ont_entity_properties.rs`, unit tests, explicit test command, tree reader script list, and EV-21 fixtures validate all specified falsifier scenarios and behavior.", + "findings": [], + "raw_bytes": 3541, + "err_bytes": 0, + "envelope_status": "SUCCESS", + "verdict_source": "structured_output", + "grounding_check": "parity", + "model": "gemini-3.6-flash-low", + "model_measured": "gemini-3.6-flash-low", + "model_source": "measured", + "family": "gemini" + } + ], + "dissent": [], + "dedup": [ + { + "file": "crates/aprender-contracts-cli/tests/ont_entity_properties.rs", + "line": 77, + "lanes_agreeing": [ + 1, + 2 + ], + "claims": [ + "The SAME contracts/ontology.yaml passes `pv validate` AND `pv lint --gate sigma`", + "The SAME contracts/ontology.yaml passes pv validate AND pv lint --gate sigma, and removing claim brings exit 3 back naming the key" + ] + }, + { + "file": "crates/aprender-contracts/src/ontology/extract/pv_contract.rs", + "line": 146, + "lanes_agreeing": [ + 1, + 2 + ], + "claims": [ + "entity.properties. becomes the predicate : (IRI: ONT_BASE + /) on the contract node" + ] + }, + { + "file": "crates/aprender-contracts/src/ontology/extract/pv_contract.rs", + "line": 204, + "lanes_agreeing": [ + 1, + 2 + ], + "claims": [ + "The predicate IRI carries the entity type as its vocabulary segment and NEVER the bare ont:scale" + ] + }, + { + "file": "crates/aprender-contracts/src/ontology/extract/pv_contract.rs", + "line": 268, + "lanes_agreeing": [ + 1, + 2 + ], + "claims": [ + "No entity.type means no property triples, and nested mappings/sequences are not emitted", + "No entity.type means no property triples; nested mappings and sequences are not emitted" + ] + }, + { + "file": "crates/aprender-contracts/src/ontology/shapes.rs", + "line": 585, + "lanes_agreeing": [ + 1, + 2 + ], + "claims": [ + "A lexical violation names its PATH in the message (in, pattern, minLength, maxLength)" + ] + } + ], + "uncovered": [], + "coverage_source": "lanes", + "partial": false, + "partial_reasons": [], + "auto_merge": { + "checked": true, + "was_armed": true, + "disarmed": true, + "note": "auto-merge was armed from an earlier round; disarmed before lanes launched" + }, + "lint": { + "ok": true, + "output": "receipt complete: kind=artifact lanes=3 author=claude-opus-5/claude" + } +} diff --git a/docs/roadmaps/entries/PMAT-3529.yaml b/docs/roadmaps/entries/PMAT-3529.yaml new file mode 100644 index 0000000000..0603569e65 --- /dev/null +++ b/docs/roadmaps/entries/PMAT-3529.yaml @@ -0,0 +1,25 @@ +- id: PMAT-3529 + github_issue: 3529 + item_type: task + title: 'pv contradicts itself about metadata: (validate requires it, the sigma gate refuses it); entity.properties is read by nothing; and a lexical violation does not name its path — Σ accepts an opaque metadata block, extract:pv-contract emits entity.properties. as :, and in/pattern/minLength/maxLength name the property they fired on, with apex''s two EV-21 falsifiers as fixtures' + status: planned + priority: medium + assigned_to: null + created: 2026-09-19T12:20:38Z + updated: 2026-09-19T12:36:52Z + spec: null + acceptance_criteria: + - 'The SAME contracts/ontology.yaml passes `pv validate` AND `pv lint --gate sigma`: Σ carries `metadata` as opaque YAML (no Σ rule reads a field of it), it joins READABLE_KEYS so `readers:` must claim it, and removing that claim brings exit 3 back naming the key — measured by a CLI leg that runs both subcommands on one fixture.' + - 'entity.properties. becomes the predicate : (IRI: ONT_BASE + /) on the contract node, so a shape over the entity''s own properties has focus predicates: apex''s EV-21 falsifiers through the CLI — entity-props-ok Pass, entity-props-bad-value Fail (in) naming the property and the value, entity-props-undeclared-key Fail (closed) naming the undeclared property.' + - 'The predicate IRI carries the entity type as its vocabulary segment: entity.properties.scale of an entity typed `study` is https://ont.paiml.dev/v1alpha1/study/scale — which is what `study:scale` expands to — and NEVER the bare https://ont.paiml.dev/v1alpha1/scale, so a shape over one entity type cannot constrain another type''s `scale`. Both directions asserted in a unit test. (Rendering is unchanged and separate: `short()` prints every ONT_BASE IRI with the `ont:` prefix, so messages read `ont:study/scale`, exactly as they already read `ont:model/parityReceipt`.)' + - 'No entity.type means no property triples — there is no vocabulary segment and inventing one would be an inference; nested mappings and sequences are not emitted at v1alpha1, because the subset has no path expressions and a predicate no shape can reach is decoration. One unit test each.' + - 'A lexical violation names its PATH in the message (in, pattern, minLength, maxLength), not only in the result''s path field. Without it the two falsifiers above cannot name the property at all, and a shape with nine properties emits nine lines a reader cannot tell apart.' + - 'No regression on the real corpus: pv lint contracts --gate sigma Pass, --gate shapes Pass over 1731 focus nodes, pv extract contracts --check 0, aprender-contracts lib green, ont2b_sigma_gate / ont4_relations_gate / ont4b_shapes_gate green, clippy -D warnings clean.' + phases: [] + subtasks: [] + estimated_effort: null + labels: + - kind:code + - orch:fable + - orch-basis:state + notes: null diff --git a/docs/roadmaps/roadmap.yaml b/docs/roadmaps/roadmap.yaml index 0cfce5a915..f7f98eb62a 100644 --- a/docs/roadmaps/roadmap.yaml +++ b/docs/roadmaps/roadmap.yaml @@ -18596,3 +18596,28 @@ roadmap: estimated_effort: null labels: [] notes: null +- id: PMAT-3529 + github_issue: 3529 + item_type: task + title: 'pv contradicts itself about metadata: (validate requires it, the sigma gate refuses it); entity.properties is read by nothing; and a lexical violation does not name its path — Σ accepts an opaque metadata block, extract:pv-contract emits entity.properties. as :, and in/pattern/minLength/maxLength name the property they fired on, with apex''s two EV-21 falsifiers as fixtures' + status: planned + priority: medium + assigned_to: null + created: 2026-09-19T12:20:38Z + updated: 2026-09-19T12:36:52Z + spec: null + acceptance_criteria: + - 'The SAME contracts/ontology.yaml passes `pv validate` AND `pv lint --gate sigma`: Σ carries `metadata` as opaque YAML (no Σ rule reads a field of it), it joins READABLE_KEYS so `readers:` must claim it, and removing that claim brings exit 3 back naming the key — measured by a CLI leg that runs both subcommands on one fixture.' + - 'entity.properties. becomes the predicate : (IRI: ONT_BASE + /) on the contract node, so a shape over the entity''s own properties has focus predicates: apex''s EV-21 falsifiers through the CLI — entity-props-ok Pass, entity-props-bad-value Fail (in) naming the property and the value, entity-props-undeclared-key Fail (closed) naming the undeclared property.' + - 'The predicate IRI carries the entity type as its vocabulary segment: entity.properties.scale of an entity typed `study` is https://ont.paiml.dev/v1alpha1/study/scale — which is what `study:scale` expands to — and NEVER the bare https://ont.paiml.dev/v1alpha1/scale, so a shape over one entity type cannot constrain another type''s `scale`. Both directions asserted in a unit test. (Rendering is unchanged and separate: `short()` prints every ONT_BASE IRI with the `ont:` prefix, so messages read `ont:study/scale`, exactly as they already read `ont:model/parityReceipt`.)' + - 'No entity.type means no property triples — there is no vocabulary segment and inventing one would be an inference; nested mappings and sequences are not emitted at v1alpha1, because the subset has no path expressions and a predicate no shape can reach is decoration. One unit test each.' + - 'A lexical violation names its PATH in the message (in, pattern, minLength, maxLength), not only in the result''s path field. Without it the two falsifiers above cannot name the property at all, and a shape with nine properties emits nine lines a reader cannot tell apart.' + - 'No regression on the real corpus: pv lint contracts --gate sigma Pass, --gate shapes Pass over 1731 focus nodes, pv extract contracts --check 0, aprender-contracts lib green, ont2b_sigma_gate / ont4_relations_gate / ont4b_shapes_gate green, clippy -D warnings clean.' + phases: [] + subtasks: [] + estimated_effort: null + labels: + - kind:code + - orch:fable + - orch-basis:state + notes: null diff --git a/scripts/tree_reader_tests.txt b/scripts/tree_reader_tests.txt index 99c7b42e98..9aa265d068 100644 --- a/scripts/tree_reader_tests.txt +++ b/scripts/tree_reader_tests.txt @@ -64,6 +64,7 @@ aprender-contracts-cli --test ont4_relations_gate aprender-contracts-cli --test ont4b_shapes_gate aprender-contracts-cli --test ont6_lint_verdict aprender-contracts-cli --test ont_extract_json +aprender-contracts-cli --test ont_entity_properties aprender-contracts-cli --test pv_surface_gate aprender-contracts-cli --test pvl_zero_contracts aprender-core --lib format::metadata_bounds_contract_falsify diff --git a/tests/fixtures/ont/entity-props-bad-value/contracts/lint-baseline.json b/tests/fixtures/ont/entity-props-bad-value/contracts/lint-baseline.json new file mode 100644 index 0000000000..f8a66767c5 --- /dev/null +++ b/tests/fixtures/ont/entity-props-bad-value/contracts/lint-baseline.json @@ -0,0 +1,5 @@ +{ + "armed_gates": ["validate"], + "armed_shapes": ["study-shape-v1"], + "ont": {} +} diff --git a/tests/fixtures/ont/entity-props-bad-value/contracts/ontology.yaml b/tests/fixtures/ont/entity-props-bad-value/contracts/ontology.yaml new file mode 100644 index 0000000000..cc88a2273e --- /dev/null +++ b/tests/fixtures/ont/entity-props-bad-value/contracts/ontology.yaml @@ -0,0 +1,21 @@ +schema: ont-sigma-v1 +metadata: + version: "1.0.0" + kind: pattern + created: '2026-09-19' + last_modified: '2026-09-19' + author: fixture + description: "the contract schema's own block, carried by Σ and read by neither Σ rule" + references: + - 'paiml/infra docs/specifications/paiml-ontology.md §4.1 (Σ) — and pv validate, which requires this block' +concepts: + Contract: {doc: a pv contract file} +entity_types: + - {name: study, extractor: pv_contract, implemented: true} +extractors: + - {name: pv_contract, reader: ontology/extract/pv_contract.rs, implemented: true} +readers: + concepts: ontology/sigma.rs + entity_types: lint/sigma_gate.rs + extractors: ontology/sigma.rs + metadata: schema/types.rs (the contract schema, not Σ) diff --git a/tests/fixtures/ont/entity-props-bad-value/contracts/study-shape-v1.yaml b/tests/fixtures/ont/entity-props-bad-value/contracts/study-shape-v1.yaml new file mode 100644 index 0000000000..4aea763937 --- /dev/null +++ b/tests/fixtures/ont/entity-props-bad-value/contracts/study-shape-v1.yaml @@ -0,0 +1,27 @@ +# apex EV-21 (FALSIFY-ONT-021/021b): the researcher degrees of freedom live in the contract, because the +# contract IS the pre-registration. The shape targets ont:Contract and constrains them by path. +name: study-shape +version: "1.0.0" +status: active +metadata: + version: "1.0.0" + kind: pattern + created: '2026-09-19' + last_modified: '2026-09-19' + author: fixture + description: a pre-registered study's closed degrees of freedom +entity: + type: study + ref: study-shape-v1 + properties: + scale: quadratic + denominator: population-100k + vintage: '2026-09-12' +shape: + targetClass: ont:Contract + closed: true + ignoredProperties: [ont:id, ont:file, ont:name, ont:version, ont:status, ont:kind, ont:entityType, ont:entityRef] + properties: + - {path: study:scale, minCount: 1, maxCount: 1, in: [linear, log10, log2, sqrt, logit]} + - {path: study:denominator, minCount: 1, maxCount: 1, in: [population-100k, count]} + - {path: study:vintage, minCount: 1, maxCount: 1, pattern: "^[0-9]{4}-[0-9]{2}-[0-9]{2}$"} diff --git a/tests/fixtures/ont/entity-props-ok/contracts/lint-baseline.json b/tests/fixtures/ont/entity-props-ok/contracts/lint-baseline.json new file mode 100644 index 0000000000..f8a66767c5 --- /dev/null +++ b/tests/fixtures/ont/entity-props-ok/contracts/lint-baseline.json @@ -0,0 +1,5 @@ +{ + "armed_gates": ["validate"], + "armed_shapes": ["study-shape-v1"], + "ont": {} +} diff --git a/tests/fixtures/ont/entity-props-ok/contracts/ontology.yaml b/tests/fixtures/ont/entity-props-ok/contracts/ontology.yaml new file mode 100644 index 0000000000..cc88a2273e --- /dev/null +++ b/tests/fixtures/ont/entity-props-ok/contracts/ontology.yaml @@ -0,0 +1,21 @@ +schema: ont-sigma-v1 +metadata: + version: "1.0.0" + kind: pattern + created: '2026-09-19' + last_modified: '2026-09-19' + author: fixture + description: "the contract schema's own block, carried by Σ and read by neither Σ rule" + references: + - 'paiml/infra docs/specifications/paiml-ontology.md §4.1 (Σ) — and pv validate, which requires this block' +concepts: + Contract: {doc: a pv contract file} +entity_types: + - {name: study, extractor: pv_contract, implemented: true} +extractors: + - {name: pv_contract, reader: ontology/extract/pv_contract.rs, implemented: true} +readers: + concepts: ontology/sigma.rs + entity_types: lint/sigma_gate.rs + extractors: ontology/sigma.rs + metadata: schema/types.rs (the contract schema, not Σ) diff --git a/tests/fixtures/ont/entity-props-ok/contracts/study-shape-v1.yaml b/tests/fixtures/ont/entity-props-ok/contracts/study-shape-v1.yaml new file mode 100644 index 0000000000..d881c41afe --- /dev/null +++ b/tests/fixtures/ont/entity-props-ok/contracts/study-shape-v1.yaml @@ -0,0 +1,27 @@ +# apex EV-21 (FALSIFY-ONT-021/021b): the researcher degrees of freedom live in the contract, because the +# contract IS the pre-registration. The shape targets ont:Contract and constrains them by path. +name: study-shape +version: "1.0.0" +status: active +metadata: + version: "1.0.0" + kind: pattern + created: '2026-09-19' + last_modified: '2026-09-19' + author: fixture + description: a pre-registered study's closed degrees of freedom +entity: + type: study + ref: study-shape-v1 + properties: + scale: linear + denominator: population-100k + vintage: '2026-09-12' +shape: + targetClass: ont:Contract + closed: true + ignoredProperties: [ont:id, ont:file, ont:name, ont:version, ont:status, ont:kind, ont:entityType, ont:entityRef] + properties: + - {path: study:scale, minCount: 1, maxCount: 1, in: [linear, log10, log2, sqrt, logit]} + - {path: study:denominator, minCount: 1, maxCount: 1, in: [population-100k, count]} + - {path: study:vintage, minCount: 1, maxCount: 1, pattern: "^[0-9]{4}-[0-9]{2}-[0-9]{2}$"} diff --git a/tests/fixtures/ont/entity-props-undeclared-key/contracts/lint-baseline.json b/tests/fixtures/ont/entity-props-undeclared-key/contracts/lint-baseline.json new file mode 100644 index 0000000000..f8a66767c5 --- /dev/null +++ b/tests/fixtures/ont/entity-props-undeclared-key/contracts/lint-baseline.json @@ -0,0 +1,5 @@ +{ + "armed_gates": ["validate"], + "armed_shapes": ["study-shape-v1"], + "ont": {} +} diff --git a/tests/fixtures/ont/entity-props-undeclared-key/contracts/ontology.yaml b/tests/fixtures/ont/entity-props-undeclared-key/contracts/ontology.yaml new file mode 100644 index 0000000000..cc88a2273e --- /dev/null +++ b/tests/fixtures/ont/entity-props-undeclared-key/contracts/ontology.yaml @@ -0,0 +1,21 @@ +schema: ont-sigma-v1 +metadata: + version: "1.0.0" + kind: pattern + created: '2026-09-19' + last_modified: '2026-09-19' + author: fixture + description: "the contract schema's own block, carried by Σ and read by neither Σ rule" + references: + - 'paiml/infra docs/specifications/paiml-ontology.md §4.1 (Σ) — and pv validate, which requires this block' +concepts: + Contract: {doc: a pv contract file} +entity_types: + - {name: study, extractor: pv_contract, implemented: true} +extractors: + - {name: pv_contract, reader: ontology/extract/pv_contract.rs, implemented: true} +readers: + concepts: ontology/sigma.rs + entity_types: lint/sigma_gate.rs + extractors: ontology/sigma.rs + metadata: schema/types.rs (the contract schema, not Σ) diff --git a/tests/fixtures/ont/entity-props-undeclared-key/contracts/study-shape-v1.yaml b/tests/fixtures/ont/entity-props-undeclared-key/contracts/study-shape-v1.yaml new file mode 100644 index 0000000000..6ec9faa22e --- /dev/null +++ b/tests/fixtures/ont/entity-props-undeclared-key/contracts/study-shape-v1.yaml @@ -0,0 +1,28 @@ +# apex EV-21 (FALSIFY-ONT-021/021b): the researcher degrees of freedom live in the contract, because the +# contract IS the pre-registration. The shape targets ont:Contract and constrains them by path. +name: study-shape +version: "1.0.0" +status: active +metadata: + version: "1.0.0" + kind: pattern + created: '2026-09-19' + last_modified: '2026-09-19' + author: fixture + description: a pre-registered study's closed degrees of freedom +entity: + type: study + ref: study-shape-v1 + properties: + scale: linear + denominator: population-100k + vintage: '2026-09-12' + subgroup: none +shape: + targetClass: ont:Contract + closed: true + ignoredProperties: [ont:id, ont:file, ont:name, ont:version, ont:status, ont:kind, ont:entityType, ont:entityRef] + properties: + - {path: study:scale, minCount: 1, maxCount: 1, in: [linear, log10, log2, sqrt, logit]} + - {path: study:denominator, minCount: 1, maxCount: 1, in: [population-100k, count]} + - {path: study:vintage, minCount: 1, maxCount: 1, pattern: "^[0-9]{4}-[0-9]{2}-[0-9]{2}$"}