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: