D ビジネス/PM — バックログ 5 件の着手順を Cost of Delay で決める(年間効果順+同時着手 1,079 万円 → 分割+年末先頭 543 万円・損益分岐 L = 215.3 万円)

2026-10-08 木曜 木曜 D: ビジネス/PM ★★★★★ Cost of Delay / CD3 / WSPT 固定日型 / WIP 制限 / 分割

概要

⏱️

「いつ得るか」で並べる

年間効果順は最適より +90.0 万円。並べ替えで総額は変わらない。

🧵

同時着手は全部を遅らせる

5 件均等割りで 1,079.0 万円(+72%)。最初の完成が 5 週目。

🗓️

締切で消える案件は別扱い

年末セールは 7 週を過ぎると 200 万円が消える。CD3 だけでは最後に回す。

✂️

一番効くのは割ること

カゴ落ちを 2 つに割って 629.0 → 457.0 万円。結論を決めるのは L(分岐 215.3)。

問題

MOps 販促システムチーム(エンジニア 4 名)は、次の 26 週で 5 件の案件を抱えている。後輩 PdM 見習いが着手順の案を作り、来週の優先度会議に出す。別のシニアからは「SAFe の WSJF で点数を付ければよい」という意見も出ている。あなたはレビューを頼まれた。

制約・前提条件(すべて 仮定。会議前にチームの値に置換)

案件CoD(万円/週)期間(週)(最小, 最頻, 最大)備考
CART カゴ落ちリマインド配信30(4, 6, 10)年間効果 1,560 = 30 × 52
XMAS 年末セールの多段クーポン2(3, 4, 8)7 週目の終わりまでに出せないと 200 万円を一度に失う(固定日型)
OPTOUT 配信停止の即時反映6(1.5, 2, 3)反映漏れの苦情・対応費の期待値
BQ スキャン削減3(0.5, 1, 2)
ADMIN 管理画面リニューアル10(6, 8, 14)
  • 直列なら上の期間で終わる。5 件同時なら稼働を均等割り(1/5 の速さ)
  • カゴ落ちは 1 段目(固定文面・1 通、CoD 20、(1.5, 2, 4))と 2 段目(出し分け・3 通、CoD 10、(3, 4, 7))に割れる。CoD 合計は 30 のまま
  • 遅延コスト = Σ(完成週 × CoD)+ 締切を過ぎた固定日型の損失。これを小さくする順を選ぶ

期待する回答形式: 問題点 8 点/試算のコードとテスト/実行例/会議に出す着手順/判断ルールと見直す条件/迷った案と前提が変わったときの判断/扱わなかった範囲

悪い案 (Before)

後輩案とシニア案に 8 つの問題 が隠れています。見つけてから先へ進んでください。
後輩案

件名: 来四半期の着手順(年間効果の大きい順)

1. カゴ落ちリマインド 1,560 万円 / 2. 管理画面 520 万円 / 3. 配信停止 312 万円 / 4. 年末セール 304 万円 / 5. BQ 削減 156 万円

どれも大事なので、来週から 5 件とも同時に着手します(4 名で少しずつ並行)。

シニア案(SAFe 風 WSJF・相対点)
案件価値緊急度リスク規模WSJF
カゴ落ち135182.38
年末813154.40
配信停止231329.00
BQ21114.00
管理画面822130.92
問題点サマリー(8点)
1-2並べ方 — 総額で並べ、期間を見ない
3WIP — 5 件同時着手
4締切 — 固定日型を普通の案件扱い
5-6尺度と分布 — 相対点の比・最頻値の 1 点
7-8判断 — 割らない・結論を決める仮定を名指ししない

ヒント(段階的開示)

ヒント1 — 方向性
「年間 1,560 万円」と「年末の 200 万円」は同じ物差しか。並べ替えで変わるのは「いくら得るか」ではなく「いつ得るか」。1 週遅れると各案件はいくら失うか。
ヒント2 — アプローチ
  • 年間効果(ストック)を 1 週あたりの遅延コスト(フロー)に直す
  • 線形の遅延コストだけなら CoD ÷ 期間(CD3)の大きい順が最適(Smith の規則、1956)
  • 締切で一度に価値が消える案件は CD3 では扱えない。5 件なら 120 通りなので全順列で確かめる
  • 同時並行はどれだけ全体を遅らせるか計算する
  • 期間を最頻値で決めてよいか。ぶれたら順位はどうなるか
ヒント3 — 誘導
delay_cost(順) = Σ CoD_i × 完成週_i + Σ(完成週_i > 締切_i) × 損失_i
CD3_i = CoD_i ÷ 期間_i     → 大きい順(隣り合う 2 件の入れ替えで最適性を示せる)
同時並行(n 件均等割り)   → 残作業最小の件が終わるまで 残作業 × n 週
見る数字 = 最頻値の遅延コスト、2 万回ぶらした平均と P90、年末に間に合う確率、L の損益分岐

問題点分析(8点)

#問題点分類何が起きているか(出所つき)改善方法
1年間効果で並べた並べ方年間効果順は 719.0 万円。最適 629.0 より +90.0 <実測:試算>1 週遅れると失う額(CoD)に直す
2期間を見ていない並べ方管理画面(10÷8=1.25)を配信停止(6÷2=3.0)より前に置いたCoD ÷ 期間(CD3)で並べる
35 件を同時に着手WIP最初の完成は 5 週目。1,079.0 万円(+450.0、+72%)。ぶれても平均 +495.4 <実測:試算>1 件ずつ終わらせる
4年末セールを普通の案件扱い締切7 週を過ぎると 200 万円が一度に消える。完成 20 週目で間に合う確率 0.0%固定日型を別に扱う
5相対点の比(シニア案)尺度WSJF 順は最頻値で +16.0、ぶれると平均 +143.4・P90 +292.0、年末 49.6% <実測:試算>万円/週と週で置く
6期間を最頻値で置いた分布年末 4 → 平均 5.0 週 <導出>。最頻値では年末先頭が最適(629.0 vs 667.0)、期待値では逆(740.2 vs 724.6)範囲で置き平均と P90 で比べる
7大きい案件を割らない分割カゴ落ちを割るだけで 629.0 → 457.0(−27%)、期待値 724.6 → 648.0価値の大半を先に出す切り方
8結論を決める仮定を名指ししない判断年末の損失 L の損益分岐は 215.3 万円。仮置き 200 はその 7% 手前L を測り、見直す条件を書く

並べ方と結論を決める仮定(SVG)

① 同じ 5 件、並べ方だけで遅延コストが 1,079 → 543 万円(期間は最頻値) 帯 = 着手から完成まで(週)。赤い破線 = 年末セールのコードフリーズ(7 週目の終わり)。数値は実測:試算、入力は仮定 0 2 4 6 8 10 12 14 16 18 20 22 週 締切 7 週 Bad: 年間効果順719.0 万円・年末 0% カゴ落ち 管理画面 停止 年末 年末は 20 週目 → 200 万円を失う Bad: 5 件同時着手1,079.0 万円・最初の完成 5 週 細い帯 = 1/5 の速さ Good: 分割+年末を先頭543.0 万円(期待値 663.4)・年末 95.0% 年末 カゴ落ち1 停止 カゴ落ち2 管理画面 Good: 分割+CD3期待値 648.0 万円・年末は諦める カゴ落ち1 停止 カゴ落ち2 管理画面 年末 下 2 本のどちらにするかは「年末に間に合わないと失う額 L」で決まる(損益分岐 215.3 万円)。期間がぶれると最頻値の順位は入れ替わる。

同じ 5 件・同じ人数でも、並べ方と割り方だけで遅延コストが半分になる(最頻値)。同時着手は全件が細い帯で長く伸びる。

② 結論を決める仮定は 1 つ — 年末に間に合わないと失う額 L(分割後、期待値、各点 5,000 回の試算) 550 600 650 700 750 800 850 100 200 300 400 L(万円)期待遅延コスト(万円) 年末を先頭 671.4CD3(年末を諦める)847.3 損益分岐 L = 215.3 仮置き L = 200(7% 手前) L=100: 諦める方が 109.9 得L=400: 先頭の方が 175.9 得

2 本の線は L ≈ 215 万円で交わる。仮置きの 200 万円はその手前にあり、L を測らずにどちらかに決めるのは賭けになる。L が分からないなら、最悪の後悔が小さい「年末を先頭」(109.9 < 175.9)。

Bad → Good(コード差分)

Bad
# Bad: 年間効果の大きい順に並べ、全部に同時着手する
backlog = {"CART": 1560, "ADMIN": 520, "OPTOUT": 312, "XMAS": 304, "BQ": 156}  # 年間効果(万円)
order = sorted(backlog, key=lambda k: -backlog[k])   # いくら得るか(総額)で並べる。期間を見ない
for k in order:
    start(k)                                          # 4 名で 5 件を同時に少しずつ。最初の完成は 5 週目
# 年末セールは「年 304 万円の普通の案件」。7 週のフリーズは表に無い
# 期間は最頻値の 1 点。結論を変える仮定(年末の損失)を名指ししない
# 結果: 遅延コスト 1,079.0 万円(最適 629.0 の +72%)、年末に間に合う 0%
Good
# Good: 1 週あたりの遅延コストと期間で並べ、締切と分割と不確かさを入れる
cod_w = {"CART1": 20, "CART2": 10, "XMAS": 2, "OPTOUT": 6, "BQ": 3, "ADMIN": 10}  # 万円/週
order = order_cd3(items=SPLIT_ITEMS)        # CoD ÷ 期間の大きい順(Smith の規則)
# 固定日型(XMAS)は CD3 の外で扱う。入れるなら先頭(最頻値で詰めると 38.7% しか間に合わない)
mean, p90 = expected_cost(order, SPLIT_ITEMS)   # 期間を三角分布で 2 万回ぶらす
table, be = split_decision()                # 年末の損失 L ごとに 2 案を比べ、損益分岐を出す
# 結果: 分割+CD3 648.0 / 分割+年末先頭 663.4(年末 95.0%)、L の損益分岐 215.3 万円

適用した考え方: Cost of Delay、CD3(WSPT / Smith の規則)、固定日型の別扱い、WIP 制限、三角分布での Monte Carlo、損益分岐と最悪の後悔(minimax regret)。D の日のため書籍の章対応は無し。

