smith-agents 0.4.2 → 0.4.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (55) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +81 -3
  3. data/README.md +45 -2
  4. data/UPSTREAM_PROPOSAL.md +19 -0
  5. data/docs/PATTERNS.md +41 -1
  6. data/lib/smith/agent/lifecycle.rb +30 -10
  7. data/lib/smith/agent/registry/introspection.rb +27 -0
  8. data/lib/smith/agent/registry.rb +22 -17
  9. data/lib/smith/agent/registry_binding.rb +43 -0
  10. data/lib/smith/agent.rb +3 -4
  11. data/lib/smith/doctor/checks/models_registry.rb +25 -14
  12. data/lib/smith/doctor/checks/persistence_capabilities.rb +37 -29
  13. data/lib/smith/version.rb +1 -1
  14. data/lib/smith/workflow/agent_result.rb +26 -0
  15. data/lib/smith/workflow/branch_env.rb +24 -0
  16. data/lib/smith/workflow/budget_integration.rb +2 -0
  17. data/lib/smith/workflow/deterministic_execution.rb +1 -1
  18. data/lib/smith/workflow/deterministic_step.rb +32 -4
  19. data/lib/smith/workflow/durability.rb +38 -12
  20. data/lib/smith/workflow/evaluator_optimizer.rb +5 -8
  21. data/lib/smith/workflow/event_integration.rb +3 -3
  22. data/lib/smith/workflow/execution.rb +2 -0
  23. data/lib/smith/workflow/fanout_execution.rb +2 -0
  24. data/lib/smith/workflow/graph/contract_helpers.rb +65 -0
  25. data/lib/smith/workflow/graph/fanout_contract.rb +140 -0
  26. data/lib/smith/workflow/graph/nested_readiness_diagnostics.rb +98 -0
  27. data/lib/smith/workflow/graph/optimization_contract.rb +96 -0
  28. data/lib/smith/workflow/graph/orchestration_contract.rb +83 -0
  29. data/lib/smith/workflow/graph/runtime_binding_diagnostic_builder.rb +124 -0
  30. data/lib/smith/workflow/graph/runtime_binding_diagnostics.rb +114 -0
  31. data/lib/smith/workflow/graph/runtime_readiness.rb +63 -0
  32. data/lib/smith/workflow/graph/runtime_readiness_metrics.rb +87 -0
  33. data/lib/smith/workflow/graph/runtime_readiness_report.rb +65 -0
  34. data/lib/smith/workflow/graph/targets.rb +5 -0
  35. data/lib/smith/workflow/graph/transition_diagnostics.rb +7 -0
  36. data/lib/smith/workflow/graph/transition_snapshot.rb +61 -20
  37. data/lib/smith/workflow/graph.rb +16 -1
  38. data/lib/smith/workflow/graph_dsl.rb +4 -0
  39. data/lib/smith/workflow/guardrail_integration.rb +20 -3
  40. data/lib/smith/workflow/nested_execution.rb +5 -4
  41. data/lib/smith/workflow/optimization_state.rb +13 -0
  42. data/lib/smith/workflow/orchestration_state.rb +13 -0
  43. data/lib/smith/workflow/orchestrator_worker.rb +3 -15
  44. data/lib/smith/workflow/parallel/cancellation_signal.rb +21 -0
  45. data/lib/smith/workflow/parallel.rb +6 -15
  46. data/lib/smith/workflow/parallel_execution.rb +2 -0
  47. data/lib/smith/workflow/persistence.rb +37 -6
  48. data/lib/smith/workflow/router.rb +15 -4
  49. data/lib/smith/workflow/run_result.rb +54 -0
  50. data/lib/smith/workflow/transition.rb +90 -4
  51. data/lib/smith/workflow/usage_entry.rb +30 -0
  52. data/lib/smith/workflow/worker_execution.rb +13 -0
  53. data/lib/smith/workflow.rb +40 -136
  54. data/lib/smith.rb +9 -0
  55. metadata +21 -1
