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, serialisedauto/manual,Autothe default.FunnelMode::parse(&str) -> Option<Self>— forgiving, case-insensitive; an unknown/future token degrades toNone(never an error), matchingLane::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'sfunnel promptapplies 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 equalssimulate_flow's wave count).PlanDag::critical_path_deps() -> Vec<id>— a Dep-only twin of the above that follows ONLY task→taskDepedges, ignoring theMotherdecomposition lineage entirely. Wherecritical_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.
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)
The Dep-only twin drops the mother→child hop and highlights just the task→task chain (len 2 here vs the mixed len 3):
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>whereBottleneck { 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
downstreamdesc →fan_outdesc →fan_indesc → 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"].
┌─▶ 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)
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); themodemodule'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).