模範解答

試算(cod.py)

"""販促システムチームの次の 26 週の着手順を Cost of Delay で決める試算。Python 3.12+、標準ライブラリのみ。

出所ラベル: CoD・期間・締切・年間効果・相対点=仮定(導入前にチームの値で置換する) /
順位・遅延コスト・確率=この試算の実測(seed 固定)。
CD3 = Cost of Delay ÷ Duration(Reinertsen 2009)。線形の CoD だけなら Smith の規則(1956, WSPT)で最適。
固定日型(締切を過ぎると一度に失う)が混ざると CD3 は最適を保証しない。
"""
import itertools
import random
import statistics
from dataclasses import dataclass

TRIALS = 20_000          # 期間の不確かさを試す回数
SEED = 7

@dataclass(frozen=True)
class Item:
    """バックログの 1 件。"""
    key: str
    name: str
    cod_w: float                      # 線形の遅延コスト(万円/週): 完成が 1 週遅れるたびに失う額
    dur: tuple[float, float, float]   # 期間(週)(最小, 最頻, 最大) の三角分布
    cliff_week: float | None = None   # この週までに出せないと一度に失う(固定日型)
    cliff_loss: float = 0.0           # 締切を過ぎたときに失う額(万円)
    annual_value: float = 0.0         # 後輩案が並べた「年間効果」(万円)
    fib_value: int = 0                # SAFe 風の相対点(フィボナッチ): 事業価値
    fib_time: int = 0                 #   時間的緊急度
    fib_rr: int = 0                   #   リスク低減・機会創出
    fib_size: int = 0                 #   規模

ITEMS = [
    Item("CART", "カゴ落ちリマインド配信", 30, (4, 6, 10), annual_value=1560,
         fib_value=13, fib_time=5, fib_rr=1, fib_size=8),
    Item("XMAS", "年末セールの多段クーポン配布", 2, (3, 4, 8), cliff_week=7, cliff_loss=200,
         annual_value=304, fib_value=8, fib_time=13, fib_rr=1, fib_size=5),
    Item("OPTOUT", "配信停止の即時反映", 6, (1.5, 2, 3), annual_value=312,
         fib_value=2, fib_time=3, fib_rr=13, fib_size=2),
    Item("BQ", "BigQuery スキャン削減", 3, (0.5, 1, 2), annual_value=156,
         fib_value=2, fib_time=1, fib_rr=1, fib_size=1),
    Item("ADMIN", "管理画面リニューアル", 10, (6, 8, 14), annual_value=520,
         fib_value=8, fib_time=2, fib_rr=2, fib_size=13),
]

# カゴ落ちを「1段目(固定文面・1通)」と「2段目(出し分け・3通)」に割る案。
# 仮定: CoD 30 万円/週のうち 20 は 1段目だけで取れる。分割の手間で合計期間は 6 → 6 週(最頻値)のまま。
SPLIT_ITEMS = [i for i in ITEMS if i.key != "CART"] + [
    Item("CART1", "カゴ落ち 1段目(固定文面・1通)", 20, (1.5, 2, 4)),
    Item("CART2", "カゴ落ち 2段目(出し分け・3通)", 10, (3, 4, 7)),
]

def mode_durs(items: list[Item]) -> dict[str, float]:
    return {i.key: i.dur[1] for i in items}

def delay_cost(order: list[str], durs: dict[str, float], items: list[Item] = ITEMS) -> float:
    """1 件ずつ直列に進めたときの遅延コスト合計(万円)。

    Args:
        order: 着手順(key の並び)。
        durs: 各 key の実際の期間(週)。
        items: 対象のバックログ。

    Returns:
        Σ(完成週 × CoD) + 締切を過ぎた固定日型の損失。
    """
    by = {i.key: i for i in items}
    t = total = 0.0
    for k in order:
        i = by[k]
        t += durs[k]                                   # この件の完成週
        total += i.cod_w * t
        if i.cliff_week is not None and t > i.cliff_week:
            total += i.cliff_loss                      # 締切当日の完成は間に合った扱い
    return total

def parallel_cost(durs: dict[str, float], items: list[Item] = ITEMS) -> float:
    """全件に同時着手し、稼働を残り件数で均等割りしたとき(processor sharing)の遅延コスト。"""
    by = {i.key: i for i in items}
    remaining = dict(durs)
    t = total = 0.0
    while remaining:
        n = len(remaining)
        k_min = min(remaining, key=remaining.get)
        done = remaining.pop(k_min)
        t += done * n                                  # 1/n の速さなので暦では n 倍かかる
        for k in remaining:
            remaining[k] -= done
        i = by[k_min]
        total += i.cod_w * t
        if i.cliff_week is not None and t > i.cliff_week:
            total += i.cliff_loss
    return total

# ---- 並べ方(後輩案と代替案)----
def order_by_annual_value(items: list[Item] = ITEMS) -> list[str]:
    return [i.key for i in sorted(items, key=lambda i: -i.annual_value)]

def order_easy_first(items: list[Item] = ITEMS) -> list[str]:
    return [i.key for i in sorted(items, key=lambda i: i.dur[1])]

