Skip to content

Mapping MS Project Tasks to WBS Elements

A parsed Microsoft Project task knows its OutlineNumber and its name, but it does not know where it sits in the enterprise cost structure. This page solves one precise problem: how to resolve each MSP task to a canonical PROJ-NNN-DIV-NN Work Breakdown Structure element through an OutlineNumber or custom-field crosswalk, scored by the site-canonical confidence bands so an exact match auto-routes, a fuzzy or division-level match waits for human review, and an unmapped task is quarantined rather than posted against a guessed node. Binding schedule activities to the wrong WBS element is not a cosmetic error: it silently mis-attributes float, criticality, and eventual cost impact to a scope that never incurred them, and the mistake stays invisible until a reconciliation exposes it. This resolver is the second stage of MS Project schedule parsing, consuming the typed tasks the reader produces and emitting a bound node ready for the ScheduleActivity contract. It targets Python automation builders who need schedule tasks bound to the same WBS taxonomy their cost data already uses.

MS Project task to WBS crosswalk with confidence gate A top-down diagram. A parsed MSP task enters a crosswalk resolver that tries tiers in order and stops at the first match: a stable custom-field enterprise-WBS attribute scored 0.98, an OutlineNumber crosswalk hit scored 0.94, a parent-division rollup scored 0.83, or no mapping scored 0.00. Each tier feeds a confidence gate with three site-canonical bands: below 0.75 quarantines the task, 0.75 to 0.92 sends it to human review where a scheduler confirms, and 0.92 or above auto-routes it as a bound PROJ-NNN-DIV-NN node. Parsed MSP task OutlineNumber · custom field · name crosswalk resolver — first matching tier wins custom field stable WBS attr · 0.98 outline hit OutlineNumber · 0.94 division rollup parent only · 0.83 no match conf 0.00 confidence gate — site-canonical bands < 0.75 → quarantine 0.75 – 0.92 → human review ≥ 0.92 → auto-route quarantine held — never bound human review scheduler confirms node auto-route bind PROJ-NNN-DIV-NN
The crosswalk tries the most stable key first and assigns each tier a confidence, then the site-canonical gate decides auto-route, review, or quarantine — never binding a task to a guessed node.

Key Rules and Specification

Binding a schedule task to cost scope is a resolution problem, and it obeys the same discipline as every other match on the site:

  • Canonical WBS target. Every bound node matches ^PROJ-\d{3}-(ARCH|STR|MEP|CIV|ELEC|PLMB)-\d{2}$ — for example PROJ-014-STR-02 — and is regex-validated before it leaves the resolver, so a malformed target never enters an activity record.
  • Prefer the stable key. A custom text field carrying an enterprise WBS attribute (set once in Project and preserved across updates) is the highest-confidence key. Fall back to OutlineNumber only when no custom field is present, because outline paths renumber.
  • Confidence bands are site-canonical. A stable custom-field hit or exact OutlineNumber crosswalk scores 0.92 or above and auto-routes; a parent-division rollup or a fuzzy name match scores in the 0.750.92 band and waits for a scheduler; anything below 0.75 quarantines. These are the same thresholds used in WBS mapping strategies.
  • Leaf tasks only. Summary tasks (<Summary>1</Summary>) roll up their children and carry no independent scope; binding them double-counts. Resolve leaf tasks and reconstruct the rollup separately if needed.
  • Never bind on a guess. An unmapped task goes to quarantine — a held record with a low score — never to a catch-all WBS bucket. A guessed binding hides the gap until reconciliation.
Match tier Trigger Confidence Route state
custom_field Stable enterprise-WBS attribute present 0.98 auto_route
outline OutlineNumber in the crosswalk 0.94 auto_route
division Only the parent outline maps 0.83 human_review
quarantine No mapping, or summary task 0.00 quarantine

Production Code Example

The resolver reuses the confidence-band pattern verbatim: a Decimal score per tier, classified into auto_route, human_review, or quarantine by the same 0.92 / 0.75 thresholds used everywhere else on the site. Pydantic makes the output a frozen contract so an invalid WBS node raises at construction time rather than corrupting an activity downstream.

from __future__ import annotations

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

from pydantic import BaseModel, ConfigDict, Field, field_validator

logger = logging.getLogger("mspdi.wbs")

