dogfood 0.1.0

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.
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: d8f5e84e866312bd228f0fa94068f9a1553c52ef15221e541f933f3713c6ca84
4
+ data.tar.gz: 382acac5d2fddd967522af9f7c97a7a2dde1ab73a12dc67655a31ce3ee7108cc
5
+ SHA512:
6
+ metadata.gz: e3876f850dcd5bcc59b58728ad4df5090400ae2fd6ad7391edd9a2f26db9a48104f416c7300810a252d4907ae9eadb1aa0bf5fc160c737eb95189739784e55c2
7
+ data.tar.gz: b1f219bb17809b21fbb1ddcaaf44941bb47c30497513c27341a24a366d453f5eec8c293702a9ea6ffaf39613b255e37dec6df22376a5b3128bd37f97c8087db2
data/bin/dogfood ADDED
@@ -0,0 +1,5 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ require "dogfood"
5
+ Dogfood::CLI.new(ARGV).run
@@ -0,0 +1,174 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fileutils"
4
+
5
+ module Dogfood
6
+ class CLI
7
+ def initialize(argv)
8
+ @argv = argv
9
+ @options = {
10
+ output: default_output_path,
11
+ days: 1,
12
+ wo_per_day: (3..6),
13
+ fleet_ratio: 0.25,
14
+ seed: nil,
15
+ scenario: nil
16
+ }
17
+ end
18
+
19
+ attr_reader :options
20
+
21
+ def run
22
+ parse!
23
+
24
+ if @options[:help]
25
+ print_help
26
+ return
27
+ end
28
+
29
+ unless @options[:pack]
30
+ warn "Error: --pack PATH is required"
31
+ print_help
32
+ exit 1
33
+ end
34
+
35
+ load_pack(@options[:pack])
36
+
37
+ if @options[:list]
38
+ list_scenarios
39
+ return
40
+ end
41
+
42
+ if @options[:explain]
43
+ explain_file(@options[:explain])
44
+ return
45
+ end
46
+
47
+ if @options[:dry_run] && @options[:scenario]
48
+ explain_scenario(@options[:scenario])
49
+ return
50
+ end
51
+
52
+ generator = Dogfood::DayGenerator.new(
53
+ pack: Dogfood::Pack.current,
54
+ days: @options[:days],
55
+ wo_per_day: @options[:wo_per_day],
56
+ fleet_ratio: @options[:fleet_ratio],
57
+ seed: @options[:seed],
58
+ scenario: @options[:scenario]
59
+ )
60
+ result = generator.generate
61
+
62
+ output = Dogfood::Renderer.new(result).render
63
+ FileUtils.mkdir_p(File.dirname(@options[:output]))
64
+ File.write(@options[:output], output)
65
+
66
+ puts "Generated simulation: #{@options[:output]}"
67
+ puts "Days: #{result[:days].length}"
68
+ puts "Seed: #{result[:seed]}"
69
+ end
70
+
71
+ def parse!
72
+ i = 0
73
+ while i < @argv.length
74
+ arg = @argv[i]
75
+ case arg
76
+ when "--pack"
77
+ @options[:pack] = @argv[i + 1]
78
+ i += 1
79
+ when "--scenario"
80
+ @options[:scenario] = @argv[i + 1]
81
+ i += 1
82
+ when "--list", "-l"
83
+ @options[:list] = true
84
+ when "--explain"
85
+ @options[:explain] = @argv[i + 1]
86
+ i += 1
87
+ when "--dry-run"
88
+ @options[:dry_run] = true
89
+ when "--random", "-r"
90
+ @options[:seed] = Random.new_seed
91
+ when /^--days(?:=(.+))?$/
92
+ @options[:days] = ($1 || @argv[i + 1]).to_i
93
+ i += 1 unless $1
94
+ when /^--wo-per-day(?:=(.+))?$/
95
+ @options[:wo_per_day] = parse_wo_per_day($1 || @argv[i + 1])
96
+ i += 1 unless $1
97
+ when /^--fleet-ratio(?:=(.+))?$/
98
+ @options[:fleet_ratio] = ($1 || @argv[i + 1]).to_f
99
+ i += 1 unless $1
100
+ when /^--seed(?:=(.+))?$/
101
+ @options[:seed] = ($1 || @argv[i + 1]).to_i
102
+ i += 1 unless $1
103
+ when /^--output(?:=(.+))?$/, "-o"
104
+ @options[:output] = ($1 || @argv[i + 1])
105
+ i += 1 unless $1
106
+ when "--help", "-h"
107
+ @options[:help] = true
108
+ end
109
+ i += 1
110
+ end
111
+ end
112
+
113
+ private
114
+
115
+ def load_pack(path)
116
+ Dogfood::Pack.current = nil
117
+ load File.expand_path(path)
118
+ raise "Pack file did not set Dogfood::Pack.current" unless Dogfood::Pack.current
119
+ end
120
+
121
+ def parse_wo_per_day(value)
122
+ if value.include?("-")
123
+ min, max = value.split("-").map(&:to_i)
124
+ (min..max)
125
+ else
126
+ value.to_i
127
+ end
128
+ end
129
+
130
+ def list_scenarios
131
+ puts "Available scenarios:"
132
+ Dogfood::Pack.current.scenario_keys.each do |key|
133
+ puts " - #{key}"
134
+ end
135
+ end
136
+
137
+ def explain_file(path)
138
+ require "yaml"
139
+ yaml = YAML.load_file(path)
140
+ Dogfood::DSL::Schema.validate!(yaml)
141
+ klass = Dogfood::DSL::Compiler.compile(yaml, pack: Dogfood::Pack.current)
142
+ puts Dogfood::DSL::ExplainRenderer.new(klass.compiled_ast).render
143
+ end
144
+
145
+ def explain_scenario(name)
146
+ klass = Dogfood::Pack.current.find_scenario(name)
147
+ puts Dogfood::DSL::ExplainRenderer.new(klass.compiled_ast).render
148
+ end
149
+
150
+ def default_output_path
151
+ timestamp = Time.now.strftime("%Y-%m-%d-%H%M%S")
152
+ "tmp/simulations/#{timestamp}-simulation.md"
153
+ end
154
+
155
+ def print_help
156
+ puts <<~HELP
157
+ Usage: dogfood [options]
158
+
159
+ --pack PATH Ruby file that registers a Dogfood::Pack (required)
160
+ --scenario NAME Run a specific named scenario (otherwise: random mix)
161
+ --days N Number of simulated days (default: 1)
162
+ --wo-per-day N or M-N Work orders per day (default: 3-6)
163
+ --fleet-ratio RATIO Fraction of fleet stories (default: 0.25)
164
+ --seed N Set random seed for reproducibility
165
+ --random Use a random seed (prints it)
166
+ --output PATH, -o PATH Output file path (default: tmp/simulations/<timestamp>.md)
167
+ --list, -l List available scenarios
168
+ --explain FILE Compile FILE and print the stage/decision tree, no RNG
169
+ --dry-run --scenario N Same as --explain but takes a registered name
170
+ --help, -h Show help
171
+ HELP
172
+ end
173
+ end
174
+ end
@@ -0,0 +1,95 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "date"
4
+
5
+ module Dogfood
6
+ class DayGenerator
7
+ DEFAULTS = { days: 1, wo_per_day: (3..6), fleet_ratio: 0.25, seed: nil }.freeze
8
+
9
+ def initialize(pack:, days: nil, wo_per_day: nil, fleet_ratio: nil, seed: nil, scenario: nil)
10
+ @pack = pack
11
+ @config = DEFAULTS.dup
12
+ @config[:days] = days if days
13
+ @config[:wo_per_day] = wo_per_day if wo_per_day
14
+ @config[:fleet_ratio] = fleet_ratio if fleet_ratio
15
+ @config[:seed] = seed if seed
16
+ @scenario = scenario&.to_sym
17
+ @rng = Dogfood::Randomness.new(seed: @config[:seed])
18
+ @ledger = Dogfood::Ledger.new
19
+ @builder = Dogfood::StoryBuilder.new(pack: @pack, rng: @rng)
20
+ end
21
+
22
+ def generate
23
+ days = @config[:days].times.map { |i| build_day(i) }
24
+ { days: days, seed: @rng.seed }
25
+ end
26
+
27
+ private
28
+
29
+ def build_day(day_index)
30
+ carry_overs = @ledger.resumable_for(day_index)
31
+ new_stories = build_new_stories(day_index, carry_overs.length)
32
+ active = carry_overs + new_stories
33
+
34
+ morning, afternoon, evening = [], [], []
35
+ active.shuffle(random: @rng).each_with_index do |entry, i|
36
+ story = entry.is_a?(Hash) ? entry[:story] : entry
37
+ slot = time_slot_for(i, active.length)
38
+ result = story.advance(day_index: day_index)
39
+ case slot
40
+ when :morning then morning << result
41
+ when :afternoon then afternoon << result
42
+ when :evening then evening << result
43
+ end
44
+ update_ledger(result)
45
+ end
46
+
47
+ {
48
+ index: day_index,
49
+ date: Date.today + day_index,
50
+ morning: morning, afternoon: afternoon, evening: evening,
51
+ carry_overs: carry_overs.map { |s| summarize(s) },
52
+ end_of_day_board: @ledger.open_stories.map { |s| summarize(s) }
53
+ }
54
+ end
55
+
56
+ def build_new_stories(day_index, carry_over_count)
57
+ target = wo_count_for_day
58
+ count = [target - carry_over_count, 0].max
59
+ count.times.map do
60
+ story = @builder.build(fleet_ratio: @config[:fleet_ratio], include: @scenario)
61
+ @ledger.register({ story: story, id: story.id, status: :estimate })
62
+ story
63
+ end
64
+ end
65
+
66
+ def update_ledger(result)
67
+ if result[:terminal]
68
+ @ledger.close(result[:id])
69
+ else
70
+ entry = @ledger.find(result[:id])
71
+ if entry
72
+ entry[:status] = result[:state][:status]
73
+ entry[:substatus] = result[:state][:substatus]
74
+ entry[:resume_on] = result[:resume_on]
75
+ end
76
+ end
77
+ end
78
+
79
+ def wo_count_for_day
80
+ range = @config[:wo_per_day]
81
+ range.is_a?(Range) ? @rng.rand(range) : range
82
+ end
83
+
84
+ def time_slot_for(index, total)
85
+ return :morning if index < (total * 0.5).ceil
86
+ return :afternoon if index < (total * 0.85).ceil
87
+ :evening
88
+ end
89
+
90
+ def summarize(story_or_hash)
91
+ story = story_or_hash.is_a?(Hash) ? story_or_hash[:story] : story_or_hash
92
+ { id: story.id, status: story.state[:status], substatus: story.state[:substatus] }
93
+ end
94
+ end
95
+ end
@@ -0,0 +1,253 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Dogfood
4
+ module DSL
5
+ # Compiles a validated scenario YAML hash into an anonymous
6
+ # Class(Dogfood::StoryBase). See SPEC.md 4.9 and 4.7.
7
+ module Compiler
8
+ TERMINAL_STATUSES = %i[paid declined voided].freeze
9
+
10
+ def self.compile(yaml, pack:)
11
+ ast = parse_ast(yaml)
12
+ validate_calls(ast, pack)
13
+
14
+ klass = Class.new(Dogfood::StoryBase) do
15
+ pack.step_modules.each { |m| include(m) }
16
+ pack.mixin_modules.each { |m| include(m) }
17
+ define_singleton_method(:compiled_ast) { ast }
18
+ end
19
+
20
+ klass.define_method(:title) { yaml["title"] }
21
+
22
+ klass.define_method(:advance) do |day_index:|
23
+ @current_day = day_index
24
+ @bindings ||= {}
25
+ @branch_for ||= {}
26
+ @next_stage ||= nil
27
+
28
+ stages.each do |stage_spec|
29
+ next unless @next_stage.nil? || @next_stage == stage_spec[:stage]
30
+ run_stage(stage_spec)
31
+ @next_stage = next_stage_after(stage_spec[:stage])
32
+ end
33
+
34
+ terminal = TERMINAL_STATUSES.include?(@state[:status])
35
+ {
36
+ id: id,
37
+ steps: @steps,
38
+ state: @state,
39
+ terminal: terminal,
40
+ resume_on: terminal ? nil : next_resume_day
41
+ }
42
+ end
43
+
44
+ klass.define_method(:stages) { self.class.compiled_ast[:stages] }
45
+
46
+ klass.define_method(:next_stage_after) do |stage|
47
+ idx = self.class.compiled_ast[:stages].index { |s| s[:stage] == stage }
48
+ nxt = self.class.compiled_ast[:stages][idx + 1]
49
+ nxt && nxt[:stage]
50
+ end
51
+
52
+ klass.define_method(:run_stage) do |stage_spec|
53
+ branch = resolve_branch(stage_spec)
54
+ stage_spec[:steps].each do |step_spec|
55
+ run_step(step_spec, branch)
56
+ end
57
+ end
58
+
59
+ klass.define_method(:resolve_branch) do |stage_spec|
60
+ weights = stage_spec[:branch_weights]
61
+ return nil unless weights
62
+ @branch_for[stage_spec[:stage]] ||= @rng.branch(weights)
63
+ end
64
+
65
+ klass.define_method(:run_step) do |step_spec, branch|
66
+ case step_spec[:type]
67
+ when :call
68
+ return if step_spec[:when_branch] && step_spec[:when_branch] != branch
69
+ call_step(step_spec)
70
+ when :when
71
+ list = eval_expr(step_spec[:expr]) ? step_spec[:then] : step_spec[:else]
72
+ (list || []).each { |c| run_step(c, nil) }
73
+ when :maybe
74
+ step_spec[:then].each { |c| run_step(c, nil) } if @rng.rand < step_spec[:prob]
75
+ end
76
+ end
77
+
78
+ klass.define_method(:call_step) do |call_spec|
79
+ if call_spec[:name] == :update_state
80
+ args = eval_with(call_spec[:with])
81
+ update_state(status: args[:status].to_sym, substatus: args[:substatus])
82
+ return
83
+ end
84
+
85
+ args = eval_with(call_spec[:with])
86
+ ret = send(call_spec[:name], **args)
87
+ bind_out(call_spec[:out], ret)
88
+ end
89
+
90
+ klass.define_method(:eval_with) do |with_hash|
91
+ (with_hash || {}).each_with_object({}) do |(k, v), acc|
92
+ acc[k.to_sym] = Dogfood::DSL::Evaluator.eval(v, bindings: @bindings, state: @state, rng: @rng, self_object: self)
93
+ end
94
+ end
95
+
96
+ klass.define_method(:eval_expr) do |expr|
97
+ Dogfood::DSL::Evaluator.eval(expr, bindings: @bindings, state: @state, rng: @rng, self_object: self)
98
+ end
99
+
100
+ klass.define_method(:bind_out) do |out, ret|
101
+ return unless out
102
+ if out.is_a?(Array)
103
+ if ret.is_a?(Hash)
104
+ out.each { |name| @bindings[name.to_sym] = ret[name.to_sym] }
105
+ else
106
+ out.each_with_index { |name, i| @bindings[name.to_sym] = ret[i] }
107
+ end
108
+ else
109
+ @bindings[out.to_sym] = ret
110
+ end
111
+ end
112
+
113
+ klass.define_method(:next_resume_day) do
114
+ resume = self.class.compiled_ast[:resume]
115
+ resume && resume[:after_await] == :same_day ? @current_day : @current_day + 1
116
+ end
117
+
118
+ klass
119
+ end
120
+
121
+ def self.parse_ast(yaml)
122
+ stages = yaml["stages"].map(&:to_sym)
123
+ branches = (yaml["branches"] || {}).each_with_object({}) do |(stage, weights), acc|
124
+ acc[stage.to_sym] = weights.each_with_object({}) { |(k, v), h| h[k.to_sym] = v }
125
+ end
126
+
127
+ stage_list = stages.map do |stage|
128
+ steps = yaml["stages_def"][stage.to_s].map { |s| parse_step(s, stage.to_s) }
129
+ {
130
+ stage: stage,
131
+ branch_weights: branches[stage],
132
+ steps: steps
133
+ }
134
+ end
135
+
136
+ resume = if yaml["resume"] && yaml["resume"]["after_await"]
137
+ { after_await: yaml["resume"]["after_await"].to_sym }
138
+ end
139
+
140
+ {
141
+ name: yaml["name"].to_sym,
142
+ title: yaml["title"],
143
+ stages: stage_list,
144
+ delays: (yaml["delays"] || {}).keys.map(&:to_sym),
145
+ resume: resume
146
+ }
147
+ end
148
+
149
+ def self.parse_step(step, stage)
150
+ if step.key?("call")
151
+ out = step["out"]
152
+ {
153
+ type: :call,
154
+ name: step["call"].to_sym,
155
+ when_branch: step["when_branch"]&.to_sym,
156
+ with: step["with"] || {},
157
+ out: normalize_out(out)
158
+ }
159
+ elsif step.key?("when")
160
+ {
161
+ type: :when,
162
+ expr: step["when"],
163
+ then: step["then"].map { |c| parse_block_step(c, stage) },
164
+ else: step["else"] ? step["else"].map { |c| parse_block_step(c, stage) } : []
165
+ }
166
+ elsif step.key?("maybe")
167
+ {
168
+ type: :maybe,
169
+ prob: step["maybe"],
170
+ then: step["then"].map { |c| parse_call(c, stage) }
171
+ }
172
+ else
173
+ raise Dogfood::NestingTooDeep, "invalid step in stage #{stage}"
174
+ end
175
+ end
176
+
177
+ def self.parse_call(step, stage)
178
+ unless step.key?("call")
179
+ raise Dogfood::NestingTooDeep, "nesting too deep at stage #{stage}: only call steps allowed in blocks"
180
+ end
181
+ {
182
+ type: :call,
183
+ name: step["call"].to_sym,
184
+ when_branch: nil,
185
+ with: step["with"] || {},
186
+ out: normalize_out(step["out"])
187
+ }
188
+ end
189
+
190
+ # A step inside a `when`/`then`/`else` block may be a `call` or a
191
+ # `maybe` probability step, but never another `when`. This is the
192
+ # depth-1 limit (SPEC.md 4.7).
193
+ def self.parse_block_step(step, stage)
194
+ if step.key?("when")
195
+ raise Dogfood::NestingTooDeep, "nesting too deep at stage #{stage}: when blocks may not nest"
196
+ end
197
+ if step.key?("maybe")
198
+ {
199
+ type: :maybe,
200
+ prob: step["maybe"],
201
+ then: step["then"].map { |c| parse_call(c, stage) }
202
+ }
203
+ else
204
+ parse_call(step, stage)
205
+ end
206
+ end
207
+
208
+ def self.normalize_out(out)
209
+ case out
210
+ when Array then out.map(&:to_sym)
211
+ when String then out.to_sym
212
+ else nil
213
+ end
214
+ end
215
+
216
+ def self.validate_calls(ast, pack)
217
+ names = collect_call_names(ast).uniq
218
+ names.each do |name|
219
+ next if name == :update_state
220
+ found = pack.step_modules.any? { |m| m.instance_methods.include?(name) } ||
221
+ pack.mixin_modules.any? { |m| m.instance_methods.include?(name) }
222
+ raise Dogfood::Pack::UnknownScenario, "call :#{name} does not resolve to a pack step module" unless found
223
+ end
224
+ end
225
+
226
+ def self.collect_call_names(ast)
227
+ ast[:stages].flat_map { |s| collect_stage_names(s[:steps]) }
228
+ end
229
+
230
+ def self.collect_stage_names(steps)
231
+ steps.flat_map do |step|
232
+ case step[:type]
233
+ when :call then [step[:name]]
234
+ when :when
235
+ collect_block_names(step[:then]) + collect_block_names(step[:else])
236
+ when :maybe then step[:then].map { |c| c[:name] }
237
+ else []
238
+ end
239
+ end
240
+ end
241
+
242
+ def self.collect_block_names(steps)
243
+ steps.flat_map do |step|
244
+ if step[:type] == :maybe
245
+ step[:then].map { |c| c[:name] }
246
+ else
247
+ [step[:name]]
248
+ end
249
+ end
250
+ end
251
+ end
252
+ end
253
+ end