def order_safe_wsjf(items: list[Item] = ITEMS) -> list[str]:
    """SAFe 風 WSJF =(事業価値+時間的緊急度+リスク低減)÷ 規模。すべて相対点。"""
    return [i.key for i in sorted(items, key=lambda i: -(i.fib_value + i.fib_time + i.fib_rr) / i.fib_size)]

def order_cd3(durs: dict[str, float] | None = None, items: list[Item] = ITEMS) -> list[str]:
    d = durs or mode_durs(items)
    return [i.key for i in sorted(items, key=lambda i: -i.cod_w / d[i.key])]

def order_cd3_with_deadline(durs: dict[str, float] | None = None, items: list[Item] = ITEMS) -> list[str]:
    """CD3 の順に並べ、固定日型だけを「締切に間に合う最も後ろの位置」へ動かす。"""
    d = durs or mode_durs(items)
    order = order_cd3(d, items)
    for i in items:
        if i.cliff_week is None:
            continue
        order.remove(i.key)
        best = 0                                       # どこにも入らなければ先頭(それでも遅れるなら守れない)
        for pos in range(len(order) + 1):
            if sum(d[k] for k in order[:pos]) + d[i.key] > i.cliff_week:
                break
            best = pos
        order.insert(best, i.key)
    return order

def brute_force(durs: dict[str, float], items: list[Item] = ITEMS) -> tuple[list[str], float]:
    """全順列を試す(5 件で 120 通り、7 件で 5,040 通り)。10 件を超えたら使わない(10! = 362 万)。"""
    best = min(itertools.permutations([i.key for i in items]), key=lambda o: delay_cost(list(o), durs, items))
    return list(best), delay_cost(list(best), durs, items)

def robustness(seed: int = SEED, n: int = TRIALS) -> dict[str, dict]:
    """計画時(最頻値)に決めた順のまま走り、実際の期間がぶれたとき、事後の最適からどれだけ外れるか。"""
    rng = random.Random(seed)
    plans = {"年間効果順": order_by_annual_value(), "楽な順": order_easy_first(),
             "SAFe WSJF": order_safe_wsjf(), "CD3": order_cd3(), "CD3+締切": order_cd3_with_deadline()}
    regrets: dict[str, list[float]] = {k: [] for k in [*plans, "同時並行"]}
    xmas_ok = dict.fromkeys(plans, 0)
    for _ in range(n):
        d = {i.key: rng.triangular(i.dur[0], i.dur[2], i.dur[1]) for i in ITEMS}
        _, opt = brute_force(d)
        for name, o in plans.items():
            regrets[name].append(delay_cost(o, d) - opt)
            finish = sum(d[k] for k in o[: o.index("XMAS") + 1])
            xmas_ok[name] += finish <= 7
        regrets["同時並行"].append(parallel_cost(d) - opt)
    out = {}
    for name, r in regrets.items():
        r.sort()
        out[name] = {"mean": statistics.fmean(r), "p90": r[int(n * 0.9)],
                     "p_opt": sum(x < 1e-9 for x in r) / n,
                     "xmas": xmas_ok[name] / n if name in xmas_ok else None}
    return out

def expected_cost(order: list[str], items: list[Item] = ITEMS, seed: int = SEED, n: int = TRIALS) -> tuple[float, float]:
    """期間がぶれるときの遅延コストの平均と P90(同じ seed なら案どうしで同じ乱数列を使う)。"""
    rng = random.Random(seed)
    cs = sorted(delay_cost(order, {i.key: rng.triangular(i.dur[0], i.dur[2], i.dur[1]) for i in items}, items)
                for _ in range(n))
    return statistics.fmean(cs), cs[int(n * 0.9)]

def on_time(order: list[str], items: list[Item] = ITEMS, seed: int = SEED, n: int = TRIALS) -> float:
    """年末セール(XMAS)が締切 7 週目までに出る確率。"""
    rng = random.Random(seed)
    hit = 0
    for _ in range(n):
        d = {i.key: rng.triangular(i.dur[0], i.dur[2], i.dur[1]) for i in items}
        hit += sum(d[k] for k in order[: order.index("XMAS") + 1]) <= 7
    return hit / n

def cliff_sensitivity(losses=(100, 150, 200, 300, 400), n: int = 5_000) -> list[tuple[float, float, float]]:
    """年末セールの損失額(最も根拠の薄い仮定)を動かし、「年末を先に」と「CD3 のまま」の期待コストを比べる。"""
    out = []
    for loss in losses:
        items = [Item(**{**i.__dict__, "cliff_loss": loss}) if i.key == "XMAS" else i for i in ITEMS]
        out.append((loss, expected_cost(order_cd3_with_deadline(items=items), items, n=n)[0],
                    expected_cost(order_cd3(items=items), items, n=n)[0]))
    return out

