Расписание, зависимости и ресурсы: сетевой график, сжатие срока, критическая цепь
Задача «из работ, длительностей и связей получить дату» выглядит решённой в 1957 году: алгоритм критического пути линеен и умещается в тридцать строк (он разобран в обзоре трека). Проблема не в алгоритме, а в том, что его ответ систематически оптимистичен — по трём накладывающимся причинам. Резерв не то, чем кажется: резервов у работы два, и путаница роняет соседей при полной уверенности, что «запас был». В сети нет людей: CPM считает так, будто исполнителей бесконечно много, а с конечной ёмкостью задача становится NP-трудной. Длительности — распределения, а не числа: там, где сходятся ветви, вероятности перемножаются, и «некритический» путь оказывается критическим в трети прогонов.
Статья достраивает оценку и планирование (сколько работы и с каким разбросом) до расписания (когда и кем) и заканчивается тем, во что этот же предмет превратился в командах, у которых нет ни одного сетевого графика.
Часть 1. Когда расписание вообще нужно
Сетевой график дорог: его строят, поддерживают и переделывают при каждом изменении. Он окупается там, где есть жёсткие внешние даты и физически неустранимые связи — интеграция с вендором по контракту, миграция в согласованное окно, сертификация, аппаратная поставка, отключение старой системы. Он не окупается там, где работа однородна и приходит потоком: дешевле управлять потоком и прогнозировать вероятностно (Kanban и управление потоком).
прогноз по throughput] A -->|Да| B{Есть зависимости между командами
или внешними сторонами?} B -->|Нет| M[Монте-Карло по throughput:
сеть не нужна] B -->|Да| C{Зависимость устранима
архитектурно или переговорами?} C -->|Да| D[Устраните: дешевле,
чем планировать вокруг неё] C -->|Нет| E{Длительности детерминированы?} E -->|Да: поставки, окна, аудиты| F[CPM + ресурсное выравнивание] E -->|Нет: это разработка| G[Сеть + Монте-Карло по сети
+ буфер на уровне проекта] F --> H[Базовый план и контроль по вехам] G --> H
Главный вывод развилки практический: прежде чем планировать вокруг зависимости, попробуйте её убрать. Согласованный заранее контракт API превращает связь «финиш→старт» в «старт→старт» и укорачивает план, фича-флаг превращает «релиз после всех» в «релиз в любой момент» (Качество и релизный менеджмент). Устранённая зависимость снижает и срок, и его разброс одновременно — ни один приём планирования так не умеет.
Часть 2. Из чего сделана сеть: работы, связи, лаги
Сначала декомпозиция: до пакетов с одним ответственным, проверяемым результатом и называемой длительностью. Правило «8/80» — пакет меньше дня означает бюрократию, больше двух недель означает, что вы не понимаете содержание. Правило 100% — сумма пакетов покрывает весь объём: самая частая причина срыва не плохие оценки, а отсутствующие строки. Затем связи; их ровно четыре типа, и разница определяет, что можно вести параллельно.
Лаг — задержка на связи (индексы догоняются двое суток после миграции). Опережение (отрицательный лаг) — разрешение стартовать до конца предшественника, то есть ставка на то, что предшественник не изменит того, на что опирается последователь. Ставка проиграла — получаете переделку.
| Болезнь сети | Как выглядит | Чем лечить |
|---|---|---|
| Связь по привычке | «Тестирование после разработки», хотя тесты пишутся вместе с кодом | Спросить, что физически мешает начать раньше. Обычно ничего |
| Ресурсная связь под видом логической | «B после A», потому что их делает один человек | Вынести в ресурсную модель, иначе выравнивание считает неправду |
| Жёсткая дата вместо связи | В плане «начать 12 мая», связи нет | Заменить связью: даты не пересчитываются, связи пересчитываются сами |
| Висячая работа | Нет предшественников или последователей | Подключить: её резерв бесконечен, и она всегда опаздывает |
| 700 работ на квартал | План поддерживает выделенный человек | Укрупнить: точность ограничена неопределённостью, а не детализацией |
Сквозной пример статьи — релиз «Импорт каталога поставщика», десять пакетов.
и контракт API · 4д"] --> DB["DB · Схема БД
и миграции · 3д"] ANL --> PARSE["PARSE · Парсер
и валидация · 8д"] ANL --> FE["FE · UI мастера
импорта · 9д"] DB --> BE["BE · Бэкенд
импорта · 8д"] BE --> INT["INT · Интеграция
и e2e · 4д"] PARSE --> INT FE --> INT BE --> SEC["SEC · Ревью
безопасности · 2д"] FE --> DOC["DOC · Документация
и обучение · 3д"] INT --> PERF["PERF · Нагрузочное
тестирование · 3д"] PERF --> REL["REL · Релиз
и прогрев · 2д"] SEC --> REL DOC --> REL classDef crit fill:#d8504a,fill-opacity:0.25,stroke:#d8504a,stroke-width:2px; class ANL,DB,BE,INT,PERF,REL crit;
Часть 3. Два резерва, и почему их путают
Прямой проход даёт ранние даты (ES, EF), обратный — поздние (LS, LF). Дальше начинается то, о чём обычно
молчат: резервов два.
- Общий резерв (total float,
LF − EF) — насколько работу можно сдвинуть, не сдвинув дату проекта. Он общий буквально: принадлежит всему пути. Съел первый — остальным на пути не осталось. - Свободный резерв (free float,
min(ES последователей) − EF) — насколько можно сдвинуть, не сдвинув ни одну соседнюю работу. Только он принадлежит работе лично.
from collections import deque
NET = { # работа -> (длительность в днях, предшественники)
"ANL": (4, []), "DB": (3, ["ANL"]), "BE": (8, ["DB"]), "PARSE": (8, ["ANL"]),
"FE": (9, ["ANL"]), "SEC": (2, ["BE"]), "DOC": (3, ["FE"]), "PERF": (3, ["INT"]),
"INT": (4, ["BE", "PARSE", "FE"]), "REL": (2, ["PERF", "SEC", "DOC"]),
}
def topo(net):
"""Топологический порядок (алгоритм Кана) и списки последователей. O(V + E)."""
succ, indeg = {t: [] for t in net}, {t: 0 for t in net}
for t, (_d, preds) in net.items():
for p in preds:
succ[p].append(t)
indeg[t] += 1
order, q = [], deque(t for t in net if indeg[t] == 0)
while q:
t = q.popleft()
order.append(t)
for s in succ[t]:
indeg[s] -= 1
if indeg[s] == 0:
q.append(s)
if len(order) != len(net): # цикл = взаимная блокировка команд
raise ValueError("в графе зависимостей цикл")
return order, succ
def cpm(net):
"""Прямой и обратный проход: ранние даты, общий и свободный резервы, срок проекта.
O(V + E) по времени и памяти — узкое место планирования никогда не в алгоритме."""
order, succ = topo(net)
es = {t: 0.0 for t in net}
for t in order: # прямой проход
for s in succ[t]:
es[s] = max(es[s], es[t] + net[t][0])
ef = {t: es[t] + net[t][0] for t in net}
end = max(ef.values())
lf = {t: end for t in net}
for t in reversed(order): # обратный проход
if succ[t]:
lf[t] = min(lf[s] - net[s][0] for s in succ[t])
total = {t: lf[t] - ef[t] for t in net} # до сдвига проекта
free = {t: (min(es[s] for s in succ[t]) if succ[t] else end) - ef[t] for t in net} # до сдвига соседа
return es, ef, total, free, end
es, ef, total, free, end = cpm(NET)
print(f"срок проекта: {end:.0f} дней")
for t in sorted(NET, key=lambda x: (es[x], x)):
print(f"{t:<6} ES={es[t]:>3.0f} EF={ef[t]:>3.0f} общий={total[t]:>3.0f} свободный={free[t]:>3.0f}")
# срок проекта: 24 дней
# ... ANL, DB, BE, INT, PERF, REL — общий и свободный резервы нулевые, это критический путь
# FE ES= 4 EF= 13 общий= 2 свободный= 0 <- ловушка
# PARSE ES= 4 EF= 12 общий= 3 свободный= 3
# DOC ES= 13 EF= 16 общий= 6 свободный= 6
# SEC ES= 15 EF= 17 общий= 5 свободный= 5
Разглядывайте FE: общий резерв два дня, свободный — ноль. Менеджер, увидевший в трекере «резерв 2 дня»,
разрешает фронтенду задержаться на день — и немедленно уезжает DOC, потому что весь резерв FE лежит дальше по
пути. Отсюда правило: с исполнителем говорят по свободному резерву, с заказчиком — по общему.
Второй вывод — про распределение резервов. Меньше 15% критических работ означает недоспецифицированный план (забыты
связи), больше 60% — переспецифицированный (связи «по привычке» сделали критическим всё). Здоровая сеть на десятки
работ имеет один критический путь и два-три почти критических — с резервом меньше типичной ошибки оценки, здесь
это FE и SEC. За ними следят наравне с критическими: они станут критическими при первом отклонении.
Часть 4. Ресурсы: расписание без людей — это фантазия
CPM предполагает, что параллельные работы кто-то делает параллельно. В день 4 у нас одновременно активны DB,
PARSE и FE — нужно три человека. Если их два, честная нижняя граница срока выглядит так:
$$T \ge \max\left(L_{\text{КП}},\ \frac{\sum_i d_i \cdot r_i}{R}\right)$$
где $L_{\text{КП}}$ — длина критического пути, $d_i$ — длительность, $r_i$ — сколько людей требует работа, $R$ — ёмкость. У нас $\max(24,\ 46/2) = 24$ дня, но граница достижима не всегда: задача с ограниченными ресурсами (RCPSP) NP-трудна (NP-полнота и приближения), поэтому используют эвристику — последовательную схему генерации расписания с приоритетом по позднему старту. Различают два режима: выравнивание (leveling) сдвигает работы, чтобы уложиться в ёмкость, и дата может уехать; сглаживание (smoothing) двигает только в пределах резервов — дата стоит, но запас исчезает.
from collections import defaultdict
def serial_sgs(net, capacity, demand):
"""Ресурсно-ограниченное расписание, последовательная схема (serial SGS).
Приоритет — минимальный поздний старт: первыми те, у кого меньше запаса.
O(n^2) на выбор порядка плюс O(n · H) на профиль ресурса, H — горизонт в днях.
Эвристика: даёт допустимое расписание, оптимум не гарантирует."""
_es, _ef, total, _free, _end = cpm(net)
ls = {t: _es[t] + total[t] for t in net} # поздний старт = ранний старт + общий резерв
done, start, finish, usage = set(), {}, {}, defaultdict(int)
while len(done) < len(net):
ready = [t for t in net if t not in done and all(p in done for p in net[t][1])]
t = min(ready, key=lambda x: (ls[x], -net[x][0], x)) # при равенстве — сначала длинные
s = int(max((finish[p] for p in net[t][1]), default=0))
d, need = int(net[t][0]), demand[t]
while any(usage[day] + need > capacity for day in range(s, s + d)):
s += 1 # ближайший день, где хватает людей
for day in range(s, s + d):
usage[day] += need
start[t], finish[t] = s, s + d
done.add(t)
return start, finish, max(finish.values())
demand = {t: 1 for t in NET} # каждая работа занимает одного инженера
for capacity in (2, 3, 4):
*_, makespan = serial_sgs(NET, capacity, demand)
print(f"инженеров {capacity}: срок {makespan} дней")
# инженеров 2: срок 30 дней (PARSE уезжает на 9 дней и тащит за собой INT, PERF, REL)
# инженеров 3: срок 24 дней
# инженеров 4: срок 24 дней
Три числа стоит разглядывать долго. Двое инженеров дают 30 дней вместо 24 при том же графе. Третий инженер покупает шесть дней. Четвёртый не покупает ничего: ограничение переехало с ресурса на зависимость. Это и есть содержательный ответ на «дайте ещё людей» — одна строка кода вместо спора. Отдельно про мультизадачность: посадить человека на две работы по половине дня удлиняет обе больше чем вдвое — добавляется переключение контекста и растёт незавершённая работа, а по закону Литтла время прохождения пропорционально ей.
Часть 5. Сжатие срока: сколько стоит каждый купленный день
Crashing — покупать длительность за деньги: второй разработчик на пакет, подрядчик, ускоренная поставка. У работы есть предельная длительность (ниже не сожмётся ни за какие деньги) и цена дня — наклон кривой «стоимость/время». Правило: сжимать только критические работы, только самую дешёвую, пересчитывая критический путь после каждого дня.
def crash(net, limit, price, target):
"""Жадное сжатие: покупаем по дню на самой дешёвой критической работе.
limit — предельная (несжимаемая) длительность, price — цена дня в тыс. ₽.
Откатываем покупку, если срок не уменьшился: значит, критическим стал параллельный путь.
O(K · V · (V + E)), где K — число купленных дней."""
cur = {t: [d, list(p)] for t, (d, p) in net.items()}
*_, end = cpm(cur)
spent, log, blocked = 0.0, [], set()
while end > target:
_es, _ef, total, _free, end0 = cpm(cur)
cand = [t for t in cur if total[t] == 0 and cur[t][0] > limit[t] and t not in blocked]
if not cand:
break # дешевле уже не будет: упёрлись
t = min(cand, key=lambda x: (price[x], x))
cur[t][0] -= 1
*_, new_end = cpm(cur)
if new_end >= end0: # день куплен, а проект не сократился
cur[t][0] += 1 # откатываем и больше не трогаем этот пакет
blocked.add(t)
continue
blocked.clear()
spent, end = spent + price[t], new_end
log.append((t, cur[t][0]))
return end, spent, log
LIMIT = {"ANL": 3, "DB": 2, "BE": 5, "PARSE": 6, "FE": 7, "INT": 3, "PERF": 2, "SEC": 2, "DOC": 2, "REL": 2}
PRICE = {"ANL": 60, "DB": 40, "BE": 90, "PARSE": 50, "FE": 70, "INT": 120, "PERF": 30, "SEC": 0, "DOC": 25, "REL": 0}
for target in (20, 18):
end, spent, log = crash(NET, LIMIT, PRICE, target)
print(f"цель {target} д -> {end:.0f} д за {spent:.0f} тыс. ₽:", " ".join(f"{t}->{d}" for t, d in log))
# цель 20 д -> 20 д за 220 тыс. ₽: PERF->2 DB->2 ANL->3 BE->7
# цель 18 д -> 19 д за 340 тыс. ₽: PERF->2 DB->2 ANL->3 BE->7 INT->3
Второй запуск показывает эффект, который ломает интуицию: первые четыре дня стоят в среднем 55 тыс. ₽ каждый, пятый
(с 20 до 19) — уже 120, а шестой не покупается вовсе. К девятнадцатому дню критическими стали три пути сразу — через
BE, FE и PARSE, — и день пришлось бы покупать на каждом: 90 + 70 = 160 тыс. ₽ за сутки. Жадный алгоритм честно
останавливается: дальше либо принципиально другие деньги, либо другой объём. Точная задача решается как поток минимальной стоимости
(потоки в сетях), но на практике важнее не оптимальность, а сам
график цены дня — он превращает «нам надо на две недели раньше» в разговор о конкретной сумме.
Fast-tracking — вести последовательные работы внахлёст (FS превращается в SS с опережением). Денег не стоит,
покупает срок за вероятность переделки: если параллельный запуск экономит S дней, вероятность изменения предпосылки
p, а переделка стоит R дней, ставка выгодна при S > p · R. Вёрстка по правящемуся макету при p = 0,5 и
R = 8 — покупка четырёх дней ожидаемой переделки за экономию трёх. Третий способ не работает вовсе: добавленный в
горящий проект человек не производит с первого дня, зато потребляет время старых и квадратично увеличивает число
каналов связи (Команда и коммуникации).
Часть 6. Разброс: почему сеть опаздывает, даже когда все работы «в среднем вовремя»
Здесь живёт самая недооценённая ошибка планирования — эффект слияния путей (merge bias). В точке, куда сходятся
k независимых ветвей, проект продолжается, только когда закончилась последняя:
$$P(\text{узел не опоздал}) = \prod_{i=1}^{k} p_i$$
Три ветви по 0,8 — узел успевает с вероятностью 0,51. Наш INT собирает три ветви, REL — ещё три. Задержки
складываются, опережения не складываются: PARSE, закончившийся на два дня раньше, не ускоряет ничего, пока ждём
BE. Поэтому сумма честных оценок по сети всегда даёт оптимистичный срок, и запас внутри отдельных работ этого
не лечит. Лечит симуляция по сети, а не по сумме.
import random
def simulate_network(net, three_point, trials=20_000, seed=7):
"""Монте-Карло по сети: распределение срока и индекс критичности каждой работы.
three_point: работа -> (оптимистичная, вероятная, пессимистичная).
O(trials · (V + E)) времени, O(V + trials) памяти; точность растёт как O(1/sqrt(trials))."""
order, succ = topo(net) # топологию считаем один раз
rnd = random.Random(seed)
ends, critical = [], {t: 0 for t in net}
for _ in range(trials):
d = {t: rnd.triangular(o, p, m) for t, (o, m, p) in three_point.items()}
es = {t: 0.0 for t in net}
for t in order:
for s in succ[t]:
es[s] = max(es[s], es[t] + d[t])
end = max(es[t] + d[t] for t in net)
lf = {t: end for t in net}
for t in reversed(order):
if succ[t]:
lf[t] = min(lf[s] - d[s] for s in succ[t])
for t in net:
if lf[t] - es[t] - d[t] < 1e-9: # нулевой резерв именно в этом прогоне
critical[t] += 1
ends.append(end)
ends.sort()
return ends, {t: c / trials for t, c in critical.items()}
THREE = {
"ANL": (3, 4, 7), "DB": (2, 3, 6), "BE": (6, 8, 16), "PARSE": (5, 8, 16),
"FE": (6, 9, 18), "INT": (3, 4, 9), "PERF": (2, 3, 7), "SEC": (1, 2, 5),
"DOC": (2, 3, 6), "REL": (1, 2, 4),
}
ends, ci = simulate_network(NET, THREE)
pct = lambda p: ends[int(p * len(ends)) - 1]
print(f"P50={pct(0.5):.1f} P85={pct(0.85):.1f} P95={pct(0.95):.1f}")
print("уложились в детерминированные 24 дня:", f"{sum(1 for e in ends if e <= 24) / len(ends):.1%}")
print(" ".join(f"{t}:{c:.0%}" for t, c in sorted(ci.items(), key=lambda kv: -kv[1])))
# P50=30.4 P85=33.6 P95=35.5
# уложились в детерминированные 24 дня: 0.5%
# ANL:100% PERF:100% INT:100% REL:100% DB:70% BE:70% FE:21% PARSE:9% SEC:0% DOC:0%
Разбор результата:
- Детерминированные 24 дня достигаются в 0,5% прогонов. Это не «оценки плохие», это устройство сети. Наружу
называют
P85 ≈ 34 дня, а 24 дня оставляют внутренней целью. - Индекс критичности важнее критического пути.
FEформально некритичен (резерв 2 дня), но критичен в 21% прогонов;DBиBE— в 70%, потому что иногда их обгоняют соседние ветви. Всё выше 15% попадает в еженедельный контроль независимо от того, что нарисовано в плане; нулевой индекс (SEC,DOC) — повод перестать тратить на работу внимание руководства. - Независимость — идеализация. Общая инфраструктура, один заболевший, один сорвавшийся вендор двигают сразу несколько работ; корреляция увеличивает разброс, иногда вдвое. Корректно она добавляется дискретными рисковыми событиями поверх симуляции — см. Управление рисками.
Часть 7. Критическая цепь: буфер вместо запаса в каждой задаче
Голдратт заметил («Critical Chain», 1997), что запас, заложенный внутрь каждой оценки, до проекта не доходит. Съедают его студенческий синдром (начинаем в последний момент), закон Паркинсона (работа заполняет отведённое время), нежелание сдавать раньше (сдал раньше — в следующий раз срежут оценку) и вредная многозадачность. Плюс уже разобранное слияние путей. Механика ответа:
- Длительности берутся агрессивные, с вероятностью успеть около 50%.
- Критическая цепь — критический путь с учётом ресурсных конфликтов (выравнивание из части 4).
- Срезанное собирается в проектный буфер в конце цепи; впадающие ветви защищаются питающими буферами; дефицитные люди — ресурсными буферами (предупреждение «ты нужен через три дня»).
- Управление ведётся по расходу буфера, а не по датам отдельных задач.
from math import sqrt
# критическая цепь: (работа, оценка с вероятностью 50%, оценка с вероятностью 90%)
CHAIN = [("ANL", 4, 7), ("DB", 3, 6), ("BE", 8, 16), ("INT", 4, 9), ("PERF", 3, 7), ("REL", 2, 4)]
chain_len = sum(m for _t, m, _p in CHAIN)
cuts = [p - m for _t, m, p in CHAIN] # то, что срезали с каждой работы
print(f"цепь по 50%: {chain_len} д; наивная сумма оценок по 90%: {sum(p for _t, _m, p in CHAIN)} д")
print(f"буфер «половина срезанного»: {0.5 * sum(cuts):.1f} д -> обязательство {chain_len + 0.5 * sum(cuts):.1f} д")
print(f"буфер SSQ (корень из суммы квадратов): {sqrt(sum(c * c for c in cuts)):.1f} д"
f" -> обязательство {chain_len + sqrt(sum(c * c for c in cuts)):.1f} д")
# цепь по 50%: 24 д; наивная сумма оценок по 90%: 49 д
# буфер «половина срезанного»: 12.5 д -> обязательство 36.5 д
# буфер SSQ (корень из суммы квадратов): 11.3 д -> обязательство 35.3 д
Сравните с симуляцией из части 6: обязательство по SSQ (35,3) попадает примерно в P94 распределения, «половина срезанного» (36,5) — в P98, а наивная сумма оценок «по 90%» дала бы 49 дней, почти вдвое больше медианы. Разница между 49 и ~34 — это цена перестраховки, и одновременно причина, по которой команды с раздутыми оценками всё равно опаздывают. Контроль ведут по fever chart: по горизонтали доля выполненной цепи, по вертикали доля израсходованного буфера; важна траектория, а не точка.
Правило вмешательства обезоруживающе простое: зелёная зона — не трогать (руководитель, «наводящий порядок» в зелёной зоне, только вредит); жёлтая — написать план восстановления; красная — исполнять его. Это редкий артефакт статус-отчётности, где действие однозначно следует из цвета.
Честная критика. Три вещи работают независимо от отношения к теории ограничений: буфер на уровне проекта, явный учёт ресурсных конфликтов, управление по тренду расхода буфера. Но рандомизированных исследований эффективности почти нет; публикуемые кейсы — из проектных организаций (стройка, ОКР, обслуживание техники), а не из продуктовой разработки; «оценки по 50%» не приживаются там, где за срыв наказывают; «одна задача на человека» ломается о дежурства. Разумно забрать буферы и fever chart, не покупая идеологию целиком — как поступают с гибридными процессами.
Часть 8. Тот же предмет без единого сетевого графика
В командах, которые никогда не рисовали сеть, всё перечисленное существует под другими именами. От переименования физика не меняется.
| Классическое планирование | То же в потоковом мире | Что общего |
|---|---|---|
| Критический путь | Самая длинная цепочка блокировок между командами | Срок определяет цепь, а не сумма работ |
| Общий резерв | Запас до внешнего обязательства (аудит, конференция) | Принадлежит пути, а не задаче |
| Ресурсное выравнивание | WIP-лимиты и число параллельных инициатив | Ёмкость конечна, очередь удлиняет всё |
| Проектный буфер | Разница между P50-прогнозом и озвученной датой | Запас держат снаружи задач |
| Fast-tracking | Работа по контракту API до готовности бэкенда | Срок покупается за вероятность переделки |
| Индекс критичности | Доля задач, ждавших внешней зависимости | Где реально теряется время |
| Сетевой график релиза | Доска зависимостей на PI planning | Явные связи между командами |
Практический минимум для потоковой команды — три метрики: доля работ, хоть раз заблокированных внешней стороной, суммарное время в блокировке (обычно больше времени активной работы) и число открытых межкомандных зависимостей. Первые две живут в метриках потока и видны в lead time из DORA; третья — в масштабировании. Структурное лекарство — не лучшее расписание, а меньше связей: разделение по ограниченным контекстам убирает зависимость навсегда, выравнивание лишь аккуратно её обходит.
Часть 9. Базовый план: что делать, когда всё поехало
Расписание бесполезно без зафиксированного базового плана — снимка, с которым сравнивают факт. Без него «мы отстаём на неделю» непроверяемо, а перепланирование каждую пятницу гарантирует, что проект никогда не опаздывает и никогда не приезжает.
ёмкость и дата Базовый --> Базовый: факт внутри буфера,
отклонения гасятся резервами Базовый --> ПодУгрозой: расход буфера обгоняет
прогресс по цепи (жёлтая зона) ПодУгрозой --> Базовый: план восстановления сработал ПодУгрозой --> Пересмотр: изменились объём,
ёмкость или внешняя дата Пересмотр --> Базовый: новая версия базового плана,
старая сохранена для разбора ПодУгрозой --> Остановка: цель недостижима
в приемлемой цене Остановка --> [*] note right of Пересмотр Пересмотр — решение спонсора и запись в журнале изменений, а не тихая правка дат в трекере end note
Дешёвый и незаслуженно редкий инструмент контроля — анализ тренда вех: на каждом отчётном срезе фиксируется прогнозируемая дата каждой вехи, и смотрится не значение, а наклон.
| Отчёт | Прогноз вехи «Интеграция» | Прогноз вехи «Релиз» | Чтение |
|---|---|---|---|
| Неделя 2 | 19-й день | 24-й день | Плана держимся |
| Неделя 4 | 21-й день | 26-й день | Обе поехали на два дня — общая причина |
| Неделя 6 | 24-й день | 29-й день | Наклон устойчив: экстраполируйте, а не надейтесь |
| Неделя 8 | 25-й день | 33-й день | Линии разошлись: между вехами появилась новая работа |
Горизонтальная линия — веха под контролем; устойчивый наклон — систематическая ошибка оценки, которая сама не исправится; расхождение линий — в план тихо попала незапланированная работа. Наклон, подтверждённый трижды подряд, — момент решения: сокращать объём, покупать дни (часть 5) или признавать срыв и идти в контроль изменений и критерии остановки. Правило, экономящее репутацию: сообщайте тренд, а не свершившийся факт.
Часть 10. Типичные ошибки
- Называть исполнителю общий резерв. Он принадлежит пути; личный запас работы — свободный резерв.
- Планировать загрузку на 100%. Ожидание в очереди растёт как
ρ/(1−ρ): 90% вместо 80% удваивает срок. - Ставить в план даты вместо связей. План с жёсткими датами не пересчитывается и мгновенно становится фикцией.
- Прятать ресурсные конфликты в логические связи. Выравнивание после этого считает неправду.
- Называть наружу детерминированную дату CPM. В нашей сети её вероятность — половина процента.
- Сжимать некритическую работу. Деньги потрачены, срок не изменился; классика — ускорять то, что легче ускорить.
- Одна оценка на пакет вместо трёх. Без разброса нельзя посчитать ни буфер, ни индекс критичности.
- Держать буфер внутри задач. Он гарантированно съедается студенческим синдромом и законом Паркинсона.
- Реагировать на зелёную зону fever chart. Вмешательство без сигнала разрушает доверие к сигналу.
- Перепланировать молча. Каждый пересмотр базового плана — решение спонсора и запись в журнале.
- Забыть приёмку, обучение поддержки и прогрев. Работы, которых нет в сети, всё равно случаются.
Часть 11. Как это выглядит в проде
Релиз интеграции с внешним платёжным провайдером; жёсткая дата — окно сертификации у вендора, сдвиг стоит квартала.
- День 1. Декомпозиция по правилу 8/80, 22 пакета. Связи ставились по вопросу «что физически мешает начать раньше»: из 31 нарисованной связи выжили 19.
- День 2. Три оценки на пакет, полчаса на всю сеть. CPM даёт 41 день, симуляция по сети — P50 = 48, P85 = 55. Со спонсором говорим сразу по P85: это единственный способ не пообещать невыполнимое.
- День 3. Выравнивание по фактической ёмкости (два бэкендера, полтора фронтендера) даёт 59 дней — ограничением оказался не граф, а люди. Человек с соседней инициативы на четыре недели возвращает срок к 51 дню.
- День 4. Индекс критичности показал, что пакет «обновление SDK провайдера» критичен в 34% прогонов при плановом резерве в четыре дня. Попал в еженедельный контроль и в итоге действительно уехал.
- Еженедельно. Один экран: fever chart, три работы с наибольшим индексом критичности, открытые внешние зависимости с датами обещаний. Статус-отчёта на четыре страницы не существует.
- Неделя 5 и итог. Жёлтая зона; план восстановления — сократить объём первой версии вместо сверхурочных, буфер вернулся в зелёное за две недели. 53 дня против обещанных 55.
Мини-итог
- У работы два резерва: общий (до сдвига проекта) и свободный (до сдвига соседа). С исполнителем говорят по свободному, с заказчиком — по общему.
- CPM линеен и оптимистичен: он не знает ни про людей, ни про разброс. Ресурсы считаются эвристикой, разброс — симуляцией по сети; ёмкость перестаёт быть ограничением скачком (третий инженер купил шесть дней, четвёртый — ноль).
- Цена дня растёт нелинейно: пока критический путь один — 55 тыс. ₽ за сутки, когда их три — 160 тыс. ₽.
- Слияние путей делает сумму честных оценок оптимистичной; наружу называют P85, а не дату из CPM, а индекс критичности даёт более полезный список для контроля, чем критический путь.
- Буфер держат снаружи задач и управляют по его расходу. Лучшее расписание — то, которое не понадобилось: устранённая зависимость снижает и срок, и разброс.
Источники
- Kelley J., Walker M. «Critical-Path Planning and Scheduling» (1959) — исходная работа по CPM.
- PMI. «PMBOK Guide», раздел Schedule Management — типы связей, лаги, выравнивание, сжатие: pmi.org.
- Goldratt E. «Critical Chain» (1997) — буферы, студенческий синдром, управление по расходу буфера: toc-goldratt.com.
- Newbold R. «Project Management in the Fast Lane» (1998) — размер буфера по корню из суммы квадратов.
- Kolisch R., Hartmann S. «Experimental Investigation of Heuristics for RCPSP» (2006) — почему приоритет по позднему старту работает лучше большинства правил: doi.org/10.1016/j.ejor.2005.01.065.
- Vanhoucke M. «Measuring Time» (2009) — индекс критичности и чувствительность расписания.
- Reinertsen D. «The Principles of Product Development Flow» (2009) — очереди и загрузка; Brooks F. «The Mythical Man-Month» (1975) — почему люди в горящем проекте его не ускоряют.
Что дальше
Это последняя статья трека. Она замыкает его инженерную часть: процесс из статей 01–12 определяет, как быстро вы узнаёте правду, статья 13 — на каких условиях проект живёт и умирает, а расписание — единственное место, где зависимости, ёмкость и разброс складываются в конкретную дату. Если из статьи остаётся одна мысль, пусть будет эта: дата — не число, а распределение, и управляют не датой, а размером буфера между ней и обещанием.
Куда идти дальше:
- Вернуться к карте трека и пройти пропущенное — Управление проектами в IT: карта трека; внешняя петля проекта — Запуск и закрытие проекта.
- Откуда берётся приоритет — трек Product Management, в частности приоритизация и стоимость задержки; довести требования до приёмки, чтобы в сети не появлялись забытые работы — трек системного и бизнес-анализа.
- Снизить транзакционную стоимость поставки, без чего сжимать расписание бессмысленно — DevOps и SRE; уменьшить связность, из-за которой возникают сами зависимости — DDD и архитектурные паттерны.
- Алгоритмическая основа расчётов — обход графов и топологическая сортировка и потоки в сетях. Общая карта портала — Roadmap.