smith-agents 0.5.0 → 0.6.1

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 (70) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +72 -0
  3. data/README.md +83 -0
  4. data/lib/smith/agent.rb +13 -0
  5. data/lib/smith/errors.rb +4 -0
  6. data/lib/smith/version.rb +2 -2
  7. data/lib/smith/workflow/composite/branch.rb +93 -0
  8. data/lib/smith/workflow/composite/branch_budget_contract.rb +41 -0
  9. data/lib/smith/workflow/composite/branch_contract.rb +102 -0
  10. data/lib/smith/workflow/composite/branch_execution.rb +124 -0
  11. data/lib/smith/workflow/composite/branch_failure.rb +91 -0
  12. data/lib/smith/workflow/composite/branch_outcome.rb +108 -0
  13. data/lib/smith/workflow/composite/budget_allocator.rb +78 -0
  14. data/lib/smith/workflow/composite/budget_math.rb +51 -0
  15. data/lib/smith/workflow/composite/contract.rb +100 -0
  16. data/lib/smith/workflow/composite/effects.rb +140 -0
  17. data/lib/smith/workflow/composite/effects_application.rb +36 -0
  18. data/lib/smith/workflow/composite/effects_baseline.rb +36 -0
  19. data/lib/smith/workflow/composite/effects_preflight.rb +102 -0
  20. data/lib/smith/workflow/composite/encoded_value_budget.rb +36 -0
  21. data/lib/smith/workflow/composite/enums.rb +27 -0
  22. data/lib/smith/workflow/composite/error.rb +54 -0
  23. data/lib/smith/workflow/composite/error_evidence.rb +61 -0
  24. data/lib/smith/workflow/composite/execution_contract.rb +73 -0
  25. data/lib/smith/workflow/composite/fanout_branch_contract.rb +61 -0
  26. data/lib/smith/workflow/composite/input.rb +53 -0
  27. data/lib/smith/workflow/composite/outcome_accumulator.rb +131 -0
  28. data/lib/smith/workflow/composite/outcome_set.rb +23 -0
  29. data/lib/smith/workflow/composite/payload.rb +128 -0
  30. data/lib/smith/workflow/composite/payload_digest.rb +28 -0
  31. data/lib/smith/workflow/composite/plan.rb +130 -0
  32. data/lib/smith/workflow/composite/plan_integrity.rb +52 -0
  33. data/lib/smith/workflow/composite/planner.rb +53 -0
  34. data/lib/smith/workflow/composite/preparation.rb +27 -0
  35. data/lib/smith/workflow/composite/reducer.rb +133 -0
  36. data/lib/smith/workflow/composite/reduction.rb +31 -0
  37. data/lib/smith/workflow/composite/value_budget.rb +90 -0
  38. data/lib/smith/workflow/composite_branch_execution_authorization.rb +52 -0
  39. data/lib/smith/workflow/deadline_enforcement.rb +22 -5
  40. data/lib/smith/workflow/execution.rb +16 -16
  41. data/lib/smith/workflow/fanout_execution.rb +19 -18
  42. data/lib/smith/workflow/message_value_normalizer.rb +11 -10
  43. data/lib/smith/workflow/parallel_execution.rb +41 -13
  44. data/lib/smith/workflow/prepared_branch_execution.rb +31 -0
  45. data/lib/smith/workflow/prepared_step_execution_authorization.rb +14 -29
  46. data/lib/smith/workflow/prepared_step_execution_scope.rb +81 -15
  47. data/lib/smith/workflow/process_local.rb +33 -0
  48. data/lib/smith/workflow/split_step_persistence/composite_branch_authorization.rb +99 -0
  49. data/lib/smith/workflow/split_step_persistence/composite_branch_effects.rb +41 -0
  50. data/lib/smith/workflow/split_step_persistence/composite_branch_execution.rb +95 -0
  51. data/lib/smith/workflow/split_step_persistence/composite_branch_outcome.rb +36 -0
  52. data/lib/smith/workflow/split_step_persistence/composite_execution.rb +39 -0
  53. data/lib/smith/workflow/split_step_persistence/composite_preparation.rb +57 -0
  54. data/lib/smith/workflow/split_step_persistence/composite_reduction_execution.rb +122 -0
  55. data/lib/smith/workflow/split_step_persistence/execution.rb +7 -53
  56. data/lib/smith/workflow/split_step_persistence/execution_authorization.rb +41 -21
  57. data/lib/smith/workflow/split_step_persistence/execution_authorization_issuance.rb +57 -0
  58. data/lib/smith/workflow/split_step_persistence/execution_binding_collector.rb +5 -0
  59. data/lib/smith/workflow/split_step_persistence/execution_binding_snapshot.rb +17 -3
  60. data/lib/smith/workflow/split_step_persistence/execution_lifecycle.rb +96 -0
  61. data/lib/smith/workflow/split_step_persistence/execution_verification.rb +17 -5
  62. data/lib/smith/workflow/split_step_persistence/preparation_claim.rb +8 -0
  63. data/lib/smith/workflow/split_step_persistence/subclass_boundary.rb +21 -5
  64. data/lib/smith/workflow/split_step_persistence.rb +14 -0
  65. data/lib/smith/workflow/step_context.rb +46 -0
  66. data/lib/smith/workflow/thread_context_snapshot.rb +103 -0
  67. data/lib/smith/workflow/transition.rb +16 -1
  68. data/lib/smith/workflow.rb +21 -1
  69. data/lib/smith.rb +3 -0
  70. metadata +46 -1
