#!/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()