Skip to content

Calculating Total Float Erosion Across Schedule Updates

An activity does not enter the critical path without warning — it spends the prior few updates bleeding total float, and every one of those days lost is a signal that nobody read. This page solves one precise problem: how to quantify total-float erosion per activity across two schedule updates, so a path trending toward critical is flagged while it still has float to lose rather than the moment it hits zero. The calculator takes the total-float value for each activity from two updates, computes the signed per-activity delta in integer working days, classifies how severe the erosion is, and routes each eroding activity by the site-canonical confidence bands onto a watch list. It is a companion operation inside critical path delta detection under schedule sync integration, turning delta detection from a report of what already went critical into an early warning of what is about to. It targets Python automation builders and schedulers who need float trends tracked activity by activity across a monthly update cycle.

Total float erosion pipeline A top-down data-flow diagram. A prior float map and a current float map converge into a per-activity float delta computation, current minus prior in integer working days. The delta feeds an erosion severity classifier that fans out to three chips: eroded to critical when float is at or below zero, near critical when float is between zero and five days, and watch when the activity is still eroding with more than five days of float. The three chips converge into an identity confidence decision that routes three ways: at or above 0.92 auto-routes down to an erosion alert added to a critical-path watch list and on to the schedule impact log; the 0.75 to 0.92 band routes right to scheduler review; below 0.75 routes left to quarantine. Prior float map Current float map Per-activity float delta prior − current (int days) Classify erosion severity eroded_to_critical float ≤ 0 near_critical 0 < float ≤ 5 watch eroding, float > 5 Identity confidence? ≥ 0.92 0.75 – 0.92 < 0.75 Auto-route erosion alert add to critical-path watch list Schedule impact log Scheduler review Quarantine
Erosion is a signed day count: the classifier sorts each activity by how much float remains and how fast it is falling, and the confidence bands decide whether the alert auto-routes to the watch list, waits for a scheduler, or is quarantined.

Key Rules and Specification

Float erosion is a difference of two integer day counts, and the value is only meaningful under a few strict conventions:

  • Fix the sign convention once. Erosion is prior_float − current_float. A positive result means float was lost (the activity moved toward critical); a negative result means float was gained. Pick this direction and hold it everywhere, because a flipped sign turns an improving activity into a false alarm and hides a real one.
  • Float and erosion are integer working days. Total float is a whole-day difference of two CPM day indices, so the delta is an int. No Decimal or float appears in the day math; currency only enters later when a downstream stage prices a slip.
  • Severity is remaining float first, rate second. An activity already at or below zero float is eroded_to_critical; one inside the near-critical window is near_critical; one still comfortable but losing float faster than the flag rate is watch. Remaining float decides urgency; erosion rate decides whether a comfortable activity is trending the wrong way.
  • Only compare activities present in both updates, keyed on the stable id. An activity in one update only has no prior float to erode from. Join on the immutable activity_id, never the display name, so a rename is not read as an activity vanishing and reappearing.
  • One shared calendar. Total float is counted in working days, so two updates computed on different calendars produce a spurious erosion for every activity. Reconcile to a single calendar before differencing.
  • Route by confidence and severity together. A trusted, high-severity erosion auto-routes; a fuzzy identity match holds for review regardless of severity; a low-confidence pairing is quarantined so no watch-list alert fires against the wrong activity.
Severity Condition Route (at ≥ 0.92 confidence)
eroded_to_critical current_float ≤ 0 auto_route
near_critical 0 < current_float ≤ 5 auto_route
watch current_float > 5 and erosion ≥ 3 days human_review
stable float steady or improving file_for_record

Production Code Example

The calculator takes the total-float value for each activity from two updates and produces a validated FloatErosion record carrying the signed delta and its severity. The record is a frozen Pydantic contract so the sign convention and the day-integer types are enforced at construction. Identity confidence is a bounded Decimal — the same site-canonical bands used across every routing decision — and it gates the watch-list alert.

from __future__ import annotations

from decimal import Decimal
from typing import Literal

from pydantic import BaseModel, ConfigDict, Field

ACTIVITY_ID_PATTERN = r"^[A-Z]{1,4}\d{3,5}$"    # stable key, e.g. MEP2210

NEAR_CRITICAL_DAYS = 5      # remaining float at or below this is near-critical
EROSION_FLAG_DAYS = 3       # losing this many float days in one update is material

AUTO_ROUTE = Decimal("0.92")     # site-canonical: >= 0.92 identity auto-routes
REVIEW_FLOOR = Decimal("0.75")   # 0.75-0.92 holds for a scheduler; < 0.75 quarantines

Severity = Literal["eroded_to_critical", "near_critical", "watch", "stable"]
RouteState = Literal["auto_route", "human_review", "quarantine", "file_for_record"]


class FloatErosion(BaseModel):
    """A signed, per-activity total-float change between two schedule updates."""

    model_config = ConfigDict(frozen=True)

    activity_id: str = Field(pattern=ACTIVITY_ID_PATTERN)
    prior_float_days: int
    current_float_days: int
    identity_confidence: Decimal = Field(ge=0, le=1)

    @property
    def erosion_days(self) -> int:
        # Positive == float LOST (trending toward critical). Sign fixed here, once.
        return self.prior_float_days - self.current_float_days

    @property
    def severity(self) -> Severity:
        cf = self.current_float_days
        if cf <= 0:
            return "eroded_to_critical"
        if cf <= NEAR_CRITICAL_DAYS:
            return "near_critical"
        if self.erosion_days >= EROSION_FLAG_DAYS:
            return "watch"
        return "stable"

    @property
    def route_state(self) -> RouteState:
        if self.severity == "stable":
            return "file_for_record"
        if self.identity_confidence < REVIEW_FLOOR:
            return "quarantine"
        if self.identity_confidence >= AUTO_ROUTE and self.severity in (
            "eroded_to_critical", "near_critical"
        ):
            return "auto_route"
        return "human_review"


