Skip to content

Regex Patterns for Parsing Construction Cost Codes

A construction cost code is a small string with a large blast radius: get the pattern wrong and a subcontractor’s invoice posts against the wrong division, a WBS rollup double-counts, or a general-ledger account silently absorbs cost that should have been quarantined. This page solves one precise problem: how to write anchored, named-group regular expressions that reliably parse the three code families a project pipeline actually sees — MasterFormat divisions, enterprise WBS elements, and GL account numbers — normalize each to a canonical form, and route ambiguous tokens by confidence, without the catastrophic backtracking or over-greedy suffix capture that turns a parser into a liability. These patterns are the deterministic core of field extraction techniques: identifiers and cost codes must be exact and auditable, so they are matched with compiled expressions rather than inferred by a probabilistic model. Every pattern here uses the standard Python re module, and the canonical shapes it emits are the same ones consumed downstream by WBS mapping strategies and budget code standardization. It targets Python builders who need a parser that fails loudly on a malformed code instead of matching it by accident.

Cost-code regex dispatcher and confidence routing A top-down diagram. A raw code token enters a pattern dispatcher that tries three anchored compiled patterns in priority order. The first lane matches MasterFormat with the pattern for two-digit division, broadscope, and narrowscope. The second lane matches a WBS element with a project number, a discipline drawn from a closed set, and a sequence. The third lane matches a GL account of four digits with an optional subledger. The matching lane feeds a normalize-and-tag stage that produces a canonical code and a code type. A confidence gate then routes the result three ways: below 0.75 quarantines an unmatched token, 0.75 to 0.92 sends a padded or ambiguous match to human review, and 0.92 or above auto-routes a canonical match to the standardized code store. Raw code token 033000 · PROJ-014-STR-02 · 5000-100 Pattern dispatcher try compiled patterns · first match wins MasterFormat ^\d{2} \d{2} \d{2}$ div · broad · narrow e.g. 03 30 00 WBS element ^PROJ-\d{3}-(ARCH|STR|...)-\d{2}$ project · discipline · seq e.g. PROJ-014-STR-02 GL account ^\d{4}(?:-\d{3})?$ account · optional subledger e.g. 5000-100 Normalize + tag code_type canonical string · named parts quarantine no pattern matched confidence < 0.75 human review padded / ambiguous 0.75 – 0.92 auto-route canonical match confidence ≥ 0.92
The dispatcher tries three anchored patterns in priority order; the matching lane normalizes and tags the code, and the site-canonical confidence bands decide whether it auto-routes, waits for review, or is quarantined.

Key Rules and Specification

A cost-code regex is a contract at the ingestion boundary, and every rule below exists to keep a malformed code from matching by accident:

  • Anchor everything. Every pattern begins with ^ and ends with $. An unanchored pattern matches a code buried in surrounding text — the digits inside INV-033000-REV2 — and hands you a fragment that looks valid. Anchoring forces the whole token to conform.
  • Use fixed-width classes, never open quantifiers. A division field is exactly \d{2}, not \d+. An open quantifier is where greedy suffix capture creeps in: \d+ will happily swallow the sequence digits of a longer code and leave you a plausible but wrong division.
  • Name every capture group. Groups are (?P<div>\d{2}), not positional (\d{2}). Named groups make normalization self-documenting and let a downstream consumer read match["disc"] instead of counting parentheses, which is exactly where off-by-one extraction bugs hide.
  • Constrain the discipline to a closed set inside the pattern. The WBS discipline segment is (?P<disc>ARCH|STR|MEP|CIV|ELEC|PLMB), so an unknown discipline fails the match rather than parsing into a phantom code the pattern would otherwise accept.
  • Avoid nested quantifiers entirely. Patterns like (\d{2}[ ]?)+ allow the engine to split the same input many ways and are the classic source of catastrophic backtracking; a single flat alternation of fixed classes cannot backtrack pathologically.
  • Confidence bands drive routing. A token already in canonical form scores highest and auto-routes; a match that required delimiter reformatting stays high; a legacy five-digit code padded to six lands in the 0.750.92 review band; a token that matches nothing quarantines below 0.75. The thresholds are site-canonical: 0.92 and above auto-routes, 0.75 to 0.92 reviews, below 0.75 quarantines.
Code family Anchored pattern Named groups Canonical form
MasterFormat ^\d{2}[ .-]?\d{2}[ .-]?\d{2}$ div, broad, narrow DD SS NN
WBS element ^PROJ-\d{3}-(ARCH|STR|MEP|CIV|ELEC|PLMB)-\d{2}$ proj, disc, seq PROJ-NNN-DISC-NN
GL account ^\d{4}(?:-\d{3})?$ acct, sub NNNN or NNNN-NNN

