Reconciling Cost Code Aliases with a Canonical Registry
Every construction system spells the same cost code a little differently: the ERP exports GL1001, Procore returns GL-1001, and a field CSV carries gl 1001. To a string comparison these are three different accounts, so committed cost splits three ways and the monthly rollup never ties out. This page solves one precise problem: how to collapse platform-specific cost-code aliases into a single canonical identity through a registry, an idempotent normalizer, and confidence-scored alias matching, so figures aggregate correctly no matter which system originated them. The registry holds each canonical general-ledger account alongside the aliases that have been observed for it; the normalizer reduces any inbound spelling to a stable comparison key; and the resolver emits a routing decision — auto-route, human review, or quarantine — backed by a confidence score and the original alias for audit. It is the reconciliation layer beneath budget code standardization within a deterministic construction data architecture and taxonomy, and it targets Python automation builders who need one dependable account key across ERP, project management, and spreadsheet sources.
Key Rules and Specification
Reconciliation is reliable only when identity is decided before any money is summed. These rules govern every resolution:
- Canonical account shape. The registry’s canonical key is the enterprise general-ledger account rendered in one fixed form — here
GL-NNNN(for exampleGL-1001) — validated by regex before it leaves the resolver, so a malformed target can never anchor a rollup. - Normalize before matching. The comparison key is produced by upper-casing and stripping every non-alphanumeric character, so
GL1001,GL-1001, andgl 1001collapse to the single keyGL1001. Matching happens on keys, never on raw strings. - Idempotent normalization.
normalize_aliasis a pure function: re-running it on its own output is a no-op, so a broker redelivery produces the identical key and reconciliation stays deterministic across retries. - Confidence bands drive routing. Scores are site-canonical: 0.92 and above auto-routes to the canonical account, 0.75 to 0.92 flags for human review, and below 0.75 quarantines the record to a dead-letter queue. An exact registered-alias key hit scores
0.99; a fuzzy candidate is scored by string similarity. - Every record keeps its origin. The resolution preserves the raw alias and its source system for the audit trail, so a disputed rollup can always be traced back to the exact inbound token.
- Aliases accrete, they do not overwrite. When an estimator confirms a human-review match, the new spelling is added to the canonical entry’s alias set, so the next occurrence resolves as an exact hit rather than being re-scored.
| Match tier | Trigger | Confidence | Route state |
|---|---|---|---|
registered_alias |
Normalized key is a known alias of a canonical code | 0.99 | auto_route |
fuzzy_alias (high) |
Best SequenceMatcher ratio ≥ 0.92 |
ratio | auto_route |
fuzzy_alias (mid) |
Best ratio in [0.75, 0.92) |
ratio | human_review |
unmatched |
No candidate, or best ratio < 0.75 |
0.00 | quarantine |
Production Code Example
The registry below indexes each canonical account by the normalized keys of its known aliases, so an exact hit is an O(1) dictionary lookup and only genuinely novel spellings fall through to fuzzy scoring. The resolution is a frozen Pydantic v2 contract whose confidence is a bounded Decimal and whose canonical code is regex-validated, so an invalid identity raises at construction time rather than corrupting a total downstream. Pattern-matching details follow the standard Python re module documentation.
from __future__ import annotations
import logging
import re
from decimal import Decimal
from difflib import SequenceMatcher
from typing import Literal
from pydantic import BaseModel, ConfigDict, Field, field_validator
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s | %(levelname)-8s | %(message)s",
datefmt="%Y-%m-%d %H:%M:%S",
)
logger = logging.getLogger("cost_code_registry")
# Canonical general-ledger account key, e.g. "GL-1001".
CANONICAL_RE = re.compile(r"^GL-\d{4}$")
# Any non-alphanumeric run is a delimiter to be stripped during normalization.
NON_ALNUM_RE = re.compile(r"[^A-Z0-9]+")
MatchType = Literal["registered_alias", "fuzzy_alias", "unmatched"]
RouteState = Literal["auto_route", "human_review", "quarantine"]
AUTO_ROUTE = Decimal("0.92") # site-canonical: >= 0.92 adopts the canonical code
HUMAN_REVIEW = Decimal("0.75") # 0.75–0.92 holds for an estimator to confirm
def normalize_alias(raw: str) -> str:
"""Reduce any source spelling to one comparison key. Pure and idempotent.
'GL1001', 'GL-1001', and 'gl 1001' all collapse to 'GL1001', so a second
pass over the output changes nothing and a broker retry is a no-op.
"""
return NON_ALNUM_RE.sub("", raw.strip().upper())
class AliasResolution(BaseModel):
"""A validated alias->canonical resolution carrying its own routing decision."""
model_config = ConfigDict(frozen=True)
source_alias: str = Field(..., description="Raw inbound spelling, preserved for audit")
source_system: str = Field(..., description="Originating platform, for the audit trail")
canonical_code: str | None = Field(default=None)
match_type: MatchType
confidence: Decimal = Field(..., ge=0, le=1)
@field_validator("canonical_code")
@classmethod
def _valid_canonical(cls, v: str | None) -> str | None:
# None is legal only for the quarantine path; a present code must be canonical.
if v is not None and not CANONICAL_RE.match(v):
raise ValueError(f"Non-canonical account key: {v!r}")
return v
@property
def route_state(self) -> RouteState:
if self.canonical_code is not None and self.confidence >= AUTO_ROUTE:
return "auto_route"
if self.confidence >= HUMAN_REVIEW:
return "human_review"
return "quarantine"
class CanonicalRegistry:
"""Reconciles platform-specific aliases to one canonical account key."""
def __init__(self, entries: dict[str, list[str]]) -> None:
# entries maps a canonical code -> the raw alias spellings observed for it.
self._by_key: dict[str, str] = {}
for canonical, aliases in entries.items():
if not CANONICAL_RE.match(canonical):
raise ValueError(f"Registry seeded with bad canonical code: {canonical!r}")
# A canonical code is its own alias, so a clean source resolves exactly.
for spelling in (canonical, *aliases):
self._by_key[normalize_alias(spelling)] = canonical
def learn(self, canonical: str, alias: str) -> None:
"""Promote a confirmed human-review spelling to an exact future hit."""
self._by_key[normalize_alias(alias)] = canonical
def resolve(self, raw_alias: str, source_system: str) -> AliasResolution:
key = normalize_alias(raw_alias)
# 1. Exact registered alias — highest confidence, auto-routes.
if key in self._by_key:
return AliasResolution(
source_alias=raw_alias, source_system=source_system,
canonical_code=self._by_key[key],
match_type="registered_alias", confidence=Decimal("0.99"),
)
# 2. Fuzzy match against every known key; the band decides the route.
best_key, best_score = None, 0.0
for candidate in self._by_key:
score = SequenceMatcher(None, key, candidate).ratio()
if score > best_score:
best_key, best_score = candidate, score
confidence = Decimal(str(round(best_score, 2)))
if best_key is not None and confidence >= HUMAN_REVIEW:
return AliasResolution(
source_alias=raw_alias, source_system=source_system,
canonical_code=self._by_key[best_key],
match_type="fuzzy_alias", confidence=confidence,
)
# 3. Nothing credible matched — quarantine, never guess an account.
logger.warning("No canonical match for %r from %s; quarantining", raw_alias, source_system)
return AliasResolution(
source_alias=raw_alias, source_system=source_system,
canonical_code=None, match_type="unmatched", confidence=confidence,
)Once identity is settled, aggregation is trivial and — critically — safe: only auto-routed records post automatically, everything else is held for triage, and every monetary sum is computed in Decimal per the Python decimal module so no float subtraction drifts a rollup. The batch below isolates each row, emits a structured audit line via the Python Logging HOWTO pattern for anything that does not auto-route, and totals committed cost against the one canonical account.
def reconcile_batch(
registry: CanonicalRegistry,
rows: list[tuple[str, str, Decimal]],
) -> dict[str, Decimal]:
"""Resolve (alias, source_system, amount) rows and total the auto-routed ones."""
ledger: dict[str, Decimal] = {}
for raw_alias, source_system, amount in rows:
resolution = registry.resolve(raw_alias, source_system)
if resolution.route_state == "auto_route" and resolution.canonical_code:
running = ledger.get(resolution.canonical_code, Decimal("0.00"))
ledger[resolution.canonical_code] = running + amount
else:
# Held or quarantined: audit it, never fold a guessed account into a total.
logger.info("AUDIT | %s | %s", resolution.route_state, resolution.model_dump_json())
return ledger
if __name__ == "__main__":
registry = CanonicalRegistry(
{
"GL-1001": ["GL1001", "gl 1001", "1001-CONC"],
"GL-2200": ["GL2200", "GL 2200", "2200-STEEL"],
}
)
rows = [
("GL1001", "erp", Decimal("150000.00")), # exact alias -> auto
("gl-1001", "procore", Decimal("42500.50")), # normalizes to a known key
("GL 2200", "field_csv", Decimal("87250.00")), # exact after normalize
("GL-1O01", "ocr", Decimal("9900.00")), # OCR slip: fuzzy -> review
("MISC-XYZ", "email", Decimal("500.00")), # no match -> quarantine
]
for code, total in reconcile_batch(registry, rows).items():
print(f"{code}: ${total:,.2f}")Common Mistakes and Gotchas
Aggregating before reconciling identity. The most expensive mistake is summing committed cost keyed on the raw source string. GL1001 and GL-1001 become two ledger lines, the concrete account looks half-funded, and the error is invisible until audit. Always resolve to the canonical code first, and only fold auto-routed records into a total — held and quarantined rows must stay out of the sum until a human confirms them.
A normalizer that erases meaningful distinctions. Stripping all punctuation is right for delimiter noise but wrong if two genuinely different accounts differ only by a character your normalizer discards — for instance if GL-1001A (a phase sub-account) collapses onto GL-1001. Keep alphanumerics intact and strip only true delimiters; where sub-accounts exist, model them as their own canonical entries rather than letting normalization merge them silently.
Trusting a fuzzy match into the auto-route band on a dense registry. When the registry holds near-identical keys, an OCR slip like GL-1O01 (letter O for zero) can score above 0.92 and auto-post against the wrong account. On short, dense keys, require an exact registered-alias hit for auto-route and push every fuzzy candidate into the human-review band, where an estimator either confirms it — teaching the registry via learn() — or rejects it. That held state is exactly what fallback alert routing exists to surface, the reconciliation cousin of a dead-letter queue.
Where This Fits in the Pipeline
This registry is the identity layer directly beneath budget code standardization inside the construction data architecture and taxonomy. Upstream, an alias only reaches it after the payload clears schema validation rules at the ingestion boundary, so the resolver deals with a well-formed string and worries only about identity. Alongside it, WBS mapping strategies answer the orthogonal question of where the cost sits in scope while this layer answers which account it belongs to; the canonical code produced here becomes a clean join key for both. Quarantined and human-review records hand off to fallback alert routing so nothing exits unresolved, and because the confidence bands are the same 0.92 and 0.75 thresholds used across every routing decision on the site, an estimator reading a held cost-code record sees the identical vocabulary they see on a held RFI or budget record.
Frequently Asked Questions
Why normalize to a delimiter-free key instead of matching raw strings?
Source systems disagree on separators and casing — GL1001, GL-1001, and gl 1001 are the same account spelled three ways — so raw string equality fails across systems and splits one account into several ledger lines. Collapsing every spelling to one upper-cased, alphanumeric-only key makes the lookup a clean dictionary hit and lets a single regex contract reject a malformed canonical code at the boundary.
How do the confidence bands map to action here?
They are site-canonical. An exact registered-alias key hit scores 0.99 and auto-routes to the canonical account. A fuzzy candidate scored 0.92 or above also auto-routes, a score in the 0.75–0.92 band holds for an estimator to confirm, and anything below 0.75 quarantines to the dead-letter queue rather than posting against a guessed account.
What happens to an alias with no canonical match?
It is quarantined, not folded into a total. The resolver returns a None canonical code, a low confidence score, and the raw alias plus its source system for audit, then hands the record to fallback alert routing for triage. Summing an unrecognized alias into a catch-all account is exactly what makes misreconciled cost look tied-out until audit.
Why is every money field a Decimal?
Reconciliation exists so figures aggregate correctly, and binary floating point cannot represent most currency values exactly, so repeated float additions drift a rollup by cents that compound across thousands of rows. Every amount stays a Decimal from ingestion through aggregation, so the totalled ledger reconciles to the penny.
How does the registry improve over time?
When an estimator confirms a human-review match, learn() records the new spelling as an alias of the canonical code, so the next occurrence resolves as an exact 0.99 hit instead of being re-scored. The registry accretes real-world spellings rather than overwriting them, and normalization keeps every added variant idempotent.
Related
- Budget Code Standardization
- Standardizing budget cost codes across Procore and Sage 300
- WBS Mapping Strategies
- Schema Validation Rules
- Fallback Alert Routing
← Back to Budget Code Standardization