WBS_RE = re.compile(r"^PROJ-\d{3}-(ARCH|STR|MEP|CIV|ELEC|PLMB)-\d{2}$")

AUTO_ROUTE = Decimal("0.92")    # site-canonical: >= 0.92 binds automatically
REVIEW_FLOOR = Decimal("0.75")  # 0.75–0.92 holds for a scheduler; < 0.75 quarantines

MatchTier = Literal["custom_field", "outline", "division", "quarantine"]
RouteState = Literal["auto_route", "human_review", "quarantine"]


def classify_confidence(score: Decimal) -> RouteState:
    """The single site-canonical band function; identical wherever routing happens."""
    if score >= AUTO_ROUTE:
        return "auto_route"
    if score >= REVIEW_FLOOR:
        return "human_review"
    return "quarantine"


class WBSBinding(BaseModel):
    """A validated task-to-WBS resolution carrying its own routing decision."""

    model_config = ConfigDict(frozen=True)

    task_uid: int = Field(ge=1)
    outline_number: str = Field(description="Preserved for audit, even when unused")
    wbs_node: str | None
    match_tier: MatchTier
    confidence: Decimal = Field(ge=0, le=1)

    @field_validator("wbs_node")
    @classmethod
    def _valid_or_none(cls, v: str | None) -> str | None:
        if v is not None and not WBS_RE.match(v):
            raise ValueError(f"WBS node violates PROJ-NNN-DIV-NN: {v!r}")
        return v

    @property
    def route_state(self) -> RouteState:
        return classify_confidence(self.confidence)


class TaskWBSResolver:
    """Deterministic MSP-task -> WBS crosswalk, most stable key first."""

    def __init__(
        self,
        custom_field_map: dict[str, str],  # stable enterprise-WBS attribute -> node
        outline_map: dict[str, str],       # OutlineNumber -> node
        division_map: dict[str, str],      # parent outline -> division rollup node
    ) -> None:
        self.custom_field_map = custom_field_map
        self.outline_map = outline_map
        self.division_map = division_map

    def resolve(
        self,
        task_uid: int,
        outline_number: str,
        is_summary: bool,
        custom_wbs_attr: str | None = None,
    ) -> WBSBinding:
        # 0. Summary tasks carry no independent scope — never bind them.
        if is_summary:
            logger.info("Summary task UID %s skipped for binding", task_uid)
            return WBSBinding(
                task_uid=task_uid, outline_number=outline_number,
                wbs_node=None, match_tier="quarantine", confidence=Decimal("0.0"),
            )

        # 1. Stable custom field — highest confidence, survives outline renumbering.
        if custom_wbs_attr and custom_wbs_attr in self.custom_field_map:
            return WBSBinding(
                task_uid=task_uid, outline_number=outline_number,
                wbs_node=self.custom_field_map[custom_wbs_attr],
                match_tier="custom_field", confidence=Decimal("0.98"),
            )

        # 2. Exact OutlineNumber crosswalk hit.
        if outline_number in self.outline_map:
            return WBSBinding(
                task_uid=task_uid, outline_number=outline_number,
                wbs_node=self.outline_map[outline_number],
                match_tier="outline", confidence=Decimal("0.94"),
            )

        # 3. Parent-division rollup -> valid, but ask a scheduler to confirm.
        parent = outline_number.rsplit(".", 1)[0] if "." in outline_number else outline_number
        if parent in self.division_map:
            return WBSBinding(
                task_uid=task_uid, outline_number=outline_number,
                wbs_node=self.division_map[parent],
                match_tier="division", confidence=Decimal("0.83"),
            )

        # 4. Nothing matched -> quarantine, never bind to a guessed bucket.
        logger.warning("No WBS mapping for task UID %s (%s); quarantining",
                       task_uid, outline_number)
        return WBSBinding(
            task_uid=task_uid, outline_number=outline_number,
            wbs_node=None, match_tier="quarantine", confidence=Decimal("0.0"),
        )