@@ -0,0 +1,128 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "dry-struct"
4
+ require "json"
5
+
6
+ require_relative "../message_value_normalizer"
7
+
8
+ module Smith
9
+ class Workflow
10
+ module Composite
11
+ class Payload < Dry::Struct
12
+ MAX_SERIALIZED_BYTES = MessageValueNormalizer::MAX_BYTES
13
+ HASH_EACH_PAIR = Hash.instance_method(:each_pair)
14
+ private_constant :HASH_EACH_PAIR
15
+
16
+ class << self
17
+ def deserialize(value)
18
+ attributes = parse_attributes(value)
19
+ preflight_attributes!(attributes)
20
+ bounded = MessageValueNormalizer.new(attributes, label: payload_name).call
21
+ new(normalize_attributes(bounded))
22
+ rescue JSON::ParserError, TypeError => e
23
+ raise ArgumentError, "#{payload_name} is invalid: #{e.message}"
24
+ end
25
+
26
+ def normalize_attributes(attributes)
27
+ raise ArgumentError, "#{payload_name} must contain an object" unless attributes.is_a?(Hash)
28
+
29
+ result = normalize_attribute_keys(attributes)
30
+ validate_known_attributes!(result)
31
+ result
32
+ end
33
+
34
+ def payload_name
35
+ name.split("::").last.gsub(/([a-z])([A-Z])/, '\\1-\\2').downcase
36
+ end
37
+
38
+ def preflight_attributes!(attributes) = attributes
39
+
40
+ private
41
+
42
+ def normalize_attribute_keys(attributes)
43
+ result = {}
44
+ HASH_EACH_PAIR.bind_call(attributes) do |key, value|
45
+ normalized_key = normalize_attribute_key(key)
46
+ raise ArgumentError, "#{payload_name} contains duplicate attributes" if result.key?(normalized_key)
47
+
48
+ result[normalized_key] = value
49
+ end
50
+ result
51
+ end
52
+
53
+ def normalize_attribute_key(key)
54
+ unless key.is_a?(String) || key.is_a?(Symbol)
55
+ raise ArgumentError, "#{payload_name} attribute names must be strings or symbols"
56
+ end
57
+
58
+ return key if key.is_a?(Symbol)
59
+
60
+ payload_attribute_lookup.fetch(key) { key.dup.freeze }
61
+ end
62
+
63
+ def validate_known_attributes!(attributes)
64
+ unknown = attributes.keys - payload_attribute_names
65
+ missing = payload_attribute_names - attributes.keys
66
+ raise ArgumentError, "#{payload_name} contains unknown attributes: #{unknown.join(", ")}" if unknown.any?
67
+ return if missing.empty?
68
+
69
+ raise ArgumentError, "#{payload_name} is missing attributes: #{missing.join(", ")}"
70
+ end
71
+
72
+ def payload_attribute_names
73
+ @payload_attribute_names ||= schema.keys.map(&:name).freeze
74
+ end
75
+
76
+ def payload_attribute_lookup
77
+ @payload_attribute_lookup ||= payload_attribute_names.to_h do |name|
78
+ [name.to_s.freeze, name]
79
+ end.freeze
80
+ end
81
+
82
+ def raw_attribute(attributes, name)
83
+ symbol_present = Hash.instance_method(:key?).bind_call(attributes, name)
84
+ string_present = Hash.instance_method(:key?).bind_call(attributes, name.to_s)
85
+ raise ArgumentError, "#{payload_name} contains duplicate attributes" if symbol_present && string_present
86
+
87
+ Hash.instance_method(:[]).bind_call(attributes, symbol_present ? name : name.to_s)
88
+ end
89
+
90
+ def parse_attributes(value)
91
+ return value if value.is_a?(Hash)
92
+ unless value.is_a?(String) && value.bytesize <= MAX_SERIALIZED_BYTES
93
+ raise ArgumentError, "#{payload_name} must be a bounded Hash or JSON object"
94
+ end
95
+
96
+ JSON.parse(value).tap do |parsed|
97
+ raise ArgumentError, "#{payload_name} JSON must contain an object" unless parsed.is_a?(Hash)
98
+ end
99
+ end
100
+ end
101
+
102
+ def serialize
103
+ JSON.generate(to_h).tap do |payload|
104
+ raise WorkflowError, "#{self.class.payload_name} exceeds maximum bytes" if
105
+ payload.bytesize > self.class::MAX_SERIALIZED_BYTES
106
+ end
107
+ end
108
+
109
+ def initialize(attributes)
110
+ super
111
+ validate_serialized_size!
112
+ self.attributes.freeze
113
+ freeze
114
+ end
115
+
116
+ private
117
+
118
+ def validate_serialized_size!
119
+ return if JSON.generate(to_h).bytesize <= self.class::MAX_SERIALIZED_BYTES
120
+
121
+ raise ArgumentError, "#{self.class.payload_name} exceeds maximum encoded bytes"
122
+ end
123
+ end
124
+
125
+ private_constant :Payload
126
+ end
127
+ end
128
+ end
@@ -0,0 +1,28 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "digest"
4
+ require "dry-initializer"
5
+ require "json"
6
+
7
+ require_relative "../message_value_normalizer"
8
+
9
+ module Smith
10
+ class Workflow
11
+ module Composite
12
+ class PayloadDigest
13
+ extend Dry::Initializer
14
+
15
+ param :value
16
+
17
+ def self.call(value) = new(value).call
18
+
19
+ def call
20
+ normalized = MessageValueNormalizer.new(value, label: "composite payload").call
21
+ Digest::SHA256.hexdigest(JSON.generate(normalized))
22
+ end
23
+ end
24
+
25
+ private_constant :PayloadDigest
26
+ end
27
+ end
28
+ end
@@ -0,0 +1,130 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../../types"
4
+ require_relative "../../version"
5
+ require_relative "../prepared_step_dispatch"
6
+ require_relative "branch"
7
+ require_relative "enums"
8
+ require_relative "payload"
9
+ require_relative "payload_digest"
10
+ require_relative "plan_integrity"
11
+
12
+ module Smith
13
+ class Workflow
14
+ module Composite
15
+ class Plan < Payload
16
+ VERSION = 1
17
+ MAX_BRANCHES = 10_000
18
+ RESUME_POLICY = :incomplete_only
19
+ FAILURE_POLICY = :host_committed_primary
20
+ REDUCTION_POLICY = :ordered_all_success
21
+ RETRY_POLICY = :none
22
+ ENUM_ATTRIBUTES = %i[kind resume_policy failure_policy reduction_policy retry_policy].freeze
23
+ private_constant :ENUM_ATTRIBUTES
24
+ OwnedString = Types::String.constructor { |value| value.is_a?(String) ? value.dup.freeze : value }
25
+ private_constant :OwnedString
26
+
27
+ attribute :version, Types::Integer.enum(VERSION)
28
+ attribute :execution_semantics_version, OwnedString.constrained(min_size: 1, max_size: 32)
29
+ attribute :dispatch, Types.Instance(PreparedStepDispatch)
30
+ attribute :kind, Types::Symbol.enum(:parallel, :fanout)
31
+ attribute :transition, OwnedString.constrained(min_size: 1, max_size: 256)
32
+ attribute :from, OwnedString.constrained(min_size: 1, max_size: 256)
33
+ attribute :execution_namespace, OwnedString.constrained(format: PreparedStep::UUID_PATTERN)
34
+ attribute :branches, Types::Array.of(Types.Instance(Branch))
35
+ attribute :input_digest, OwnedString.constrained(format: PreparedStep::DIGEST_PATTERN)
36
+ attribute :budget_state_digest, OwnedString.constrained(format: PreparedStep::DIGEST_PATTERN)
37
+ attribute :resume_policy, Types::Symbol.enum(RESUME_POLICY)
38
+ attribute :failure_policy, Types::Symbol.enum(FAILURE_POLICY)
39
+ attribute :reduction_policy, Types::Symbol.enum(REDUCTION_POLICY)
40
+ attribute :retry_policy, Types::Symbol.enum(RETRY_POLICY)
41
+ attribute :plan_digest, OwnedString.constrained(format: PreparedStep::DIGEST_PATTERN)
42
+
43
+ class << self
44
+ def build(**attributes)
45
+ values = {
46
+ version: VERSION,
47
+ execution_semantics_version: Smith::EXECUTION_SEMANTICS_VERSION,
48
+ dispatch: attributes.fetch(:dispatch),
49
+ kind: attributes.fetch(:kind),
50
+ transition: attributes.fetch(:transition).to_s,
51
+ from: attributes.fetch(:from).to_s,
52
+ execution_namespace: attributes.fetch(:execution_namespace),
53
+ branches: attributes.fetch(:branches),
54
+ input_digest: attributes.fetch(:input_digest),
55
+ budget_state_digest: attributes.fetch(:budget_state_digest),
56
+ resume_policy: RESUME_POLICY,
57
+ failure_policy: FAILURE_POLICY,
58
+ reduction_policy: REDUCTION_POLICY,
59
+ retry_policy: RETRY_POLICY
60
+ }
61
+ new(values.merge(plan_digest: PayloadDigest.call(serializable(values))))
62
+ end
63
+
64
+ def normalize_attributes(attributes)
65
+ normalized = super
66
+ normalize_dispatch!(normalized)
67
+ normalize_branches!(normalized)
68
+ normalize_policies!(normalized)
69
+ normalized
70
+ end
71
+
72
+ def preflight_attributes!(attributes)
73
+ branches = raw_attribute(attributes, :branches)
74
+ validate_branch_count!(branches) if branches.is_a?(Array)
75
+ attributes
76
+ end
77
+
78
+ def serializable(values)
79
+ values.merge(
80
+ dispatch: values.fetch(:dispatch).to_h,
81
+ branches: values.fetch(:branches).map(&:to_h)
82
+ )
83
+ end
84
+
85
+ def validate_branch_count!(branches_or_count)
86
+ count = if branches_or_count.is_a?(Array)
87
+ Array.instance_method(:length).bind_call(branches_or_count)
88
+ else
89
+ branches_or_count
90
+ end
91
+ return if count&.between?(1, MAX_BRANCHES)
92
+
93
+ raise ArgumentError, "composite plan branch count is outside the transport limit"
94
+ end
95
+
96
+ private
97
+
98
+ def normalize_dispatch!(attributes)
99
+ dispatch = attributes[:dispatch]
100
+ attributes[:dispatch] = PreparedStepDispatch.deserialize(dispatch) unless
101
+ dispatch.is_a?(PreparedStepDispatch)
102
+ end
103
+
104
+ def normalize_branches!(attributes)
105
+ branches = attributes[:branches]
106
+ validate_branch_count!(branches)
107
+ normalized = []
108
+ Array.instance_method(:each).bind_call(branches) do |branch|
109
+ normalized << (branch.is_a?(Branch) ? branch : Branch.deserialize(branch))
110
+ end
111
+ attributes[:branches] = normalized.freeze
112
+ end
113
+
114
+ def normalize_policies!(attributes)
115
+ ENUM_ATTRIBUTES.each { |key| attributes[key] = Enums.normalize(key, attributes[key]) }
116
+ end
117
+ end
118
+
119
+ def initialize(attributes)
120
+ self.class.preflight_attributes!(attributes)
121
+ owned = self.class.normalize_attributes(attributes)
122
+ super(owned)
123
+ PlanIntegrity.new(self).call
124
+ end
125
+
126
+ def execution_for(branch) = BranchExecution.build(plan: self, branch:)
127
+ end
128
+ end
129
+ end
130
+ end
@@ -0,0 +1,52 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "dry/initializer"
4
+
5
+ require_relative "payload_digest"
6
+
7
+ module Smith
8
+ class Workflow
9
+ module Composite
10
+ class PlanIntegrity
11
+ extend Dry::Initializer
12
+
13
+ param :plan
14
+
15
+ def call
16
+ validate_execution_semantics!
17
+ validate_branches!
18
+ validate_digest!
19
+ plan
20
+ end
21
+
22
+ private
23
+
24
+ def validate_execution_semantics!
25
+ return if plan.execution_semantics_version == Smith::EXECUTION_SEMANTICS_VERSION
26
+
27
+ raise ArgumentError, "composite plan execution semantics do not match"
28
+ end
29
+
30
+ def validate_branches!
31
+ keys = {}
32
+ plan.branches.each_with_index do |branch, ordinal|
33
+ raise ArgumentError, "composite plan ordinals must be contiguous" unless branch.ordinal == ordinal
34
+ raise ArgumentError, "composite plan branch keys must be unique" if keys.key?(branch.key)
35
+
36
+ keys[branch.key] = true
37
+ end
38
+ end
39
+
40
+ def validate_digest!
41
+ attributes = plan.to_h.except(:plan_digest).merge(
42
+ dispatch: plan.dispatch.to_h,
43
+ branches: plan.branches.map(&:to_h)
44
+ )
45
+ return if plan.plan_digest == PayloadDigest.call(attributes)
46
+
47
+ raise ArgumentError, "composite plan digest does not match"
48
+ end
49
+ end
50
+ end
51
+ end
52
+ end
@@ -0,0 +1,53 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "dry-initializer"
4
+
5
+ require_relative "branch"
6
+ require_relative "plan"
7
+
8
+ module Smith
9
+ class Workflow
10
+ module Composite
11
+ class Planner
12
+ extend Dry::Initializer
13
+
14
+ option :dispatch
15
+ option :kind
16
+ option :transition
17
+ option :from
18
+ option :execution_namespace
19
+ option :branch_specs
20
+ option :input_digest
21
+ option :budget_state_digest
22
+
23
+ def call
24
+ Plan.validate_branch_count!(branch_specs)
25
+ Plan.build(
26
+ dispatch:,
27
+ kind:,
28
+ transition:,
29
+ from:,
30
+ execution_namespace:,
31
+ branches: build_branches,
32
+ input_digest:,
33
+ budget_state_digest:
34
+ )
35
+ end
36
+
37
+ private
38
+
39
+ def build_branches
40
+ branch_specs.each_with_index.map do |spec, ordinal|
41
+ Branch.build(
42
+ ordinal:,
43
+ key: spec.fetch(:key),
44
+ agent: spec.fetch(:agent),
45
+ binding_identity: spec.fetch(:binding_identity),
46
+ budget: spec.fetch(:budget, {})
47
+ )
48
+ end.freeze
49
+ end
50
+ end
51
+ end
52
+ end
53
+ end
@@ -0,0 +1,27 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../../types"
4
+ require_relative "input"
5
+ require_relative "plan"
6
+
7
+ module Smith
8
+ class Workflow
9
+ module Composite
10
+ class Preparation < Dry::Struct
11
+ attribute :plan, Types.Instance(Plan)
12
+ attribute :input, Types.Instance(Input)
13
+
14
+ def initialize(attributes)
15
+ super
16
+ unless plan.input_digest == input.digest
17
+ raise ArgumentError,
18
+ "composite preparation input does not match plan"
19
+ end
20
+
21
+ self.attributes.freeze
22
+ freeze
23
+ end
24
+ end
25
+ end
26
+ end
27
+ end
@@ -0,0 +1,133 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "dry-initializer"
4
+
5
+ require_relative "branch_outcome"
6
+ require_relative "budget_math"
7
+ require_relative "effects"
8
+ require_relative "outcome_accumulator"
9
+ require_relative "plan"
10
+ require_relative "reduction"
11
+
12
+ module Smith
13
+ class Workflow
14
+ module Composite
15
+ class Reducer
16
+ extend Dry::Initializer
17
+
18
+ option :plan
19
+ option :outcomes
20
+ option :primary_failure, optional: true
21
+
22
+ def call
23
+ outcome_set = OutcomeAccumulator.new(plan:, outcomes:).call
24
+ ordered = outcome_set.ordered
25
+ validate_effects!(ordered)
26
+ effects = merged_effects(ordered)
27
+ failure_seen, selected_failure = failure_state(ordered)
28
+ return successful_reduction(outcome_set.output, effects) unless failure_seen
29
+
30
+ failed_reduction(selected_failure, effects)
31
+ end
32
+
33
+ private
34
+
35
+ def validate_effects!(ordered)
36
+ usage_ids = {}
37
+ ordered.each { validate_outcome_effects!(_1, usage_ids) }
38
+ end
39
+
40
+ def validate_outcome_effects!(outcome, usage_ids)
41
+ branch = plan.branches.fetch(outcome.ordinal)
42
+ validate_budget!(outcome.effects, branch.budget)
43
+ validate_usage!(outcome.effects.usage_entries, branch, usage_ids)
44
+ end
45
+
46
+ def validate_usage!(entries, branch, usage_ids)
47
+ entries.each { validate_usage_entry!(_1, branch, usage_ids) }
48
+ end
49
+
50
+ def validate_usage_entry!(entry, branch, usage_ids)
51
+ unless entry.fetch("agent_name") == branch.agent
52
+ raise ArgumentError, "composite usage entry does not match its branch agent"
53
+ end
54
+
55
+ usage_id = entry.fetch("usage_id")
56
+ raise ArgumentError, "composite usage entry is duplicated" if usage_ids.key?(usage_id)
57
+
58
+ usage_ids[usage_id] = true
59
+ end
60
+
61
+ def validate_budget!(effects, envelope)
62
+ consumed = effects.budget_consumed
63
+ validate_budget_dimensions!(consumed, envelope)
64
+ envelope.each { |dimension, limit| validate_budget_dimension!(dimension, limit, consumed, effects) }
65
+ end
66
+
67
+ def validate_budget_dimensions!(consumed, envelope)
68
+ return if (consumed.keys - envelope.keys).empty?
69
+
70
+ raise ArgumentError, "composite branch consumed an undeclared budget dimension"
71
+ end
72
+
73
+ def validate_budget_dimension!(dimension, limit, consumed, effects)
74
+ amount = consumed.fetch(dimension, 0)
75
+ unless amount.is_a?(Numeric) && amount.finite? && amount >= 0 && amount <= limit
76
+ raise ArgumentError, "composite branch budget consumption exceeds its envelope"
77
+ end
78
+
79
+ expected = expected_consumption(dimension, effects)
80
+ return if decimal_equal?(amount, expected)
81
+
82
+ raise ArgumentError, "composite branch budget consumption does not match recorded usage"
83
+ end
84
+
85
+ def expected_consumption(dimension, effects)
86
+ return effects.total_tokens if %w[total_tokens token_limit].include?(dimension)
87
+ return effects.total_cost if dimension == "total_cost"
88
+
89
+ 0
90
+ end
91
+
92
+ def decimal_equal?(left, right)
93
+ Budget::DecimalContext.call { BigDecimal(left.to_s) == BigDecimal(right.to_s) }
94
+ end
95
+
96
+ def merged_effects(ordered)
97
+ Effects.new(
98
+ usage_entries: ordered.flat_map { _1.effects.usage_entries },
99
+ tool_results: ordered.flat_map { _1.effects.tool_results },
100
+ budget_consumed: BudgetMath.sum(ordered.map { _1.effects.budget_consumed })
101
+ )
102
+ end
103
+
104
+ def failure_state(ordered)
105
+ expected_key = primary_failure&.to_s
106
+ failure_seen = false
107
+ selected_failure = nil
108
+ ordered.each do |outcome|
109
+ next unless outcome.failed?
110
+
111
+ failure_seen = true
112
+ selected_failure = outcome if outcome.branch_key == expected_key
113
+ end
114
+ [failure_seen, selected_failure]
115
+ end
116
+
117
+ def successful_reduction(output, effects)
118
+ raise ArgumentError, "primary failure must be absent for a successful composite" if primary_failure
119
+
120
+ Reduction.new(status: :succeeded, output:, error: nil,
121
+ failed_branch_key: nil, effects:)
122
+ end
123
+
124
+ def failed_reduction(failure, effects)
125
+ raise ArgumentError, "primary failure must identify a failed branch" unless failure
126
+
127
+ Reduction.new(status: :failed, output: nil, error: failure.error,
128
+ failed_branch_key: failure.branch_key, effects:)
129
+ end
130
+ end
131
+ end
132
+ end
133
+ end
@@ -0,0 +1,31 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../../types"
4
+ require_relative "effects"
5
+ require_relative "error"
6
+
7
+ module Smith
8
+ class Workflow
9
+ module Composite
10
+ class Reduction < Dry::Struct
11
+ attribute :status, Types::Symbol.enum(:succeeded, :failed)
12
+ attribute? :output, Types::Any.optional
13
+ attribute? :error, Types.Instance(Error).optional
14
+ attribute? :failed_branch_key, Types::String.optional
15
+ attribute :effects, Types.Instance(Effects)
16
+
17
+ def initialize(attributes)
18
+ super
19
+ valid = status == :succeeded ? error.nil? && failed_branch_key.nil? : error && failed_branch_key
20
+ raise ArgumentError, "composite reduction fields do not match status" unless valid
21
+
22
+ self.attributes.freeze
23
+ freeze
24
+ end
25
+
26
+ def succeeded? = status == :succeeded
27
+ def failed? = status == :failed
28
+ end
29
+ end
30
+ end
31
+ end
@@ -0,0 +1,90 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "dry-initializer"
4
+
5
+ require_relative "../../errors"
6
+ require_relative "../string_snapshot"
7
+
8
+ module Smith
9
+ class Workflow
10
+ module Composite
11
+ class ValueBudget
12
+ extend Dry::Initializer
13
+
14
+ ARRAY_EACH = Array.instance_method(:each)
15
+ HASH_EACH_PAIR = Hash.instance_method(:each_pair)
16
+ private_constant :ARRAY_EACH, :HASH_EACH_PAIR
17
+
18
+ option :max_bytes
19
+ option :max_nodes
20
+ option :max_depth
21
+ option :label
22
+
23
+ def initialize(...)
24
+ super
25
+ @bytes = 0
26
+ @nodes = 0
27
+ end
28
+
29
+ def add(value, depth: 0)
30
+ pending = [[value, depth]]
31
+ until pending.empty?
32
+ item, item_depth = pending.pop
33
+ visit!(item, item_depth, pending)
34
+ end
35
+ self
36
+ end
37
+
38
+ private
39
+
40
+ def visit!(item, depth, pending)
41
+ validate_visit!(depth)
42
+ dispatch_value(item, depth, pending)
43
+ end
44
+
45
+ def dispatch_value(item, depth, pending)
46
+ case item
47
+ when Hash then enqueue_hash(item, depth, pending)
48
+ when Array then enqueue_array(item, depth, pending)
49
+ when String, Symbol then add_bytes!(item.to_s)
50
+ when Float then validate_float!(item)
51
+ when Integer, true, false, nil then nil
52
+ else raise WorkflowError, "#{label} contains unsupported value #{item.class}"
53
+ end
54
+ end
55
+
56
+ def validate_visit!(depth)
57
+ raise WorkflowError, "#{label} exceeds maximum depth #{max_depth}" if depth > max_depth
58
+
59
+ @nodes += 1
60
+ raise WorkflowError, "#{label} exceeds maximum size #{max_nodes}" if @nodes > max_nodes
61
+ end
62
+
63
+ def enqueue_hash(hash, depth, pending)
64
+ HASH_EACH_PAIR.bind_call(hash) do |key, value|
65
+ unless key.is_a?(String) || key.is_a?(Symbol)
66
+ raise WorkflowError, "#{label} contains unsupported Hash key #{key.class}"
67
+ end
68
+
69
+ pending << [value, depth + 1] << [key, depth + 1]
70
+ end
71
+ end
72
+
73
+ def enqueue_array(array, depth, pending)
74
+ ARRAY_EACH.bind_call(array) { |value| pending << [value, depth + 1] }
75
+ end
76
+
77
+ def add_bytes!(string)
78
+ @bytes += StringSnapshot.bytesize(string)
79
+ raise WorkflowError, "#{label} exceeds maximum bytes #{max_bytes}" if @bytes > max_bytes
80
+ end
81
+
82
+ def validate_float!(float)
83
+ return if float.finite?
84
+
85
+ raise WorkflowError, "#{label} contains a non-finite Float"
86
+ end
87
+ end
88
+ end
89
+ end
90
+ end