def split_decision(losses=(100, 200, 300, 400)) -> tuple[list[tuple[float, float, float]], float]:
    """分割したうえで「年末を先頭」と「CD3(年末は諦める)」を年末損失 L ごとに比べ、損益分岐の L を二分探索で出す。"""
    so = order_cd3(items=SPLIT_ITEMS)
    xf = ["XMAS", *[k for k in so if k != "XMAS"]]
    def costs(loss: float) -> tuple[float, float]:
        its = [Item(**{**i.__dict__, "cliff_loss": loss}) if i.key == "XMAS" else i for i in SPLIT_ITEMS]
        return expected_cost(xf, its, n=5_000)[0], expected_cost(so, its, n=5_000)[0]
    table = [(loss, *costs(loss)) for loss in losses]
    lo, hi = 0.0, 1_000.0
    for _ in range(30):
        mid = (lo + hi) / 2
        a, b = costs(mid)
        lo, hi = (lo, mid) if a < b else (mid, hi)
    return table, lo

if __name__ == "__main__":
    d0 = mode_durs(ITEMS)
    best, opt = brute_force(d0)
    rows = [("年間効果順", order_by_annual_value()), ("楽な順", order_easy_first()),
            ("SAFe WSJF", order_safe_wsjf()), ("CD3", order_cd3()), ("CD3+締切", order_cd3_with_deadline()),
            ("全順列の最適", best)]
    for name, o in rows:
        c = delay_cost(o, d0)
        print(f"{name:10s} {' → '.join(o):38s} 遅延コスト {c:6.1f} 万円  最適との差 {c - opt:5.1f}")
    pc = parallel_cost(d0)
    print(f"{'同時並行':10s} {'5 件を均等割り':38s} 遅延コスト {pc:6.1f} 万円  最適との差 {pc - opt:5.1f}")
    ds = mode_durs(SPLIT_ITEMS)
    so = order_cd3_with_deadline(ds, SPLIT_ITEMS)
    sb, sopt = brute_force(ds, SPLIT_ITEMS)
    print(f"{'分割+CD3+締切':10s} {' → '.join(so):38s} 遅延コスト {delay_cost(so, ds, SPLIT_ITEMS):6.1f} 万円  分割後の全順列最適 {sopt:.1f}({' → '.join(sb)})")
    print()
    for name, o, its in (("CD3", order_cd3(), ITEMS), ("CD3+締切", order_cd3_with_deadline(), ITEMS),
                         ("分割+CD3+締切", so, SPLIT_ITEMS),
                         ("分割+年末を先頭", ["XMAS", *[k for k in so if k != "XMAS"]], SPLIT_ITEMS),
                         ("分割+CD3", order_cd3(items=SPLIT_ITEMS), SPLIT_ITEMS)):
        m, p90 = expected_cost(o, its)
        print(f"[期待値] {name:10s} {' → '.join(o):38s} 遅延コスト 平均 {m:6.1f} 万円  P90 {p90:6.1f}  年末に間に合う {on_time(o, its):5.1%}")
    for loss, dl, cd in cliff_sensitivity():
        print(f"[感度] 年末の損失 {loss:3.0f} 万円: 年末を先に {dl:6.1f} / CD3 のまま {cd:6.1f}  → {'年末を先に' if dl < cd else 'CD3 のまま'}")
    table, be = split_decision()
    for loss, xf_c, cd_c in table:
        print(f"[分割後の判断] 年末の損失 {loss:3.0f} 万円: 年末を先頭 {xf_c:6.1f} / CD3(年末を諦める) {cd_c:6.1f}  差 {xf_c - cd_c:+6.1f}")
    print(f"[分割後の判断] 損益分岐の年末損失 {be:.1f} 万円(これ以上なら年末を先頭)")
    print()
    for name, s in robustness().items():
        x = "" if s["xmas"] is None else f"  年末に間に合う {s['xmas']:.1%}"
        print(f"[期間が揺れる] {name:10s} 最適との差 平均 {s['mean']:6.1f} 万円  P90 {s['p90']:6.1f}  最適と一致 {s['p_opt']:5.1%}{x}")

テスト(test_cod.py、12 件)

import random
import unittest

import cod as C


