Back to app index

Cliff-Refuge (Shed-on-a-Cliff) Mini-Generator

A stdlib-only Julia script that models a cliffside refuge Petri net, enumerates all reachable markings, and emits a D3/Tailwind HTML reachability graph.

What this script does

Cliff-specific constraints

Encodes access logistics (fixed lines, anchors), permits, weather windows, and a consumable heli slot.

Reachability graph

BFS enumerates every reachable marking and records labeled transition firings.

Shareable artifacts

Outputs a TSV of markings plus a standalone HTML visualization.

Run locally

julia cliff_refuge_petri.jl
# Outputs:
#   /tmp/cliff_refuge_markings.tsv
#   /tmp/cliff_refuge_reachability.html
Show the full Julia source
###############################
#  Cliff‑Refuge (Shed‑on‑a‑Cliff) mini‑generator
#  Reachability (markings) + HTML viz
#  Julia (stdlib‑only)
###############################
#
# This script is deliberately dependency‑free (stdlib only):
# we want a “tiny sharable artifact” that runs anywhere.
#
# What it does
# ------------
#  1) Defines a *Petri net* for a mountain refuge halfway up a cliff
#     (places = physical preconditions/resources, transitions = tasks).
#  2) Auto‑enumerates all reachable markings from the initial marking.
#  3) Builds the reachability graph (nodes = markings, edges = firings).
#  4) Emits:
#       - /tmp/cliff_refuge_markings.tsv
#       - /tmp/cliff_refuge_reachability.html
#
# Run
# ---
#   julia cliff_refuge_petri.jl
# Then open the HTML in a browser.
#
# Notes on modeling style
# -----------------------
# We stick to the “collective token” philosophy: markings are multisets of places.
# This is the Petri‑net/CMC stance (Baez–Master), which is exactly what you want
# if you interpret markings as “state snapshots” and transitions as “task firings”.

using Random
using Printf

###############################
# 0. Petri net core
###############################

"""A Petri net over named places and named transitions.

pre[t][p]  = tokens required from place p to fire transition t
post[t][p] = tokens produced into place p after firing transition t

All token counts are nonnegative integers.
"""
struct PetriNet
    places::Vector{String}
    transitions::Vector{String}
    pre::Matrix{Int}   # |T| × |P|
    post::Matrix{Int}  # |T| × |P|
    duration::Vector{Float64}  # per‑transition estimate (days)
end

"""Return true iff transition t is enabled at marking m."""
function enabled(net::PetriNet, m::Vector{Int}, t::Int)
    @inbounds for p in eachindex(m)
        if m[p] < net.pre[t,p]
            return false
        end
    end
    return true
end

"""Fire transition t at marking m, producing a new marking."""
function fire(net::PetriNet, m::Vector{Int}, t::Int)
    m2 = copy(m)
    @inbounds for p in eachindex(m)
        m2[p] = m2[p] - net.pre[t,p] + net.post[t,p]
        if m2[p] < 0
            error("Bug: negative token count after firing $(net.transitions[t]) at place $(net.places[p])")
        end
    end
    return m2
end

"""Stable string key for a marking (for hashing / indexing)."""
marking_key(m::Vector{Int}) = join(m, ",")

###############################
# 1. Cliff‑refuge toy project net
###############################

