tbox: v2.2 - polymorphisme declare sur 19 proprietes, 17 domaines poses, provenance portee par les classes concretes, GovernanceLayerObject rattachee, affectations de gouvernance completes (12 completees, 10 SDO, 1 PO, 16 stewards par objet metier), Panel Coverage gouverne. Validation SHACL sans violation bloquante

This commit is contained in:
Bastien Gourdon
2026-08-07 10:31:16 +02:00
parent d90d2a316d
commit 0b7d3834d2
29 changed files with 3274 additions and 274 deletions
+488
View File
@@ -0,0 +1,488 @@
#!/usr/bin/env python3
"""
Enforce the naming and declaration rules of the T-Box rulebook.
USAGE
python3 check_tbox_naming.py [--ontology PATH] [--json PATH] [--quiet]
Named as the executor of 25 rules. EV-011 forbids declaring a rule blocking
without one, so this script is what makes those declarations true.
THREE VERDICTS
VIOLATION the rule is settled mechanically and is broken -> exit 1
REVIEW the checker cannot conclude; a person must -> exit 0
PASS conforming
The middle verdict is the point. Without it the choice would be between
blocking on false positives and staying silent on real problems. What the
checker cannot decide alone comes from naming_lexicon.yaml; a word absent from
the lexicon produces a REVIEW, never a VIOLATION.
"""
import argparse
import json
import os
import re
import sys
from collections import OrderedDict
import yaml
from rdflib import Graph, Literal, Namespace, RDF, RDFS, OWL, URIRef, XSD
HERE = os.path.dirname(os.path.abspath(__file__))
RULES = os.path.join(HERE, "rules.yaml")
LEXICON = os.path.join(HERE, "naming_lexicon.yaml")
DEFAULT_ONTOLOGY = os.path.normpath(os.path.join(HERE, "..", "ontology", "pr_metamodel.ttl"))
NS = "https://ontology.pernod-ricard.com/metamodel/"
PR = Namespace(NS)
VIOLATION, REVIEW = "VIOLATION", "REVIEW"
KINDS = OrderedDict([
(OWL.Class, "class"),
(OWL.ObjectProperty, "relation"),
(OWL.DatatypeProperty, "attribute"),
(OWL.AnnotationProperty, "annotation"),
])
# ------------------------------------------------------------------- findings
class Findings(object):
def __init__(self):
self.items = []
def add(self, verdict, rule, term, message):
self.items.append({"verdict": verdict, "rule": rule,
"term": term, "message": message})
def violation(self, rule, term, message):
self.add(VIOLATION, rule, term, message)
def review(self, rule, term, message):
self.add(REVIEW, rule, term, message)
@property
def violations(self):
return [i for i in self.items if i["verdict"] == VIOLATION]
@property
def reviews(self):
return [i for i in self.items if i["verdict"] == REVIEW]
# -------------------------------------------------------------------- helpers
def local(uri):
s = str(uri)
return s[len(NS):] if s.startswith(NS) else None
def tokens(name):
"""Split a CamelCase local name. A run of capitals is one token: an acronym."""
return re.findall(r"[A-Z]+(?![a-z])|[A-Z][a-z0-9]*|^[a-z0-9]+|[a-z0-9]+", name)
def decamelise(name, is_class):
"""TN-018. A run of capitals is kept together: it carries an acronym."""
spaced = re.sub(r"(?<=[a-z0-9])(?=[A-Z])|(?<=[A-Z])(?=[A-Z][a-z])", " ", name)
return spaced if is_class else spaced[0].lower() + spaced[1:]
def acronyms_in(name, lex):
"""Runs of two or more capitals, plus any known short form appearing whole.
The negative lookahead matters: without it, BIField yields BIF rather than
BI, because the run swallows the initial capital of the word that follows.
"""
found = set(re.findall(r"[A-Z]{2,}(?![a-z])", name))
for short in lex["known_acronyms"]:
if re.search(r"(?:^|(?<=[a-z]))%s(?![a-z])" % re.escape(short), name):
found.add(short)
return sorted(found)
def strip_case(text):
return re.sub(r"[^a-z0-9]", "", (text or "").lower())
# --------------------------------------------------------------------- checks
def provenance_axis(g, term):
"""The provenance axis a class descends from, or None."""
seen, stack = set(), [term]
while stack:
node = stack.pop()
for parent in g.objects(node, RDFS.subClassOf):
if isinstance(parent, URIRef) and parent not in seen:
seen.add(parent)
stack.append(parent)
if PR.CapturedObject in seen:
return "CapturedObject"
if PR.DefinedObject in seen:
return "DefinedObject"
return None
def check_identifier(g, terms, lex, f):
"""TN-001 to TN-015 — the form of the IRI."""
short_labels = {t: str(v) for t in terms
for v in g.objects(t, PR.shortLabel)}
allowed_acronyms = set()
for value in short_labels.values():
allowed_acronyms |= set(re.findall(r"[A-Z]{2,}", value))
for term, kind in terms.items():
name = local(term)
if name is None:
continue
# TN-001 — CamelCase, no separator, correct initial
if re.search(r"[^A-Za-z0-9]", name):
f.violation("TN-001", name, "the local name carries a separator")
expect_upper = kind in ("class", "individual")
if name and name[0].isupper() != expect_upper:
f.violation("TN-001", name,
"a %s starts with %s case" %
(kind, "upper" if expect_upper else "lower"))
# TN-004 — no digit
if any(c.isdigit() for c in name):
f.violation("TN-004", name, "a digit belongs to a value, not an identity")
# TN-002 — no acronym, except one reproduced from a target short label
for a in acronyms_in(name, lex):
if kind == "relation" and a in allowed_acronyms:
continue # TN-011 exception
expansion = lex["known_acronyms"].get(a)
f.violation("TN-002", name, "carries the acronym %s%s"
% (a, " (%s)" % expansion if expansion else ""))
if kind == "class":
check_class_name(name, lex, f)
elif kind == "relation":
check_relation_name(g, term, name, lex, f)
elif kind == "attribute":
check_attribute_name(g, term, name, lex, f)
def check_class_name(name, lex, f):
"""TN-005 — a singular common noun, no type word."""
if name in lex["type_word_exceptions"]:
return
for w in lex["type_words"]:
if re.search(r"(?:^|(?<=[a-z]))%s(?![a-z])" % w, name):
f.violation("TN-005", name,
"carries the type word %s and is not a recorded abstraction" % w)
last = tokens(name)[-1] if tokens(name) else name
if last.endswith("s") and last not in lex["not_plural"]:
f.review("TN-005", name, "%s looks plural; a class name is singular" % last)
def check_relation_name(g, term, name, lex, f):
"""TN-009, TN-010, TN-011 — verb first, is disambiguated, target named whole."""
toks = tokens(name)
head = toks[0] if toks else name
# TN-009 — an adverb is transparent, the verb follows
if head in lex["adverbs"]:
toks = toks[1:]
head = toks[0].lower() if toks else head
head = head.lower()
if head in lex["copula"]:
pass # TN-010, copula use
elif head in lex["active_verbs"] or head in lex["participles"]:
pass
else:
f.review("TN-009", name,
"%s is not a verb known to the lexicon; add it there or rename" % head)
# TN-011 — if the target class is named, it is named by its short label whole
for rng in g.objects(term, RDFS.range):
target = local(rng)
if not target:
continue
short = next((str(v) for v in g.objects(rng, PR.shortLabel)), None)
reference = strip_case(short) if short else strip_case(target)
tail = strip_case("".join(toks[1:]))
if not tail or tail == reference:
continue
if reference.endswith(tail) or tail in reference:
f.violation("TN-011", name,
"names its target by a truncation; %s expects %s"
% (target, short or target))
def check_attribute_name(g, term, name, lex, f):
"""TN-012 to TN-015 — a noun, no relational suffix, booleans predicative."""
ranges = [str(r) for r in g.objects(term, RDFS.range)]
is_boolean = str(XSD.boolean) in ranges
is_date = any(r in lex["date_ranges"] for r in ranges)
toks = tokens(name)
head = (toks[0] if toks else name).lower()
if is_boolean:
if head not in lex["copula"]:
f.violation("TN-015", name, "a boolean attribute is predicative and begins with is")
return
# TN-012 — a noun, and in particular no has
if head == "has":
f.violation("TN-012", name, "an attribute is a noun; the domain already says who has it")
elif head in lex["active_verbs"] or head in lex["copula"]:
f.violation("TN-012", name, "%s is a verb; an attribute is a noun" % head)
elif head in lex["participles"]:
f.review("TN-012", name, "%s is a participle; confirm this reads as a noun" % head)
# TN-014 — relational suffixes are reserved for relations
last = toks[-1] if toks else name
if last in lex["relational_suffixes"]:
f.violation("TN-014", name,
"ends in %s, a form reserved for object properties" % last)
# TN-013 — a date attribute is nominal
if is_date and (last in lex["relational_suffixes"] or head in lex["participles"]):
f.violation("TN-013", name, "a date attribute is a noun, not a participle")
def check_declaration(g, terms, lex, f, abstractness):
"""TN-003, TN-006, TN-007, TN-024, TN-025, TN-026, TN-027, TN-028."""
layer_roots = {t for t in terms
if terms[t] == "class"
and (local(t) or "").endswith("LayerObject")}
for term, kind in terms.items():
name = local(term)
if name is None:
continue
# TN-024 — every active term carries a comment
if not list(g.objects(term, RDFS.comment)):
f.violation("TN-024", name, "carries no rdfs:comment")
# TN-026 — only a concrete class declares how its instances are produced
modes = [str(m) for m in g.objects(term, PR.authoringMode)]
concrete = (kind == "class"
and Literal(False) in set(g.objects(term, PR.isAbstract)))
if modes and not concrete:
f.violation("TN-026", name,
"declares pr:authoringMode; only a concrete class does, "
"since a vocabulary term is declared by definition")
elif concrete:
if not modes:
f.violation("TN-026", name, "does not declare pr:authoringMode")
else:
if "HARVESTED" in modes and not list(g.objects(term, PR.harvestSource)):
f.violation("TN-026", name, "is HARVESTED and names no harvestSource")
axis = provenance_axis(g, term)
if axis == "CapturedObject" and "HARVESTED" not in modes:
f.violation("TN-026", name,
"descends from CapturedObject and must declare HARVESTED")
if axis == "DefinedObject" and "ASSERTED" not in modes:
f.violation("TN-026", name,
"descends from DefinedObject and must declare ASSERTED")
if kind == "class":
values = list(g.objects(term, PR.isAbstract))
# TN-007 — declared exactly once, explicitly
if len(values) != 1:
f.violation("TN-007", name,
"declares pr:isAbstract %d times; exactly one is required"
% len(values))
# TN-006 — a layer root is abstract
elif term in layer_roots and values[0].toPython() is not True:
f.violation("TN-006", name, "is a layer root and must be abstract")
# TN-027 — a concrete class has one layer and one provenance
elif values[0].toPython() is False:
check_attachment(g, term, name, layer_roots, f)
if kind in ("relation", "attribute"):
check_property_typing(g, term, name, lex, f)
# TN-003 — the display properties are annotations
for display in (PR.shortLabel, PR.acronym):
name = local(display)
types = set(g.objects(display, RDF.type))
if not types:
continue
if OWL.AnnotationProperty not in types:
f.violation("TN-003", name, "is declared %s and must be an annotation property"
% ", ".join(sorted(local(t) or str(t) for t in types)))
def check_attachment(g, term, name, layer_roots, f):
"""TN-027 — exactly one layer root and one provenance axis above."""
seen, stack = set(), [term]
while stack:
node = stack.pop()
for parent in g.objects(node, RDFS.subClassOf):
if isinstance(parent, URIRef) and parent not in seen:
seen.add(parent)
stack.append(parent)
layers = seen & layer_roots
axes = seen & {PR.DefinedObject, PR.CapturedObject}
if len(layers) != 1:
f.violation("TN-027", name, "descends from %d layer roots; exactly one is required"
% len(layers))
if len(axes) != 1:
f.violation("TN-027", name, "descends from %d provenance axes; exactly one is required"
% len(axes))
def check_property_typing(g, term, name, lex, f):
"""TN-025 and TN-028 — domain, range, and inherited domain."""
domains = list(g.objects(term, RDFS.domain))
ranges = list(g.objects(term, RDFS.range))
if not ranges and Literal(True) not in set(g.objects(term, PR.polymorphic)):
f.violation("TN-025", name, "declares no rdfs:range")
polymorphic = Literal(True) in set(g.objects(term, PR.polymorphic))
if not domains:
if polymorphic:
f.review("TN-025", name,
"declares itself polymorphic; confirm the scope is declared in SHACL")
else:
f.violation("TN-025", name,
"omits its domain in silence; declare pr:polymorphic true "
"or state the domain")
# TN-028 — a sub-property inherits the domain of its parent
for parent in g.objects(term, RDFS.subPropertyOf):
if not isinstance(parent, URIRef) or local(parent) is None:
continue
parent_domains = set(g.objects(parent, RDFS.domain))
if not parent_domains or not domains:
continue
if set(domains) & parent_domains:
continue
compatible = False
for d in domains:
ancestors, stack = set(), [d]
while stack:
node = stack.pop()
for up in g.objects(node, RDFS.subClassOf):
if isinstance(up, URIRef) and up not in ancestors:
ancestors.add(up)
stack.append(up)
if ancestors & parent_domains:
compatible = True
if not compatible:
f.review("TN-028", name,
"is a sub-property of %s, whose domain does not cover its own; "
"asserting it will retype the subject"
% local(parent))
def check_label(g, terms, f):
"""TN-018 to TN-023 — the label is derived, and carries nothing else."""
for term, kind in terms.items():
name = local(term)
if name is None:
continue
labels = list(g.objects(term, RDFS.label))
if not labels:
f.violation("TN-023", name, "carries no rdfs:label")
continue
if len(labels) > 1:
f.violation("TN-023", name, "carries %d labels; the derivation yields one"
% len(labels))
text = str(labels[0])
expected = decamelise(name, kind in ("class", "individual"))
if text != expected:
f.violation("TN-018", name, "label is %r; the derivation yields %r"
% (text, expected))
if "(" in text or ")" in text:
f.violation("TN-021", name, "the label carries a gloss; it belongs in the comment")
if re.search(r"\b(deprecated|obsolete|draft|published|v\d)\b", text, re.I):
f.violation("TN-020", name, "the label carries state; the axiom carries it")
def check_namespace(g, f):
"""EV-014 — the term namespace is never versioned."""
for prefix, uri in g.namespaces():
if str(uri).startswith("https://ontology.pernod-ricard.com/metamodel/") \
and str(uri) != NS:
f.violation("EV-014", str(uri),
"the prefix %s binds a versioned namespace; the version lives "
"in owl:versionIRI alone" % prefix)
# ------------------------------------------------------------------------ main
def collect(g):
terms = OrderedDict()
for rdf_type, kind in KINDS.items():
for term in sorted(g.subjects(RDF.type, rdf_type), key=str):
if local(term) is None:
continue
if (term, OWL.deprecated, Literal(True)) in g:
continue
terms[term] = kind
return terms
def main():
ap = argparse.ArgumentParser()
ap.add_argument("--ontology", default=DEFAULT_ONTOLOGY)
ap.add_argument("--json", default=None, help="write the findings as JSON")
ap.add_argument("--quiet", action="store_true", help="print the summary only")
args = ap.parse_args()
lex = yaml.safe_load(open(LEXICON, encoding="utf-8"))
rules = yaml.safe_load(open(RULES, encoding="utf-8"))
abstractness = {a["term"]: a["is_abstract"] for a in rules["abstractness"]}
g = Graph()
g.parse(args.ontology, format="turtle")
terms = collect(g)
f = Findings()
check_identifier(g, terms, lex, f)
check_declaration(g, terms, lex, f, abstractness)
check_label(g, terms, f)
check_namespace(g, f)
titles = {r["id"]: r["title"] for r in rules["rules"]}
print("=" * 78)
print("T-BOX NAMING CHECK")
print(" ontology : %s" % args.ontology)
print(" terms : %d active" % len(terms))
print("=" * 78)
if not args.quiet:
for verdict in (VIOLATION, REVIEW):
items = [i for i in f.items if i["verdict"] == verdict]
if not items:
continue
print("\n%s%d" % (verdict, len(items)))
by_rule = OrderedDict()
for i in items:
by_rule.setdefault(i["rule"], []).append(i)
for rule, group in sorted(by_rule.items()):
print("\n [%s] %s (%d)" % (rule, titles.get(rule, ""), len(group)))
for i in group[:12]:
print(" %-34s %s" % (i["term"], i["message"]))
if len(group) > 12:
print(" ... and %d more" % (len(group) - 12))
print("\n" + "=" * 78)
print("RESULT : %d violation(s) | %d for review | %d term(s) checked"
% (len(f.violations), len(f.reviews), len(terms)))
print("=" * 78)
if args.json:
directory = os.path.dirname(os.path.abspath(args.json))
if directory:
os.makedirs(directory, exist_ok=True)
json.dump({"ontology": args.ontology, "terms": len(terms),
"violations": len(f.violations), "reviews": len(f.reviews),
"findings": f.items},
open(args.json, "w", encoding="utf-8"), indent=2)
print("json: %s" % args.json)
sys.exit(1 if f.violations else 0)
if __name__ == "__main__":
main()
+416
View File
@@ -0,0 +1,416 @@
{
"ontology": "ontology/pr_metamodel.ttl",
"terms": 130,
"violations": 49,
"reviews": 19,
"findings": [
{
"verdict": "VIOLATION",
"rule": "TN-026",
"term": "BaseTable",
"message": "is HARVESTED and names no harvestSource"
},
{
"verdict": "VIOLATION",
"rule": "TN-026",
"term": "ExternalTable",
"message": "is HARVESTED and names no harvestSource"
},
{
"verdict": "VIOLATION",
"rule": "TN-026",
"term": "ForeignKey",
"message": "is HARVESTED and names no harvestSource"
},
{
"verdict": "VIOLATION",
"rule": "TN-026",
"term": "PrimaryKey",
"message": "is HARVESTED and names no harvestSource"
},
{
"verdict": "VIOLATION",
"rule": "TN-026",
"term": "SystemType",
"message": "does not declare pr:authoringMode"
},
{
"verdict": "VIOLATION",
"rule": "TN-027",
"term": "SystemType",
"message": "descends from 0 layer roots; exactly one is required"
},
{
"verdict": "VIOLATION",
"rule": "TN-027",
"term": "SystemType",
"message": "descends from 0 provenance axes; exactly one is required"
},
{
"verdict": "VIOLATION",
"rule": "TN-026",
"term": "View",
"message": "is HARVESTED and names no harvestSource"
},
{
"verdict": "REVIEW",
"rule": "TN-025",
"term": "belongsTo",
"message": "declares itself polymorphic; confirm the scope is declared in SHACL"
},
{
"verdict": "VIOLATION",
"rule": "TN-024",
"term": "consumes",
"message": "carries no rdfs:comment"
},
{
"verdict": "VIOLATION",
"rule": "TN-024",
"term": "containsBIField",
"message": "carries no rdfs:comment"
},
{
"verdict": "VIOLATION",
"rule": "TN-024",
"term": "governedBy",
"message": "carries no rdfs:comment"
},
{
"verdict": "VIOLATION",
"rule": "TN-024",
"term": "hasDomainOwner",
"message": "carries no rdfs:comment"
},
{
"verdict": "VIOLATION",
"rule": "TN-024",
"term": "hasElement",
"message": "carries no rdfs:comment"
},
{
"verdict": "VIOLATION",
"rule": "TN-024",
"term": "hasGovernanceLead",
"message": "carries no rdfs:comment"
},
{
"verdict": "VIOLATION",
"rule": "TN-024",
"term": "hasMetric",
"message": "carries no rdfs:comment"
},
{
"verdict": "VIOLATION",
"rule": "TN-024",
"term": "hasSubDomainOwner",
"message": "carries no rdfs:comment"
},
{
"verdict": "VIOLATION",
"rule": "TN-024",
"term": "hasSystemType",
"message": "carries no rdfs:comment"
},
{
"verdict": "VIOLATION",
"rule": "TN-024",
"term": "hostedOn",
"message": "carries no rdfs:comment"
},
{
"verdict": "VIOLATION",
"rule": "TN-024",
"term": "isInBIDataSource",
"message": "carries no rdfs:comment"
},
{
"verdict": "VIOLATION",
"rule": "TN-024",
"term": "isInBIWorkspace",
"message": "carries no rdfs:comment"
},
{
"verdict": "VIOLATION",
"rule": "TN-024",
"term": "isInDatabase",
"message": "carries no rdfs:comment"
},
{
"verdict": "VIOLATION",
"rule": "TN-024",
"term": "livesIn",
"message": "carries no rdfs:comment"
},
{
"verdict": "VIOLATION",
"rule": "TN-024",
"term": "measures",
"message": "carries no rdfs:comment"
},
{
"verdict": "VIOLATION",
"rule": "TN-024",
"term": "ownedBy",
"message": "carries no rdfs:comment"
},
{
"verdict": "REVIEW",
"rule": "TN-025",
"term": "ownedBy",
"message": "declares itself polymorphic; confirm the scope is declared in SHACL"
},
{
"verdict": "REVIEW",
"rule": "TN-025",
"term": "ownedByDomain",
"message": "declares itself polymorphic; confirm the scope is declared in SHACL"
},
{
"verdict": "VIOLATION",
"rule": "TN-024",
"term": "produces",
"message": "carries no rdfs:comment"
},
{
"verdict": "VIOLATION",
"rule": "TN-024",
"term": "referencesSourceField",
"message": "carries no rdfs:comment"
},
{
"verdict": "VIOLATION",
"rule": "TN-024",
"term": "referencesTargetField",
"message": "carries no rdfs:comment"
},
{
"verdict": "REVIEW",
"rule": "TN-025",
"term": "represents",
"message": "declares itself polymorphic; confirm the scope is declared in SHACL"
},
{
"verdict": "REVIEW",
"rule": "TN-025",
"term": "sourcedFrom",
"message": "declares itself polymorphic; confirm the scope is declared in SHACL"
},
{
"verdict": "REVIEW",
"rule": "TN-025",
"term": "storedIn",
"message": "declares itself polymorphic; confirm the scope is declared in SHACL"
},
{
"verdict": "VIOLATION",
"rule": "TN-024",
"term": "usesBIDataSource",
"message": "carries no rdfs:comment"
},
{
"verdict": "VIOLATION",
"rule": "TN-024",
"term": "activationStatus",
"message": "carries no rdfs:comment"
},
{
"verdict": "REVIEW",
"rule": "TN-025",
"term": "arbitrationStatus",
"message": "declares itself polymorphic; confirm the scope is declared in SHACL"
},
{
"verdict": "VIOLATION",
"rule": "TN-024",
"term": "businessRule",
"message": "carries no rdfs:comment"
},
{
"verdict": "VIOLATION",
"rule": "TN-024",
"term": "canonicalName",
"message": "carries no rdfs:comment"
},
{
"verdict": "REVIEW",
"rule": "TN-025",
"term": "canonicalName",
"message": "declares itself polymorphic; confirm the scope is declared in SHACL"
},
{
"verdict": "VIOLATION",
"rule": "TN-024",
"term": "creationDate",
"message": "carries no rdfs:comment"
},
{
"verdict": "REVIEW",
"rule": "TN-025",
"term": "creationDate",
"message": "declares itself polymorphic; confirm the scope is declared in SHACL"
},
{
"verdict": "VIOLATION",
"rule": "TN-024",
"term": "environment",
"message": "carries no rdfs:comment"
},
{
"verdict": "VIOLATION",
"rule": "TN-024",
"term": "exampleValue",
"message": "carries no rdfs:comment"
},
{
"verdict": "REVIEW",
"rule": "TN-025",
"term": "exampleValue",
"message": "declares itself polymorphic; confirm the scope is declared in SHACL"
},
{
"verdict": "VIOLATION",
"rule": "TN-024",
"term": "formula",
"message": "carries no rdfs:comment"
},
{
"verdict": "REVIEW",
"rule": "TN-025",
"term": "fullyQualifiedName",
"message": "declares itself polymorphic; confirm the scope is declared in SHACL"
},
{
"verdict": "REVIEW",
"rule": "TN-025",
"term": "harvestDate",
"message": "declares itself polymorphic; confirm the scope is declared in SHACL"
},
{
"verdict": "VIOLATION",
"rule": "TN-024",
"term": "identifier",
"message": "carries no rdfs:comment"
},
{
"verdict": "REVIEW",
"rule": "TN-025",
"term": "identifier",
"message": "declares itself polymorphic; confirm the scope is declared in SHACL"
},
{
"verdict": "VIOLATION",
"rule": "TN-024",
"term": "isNullable",
"message": "carries no rdfs:comment"
},
{
"verdict": "VIOLATION",
"rule": "TN-024",
"term": "lastReviewDate",
"message": "carries no rdfs:comment"
},
{
"verdict": "REVIEW",
"rule": "TN-025",
"term": "lastReviewDate",
"message": "declares itself polymorphic; confirm the scope is declared in SHACL"
},
{
"verdict": "VIOLATION",
"rule": "TN-024",
"term": "logicalFormat",
"message": "carries no rdfs:comment"
},
{
"verdict": "VIOLATION",
"rule": "TN-024",
"term": "ordinalPosition",
"message": "carries no rdfs:comment"
},
{
"verdict": "VIOLATION",
"rule": "TN-024",
"term": "physicalDataType",
"message": "carries no rdfs:comment"
},
{
"verdict": "REVIEW",
"rule": "TN-025",
"term": "physicalName",
"message": "declares itself polymorphic; confirm the scope is declared in SHACL"
},
{
"verdict": "VIOLATION",
"rule": "TN-024",
"term": "queryCount",
"message": "carries no rdfs:comment"
},
{
"verdict": "REVIEW",
"rule": "TN-025",
"term": "sourceIdentifier",
"message": "declares itself polymorphic; confirm the scope is declared in SHACL"
},
{
"verdict": "VIOLATION",
"rule": "TN-024",
"term": "status",
"message": "carries no rdfs:comment"
},
{
"verdict": "REVIEW",
"rule": "TN-025",
"term": "status",
"message": "declares itself polymorphic; confirm the scope is declared in SHACL"
},
{
"verdict": "VIOLATION",
"rule": "TN-024",
"term": "synonym",
"message": "carries no rdfs:comment"
},
{
"verdict": "VIOLATION",
"rule": "TN-024",
"term": "technicalDefinition",
"message": "carries no rdfs:comment"
},
{
"verdict": "VIOLATION",
"rule": "TN-024",
"term": "timeAggregation",
"message": "carries no rdfs:comment"
},
{
"verdict": "REVIEW",
"rule": "TN-025",
"term": "unit",
"message": "declares itself polymorphic; confirm the scope is declared in SHACL"
},
{
"verdict": "VIOLATION",
"rule": "TN-024",
"term": "version",
"message": "carries no rdfs:comment"
},
{
"verdict": "REVIEW",
"rule": "TN-025",
"term": "version",
"message": "declares itself polymorphic; confirm the scope is declared in SHACL"
},
{
"verdict": "VIOLATION",
"rule": "TN-024",
"term": "viewDefinition",
"message": "carries no rdfs:comment"
},
{
"verdict": "VIOLATION",
"rule": "TN-024",
"term": "acronym",
"message": "carries no rdfs:comment"
}
]
}
+179
View File
@@ -0,0 +1,179 @@
# T-BOX AND A-BOX MIGRATION TO v2.2 — GOVERNANCE ASSIGNMENTS
# =============================================================================
# Data consumed by migrate_tbox_v2_2.py.
#
# An actor node is an ASSIGNMENT, not a person. The post is stable and carries
# the canonical name, the identifier and the domain it answers to; the person
# holding it is a value that changes. Modelled the other way round, a change of
# incumbent would move nodes and edges rather than one literal, and the same
# person covering two domains would exist twice.
# =============================================================================
meta:
from_version: "2.1"
to_version: "2.2"
namespace: "https://ontology.pernod-ricard.com/metamodel/"
instance_namespace: "https://data.pernod-ricard.com/sodh/"
# -----------------------------------------------------------------------------
# T-BOX
# -----------------------------------------------------------------------------
declare_properties:
- term: personName
kind: DatatypeProperty
domain: Actor
range: "http://www.w3.org/2001/XMLSchema#string"
comment: >
The name of the person currently holding the post. Declared on Actor rather
than on each role, since every role shares it and Actor is their smallest
common ancestor below the layer root. Held as a value rather than a node
because what changes is the person, not the assignment: the post keeps its
identifier, its domain and every edge that points at it, and a change of
holder rewrites one literal. Used on actors and nowhere else.
display_updates:
- {term: DataSteward, acronym: DST}
# -----------------------------------------------------------------------------
# A-BOX — RENAME
# ST is not the short form of Data Steward; DST is, and the rulebook now
# declares it.
# -----------------------------------------------------------------------------
instance_renames:
- {from: ST_DD_04, to: DST_DD_04}
- {from: ST_DD_05, to: DST_DD_05}
- {from: ST_DD_06, to: DST_DD_06}
- {from: ST_DD_10, to: DST_DD_10}
- {from: ST_DD_16, to: DST_DD_16}
- {from: ST_DD_21, to: DST_DD_21}
# -----------------------------------------------------------------------------
# A-BOX — COMPLETE THE EXISTING ASSIGNMENTS
# The canonical name becomes the name of the POST. The person moves to heldBy.
# -----------------------------------------------------------------------------
complete:
- {node: DDO_DD_04, identifier: "DDO-04", name: "Data Domain Owner", held_by: "David Carpio", domain: DD_04}
- {node: DDO_DD_05, identifier: "DDO-05", name: "Data Domain Owner", held_by: "David Carpio", domain: DD_05}
- {node: DDO_DD_06, identifier: "DDO-06", name: "Data Domain Owner", held_by: "Ludovic Cottier", domain: DD_06}
- {node: DDO_DD_10, identifier: "DDO-10", name: "Data Domain Owner", held_by: "Alberto Lupano", domain: DD_10}
- {node: DDO_DD_16, identifier: "DDO-16", name: "Data Domain Owner", held_by: "Julien Soisson", domain: DD_16}
- {node: DDO_DD_21, identifier: "DDO-21", name: "Data Domain Owner", held_by: "Vincent Meunier", domain: DD_21}
- {node: DGL_DD_04, identifier: "DGL-04", name: "Data Governance Lead", held_by: "Marie Carabin", domain: DD_04}
- {node: DGL_DD_05, identifier: "DGL-05", name: "Data Governance Lead", held_by: "Alberic Piot", domain: DD_05}
- {node: DGL_DD_06, identifier: "DGL-06", name: "Data Governance Lead", held_by: "Bastien Gourdon", domain: DD_06}
- {node: DGL_DD_10, identifier: "DGL-10", name: "Data Governance Lead", held_by: "Anas El Kesri", domain: DD_10}
- {node: DGL_DD_16, identifier: "DGL-16", name: "Data Governance Lead", held_by: "Gaelle Seret", domain: DD_16}
- {node: DGL_DD_21, identifier: "DGL-21", name: "Data Governance Lead", held_by: "Bastien Gourdon", domain: DD_21}
# -----------------------------------------------------------------------------
# A-BOX — SUB-DOMAIN OWNERS, 10
# -----------------------------------------------------------------------------
create_sub_domain_owners:
- {node: SDO_SD_04_01, identifier: "SDO-04.01", held_by: "Etienne Coulon", sub_domain: SD_04_01, domain: DD_04}
- {node: SDO_SD_04_02, identifier: "SDO-04.02", held_by: "Etienne Coulon", sub_domain: SD_04_02, domain: DD_04}
- {node: SDO_SD_04_03, identifier: "SDO-04.03", held_by: "Etienne Coulon", sub_domain: SD_04_03, domain: DD_04}
- {node: SDO_SD_05_01, identifier: "SDO-05.01", held_by: "David Carpio", sub_domain: SD_05_01, domain: DD_05}
- {node: SDO_SD_06_01, identifier: "SDO-06.01", held_by: "Loic Berger", sub_domain: SD_06_01, domain: DD_06}
- {node: SDO_SD_10_01, identifier: "SDO-10.01", held_by: "Alberto Lupano", sub_domain: SD_10_01, domain: DD_10}
- {node: SDO_SD_16_01, identifier: "SDO-16.01", held_by: "Julien Soisson", sub_domain: SD_16_01, domain: DD_16}
- {node: SDO_SD_16_02, identifier: "SDO-16.02", held_by: "Julien Soisson", sub_domain: SD_16_02, domain: DD_16}
- {node: SDO_SD_16_03, identifier: "SDO-16.03", held_by: "Julien Soisson", sub_domain: SD_16_03, domain: DD_16}
- {node: SDO_SD_21_01, identifier: "SDO-21.01", held_by: "Vincent Meunier", sub_domain: SD_21_01, domain: DD_21}
sub_domain_owner_name: Data Sub Domain Owner
# -----------------------------------------------------------------------------
# A-BOX — PRODUCT OWNER, 1
# -----------------------------------------------------------------------------
create_product_owners:
- {node: PO_SODH, identifier: "PO-06.01-001", held_by: "Guilherme Muller", product: DP_06_01_001, domain: DD_06}
product_owner_name: Data Product Owner
# -----------------------------------------------------------------------------
# A-BOX — STEWARDS, ONE ASSIGNMENT PER BUSINESS OBJECT
# Derived from the graph rather than listed: the business objects are the source
# of truth for how many assignments exist and which domain each answers to.
# The incumbent is read from this map by domain.
# -----------------------------------------------------------------------------
steward_holders:
DD_04: Marc Chambouleyron
DD_05: Francois Vadrouille
DD_06: Michael Flores
DD_10: Magda Kugli
DD_16: Clementine Sauries
DD_21: Alberic Piot
steward_name: Data Steward
# The six per-domain steward nodes are replaced by one per business object.
# monitoredBy is repointed from the old node to the new one, and the old node is
# withdrawn once nothing names it (EV-003).
retire_domain_stewards: true
# -----------------------------------------------------------------------------
# A-BOX — GOVERN A TRANSVERSE CONCEPT
# Panel Coverage is used by four business objects through usesConcept and is the
# subject of none. A concept mobilised without an object that carries it has no
# owner, no steward and no governance chain: it is used without being governed.
# Creating the missing business object closes the chain BC -> BO -> SD -> DD,
# and the steward assignment follows automatically since assignments are derived
# from the business objects present in the graph.
# -----------------------------------------------------------------------------
create_business_objects:
- node: BO_06_01_006
identifier: "BO-06.01-006"
name: Panel Coverage
is_about: BC_06_01_003
sub_domain: SD_06_01
domain: DD_06
# -----------------------------------------------------------------------------
# A-BOX — ownedBy AGAINST THE OLD DOMAIN-LEVEL STEWARDS
# 53 subjects declared they belonged to the per-domain steward node. That node
# disappears, so each reference is resolved rather than dropped in silence.
#
# business object -> removed. It already carries monitoredBy towards the
# same assignment, and two properties holding one fact
# are two answers to one question waiting to diverge.
# sub-domain -> repointed to its Data Sub Domain Owner, whose function
# this is; a steward had no business holding it.
# everything else -> repointed to the steward assignment of the business
# object it belongs to, followed through the graph.
# -----------------------------------------------------------------------------
owned_by_resolution:
remove_on: [BusinessObject]
repoint_sub_domains: true
repoint_to_business_object_assignment: true
# Chains followed to reach the business object, in order. The first that
# resolves wins; anything unresolved is reported and left untouched.
chains:
- {via: isAbout, direction: inbound} # a BO is about this concept
- {via: represents, direction: outbound} # this object represents a BO
- {via: measures, direction: outbound} # a metric measures a concept, then isAbout
# -----------------------------------------------------------------------------
# COMMON
# -----------------------------------------------------------------------------
status: PUBLISHED
version: "1.0"
# -----------------------------------------------------------------------------
# SHAPES — NR-002 EXEMPTION
# -----------------------------------------------------------------------------
shape_notes:
- rule: NR-002
change: exempt descendants of Actor from the canonical-name uniqueness query
reason: >
An assignment is a post, not a catalogued object. Five steward posts in one
domain are legitimately homonymous; what tells them apart is the identifier
and the business object that names them. Applying a uniqueness rule written
for catalogued objects would force fifteen invented names for one job.
+166
View File
@@ -0,0 +1,166 @@
# T-BOX MIGRATION TO v2.1 — DECLARATIONS
# =============================================================================
# Data consumed by migrate_tbox_v2_1.py. No renaming here: v2.1 declares what
# v2.0 left silent.
#
# Three subjects, each closing a class of TN-025, TN-026 and TN-027 findings:
# 1. polymorphism made explicit rather than inferred from prose
# 2. domains stated wherever a single class of use exists
# 3. provenance moved off the terms and onto the concrete classes
# =============================================================================
meta:
from_version: "2.0"
to_version: "2.1"
namespace: "https://ontology.pernod-ricard.com/metamodel/"
# -----------------------------------------------------------------------------
# THE ANNOTATION ITSELF (TN-025)
# -----------------------------------------------------------------------------
declare_annotations:
- term: polymorphic
label: polymorphic
comment: >
Declares that a property deliberately carries no rdfs:domain because it
serves several classes with no useful common ancestor. Its scope is
controlled class by class in SHACL instead. Without this axiom a deliberate
omission and a forgotten one look identical, and no check can tell them
apart.
# -----------------------------------------------------------------------------
# POLYMORPHIC PROPERTIES — 18
#
# The test: a property takes as domain the smallest common ancestor of its
# classes of use, PROVIDED that ancestor sits below a layer root. A layer root,
# a provenance axis and MetaModelObject are all too high to say anything; a
# property whose smallest common ancestor is one of those is polymorphic.
# -----------------------------------------------------------------------------
polymorphic:
- {term: identifier, reason: "borne by every object in the graph"}
- {term: canonicalName, reason: "borne by every object in the graph"}
- {term: status, reason: "borne by every object in the graph"}
- {term: version, reason: "borne by every object in the graph"}
- {term: creationDate, reason: "borne by every object in the graph"}
- {term: lastReviewDate, reason: "borne by every object in the graph"}
- {term: arbitrationStatus, reason: "any declared object may be under arbitration"}
- {term: ownedByDomain, reason: "smallest common ancestor is a provenance axis, not a layer class"}
- {term: harvestDate, reason: "smallest common ancestor is a provenance axis, not a layer class"}
- {term: sourceIdentifier, reason: "smallest common ancestor is a provenance axis, not a layer class"}
- {term: belongsTo, reason: "sub-domain to domain and business object to sub-domain: two couples with no useful ancestor"}
- {term: storedIn, reason: "two granularities, data object to structure and data element to field"}
- {term: represents, reason: "data object to business object and data element to business concept"}
- {term: unit, reason: "data element and metric sit in different layers"}
- {term: sourcedFrom, reason: "data object and data structure sit in different layers"}
- {term: fullyQualifiedName, reason: "DATABASE.SCHEMA.RELATION[.COLUMN] names a structure and a column alike"}
- {term: ownedBy, reason: "any object may have an owner; Actor is the range, the domain is free"}
- {term: exampleValue, reason: "observed on a field, documented on a data element"}
- {term: physicalName, reason: "a data object names its table as a data element names its column; BR-013 names one carrier where there are two"}
# -----------------------------------------------------------------------------
# DOMAINS TO DECLARE — 18
# -----------------------------------------------------------------------------
domains:
- {term: formula, domain: Metric, basis: "shape; forbidden on BusinessConcept, which is what distinguishes a metric"}
- {term: timeAggregation, domain: Metric, basis: "belongs to the calculation, alongside formula"}
- {term: businessDefinition, domain: BusinessConcept, basis: "shape; forbidden on BusinessObject, whose meaning lives in its concept"}
- {term: businessRule, domain: BusinessConcept, basis: "meaning belongs to the concept"}
- {term: synonym, domain: BusinessConcept, basis: "meaning belongs to the concept"}
- {term: technicalDefinition, domain: BusinessConcept, basis: "meaning belongs to the concept"}
- {term: monitoredBy, domain: BusinessObject, basis: "shape; forbidden on DataElement and DataObject, where stewardship is inherited"}
- {term: logicalFormat, domain: DataElement, basis: "a logical characteristic of the element"}
- {term: isNullable, domain: Field, basis: "a property of the column"}
- {term: ordinalPosition, domain: Field, basis: "a property of the column"}
- {term: physicalDataType, domain: Field, basis: "a property of the column"}
- {term: isInSchema, domain: DataStructure, basis: "shape"}
- {term: viewDefinition, domain: View, basis: "a table has no SQL definition"}
- {term: queryCount, domain: DataStructure, basis: "ACCESS_HISTORY traces tables, views and external tables alike"}
- {term: lastQueryDate, domain: DataStructure, basis: "ACCESS_HISTORY traces tables, views and external tables alike"}
- {term: definitionAddress, domain: Transformation, basis: "the Git URI of the code behind a transformation; a view uses viewDefinition"}
- {term: expression, domain: BusinessIntelligenceField, basis: "the calculated-field formula, compared against the metric formula to detect drift"}
# -----------------------------------------------------------------------------
# NEW TERM (procedure C1)
# -----------------------------------------------------------------------------
declare_properties:
- term: serviceCommitment
kind: DatatypeProperty
domain: DataContract
range: "http://www.w3.org/2001/XMLSchema#string"
comment: >
What the contract guarantees about the product it covers: refresh schedule,
freshness, quality thresholds, support channel, notice period on a breaking
change. Held as prose for now, which no shape can verify; the structured
form, aligned on ODCS, is the next step. Recorded here because the
commitment had been written into businessRule for want of anywhere else,
where it sat next to genuine business rules and could not be told apart.
# -----------------------------------------------------------------------------
# RANGES MISSING ON POLYMORPHIC PROPERTIES
# A polymorphic property may still have a single range: what it points AT can be
# settled even when what it starts FROM cannot.
# -----------------------------------------------------------------------------
ranges: []
# -----------------------------------------------------------------------------
# PROVENANCE (TN-026)
#
# A vocabulary term is declared, always and by definition. What has a provenance
# is the INSTANCES, and the concrete class is where the model states which one.
# Nothing is inherited: each class declares its own mode, and the mode agrees
# with the provenance axis the class descends from.
# -----------------------------------------------------------------------------
provenance:
strip_from_non_concrete: true # abstract classes, properties, vocabulary terms
derive_from_axis: true # CapturedObject -> HARVESTED, DefinedObject -> ASSERTED
# -----------------------------------------------------------------------------
# ATTACHMENT (TN-027)
# -----------------------------------------------------------------------------
attach:
- term: GovernanceLayerObject
parent: DefinedObject
basis: >
A layer root sits on a provenance axis like any other. Actors and governance
objects are declared by governance, never harvested, so the governance layer
descends from DefinedObject and its five roles inherit the axis.
# SystemType remains outside the layers: it is a controlled-vocabulary class,
# the named and closed exception of TN-027. Recorded as an open arbitration
# rather than silently resolved here.
# -----------------------------------------------------------------------------
# INSTANCE CORRECTIONS
# Each one was surfaced by declaring a domain: an instance carrying a property
# outside its domain is retyped by RDFS inference, and the shapes of the wrong
# class then fire on it.
# -----------------------------------------------------------------------------
instance_fixes:
- action: remove
property: businessDefinition
subjects: [DI_06_01_001, DI_06_01_002]
reason: >
Meaning belongs to the Business Concept. An interface is a channel through
which data is delivered; it defines nothing. Same rule the shapes already
enforce on Business Object.
- action: remove
property: sourcedFrom
subjects: [DI_06_01_001, DI_06_01_002]
reason: >
An interface has no system of truth: the data it exposes is sourced by the
Data Objects behind it, and those name the system.
- action: move
property: businessRule
to: serviceCommitment
subjects: [DC_06_01_001]
reason: >
The text is a service commitment, not a business rule: refresh window,
notice period, support channel. It was written into businessRule for want
of a property to hold it.
@@ -0,0 +1,33 @@
{
"attempt_timestamp": "2026-08-06T12:21:45",
"mode": "dry-run",
"from_version": "2.0",
"to_version": "2.1",
"input_checksums": {
"ontology/pr_metamodel.ttl": "32929c506d402738807b8b30c04fbb48"
},
"steps": {
"annotations_declared": 1,
"polymorphic_declared": 18,
"domains_declared": 18,
"attached": 1,
"provenance_stripped": 5,
"provenance_declared": 29,
"provenance_no_axis": 1,
"version_bumped": 1
},
"total_changes": 74,
"triples_before": 723,
"triples_after": 786,
"notes": [
"annotations_declared: polymorphic",
"attached: GovernanceLayerObject -> DefinedObject",
"provenance_stripped: storedIn",
"provenance_stripped: primarilyStoredIn",
"provenance_stripped: DefinedObject",
"provenance_stripped: exposesMetric",
"provenance_stripped: CapturedObject",
"provenance_no_axis: SystemType",
"version_bumped: https://ontology.pernod-ricard.com/metamodel/2.1"
]
}
@@ -0,0 +1,33 @@
{
"attempt_timestamp": "2026-08-06T12:22:48",
"mode": "apply",
"from_version": "2.0",
"to_version": "2.1",
"input_checksums": {
"ontology/pr_metamodel.ttl": "32929c506d402738807b8b30c04fbb48"
},
"steps": {
"annotations_declared": 1,
"polymorphic_declared": 18,
"domains_declared": 18,
"attached": 1,
"provenance_stripped": 5,
"provenance_declared": 29,
"provenance_no_axis": 1,
"version_bumped": 1
},
"total_changes": 74,
"triples_before": 723,
"triples_after": 786,
"notes": [
"annotations_declared: polymorphic",
"attached: GovernanceLayerObject -> DefinedObject",
"provenance_stripped: DefinedObject",
"provenance_stripped: CapturedObject",
"provenance_stripped: storedIn",
"provenance_stripped: exposesMetric",
"provenance_stripped: primarilyStoredIn",
"provenance_no_axis: SystemType",
"version_bumped: https://ontology.pernod-ricard.com/metamodel/2.1"
]
}
@@ -0,0 +1,18 @@
{
"attempt_timestamp": "2026-08-06T12:22:50",
"mode": "dry-run",
"from_version": "2.0",
"to_version": "2.1",
"input_checksums": {
"ontology/pr_metamodel.ttl": "04273379898d04b9952de68004de5768"
},
"steps": {
"provenance_no_axis": 1
},
"total_changes": 1,
"triples_before": 786,
"triples_after": 786,
"notes": [
"provenance_no_axis: SystemType"
]
}
@@ -0,0 +1,49 @@
{
"attempt_timestamp": "2026-08-06T14:34:17",
"mode": "dry-run",
"from_version": "2.0",
"to_version": "2.1",
"input_checksums": {
"ontology/pr_metamodel.ttl": "32929c506d402738807b8b30c04fbb48",
"instances/sodh.ttl": "6bef7bad65c091738cd58cf5f0f1a395"
},
"steps": {
"annotations_declared": 1,
"properties_declared": 1,
"polymorphic_declared": 19,
"domains_declared": 17,
"attached": 1,
"provenance_stripped": 5,
"provenance_declared": 29,
"provenance_no_axis": 1,
"instances_removed": 4,
"instances_moved": 1,
"version_bumped": 1
},
"total_changes": 80,
"triples_before": {
"ontology/pr_metamodel.ttl": 723,
"instances/sodh.ttl": 2308
},
"triples_after": {
"ontology/pr_metamodel.ttl": 791,
"instances/sodh.ttl": 2304
},
"notes": [
"annotations_declared: polymorphic",
"properties_declared: serviceCommitment",
"attached: GovernanceLayerObject -> DefinedObject",
"provenance_stripped: CapturedObject",
"provenance_stripped: storedIn",
"provenance_stripped: primarilyStoredIn",
"provenance_stripped: DefinedObject",
"provenance_stripped: exposesMetric",
"provenance_no_axis: SystemType",
"instances_removed: DI_06_01_001 businessDefinition",
"instances_removed: DI_06_01_002 businessDefinition",
"instances_removed: DI_06_01_001 sourcedFrom",
"instances_removed: DI_06_01_002 sourcedFrom",
"instances_moved: DC_06_01_001 businessRule",
"version_bumped: https://ontology.pernod-ricard.com/metamodel/2.1"
]
}
@@ -0,0 +1,49 @@
{
"attempt_timestamp": "2026-08-06T14:35:03",
"mode": "apply",
"from_version": "2.0",
"to_version": "2.1",
"input_checksums": {
"ontology/pr_metamodel.ttl": "32929c506d402738807b8b30c04fbb48",
"instances/sodh.ttl": "6bef7bad65c091738cd58cf5f0f1a395"
},
"steps": {
"annotations_declared": 1,
"properties_declared": 1,
"polymorphic_declared": 19,
"domains_declared": 17,
"attached": 1,
"provenance_stripped": 5,
"provenance_declared": 29,
"provenance_no_axis": 1,
"instances_removed": 4,
"instances_moved": 1,
"version_bumped": 1
},
"total_changes": 80,
"triples_before": {
"ontology/pr_metamodel.ttl": 723,
"instances/sodh.ttl": 2308
},
"triples_after": {
"ontology/pr_metamodel.ttl": 791,
"instances/sodh.ttl": 2304
},
"notes": [
"annotations_declared: polymorphic",
"properties_declared: serviceCommitment",
"attached: GovernanceLayerObject -> DefinedObject",
"provenance_stripped: storedIn",
"provenance_stripped: primarilyStoredIn",
"provenance_stripped: DefinedObject",
"provenance_stripped: CapturedObject",
"provenance_stripped: exposesMetric",
"provenance_no_axis: SystemType",
"instances_removed: DI_06_01_001 businessDefinition",
"instances_removed: DI_06_01_002 businessDefinition",
"instances_removed: DI_06_01_001 sourcedFrom",
"instances_removed: DI_06_01_002 sourcedFrom",
"instances_moved: DC_06_01_001 businessRule",
"version_bumped: https://ontology.pernod-ricard.com/metamodel/2.1"
]
}
@@ -0,0 +1,25 @@
{
"attempt_timestamp": "2026-08-06T14:35:07",
"mode": "dry-run",
"from_version": "2.0",
"to_version": "2.1",
"input_checksums": {
"ontology/pr_metamodel.ttl": "d264f924c61faa2fe8caf7be8ec7490c",
"instances/sodh.ttl": "135278dd413486cb1273fe193a5aa590"
},
"steps": {
"provenance_no_axis": 1
},
"total_changes": 1,
"triples_before": {
"ontology/pr_metamodel.ttl": 791,
"instances/sodh.ttl": 2304
},
"triples_after": {
"ontology/pr_metamodel.ttl": 791,
"instances/sodh.ttl": 2304
},
"notes": [
"provenance_no_axis: SystemType"
]
}
@@ -0,0 +1,49 @@
{
"attempt_timestamp": "2026-08-07T10:27:29",
"mode": "apply",
"from_version": "2.0",
"to_version": "2.1",
"input_checksums": {
"ontology/pr_metamodel.ttl": "32929c506d402738807b8b30c04fbb48",
"instances/sodh.ttl": "6bef7bad65c091738cd58cf5f0f1a395"
},
"steps": {
"annotations_declared": 1,
"properties_declared": 1,
"polymorphic_declared": 19,
"domains_declared": 17,
"attached": 1,
"provenance_stripped": 5,
"provenance_declared": 29,
"provenance_no_axis": 1,
"instances_removed": 4,
"instances_moved": 1,
"version_bumped": 1
},
"total_changes": 80,
"triples_before": {
"ontology/pr_metamodel.ttl": 723,
"instances/sodh.ttl": 2308
},
"triples_after": {
"ontology/pr_metamodel.ttl": 791,
"instances/sodh.ttl": 2304
},
"notes": [
"annotations_declared: polymorphic",
"properties_declared: serviceCommitment",
"attached: GovernanceLayerObject -> DefinedObject",
"provenance_stripped: storedIn",
"provenance_stripped: exposesMetric",
"provenance_stripped: DefinedObject",
"provenance_stripped: CapturedObject",
"provenance_stripped: primarilyStoredIn",
"provenance_no_axis: SystemType",
"instances_removed: DI_06_01_001 businessDefinition",
"instances_removed: DI_06_01_002 businessDefinition",
"instances_removed: DI_06_01_001 sourcedFrom",
"instances_removed: DI_06_01_002 sourcedFrom",
"instances_moved: DC_06_01_001 businessRule",
"version_bumped: https://ontology.pernod-ricard.com/metamodel/2.1"
]
}
@@ -0,0 +1,41 @@
{
"attempt_timestamp": "2026-08-06T15:17:04",
"mode": "dry-run",
"from_version": "2.1",
"to_version": "2.2",
"input_checksums": {
"ontology/pr_metamodel.ttl": "d264f924c61faa2fe8caf7be8ec7490c",
"instances/sodh.ttl": "135278dd413486cb1273fe193a5aa590"
},
"steps": {
"properties_declared": 1,
"display_updated": 1,
"instances_renamed": 80,
"assignments_completed": 12,
"sub_domain_owners_created": 10,
"product_owners_created": 1,
"stewards_created": 15,
"steward_still_referenced": 6,
"version_bumped": 1
},
"total_changes": 127,
"triples_before": {
"ontology/pr_metamodel.ttl": 791,
"instances/sodh.ttl": 2304
},
"triples_after": {
"ontology/pr_metamodel.ttl": 796,
"instances/sodh.ttl": 2557
},
"notes": [
"properties_declared: heldBy",
"display_updated: DataSteward -> DST",
"steward_still_referenced: DST_DD_04 cited by 8 subject(s)",
"steward_still_referenced: DST_DD_05 cited by 3 subject(s)",
"steward_still_referenced: DST_DD_06 cited by 23 subject(s)",
"steward_still_referenced: DST_DD_10 cited by 4 subject(s)",
"steward_still_referenced: DST_DD_16 cited by 11 subject(s)",
"steward_still_referenced: DST_DD_21 cited by 4 subject(s)",
"version_bumped: https://ontology.pernod-ricard.com/metamodel/2.2"
]
}
@@ -0,0 +1,47 @@
{
"attempt_timestamp": "2026-08-06T15:23:27",
"mode": "dry-run",
"from_version": "2.1",
"to_version": "2.2",
"input_checksums": {
"ontology/pr_metamodel.ttl": "d264f924c61faa2fe8caf7be8ec7490c",
"instances/sodh.ttl": "135278dd413486cb1273fe193a5aa590"
},
"steps": {
"properties_declared": 1,
"display_updated": 1,
"instances_renamed": 80,
"assignments_completed": 12,
"sub_domain_owners_created": 10,
"product_owners_created": 1,
"stewards_created": 15,
"ownedby_removed": 15,
"ownedby_repointed_to_sdo": 7,
"ownedby_repointed_to_steward": 30,
"ownedby_unresolved": 1,
"domain_stewards_retired": 5,
"steward_still_referenced": 1,
"version_bumped": 1
},
"total_changes": 180,
"triples_before": {
"ontology/pr_metamodel.ttl": 791,
"instances/sodh.ttl": 2304
},
"triples_after": {
"ontology/pr_metamodel.ttl": 796,
"instances/sodh.ttl": 2532
},
"notes": [
"properties_declared: heldBy",
"display_updated: DataSteward -> DST",
"ownedby_unresolved: BC_06_01_003 reaches no business object",
"domain_stewards_retired: DST_DD_04",
"domain_stewards_retired: DST_DD_05",
"steward_still_referenced: DST_DD_06 cited by 1 subject(s)",
"domain_stewards_retired: DST_DD_10",
"domain_stewards_retired: DST_DD_16",
"domain_stewards_retired: DST_DD_21",
"version_bumped: https://ontology.pernod-ricard.com/metamodel/2.2"
]
}
@@ -0,0 +1,46 @@
{
"attempt_timestamp": "2026-08-06T19:49:37",
"mode": "dry-run",
"from_version": "2.1",
"to_version": "2.2",
"input_checksums": {
"ontology/pr_metamodel.ttl": "d264f924c61faa2fe8caf7be8ec7490c",
"instances/sodh.ttl": "135278dd413486cb1273fe193a5aa590"
},
"steps": {
"properties_declared": 1,
"display_updated": 1,
"instances_renamed": 80,
"assignments_completed": 12,
"sub_domain_owners_created": 10,
"product_owners_created": 1,
"business_objects_created": 1,
"stewards_created": 16,
"ownedby_repointed_to_steward": 31,
"ownedby_removed": 15,
"ownedby_repointed_to_sdo": 7,
"domain_stewards_retired": 6,
"version_bumped": 1
},
"total_changes": 182,
"triples_before": {
"ontology/pr_metamodel.ttl": 791,
"instances/sodh.ttl": 2304
},
"triples_after": {
"ontology/pr_metamodel.ttl": 796,
"instances/sodh.ttl": 2546
},
"notes": [
"properties_declared: heldBy",
"display_updated: DataSteward -> DST",
"business_objects_created: BO_06_01_006",
"domain_stewards_retired: DST_DD_04",
"domain_stewards_retired: DST_DD_05",
"domain_stewards_retired: DST_DD_06",
"domain_stewards_retired: DST_DD_10",
"domain_stewards_retired: DST_DD_16",
"domain_stewards_retired: DST_DD_21",
"version_bumped: https://ontology.pernod-ricard.com/metamodel/2.2"
]
}
@@ -0,0 +1,46 @@
{
"attempt_timestamp": "2026-08-06T19:51:46",
"mode": "apply",
"from_version": "2.1",
"to_version": "2.2",
"input_checksums": {
"ontology/pr_metamodel.ttl": "d264f924c61faa2fe8caf7be8ec7490c",
"instances/sodh.ttl": "135278dd413486cb1273fe193a5aa590"
},
"steps": {
"properties_declared": 1,
"display_updated": 1,
"instances_renamed": 80,
"assignments_completed": 12,
"sub_domain_owners_created": 10,
"product_owners_created": 1,
"business_objects_created": 1,
"stewards_created": 16,
"ownedby_repointed_to_steward": 31,
"ownedby_removed": 15,
"ownedby_repointed_to_sdo": 7,
"domain_stewards_retired": 6,
"version_bumped": 1
},
"total_changes": 182,
"triples_before": {
"ontology/pr_metamodel.ttl": 791,
"instances/sodh.ttl": 2304
},
"triples_after": {
"ontology/pr_metamodel.ttl": 796,
"instances/sodh.ttl": 2546
},
"notes": [
"properties_declared: heldBy",
"display_updated: DataSteward -> DST",
"business_objects_created: BO_06_01_006",
"domain_stewards_retired: DST_DD_04",
"domain_stewards_retired: DST_DD_05",
"domain_stewards_retired: DST_DD_06",
"domain_stewards_retired: DST_DD_10",
"domain_stewards_retired: DST_DD_16",
"domain_stewards_retired: DST_DD_21",
"version_bumped: https://ontology.pernod-ricard.com/metamodel/2.2"
]
}
@@ -0,0 +1,23 @@
{
"attempt_timestamp": "2026-08-06T19:51:51",
"mode": "dry-run",
"from_version": "2.1",
"to_version": "2.2",
"input_checksums": {
"ontology/pr_metamodel.ttl": "e705cde06956d746631e412719b6cc6d",
"instances/sodh.ttl": "82ced2220c1e0525c1dcd5028afbc546"
},
"steps": {
"instances_renamed": 0
},
"total_changes": 0,
"triples_before": {
"ontology/pr_metamodel.ttl": 796,
"instances/sodh.ttl": 2546
},
"triples_after": {
"ontology/pr_metamodel.ttl": 796,
"instances/sodh.ttl": 2546
},
"notes": []
}
@@ -0,0 +1,46 @@
{
"attempt_timestamp": "2026-08-07T10:21:51",
"mode": "apply",
"from_version": "2.1",
"to_version": "2.2",
"input_checksums": {
"ontology/pr_metamodel.ttl": "32929c506d402738807b8b30c04fbb48",
"instances/sodh.ttl": "6bef7bad65c091738cd58cf5f0f1a395"
},
"steps": {
"properties_declared": 1,
"display_updated": 1,
"instances_renamed": 80,
"assignments_completed": 12,
"sub_domain_owners_created": 10,
"product_owners_created": 1,
"business_objects_created": 1,
"stewards_created": 16,
"ownedby_repointed_to_steward": 31,
"ownedby_removed": 15,
"ownedby_repointed_to_sdo": 7,
"domain_stewards_retired": 6,
"version_bumped": 1
},
"total_changes": 182,
"triples_before": {
"ontology/pr_metamodel.ttl": 723,
"instances/sodh.ttl": 2308
},
"triples_after": {
"ontology/pr_metamodel.ttl": 729,
"instances/sodh.ttl": 2550
},
"notes": [
"properties_declared: personName",
"display_updated: DataSteward -> DST",
"business_objects_created: BO_06_01_006",
"domain_stewards_retired: DST_DD_04",
"domain_stewards_retired: DST_DD_05",
"domain_stewards_retired: DST_DD_06",
"domain_stewards_retired: DST_DD_10",
"domain_stewards_retired: DST_DD_16",
"domain_stewards_retired: DST_DD_21",
"version_bumped: https://ontology.pernod-ricard.com/metamodel/2.2"
]
}
@@ -0,0 +1,23 @@
{
"attempt_timestamp": "2026-08-07T10:21:59",
"mode": "dry-run",
"from_version": "2.1",
"to_version": "2.2",
"input_checksums": {
"ontology/pr_metamodel.ttl": "6f2deb192272d0bceecac31fcb525ad1",
"instances/sodh.ttl": "7c0d42f33590d2208b0af7b2068f559a"
},
"steps": {
"instances_renamed": 0
},
"total_changes": 0,
"triples_before": {
"ontology/pr_metamodel.ttl": 729,
"instances/sodh.ttl": 2550
},
"triples_after": {
"ontology/pr_metamodel.ttl": 729,
"instances/sodh.ttl": 2550
},
"notes": []
}
@@ -0,0 +1,46 @@
{
"attempt_timestamp": "2026-08-07T10:27:33",
"mode": "apply",
"from_version": "2.1",
"to_version": "2.2",
"input_checksums": {
"ontology/pr_metamodel.ttl": "96687771a06f85759e7b894f9d248083",
"instances/sodh.ttl": "135278dd413486cb1273fe193a5aa590"
},
"steps": {
"properties_declared": 1,
"display_updated": 1,
"instances_renamed": 80,
"assignments_completed": 12,
"sub_domain_owners_created": 10,
"product_owners_created": 1,
"business_objects_created": 1,
"stewards_created": 16,
"ownedby_repointed_to_steward": 31,
"ownedby_removed": 15,
"ownedby_repointed_to_sdo": 7,
"domain_stewards_retired": 6,
"version_bumped": 1
},
"total_changes": 182,
"triples_before": {
"ontology/pr_metamodel.ttl": 791,
"instances/sodh.ttl": 2304
},
"triples_after": {
"ontology/pr_metamodel.ttl": 797,
"instances/sodh.ttl": 2546
},
"notes": [
"properties_declared: personName",
"display_updated: DataSteward -> DST",
"business_objects_created: BO_06_01_006",
"domain_stewards_retired: DST_DD_04",
"domain_stewards_retired: DST_DD_05",
"domain_stewards_retired: DST_DD_06",
"domain_stewards_retired: DST_DD_10",
"domain_stewards_retired: DST_DD_16",
"domain_stewards_retired: DST_DD_21",
"version_bumped: https://ontology.pernod-ricard.com/metamodel/2.2"
]
}
+296
View File
@@ -0,0 +1,296 @@
#!/usr/bin/env python3
"""
Migrate the Pernod Ricard Data MetaModel from v2.0 to v2.1.
USAGE
python3 migrate_tbox_v2_1.py # dry run, writes nothing
python3 migrate_tbox_v2_1.py --apply
v2.1 renames nothing. It declares what v2.0 left silent: which properties are
polymorphic on purpose, what domain the others take, and how the instances of
each concrete class are produced.
DESIGN
EV-004 dry run is the default
EV-005 every edit goes through rdflib
EV-006 guards test the target state, so a replay reports zero change
EV-015 an execution log is written for every attempt
"""
import argparse
import datetime
import hashlib
import json
import os
import re
import sys
from collections import OrderedDict
import yaml
from rdflib import Graph, Literal, Namespace, RDF, RDFS, OWL, URIRef
HERE = os.path.dirname(os.path.abspath(__file__))
SPEC = os.path.join(HERE, "declarations_v2_1.yaml")
class Report(object):
def __init__(self):
self.steps = OrderedDict()
self.notes = []
def add(self, step, n=1, detail=None):
self.steps[step] = self.steps.get(step, 0) + n
if detail:
self.notes.append("%s: %s" % (step, detail))
@property
def total(self):
return sum(self.steps.values())
def md5(path):
h = hashlib.md5()
with open(path, "rb") as fh:
for chunk in iter(lambda: fh.read(65536), b""):
h.update(chunk)
return h.hexdigest()
def derive_label(name):
"""TN-018 and TN-023: a label is derived from the local name, never typed."""
spaced = re.sub(r"(?<=[a-z0-9])(?=[A-Z])|(?<=[A-Z])(?=[A-Z][a-z])", " ", name)
return spaced[0].lower() + spaced[1:]
def local(uri, ns):
s = str(uri)
return s[len(ns):] if s.startswith(ns) else None
def ancestors(g, term):
seen, stack = set(), [term]
while stack:
node = stack.pop()
for parent in g.objects(node, RDFS.subClassOf):
if isinstance(parent, URIRef) and parent not in seen:
seen.add(parent)
stack.append(parent)
return seen
def check_version(g, spec, path):
"""EV-010 applied to a migration script: refuse to run against the wrong input.
A script announcing 2.1 -> 2.2 will happily work on a file still in 2.0 and
report success, leaving a state that is neither version. The same silent
failure the shapes are guarded against, one layer down.
"""
ns = spec["meta"]["namespace"]
expected = URIRef(ns.rstrip("/") + "/" + spec["meta"]["from_version"])
target = URIRef(ns.rstrip("/") + "/" + spec["meta"]["to_version"])
found = set(g.objects(None, OWL.versionIRI))
if target in found:
print("ALREADY AT %s — nothing to do." % spec["meta"]["to_version"])
sys.exit(0)
if expected not in found:
print("ABORT — %s declares %s; this script migrates from %s."
% (os.path.basename(path),
", ".join(sorted(str(f) for f in found)) or "no version",
expected))
sys.exit(1)
def migrate(g, instances, spec, report):
ns = spec["meta"]["namespace"]
pr = Namespace(ns)
# 1 — declare the annotation property itself
for a in spec.get("declare_annotations") or []:
term = pr[a["term"]]
if (term, RDF.type, OWL.AnnotationProperty) not in g:
g.add((term, RDF.type, OWL.AnnotationProperty))
g.add((term, RDFS.label, Literal(a["label"])))
g.add((term, RDFS.comment, Literal(" ".join(a["comment"].split()))))
report.add("annotations_declared", 1, a["term"])
# 2 — declare the new properties (procedure C1)
for d in spec.get("declare_properties") or []:
term = pr[d["term"]]
kind = getattr(OWL, d["kind"])
if (term, RDF.type, kind) not in g:
g.add((term, RDF.type, kind))
g.add((term, RDFS.label, Literal(derive_label(d["term"]))))
g.add((term, RDFS.domain, pr[d["domain"]]))
g.add((term, RDFS.range, URIRef(d["range"])))
g.add((term, RDFS.comment, Literal(" ".join(d["comment"].split()))))
report.add("properties_declared", 1, d["term"])
# 3 — mark the deliberately polymorphic properties (TN-025)
for p in spec.get("polymorphic") or []:
term = pr[p["term"]]
if (term, None, None) not in g:
report.add("polymorphic_missing", 1, p["term"])
continue
if (term, pr.polymorphic, Literal(True)) not in g:
g.add((term, pr.polymorphic, Literal(True)))
report.add("polymorphic_declared", 1)
if list(g.objects(term, RDFS.domain)):
report.add("polymorphic_has_domain", 1,
"%s is marked polymorphic and still declares a domain" % p["term"])
# 4 — declare the domains that were merely missing (TN-025)
for d in spec.get("domains") or []:
term, target = pr[d["term"]], pr[d["domain"]]
if (term, None, None) not in g:
report.add("domain_missing_term", 1, d["term"])
continue
if (target, RDF.type, OWL.Class) not in g:
report.add("domain_missing_class", 1, "%s -> %s" % (d["term"], d["domain"]))
continue
if (term, RDFS.domain, target) not in g:
g.remove((term, RDFS.domain, None))
g.add((term, RDFS.domain, target))
report.add("domains_declared", 1)
# 5 — ranges, where a polymorphic property still points at one class
for r in spec.get("ranges") or []:
term, target = pr[r["term"]], pr[r["range"]]
if (term, RDFS.range, target) not in g:
g.remove((term, RDFS.range, None))
g.add((term, RDFS.range, target))
report.add("ranges_declared", 1)
# 6 — attach a layer root to its provenance axis (TN-027)
for a in spec.get("attach") or []:
term, parent = pr[a["term"]], pr[a["parent"]]
if (term, RDFS.subClassOf, parent) not in g:
g.add((term, RDFS.subClassOf, parent))
report.add("attached", 1, "%s -> %s" % (a["term"], a["parent"]))
# 7 — provenance belongs to the concrete classes, and to nothing else (TN-026)
prov = spec.get("provenance") or {}
concrete = {c for c in g.subjects(RDF.type, OWL.Class)
if (c, pr.isAbstract, Literal(False)) in g}
if prov.get("strip_from_non_concrete"):
for s in set(g.subjects(pr.authoringMode, None)):
if s in concrete:
continue
g.remove((s, pr.authoringMode, None))
g.remove((s, pr.harvestSource, None))
report.add("provenance_stripped", 1, local(s, ns))
if prov.get("derive_from_axis"):
for cls in sorted(concrete, key=str):
up = ancestors(g, cls)
if pr.CapturedObject in up:
want = "HARVESTED"
elif pr.DefinedObject in up:
want = "ASSERTED"
else:
report.add("provenance_no_axis", 1, local(cls, ns))
continue
if (cls, pr.authoringMode, Literal(want)) in g:
continue
g.remove((cls, pr.authoringMode, None))
g.add((cls, pr.authoringMode, Literal(want)))
report.add("provenance_declared", 1)
# 8 — instance corrections, each one surfaced by declaring a domain
ex = Namespace("https://data.pernod-ricard.com/sodh/")
for fix in spec.get("instance_fixes") or []:
prop = pr[fix["property"]]
dest = pr[fix["to"]] if fix.get("to") else None
for subject in fix["subjects"]:
node = ex[subject]
for gi in instances:
for _, _, value in list(gi.triples((node, prop, None))):
gi.remove((node, prop, value))
if dest is not None:
gi.add((node, dest, value))
report.add("instances_%sd" % fix["action"], 1,
"%s %s" % (subject, fix["property"]))
# 9 — bump the version (EV-014: the namespace itself never moves)
target = URIRef(ns.rstrip("/") + "/" + spec["meta"]["to_version"])
for onto in set(g.subjects(RDF.type, OWL.Ontology)):
if (onto, OWL.versionIRI, target) not in g:
g.remove((onto, OWL.versionIRI, None))
g.add((onto, OWL.versionIRI, target))
report.add("version_bumped", 1, str(target))
def main():
ap = argparse.ArgumentParser()
ap.add_argument("--apply", action="store_true")
ap.add_argument("--ontology", default=os.path.join(HERE, "..", "..", "ontology",
"pr_metamodel.ttl"))
ap.add_argument("--instances", action="append", default=[])
ap.add_argument("--log-dir", default=os.path.join(HERE, "logs"))
args = ap.parse_args()
if not os.path.exists(args.ontology):
print("MISSING INPUT: %s" % args.ontology)
sys.exit(1)
spec = yaml.safe_load(open(SPEC, encoding="utf-8"))
checksums = OrderedDict((p, md5(p)) for p in [args.ontology] + args.instances)
g = Graph()
g.parse(args.ontology, format="turtle")
instances = []
for path in args.instances:
gi = Graph()
gi.parse(path, format="turtle")
instances.append(gi)
check_version(g, spec, args.ontology)
before = [len(g)] + [len(x) for x in instances]
report = Report()
migrate(g, instances, spec, report)
after = [len(g)] + [len(x) for x in instances]
print("MIGRATION %s -> %s %s"
% (spec["meta"]["from_version"], spec["meta"]["to_version"],
"APPLY" if args.apply else "DRY RUN"))
print()
for step, n in report.steps.items():
print(" %-28s %6d" % (step, n))
print(" %-28s %6d" % ("total changes", report.total))
print()
for path, b, a in zip([args.ontology] + args.instances, before, after):
print(" %-46s %6d -> %6d triples" % (os.path.basename(path), b, a))
if report.notes:
print()
for n in report.notes:
print(" note: " + n)
os.makedirs(args.log_dir, exist_ok=True)
stamp = datetime.datetime.now().strftime("%Y%m%dT%H%M%S")
log_path = os.path.join(args.log_dir, "migration_v2_1_%s.json" % stamp)
json.dump({"attempt_timestamp": datetime.datetime.now().isoformat(timespec="seconds"),
"mode": "apply" if args.apply else "dry-run",
"from_version": spec["meta"]["from_version"],
"to_version": spec["meta"]["to_version"],
"input_checksums": checksums,
"steps": report.steps, "total_changes": report.total,
"triples_before": dict(zip([args.ontology] + args.instances, before)),
"triples_after": dict(zip([args.ontology] + args.instances, after)),
"notes": report.notes},
open(log_path, "w", encoding="utf-8"), indent=2)
print("\n log: %s" % log_path)
if not args.apply:
print("\n DRY RUN — nothing written. Re-run with --apply when the counts "
"above are what you expect.")
return
g.serialize(destination=args.ontology, format="turtle")
for path, gi in zip(args.instances, instances):
gi.serialize(destination=path, format="turtle")
print("\n written. Replay this script now: a second run must report zero "
"changes (EV-006).")
if __name__ == "__main__":
main()
+367
View File
@@ -0,0 +1,367 @@
#!/usr/bin/env python3
"""
Migrate the Pernod Ricard Data MetaModel and the SODH instances to v2.2.
USAGE
python3 migrate_tbox_v2_2.py --ontology ... --instances ...
python3 migrate_tbox_v2_2.py --ontology ... --instances ... --apply
v2.2 turns the actor nodes into governance ASSIGNMENTS: a stable post carrying
an identifier, a domain and a name, plus the person holding it as a value. It
completes the twelve that existed, creates the ten sub-domain owners, the
product owner, and one steward assignment per business object.
DESIGN
EV-004 dry run is the default
EV-005 every edit goes through rdflib
EV-006 guards test the target state, so a replay reports zero change
EV-015 an execution log is written for every attempt
"""
import argparse
import datetime
import hashlib
import json
import os
import re
import sys
from collections import OrderedDict
import yaml
from rdflib import Graph, Literal, Namespace, RDF, RDFS, OWL, URIRef
HERE = os.path.dirname(os.path.abspath(__file__))
SPEC = os.path.join(HERE, "assignments_v2_2.yaml")
class Report(object):
def __init__(self):
self.steps = OrderedDict()
self.notes = []
def add(self, step, n=1, detail=None):
self.steps[step] = self.steps.get(step, 0) + n
if detail:
self.notes.append("%s: %s" % (step, detail))
@property
def total(self):
return sum(self.steps.values())
def derive_label(name):
"""TN-018 and TN-023: a label is derived from the local name, never typed."""
spaced = re.sub(r"(?<=[a-z0-9])(?=[A-Z])|(?<=[A-Z])(?=[A-Z][a-z])", " ", name)
return spaced[0].lower() + spaced[1:]
def md5(path):
h = hashlib.md5()
with open(path, "rb") as fh:
for chunk in iter(lambda: fh.read(65536), b""):
h.update(chunk)
return h.hexdigest()
def rename_node(g, old, new, report, step):
"""EV-001 applied to an instance: rewrite every triple naming the node."""
if (new, None, None) in g and (old, None, None) not in g:
return 0
n = 0
for s, p, o in list(g):
ns, no = (new if s == old else s), (new if o == old else o)
if (ns, no) != (s, o):
g.remove((s, p, o))
g.add((ns, p, no))
n += 1
report.add(step, n)
return n
def assign(g, pr, node, role, identifier, name, held_by, domain, spec, report, step):
"""Write one assignment. The guard tests the identifier, so a replay is a no-op."""
if (node, pr.identifier, Literal(identifier)) in g:
return False
g.add((node, RDF.type, role))
g.remove((node, pr.identifier, None))
g.add((node, pr.identifier, Literal(identifier)))
g.remove((node, pr.canonicalName, None))
g.add((node, pr.canonicalName, Literal(name)))
g.remove((node, pr.personName, None))
if held_by:
g.add((node, pr.personName, Literal(held_by)))
g.remove((node, pr.status, None))
g.add((node, pr.status, Literal(spec["status"])))
g.remove((node, pr.version, None))
g.add((node, pr.version, Literal(spec["version"])))
if domain is not None:
g.remove((node, pr.ownedByDomain, None))
g.add((node, pr.ownedByDomain, domain))
report.add(step, 1)
return True
def check_version(g, spec, path):
"""EV-010 applied to a migration script: refuse to run against the wrong input.
A script announcing 2.1 -> 2.2 will happily work on a file still in 2.0 and
report success, leaving a state that is neither version. The same silent
failure the shapes are guarded against, one layer down.
"""
ns = spec["meta"]["namespace"]
expected = URIRef(ns.rstrip("/") + "/" + spec["meta"]["from_version"])
target = URIRef(ns.rstrip("/") + "/" + spec["meta"]["to_version"])
found = set(g.objects(None, OWL.versionIRI))
if target in found:
print("ALREADY AT %s — nothing to do." % spec["meta"]["to_version"])
sys.exit(0)
if expected not in found:
print("ABORT — %s declares %s; this script migrates from %s."
% (os.path.basename(path),
", ".join(sorted(str(f) for f in found)) or "no version",
expected))
sys.exit(1)
def migrate(onto, data, spec, report):
ns, ins = spec["meta"]["namespace"], spec["meta"]["instance_namespace"]
pr, ex = Namespace(ns), Namespace(ins)
# 1 — declare heldBy on Actor (procedure C1)
for d in spec.get("declare_properties") or []:
term = pr[d["term"]]
if (term, RDF.type, getattr(OWL, d["kind"])) not in onto:
onto.add((term, RDF.type, getattr(OWL, d["kind"])))
onto.add((term, RDFS.label, Literal(derive_label(d["term"]))))
onto.add((term, RDFS.domain, pr[d["domain"]]))
onto.add((term, RDFS.range, URIRef(d["range"])))
onto.add((term, RDFS.comment, Literal(" ".join(d["comment"].split()))))
report.add("properties_declared", 1, d["term"])
# 2 — the short form of Data Steward is DST, and the rulebook now says so
for u in spec.get("display_updates") or []:
term = pr[u["term"]]
if (term, pr.acronym, Literal(u["acronym"])) not in onto:
onto.remove((term, pr.acronym, None))
onto.add((term, pr.acronym, Literal(u["acronym"])))
report.add("display_updated", 1, "%s -> %s" % (u["term"], u["acronym"]))
# 3 — rename the instance nodes that carried the wrong short form
for r in spec.get("instance_renames") or []:
rename_node(data, ex[r["from"]], ex[r["to"]], report, "instances_renamed")
# 4 — complete the assignments that already existed
for c in spec.get("complete") or []:
node = ex[c["node"]]
if (node, None, None) not in data:
report.add("complete_missing", 1, c["node"])
continue
role = next(iter(data.objects(node, RDF.type)), None)
assign(data, pr, node, role, c["identifier"], c["name"],
c["held_by"], ex[c["domain"]], spec, report, "assignments_completed")
# 5 — sub-domain owners
sdo_of = {}
for s in spec.get("create_sub_domain_owners") or []:
node = ex[s["node"]]
sdo_of[ex[s["sub_domain"]]] = node
if assign(data, pr, node, pr.DataSubDomainOwner, s["identifier"],
spec["sub_domain_owner_name"], s["held_by"], ex[s["domain"]],
spec, report, "sub_domain_owners_created"):
data.add((ex[s["sub_domain"]], pr.hasSubDomainOwner, node))
# 6 — product owners
for p in spec.get("create_product_owners") or []:
node = ex[p["node"]]
product = ex[p["product"]]
if (product, None, None) not in data:
report.add("product_missing", 1, p["product"])
continue
if assign(data, pr, node, pr.DataProductOwner, p["identifier"],
spec["product_owner_name"], p["held_by"], ex[p["domain"]],
spec, report, "product_owners_created"):
data.add((product, pr.hasProductOwner, node))
# 7 — create the business objects that close a governance chain
for b in spec.get("create_business_objects") or []:
node = ex[b["node"]]
if (node, pr.identifier, Literal(b["identifier"])) in data:
continue
data.add((node, RDF.type, pr.BusinessObject))
data.add((node, pr.identifier, Literal(b["identifier"])))
data.add((node, pr.canonicalName, Literal(b["name"])))
data.add((node, pr.isAbout, ex[b["is_about"]]))
data.add((node, pr.belongsTo, ex[b["sub_domain"]]))
data.add((node, pr.ownedByDomain, ex[b["domain"]]))
data.add((node, pr.status, Literal(spec["status"])))
data.add((node, pr.version, Literal(spec["version"])))
report.add("business_objects_created", 1, b["node"])
# 8 — one steward assignment per business object, derived from the graph
holders = spec.get("steward_holders") or {}
steward_of = {}
for bo in sorted(data.subjects(RDF.type, pr.BusinessObject), key=str):
ident = next((str(v) for v in data.objects(bo, pr.identifier)), None)
if not ident:
report.add("steward_no_identifier", 1, str(bo).replace(ins, ""))
continue
suffix = ident.split("-", 1)[1] # 06.01-001
domain_key = "DD_" + suffix.split(".", 1)[0] # DD_06
holder = holders.get(domain_key)
if holder is None:
report.add("steward_no_holder", 1, domain_key)
continue
node = ex["DST_" + suffix.replace(".", "_").replace("-", "_")]
steward_of[bo] = node
if assign(data, pr, node, pr.DataSteward, "DST-" + suffix,
spec["steward_name"], holder, ex[domain_key],
spec, report, "stewards_created"):
for _, _, old in list(data.triples((bo, pr.monitoredBy, None))):
data.remove((bo, pr.monitoredBy, old))
data.add((bo, pr.monitoredBy, node))
# 9 — resolve every ownedBy pointing at a per-domain steward node
res = spec.get("owned_by_resolution") or {}
old_stewards = {ex[r["from"]] for r in (spec.get("instance_renames") or [])} | \
{ex[r["to"]] for r in (spec.get("instance_renames") or [])}
def business_object_of(subject):
"""Follow the declared chains until a business object is reached."""
for chain in res.get("chains") or []:
prop = pr[chain["via"]]
if chain["direction"] == "inbound":
for candidate in data.subjects(prop, subject):
if (candidate, RDF.type, pr.BusinessObject) in data:
return candidate
else:
for target in data.objects(subject, prop):
if (target, RDF.type, pr.BusinessObject) in data:
return target
for candidate in data.subjects(pr.isAbout, target):
if (candidate, RDF.type, pr.BusinessObject) in data:
return candidate
return None
remove_on = {pr[c] for c in (res.get("remove_on") or [])}
for subject, _, target in list(data.triples((None, pr.ownedBy, None))):
if target not in old_stewards:
continue
types = set(data.objects(subject, RDF.type))
if types & remove_on:
data.remove((subject, pr.ownedBy, target))
report.add("ownedby_removed", 1)
continue
if pr.DataSubDomain in types and res.get("repoint_sub_domains"):
owner = sdo_of.get(subject)
if owner is None:
report.add("ownedby_unresolved", 1,
"%s has no sub-domain owner" % str(subject).replace(ins, ""))
continue
data.remove((subject, pr.ownedBy, target))
data.add((subject, pr.ownedBy, owner))
report.add("ownedby_repointed_to_sdo", 1)
continue
bo = business_object_of(subject)
owner = steward_of.get(bo) if bo is not None else None
if owner is None:
report.add("ownedby_unresolved", 1,
"%s reaches no business object" % str(subject).replace(ins, ""))
continue
data.remove((subject, pr.ownedBy, target))
data.add((subject, pr.ownedBy, owner))
report.add("ownedby_repointed_to_steward", 1)
# 10 — withdraw the per-domain steward nodes, once nothing names them (EV-003)
if spec.get("retire_domain_stewards"):
for r in spec.get("instance_renames") or []:
node = ex[r["to"]]
referrers = {s for s in data.subjects(None, node)}
if referrers:
report.add("steward_still_referenced", 1,
"%s cited by %d subject(s)" % (r["to"], len(referrers)))
continue
n = 0
for t in list(data.triples((node, None, None))):
data.remove(t)
n += 1
if n:
report.add("domain_stewards_retired", 1, r["to"])
# 11 — bump the version
target = URIRef(ns.rstrip("/") + "/" + spec["meta"]["to_version"])
for o in set(onto.subjects(RDF.type, OWL.Ontology)):
if (o, OWL.versionIRI, target) not in onto:
onto.remove((o, OWL.versionIRI, None))
onto.add((o, OWL.versionIRI, target))
report.add("version_bumped", 1, str(target))
def main():
ap = argparse.ArgumentParser()
ap.add_argument("--apply", action="store_true")
ap.add_argument("--ontology", required=True)
ap.add_argument("--instances", required=True)
ap.add_argument("--log-dir", default=os.path.join(HERE, "logs"))
args = ap.parse_args()
for path in (args.ontology, args.instances):
if not os.path.exists(path):
print("MISSING INPUT: %s" % path)
sys.exit(1)
spec = yaml.safe_load(open(SPEC, encoding="utf-8"))
checksums = OrderedDict((p, md5(p)) for p in (args.ontology, args.instances))
onto, data = Graph(), Graph()
onto.parse(args.ontology, format="turtle")
data.parse(args.instances, format="turtle")
check_version(onto, spec, args.ontology)
before = (len(onto), len(data))
report = Report()
migrate(onto, data, spec, report)
after = (len(onto), len(data))
print("MIGRATION %s -> %s %s"
% (spec["meta"]["from_version"], spec["meta"]["to_version"],
"APPLY" if args.apply else "DRY RUN"))
print()
for step, n in report.steps.items():
print(" %-28s %6d" % (step, n))
print(" %-28s %6d" % ("total changes", report.total))
print()
for path, b, a in zip((args.ontology, args.instances), before, after):
print(" %-46s %6d -> %6d triples" % (os.path.basename(path), b, a))
if report.notes:
print()
for n in report.notes:
print(" note: " + n)
os.makedirs(args.log_dir, exist_ok=True)
stamp = datetime.datetime.now().strftime("%Y%m%dT%H%M%S")
log_path = os.path.join(args.log_dir, "migration_v2_2_%s.json" % stamp)
json.dump({"attempt_timestamp": datetime.datetime.now().isoformat(timespec="seconds"),
"mode": "apply" if args.apply else "dry-run",
"from_version": spec["meta"]["from_version"],
"to_version": spec["meta"]["to_version"],
"input_checksums": checksums,
"steps": report.steps, "total_changes": report.total,
"triples_before": dict(zip((args.ontology, args.instances), before)),
"triples_after": dict(zip((args.ontology, args.instances), after)),
"notes": report.notes},
open(log_path, "w", encoding="utf-8"), indent=2)
print("\n log: %s" % log_path)
if not args.apply:
print("\n DRY RUN — nothing written. Re-run with --apply when the counts "
"above are what you expect.")
return
onto.serialize(destination=args.ontology, format="turtle")
data.serialize(destination=args.instances, format="turtle")
print("\n written. Replay this script now: a second run must report zero "
"changes (EV-006).")
if __name__ == "__main__":
main()
+152
View File
@@ -0,0 +1,152 @@
# NAMING LEXICON
# =============================================================================
# The word knowledge the naming checks need and cannot derive. No script can
# decide on its own that "qualified" is a participle and "ordinal" an adjective.
#
# Anything absent from this file is not a violation: it is reported as REVIEW.
# A checker that guessed would either block on false positives or, worse, stay
# silent on real ones. Adding a word here is a deliberate act, reviewed like any
# other change to the vocabulary.
#
# Consumed by check_tbox_naming.py.
# =============================================================================
# -----------------------------------------------------------------------------
# VERBS — TN-009. A relation begins with one of these.
# -----------------------------------------------------------------------------
active_verbs:
- belongs
- computes
- consumes
- constrains
- contains
- depends
- exposes
- has
- lives
- measures
- packages
- produces
- references
- represents
- serves
- sources
- stores
- uses
# A past participle is a verb in first position (TN-009). Listed separately
# because the two forms read differently on an edge: the active says what the
# subject does, the participial what was done to it.
participles:
- computed
- derived
- governed
- hosted
- monitored
- operated
- owned
- packaged
- qualified
- served
- sourced
- stored
# The copula, admitted by TN-010 on object properties only.
copula:
- is
# TN-009: an adverb qualifying the verb is transparent. The checker skips it and
# tests the token that follows.
adverbs:
- primarily
- fully
- partially
- directly
- initially
- currently
# -----------------------------------------------------------------------------
# ACRONYMS — TN-002 and TN-019.
# Forbidden in an IRI, with the single exception of TN-011: a relation
# reproducing the short label of the class it targets carries it as it stands.
# Listed so that the checker names what it found rather than reporting an
# anonymous run of capitals.
# -----------------------------------------------------------------------------
known_acronyms:
BI: Business Intelligence
KPI: Key Performance Indicator
DD: Data Domain
SD: Data Sub Domain
BO: Business Object
DDO: Data Domain Owner
SDO: Data Sub Domain Owner
PO: Data Product Owner
DGL: Data Governance Lead
URI: Uniform Resource Identifier
URL: Uniform Resource Locator
API: Application Programming Interface
SQL: Structured Query Language
ETL: Extract Transform Load
UAT: User Acceptance Testing
# -----------------------------------------------------------------------------
# TYPE WORDS — TN-005. A class name carries none of these unless the word names
# a genuine abstraction of the model, which the exceptions below record.
# -----------------------------------------------------------------------------
type_words:
- Entity
- Item
- Element
- Thing
- Record
type_word_exceptions:
- MetaModelObject
- DefinedObject
- CapturedObject
- OwnershipLayerObject
- BusinessLayerObject
- LogicalLayerObject
- PhysicalLayerObject
- DeliveryLayerObject
- ConsumptionLayerObject
- GovernanceLayerObject
- BusinessObject
- DataElement
# -----------------------------------------------------------------------------
# PLURALS — TN-005. A class name is singular. English plural detection is
# unreliable, so the checker reports a suspected plural as REVIEW and this list
# silences the words that only look plural.
# -----------------------------------------------------------------------------
not_plural:
- Status
- Address
- Business
- Analysis
- Schema
# -----------------------------------------------------------------------------
# RELATIONAL SUFFIXES — TN-014. Reserved for object properties. An attribute
# ending in one of these reads as an edge and will be mistaken for one.
# -----------------------------------------------------------------------------
relational_suffixes:
- By
- In
- From
- On
- To
# -----------------------------------------------------------------------------
# DATE RANGES — TN-013. A property of one of these ranges is a date attribute
# and takes the nominal form.
# -----------------------------------------------------------------------------
date_ranges:
- "http://www.w3.org/2001/XMLSchema#date"
- "http://www.w3.org/2001/XMLSchema#dateTime"
+48 -24
View File
@@ -17,7 +17,7 @@
meta:
title: Pernod Ricard Data MetaModel — T-Box Rulebook
version: "1.2"
version: "1.4"
status: Draft for review
date: "2026-08-03"
scope: >
@@ -249,8 +249,9 @@ rules:
statement: >
The local name of an object property begins with a verb, in lower case. Both
the active form and the passive or participial form are admitted: a past
participle is a verb in first position. No relation begins with a
preposition or with a noun.
participle is a verb in first position. An adverb qualifying that verb is
transparent and may precede it. No relation begins with a preposition or
with a noun.
scope: [object_property]
severity: BLOCKING
control: {tier: script, executor: check_tbox_naming.py, procedure: null}
@@ -258,12 +259,14 @@ rules:
rationale: >
A relation reads as a verb and an attribute reads as a noun. The distinction
is what lets a reader tell an edge from a field without opening the
declaration. The explicit clause on participial forms is required: read
literally, a verb-first rule would condemn a whole family of sound relations
such as computedBy, storedIn and derivedFrom.
declaration. Two clauses are stated rather than left implicit, because a
literal reading would condemn sound relations: the participial one, which
covers a whole family such as computedBy, storedIn and derivedFrom, and the
adverbial one, which covers a relation whose verb is qualified rather than
replaced.
examples:
- {from: "pr:inDatabase", to: "pr:isInDatabase", note: a preposition is not a verb}
- {from: "pr:primaryLocation", to: "pr:primarilyStoredIn", note: a noun is not a verb}
- {from: "pr:primaryLocation", to: "pr:primarilyStoredIn", note: "a noun is not a verb; the adverb is transparent and the verb follows"}
- {from: "pr:computedBy", to: "pr:computedBy", note: participial form is conforming}
- id: TN-010
@@ -596,9 +599,9 @@ rules:
title: Every property declares its domain and range
statement: >
rdfs:domain and rdfs:range are mandatory on every property, EXCEPT where the
property is deliberately polymorphic. A polymorphic property states so in
its comment and has its scope declared in SHACL. Silent absence is a
violation; documented absence is not.
property is deliberately polymorphic. A polymorphic property declares
pr:polymorphic true and has its scope declared in SHACL. Silent absence is a
violation; declared absence is not.
scope: [object_property, datatype_property]
severity: BLOCKING
control: {tier: [script, human], executor: check_tbox_naming.py, procedure: null}
@@ -608,30 +611,51 @@ rules:
properties are polymorphic by design, their scope controlled class by class
in SHACL rather than by twin properties; giving those an rdfs:domain would
trigger the RDFS retyping described in TN-028. The rule therefore separates
the two cases rather than demanding a domain everywhere.
the two cases rather than demanding a domain everywhere. The distinction is
carried by an axiom and not by a sentence in the comment: a checker looking
for a phrase in prose fails the moment someone words it differently, and
what matters is declared rather than read, exactly as for abstractness.
examples:
- {from: a polymorphic property with no domain and no comment, to: violation, note: silence is indistinguishable from omission}
- {from: a polymorphic property with no domain, documented, to: conforming, note: scope declared in SHACL}
- {from: a property with no domain and no declaration, to: violation, note: silence is indistinguishable from omission}
- {from: "a property with no domain, declaring pr:polymorphic true", to: conforming, note: scope declared in SHACL}
- id: TN-026
category: Declaration
title: Every term declares how it was authored
title: Every concrete class declares how its instances are produced
statement: >
pr:authoringMode is mandatory on every term. pr:harvestSource is mandatory
if and only if the mode is HARVESTED.
scope: [class, object_property, datatype_property, annotation_property]
Every CONCRETE CLASS declares pr:authoringMode, stating whether its
instances are ASSERTED by governance or HARVESTED from a system.
pr:harvestSource is mandatory if and only if the mode is HARVESTED. The
declared mode agrees with the provenance axis the class descends from: a
class under CapturedObject declares HARVESTED, one under DefinedObject
declares ASSERTED. Nothing else carries the property: not an abstract class,
not a property, not a term of the vocabulary in its own right.
scope: [class]
severity: BLOCKING
control: {tier: script, executor: check_tbox_naming.py, procedure: null}
filiation: null
rationale: >
Provenance decides who may edit a term and what a divergence means. A term
stating where its data comes from without stating that it is harvested, or
declaring itself harvested without naming a source, is half-declared in a
way no control can catch. Declared symmetrically, provenance also makes a
harvester specifiable from the model itself rather than from a side
document.
A term of the vocabulary is declared, always and by definition; recording
that on every term would repeat one fact a hundred times and say nothing. It
is the INSTANCES that have a provenance, and the class is where the model
states which one: a governance object is asserted by a domain, a physical
column is harvested from a system. The distinction matters because a class
can be declared and describe captured things at once — CapturedObject itself
is a declared term naming harvested instances, and conflating the two is
what once put a harvesting mode on an abstraction.
Nothing is inherited. A subclass declares its own mode rather than taking
its parent's, so that the declaration is read where the instances are typed
and not chased up a chain. The agreement clause is what keeps that free of
contradiction: the axis says what kind of thing the class describes, the
mode says how those things arrive, and a class under CapturedObject
declaring ASSERTED is stating both at once.
Declared this way, provenance also makes a harvester specifiable from the
model itself rather than from a side document.
examples:
- {from: harvestSource present, authoringMode absent, to: "authoringMode HARVESTED", note: null}
- {from: "authoringMode on an abstract class", to: nothing, note: an abstraction has no instances to produce}
- {from: "a class under CapturedObject declaring ASSERTED", to: violation, note: the mode contradicts the axis}
- {from: "authoringMode HARVESTED, harvestSource absent", to: harvestSource declared, note: null}
- id: TN-027