Beyond the Loop: 複雑な依存関係下における「制約条件の段階的適用」アルゴリズム
プロジェクト・マネジメント・システムの自動化において、多くのエンジニアが最初に突き当たる壁がある。それは、タスクの依存関係(Predecessors)が複雑に絡み合った大規模プロジェクトにおいて、制約条件(Constraint)を流し込んだ瞬間に、スケジュールの整合性が崩壊するか、あるいは計算エンジンが計算不能(Scheduling Engine Conflict)に陥る現象だ。
単純な `For Each` ループで `Task.ConstraintType` を書き換えるような実装は、小規模なプロジェクトでは通用する。しかし、数千のタスクを持ち、かつ「Must Start On」や「As Late As Possible (ALAP)」といった強力な制約が入り乱れるエンタープライズ級のモデルでは、そのアプローチは致命的なエラーを招く。
本稿では、MS Projectの計算エンジンを「制御」するという観点から、依存関係のトポロジカルな順序に基づいた「制約条件の段階的適用(Staged Constraint Application)」アルゴリズムについて、その極限の知見を共有する。
1. なぜ「一括適用」は失敗するのか
MS Projectの計算エンジンは、プロパティが変更されるたびに、ネットワーク全体のクリティカルパスと日付を再計算しようとする。
もし、タスクA(制約なし)→ タスクB(制約なし)という関係があるとき、タスクBに「Must Start On」を適用し、直後にタスクAに「Must Finish On」を適用するとどうなるか。エンジンは、Bの制約を満たそうとしてAの終了日を動かそうとし、それがまた他のタスクの制約と衝突する――この「再計算の連鎖(Recalculation Thrashing)」が、論理的な矛盾を引き起こし、最終的にユーザーが意図しないスケジュールへと収束、あるいは計算エラーを吐き出す。
真のアーキテクトは、エンジンに「一度に多くのことをさせない」ことを原則とする。
2. 解決策:トポロジカル・レイヤー・アプローチ
この問題を回避するための最適解は、プロジェクトのタスクを「依存関係の深さ(Dependency Depth)」に基づいてレイヤー化し、浅いレイヤー(先行タスク)から順に制約を適用していくことである。
アルゴリズムのフロー
1. 計算モードの凍結: `Application.Calculation = pjManual` により、プロパティ変更ごとの自動再計算を停止する。
2. 依存グラフの解析: 全タスクの依存関係を走査し、各タスクの「階層レベル(Depth)」を算出する。
3. 階層別グループ化: 同一レベルのタスクをバッチとしてまとめる。
4. 段階的適用:
- Level 0(先行タスクのみ)の制約を適用。
- `Application.Calculate` を実行し、エンジンに現在の状態を「確定」させる。
- Level 1 の制約を適用……(以下、最深部まで反復)。
5. 最終計算と解放: 全ての制約適用後、計算モードを `pjAutomatic` に戻し、メモリ上のオブジェクトを明示的に解放する。
3. 実装:極限のVBAアーキテクチャ
以下に、シニアエンジニアが実装すべき堅牢なモジュール構成を示す。ここでは、パフォーマンスを最大化するために、Windows APIを用いたメモリ管理の概念と、再帰的な階層計算を組み合わせている。
Option Explicit
' Windows API: プロセスのワーキングセットを最小化し、大規模処理後のメモリを整理する(レガシー環境の安定化)
If VBA7 Then
Declare PtrSafe Function SetProcessWorkingSetSize Lib "kernel32" (ByVal hProcess As LongPtr, ByVal dwMinimumWorkingSetSize As LongPtr, ByVal dwMaximumWorkingSetSize As LongPtr) As Long
Declare PtrSafe Function GetCurrentProcess Lib "kernel32" () As LongPtr
Else
Declare Function SetProcessWorkingSetSize Lib "kernel32" (ByVal hProcess As Long, ByVal dwMinimumWorkingSetSize As Long, ByVal dwMaximumWorkingSetSize As Long) As Long
Declare Function GetCurrentProcess Lib "kernel32" () As Long
End If
' 制約適用のレイヤー定義
Private Enum ConstraintLayer
LayerRoot = 0
LayerStandard = 1
LayerDeep = 2
End Enum
''' <summary>
''' プロジェクトのスケジューリングエンジンを制御し、制約条件を安全に適用するメインルーチン
''' </summary>
Public Sub MasterConstraintEngine(ByRef proj As MSProject.Project)
Dim t As MSProject.Task
Dim taskDepths As Object
Dim maxDepth As Integer
Dim currentDepth As Integer
Dim i As Integer
On Error GoTo ErrorHandler
' 1. エンジンの計算を停止(Thrashingの防止)
Application.Calculation = pjManual
' 2. 依存関係の解析(Dictionaryオブジェクトを使用してタスクの深さを保持)
' ※大規模環境では、Dictionaryの初期サイズ確保がパフォーマンスに寄与する
Set taskDepths = CreateObject("Scripting.Dictionary")
maxDepth = CalculateAllDepths(proj, taskDepths)
' 3. 段階的適用プロセス
' レイヤーごとに制約を適用し、その都度エンジンの整合性を「確定」させる
For currentDepth = 0 To maxDepth
Debug.Print "Applying constraints for Layer: " & currentDepth
For Each t In proj.Tasks
' 自身の深さが現在のループ対象であるタスクのみを処理
If Not t Is Nothing Then
If taskDepths(t.ID) = currentDepth Then
ApplySpecificConstraint t
End If
End If
Next t
' レイヤー完了ごとに計算を実行し、エンジンの内部状態を同期させる
' これにより、次のレイヤーが現在のレイヤーの結果に基づいた正しい計算を行える
Application.Calculate
Next currentDepth
' 4. 後処理
Application.Calculation = pjAutomatic
Debug.Print "Scheduling engine synchronization complete."
CleanUp:
' オブジェクトの明示的解放(メモリリーク防止の鉄則)
Set taskDepths = Nothing
Exit Sub
ErrorHandler:
MsgBox "Critical Error in Scheduling Engine: " & Err.Description, vbCritical
Resume CleanUp
End Sub
''' <summary>
''' 各タスクの依存関係における「深さ」を再帰的に計算する
''' </summary>
Private Function CalculateAllDepths(ByRef proj As MSProject.Project, ByRef dict As Object) As Integer
Dim t As MSProject.Task
Dim maxD As Integer: maxD = 0
' 全タスクを走査して、各タスクの依存深度を算出
' 本来はトポロジカルソートを実装すべきだが、ここでは実用性を重視した深度計算を行う
For Each t In proj.Tasks
If Not t Is Nothing Then
Dim d As Integer
d = GetTaskDepthRecursive(t, dict)
If d > maxD Then maxD = d
End If
Next t
CalculateAllDepths = maxD
End Function
''' <summary>
''' 再帰的にタスクの深度を算出する(メモ化による計算量削減)
''' </summary>
Private Function GetTaskDepthRecursive(ByRef t As MSProject.Task, ByRef dict As Object) As Integer
' すでに計算済みであれば、その値を返す(メモ化)
If dict.Exists(t.ID) Then
GetTaskDepthRecursive = dict(t.ID)
Exit Function
End If
Dim maxPredDepth As Integer: maxPredDepth = -1
Dim predID As Variant
' 先行タスク(Predecessors)を走査
' MS Projectの依存関係文字列をパースするのではなく、Predecessorプロパティを利用
If t.Predecessors.Count > 0 Then
Dim predTask As MSProject.Task
For Each predTask In t.Predecessors
Dim d As Integer
d = GetTaskDepthRecursive(predTask, dict)
If d > maxPredDepth Then maxPredDepth = d
Next predTask
End If
Dim currentDepth As Integer
currentDepth = maxPredDepth + 1
dict.Add t.ID, currentDepth
GetTaskDepthRecursive = currentDepth
End Function
''' <summary>
''' 個別のタスクに対する制約適用ロジック(ビジネスロジックの分離)
''' </summary>
Private Sub ApplySpecificConstraint(ByRef t As MSProject.Task)
' ここに、特定の条件に基づいた制約ロジックを記述する
' 例: 特定のフラグが立っているタスクを ALAP に設定するなど
' 実際の運用では、ここを外部の定義テーブルから読み込むように設計する
If t.Name Like "MILESTONE*" Then
t.ConstraintType = pjMustStartOn
ElseIf t.Priority > 50 Then
t.ConstraintType = pjAsLateAsPossible
End If
End Sub
4. アーキテクトの視点:メモリとパフォーマンスの極限
大規模なシステム間連携(例:ERPからMS Projectへの一括データ投入)を行う場合、VBAのコードだけでは不十分だ。
メモリ管理の重要性
上記コード内の `Set taskDepths = Nothing` は、単なる作法ではない。`Scripting.Dictionary` のようなCOMオブジェクトは、参照カウントが適切に処理されない限り、MS Projectのプロセス内に残り続け、バックグラウンドでのバッチ処理時にメモリ不足(Out of Memory)を引き起こす。
また、大量のタスクを処理した後は、Windows APIの `SetProcessWorkingSetSize` を呼び出すことで、OSに対してプロセスのメモリ割り当てを整理させ、システム全体の安定性を確保する手法が、エンタープライズ環境では有効である。
計算量の最適化
`GetTaskDepthRecursive` において、メモ化(Memoization)を用いている点に注目してほしい。一度計算したタスクの深度を `Dictionary` に保持することで、計算量は $O(N^2)$ から $O(N)$ に劇的に改善される。これは、タスク数が1万件を超えた際に、処理時間が「数分」から「数秒」へと変わる決定的な差となる。
結論
スケジュール計算エンジンの制御とは、単にプロパティを書き換えることではない。それは、「エンジンの計算ロジックが、自身の変更によって矛盾を起こさないよう、データの流れを制御すること」である。
トポロジカルな順序に基づいた段階的適用。この一見遠回りに見えるアプローチこそが、最も高速で、最も堅牢な自動化を実現するための唯一の道である。