@@ -0,0 +1,98 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "dry-initializer"
4
+
5
+ module Smith
6
+ class Workflow
7
+ class Graph
8
+ class NestedReadinessDiagnostics
9
+ extend Dry::Initializer
10
+
11
+ param :graph
12
+ option :visited, default: proc {}
13
+
14
+ def to_a
15
+ cycle_diagnostics + report_entries.flat_map { |entry| diagnostics_for(entry) }
16
+ end
17
+
18
+ def child_reports
19
+ report_entries.map { |entry| entry.fetch(:report) }
20
+ end
21
+
22
+ private
23
+
24
+ def diagnostics_for(entry)
25
+ entry.fetch(:report).diagnostics.map do |diagnostic|
26
+ nested_diagnostic(entry.fetch(:transition), entry.fetch(:workflow_class), diagnostic)
27
+ end
28
+ end
29
+
30
+ def cycle_diagnostics
31
+ nested_workflow_transitions.filter_map do |transition|
32
+ workflow_class = transition.workflow_class
33
+ cycle_diagnostic(transition, workflow_class) if visited_workflows.include?(workflow_class)
34
+ end
35
+ end
36
+
37
+ def report_entries
38
+ @report_entries ||= nested_workflow_transitions.filter_map do |transition|
39
+ workflow_class = transition.workflow_class
40
+ next if visited_workflows.include?(workflow_class)
41
+
42
+ {
43
+ transition: transition,
44
+ workflow_class: workflow_class,
45
+ report: workflow_class.graph.runtime_readiness(visited: visited_workflows)
46
+ }.freeze
47
+ end.freeze
48
+ end
49
+
50
+ def nested_workflow_transitions
51
+ @nested_workflow_transitions ||= graph.transitions.values.select(&:nested?)
52
+ end
53
+
54
+ def cycle_diagnostic(transition, workflow_class)
55
+ label = workflow_label(workflow_class)
56
+
57
+ Diagnostic.new(
58
+ severity: :error,
59
+ code: :nested_workflow_cycle,
60
+ transition: transition.name,
61
+ target: label,
62
+ message: "Transition #{ref(transition.name)} nests #{label}, " \
63
+ "which is already in the readiness inspection stack.",
64
+ suggestion: "Break the nested workflow cycle before runtime execution."
65
+ )
66
+ end
67
+
68
+ def nested_diagnostic(transition, workflow_class, diagnostic)
69
+ label = workflow_label(workflow_class)
70
+
71
+ Diagnostic.new(
72
+ severity: diagnostic.severity,
73
+ code: :"nested_#{diagnostic.code}",
74
+ transition: transition.name,
75
+ target: label,
76
+ message: "Nested workflow #{label}: #{diagnostic.message}",
77
+ suggestion: diagnostic.suggestion
78
+ )
79
+ end
80
+
81
+ def visited_workflows
82
+ @visited_workflows ||= Set.new(Array(visited)).add(graph.workflow_class)
83
+ end
84
+
85
+ def ref(value)
86
+ Reference.format(value)
87
+ end
88
+
89
+ def workflow_label(workflow_class)
90
+ name = workflow_class.name
91
+ return name if name && !name.empty?
92
+
93
+ workflow_class.inspect
94
+ end
95
+ end
96
+ end
97
+ end
98
+ end
@@ -0,0 +1,96 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "dry-initializer"
4
+
5
+ require_relative "contract_helpers"
6
+
7
+ module Smith
8
+ class Workflow
9
+ class Graph
10
+ class OptimizationContract
11
+ extend Dry::Initializer
12
+ include ContractHelpers
13
+
14
+ param :transition
15
+ option :workflow_class, default: proc {}
16
+
17
+ def self.from_transition(transition, workflow_class: nil)
18
+ new(transition, workflow_class: workflow_class).to_h if transition.optimization_config
19
+ end
20
+
21
+ def to_h
22
+ deep_freeze(
23
+ transition_contract.merge(
24
+ exit_modes: exit_modes,
25
+ output_contract: output_contract,
26
+ resume_contract: resume_contract
27
+ ).compact
28
+ )
29
+ end
30
+
31
+ private
32
+
33
+ def config
34
+ transition.optimization_config
35
+ end
36
+
37
+ def transition_contract
38
+ {
39
+ generator: immutable_value(config.fetch(:generator)),
40
+ evaluator: immutable_value(config.fetch(:evaluator)),
41
+ max_rounds: config.fetch(:max_rounds)
42
+ }.merge(evaluation_contract)
43
+ end
44
+
45
+ def evaluation_contract
46
+ {
47
+ evaluator_schema: label_for(config.fetch(:evaluator_schema)),
48
+ evaluator_context: config[:evaluator_context],
49
+ improvement_threshold: config[:improvement_threshold],
50
+ before_eval: callable_label(config[:before_eval])
51
+ }
52
+ end
53
+
54
+ def exit_modes
55
+ {
56
+ exhaustion: exit_mode_label(config.fetch(:on_exhaustion)),
57
+ converged: exit_mode_label(config.fetch(:on_converged)),
58
+ threshold: exit_mode_label(config.fetch(:on_threshold))
59
+ }
60
+ end
61
+
62
+ def output_contract
63
+ {
64
+ success: :accepted_or_configured_exit_candidate,
65
+ failure: :workflow_error_on_malformed_or_unaccepted_evaluation,
66
+ evaluator_output: {
67
+ required: { accept: :boolean },
68
+ rejection_requires: %i[feedback],
69
+ threshold_requires: %i[score],
70
+ optional: %i[score converged]
71
+ }
72
+ }
73
+ end
74
+
75
+ def resume_contract
76
+ {
77
+ granularity: :transition,
78
+ round_checkpointing: false,
79
+ idempotency_mode: idempotency_mode,
80
+ in_flight_resume: in_flight_resume
81
+ }
82
+ end
83
+
84
+ def exit_mode_label(value)
85
+ return value if value.is_a?(Symbol)
86
+
87
+ callable_label(value)
88
+ end
89
+
90
+ def callable_label(value)
91
+ value.respond_to?(:call) ? :callable : nil
92
+ end
93
+ end
94
+ end
95
+ end
96
+ end
@@ -0,0 +1,83 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "dry-initializer"
4
+
5
+ require_relative "contract_helpers"
6
+
7
+ module Smith
8
+ class Workflow
9
+ class Graph
10
+ class OrchestrationContract
11
+ extend Dry::Initializer
12
+ include ContractHelpers
13
+
14
+ param :transition
15
+ option :workflow_class, default: proc {}
16
+
17
+ def self.from_transition(transition, workflow_class: nil)
18
+ new(transition, workflow_class: workflow_class).to_h if transition.orchestrator_config
19
+ end
20
+
21
+ def to_h
22
+ deep_freeze(
23
+ transition_contract.merge(
24
+ decision_contract: decision_contract,
25
+ output_contract: output_contract,
26
+ resume_contract: resume_contract
27
+ )
28
+ )
29
+ end
30
+
31
+ private
32
+
33
+ def config
34
+ transition.orchestrator_config
35
+ end
36
+
37
+ def transition_contract
38
+ {
39
+ orchestrator: immutable_value(config.fetch(:orchestrator)),
40
+ worker: immutable_value(config.fetch(:worker)),
41
+ max_workers: config.fetch(:max_workers),
42
+ max_delegation_rounds: config.fetch(:max_delegation_rounds),
43
+ worker_dispatch: :serial
44
+ }.merge(schema_contract)
45
+ end
46
+
47
+ def schema_contract
48
+ {
49
+ task_schema: label_for(config.fetch(:task_schema)),
50
+ worker_output_schema: label_for(config.fetch(:worker_output_schema)),
51
+ final_output_schema: label_for(config.fetch(:final_output_schema))
52
+ }
53
+ end
54
+
55
+ def decision_contract
56
+ {
57
+ shape: :hash,
58
+ exactly_one_of: %i[tasks final stop],
59
+ tasks_limit: config.fetch(:max_workers)
60
+ }
61
+ end
62
+
63
+ def output_contract
64
+ {
65
+ success: :final_output_schema,
66
+ worker_result_shape: :execution_id_task_output,
67
+ failure: :workflow_error_on_stop_exhaustion_or_schema_violation
68
+ }
69
+ end
70
+
71
+ def resume_contract
72
+ {
73
+ granularity: :transition,
74
+ round_checkpointing: false,
75
+ worker_checkpointing: false,
76
+ idempotency_mode: idempotency_mode,
77
+ in_flight_resume: in_flight_resume
78
+ }
79
+ end
80
+ end
81
+ end
82
+ end
83
+ end
@@ -0,0 +1,124 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "dry-initializer"
4
+
5
+ module Smith
6
+ class Workflow
7
+ class Graph
8
+ class RuntimeBindingDiagnosticBuilder
9
+ extend Dry::Initializer
10
+
11
+ param :graph
12
+ option :binding
13
+
14
+ def to_diagnostic
15
+ registry_binding = Agent::Registry.binding_for(agent)
16
+ return unresolved_agent_diagnostic unless registry_binding
17
+
18
+ if registry_binding.fetch(:agent_class).nil?
19
+ return uninspectable_agent_diagnostic(registry_binding) if registry_binding.fetch(:call)
20
+
21
+ return invalid_agent_diagnostic(registry_binding.fetch(:raw_binding))
22
+ end
23
+
24
+ agent_class = registry_binding.fetch(:agent_class)
25
+ return if agent_class.model_configured?
26
+ return required_model_diagnostic if model_required?
27
+
28
+ model_warning
29
+ end
30
+
31
+ private
32
+
33
+ def unresolved_agent_diagnostic
34
+ Diagnostic.new(
35
+ severity: :error,
36
+ code: :unresolved_agent_binding,
37
+ transition: transition.name,
38
+ target: agent,
39
+ message: "Transition #{ref(transition.name)} references unregistered #{role} #{ref(agent)}.",
40
+ suggestion: "Load and register agent #{ref(agent)} before running workflow #{workflow_label}."
41
+ )
42
+ end
43
+
44
+ def invalid_agent_diagnostic(raw_binding)
45
+ Diagnostic.new(
46
+ severity: :error,
47
+ code: :invalid_agent_binding,
48
+ transition: transition.name,
49
+ target: agent,
50
+ message: "Transition #{ref(transition.name)} references #{role} #{ref(agent)}, " \
51
+ "but the registry binding is not a Smith::Agent subclass.",
52
+ suggestion: "Register #{ref(agent)} as a Smith::Agent subclass instead of " \
53
+ "#{raw_binding.class}."
54
+ )
55
+ end
56
+
57
+ def uninspectable_agent_diagnostic(registry_binding)
58
+ Diagnostic.new(
59
+ severity: model_required? ? :error : :warning,
60
+ code: :uninspectable_agent_binding,
61
+ transition: transition.name,
62
+ target: agent,
63
+ message: "Transition #{ref(transition.name)} references #{role} #{ref(agent)}, " \
64
+ "but the registry binding is lazy and cannot be inspected without resolving it.",
65
+ suggestion: "Register #{ref(registry_binding.fetch(:key))} as a concrete Smith::Agent subclass " \
66
+ "before relying on static runtime readiness."
67
+ )
68
+ end
69
+
70
+ def required_model_diagnostic
71
+ Diagnostic.new(
72
+ severity: :error,
73
+ code: :agent_without_required_model,
74
+ transition: transition.name,
75
+ target: agent,
76
+ message: "Transition #{ref(transition.name)} references #{role} #{ref(agent)}, " \
77
+ "but that role requires model output at runtime.",
78
+ suggestion: "Configure a model for agent #{ref(agent)} or move this transition to a " \
79
+ "deterministic/nested workflow pattern."
80
+ )
81
+ end
82
+
83
+ def model_warning
84
+ Diagnostic.new(
85
+ severity: :warning,
86
+ code: :agent_without_model,
87
+ transition: transition.name,
88
+ target: agent,
89
+ message: "Transition #{ref(transition.name)} references #{role} #{ref(agent)}, " \
90
+ "but the registered agent has no model configured.",
91
+ suggestion: "Configure a model for agent #{ref(agent)} if this transition should call a provider."
92
+ )
93
+ end
94
+
95
+ def agent
96
+ binding.fetch(:agent)
97
+ end
98
+
99
+ def transition
100
+ binding.fetch(:transition)
101
+ end
102
+
103
+ def role
104
+ binding.fetch(:role)
105
+ end
106
+
107
+ def model_required?
108
+ binding.fetch(:requires_model)
109
+ end
110
+
111
+ def ref(value)
112
+ Reference.format(value)
113
+ end
114
+
115
+ def workflow_label
116
+ name = graph.workflow_class.name
117
+ return name if name && !name.empty?
118
+
119
+ graph.workflow_class.inspect
120
+ end
121
+ end
122
+ end
123
+ end
124
+ end
@@ -0,0 +1,114 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "dry-initializer"
4
+
5
+ module Smith
6
+ class Workflow
7
+ class Graph
8
+ class RuntimeBindingDiagnostics
9
+ extend Dry::Initializer
10
+
11
+ param :graph
12
+
13
+ def to_a
14
+ agent_bindings.filter_map do |binding|
15
+ RuntimeBindingDiagnosticBuilder.new(graph, binding: binding).to_diagnostic
16
+ end
17
+ end
18
+
19
+ def agent_bindings
20
+ @agent_bindings ||= graph.transitions.values.flat_map do |transition|
21
+ bindings_for_transition(transition)
22
+ end
23
+ end
24
+
25
+ private
26
+
27
+ def bindings_for_transition(transition)
28
+ [
29
+ primary_agent_binding(transition),
30
+ *optimizer_agent_bindings(transition),
31
+ *orchestrator_agent_bindings(transition),
32
+ *fanout_agent_bindings(transition)
33
+ ].compact
34
+ end
35
+
36
+ def primary_agent_binding(transition)
37
+ return unless transition.agent_name
38
+
39
+ {
40
+ transition: transition,
41
+ role: agent_role_for(transition),
42
+ agent: transition.agent_name,
43
+ requires_model: transition.routed?
44
+ }
45
+ end
46
+
47
+ def optimizer_agent_bindings(transition)
48
+ config = transition.optimization_config
49
+ return [] unless config
50
+
51
+ [
52
+ {
53
+ transition: transition,
54
+ role: "optimizer generator",
55
+ agent: config.fetch(:generator),
56
+ requires_model: true
57
+ },
58
+ {
59
+ transition: transition,
60
+ role: "optimizer evaluator",
61
+ agent: config.fetch(:evaluator),
62
+ requires_model: true
63
+ }
64
+ ]
65
+ end
66
+
67
+ def orchestrator_agent_bindings(transition)
68
+ config = transition.orchestrator_config
69
+ return [] unless config
70
+
71
+ [
72
+ {
73
+ transition: transition,
74
+ role: "orchestrator",
75
+ agent: config.fetch(:orchestrator),
76
+ requires_model: true
77
+ },
78
+ {
79
+ transition: transition,
80
+ role: "worker",
81
+ agent: config.fetch(:worker),
82
+ requires_model: true
83
+ }
84
+ ]
85
+ end
86
+
87
+ def fanout_agent_bindings(transition)
88
+ branches = transition.fanout_config&.fetch(:branches, nil)
89
+ return [] unless branches
90
+
91
+ branches.map do |branch, agent|
92
+ {
93
+ transition: transition,
94
+ role: "fan-out branch #{ref(branch)}",
95
+ agent: agent,
96
+ requires_model: false
97
+ }
98
+ end
99
+ end
100
+
101
+ def agent_role_for(transition)
102
+ return "router agent" if transition.routed?
103
+ return "parallel agent" if transition.parallel?
104
+
105
+ "agent"
106
+ end
107
+
108
+ def ref(value)
109
+ Reference.format(value)
110
+ end
111
+ end
112
+ end
113
+ end
114
+ end
@@ -0,0 +1,63 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "dry-initializer"
4
+
5
+ module Smith
6
+ class Workflow
7
+ class Graph
8
+ class RuntimeReadiness
9
+ extend Dry::Initializer
10
+
11
+ param :graph
12
+ option :visited, default: proc {}
13
+
14
+ def report
15
+ RuntimeReadinessReport.new(
16
+ workflow_class: workflow_label(graph.workflow_class),
17
+ topology_report: topology_report,
18
+ runtime_diagnostics: runtime_diagnostics,
19
+ metrics: runtime_metrics
20
+ )
21
+ end
22
+
23
+ private
24
+
25
+ def topology_report
26
+ @topology_report ||= graph.validate
27
+ end
28
+
29
+ def runtime_diagnostics
30
+ @runtime_diagnostics ||= [
31
+ *binding_diagnostics.to_a,
32
+ *nested_diagnostics.to_a
33
+ ]
34
+ end
35
+
36
+ def binding_diagnostics
37
+ @binding_diagnostics ||= RuntimeBindingDiagnostics.new(graph)
38
+ end
39
+
40
+ def nested_diagnostics
41
+ @nested_diagnostics ||= NestedReadinessDiagnostics.new(graph, visited: visited)
42
+ end
43
+
44
+ def runtime_metrics
45
+ RuntimeReadinessMetrics.new(
46
+ graph,
47
+ topology_report: topology_report,
48
+ runtime_diagnostics: runtime_diagnostics,
49
+ agent_bindings: binding_diagnostics.agent_bindings,
50
+ nested_reports: nested_diagnostics.child_reports
51
+ ).to_h
52
+ end
53
+
54
+ def workflow_label(workflow_class)
55
+ name = workflow_class.name
56
+ return name if name && !name.empty?
57
+
58
+ workflow_class.inspect
59
+ end
60
+ end
61
+ end
62
+ end
63
+ end
@@ -0,0 +1,87 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "dry-initializer"
4
+
5
+ module Smith
6
+ class Workflow
7
+ class Graph
8
+ class RuntimeReadinessMetrics
9
+ extend Dry::Initializer
10
+
11
+ param :graph
12
+ option :topology_report
13
+ option :runtime_diagnostics
14
+ option :agent_bindings
15
+ option :nested_reports, default: proc { [] }
16
+
17
+ def to_h
18
+ core_metrics.merge(
19
+ binding_metrics,
20
+ nested_workflow_metrics,
21
+ fanout_metrics
22
+ )
23
+ end
24
+
25
+ private
26
+
27
+ def core_metrics
28
+ {
29
+ topology_status: topology_report.status,
30
+ unresolved_agent_bindings_count: diagnostic_count(:unresolved_agent_binding),
31
+ invalid_agent_bindings_count: diagnostic_count(:invalid_agent_binding),
32
+ uninspectable_agent_bindings_count: diagnostic_count(:uninspectable_agent_binding),
33
+ modelless_agent_bindings_count: diagnostic_count(:agent_without_model),
34
+ required_model_missing_count: diagnostic_count(:agent_without_required_model)
35
+ }
36
+ end
37
+
38
+ def binding_metrics
39
+ {
40
+ direct_agent_bindings_count: agent_bindings.length,
41
+ agent_bindings_count: agent_bindings.length + nested_metric_sum(:agent_bindings_count)
42
+ }
43
+ end
44
+
45
+ def nested_workflow_metrics
46
+ {
47
+ direct_nested_workflow_count: direct_nested_workflow_count,
48
+ nested_workflow_count: direct_nested_workflow_count + nested_metric_sum(:nested_workflow_count)
49
+ }
50
+ end
51
+
52
+ def fanout_metrics
53
+ {
54
+ direct_fanout_groups_count: fanout_transitions.length,
55
+ fanout_groups_count: fanout_transitions.length + nested_metric_sum(:fanout_groups_count),
56
+ direct_fanout_branches_count: fanout_branch_count,
57
+ fanout_branches_count: fanout_branch_count + nested_metric_sum(:fanout_branches_count)
58
+ }
59
+ end
60
+
61
+ def nested_metric_sum(key)
62
+ nested_reports.sum { |report| report.metrics.fetch(key, 0) }
63
+ end
64
+
65
+ def diagnostic_count(code)
66
+ runtime_diagnostics.count do |diagnostic|
67
+ diagnostic.code.to_s.end_with?(code.to_s)
68
+ end
69
+ end
70
+
71
+ def fanout_branch_count
72
+ fanout_transitions.sum do |transition|
73
+ transition.fanout_config.fetch(:branches).length
74
+ end
75
+ end
76
+
77
+ def fanout_transitions
78
+ @fanout_transitions ||= graph.transitions.values.select(&:fanout?)
79
+ end
80
+
81
+ def direct_nested_workflow_count
82
+ graph.transitions.values.count(&:nested?)
83
+ end
84
+ end
85
+ end
86
+ end
87
+ end