Normalising Rights Holder Names Across Sources
Resolve every rights holder to a canonical entity before generating any notice: match on a normalised form of the name, keep an explicit alias table for the cases normalisation cannot reach, and never merge two holders automatically on a fuzzy match alone.
Rights holder names arrive from catalogues that were never coordinated. The same organisation appears as City of Example, City of Example Council, Example City GIS, and CITY OF EXAMPLE (Planning Dept.), and every distinct spelling becomes a separate credit in a generated attribution block. The visible symptom is a notice twice as long as it should be; the invisible one is that per-holder obligation tracking, deduplication and contact resolution all silently operate on four entities where there is one. This guide is part of automated attribution mapping workflows, under Geospatial Data Licensing & Compliance Fundamentals.
Automated Python Implementation
The resolver below is deliberately conservative. It resolves confidently where the evidence is strong, proposes candidates where it is not, and never merges on similarity alone.
#!/usr/bin/env python3
"""Resolve raw rights holder strings to canonical entities."""
import json
import re
import unicodedata
from difflib import SequenceMatcher
# Organisational suffixes that carry no identifying information.
NOISE = (
"council", "department", "dept", "office", "authority", "agency",
"administration", "gis", "open data", "programme", "program", "team",
)
def normalise(name):
"""Case-fold, strip accents and punctuation, drop organisational noise."""
text = unicodedata.normalize("NFKD", name)
text = "".join(ch for ch in text if not unicodedata.combining(ch))
text = text.casefold()
text = re.sub(r"[^a-z0-9 ]+", " ", text)
tokens = [t for t in text.split() if t and t not in NOISE]
return " ".join(tokens)
class HolderResolver:
"""Canonical entities plus an explicit alias table."""
def __init__(self, canonical, aliases):
# canonical: {entity_id: display_name}
# aliases: {raw_or_normalised_name: entity_id}
self.canonical = canonical
self.aliases = {normalise(k): v for k, v in aliases.items()}
self.by_norm = {normalise(v): k for k, v in canonical.items()}
def resolve(self, raw):
"""Return (entity_id, confidence, candidates)."""
norm = normalise(raw)
if norm in self.aliases:
return self.aliases[norm], "alias", []
if norm in self.by_norm:
return self.by_norm[norm], "exact", []
scored = sorted(
((SequenceMatcher(None, norm, other).ratio(), eid)
for other, eid in self.by_norm.items()),
reverse=True,
)
near = [eid for score, eid in scored if score >= 0.85]
# A near match is a proposal, never a decision.
return None, "unresolved", near[:3]
def resolve_manifest(layers, resolver):
resolved, queue = [], []
for layer in layers:
entity, how, candidates = resolver.resolve(layer["rights_holder"])
if entity is None:
queue.append({
"raw": layer["rights_holder"],
"layer": layer["id"],
"candidates": [resolver.canonical[c] for c in candidates],
})
else:
resolved.append({**layer, "holder_id": entity, "matched_by": how})
return resolved, queue
if __name__ == "__main__":
import sys
config = json.load(open(sys.argv[1], encoding="utf-8"))
resolver = HolderResolver(config["canonical"], config["aliases"])
resolved, queue = resolve_manifest(config["layers"], resolver)
print(f"resolved {len(resolved)}, queued {len(queue)}")
for item in queue:
print(" ?", item["raw"], "->", item["candidates"] or "no candidates")
The key line is the one that returns None for a near match. Automatic merging on a similarity score is tempting and wrong: North Example District and South Example District normalise to strings that score above almost any threshold you would pick, and merging them attributes one authority’s data to another. Proposing the candidates and requiring a human to add an alias costs one line in a configuration file per organisation, once.
Validation and Pipeline Integration
Two properties are worth asserting, and the second is the one that catches the dangerous class of error.
def test_normalisation_is_idempotent():
for name in ["City of Example Council", "Región de Ejemplo", "DEPT. OF TRANSPORT"]:
assert normalise(normalise(name)) == normalise(name)
def test_distinct_authorities_do_not_collide():
canonical = {"north": "North Example District", "south": "South Example District"}
resolver = HolderResolver(canonical, {})
entity, how, _ = resolver.resolve("North Example District Council")
assert entity == "north"
entity, how, _ = resolver.resolve("Southern Example")
assert entity is None, "a near match must not resolve automatically"
def test_alias_wins_over_fuzzy_match():
canonical = {"dot": "Department of Transport"}
resolver = HolderResolver(canonical, {"DOT": "dot"})
entity, how, _ = resolver.resolve("D.O.T.")
assert entity == "dot" and how == "alias"
Run the resolver in CI over the full manifest and fail the build when the queue is non-empty. That sounds strict, and it is the right default: an unresolved holder means the attribution notice is about to contain a name the organisation has not agreed is distinct, and adding an alias takes a minute. Report the queue with its candidate suggestions so the fix is a copy-paste rather than an investigation.
When the Same Name Is Two Organisations
The failure mode opposite to over-splitting is under-splitting, and it is rarer but considerably worse, because it produces a notice that credits the wrong body rather than one that credits the right body twice.
It arises in three situations that a resolver will happily collapse.
Reorganisation. An authority is abolished and its functions distributed between two successors, both of which inherit part of the name. Layers published before and after the split share a normalised form and are not the same rights holder. The alias table needs a date term for this to be expressible at all: Example County before 2019 is one entity, after 2019 it is two.
Homonymous jurisdictions. Administrative names repeat across countries and, within large countries, across states. A canonical entity list keyed only on name will merge a district in one nation with an identically named district in another the first time a cross-border catalogue is ingested. Including a jurisdiction code in the canonical record — not in the display name — prevents it without cluttering the notice.
Sub-units publishing independently. A transport department and a planning department of the same council may hold rights separately under their respective agreements, and normalisation strips exactly the suffix that distinguished them. Where the sub-unit genuinely is the rights holder, it needs its own canonical entity, and the noise-word list must not remove the token that identifies it.
The practical safeguard is to make the noise list explicit and short, and to review it whenever a new catalogue is ingested. Every word on that list is an assertion that it never distinguishes two rights holders, and each assertion is true until the day a catalogue arrives where it is not. Reviewing the list costs five minutes per ingestion; discovering the error costs a corrected publication.
Long-Term Compliance Best Practices
- Give each canonical entity a stable identifier that is not its name. Organisations rename; a notice generated last year and one generated today should still be recognisably about the same body, and only an opaque identifier makes that traceable.
- Date the aliases. When an authority is renamed or merged, the alias records a historical fact. A
valid_fromfield means a notice regenerated for an archived product can credit the body that existed at the time. - Never delete an alias. Removing one silently re-queues every layer that used it, usually during an unrelated build, and the failure is confusing out of proportion to its cause.
- Keep the alias table with the pipeline, not in a database. It is small, it changes rarely, and every change deserves a review. A file in version control provides that; a table in an operational store does not.
- Report the resolution rate as a metric. A steadily growing unresolved queue is the signal that an upstream catalogue changed its conventions, which is worth knowing before it shows up in a notice.
Related
- Automated Attribution Mapping Workflows — the parent workflow this resolution step feeds
- Attribution Stacking for Multi-Source Basemaps — grouping and displaying the credits once holders are canonical
- Embedding Attribution in GeoPackage Metadata Tables — writing the resolved notice into the data itself
- Building a License Compliance Matrix for Municipal Data — the inventory this resolver runs across