Production Code Example

The patterns below are compiled once at import time. Each uses named groups and fixed-width character classes, and each is anchored end to end, so a partial or over-long token fails the match instead of yielding a misleading fragment. The tolerant separator class [ .-]? on the MasterFormat pattern absorbs the delimiter variance between systems without ever admitting a second interpretation of the digits.

from __future__ import annotations

import logging
import re
from decimal import Decimal
from typing import Literal, Optional

from pydantic import BaseModel, ConfigDict, Field

logging.basicConfig(
    level=logging.INFO,
    format="%(asctime)s | %(levelname)-8s | %(message)s",
    datefmt="%Y-%m-%d %H:%M:%S",
)
logger = logging.getLogger("cost_code_regex")

# Anchored, named-group, fixed-width patterns. Compiled once, reused per token.
MASTERFORMAT_RE = re.compile(
    r"^(?P<div>\d{2})[ .\-]?(?P<broad>\d{2})[ .\-]?(?P<narrow>\d{2})$"
)
WBS_RE = re.compile(
    r"^PROJ-(?P<proj>\d{3})-(?P<disc>ARCH|STR|MEP|CIV|ELEC|PLMB)-(?P<seq>\d{2})$"
)
GL_ACCOUNT_RE = re.compile(
    r"^(?P<acct>\d{4})(?:-(?P<sub>\d{3}))?$"
)

CodeType = Literal["masterformat", "wbs", "gl_account", "unknown"]
RouteState = Literal["auto_route", "human_review", "quarantine"]

AUTO_ROUTE = Decimal("0.92")    # site-canonical: >= 0.92 auto-routes
HUMAN_REVIEW = Decimal("0.75")  # 0.75-0.92 holds for review; < 0.75 quarantines


class ParsedCode(BaseModel):
    """A parsed cost code with its canonical form and routing confidence."""

    model_config = ConfigDict(frozen=True)

    raw: str
    code_type: CodeType
    canonical: str
    parts: dict[str, str] = Field(default_factory=dict)
    confidence: Decimal = Field(ge=0, le=1)

    @property
    def route_state(self) -> RouteState:
        if self.confidence >= AUTO_ROUTE:
            return "auto_route"
        if self.confidence >= HUMAN_REVIEW:
            return "human_review"
        return "quarantine"

Normalization is driven entirely by named groups: because each pattern labels its own segments, the canonical string is rebuilt without any positional indexing. The dispatcher tries patterns in priority order and stops at the first match; the legacy five-digit MasterFormat check runs last, and because it pads a missing NarrowScope digit it is scored into the review band rather than trusted outright.

def parse_cost_code(raw: str) -> ParsedCode:
    """Resolve a raw token to a canonical cost code by trying anchored patterns
    in priority order. Confidence reflects how much normalization was needed."""
    token = raw.strip().upper()

    m = MASTERFORMAT_RE.match(token)
    if m:
        canonical = f"{m['div']} {m['broad']} {m['narrow']}"
        # Already spaced canonical -> highest; reformatted from '.'/'-'/'' -> high.
        conf = Decimal("0.99") if token == canonical else Decimal("0.94")
        return ParsedCode(raw=raw, code_type="masterformat", canonical=canonical,
                          parts=m.groupdict(), confidence=conf)

    m = WBS_RE.match(token)
    if m:
        canonical = f"PROJ-{m['proj']}-{m['disc']}-{m['seq']}"
        return ParsedCode(raw=raw, code_type="wbs", canonical=canonical,
                          parts=m.groupdict(), confidence=Decimal("0.97"))

    m = GL_ACCOUNT_RE.match(token)
    if m:
        parts = {k: v for k, v in m.groupdict().items() if v is not None}
        canonical = m["acct"] if m["sub"] is None else f"{m['acct']}-{m['sub']}"
        return ParsedCode(raw=raw, code_type="gl_account", canonical=canonical,
                          parts=parts, confidence=Decimal("0.96"))

    # Legacy 2004 five-digit MasterFormat: pad the NarrowScope, then flag for review.
    digits = re.sub(r"\D", "", token)
    if len(digits) == 5:
        canonical = f"{digits[0:2]} {digits[2:4]} {digits[4]}0"
        logger.info("legacy 5-digit code padded: %s -> %s", raw, canonical)
        return ParsedCode(
            raw=raw, code_type="masterformat", canonical=canonical,
            parts={"div": digits[0:2], "broad": digits[2:4], "narrow": f"{digits[4]}0"},
            confidence=Decimal("0.88"),
        )

    logger.warning("unrecognized cost code, quarantining: %r", raw)
    return ParsedCode(raw=raw, code_type="unknown", canonical="",
                      confidence=Decimal("0.0"))