if __name__ == "__main__":
    resolver = TaskWBSResolver(
        custom_field_map={"STR-STEEL-L3": "PROJ-014-STR-02"},
        outline_map={"1.2.3": "PROJ-014-STR-02", "1.4.1": "PROJ-014-ELEC-07"},
        division_map={"1.2": "PROJ-014-STR-00"},
    )
    rows = [
        (12, "1.2.3", False, "STR-STEEL-L3"),  # custom field -> auto
        (13, "1.2.3", False, None),            # outline hit  -> auto
        (14, "1.2.9", False, None),            # division rollup -> review
        (15, "9.9.9", False, None),            # unmapped     -> quarantine
        (1, "1", True, None),                  # summary      -> skipped
    ]
    for uid, outline, summary, attr in rows:
        b = resolver.resolve(uid, outline, summary, attr)
        print(f"UID {uid:>3}  {str(b.wbs_node):<16} {b.match_tier:<12} {b.route_state}")

Common Mistakes and Gotchas

Keying the crosswalk on OutlineNumber alone. MS Project renumbers OutlineNumber whenever a summary task is inserted, deleted, or moved, so a crosswalk built against last week’s outline resolves this week’s tasks to the wrong node — and because both are valid-looking PROJ-NNN-DIV-NN codes, nothing raises. Anchor the crosswalk on a stable custom text field (an enterprise WBS attribute set in Project) as the 0.98 tier and demote OutlineNumber to a fallback. When only the outline is available, treat any post-update binding as provisional until a scheduler confirms it.

Binding summary tasks as if they were leaves. A <Summary>1</Summary> row aggregates the duration and float of its children. Emit a binding for it and its children, and the same scope posts twice — the rollup and the detail both count. Filter to leaf tasks before resolving, and if the comparators need the hierarchy, rebuild it from OutlineLevel separately rather than binding the summary rows.

Routing unmapped tasks to a real WBS bucket. The tempting shortcut sends anything unrecognized to a catch-all node so the batch “completes.” That buries the failure: the mis-bound task’s float and criticality look reconciled until an audit or a cost reconciliation exposes them. Quarantine instead — a 0.00 score and a held record force triage, exactly how fallback alert routing is meant to surface the gap. Quarantine here is the schedule-side cousin of a dead-letter queue.

Where This Fits in the Pipeline

This resolver is the binding stage of MS Project schedule parsing, taking the typed rows produced by reading MS Project XML exports with Python and attaching each to a canonical node. The crosswalk it consults is defined once under WBS mapping strategies inside the construction data architecture and taxonomy, which is what lets a schedule task and a cost code that describe the same scope land on the identical WBS element. A cleanly bound task becomes part of the shared ScheduleActivity contract that feeds critical-path and baseline comparison; a quarantined or review-band task holds until a scheduler resolves it, and the 0.92 / 0.75 bands used here are the same ones applied across every routing decision on the site, keeping audit behavior consistent from schedule intake to ledger.

Frequently Asked Questions

Why prefer a custom field over OutlineNumber as the crosswalk key?

OutlineNumber is positional: MS Project renumbers it whenever a summary task is inserted, deleted, or moved, so a crosswalk keyed on it silently mis-binds after any structural edit. A custom text field carrying the enterprise WBS attribute is set once and travels with the task across updates, which is why it scores the 0.98 auto-route tier while the outline hit sits just below it at 0.94.

What confidence does a division-level rollup get, and why?

A parent-division rollup scores 0.83, which lands in the 0.750.92 human-review band. The binding is plausible — the task genuinely belongs under that division — but posting a leaf task straight to a parent bucket buries detail the schedule may need. The score routes it to a scheduler who either confirms the rollup or adds the missing granular mapping.

What happens to a task with no WBS mapping at all?

It is quarantined with a 0.00 score and a null node, not routed to a catch-all bucket. The held record forces triage instead of letting a mis-bound task look reconciled. Binding unmapped schedule scope to a real WBS element is what makes misattributed float and cost invisible until an audit.

Why are summary tasks excluded from binding?

A summary task aggregates the duration and float of its children, so binding both the summary and its leaves double-counts the same scope against the WBS node. The resolver quarantines summary rows and binds only leaf tasks; the rollup, if the comparators need it, is reconstructed separately from OutlineLevel.

Are these confidence thresholds the same as the cost-side resolver?

Yes. The 0.92 auto-route and 0.75 review floor are site-canonical and identical to the thresholds used when mapping MasterFormat cost codes to WBS. Reusing one band function everywhere means a scheduler and an estimator interpret a confidence score the same way, and audit behavior is uniform whether a record originated in a schedule or a cost ledger.

← Back to MS Project Schedule Parsing