Skip to content

Detecting Critical Path Changes Between Schedule Updates

Two schedule updates from the same project, a month apart, describe the same work — but the set of activities driving the finish date has quietly changed, and nobody flagged it. This page solves one precise problem: how to diff the critical-activity set between two updates and classify each change — an activity that entered the critical path, one that left it, and whether the overall path got longer or shorter — using a stable activity key so the comparison survives renamed and re-sequenced activities. The classifier consumes a computed CPM result for each update and emits one typed record per changed activity plus a path-length verdict, so a project controls engineer sees exactly what moved between updates instead of re-reading the whole network. It is one operation inside critical path delta detection under schedule sync integration, and it targets Python automation builders who need the diff to be reproducible and keyed on identity, not on a display label that a scheduler edited last week.

Critical-set diff and classification A flow diagram. A prior critical set and a current critical set, each a small set of activity ids, feed a matcher that pairs activities by their stable activity id rather than by name. The matched pairs pass to a classify step that also compares the two project finish lengths. Four outputs fan out at the bottom: entered critical path (was off, now on), left critical path (was on, now off), path longer (finish later), and path shorter (finish earlier). Prior critical set {A1000, A1010, A1030} Current critical set {A1000, A1010, A1050} Match by activity_id (stable key) Classify each change + compare project finish length entered_cp was off, now on left_cp was on, now off path_longer finish later path_shorter finish earlier
The diff pairs activities by their stable id, classifies each membership change, and compares the two project finish lengths to report whether the driving path grew or shrank.

Key Rules and Specification

A critical-set diff is trustworthy only when it compares like with like. These rules govern every comparison:

  • Key on the stable activity id, never the name. The comparison must join the two updates on the scheduling tool’s immutable activity_id. Display names get edited between updates — “Pour Level 3 slab” becomes “L3 slab pour” — and a name-keyed diff would report the same activity as one that left the path and a different one that entered it.
  • A membership change has exactly one direction. For each activity present in both updates, it either entered_cp (was off the critical path, now on), left_cp (was on, now off), or is unchanged. An id present in only one update is a churn signal handled separately, not a membership change.
  • Compare the path length, not just membership. Two updates can share an identical critical set while the project finish moves, because a critical activity’s duration grew. Report path_longer or path_shorter from the two project_finish values so a slip with no membership change is still visible.
  • Honour a near-critical threshold. An activity sitting at one or two days of total float is one estimate away from critical. Surface those within a NEAR_CRITICAL_DAYS window so a path about to flip is reported before it does, rather than treated as a non-event.
  • Both updates must share a calendar. Total float is counted in working days, so the two CpmResult objects are only comparable when they were computed against the same working calendar. Reconcile the calendar before diffing.
  • The diff is a pure function. Given the same two results it returns the identical classification, so a broker retry is a no-op and the audit record is reproducible.
Classification Trigger Reported from
entered_cp id ∉ prior_critical and id ∈ current_critical Per-activity membership
left_cp id ∈ prior_critical and id ∉ current_critical Per-activity membership
near_critical 0 < current_float ≤ NEAR_CRITICAL_DAYS Per-activity float
path_longer current.project_finish > prior.project_finish Whole-schedule length
path_shorter current.project_finish < prior.project_finish Whole-schedule length

Production Code Example

The classifier below builds on a CpmResult — the early/late dates, total float, and project finish already computed for each update. It is deliberately narrow: it does not re-run CPM, it diffs two results. Each change becomes a frozen Pydantic record keyed on the stable activity_id, so an invalid classification raises at construction rather than corrupting a report downstream. Durations and float are integer working days throughout; the only reason Decimal would appear here is money, and this stage carries none.

from __future__ import annotations

from dataclasses import dataclass
from typing import Literal

from pydantic import BaseModel, Field

ACTIVITY_ID_PATTERN = r"^[A-Z]{1,4}\d{3,5}$"   # stable key, e.g. STR1010
NEAR_CRITICAL_DAYS = 2                          # float at or below this is one estimate from critical

ChangeKind = Literal["entered_cp", "left_cp", "near_critical"]
PathTrend = Literal["path_longer", "path_shorter", "path_unchanged"]


@dataclass(frozen=True)
class CpmResult:
    """The minimum CPM output this diff needs from each update."""
    total_float: dict[str, int]     # int working days per activity_id
    project_finish: int             # int working days from data date

    def critical_set(self) -> set[str]:
        return {aid for aid, tf in self.total_float.items() if tf <= 0}


class CriticalPathChange(BaseModel):
    activity_id: str = Field(pattern=ACTIVITY_ID_PATTERN)
    kind: ChangeKind
    prior_float_days: int | None = None
    current_float_days: int | None = None


class CriticalPathDiff(BaseModel):
    prior_finish_days: int
    current_finish_days: int
    trend: PathTrend
    changes: list[CriticalPathChange]

    @property
    def finish_slip_days(self) -> int:
        return self.current_finish_days - self.prior_finish_days