def parse_batch(tokens: list[str]) -> list[ParsedCode]:
    """Parse a batch with per-token isolation and audit logging of non-auto rows."""
    results: list[ParsedCode] = []
    for tok in tokens:
        parsed = parse_cost_code(tok)
        results.append(parsed)
        if parsed.route_state != "auto_route":
            logger.info("AUDIT | %s", parsed.model_dump_json())
    return results


if __name__ == "__main__":
    samples = ["03 30 00", "033000", "26-05-19", "PROJ-014-STR-02",
               "5000-100", "5000", "03300", "GARBAGE-1"]
    for p in parse_batch(samples):
        print(f"{p.raw:<16} -> {p.canonical:<18} {p.code_type:<12} {p.route_state}")

Common Mistakes and Gotchas

Unanchored patterns that match a substring. A pattern like \d{2} \d{2} \d{2} without ^/$ will find a MasterFormat number inside a longer string — PO 03 30 00 dated — and return it as if the whole token were a cost code. Worse, on a document reference it can extract a coincidental digit run that was never a code at all. Always anchor, and validate that the entire token conformed, not just that a match exists somewhere in it.

Open quantifiers that swallow adjacent digits. Writing the division as (?P<div>\d+) instead of \d{2} is the greedy-suffix trap: on 033000 the group eats all six digits and the following fixed groups fail or, worse, backtrack into a plausible-but-wrong split. Fixed-width classes make each segment claim exactly its share, which is both correct and immune to the backtracking that open quantifiers invite.

Nested quantifiers that trigger catastrophic backtracking. A “flexible” pattern such as ^(\d{2}[ .-]*)+$ looks convenient but lets the engine partition the input exponentially many ways when it ultimately fails to match, freezing the parser on a hostile or malformed token. Replace repetition-of-a-group with a flat sequence of fixed-width classes and a single optional separator, as the patterns here do; a linear pattern cannot backtrack pathologically. When a token genuinely matches nothing, let it fall through to quarantine and on to change order schema validation, rather than adding looser alternations that admit garbage.

Where This Fits in the Pipeline

These patterns are the deterministic anchors inside field extraction techniques, the load-bearing layer of the document ingestion pipeline. A code is parsed here immediately after OCR and layout parsing produce raw text, and the canonical string this stage emits is what lets a record clear change order schema validation at the boundary. Once canonicalized, a MasterFormat number is resolved to a work-breakdown node by WBS mapping strategies, and a GL account is reconciled against the enterprise chart of accounts through budget code standardization — both of which depend on receiving exactly the canonical shapes these regexes produce. The confidence bands used here are the same 0.92 and 0.75 thresholds applied across every routing decision on the site, so a padded legacy code is treated with the same caution whether it surfaces at parsing or three stages later.

Frequently Asked Questions

Why anchor every pattern with caret and dollar?

Anchoring forces the entire token to conform rather than allowing a match on a substring. Without ^ and $, a pattern will happily extract a cost-code-shaped fragment from inside a longer string like an invoice or PO number, handing downstream stages a value that was never a standalone code. Anchoring turns “contains a match” into “is a valid code,” which is the property you actually need at the ingestion boundary.

What causes catastrophic backtracking and how do these patterns avoid it?

It comes from nested or ambiguous quantifiers — a repeated group like (\d{2}[ .-]*)+ — that let the regex engine partition the input exponentially many ways before failing. The patterns here use a flat sequence of fixed-width classes with a single optional separator, so there is only one way to match and the engine runs in linear time. Fixed widths also prevent one segment from greedily consuming a neighbor’s digits.

Why use named groups instead of positional captures?

Named groups make normalization self-documenting and eliminate off-by-one extraction bugs. Rebuilding the canonical string from match["div"], match["broad"], and match["narrow"] reads exactly as intended and does not break when the pattern gains an optional group, whereas positional indexing silently shifts. It also lets the parser return the parsed parts as a labeled dictionary for the audit record.

How are legacy five-digit MasterFormat codes handled?

They do not match the six-digit pattern, so they fall through to a final check that pads the NarrowScope with a trailing zero — 03300 becomes 03 30 00. Because that pad is an inference rather than a clean read, the result is scored into the 0.750.92 review band so an estimator can confirm it, rather than being auto-routed as if it were canonical.

What happens when a token matches none of the three families?

It is tagged unknown with a confidence of zero and quarantined, never coerced into a plausible code. Loosening the alternations to force a match is the wrong fix because it admits genuine garbage; the correct path is to let the token quarantine and surface for manual triage, keeping the parser’s precision intact.

← Back to Field Extraction Techniques