Skip to content

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.

Cost-code alias reconciliation through a canonical registry Aliases GL1001, GL-1001, and gl 1001 from three source systems flow into an idempotent normalizer that strips delimiters and upper-cases to a single comparison key GL1001. The key is resolved against the canonical registry: an exact registered-alias hit scores 0.99, a fuzzy alias match is scored by SequenceMatcher, and no match scores 0.00. The score crosses a confidence gate with three bands: 0.92 and above auto-routes to the canonical code GL-1001, 0.75 to 0.92 holds for human review, and below 0.75 quarantines to the dead-letter queue. Auto-routed and confirmed records aggregate Decimal cost against the one canonical account. ERP export GL1001 Procore GL-1001 field CSV gl 1001 normalize_alias() — pure · idempotent upper-case · strip non-alphanumerics → key "GL1001" CanonicalRegistry.resolve() — first tier wins registered alias exact key hit · conf 0.99 fuzzy alias SequenceMatcher ratio unmatched no candidate · conf 0.00 confidence band site-canonical bands ≥ 0.92 0.75 – 0.92 < 0.75 auto_route use canonical human_review estimator confirms quarantine dead-letter queue Aggregate Decimal cost by one canonical account GL-1001
One canonical account absorbs every source spelling once the normalizer, registry, and confidence gate agree on identity.

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 example GL-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, and gl 1001 collapse to the single key GL1001. Matching happens on keys, never on raw strings.
  • Idempotent normalization. normalize_alias is 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.750.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.

← Back to Budget Code Standardization