Detecting Out-of-Sequence Progress in Schedule Updates
When a schedule update reports an activity as started or partly complete while the activity it logically depends on has not finished, the schedule has recorded out-of-sequence progress — work that happened, or was reported as happening, in an order the network logic says is impossible. This page solves one precise problem: how to scan a schedule update, find every in-progress activity whose predecessors are not yet satisfied under the relationship graph, and route those violations by severity so a real logic break is escalated while a legitimately re-sequenced activity is not. Out-of-sequence progress quietly corrupts two things at once: earned-value math credits progress that the plan says cannot exist, and the forward pass mis-computes the critical path delta because retained-logic and progress-override calculation modes disagree about what the remaining path even is. It sits alongside schedule baseline variance inside Schedule Sync & Critical Path Integration, consuming typed activity and relationship records from Primavera P6 export parsing or MS Project schedule parsing. It targets Python automation builders enforcing schedule integrity before variance and earned-value numbers are trusted.
Key Rules and Specification
Out-of-sequence detection is a graph problem with construction-specific gates. The rules:
- Only in-progress successors can be out of sequence. An activity that has not started cannot have progressed ahead of anything. Select activities with an actual start and no actual finish (or a percent-complete strictly between 0 and 100) before checking predecessors.
- The gating event depends on the relationship type. A finish-to-start (FS) predecessor gates the successor’s start on the predecessor’s finish; start-to-start (SS) gates start on start; finish-to-finish (FF) gates the successor’s finish on the predecessor’s finish; start-to-finish (SF) gates the successor’s finish on the predecessor’s start. Applying an FS rule to an SS link is the most common source of false positives.
- Lag is part of the constraint, not decoration. A relationship carries a lag in working days that can be negative (a lead). A lead legitimately permits overlap, so a successor starting before an FS predecessor finishes is not automatically a violation — the lead may allow it.
- Re-logicked activities are not violations. When a planner deliberately re-sequences work this period, the update legitimately shows progress against changed logic. Detections touching activities whose relationships changed are low-confidence and belong in review, not escalation.
- Route by confidence, then severity. Scores are site-canonical: 0.92 and above is a confident detection, 0.75 to 0.92 goes to human review, and below 0.75 quarantines. Among confident detections, a violation on a critical activity escalates while a non-critical one is flagged.
| Relationship | Gates successor’s… | On predecessor’s… | Violation when in-progress successor… |
|---|---|---|---|
| FS | start | finish | started, predecessor not finished |
| SS | start | start | started, predecessor not started |
| FF | finish | finish | finished, predecessor not finished |
| SF | finish | start | finished, predecessor not started |
Production Code Example
The detector indexes activities by id, walks the relationship graph, and — for each relationship whose successor has reached the milestone the link gates — checks whether the predecessor’s gating event has occurred. Lag and re-logic status feed a confidence score, and each violation carries its own routing decision.
from __future__ import annotations
import logging
from dataclasses import dataclass
from datetime import datetime, timezone
from decimal import Decimal
from typing import Literal, Optional
from pydantic import BaseModel, ConfigDict, Field
logging.basicConfig(level=logging.INFO, format="%(levelname)-8s | %(message)s")
logger = logging.getLogger("schedule.sequence")
RelType = Literal["FS", "SS", "FF", "SF"]
RouteAction = Literal["escalate_alert", "flag_report", "human_review", "quarantine"]
AUTO_ROUTE = Decimal("0.92") # >= 0.92 -> confident detection
REVIEW_FLOOR = Decimal("0.75") # 0.75–0.92 -> human review; < 0.75 -> quarantine
@dataclass(frozen=True)
class ActivityProgress:
"""An activity's reported progress from a parsed schedule update."""
activity_id: str
is_critical: bool
actual_start: Optional[datetime]
actual_finish: Optional[datetime]
percent_complete: Decimal
def started(self) -> bool:
return self.actual_start is not None or self.percent_complete > 0
def finished(self) -> bool:
return self.actual_finish is not None or self.percent_complete >= 100
@dataclass(frozen=True)
class Relationship:
"""A precedence link. lag_days may be negative (a lead permitting overlap)."""
predecessor_id: str
successor_id: str
rel_type: RelType
lag_days: int = 0
class SequenceViolation(BaseModel):
"""One detected out-of-sequence progress event, carrying its routing decision."""
model_config = ConfigDict(frozen=True)
successor_id: str
predecessor_id: str
rel_type: RelType
reason: str
is_critical: bool
confidence: Decimal = Field(ge=0, le=1, decimal_places=2)
@property
def route_state(self) -> RouteAction:
if self.confidence < REVIEW_FLOOR:
return "quarantine"
if self.confidence < AUTO_ROUTE:
return "human_review"
return "escalate_alert" if self.is_critical else "flag_report"
def _violation_reason(rel: Relationship, pred: ActivityProgress, succ: ActivityProgress) -> str | None:
"""Return a reason if the relationship's gating event has not occurred, else None."""
# FS and SS gate the successor's START on a predecessor event.
if rel.rel_type == "FS" and succ.started() and not pred.finished():
return "successor started before FS predecessor finished"
if rel.rel_type == "SS" and succ.started() and not pred.started():
return "successor started before SS predecessor started"
# FF and SF gate the successor's FINISH on a predecessor event.
if rel.rel_type == "FF" and succ.finished() and not pred.finished():
return "successor finished before FF predecessor finished"
if rel.rel_type == "SF" and succ.finished() and not pred.started():
return "successor finished before SF predecessor started"
return None
def _confidence(rel: Relationship, relogicked_ids: frozenset[str]) -> Decimal:
"""Score how sure we are the detection is a real logic break, not legitimate re-sequencing."""
if rel.successor_id in relogicked_ids or rel.predecessor_id in relogicked_ids:
return Decimal("0.70") # logic changed this period -> likely a deliberate re-sequence
if rel.lag_days < 0:
return Decimal("0.80") # a lead permits overlap -> a human must confirm the dates
return Decimal("0.97")
def detect_out_of_sequence(
activities: list[ActivityProgress],
relationships: list[Relationship],
relogicked_ids: frozenset[str] = frozenset(),
) -> list[SequenceViolation]:
"""Find every in-progress activity that has run ahead of its predecessors."""
by_id = {a.activity_id: a for a in activities}
violations: list[SequenceViolation] = []
for rel in relationships:
pred, succ = by_id.get(rel.predecessor_id), by_id.get(rel.successor_id)
if pred is None or succ is None:
logger.warning(
"dangling relationship %s -> %s", rel.predecessor_id, rel.successor_id
)
continue
reason = _violation_reason(rel, pred, succ)
if reason is None:
continue
violations.append(
SequenceViolation(
successor_id=rel.successor_id,
predecessor_id=rel.predecessor_id,
rel_type=rel.rel_type,
reason=reason,
is_critical=succ.is_critical,
confidence=_confidence(rel, relogicked_ids),
)
)
return violations
if __name__ == "__main__":
def _dt(y: int, m: int, d: int) -> datetime:
return datetime(y, m, d, 12, 0, tzinfo=timezone.utc)
activities = [
# Formwork not finished, but the pour it gates has already started: FS break.
ActivityProgress("A100", True, _dt(2026, 4, 6), None, Decimal("60")),
ActivityProgress("A110", True, _dt(2026, 4, 8), None, Decimal("25")),
# A wall started before its SS predecessor even began — but it was re-logicked.
ActivityProgress("A200", False, None, None, Decimal("0")),
ActivityProgress("A210", False, _dt(2026, 4, 9), None, Decimal("10")),
]
relationships = [
Relationship("A100", "A110", "FS", lag_days=0),
Relationship("A200", "A210", "SS", lag_days=0),
]
for v in detect_out_of_sequence(activities, relationships, relogicked_ids=frozenset({"A210"})):
print(f"{v.successor_id}: {v.reason} (conf {v.confidence}) -> {v.route_state}")Serializing each violation with model_dump_json() writes the detection, its reason, and its routing decision into the audit store, so a schedule reviewer sees not just that an activity is out of sequence but why and how confident the detector is.
Common Mistakes and Gotchas
Applying a finish-to-start rule to every link. The most frequent false positive comes from treating all predecessors as FS. Two activities joined start-to-start are meant to run in parallel, so a successor that starts while the predecessor is still in progress is perfectly legal — flagging it buries the real FS breaks in noise. Branch on rel_type and check the gating event each link actually constrains, as the table above specifies. Finish-to-finish and start-to-finish links gate the successor’s finish, not its start, and must not be checked until the successor completes.
Treating every lag as zero. A relationship with a negative lag is a lead, and a lead explicitly permits the successor to overlap its predecessor. Assuming zero lag turns every legitimate overlap into a violation. Read the lag off the relationship and let a lead lower the detection’s confidence so it lands in review, where a scheduler can confirm the overlap is within the lead rather than beyond it, instead of escalating it as a hard break.
Escalating legitimately re-logicked work. When a planner deliberately re-sequences activities this period, progress against the new logic looks out-of-sequence relative to the old network but is entirely intentional. Feeding the set of re-logicked activities into the confidence score drops those detections into the review band, so a real logic error is escalated while a documented re-sequence is surfaced quietly for confirmation — the same confidence discipline the parent schedule baseline variance pipeline uses to gate its rollups.
Where This Fits in the Pipeline
Out-of-sequence detection is a schedule-integrity check that runs before variance and earned value are trusted, under Schedule Sync & Critical Path Integration. It shares its inputs with schedule baseline variance: both consume the typed activities and relationships parsed from Primavera P6 export parsing and MS Project schedule parsing. Its output matters most to critical path delta detection, because out-of-sequence progress is exactly what makes retained-logic and progress-override scheduling modes compute different critical paths — clearing or accounting for the violations first is what keeps the delta comparison meaningful. Escalated violations hand off to the alert router just as material variance does.
Frequently Asked Questions
What exactly counts as out-of-sequence progress?
An activity reports progress — an actual start, or a percent-complete above zero — while a predecessor that its network logic depends on has not reached the state the relationship requires. For a finish-to-start link that means the predecessor has not finished; for start-to-start it means the predecessor has not started. The activity has effectively jumped ahead of its plan, which is why earned-value and critical-path calculations built on the network can no longer be trusted until it is reconciled.
Why check the relationship type instead of just comparing dates?
Because the same pair of dates is a violation under one relationship type and completely legal under another. A successor starting while its predecessor runs is a breach for a finish-to-start link but the intended behaviour for a start-to-start link. Only by branching on the relationship type — and honouring its lag — can the detector separate a genuine logic break from parallel work that the planner designed.
How are lags and leads handled?
The lag is read straight off the relationship in working days. A positive lag makes the gate stricter, and an out-of-sequence start against it is a clear violation. A negative lag is a lead that permits the successor to overlap its predecessor, so the detector lowers the confidence of any such detection into the review band, where a scheduler confirms whether the overlap stayed within the lead or genuinely ran ahead of it.
Won't deliberately re-sequenced work generate false alarms?
It would, if every detection were treated equally. That is why the set of activities whose logic changed this period feeds the confidence score: detections touching re-logicked activities score below the auto-route band and route to human review rather than escalation. A planner’s intentional re-sequence is surfaced quietly for confirmation, while an undocumented logic break on a critical activity still escalates.
How does out-of-sequence progress affect the critical path?
Scheduling engines resolve out-of-sequence progress with either retained logic or progress override, and the two modes compute different remaining paths — retained logic holds the successor until its predecessor finishes, while progress override lets the started work continue. That divergence is what makes an unreconciled violation distort critical-path delta detection, so clearing or accounting for out-of-sequence progress first is a precondition for a trustworthy path comparison.
Related
- Schedule Baseline Variance
- Comparing Baseline and Current Schedules in Python
- Critical Path Delta Detection
- Primavera P6 Export Parsing
- MS Project Schedule Parsing
← Back to Schedule Baseline Variance