"""Build a Petri net for a small refuge halfway up a cliff.

Interpretation heuristics:
- Places are *facts/resources* that must hold.
- Tokens are coarse "available" flags (mostly 0/1) except where noted.

We include the cliff‑specific constraints that make this richer than a garden shed:
- Access logistics: approach path, fixed lines, anchor points
- Lift window: helicopter / winch availability as a token
- Safety gating: method statement + permit + buddy check
- Weather window: treated as a tokenized resource (a single usable window)

This is still a toy model: it’s meant to stress concurrency vs gating, not realism.
"""
function build_cliff_refuge_net()
    # Places
    P = String[
        # logistical / safety
        "BaseCampReady",          # staging area established
        "ApproachRouteOpen",      # approach path usable
        "FixedLinesRigged",       # ropes fixed / protection installed
        "AnchorsCertified",       # anchor points tested/certified
        "PermitApproved",         # local permission / safety plan approved
        "WeatherWindow",          # a good weather window exists (token consumed by some tasks)
        "HeliSlot",               # helicopter/winch slot available (token consumed)
        "CrewOnLedge",            # crew positioned at ledge site

        # build state
        "RockPadPrepared",        # leveled/secured pad or terrace prepared
        "FoundationSet",          # micro‑foundation / anchors drilled & cured
        "FrameErected",           # structure framed
        "RoofSealed",             # roof on & sealed
        "WallCladdingOn",         # walls/cladding on
        "StoveFlueFitted",         # stove/flue installed
        "DoorInstalled",          # door installed
        "InteriorBunkBuilt",       # bunks fitted
        "Painted",                # protective coating applied
        "RefugeComplete"          # end state
    ]

    # Transitions (tasks)
    T = String[
        "EstablishBaseCamp",
        "ApprovePermitAndMethod",
        "RigFixedLines",
        "TestAndCertifyAnchors",
        "MoveCrewToLedge",
        "PrepareRockPad",
        "DrillAndSetFoundation",
        "WinchMaterialsToLedge",
        "ErectFrame",
        "SealRoof",
        "AttachWallCladding",
        "FitDoor",
        "FitStoveAndFlue",
        "BuildBunks",
        "PaintProtectiveCoat",
        "FinalSafetyAndSignoff"
    ]

    np = length(P)
    nt = length(T)
    pre  = zeros(Int, nt, np)
    post = zeros(Int, nt, np)

    # Helper to index places / transitions by name
    pidx = Dict(P[i] => i for i in eachindex(P))
    tidx = Dict(T[i] => i for i in eachindex(T))

    # Convenience setters
    function req(tname::String, pname::String, k::Int=1)
        pre[tidx[tname], pidx[pname]] += k
    end
    function prod(tname::String, pname::String, k::Int=1)
        post[tidx[tname], pidx[pname]] += k
    end

    # --- Define task semantics ---

    # Establish basecamp: requires open approach route + weather; yields basecamp
    req("EstablishBaseCamp", "ApproachRouteOpen")
    req("EstablishBaseCamp", "WeatherWindow")
    prod("EstablishBaseCamp", "BaseCampReady")

    # Permit/method statement approval: yields permit
    prod("ApprovePermitAndMethod", "PermitApproved")

    # Rig fixed lines: requires basecamp, permit, weather; yields fixed lines
    req("RigFixedLines", "BaseCampReady")
    req("RigFixedLines", "PermitApproved")
    req("RigFixedLines", "WeatherWindow")
    prod("RigFixedLines", "FixedLinesRigged")

    # Certify anchors: requires fixed lines; yields anchors certified
    req("TestAndCertifyAnchors", "FixedLinesRigged")
    prod("TestAndCertifyAnchors", "AnchorsCertified")

    # Move crew to ledge: requires fixed lines + anchors certified
    req("MoveCrewToLedge", "FixedLinesRigged")
    req("MoveCrewToLedge", "AnchorsCertified")
    prod("MoveCrewToLedge", "CrewOnLedge")

    # Prepare rock pad: requires crew on ledge + weather
    req("PrepareRockPad", "CrewOnLedge")
    req("PrepareRockPad", "WeatherWindow")
    prod("PrepareRockPad", "RockPadPrepared")

    # Drill & set foundation: requires rock pad prepared + anchors certified + weather
    req("DrillAndSetFoundation", "RockPadPrepared")
    req("DrillAndSetFoundation", "AnchorsCertified")
    req("DrillAndSetFoundation", "WeatherWindow")
    prod("DrillAndSetFoundation", "FoundationSet")

    # Winch materials: requires basecamp + heli slot + crew on ledge
    # We model the heli slot as consumable (one lift); basecamp and crew persist.
    req("WinchMaterialsToLedge", "BaseCampReady")
    req("WinchMaterialsToLedge", "CrewOnLedge")
    req("WinchMaterialsToLedge", "HeliSlot")
    prod("WinchMaterialsToLedge", "HeliSlot", 0)  # consumed by not reproducing
    # (No explicit material inventory place in this toy model; the effect is enabling later tasks
    #  via the fact that we can schedule this transition before frame/roof/etc in the reachability graph.)

    # Erect frame: requires foundation set + crew on ledge
    req("ErectFrame", "FoundationSet")
    req("ErectFrame", "CrewOnLedge")
    prod("ErectFrame", "FrameErected")

    # Seal roof: requires frame erected + weather
    req("SealRoof", "FrameErected")
    req("SealRoof", "WeatherWindow")
    prod("SealRoof", "RoofSealed")

    # Attach wall cladding: requires frame erected
    req("AttachWallCladding", "FrameErected")
    prod("AttachWallCladding", "WallCladdingOn")

    # Fit door: requires wall cladding
    req("FitDoor", "WallCladdingOn")
    prod("FitDoor", "DoorInstalled")

    # Fit stove/flue: requires roof sealed + wall cladding
    req("FitStoveAndFlue", "RoofSealed")
    req("FitStoveAndFlue", "WallCladdingOn")
    prod("FitStoveAndFlue", "StoveFlueFitted")

    # Build bunks: requires wall cladding
    req("BuildBunks", "WallCladdingOn")
    prod("BuildBunks", "InteriorBunkBuilt")

    # Paint protective coat: requires roof sealed + door installed
    req("PaintProtectiveCoat", "RoofSealed")
    req("PaintProtectiveCoat", "DoorInstalled")
    prod("PaintProtectiveCoat", "Painted")

    # Final safety/signoff: requires painted + stove/flue + bunks
    req("FinalSafetyAndSignoff", "Painted")
    req("FinalSafetyAndSignoff", "StoveFlueFitted")
    req("FinalSafetyAndSignoff", "InteriorBunkBuilt")
    prod("FinalSafetyAndSignoff", "RefugeComplete")

    # --- Durations (days), deliberately rough ---
    dur = Dict(
        "EstablishBaseCamp" => 1.0,
        "ApprovePermitAndMethod" => 0.5,
        "RigFixedLines" => 1.0,
        "TestAndCertifyAnchors" => 0.5,
        "MoveCrewToLedge" => 0.5,
        "PrepareRockPad" => 1.0,
        "DrillAndSetFoundation" => 1.5,
        "WinchMaterialsToLedge" => 0.5,
        "ErectFrame" => 1.0,
        "SealRoof" => 0.5,
        "AttachWallCladding" => 1.0,
        "FitDoor" => 0.5,
        "FitStoveAndFlue" => 0.5,
        "BuildBunks" => 0.5,
        "PaintProtectiveCoat" => 0.5,
        "FinalSafetyAndSignoff" => 0.5
    )
    duration = [dur[t] for t in T]

    return PetriNet(P, T, pre, post, duration)
end

# Initial marking (tokens)
function initial_marking(net::PetriNet)
    m = zeros(Int, length(net.places))
    idx = Dict(net.places[i] => i for i in eachindex(net.places))
    # We start with: approach route open, a weather window, and one heli slot.
    # Everything else must be achieved.
    m[idx["ApproachRouteOpen"]] = 1
    m[idx["WeatherWindow"]] = 1
    m[idx["HeliSlot"]] = 1
    return m
end

###############################
# 2. Reachability enumeration
###############################

"""Enumerate all reachable markings and the labeled edges between them.

Returns:
  markings :: Vector{Vector{Int}}      # list of unique markings
  edges    :: Vector{NamedTuple}       # (src, dst, t)

We do a standard BFS over the state graph of markings.
"""
function enumerate_reachability(net::PetriNet, m0::Vector{Int}; max_states::Int=50_000)
    q = Vector{Int}()                # queue of node indices
    markings = Vector{Vector{Int}}() # index -> marking
    seen = Dict{String, Int}()       # key -> index
    edges = NamedTuple{(:src,:dst,:t),Tuple{Int,Int,Int}}[]

    function add_marking!(m::Vector{Int})
        k = marking_key(m)
        if haskey(seen, k)
            return seen[k]
        end
        push!(markings, m)
        idx = length(markings)
        seen[k] = idx
        push!(q, idx)
        return idx
    end

    add_marking!(copy(m0))

    head = 1
    while head <= length(q)
        if length(markings) > max_states
            error("State explosion: exceeded max_states=$(max_states). Consider tightening the model.")
        end
        src_idx = q[head]
        head += 1
        m = markings[src_idx]

        for t in eachindex(net.transitions)
            if enabled(net, m, t)
                m2 = fire(net, m, t)
                dst_idx = add_marking!(m2)
                push!(edges, (src=src_idx, dst=dst_idx, t=t))
            end
        end
    end

    return markings, edges
end

###############################
# 3. Pretty printing & TSV export
###############################

"""Human‑readable marking: only list places with nonzero tokens."""
function pretty_marking(net::PetriNet, m::Vector{Int})
    parts = String[]
    for (i, c) in enumerate(m)
        if c != 0
            if c == 1
                push!(parts, net.places[i])
            else
                push!(parts, "$(net.places[i])×$c")
            end
        end
    end
    isempty(parts) ? "∅" : join(parts, "; ")
end

function write_tsv(net::PetriNet, markings, edges, path::String)
    open(path, "w") do io
        println(io, "state_id\tmarking\tis_goal\tout_degree")
        outdeg = zeros(Int, length(markings))
        for e in edges
            outdeg[e.src] += 1
        end
        goal_idx = findfirst(i -> net.places[i] == "RefugeComplete", eachindex(net.places))
        for (i, m) in enumerate(markings)
            is_goal = (m[goal_idx] > 0) ? 1 : 0
            println(io, "$(i)\t$(pretty_marking(net, m))\t$(is_goal)\t$(outdeg[i])")
        end
    end
end

###############################
# 4. HTML + D3 visualisation
###############################

"""A self‑contained HTML page with:

- Force‑directed reachability graph
- Click node => show marking
- Click edge => show which transition fired
- Buttons:
    * How to read this
    * Model notes (cliff‑specific constraints)
    * Invariants & sanity checks
    * Possible extension (Willerton enriched scheduling / adjoints)

We include Tailwind via CDN (allowed) and D3 via CDNJS.
"""
function write_html(net::PetriNet, markings, edges, html_path::String)
    # Build JSON strings by hand to avoid extra deps
    # Nodes: {id, label, is_goal}
    goal_place = findfirst(==("RefugeComplete"), net.places)

    node_lines = String[]
    for (i, m) in enumerate(markings)
        is_goal = (m[goal_place] > 0)
        label = replace(pretty_marking(net, m), '"' => "\\\"")
        push!(node_lines, "{\\\"id\\\":$i,\\\"label\\\":\\\"$label\\\",\\\"is_goal\\\":$(is_goal ? \"true\" : \"false\")}" )
    end

    edge_lines = String[]
    for e in edges
        tname = replace(net.transitions[e.t], '"' => "\\\"")
        dur = @sprintf("%.2f", net.duration[e.t])
        push!(edge_lines, "{\\\"source\\\":$(e.src),\\\"target\\\":$(e.dst),\\\"t\\\":\\\"$tname\\\",\\\"dur\\\":$dur}" )
    end

    nodes_json = "[" * join(node_lines, ",") * "]"
    edges_json = "[" * join(edge_lines, ",") * "]"

    # Small helper: crude token conservation/invariant hints (toy only)
    # Here, we note that many places are “facts” (0/1). Not a strict invariant.

    html = """
<!doctype html>
<html lang=\"en\">
<head>
  <meta charset=\"utf-8\" />
  <meta name=\"viewport\" content=\"width=device-width, initial-scale=1\" />
  <title>Cliff Refuge — Reachability (Markings)</title>
  <script src=\"https://cdn.tailwindcss.com?plugins=typography\"></script>
  <script src=\"https://cdnjs.cloudflare.com/ajax/libs/d3/7.8.5/d3.min.js\"></script>
</head>
<body class=\"bg-slate-950 text-slate-100\">

<div class=\"max-w-6xl mx-auto p-4 sm:p-6\">
  <header class=\"flex flex-col sm:flex-row sm:items-end sm:justify-between gap-3\">
    <div>
      <h1 class=\"text-2xl sm:text-3xl font-semibold tracking-tight\">Cliff Refuge (Halfway up a Cliff)</h1>
      <p class=\"text-slate-300 mt-1\">Reachability graph of Petri-net markings (states) — click nodes/edges for details.</p>
    </div>
    <div class=\"flex flex-wrap gap-2\">
      <button id=\"btnHow\" class=\"px-3 py-2 rounded-xl bg-slate-800 hover:bg-slate-700 shadow\">How to read this</button>
      <button id=\"btnModel\" class=\"px-3 py-2 rounded-xl bg-slate-800 hover:bg-slate-700 shadow\">Model notes</button>
      <button id=\"btnInv\" class=\"px-3 py-2 rounded-xl bg-slate-800 hover:bg-slate-700 shadow\">Invariants & sanity</button>
      <button id=\"btnExt\" class=\"px-3 py-2 rounded-xl bg-slate-800 hover:bg-slate-700 shadow\">Possible extension</button>
      <button id=\"btnReset\" class=\"px-3 py-2 rounded-xl bg-indigo-600 hover:bg-indigo-500 shadow\">Reset layout</button>
    </div>
  </header>

  <main class=\"mt-5 grid grid-cols-1 lg:grid-cols-3 gap-4\">
    <section class=\"lg:col-span-2\">
      <div class=\"rounded-2xl bg-slate-900 shadow border border-slate-800 overflow-hidden\">
        <div class=\"px-4 py-3 border-b border-slate-800 flex items-center justify-between\">
          <div class=\"text-sm text-slate-300\">
            <span class=\"font-medium\">Graph</span>
            <span class=\"ml-2\" id=\"stats\"></span>
          </div>
          <div class=\"text-xs text-slate-400\">drag nodes • scroll to zoom</div>
        </div>
        <div id=\"viz\" class=\"w-full\" style=\"height: 520px\"></div>
      </div>

      <div class=\"mt-4 grid grid-cols-1 md:grid-cols-2 gap-4\">
        <div class=\"rounded-2xl bg-slate-900 shadow border border-slate-800 p-4\">
          <h2 class=\"font-semibold\">Selected node (marking)</h2>
          <p id=\"nodeText\" class=\"mt-2 text-slate-200 text-sm leading-relaxed\">Click a node…</p>
          <div class=\"mt-3 text-xs text-slate-400\" id=\"nodeMeta\"></div>
        </div>
        <div class=\"rounded-2xl bg-slate-900 shadow border border-slate-800 p-4\">
          <h2 class=\"font-semibold\">Selected edge (firing)</h2>
          <p id=\"edgeText\" class=\"mt-2 text-slate-200 text-sm leading-relaxed\">Click an edge…</p>
          <div class=\"mt-3 text-xs text-slate-400\" id=\"edgeMeta\"></div>
        </div>
      </div>
    </section>

    <aside class=\"lg:col-span-1\">
      <div class=\"rounded-2xl bg-slate-900 shadow border border-slate-800 p-4\">
        <h2 class=\"font-semibold\">Legend</h2>
        <ul class=\"mt-3 space-y-2 text-sm text-slate-200\">
          <li class=\"flex items-center gap-2\"><span class=\"inline-block w-3 h-3 rounded-full bg-slate-400\"></span> ordinary marking</li>
          <li class=\"flex items-center gap-2\"><span class=\"inline-block w-3 h-3 rounded-full bg-emerald-400\"></span> goal marking (RefugeComplete)</li>
        </ul>
        <hr class=\"my-4 border-slate-800\"/>
        <h3 class=\"font-semibold\">Quick checks</h3>
        <ul class=\"mt-2 space-y-2 text-sm text-slate-200\">
          <li>Does the goal appear reachable?</li>
          <li>Where do dead-ends (out-degree 0) occur?</li>
          <li>Which transitions branch the most?</li>
        </ul>
      </div>

      <div class=\"mt-4 rounded-2xl bg-slate-900 shadow border border-slate-800 p-4\">
        <h2 class=\"font-semibold\">Transitions</h2>
        <div class=\"mt-2 text-sm text-slate-200\" id=\"tList\"></div>
      </div>
    </aside>
  </main>
</div>

<!-- Modal -->
<div id=\"modal\" class=\"fixed inset-0 hidden items-center justify-center p-4\">
  <div class=\"absolute inset-0 bg-black/70\"></div>
  <div class=\"relative max-w-2xl w-full rounded-2xl bg-slate-900 border border-slate-700 shadow-xl overflow-hidden\">
    <div class=\"flex items-start justify-between gap-3 px-4 py-3 border-b border-slate-800\">
      <h3 id=\"modalTitle\" class=\"font-semibold\"></h3>
      <button id=\"modalClose\" class=\"px-3 py-1 rounded-xl bg-slate-800 hover:bg-slate-700\">Close</button>
    </div>
    <div id=\"modalBody\" class=\"px-4 py-4 prose prose-invert max-w-none\"></div>
  </div>
</div>

<script>
const NODES = $nodes_json;
const LINKS = $edges_json;

// Build stats
const statsEl = document.getElementById('stats');
statsEl.textContent = `${NODES.length} markings • ${LINKS.length} firings`;

// Transition list
const tList = document.getElementById('tList');
{
  const byName = new Map();
  LINKS.forEach(e => {
    const k = e.t;
    byName.set(k, (byName.get(k) || 0) + 1);
  });
  const rows = [...byName.entries()].sort((a,b)=>b[1]-a[1]).map(([t,c]) =>
    `<div class=\"flex justify-between gap-3 py-1 border-b border-slate-800/60\">
       <div class=\"truncate\">${t}</div>
       <div class=\"text-slate-400\">${c}</div>
     </div>`
  ).join('');
  tList.innerHTML = `<div class=\"text-xs text-slate-400 mb-2\">(count of outgoing firings across the state graph)</div>${rows}`;
}

// Modal helpers
const modal = document.getElementById('modal');
const modalTitle = document.getElementById('modalTitle');
const modalBody = document.getElementById('modalBody');
function openModal(title, html){
  modalTitle.textContent = title;
  modalBody.innerHTML = html;
  modal.classList.remove('hidden');
  modal.classList.add('flex');
}
function closeModal(){
  modal.classList.add('hidden');
  modal.classList.remove('flex');
}
document.getElementById('modalClose').onclick = closeModal;
modal.addEventListener('click', (e)=>{ if(e.target === modal.firstElementChild) closeModal(); });

// Explainer buttons

document.getElementById('btnHow').onclick = () => openModal(
  'How to read this',
  `<p><b>Nodes</b> are <i>markings</i> (token distributions over places). Think: “what is true/available right now?”.</p>
   <p><b>Edges</b> are <i>firings</i>: a named transition whose pre‑places were marked, producing a new marking.</p>
   <p>This graph is the plan‑space: any path from the initial node to a green goal node is a feasible schedule skeleton.
   Concurrency shows up as <i>branching and re‑joining</i> rather than a single linear chain.</p>
   <p>Tip: click nodes to see the state; click edges to see the task name + duration estimate.</p>`
);

document.getElementById('btnModel').onclick = () => openModal(
  'Model notes (cliff specifics)',
  `<p>This is the “shed” upgraded into a <b>mountain refuge halfway up a cliff</b>. The extra structure is mainly about <i>access and safety gating</i>.</p>
   <ul>
     <li><b>Fixed lines</b> and <b>anchor certification</b> gate getting crew onto the ledge.</li>
     <li><b>WeatherWindow</b> is modeled as a tokenized condition used by exposure‑sensitive tasks (basecamp, rigging, pad prep, foundation, roof, paint).</li>
     <li><b>HeliSlot</b> is a consumable “lift opportunity” required for winching materials; in a richer model you’d track material lots explicitly.</li>
   </ul>
   <p>All of this is still toy‑level; the point is to show how a small set of places can enforce the physical logic while still generating many schedules.</p>`
);

document.getElementById('btnInv').onclick = () => openModal(
  'Invariants & sanity checks',
  `<p>In a “collective token” Petri net you can often compute linear invariants (S‑invariants) from the incidence matrix.
  In this dependency‑only toy, most places behave like <b>0/1 facts</b> rather than conserved resources, so invariants are weak.</p>
  <p>Practical sanity checks you can do visually:</p>
  <ul>
    <li><b>Deadlocks</b>: nodes with no outgoing edges. Are they expected (e.g. after consuming HeliSlot without enabling enough)?</li>
    <li><b>Goal reachability</b>: do any green nodes exist? If not, you’ve over‑constrained (or missed a producer).</li>
    <li><b>Accidental bypass</b>: can you reach FrameErected without FoundationSet? If yes, you mis‑wired a precondition.</li>
  </ul>
  <p>If you want real invariants, introduce explicit resources (crew‑hours, rope kits, battery drills, material crates) as conserved tokens.</p>`
);

document.getElementById('btnExt').onclick = () => openModal(
  'Possible extension',
  `<p><b>Second layer:</b> project the reachability graph into a <i>poset of enabling relations</i> (Willerton‑style enriched scheduling),
  and compute the <b>critical path</b> as an <i>adjoint</i> rather than by explicit search.</p>
  <p>Concretely: build the free \(\mathbb{R}\)-enriched category from task durations, take earliest‑start as a left Kan extension / colimit,
  and obtain slack/float from the right adjoint (latest‑start), so “critical” becomes “zero residuation” in the enriched order.</p>`
);

// Force‑directed graph
const width = document.getElementById('viz').clientWidth;
const height = document.getElementById('viz').clientHeight;

const svg = d3.select('#viz').append('svg')
  .attr('width', width)
  .attr('height', height)
  .attr('class', 'bg-slate-950');

const g = svg.append('g');

const zoom = d3.zoom().scaleExtent([0.2, 4]).on('zoom', (event) => {
  g.attr('transform', event.transform);
});
svg.call(zoom);

const link = g.append('g')
  .attr('stroke', '#334155')
  .attr('stroke-opacity', 0.8)
  .selectAll('line')
  .data(LINKS)
  .join('line')
  .attr('stroke-width', 1);

const node = g.append('g')
  .selectAll('circle')
  .data(NODES)
  .join('circle')
  .attr('r', d => d.is_goal ? 7 : 5)
  .attr('fill', d => d.is_goal ? '#34d399' : '#94a3b8')
  .attr('stroke', '#0b1220')
  .attr('stroke-width', 1.5)
  .call(d3.drag()
    .on('start', dragstarted)
    .on('drag', dragged)
    .on('end', dragended));

const label = g.append('g')
  .selectAll('text')
  .data(NODES)
  .join('text')
  .text(d => d.id)
  .attr('font-size', 10)
  .attr('fill', '#cbd5e1')
  .attr('opacity', 0.9)
  .attr('dx', 8)
  .attr('dy', 3);

const sim = d3.forceSimulation(NODES)
  .force('link', d3.forceLink(LINKS).id(d => d.id).distance(42).strength(0.6))
  .force('charge', d3.forceManyBody().strength(-160))
  .force('center', d3.forceCenter(width/2, height/2))
  .force('collide', d3.forceCollide().radius(d => d.is_goal ? 12 : 10));

sim.on('tick', () => {
  link
    .attr('x1', d => d.source.x)
    .attr('y1', d => d.source.y)
    .attr('x2', d => d.target.x)
    .attr('y2', d => d.target.y);

  node
    .attr('cx', d => d.x)
    .attr('cy', d => d.y);

  label
    .attr('x', d => d.x)
    .attr('y', d => d.y);
});

function dragstarted(event, d) {
  if (!event.active) sim.alphaTarget(0.25).restart();
  d.fx = d.x;
  d.fy = d.y;
}
function dragged(event, d) {
  d.fx = event.x;
  d.fy = event.y;
}
function dragended(event, d) {
  if (!event.active) sim.alphaTarget(0);
  d.fx = null;
  d.fy = null;
}

// Selection UI
const nodeText = document.getElementById('nodeText');
const nodeMeta = document.getElementById('nodeMeta');
const edgeText = document.getElementById('edgeText');
const edgeMeta = document.getElementById('edgeMeta');

node.on('click', (event, d) => {
  node.attr('stroke', '#0b1220');
  d3.select(event.currentTarget).attr('stroke', '#60a5fa');
  nodeText.textContent = d.label;
  nodeMeta.textContent = `state #${d.id}${d.is_goal ? ' • GOAL' : ''}`;
});

link.on('click', (event, d) => {
  link.attr('stroke', '#334155');
  d3.select(event.currentTarget).attr('stroke', '#fbbf24');
  const s = (typeof d.source === 'object') ? d.source.id : d.source;
  const t = (typeof d.target === 'object') ? d.target.id : d.target;
  edgeText.textContent = `${d.t}  (≈ ${d.dur} days)`;
  edgeMeta.textContent = `from state #${s} → state #${t}`;
});

// Reset layout button

document.getElementById('btnReset').onclick = () => {
  sim.alpha(1).restart();
  svg.transition().duration(250).call(zoom.transform, d3.zoomIdentity);
};

</script>
</body>
</html>
"""

    open(html_path, "w") do io
        write(io, html)
    end
end

###############################
# 5. Main
###############################

function main()
    net = build_cliff_refuge_net()
    m0 = initial_marking(net)

    markings, edges = enumerate_reachability(net, m0)

    tsv_path  = "/tmp/cliff_refuge_markings.tsv"
    html_path = "/tmp/cliff_refuge_reachability.html"

    write_tsv(net, markings, edges, tsv_path)
    write_html(net, markings, edges, html_path)

    println("Wrote TSV  : $tsv_path")
    println("Wrote HTML : $html_path")
    println("Markings   : $(length(markings))")
    println("Firings    : $(length(edges))")
    println("\nOpen the HTML file in your browser to explore the reachability graph.")
end

main()