feat(scheduler): incremental CPM recompute — changed_task_ids subgraph delta (ADR-0027)

ADR-0027 implementation. Currently the scheduler runs a full CPM pass on every trigger. For large projects (500+ tasks) this is unnecessarily expensive when only a small subgraph has changed.

Summary

Add changed_task_ids parameter to the CPM engine so it can extract the affected subgraph (ancestors + descendants) and recompute only that delta, falling back to a full pass when the change ratio exceeds a configurable threshold.

Scheduler changes (packages/scheduler)

def compute_cpm(
    tasks: list[Task],
    changed_task_ids: set[str] | None = None,   # new
    incremental_threshold: float = 0.3,          # new — fall back if > 30% affected
) -> CPMResult: ...
  • Build the full dependency graph once; extract the minimal subgraph (ancestors ∪ descendants of changed_task_ids)
  • If len(subgraph) / len(tasks) > incremental_threshold → fall back to full pass
  • Closure is computed once at project load and cached; only invalidated when dependency edges change

API / Celery changes (packages/api)

  • Celery task run_cpm.apply_async passes changed_task_ids when the trigger is a partial mutation (task PATCH with date/duration/dependency fields changed)
  • Full triggers (project load, baseline operations, dependency bulk-edit) always pass changed_task_ids=None

Configuration

# settings.py
SCHEDULER_INCREMENTAL_CHANGE_RATIO = 0.3   # fall back to full above this fraction
SCHEDULER_INCREMENTAL_CLOSURE_RATIO = 0.5  # fall back if closure > this fraction of graph

Acceptance criteria

  • compute_cpm accepts changed_task_ids with correct subgraph extraction
  • Incremental pass produces identical results to full pass (property-based test with hypothesis)
  • Threshold fallback is logged at DEBUG level
  • Celery run_cpm task passes changed_task_ids for single-task mutations
  • Benchmark: incremental pass on 5-task change in a 500-task project is ≥ 5× faster than full pass
  • pytest coverage including edge cases: task at critical path root, disconnected subgraph, threshold fallback