Temporal Match Cellar — durative actions, CSP scheduling, and Gantt visualization#
This Tier 3 tutorial translates the classic match cellar temporal planning problem from unified-planning/03 into LangGOAP. An agent must mend a set of fuses in a dark cellar, and mending requires light from a match that burns for a fixed time — the textbook motivation for durative actions.
The original problem hinges on over-all temporal overlap: the match must stay lit for the full duration of the mend. LangGOAP’s CSP scheduler expresses precedence via end-before-start dependency edges and cannot model interval containment directly, so the domain is recast into an equivalent shape the scheduler can honor:
Lighting a match (6 s) leaves behind a
light_N_readyflag — the light has been struck and the work window is open.Mending a fuse (5 s) requires the matching
light_N_readyflag and the previous fuse to be mended, which encodes the single-hand mutex as a precondition chain.
The payoff is the parallel-then-sequential staircase: three independent light_match actions run in parallel at t=0, then three mend_fuse actions form a strict sequential chain. Every assertion in the companion integration test pins the exact schedule CP-SAT returns — none of the numbers in this notebook are approximate.
What this notebook spotlights:
Durative actions via
ActionSpec.duration— every action carries adatetime.timedeltaduration that CP-SAT’s temporal scheduler turns into anIntervalVardecision variable.Parallelization from the dependency graph — independent lights overlap at
t=0..6, saving 12 wall-clock seconds versus serial execution.Mutex via precondition chains — the hand mutex is expressed in action preconditions, not in resource totals; the scheduler honors it automatically because those preconditions become dependency-graph edges.
Gantt visualization —
plan.draw_gantt_png()renders the schedule fromCSPMetadata.scheduleso the staircase is immediately visible.Fluent
ConstraintBuildertwin — the same goal is built two different ways and the resulting plans are structurally identical.
1. The match cellar domain#
tutorial_examples.temporal_match_cellar exposes six actions and two goal factories. The action catalog is deliberately tiny so every number in the notebook is inspectable by eye.
from datetime import timedelta
from tutorial_examples.temporal_match_cellar import (
LIGHT_DURATION_SECONDS,
MEND_DURATION_SECONDS,
NUM_PAIRS,
match_cellar_actions,
match_cellar_goal,
match_cellar_goal_fluent,
match_cellar_start,
)
actions = match_cellar_actions()
start = match_cellar_start()
print(f"NUM_PAIRS = {NUM_PAIRS}")
print(f"light duration = {LIGHT_DURATION_SECONDS}s, mend duration = {MEND_DURATION_SECONDS}s")
print(f"total actions = {len(actions)}")
print()
print("action duration preconditions effects")
print("-" * 90)
for a in actions:
dur = f"{int(a.duration.total_seconds())}s" if a.duration else "-"
pre = ", ".join(f"{k}={v}" for k, v in a.preconditions.items()) or "(none)"
eff = ", ".join(f"{k}={v}" for k, v in a.effects.items())
print(f"{a.name:<20}{dur:<10}{pre:<40}{eff}")
NUM_PAIRS = 3
light duration = 6.0s, mend duration = 5.0s
total actions = 6
action duration preconditions effects
------------------------------------------------------------------------------------------
light_match_1 6s (none) light_1_ready=True, match_1_used=True
light_match_2 6s (none) light_2_ready=True, match_2_used=True
light_match_3 6s (none) light_3_ready=True, match_3_used=True
mend_fuse_1 5s light_1_ready=True fuse_1_mended=True
mend_fuse_2 5s light_2_ready=True, fuse_1_mended=True fuse_2_mended=True
mend_fuse_3 5s light_3_ready=True, fuse_2_mended=True fuse_3_mended=True
GOAP Execution Graph#
The planner discovers a plan, the executor runs each action, and the observer checks progress — replanning automatically if something fails.
from IPython.display import Image, display
from langgoap import GoapGraph
graph = GoapGraph(actions=actions)
display(Image(graph.compile().get_graph().draw_mermaid_png()))
Note the two key design choices:
light_match_*actions have no preconditions — they are independent work that the scheduler can overlap.mend_fuse_Nrequiresfuse_(N-1)_mended— this is the hand mutex. It becomes a dependency-graph edge that the scheduler turns into a strictend-before-startprecedence relation, which serializes the mends automatically.
2. Planning with the A* → CSP pipeline#
The goal mends every fuse and carries a duration_seconds → MINIMIZE objective. The objective’s real job is to route the goal through CSP — A* alone has no notion of wall-clock time and would happily return a plan with no schedule. Once CSP runs, the scheduler computes the exact start/end of every action and populates plan.metadata.csp.
from langgoap.planner.pipeline import plan as pipeline_plan
plan = pipeline_plan(start, match_cellar_goal(), actions)
assert plan is not None
print(f"plan actions : {plan.action_names}")
print(f"total cost : {plan.total_cost}")
print(f"score : {plan.score}")
Six actions, total cost 6.0 (cost 1.0 per action), and a HardSoftScore with hard=0.0 — the plan is feasible and the soft component encodes the MINIMIZE objective (more on that in §4).
3. The schedule — parallel lights, sequential mends#
Every action has a duration, so validate_plan() hands the primary plan to the CP-SAT scheduler. The scheduler builds an IntervalVar per action, seeds precedence constraints from the dependency graph, and minimizes makespan. The result is the parallel-then-sequential staircase:
csp = plan.metadata.csp
assert csp is not None
print(f"status : {csp.status.value}")
print(f"makespan : {csp.makespan}")
print()
print(f"{'action':<18}{'start':>10}{'end':>10}")
print("-" * 38)
for entry in sorted(csp.schedule, key=lambda s: (s.start, s.action_name)):
print(f"{entry.action_name:<18}{str(entry.start):>10}{str(entry.end):>10}")
status : feasible
makespan : 0:00:21
action start end
--------------------------------------
light_match_1 0:00:00 0:00:06
light_match_2 0:00:00 0:00:06
light_match_3 0:00:00 0:00:06
mend_fuse_1 0:00:06 0:00:11
mend_fuse_2 0:00:11 0:00:16
mend_fuse_3 0:00:16 0:00:21
Reading the schedule from top to bottom:
light_match_1,light_match_2,light_match_3— all three run fromt=0tot=6. They have no preconditions and therefore no incoming dependency edges, so the scheduler is free to overlap them.mend_fuse_1— starts att=6(waits forlight_1_ready) and ends att=11.mend_fuse_2— starts att=11(waits forfuse_1_mended;light_2_readyhas been available sincet=6but it is the hand mutex that pins the start time) and ends att=16.mend_fuse_3— starts att=16(waits forfuse_2_mended) and ends att=21.
Makespan is exactly 21 seconds. Serial execution would take 3*6 + 3*5 = 33 seconds — parallel lights save 12 seconds of wall-clock time, and the assertion block below pins that number.
serial_seconds = NUM_PAIRS * LIGHT_DURATION_SECONDS + NUM_PAIRS * MEND_DURATION_SECONDS
serial = timedelta(seconds=serial_seconds)
saved = serial - csp.makespan
print(f"serial execution : {serial}")
print(f"CSP schedule : {csp.makespan}")
print(f"saved : {saved}")
assert saved == timedelta(seconds=12)
serial execution : 0:00:33
CSP schedule : 0:00:21
saved : 0:00:12
Gantt chart#
plan.draw_gantt_png() renders the schedule from CSPMetadata.schedule as a PNG image. The three overlapping light_match_* bars at the top and the staircase of mend_fuse_* bars below are the whole story of this notebook in one picture.
display(Image(plan.draw_gantt_png()))
Plan.draw_gantt_png() returns the same PNG bytes — use it directly when you already have a Plan in hand.
gantt_bytes = plan.draw_gantt_png()
assert isinstance(gantt_bytes, bytes) and len(gantt_bytes) > 0
4. Score decomposition#
The goal has no constraints and a single duration_seconds -> MINIMIZE objective, so the HardSoftScore has a clean, inspectable decomposition:
Contribution |
Source |
Value |
|---|---|---|
|
(no constraints) |
|
|
|
|
The sign convention follows the penalize/reward convention: hard is <= 0 (zero means feasible), and the soft component flips sign for MINIMIZE objectives so the optimizer can maximize total score. The aggregated duration_seconds across all six actions is 3*6 + 3*5 = 33 — the makespan of a hypothetical serial execution — which is why the soft score reads -33.0 even though the real schedule is only 21 seconds long. CSP aggregates the resource totals; the scheduler computes the wall-clock makespan separately on metadata.csp.makespan.
from langgoap.score import HardSoftScore
assert isinstance(plan.score, HardSoftScore)
print(f"hard = {plan.score.hard}")
print(f"soft = {plan.score.soft}")
print(f"feasible = {plan.score.is_feasible()}")
print()
print(f"duration_seconds total = {csp.objective_values['duration_seconds']}")
hard = 0.0
soft = -33.0
feasible = True
duration_seconds total = 33.0
5. Fluent ConstraintBuilder twin#
The companion module ships two goal factories: match_cellar_goal() builds the GoalSpec by hand, and match_cellar_goal_fluent() builds it via the ConstraintBuilder fluent API. Both are expected to produce structurally identical GoalSpec instances and — when passed to the same pipeline — identical plans, scores, and schedules.
The builder is a constraint-provider analogue: a typed way to build the same constraints and objectives the explicit GoalSpec fields accept. Hand-rolled and fluent forms produce byte-identical goals and byte-identical plans.
from langgoap.constraints import ConstraintBuilder
from langgoap import GoalSpec
from langgoap.goals import ObjectiveDirection
hand_goal = match_cellar_goal()
fluent_goal = match_cellar_goal_fluent()
assert dict(hand_goal.conditions) == dict(fluent_goal.conditions)
assert hand_goal.constraints == fluent_goal.constraints
assert dict(hand_goal.objectives or {}) == dict(fluent_goal.objectives or {})
print("hand-rolled and fluent goals are structurally identical")
print()
print("fluent chain:")
print(" ConstraintBuilder.for_plan()")
print(' .sum_resource("duration_seconds")')
print(" .minimize()")
print(' .as_objective("duration_seconds")')
hand-rolled and fluent goals are structurally identical
fluent chain:
ConstraintBuilder.for_plan()
.sum_resource("duration_seconds")
.minimize()
.as_objective("duration_seconds")
fluent_plan = pipeline_plan(
start, fluent_goal, actions
)
assert fluent_plan is not None
assert set(plan.action_names) == set(fluent_plan.action_names)
assert plan.score == fluent_plan.score
assert (
plan.metadata.csp is not None
and fluent_plan.metadata.csp is not None
and plan.metadata.csp.makespan == fluent_plan.metadata.csp.makespan
)
print(f"both plans: makespan = {fluent_plan.metadata.csp.makespan}")
6. Scaling the instance — two pairs#
The factory takes a num_pairs argument. Reducing it from three to two removes one match-and-fuse pair entirely, and the makespan shrinks to 6 + 2*5 = 16 seconds. This is an easy regression guard: if the scheduler were silently ignoring num_pairs, this number would be wrong.
small_actions = match_cellar_actions(num_pairs=2)
small_start = match_cellar_start(num_pairs=2)
small_goal = match_cellar_goal(num_pairs=2)
small_plan = pipeline_plan(
small_start, small_goal, small_actions
)
assert small_plan is not None
assert small_plan.metadata.csp is not None
print(f"actions : {small_plan.action_names}")
print(f"total cost : {small_plan.total_cost}")
print(f"makespan : {small_plan.metadata.csp.makespan}")
7. End-to-end execution via GoapGraph#
The pipeline produces the plan; GoapGraph.invoke() runs it through the full planner → executor → observer loop. Every light_N_ready, match_N_used, and fuse_N_mended flag in the final world state ends up True, and the execution history respects the mend-chain order.
from langgoap import GoapGraph
graph = GoapGraph(match_cellar_actions())
result = graph.invoke(goal=match_cellar_goal(), world_state=match_cellar_start())
assert result["status"] == "goal_achieved"
ws = result["world_state"]
for i in range(1, NUM_PAIRS + 1):
assert ws[f"fuse_{i}_mended"] is True
assert ws[f"light_{i}_ready"] is True
assert ws[f"match_{i}_used"] is True
sequence = [r.action_name for r in result["execution_history"]]
print(f"status : {result['status']}")
print(f"execution : {sequence}")
idx1 = sequence.index("mend_fuse_1")
idx2 = sequence.index("mend_fuse_2")
idx3 = sequence.index("mend_fuse_3")
assert idx1 < idx2 < idx3
print()
print("mend_fuse_1 -> mend_fuse_2 -> mend_fuse_3 order preserved")
status : goal_achieved
execution : ['light_match_1', 'mend_fuse_1', 'light_match_2', 'mend_fuse_2', 'light_match_3', 'mend_fuse_3']
mend_fuse_1 -> mend_fuse_2 -> mend_fuse_3 order preserved
Summary#
Durative actions are expressed as
ActionSpec.duration; the CSP scheduler turns them into CP-SATIntervalVardecision variables.Parallelization comes from the dependency graph for free: independent actions with no shared effects are scheduled to overlap automatically. Here that means three lights at
t=0..6.Mutex is best expressed as a precondition chain, not a resource total. LangGOAP does not support over-all temporal overlap, but that restriction usually corresponds to a world-state flag you can introduce directly — the
fuse_(N-1)_mendedchain here is exactly that shape.plan.draw_gantt_png()renders any scheduled plan as a Gantt chart PNG directly fromCSPMetadata.schedule. Use it whenever you want to show the parallel-then-sequential structure of a plan at a glance.The fluent
ConstraintBuilderis a constraint-provider analogue — a typed way to build the same constraints and objectives the explicitGoalSpecfields accept. Hand-rolled and fluent forms produce byte-identical goals and byte-identical plans.
The integration test in tests/integration/test_temporal_match_cellar.py pins every number in this notebook.