class T(unittest.TestCase):
    def test_bad_order_reproduces_719(self):
        d = C.mode_durs(C.ITEMS)
        self.assertEqual(C.order_by_annual_value(), ["CART", "ADMIN", "OPTOUT", "XMAS", "BQ"])
        self.assertAlmostEqual(C.delay_cost(C.order_by_annual_value(), d), 719.0)

    def test_cd3_is_optimal_when_all_linear(self):
        """締切を外すと CD3 は全順列の最適と一致する(Smith の規則)。乱数 200 例。"""
        rng = random.Random(1)
        for _ in range(200):
            items = [C.Item(f"I{j}", "", rng.uniform(1, 30), (0, 1, 2)) for j in range(5)]
            d = {i.key: rng.uniform(0.5, 10) for i in items}
            _, opt = C.brute_force(d, items)
            self.assertAlmostEqual(C.delay_cost(C.order_cd3(d, items), d, items), opt, places=6)

    def test_cd3_alone_misses_cliff(self):
        d = C.mode_durs(C.ITEMS)
        o = C.order_cd3()
        self.assertGreater(sum(d[k] for k in o[: o.index("XMAS") + 1]), 7)

    def test_deadline_order_meets_cliff_and_is_optimal_at_mode(self):
        d = C.mode_durs(C.ITEMS)
        o = C.order_cd3_with_deadline()
        self.assertLessEqual(sum(d[k] for k in o[: o.index("XMAS") + 1]), 7)
        self.assertAlmostEqual(C.delay_cost(o, d), C.brute_force(d)[1])

    def test_cliff_on_the_day_is_in_time(self):
        items = [C.Item("X", "", 0, (1, 7, 8), cliff_week=7, cliff_loss=200)]
        self.assertEqual(C.delay_cost(["X"], {"X": 7.0}, items), 0)
        self.assertEqual(C.delay_cost(["X"], {"X": 7.01}, items), 200)

    def test_parallel_never_beats_best_serial(self):
        rng = random.Random(3)
        for _ in range(300):
            d = {i.key: rng.triangular(i.dur[0], i.dur[2], i.dur[1]) for i in C.ITEMS}
            self.assertGreaterEqual(C.parallel_cost(d) + 1e-9, C.brute_force(d)[1])

    def test_parallel_finish_time_of_last_equals_total_work(self):
        items = [C.Item("A", "", 1, (0, 1, 2)), C.Item("B", "", 0, (0, 1, 2))]
        # A=2 週, B=2 週を半分ずつ → 2 件とも 4 週目に完成。A の CoD 1 × 4 = 4
        self.assertAlmostEqual(C.parallel_cost({"A": 2, "B": 2}, items), 4.0)

    def test_split_reduces_cost(self):
        d, ds = C.mode_durs(C.ITEMS), C.mode_durs(C.SPLIT_ITEMS)
        self.assertLess(C.brute_force(ds, C.SPLIT_ITEMS)[1], C.brute_force(d)[1])

    def test_same_seed_same_result(self):
        self.assertEqual(C.robustness(n=300), C.robustness(n=300))

    def test_deadline_item_goes_as_late_as_possible(self):
        """締切に余裕があるなら前に出さない(不要に他を遅らせない)。"""
        items = [C.Item("A", "", 10, (0, 1, 2)), C.Item("X", "", 1, (0, 1, 2), cliff_week=100, cliff_loss=50)]
        self.assertEqual(C.order_cd3_with_deadline({"A": 1, "X": 1}, items), ["A", "X"])

    def test_parallel_counts_cliff(self):
        items = [C.Item("A", "", 0, (0, 1, 2)), C.Item("X", "", 0, (0, 1, 2), cliff_week=3, cliff_loss=50)]
        # 単独なら 2 週で間に合うが、半分ずつだと 4 週目 → 締切超え
        self.assertEqual(C.parallel_cost({"A": 2, "X": 2}, items), 50)

    def test_split_keeps_total_cod(self):
        """分割で CoD を増やして見せない(合計は元の 30 万円/週のまま)。"""
        cart = next(i for i in C.ITEMS if i.key == "CART")
        self.assertEqual(sum(i.cod_w for i in C.SPLIT_ITEMS if i.key.startswith("CART")), cart.cod_w)


if __name__ == "__main__":
    unittest.main()
テストの強さの確認: 7 種の誤り(変異)を 1 つずつ入れて実行。1 回目は 3 種(締切の挿入位置を常に先頭/同時並行で締切を見ない/分割で CoD を減らす)が緑のまま通った。3 件のテストを足し、7 種すべてが赤になることを確かめた。

実行例

$ python3 cod.py
年間効果順      CART → ADMIN → OPTOUT → XMAS → BQ      遅延コスト  719.0 万円  最適との差  90.0
楽な順        BQ → OPTOUT → XMAS → CART → ADMIN      遅延コスト  635.0 万円  最適との差   6.0
SAFe WSJF  OPTOUT → XMAS → BQ → CART → ADMIN      遅延コスト  645.0 万円  最適との差  16.0
CD3        CART → OPTOUT → BQ → ADMIN → XMAS      遅延コスト  667.0 万円  最適との差  38.0
CD3+締切     XMAS → CART → OPTOUT → BQ → ADMIN      遅延コスト  629.0 万円  最適との差   0.0
全順列の最適     XMAS → CART → OPTOUT → BQ → ADMIN      遅延コスト  629.0 万円  最適との差   0.0
同時並行       5 件を均等割り                               遅延コスト 1079.0 万円  最適との差 450.0
分割+CD3+締切  CART1 → XMAS → OPTOUT → BQ → CART2 → ADMIN 遅延コスト  467.0 万円  分割後の全順列最適 457.0(CART1 → BQ → XMAS → OPTOUT → CART2 → ADMIN)

