256 lines
12 KiB
Python
256 lines
12 KiB
Python
|
|
#!/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_01_002", "BO-04.01-002", "Outlet", "ex:DD_04", "ex:ST_DD_04", "ex:SD_04_01",
|
||
|
|
"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)
|
||
|
|
|
||
|
|
new_block = re.sub(r'[ \t]*pr:composedOf\s+[^";]*;[ \t]*\n', '', 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 --------------------------------------
|
||
|
|
anchor = re.search(r'\n(?=ex:BO_\w+ a pr:BusinessObject ;)', text)
|
||
|
|
if anchor:
|
||
|
|
blocks = "\n".join(concept_block(*c) for c in NEW_CONCEPTS)
|
||
|
|
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(NEW_CONCEPTS), sum(1 for c in NEW_CONCEPTS if c[6])))
|
||
|
|
|
||
|
|
# ---- 4. append the objects produced by the splits ------------------------
|
||
|
|
tail = re.search(r'\n(?=ex:M_\w+ a pr:Metric ;|ex:DO_\w+ a pr:DataObject ;)', text)
|
||
|
|
if tail:
|
||
|
|
blocks = "\n".join(object_block(*o) for o in NEW_OBJECTS)
|
||
|
|
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(NEW_OBJECTS))
|
||
|
|
|
||
|
|
# ---- report --------------------------------------------------------------
|
||
|
|
print()
|
||
|
|
print("SODH ARBITRATION %s" % ("APPLY" if apply_changes else "DRY RUN"))
|
||
|
|
print("=" * W)
|
||
|
|
for line in report:
|
||
|
|
print(" " + line)
|
||
|
|
print("=" * W)
|
||
|
|
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()
|