You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@ossie.apache.org by jb...@apache.org on 2026/10/07 16:43:28 UTC
(ossie) branch main updated: feat(validation): semantic checks for ontology documents (#489)
This is an automated email from the ASF dual-hosted git repository.
jbonofre pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/ossie.git
The following commit(s) were added to refs/heads/main by this push:
new 8dd6732d feat(validation): semantic checks for ontology documents (#489)
8dd6732d is described below
commit 8dd6732da354f22ca71f16d82626a46039ecdcd9
Author: Kyoung Min Kim <ka...@gmail.com>
AuthorDate: Thu Oct 8 01:43:21 2026 +0900
feat(validation): semantic checks for ontology documents (#489)
* feat(validation): semantic checks for ontology documents
validate.py stopped at the JSON schema for ontology documents, because
every semantic check was guarded by the presence of `datasets`. An
ontology that declares the same concept twice, extends a concept that
does not exist, identifies itself by a relationship it does not have,
gives a role to an undeclared concept, or uses a QName prefix missing
from `prefixes` all came back "Validation PASSED".
Add validate_ontology with the same kind of checks the core side has:
- unique concept names in the ontology and unique relationship names
within a concept
- extends, identify_by and role references resolve to declared concepts
and relationships, with the built-in concepts from ontology.md
(Any, Boolean, Date, DateTime, Decimal, Float, Integer, String)
included and identify_by allowed to name a supertype's relationship
- a cycle check on extends
- QName iri values on concepts and relationships must use a prefix
declared in the top-level `prefixes` map
ontology_mappings are left to the schema for now. The validation CI
gains a step running examples/flights.yaml against the ontology schema,
which previously only covered the TPC-DS core example.
Generated-by: Claude Code
* Treat known URI schemes as full IRIs in the prefix check
mailto:, tel: and urn:x have no "//" and only one colon, so the QName
check read them as prefix:local and reported an undeclared prefix.
Skip the check when the part before the colon is a known URI scheme
(case-insensitive); declared prefixes and the "//" and second-colon
rules are unchanged. Tests cover the accepted schemes and a prefix
that is genuinely undeclared.
Generated-by: Claude Code
---
validation/tests/test_validate.py | 184 +++++++++++++++++++++++++++++++++++++-
validation/validate.py | 147 ++++++++++++++++++++++++++++++
2 files changed, 330 insertions(+), 1 deletion(-)
diff --git a/validation/tests/test_validate.py b/validation/tests/test_validate.py
index 9087b398..96fef47d 100644
--- a/validation/tests/test_validate.py
+++ b/validation/tests/test_validate.py
@@ -24,7 +24,7 @@ import pytest
# validate.py exits at import time when its dependencies are missing, which
# would abort the whole pytest session during collection — skip instead.
-pytest.importorskip("yaml")
+yaml = pytest.importorskip("yaml")
pytest.importorskip("jsonschema")
_VALIDATE_PATH = Path(__file__).parents[1] / "validate.py"
@@ -750,3 +750,185 @@ def test_a_multi_statement_expression_fails_validation(run_validator):
assert exit_code == 1
assert "expected a single expression but found 2 statements" in output
+
+
+validate_ontology = _VALIDATE.validate_ontology
+
+_ONTOLOGY_SCHEMA = Path(__file__).parents[2] / "ontology/ontology.json"
+
+
+def _ontology(components: list[dict], **extra) -> dict:
+ return {"version": "0.2.0.dev0", "name": "o", "ontology": components, **extra}
+
+
+def _concept(name: str, **fields) -> dict:
+ return {"concept": name, "type": "EntityType", **fields}
+
+
+def _ontology_relationship(name: str, role: str, **fields) -> dict:
+ return {
+ "name": name,
+ "roles": [{"concept": role}],
+ "verbalizes": [f"{{x}} {name} {{{role}}}"],
+ **fields,
+ }
+
+
+def test_ontology_checks_accept_the_flights_example() -> None:
+ flights = yaml.safe_load((Path(__file__).parents[2] / "examples/flights.yaml").read_text())
+
+ assert validate_ontology(flights) == []
+
+
+@pytest.mark.parametrize("data", [None, [], {}, {"datasets": []}, {"ontology": {"concept": "A"}}])
+def test_ontology_checks_skip_non_ontology_payloads(data: object) -> None:
+ assert validate_ontology(data) == []
+
+
+def test_duplicate_concepts_are_reported_once_per_repeat() -> None:
+ errors = validate_ontology(_ontology([_concept("Order"), _concept("Order"), _concept("Item")]))
+
+ assert errors == ["[Unique] Duplicate concept 'Order' in ontology 'o'"]
+
+
+def test_a_duplicate_concept_does_not_repeat_its_reference_errors() -> None:
+ first = _concept("Order", extends=["Ghost"], relationships=[_ontology_relationship("nr", "Integer")])
+ second = _concept("Order", identify_by=["nr"])
+
+ errors = validate_ontology(_ontology([first, second]))
+
+ assert errors == [
+ "[Unique] Duplicate concept 'Order' in ontology 'o'",
+ "[Reference] Concept 'Order' extends unknown concept 'Ghost'",
+ ]
+
+
+def test_duplicate_relationship_names_are_scoped_to_their_concept() -> None:
+ order = _concept("Order", relationships=[_ontology_relationship("nr", "Integer"), _ontology_relationship("nr", "String")])
+ item = _concept("Item", relationships=[_ontology_relationship("nr", "Integer")])
+
+ errors = validate_ontology(_ontology([order, item]))
+
+ assert errors == ["[Unique] Duplicate relationship 'nr' in concept 'Order'"]
+
+
+@pytest.mark.parametrize(
+ ("component", "expected"),
+ [
+ (
+ _concept("Order", extends=["Ghost"]),
+ "[Reference] Concept 'Order' extends unknown concept 'Ghost'",
+ ),
+ (
+ _concept("Order", identify_by=["nr"]),
+ "[Reference] Concept 'Order' identify_by references unknown relationship 'nr'",
+ ),
+ (
+ _concept("Order", relationships=[_ontology_relationship("placed_by", "Nobody")]),
+ "[Reference] Relationship 'Order.placed_by' has a role played by unknown concept 'Nobody'",
+ ),
+ (
+ _concept("Order", iri="ex:Order"),
+ "[Reference] Concept 'Order' iri uses undeclared prefix 'ex'",
+ ),
+ (
+ _concept("Order", relationships=[_ontology_relationship("nr", "Integer", iri="ex:nr")]),
+ "[Reference] Relationship 'Order.nr' iri uses undeclared prefix 'ex'",
+ ),
+ ],
+)
+def test_dangling_ontology_references_are_reported(component: dict, expected: str) -> None:
+ assert validate_ontology(_ontology([component])) == [expected]
+
+
+def test_built_in_concepts_resolve_without_being_declared() -> None:
+ amount = {"concept": "Amount", "type": "ValueType", "extends": ["Decimal"]}
+ order = _concept(
+ "Order",
+ extends=["Any"],
+ relationships=[_ontology_relationship("total", "Amount"), _ontology_relationship("placed_on", "DateTime")],
+ )
+
+ assert validate_ontology(_ontology([amount, order])) == []
+
+
+def test_identify_by_resolves_relationships_declared_on_a_supertype() -> None:
+ party = _concept("Party", relationships=[_ontology_relationship("nr", "Integer")])
+ person = _concept("Person", extends=["Party"], identify_by=["nr"])
+
+ assert validate_ontology(_ontology([party, person])) == []
+
+
+@pytest.mark.parametrize(
+ ("components", "expected"),
+ [
+ (
+ [_concept("A", extends=["B"]), _concept("B", extends=["A"])],
+ ["[Reference] Concept 'A' extends itself: A -> B -> A"],
+ ),
+ (
+ [_concept("A", extends=["A"])],
+ ["[Reference] Concept 'A' extends itself: A -> A"],
+ ),
+ (
+ [_concept("A", extends=["B"]), _concept("B", extends=["C"]), _concept("C", extends=["Any"])],
+ [],
+ ),
+ ],
+)
+def test_extends_cycles_are_reported_once(components: list[dict], expected: list[str]) -> None:
+ assert validate_ontology(_ontology(components)) == expected
+
+
+@pytest.mark.parametrize(
+ "iri",
+ [
+ "ex:Order",
+ "http://example.com/Order",
+ "urn:isbn:0451450523",
+ "urn:x",
+ "https://example.com/ns#Order",
+ "mailto:orders@example.com",
+ "tel:+15551234567",
+ "MAILTO:orders@example.com",
+ ],
+)
+def test_declared_prefixes_and_full_iris_are_accepted(iri: str) -> None:
+ document = _ontology([_concept("Order", iri=iri)], prefixes={"ex": "http://example.com/"})
+
+ assert validate_ontology(document) == []
+
+
+def test_undeclared_prefix_is_reported_even_when_prefixes_are_declared() -> None:
+ document = _ontology([_concept("Order", iri="foaf:Order")], prefixes={"ex": "http://example.com/"})
+
+ assert validate_ontology(document) == ["[Reference] Concept 'Order' iri uses undeclared prefix 'foaf'"]
+
+
+def test_ontology_checks_tolerate_malformed_shapes() -> None:
+ # Schema validation owns shape errors; the semantic pass must not crash on them.
+ document = _ontology(
+ ["not a component", _concept("Order", extends="Any", identify_by="nr", relationships=[None, {}])],
+ prefixes="foaf",
+ )
+
+ assert validate_ontology(document) == []
+
+
+def test_ontology_cli_reports_dangling_references(run_validator, offline) -> None:
+ document = _ontology([_concept("Order", extends=["Ghost"])])
+
+ exit_code, output = run_validator(document, _ONTOLOGY_SCHEMA)
+
+ assert exit_code == 1
+ assert "[Reference] Concept 'Order' extends unknown concept 'Ghost'" in output
+
+
+def test_ontology_cli_skips_semantic_checks_when_the_schema_fails(run_validator, offline) -> None:
+ document = _ontology([{"concept": "Order", "extends": ["Ghost"]}]) # missing required type
+
+ exit_code, output = run_validator(document, _ONTOLOGY_SCHEMA)
+
+ assert exit_code == 1
+ assert "[Schema]" in output
+ assert "[Reference]" not in output
diff --git a/validation/validate.py b/validation/validate.py
index 3f600f76..1899e094 100644
--- a/validation/validate.py
+++ b/validation/validate.py
@@ -36,6 +36,10 @@ Validates Ossie YAML or JSON documents containing one semantic model against:
4. Relationship column arity (from_columns and to_columns lengths match)
5. SQL syntax (using sqlglot)
+Ontology documents (--schema ontology/ontology.json) are checked for unique
+concept and relationship names, and for extends, identify_by, roles and QName
+prefixes that resolve to something declared.
+
Usage:
python validation/validate.py <yaml_file>
python validation/validate.py <yaml_file> --schema ontology/ontology.json
@@ -85,6 +89,9 @@ DIALECT_MAP = {
# Dialects that sqlglot cannot parse
SKIP_SQL_VALIDATION = {"MDX", "TABLEAU", "MAQL", "SIGMA", "THOUGHTSPOT", "DAX"}
+# Concepts every ontology includes implicitly (ontology.md, "Built-in concepts")
+BUILT_IN_CONCEPTS = {"Any", "Boolean", "Date", "DateTime", "Decimal", "Float", "Integer", "String"}
+
class ValidationWarning(str):
"""An explicitly nonfatal diagnostic, compatible with existing string callers."""
@@ -414,6 +421,144 @@ def validate_sql(data: dict) -> list[str]:
return errors
+def as_list(value: object) -> list:
+ """The items of a list, or nothing for shapes that already failed the schema."""
+ return value if isinstance(value, list) else []
+
+
+# URI schemes whose IRIs have no "//" and may have a single colon (mailto:a@b.org,
+# tel:+1555..., urn:x). Without this list they look like prefix:local QNames.
+URI_SCHEMES = frozenset(
+ {"http", "https", "urn", "mailto", "tel", "file", "ftp", "data", "did", "doi", "geo", "ldap", "news", "sms", "tag"}
+)
+
+
+def undeclared_qname_prefix(iri: object, prefixes: dict) -> str | None:
+ """Return the prefix of a QName iri that prefixes does not declare."""
+ if not isinstance(iri, str) or ":" not in iri:
+ return None
+ prefix, local = iri.split(":", 1)
+ # ontology.md allows a full IRI or a prefix:local QName. A scheme followed
+ # by "//" (http://...), a second colon (urn:isbn:...) or a known URI scheme
+ # marks a full IRI; anything else is read as a QName.
+ if local.startswith("//") or ":" in local or prefix.lower() in URI_SCHEMES:
+ return None
+ return None if prefix in prefixes else prefix
+
+
+def find_extends_cycles(supertypes: dict[str, list[str]]) -> list[list[str]]:
+ """Find cycles in the extends graph, each reported once as a path."""
+ cycles = []
+ state = {} # concept -> "active" while on the current path, "done" after
+
+ def visit(concept: str, path: list[str]) -> None:
+ state[concept] = "active"
+ path.append(concept)
+ for supertype in supertypes.get(concept, []):
+ if state.get(supertype) == "active":
+ cycles.append(path[path.index(supertype):] + [supertype])
+ elif supertype not in state:
+ visit(supertype, path)
+ path.pop()
+ state[concept] = "done"
+
+ for concept in supertypes:
+ if concept not in state:
+ visit(concept, [])
+ return cycles
+
+
+def validate_ontology(data: dict) -> list[str]:
+ """Validate the concepts and relationships of an ontology document.
+
+ The same kind of checks the core side gets from validate_unique_names and
+ validate_references: concept names are unique within the ontology,
+ relationship names are unique within their concept, and extends,
+ identify_by, roles and QName prefixes resolve to something declared.
+ ontology_mappings are left to schema validation.
+ """
+ if not isinstance(data, dict) or not isinstance(data.get("ontology"), list):
+ return []
+
+ errors = []
+
+ ontology_name = data.get("name", "<unnamed>")
+ components = [c for c in data["ontology"] if isinstance(c, dict)]
+ prefixes = data.get("prefixes") if isinstance(data.get("prefixes"), dict) else {}
+
+ concept_names = [c.get("concept") for c in components if c.get("concept")]
+ for dup in find_duplicates(concept_names):
+ errors.append(f"[Unique] Duplicate concept '{dup}' in ontology '{ontology_name}'")
+
+ declared = set(concept_names) | BUILT_IN_CONCEPTS
+ supertypes = {}
+ relationships = {}
+ for component in components:
+ concept = component.get("concept", "<unnamed>")
+ rel_names = [
+ r.get("name") for r in as_list(component.get("relationships"))
+ if isinstance(r, dict) and r.get("name")
+ ]
+ for dup in find_duplicates(rel_names):
+ errors.append(f"[Unique] Duplicate relationship '{dup}' in concept '{concept}'")
+ # Merged across duplicate components, so a duplicate concept is reported
+ # once rather than again as every reference to the first declaration
+ # failing. Reference checks below read each component directly.
+ supertypes.setdefault(concept, []).extend(
+ s for s in as_list(component.get("extends")) if isinstance(s, str)
+ )
+ relationships.setdefault(concept, set()).update(rel_names)
+
+ def inherited_relationships(concept: str) -> set[str]:
+ # A concept can identify itself by a relationship declared on a supertype,
+ # which is how the reference parser resolves identify_by as well.
+ names, pending, seen = set(), [concept], set()
+ while pending:
+ current = pending.pop()
+ if current in seen:
+ continue
+ seen.add(current)
+ names |= relationships.get(current, set())
+ pending.extend(supertypes.get(current, []))
+ return names
+
+ for component in components:
+ concept = component.get("concept", "<unnamed>")
+
+ for supertype in as_list(component.get("extends")):
+ if isinstance(supertype, str) and supertype not in declared:
+ errors.append(f"[Reference] Concept '{concept}' extends unknown concept '{supertype}'")
+
+ for identifier in as_list(component.get("identify_by")):
+ if isinstance(identifier, str) and identifier not in inherited_relationships(concept):
+ errors.append(
+ f"[Reference] Concept '{concept}' identify_by references unknown relationship '{identifier}'"
+ )
+
+ prefix = undeclared_qname_prefix(component.get("iri"), prefixes)
+ if prefix:
+ errors.append(f"[Reference] Concept '{concept}' iri uses undeclared prefix '{prefix}'")
+
+ for relationship in as_list(component.get("relationships")):
+ if not isinstance(relationship, dict):
+ continue
+ rel_name = f"{concept}.{relationship.get('name', '<unnamed>')}"
+ for role in as_list(relationship.get("roles")):
+ role_concept = role.get("concept") if isinstance(role, dict) else None
+ if role_concept and role_concept not in declared:
+ errors.append(
+ f"[Reference] Relationship '{rel_name}' has a role played by unknown concept '{role_concept}'"
+ )
+ prefix = undeclared_qname_prefix(relationship.get("iri"), prefixes)
+ if prefix:
+ errors.append(f"[Reference] Relationship '{rel_name}' iri uses undeclared prefix '{prefix}'")
+
+ for cycle in find_extends_cycles(supertypes):
+ errors.append(f"[Reference] Concept '{cycle[0]}' extends itself: {' -> '.join(cycle)}")
+
+ return errors
+
+
def main():
if len(sys.argv) < 2:
print(__doc__)
@@ -464,6 +609,8 @@ def main():
errors.extend(validate_references(data))
errors.extend(validate_relationship_column_arity(data))
errors.extend(validate_sql(data))
+ if not errors and isinstance(data, dict) and "ontology" in data:
+ errors.extend(validate_ontology(data))
# Report results
if errors: