Gunnar

mikronova/funnel-core

.nornir/plan-dag-critical-path.md

<!-- nornir:class: design -->
# funnel-core — the mode vocabulary + the Plan-DAG critical path

Two **additive** extensions to the workflow-DAG core, both pure and deterministic
(FC-7). Neither changes an existing type's shape or an existing function's
signature — new module, new methods, new re-exports only.

## 1. The `FunnelMode` vocabulary (solidifying `Idea.mode`)

`Idea.mode` is an `Option<String>` set by an `IdeaModeSet` event. It is kept
**free-form on the wire on purpose**: a new mode must never force a schema bump,
and every pre-keystone log row (no `IdeaModeSet`) has to replay as `None`. That
storage contract is fixed — consumers (`ordning-funnel`, nornir's replay) build
`Idea` field-for-field and the field's *absence* is exactly what once broke
`ordning-funnel`'s field-for-field adoption.

The chosen design keeps the field and adds a **typed reading layer**:

- `FunnelMode { Auto, Manual }` — the two modes the funnel actually gates on,
  serialised `auto` / `manual`, `Auto` the default.
- `FunnelMode::parse(&str) -> Option<Self>` — forgiving, case-insensitive; an
  unknown/future token degrades to `None` (never an error), matching
  `Lane::parse` / `ItemKind::parse`.
- `FunnelMode::child_lane() -> Lane` — the **mode gate**, single-sourced here:
  `Auto → Planned` (child tasks join the live plan), `Manual → Proposed` (staged
  for human accept/reject). This is the rule nornir's `funnel prompt` applies per
  child; centralising it stops each call site re-implementing it.
- `Idea::mode_kind() -> Option<FunnelMode>` — parses the stored token, so callers
  branch on an enum instead of comparing strings.

Storage stays a `String`; **reading** is typed. The two contracts are separate on
purpose.

## 2. The Plan-DAG critical path + depth layering

The Plan-DAG already exposes nodes, edges and a topological order. Two things a
scheduler and a layout engine both want were missing:

- `PlanDag::depths() -> BTreeMap<id, u64>` — each node's **longest-path depth**
  (roots = 0). This is the render **layer** (dependencies always sit left of
  dependents) and the **wave** the flow simulator would advance the node on.
- `PlanDag::critical_path() -> Vec<id>` — the **longest dependency chain**
  (mother → child → … → leaf), the bottleneck lineage a renderer highlights.
  `critical_path_len()` is its edge count: the minimum number of sequential
  waves the plan can finish in (it equals `simulate_flow`'s wave count).
- `PlanDag::critical_path_deps() -> Vec<id>` — a **Dep-only twin** of the above
  that follows ONLY task→task `Dep` edges, ignoring the `Mother` decomposition
  lineage entirely. Where `critical_path()` always spends its first edge on the
  mother→child hop, this measures the pure *dependency* bottleneck — how many
  task→task waves the work itself imposes, decoupled from how it was decomposed.
  It is therefore always ≤ the mixed critical path and, whenever the deepest
  chain runs through the mother, strictly shorter. `critical_path_deps_len()` is
  its edge count.

All are computed in one topological pass over the existing edge set — O(V + E),
no clock, deterministic (equal-length ties break on id). A cyclic graph yields
an empty result, mirroring `topo_order`'s `Err`. They surface additively in
`state_json` (`critical_path`, `critical_path_len`, `critical_path_deps`,
`critical_path_deps_len`, `depths`) for the headless robot/viz surface.

```text
i-001 (mother, depth 0)
  └─▶ n-001 (depth 1) ─▶ n-002 (depth 2) ─▶ n-003 (depth 3)   ← critical path (len 3)
      └───────────────── n-001 ─▶ n-002 ─▶ n-003 ─────────────┘  ← critical_path_deps (len 2, mother dropped)
```

![critical path](assets/critical-path.svg)

The Dep-only twin drops the mother→child hop and highlights just the task→task
chain (len 2 here vs the mixed len 3):

![dep-only critical path](assets/critical-path-deps.svg)

## 3. The bottleneck ranking (which node unblocks the most work)

`critical_path()` answers *how long* the schedule is. A second, orthogonal
question a funnel pane must surface is *which node* a human should clear or
parallelise next — the constraint point. The chosen design is a single ranking
method over a small value type:

- `PlanDag::bottlenecks() -> Vec<Bottleneck>` where
  `Bottleneck { id, fan_in, fan_out, downstream }`, ranked most-constraining first.
- **downstream** — the headline metric — is the node's whole
  **transitive-descendant cone** (every node reachable out of it, itself
  excluded): the amount of work stalled *behind* it, so completing it unblocks
  exactly that much. **fan_out** / **fan_in** are the local shape (a divergence a
  human fans out to workers / a join everything waits on).
- Ranked `downstream` desc → `fan_out` desc → `fan_in` desc → id — so downstream
  *reach* dominates local *fan-out*: a narrow node heading a deep chain outranks a
  wide node feeding three leaves. `bottlenecks().first()` is the single biggest
  constraint (typically the mother idea's root, then the deepest task join).

Why a full ranking of *every* node rather than a filtered "bottleneck set": the
head is the actionable constraint, but a scheduler wants `downstream` as a
priority weight on all nodes, and a renderer filters `group.is_some()` for
task-only constraints — so returning the whole ranked vector is strictly more
useful and still deterministic. It spans **both** edge kinds (like
`critical_path`), so "downstream" is everything a node gates. One
reverse-topological pass accumulates each node's descendant set — O(V·E) on the
cold render path; a cyclic/empty graph yields `[]`, mirroring `depths`. Surfaces
additively in `state_json["bottlenecks"]`.

```text
           ┌─▶ n-002        n-005 ─▶ n-006 ─▶ n-007 ─▶ n-008 ─▶ n-009
i-001 ─▶ n-001 ─▶ n-003     (fan_out 1, downstream 4)  ← ranks ABOVE n-001:
           └─▶ n-004         deep reach beats wide fan-out
       (n-001: fan_out 3, downstream 3)
```

![bottleneck ranking](assets/plandag-bottlenecks.svg)

## Tests + bench

- Unit: `depths_layer_dependencies_before_dependents`,
  `critical_path_is_the_longest_chain`,
  `critical_path_picks_the_longer_of_two_branches`,
  `critical_path_deps_is_strictly_shorter_than_the_mother_lineage` (the
  RED-when-broken Dep-only guard),
  `critical_path_deps_picks_the_longer_dep_branch_ignoring_mother`,
  `bottlenecks_rank_by_downstream_reach_not_local_fanout` (the RED-when-broken
  bottleneck guard — deep-reach node outranks wide-fan-out node, mutation-verified
  by severing the deep chain's head edge),
  `state_json_carries_the_dag_vocabulary` (plan_dag); the `mode` module's four
  round-trip/gate tests.
- Integration (`tests/public_api.rs`): `funnel_mode_vocabulary_via_reexports`,
  `plan_dag_critical_path_via_reexports`.
- Bench: `funnel-core.plan_dag_critical` (critical path + depths over the
  400-plan / 12-node-chain synthetic funnel).