[期待値] CD3        CART → OPTOUT → BQ → ADMIN → XMAS      遅延コスト 平均  724.6 万円  P90  819.9  年末に間に合う  0.0%
[期待値] CD3+締切     XMAS → CART → OPTOUT → BQ → ADMIN      遅延コスト 平均  740.2 万円  P90  859.3  年末に間に合う 95.1%
[期待値] 分割+CD3+締切  CART1 → XMAS → OPTOUT → BQ → CART2 → ADMIN 遅延コスト 平均  680.9 万円  P90  826.0  年末に間に合う 38.7%
[期待値] 分割+年末を先頭   XMAS → CART1 → OPTOUT → BQ → CART2 → ADMIN 遅延コスト 平均  663.4 万円  P90  752.9  年末に間に合う 95.0%
[期待値] 分割+CD3     CART1 → OPTOUT → BQ → CART2 → ADMIN → XMAS 遅延コスト 平均  648.0 万円  P90  702.1  年末に間に合う  0.0%
[感度] 年末の損失 100 万円: 年末を先に  734.5 / CD3 のまま  624.1  → CD3 のまま
[感度] 年末の損失 150 万円: 年末を先に  736.9 / CD3 のまま  674.1  → CD3 のまま
[感度] 年末の損失 200 万円: 年末を先に  739.3 / CD3 のまま  724.1  → CD3 のまま
[感度] 年末の損失 300 万円: 年末を先に  744.1 / CD3 のまま  824.1  → 年末を先に
[感度] 年末の損失 400 万円: 年末を先に  748.8 / CD3 のまま  924.1  → 年末を先に
[分割後の判断] 年末の損失 100 万円: 年末を先頭  657.2 / CD3(年末を諦める)  547.3  差 +109.9
[分割後の判断] 年末の損失 200 万円: 年末を先頭  661.9 / CD3(年末を諦める)  647.3  差  +14.6
[分割後の判断] 年末の損失 300 万円: 年末を先頭  666.6 / CD3(年末を諦める)  747.3  差  -80.7
[分割後の判断] 年末の損失 400 万円: 年末を先頭  671.4 / CD3(年末を諦める)  847.3  差 -175.9
[分割後の判断] 損益分岐の年末損失 215.3 万円(これ以上なら年末を先頭)

[期間が揺れる] 年間効果順      最適との差 平均   85.9 万円  P90  128.5  最適と一致  0.0%  年末に間に合う 0.0%
[期間が揺れる] 楽な順        最適との差 平均  205.7 万円  P90  276.0  最適と一致  2.3%  年末に間に合う 12.4%
[期間が揺れる] SAFe WSJF  最適との差 平均  143.4 万円  P90  292.0  最適と一致  0.3%  年末に間に合う 49.6%
[期間が揺れる] CD3        最適との差 平均   22.5 万円  P90   63.4  最適と一致 26.1%  年末に間に合う 0.0%
[期間が揺れる] CD3+締切     最適との差 平均   38.1 万円  P90   83.8  最適と一致 16.1%  年末に間に合う 95.1%
[期間が揺れる] 同時並行       最適との差 平均  495.4 万円  P90  550.9  最適と一致  0.0%

$ python3 -m unittest test_cod
Ran 12 tests in 0.6s
OK
  • 最頻値: 年間効果順 719.0、同時並行 1,079.0、最適 629.0。CD3 だけは年末を最後に回して 667.0
  • 期間がぶれると逆転: 年末先頭 740.2 vs CD3 724.6。年末は最頻 4 週でも平均 5.0 週で、その間ほかの 4 件(CoD 計 49 万円/週)を待たせる
  • 最頻値で締切に詰めると落ちる: 「カゴ落ち 1 段目 → 年末」(2+4=6 ≤ 7)は間に合う確率 38.7%。年末を先頭なら 95.0%
  • 分割が一番効く: 分割+CD3 648.0(P90 702.1)、分割+年末先頭 663.4(P90 752.9)。分割前の最良 724.6 より 61〜77 万円少ない

会議に出す着手順の書き直し

件名: 来四半期の着手順 — カゴ落ちを 2 つに割り、年末セールは「失う額」を 10/10 までに測ってから決める

結論
  1. カゴ落ちを 1 段目(固定文面・1 通、約 2 週)と 2 段目(出し分け・3 通)に割る
  2. 着手順は次のどちらか。決め手は「年末セールを出せなかったときに失う額 L」
     L ≥ 約 215 万円 → 年末セール → カゴ落ち 1 段目 → 配信停止 → BQ → カゴ落ち 2 段目 → 管理画面
     L < 約 215 万円 → カゴ落ち 1 段目 → 配信停止 → BQ → カゴ落ち 2 段目 → 管理画面(年末は既存の単段クーポンで代替)
  3. 同時着手はしない。1 件ずつ終わらせる(WIP 1。レビュー待ちの間だけ次に手を付けてよい)

根拠(試算。入力はすべて仮置き、10/10 に差し替え)
  - 年間効果順+同時着手: 遅延コスト 1,079 万円。最良の並べ方(457〜629 万円)より 450〜620 万円多い
  - 年末セールは 7 週目のフリーズを過ぎると 200 万円(仮置き)を一度に失う。今の案では間に合う確率 0%
  - 期間は見積もりより長くなりがち。年末を「ぎりぎり間に合う位置」に入れると 6 割の確率で落ちる → 入れるなら先頭

10/10 までに測るもの
  - L: 昨年の年末多段クーポンの増分利益(9/24 の効果報告と同じ、10% ホールドアウトとの差で出す)
  - 各案件の期間の幅: 過去 1 年の同規模案件の予実(見積もり÷実績)
決めた後に見直す条件
  - 年末セールの残作業が 5 週目の終わりで 2 週を超えている → 単段クーポンに切り替え、カゴ落ち 1 段目に移る
  - 新しい案件が来たら、その CoD と期間を同じ表に足して再計算(10 件までは全順列で確かめる)