def diff_critical_path(prior: CpmResult, current: CpmResult) -> CriticalPathDiff:
    """Classify every critical-path membership change, keyed on the stable activity id."""
    prior_crit = prior.critical_set()
    curr_crit = current.critical_set()
    changes: list[CriticalPathChange] = []

    # Only activities present in BOTH updates are real membership changes;
    # an id in one update only is churn, resolved before this function runs.
    shared = prior.total_float.keys() & current.total_float.keys()
    for aid in sorted(shared):
        was, now = aid in prior_crit, aid in curr_crit
        pf, cf = prior.total_float[aid], current.total_float[aid]
        if not was and now:
            changes.append(CriticalPathChange(
                activity_id=aid, kind="entered_cp",
                prior_float_days=pf, current_float_days=cf))
        elif was and not now:
            changes.append(CriticalPathChange(
                activity_id=aid, kind="left_cp",
                prior_float_days=pf, current_float_days=cf))
        elif not now and 0 < cf <= NEAR_CRITICAL_DAYS and cf < pf:
            # Not critical yet, but eroding toward it — surface early.
            changes.append(CriticalPathChange(
                activity_id=aid, kind="near_critical",
                prior_float_days=pf, current_float_days=cf))

    if current.project_finish > prior.project_finish:
        trend: PathTrend = "path_longer"
    elif current.project_finish < prior.project_finish:
        trend = "path_shorter"
    else:
        trend = "path_unchanged"

    return CriticalPathDiff(
        prior_finish_days=prior.project_finish,
        current_finish_days=current.project_finish,
        trend=trend,
        changes=changes,
    )


if __name__ == "__main__":
    # Update U01: A1030 drives the finish. Update U02: A1030 gained float,
    # A1050 went critical, and the project finish slipped four working days.
    prior = CpmResult(total_float={"A1000": 0, "A1010": 0, "A1030": 0, "A1050": 6},
                      project_finish=120)
    current = CpmResult(total_float={"A1000": 0, "A1010": 0, "A1030": 5, "A1050": 0},
                        project_finish=124)
    result = diff_critical_path(prior, current)
    print(f"trend={result.trend} slip={result.finish_slip_days}d")
    for change in result.changes:
        print(f"  {change.activity_id}: {change.kind} "
              f"({change.prior_float_days} -> {change.current_float_days} days float)")

The run reports A1050 entering the critical path, A1030 leaving it, a path_longer trend, and a four-day slip — the whole story of what moved between the two updates in four lines of output. Serializing result.model_dump_json() writes that story straight into the audit store, where schedule impact logging attaches it to the change order or delayed RFI that caused it.

Common Mistakes and Gotchas

Diffing by activity name instead of id. The single most common failure. When a scheduler renames an activity between updates, a name-keyed diff reports the old name as left_cp and the new name as entered_cp, inventing two phantom changes for one unchanged activity. Always join on the immutable activity_id; if the id itself churned — the activity was deleted and re-added — resolve the pairing with a fuzzy match before this function runs, and treat the recovered pair as review-band rather than trusting it outright.

Ignoring near-critical activities within the float threshold. Reporting only membership changes hides the activity sitting at one day of float that will flip on the next update’s first slip. That activity is the early warning. The near_critical classification exists precisely so a path trending critical surfaces before it crosses zero; dropping it turns delta detection into a lagging indicator that only ever reports changes after they have already bitten the finish date.

Calendar drift between the two updates. If one update was computed on a five-day calendar and the other on a calendar that absorbed a holiday week, their float values are measured in different day scales and every activity appears to have gained or lost float. The diff then reports a flood of near_critical changes that reflect the calendar mismatch, not real erosion. Reconcile both CpmResult objects to one calendar before diffing, and record which calendar was used so the discrepancy is auditable.

Where This Fits in the Pipeline

This diff is the classification step of critical path delta detection, which is where the two CpmResult inputs are computed from parsed activity graphs. Upstream, the results come from the CPM pass over records produced by Primavera P6 export parsing; downstream, each CriticalPathChange is priced and persisted by schedule impact logging, which links the movement to the approved change order or delayed RFI response that caused it. The companion operation on the same two results, calculating total float erosion across schedule updates, quantifies the float drift that this diff only flags. Keying on the stable activity id is what makes all three stages reconcile to the same audit record.

Frequently Asked Questions

Why does the diff only consider activities present in both updates?

Because a membership change is only meaningful for an activity that exists on both sides. An id that appears in one update only is either genuinely new scope or the result of id churn — an activity deleted and re-added. Either way it is not an activity that “entered” or “left” the critical path, so it is resolved by the churn-and-identity check in the parent stage before this pure diff runs.

How is a longer or shorter path different from an activity entering the path?

They answer different questions. Membership tells you which activities now drive the finish; path length tells you whether the finish itself moved. The critical set can be identical between two updates while the project finish slips, because a critical activity’s duration grew without changing which activities are critical. Reporting the trend from the two project-finish values catches that slip when membership alone would show nothing.

What is the near-critical threshold for?

It converts delta detection from a lagging to a leading indicator. An activity at one or two days of total float has not entered the critical path yet, but it is one estimate revision away. Flagging it with near_critical lets a scheduler act before the path flips, rather than being notified after the finish date has already moved. The window is configurable; a default of two working days catches imminent flips without drowning the report in noise.

Why key the comparison on activity id rather than the activity name?

Names are edited constantly between updates while the underlying activity is unchanged. A name-keyed diff sees the edit as one activity leaving and another entering the critical path, fabricating two changes from zero. The scheduling tool’s immutable activity_id is the only key that survives a rename, so the diff joins on it and treats a genuinely changed id as churn to be resolved, not as a silent membership change.

Can I run this diff on more than two updates at once?

Run it pairwise on consecutive updates and chain the results. Each call compares one prior and one current CpmResult, so a sequence of monthly updates produces a series of diffs — U01 to U02, U02 to U03 — that together trace how the critical path migrated across the project. Keeping each call to exactly two updates keeps the classification unambiguous and the function pure.

← Back to Critical Path Delta Detection