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.
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 insideINV-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 readmatch["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.75–0.92review band; a token that matches nothing quarantines below0.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.75–0.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.
Related
- Field Extraction Techniques
- WBS Mapping Strategies
- Budget Code Standardization
- Schema Validation Rules
← Back to Field Extraction Techniques