迷った案と採らなかった理由

案期待値の遅延コスト(万円)判断前提が変わったら
年間効果順+同時着手(後輩案)最頻値で 1,079.0却下。同時着手だけで +450—
SAFe WSJF(シニア案)最適との差 平均 +143.4・P90 +292.0却下。点の比に意味が無く、年末 49.6%金額が全く置けない初期なら会話の道具には使える
CD3 だけ724.6(年末 0%)単体では採らない。固定日型を見落とす固定日型が無いなら最適(Smith の規則。テスト 200 例)
CD3+締切(最頻値で挿入)740.2(年末 95.1%)採らない。分割の方が良い—
採用: 分割+L で分岐648.0(諦める)/663.4(先頭・95.0%)L が分かるまでは年末を先頭で準備。L が 100〜400 のどこか分からないなら最悪の後悔は先頭 109.9・諦める 175.9L < 215 万円と分かれば分割+CD3 へ
全順列で毎回最適事後の最適計画には使えない。期間は着手前に分からない予実が ±10% 以内なら最頻値の最適で十分
年末を縮小版(約 1.5 週)に未計算有望。縮小版で残る価値の割合が未測定縮小版で L の 6 割以上取れるなら最有力

判断ルールと止め方

  • 並べ替えの議論は「1 週遅れると失う額(万円/週)」と「何週かかるか(範囲)」の 2 列だけで行う。相対点は使わない
  • 固定日型は CD3 の表から外し、「先頭に置いたときに締切に間に合う確率」を別に出す。7 割を切るなら範囲を削るか諦める
  • 入力を置き換えたら python3 cod.py を再実行(約 14 秒)し、結論が入れ替わる値だけを会議に出す
  • この試算をやめる条件: 案件が 10 件を超える(全順列 362 万通り)、または CoD が置けない案件が半数を超える(その場合は「固定日型か・線形か・いつ価値が消えるか」の 3 区分で並べる)

ポイント解説

  1. 並べ替えで変わるのは「いつ得るか」: 総額でなく 1 週あたりの遅延コストで比べる。年間効果順は 90 万円、同時着手は 450 万円を、何も作らずに失う
  2. CD3 には理論の裏付けがある: 「重み × 完成時刻」の和を最小にするのは、重み ÷ 処理時間の大きい順(Smith 1956、WSPT)。ただし締切で価値が消える案件が入ると保証は無い
  3. 期間は右に長い: 年末先頭は最頻値では最適、期待値では 15.6 万円負ける。締切に「ぎりぎり入る」計画は 6 割で落ちる
  4. 一番効くのは割り方: カゴ落ちの価値の 2/3 を 2 週で出すだけで −172 万円(最頻値)。並べ替えの差(6〜90 万円)より大きい
  5. 結論を決める仮定を 1 つに絞って測る: 年末の損失 L は損益分岐 215 万円のすぐ近く。議論すべきはこの数字だけで、測り方(ホールドアウト)も既にある

実務への応用

  • 優先度会議の資料は「案件・CoD(万円/週)・期間(最小・最頻・最大)・締切と失う額」の 4 列に固定する
  • MOps の年間カレンダー(年末・新春・GW・夏・ブラックフライデー)は固定日型の宝庫。毎年同じフリーズに詰まるなら、期間の予実を貯めて幅を狭める
  • CoD の根拠には 9/24 の問題で使ったホールドアウトの増分利益をそのまま使える
  • Jira・Linear の優先度欄の代わりに CoD と期間の 2 欄を足すだけで、この試算を毎週回せる

扱わなかった範囲(止め方)

  • 入力はすべて仮定。CoD の推定方法そのもの(増分利益・リスクの期待損失)は扱っていない
  • チームは 1 列に並ぶ前提。2 つの流れに分ける(並列機械)、人ごとの得意不得意、案件間の依存関係は扱っていない
  • CoD は線形と固定日型の 2 種だけ。urgency profile の「価値が逓減する/後から上がる」は扱っていない
  • 割引率・機会費用・文脈切替のコストは入れていない。同時並行の 1,079 万円は切替コストを含まないので、実際はもっと悪い
  • 外部資料(いずれも未参照・考え方の対応のみ): Reinertsen『The Principles of Product Development Flow』(2009)、Smith(1956, WSPT)、SAFe の WSJF、Black Swan Farming の urgency profiles
  • 世界の実物との対応: CoD・CD3・WSPT・固定日型・WIP 制限・期間の分布・損益分岐の 7 概念を扱った。urgency profile は 4 種中 2 種、並列機械・依存関係は 0

次のステップ(発展問題): チームを 2 つの流れ(2 名 × 2)に分けられるとき、5 件をどう割り振るか。並列機械では CD3 の最適性が崩れる。全探索と CD3 貪欲法の差を測り、「流れを分ける」こと自体の価値を万円で出す。

今日のまとめ

並べ替えは「いくら得るか」ではなく「1 週遅れると失う額 ÷ 何週かかるか」で決める。締切で価値が消える案件は別に扱い、期間は範囲で見る。一番効くのは割ることで、結論を決める仮定(年末の損失 L)だけを測る。

自己評価(あとで記入)