def compute_float_erosion(
    prior_float: dict[str, int],
    current_float: dict[str, int],
    identity: dict[str, Decimal],
) -> list[FloatErosion]:
    """Quantify erosion for every activity in both updates; skip steady activities."""
    shared = prior_float.keys() & current_float.keys()
    records: list[FloatErosion] = []
    for aid in sorted(shared):
        record = FloatErosion(
            activity_id=aid,
            prior_float_days=prior_float[aid],
            current_float_days=current_float[aid],
            identity_confidence=identity.get(aid, Decimal("1.0")),
        )
        if record.severity != "stable":
            records.append(record)
    # Most urgent first: least remaining float, then fastest erosion.
    records.sort(key=lambda r: (r.current_float_days, -r.erosion_days))
    return records


if __name__ == "__main__":
    prior_float = {"STR1010": 8, "MEP2210": 12, "ELEC3100": 1, "CIV4000": 20}
    current_float = {"STR1010": 2, "MEP2210": 9, "ELEC3100": 0, "CIV4000": 19}
    identity = {"STR1010": Decimal("1.0"), "MEP2210": Decimal("0.88"),
                "ELEC3100": Decimal("1.0"), "CIV4000": Decimal("1.0")}
    for r in compute_float_erosion(prior_float, current_float, identity):
        print(f"{r.activity_id:<10} float {r.prior_float_days:>2} -> {r.current_float_days:<2} "
              f"(lost {r.erosion_days:>2}d)  {r.severity:<18} {r.route_state}")

The run flags ELEC3100 as eroded_to_critical and auto-routes it, STR1010 as near_critical and auto-routed, and MEP2210 as a watch item that lands in scheduler review because its identity match scored 0.88 — inside the review band. CIV4000, losing a single day of float from a comfortable twenty, stays stable and never reaches the watch list. Serializing each record with model_dump_json() posts it to the audit store where the watch list lives.

Common Mistakes and Gotchas

Flipping the erosion sign. Defining erosion as current − prior instead of prior − current inverts the meaning: an activity that lost six days of float now reports −6, reads as an improvement, and never fires. Worse, an activity that gained float shows a positive number and triggers a false alert. Fix the convention in one property — prior_float − current_float, positive means float lost — and let every consumer read it from there rather than re-deriving the subtraction and getting it backwards.

Treating unbounded float on open ends as real. An activity with no successors inherits float equal to the whole remaining project — an “open end” the network never constrains. Its float swings wildly between updates as the project finish moves, generating enormous phantom erosion that means nothing about that activity’s urgency. Close open ends by linking every activity to a successor (ultimately the completion milestone) before computing float, or exclude open-ended activities from the erosion calculation so their meaningless swings do not swamp the watch list.

Mixing calendars between the two updates. Float is measured in working days, so if the prior update ran on a five-day calendar and the current one on a calendar that folded in a holiday week, every activity’s float shifts by the calendar difference and the calculator reports wholesale erosion that reflects the calendar, not the schedule. Reconcile both float maps to one calendar and record which one, exactly as the sibling operation detecting critical path changes between schedule updates requires, so the two analyses agree on the same day scale.

Where This Fits in the Pipeline

Float erosion is the leading-indicator half of critical path delta detection: where the sibling diff reports which activities have already entered or left the critical path, this calculator flags the ones bleeding float toward it, using the two float maps the same CPM pass produced. It shares its stable-key and calendar rules with detecting critical path changes between schedule updates, and every non-stable record it emits is persisted and priced by schedule impact logging, which ties the erosion to the approved change order or delayed RFI response driving it. The confidence bands used to route an erosion alert are the same 0.92 and 0.75 thresholds applied everywhere on the site, keeping the watch list’s behavior consistent with the rest of the schedule pipeline.

Frequently Asked Questions

Why track float erosion when delta detection already reports the critical path?

Because by the time an activity appears in the critical-path diff it has already gone critical — that is a lagging signal. Float erosion is the leading signal: it flags an activity while it still has float to lose, so a scheduler can intervene before the path flips. The two run on the same CPM results and complement each other, one reporting what changed and the other warning what is about to.

How is the erosion sign defined?

Erosion is prior_float − current_float, so a positive value means the activity lost float and is trending toward critical, and a negative value means it gained float. Fixing the convention in a single property keeps every downstream consumer consistent and prevents the classic bug where a flipped subtraction turns an improving activity into a false alarm.

What makes an activity "near critical" rather than just eroding?

Remaining float, not the rate of loss, decides urgency first. An activity within the near-critical window — five working days or fewer of total float, but still above zero — is one estimate revision from driving the finish, so it auto-routes at high confidence. An activity with plenty of float that is nonetheless shedding days faster than the flag rate is a watch item: worth a scheduler’s eye, but not yet urgent.

Why do open-ended activities distort the calculation?

An activity with no successor is unconstrained on its finish, so the network assigns it float equal to the entire remaining project. That number moves every time the project finish moves, producing huge erosion swings that say nothing about the activity. Closing open ends by linking every activity through to the completion milestone — or excluding open ends from the calculation — keeps those meaningless swings off the watch list.

How do the confidence bands route an erosion alert?

They gate on identity, exactly as elsewhere on the site. A high-severity erosion whose activity was matched by a stable id at 0.92 or above auto-routes onto the watch list. A pairing recovered by a fuzzy match in the 0.750.92 band holds for a scheduler to confirm regardless of severity, and anything below 0.75 is quarantined so no alert fires against the wrong activity.

← Back to Critical Path Delta Detection