Files
data-meta-model/scripts/apply_arbitration_sodh.py
T

280 lines
14 KiB
Python
Raw Normal View History

#!/usr/bin/env python3
"""
SODH arbitration - composedOf -> aboutConcept + usesConcept
============================================================
Second half of the v1.0 -> v1.1 migration. migrate_instances_v1_1.py did the
mechanical renames and deliberately stopped here, because everything below is a
judgement someone had to make.
USAGE
python3 scripts/apply_arbitration_sodh.py # dry run
python3 scripts/apply_arbitration_sodh.py --apply # rewrite, .bak kept
WHAT WAS ARBITRATED (Bastien, 2026-07-27)
-----------------------------------------
The v0.6 padding is what made this necessary: four Business Objects belonging
to other domains -- Product Structure, Customer & Outlet, Currency, Calendar --
were all declared composedOf the same three Sell Out concepts, because BR-004
demanded three and there were none to give. The subject rule replaces that
minimum: exactly one subject, and if it cannot be named the object is not
scoped.
1. Distribution -> Retail Distribution.
"Distribution" is too generic to survive contact with other domains, and
it is NOT a kind of sell out: sell out counts units sold, distribution
measures product presence in store. Retail Distribution follows panel
vocabulary (Nielsen/Circana), under which Numeric and Weighted
Distribution are the metrics.
2. Sell Out Baseline becomes a concept in its own right, not a flavour of
Sell Out: modelled non-promoted volume is a different notion from
observed volume.
3. Customer & Outlet splits into two Business Objects, and Currency &
Exchange Rates likewise. An object named "X & Y" has no single subject,
which is precisely what the rule is meant to surface. The split is
PROPOSED here; DD-04 and DD-16 own the decision.
4. Six concepts belonging to other domains are created with a DRAFT
definition and arbitrationStatus TO_ARBITRATE. A draft to correct beats a
blank to fill, and OW-007 blocks publication until the owning domain has
ratified. These definitions are the DGO's proposal, not DD-04/10/16/21's
word.
NOT DONE HERE
hasGranularity on Metrics still needs re-expressing as hasGrainElement on
Data Objects, and the BR-013 alignment (10 mother metrics, computedBy)
is a separate migration. Until that lands, every Data Element will fail
the XOR-of-meaning shape -- which is the correct, informative result.
"""
import os
import re
import shutil
import sys
REPO = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
TARGET = os.path.join(REPO, "instances", "sodh.ttl")
W = 78
# ---------------------------------------------------------------- new concepts
# (iri, identifier, name, domain, steward, definition, arbitrate)
NEW_CONCEPTS = [
("ex:BC_06_01_005", "BC-06.01-005", "Sell Out Baseline", "ex:DD_06", "ex:ST_DD_06",
"The modelled level of retail sales that would have occurred without promotional activity, "
"used as the reference against which promotional uplift is measured. Distinct from Sell Out, "
"which is observed rather than modelled.",
False),
("ex:BC_10_01_001", "BC-10.01-001", "Product", "ex:DD_10", "ex:ST_DD_10",
"A commercial item sold by Pernod Ricard, identified at Stock Keeping Unit level and "
"positioned in the corporate hierarchy from Brand through Category, Sub-Category and Flavor.",
True),
("ex:BC_04_01_001", "BC-04.01-001", "Customer", "ex:DD_04", "ex:ST_DD_04",
"A commercial counterparty that buys Pernod Ricard products for resale, positioned in the "
"trade hierarchy from Distributor through Tier-1 and Tier-2 Customer. Distinct from Outlet, "
"which is a physical point of sale.",
True),
("ex:BC_04_01_002", "BC-04.01-002", "Outlet", "ex:DD_04", "ex:ST_DD_04",
"A physical point of sale where products are made available to end consumers, characterised "
"by its Channel. An Outlet belongs to a Customer but is not the same notion: one Customer "
"operates many Outlets.",
True),
("ex:BC_16_01_001", "BC-16.01-001", "Currency", "ex:DD_16", "ex:ST_DD_16",
"A monetary unit in which a transaction or a reported amount is denominated, identified by "
"its ISO 4217 code.",
True),
("ex:BC_16_01_002", "BC-16.01-002", "Exchange Rate", "ex:DD_16", "ex:ST_DD_16",
"The conversion factor applied between two currencies at a given date, used to restate local "
"amounts into group reporting currency. Distinct from Currency: a rate is a dated "
"relationship between two of them.",
True),
("ex:BC_21_01_001", "BC-21.01-001", "Calendar Date", "ex:DD_21", "ex:ST_DD_21",
"A single day in the Gregorian calendar, the atomic unit from which Week, Month, Quarter and "
"Year are derived and against which all time-based measures are positioned.",
True),
]
# ------------------------------------------------------------------- new object
NEW_OBJECTS = [
("ex:BO_04_02_001", "BO-04.02-001", "Outlet", "ex:DD_04", "ex:ST_DD_04", "ex:SD_04_02",
"ex:BC_04_01_002", [], "MDM Customer -> SODH [split from BO-04.01-001, proposed to DD-04]"),
("ex:BO_16_01_002", "BO-16.01-002", "Exchange Rate", "ex:DD_16", "ex:ST_DD_16", "ex:SD_16_01",
"ex:BC_16_01_002", [], "Group Finance -> SODH [split from BO-16.01-001, proposed to DD-16]"),
]
# ---------------------------------------------------- subject / used per object
# iri -> (aboutConcept, [usesConcept...], new name or None)
ARBITRATION = {
"ex:BO_06_01_001": ("ex:BC_06_01_001", ["ex:BC_06_01_003", "ex:BC_05_01_001"], None),
"ex:BO_06_01_002": ("ex:BC_06_01_001", ["ex:BC_06_01_003", "ex:BC_05_01_001",
"ex:BC_16_01_001"], None),
"ex:BO_06_01_003": ("ex:BC_06_01_004", ["ex:BC_06_01_003"], None),
"ex:BO_06_01_004": ("ex:BC_06_01_002", ["ex:BC_06_01_001", "ex:BC_06_01_003"], None),
"ex:BO_06_01_005": ("ex:BC_06_01_005", ["ex:BC_06_01_001", "ex:BC_05_01_001"], None),
"ex:BO_10_01_001": ("ex:BC_10_01_001", [], None),
"ex:BO_04_01_001": ("ex:BC_04_01_001", [], "Customer"),
"ex:BO_16_01_001": ("ex:BC_16_01_001", [], "Currency"),
"ex:BO_21_01_001": ("ex:BC_21_01_001", [], None),
"ex:BO_05_01_001": ("ex:BC_05_01_001", [], None),
}
RENAME_CONCEPT = {"ex:BC_06_01_004": ("Distribution", "Retail Distribution")}
def concept_block(iri, ident, name, domain, steward, definition, arbitrate):
lines = [
'%s a pr:BusinessConcept ;' % iri,
' pr:hasIdentifier "%s" ; pr:hasName "%s" ;' % (ident, name),
' pr:owningDomain %s ; pr:ownedBy %s ;' % (domain, steward),
' pr:hasBusinessDefinition "%s" ;' % definition,
]
if arbitrate:
lines.append(' pr:arbitrationStatus "TO_ARBITRATE" ;')
src = "DGO proposal, pending ratification by the owning domain"
else:
src = "SODH Gold"
lines.append(' pr:hasStatus "DRAFT" ; pr:hasVersion "1.1" ; pr:hasSource "%s" .' % src)
return "\n".join(lines)
def object_block(iri, ident, name, domain, steward, subdomain, about, uses, source):
lines = [
'%s a pr:BusinessObject ;' % iri,
' pr:hasIdentifier "%s" ; pr:hasName "%s" ;' % (ident, name),
' pr:owningDomain %s ; pr:ownedBy %s ;' % (domain, steward),
' pr:belongsTo %s ; pr:monitoredBy %s ;' % (subdomain, steward),
' pr:aboutConcept %s ;' % about,
]
if uses:
lines.append(' pr:usesConcept %s ;' % " , ".join(uses))
lines.append(' pr:arbitrationStatus "TO_ARBITRATE" ;')
lines.append(' pr:hasStatus "DRAFT" ; pr:hasVersion "1.1" ; pr:hasSource "%s" .' % source)
return "\n".join(lines)
def main():
apply_changes = "--apply" in sys.argv
if not os.path.exists(TARGET):
print("Not found: %s" % TARGET)
sys.exit(2)
text = original = open(TARGET, encoding="utf-8").read()
report = []
# ---- 1. rename the over-generic concept ----------------------------------
for iri, (old, new) in RENAME_CONCEPT.items():
pat = re.compile(r'(%s\b.*?pr:hasName )"%s"' % (re.escape(iri), re.escape(old)), re.S)
text, n = pat.subn(r'\1"%s"' % new, text, count=1)
if n:
report.append("renamed concept %s: %s -> %s" % (iri, old, new))
# ---- 2. rewrite each Business Object -------------------------------------
for iri, (about, uses, newname) in ARBITRATION.items():
m = re.search(r'(^%s a pr:BusinessObject ;.*?\n)(?=^ex:|\Z)' % re.escape(iri),
text, re.S | re.M)
if not m:
report.append("!! block not found: %s" % iri)
continue
block = m.group(1)
# strip what a previous run may have written, so re-running is a no-op.
# Duplicated clauses carrying the SAME value merge under RDF set
# semantics: the graph stays valid and SHACL sees nothing, while the
# file quietly grows a redundant line on every pass.
new_block = re.sub(r'[ \t]*pr:composedOf\s+[^";]*;[ \t]*\n', '', block)
new_block = re.sub(r'[ \t]*pr:(aboutConcept|usesConcept)\s+[^";]*;[ \t]*\n',
'', new_block)
clause = ' pr:aboutConcept %s ;\n' % about
if uses:
clause += ' pr:usesConcept %s ;\n' % " , ".join(uses)
new_block = re.sub(r'(\n)([ \t]*pr:hasMetric|[ \t]*pr:hasBusinessDefinition|[ \t]*pr:hasStatus)',
r'\1%s\2' % clause, new_block, count=1)
# meaning belongs to the Concept, never to the Object
# match the full quoted literal: several definitions contain a semicolon
# inside the string ("Owned by Product & Material; consumed by SODH."),
# which a naive [^;]* would stop at, silently leaving the property behind
new_block, ndef = re.subn(
r'[ \t]*pr:hasBusinessDefinition\s+"(?:[^"\\]|\\.)*"\s*;[ \t]*\n',
'', new_block)
if newname:
new_block = re.sub(r'(pr:hasName )"[^"]*"', r'\1"%s"' % newname, new_block, count=1)
new_block = re.sub(r'(pr:hasSource )"[^"]*"',
r'\1"MDM -> SODH [split proposed, subject arbitrated 2026-07-27]"',
new_block, count=1)
text = text[:m.start(1)] + new_block + text[m.end(1):]
report.append("%-18s about=%s uses=%d%s%s"
% (iri, about.replace("ex:BC_", "BC-"), len(uses),
" def->concept" if ndef else "",
" renamed=%s" % newname if newname else ""))
# ---- 3. insert the missing concepts --------------------------------------
# idempotent: identical duplicated blocks merge in RDF, so SHACL would
# never flag them -- the file grows while the graph stays valid.
todo_c = [c for c in NEW_CONCEPTS
if not re.search(r'^%s a ' % re.escape(c[0]), text, re.M)]
anchor = re.search(r'\n(?=ex:BO_\w+ a pr:BusinessObject ;)', text)
if anchor and todo_c:
blocks = "\n".join(concept_block(*c) for c in todo_c)
text = (text[:anchor.start()] + "\n\n" +
"# --- concepts added in v1.1 migration -------------------------------\n" +
"# Six of these belong to other domains: DRAFT definitions proposed by the\n" +
"# DGO, flagged TO_ARBITRATE. OW-007 blocks publication until ratified.\n" +
blocks + "\n" + text[anchor.start():])
report.append("inserted %d Business Concepts (%d TO_ARBITRATE)"
% (len(todo_c), sum(1 for c in todo_c if c[6])))
elif not todo_c:
report.append("all %d Business Concepts already present, skipped"
% len(NEW_CONCEPTS))
# ---- 4. append the objects produced by the splits ------------------------
todo_o = [o for o in NEW_OBJECTS
if not re.search(r'^%s a ' % re.escape(o[0]), text, re.M)]
tail = re.search(r'\n(?=ex:M_\w+ a pr:Metric ;|ex:DO_\w+ a pr:DataObject ;)', text)
if tail and todo_o:
blocks = "\n".join(object_block(*o) for o in todo_o)
text = (text[:tail.start()] + "\n\n" +
"# --- Business Objects from the v1.1 splits ---------------------------\n" +
"# 'Customer & Outlet' and 'Currency & Exchange Rates' had no single\n" +
"# subject. Split proposed to DD-04 and DD-16, hence TO_ARBITRATE.\n" +
blocks + "\n" + text[tail.start():])
report.append("inserted %d Business Objects from splits" % len(todo_o))
elif not todo_o:
report.append("Business Objects from splits already present, skipped")
# ---- report --------------------------------------------------------------
print()
print("SODH ARBITRATION %s" % ("APPLY" if apply_changes else "DRY RUN"))
print("=" * W)
for line in report:
print(" " + line)
print("=" * W)
doubled = []
for m in re.finditer(r'^(ex:\w+) a pr:\w+ ;(.*?)\.\s*\n', text, re.S | re.M):
for prop in ("pr:hasName", "pr:aboutConcept", "pr:belongsTo"):
if len(re.findall(r'(?<![\w:])%s\s' % re.escape(prop), m.group(2))) > 1:
doubled.append("%s/%s" % (m.group(1), prop))
print(" duplicated single-valued properties: %s"
% (", ".join(doubled) if doubled else "none"))
bo = len(re.findall(r'a pr:BusinessObject', text))
bc = len(re.findall(r'a pr:BusinessConcept', text))
print(" Business Objects %d | Business Concepts %d | composedOf left %d"
% (bo, bc, len(re.findall(r'pr:composedOf', text))))
print(" Business Objects still carrying a definition: %d"
% len([b for b in re.split(r'\.\s*\n(?=\S)', text)
if "pr:BusinessObject" in b and "pr:hasBusinessDefinition" in b]))
print("=" * W)
if text != original and apply_changes:
shutil.copy2(TARGET, TARGET + ".bak")
open(TARGET, "w", encoding="utf-8").write(text)
print(" written, backup at %s.bak" % os.path.basename(TARGET))
elif text != original:
print(" dry run -- re-run with --apply to write")
print()
if __name__ == "__main__":
main()