ace-sim 0.13.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.
@@ -0,0 +1,231 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fileutils"
4
+
5
+ module Ace
6
+ module Sim
7
+ module Molecules
8
+ class FinalSynthesisExecutor
9
+ class CommandRunner
10
+ def call(args)
11
+ command = Ace::Core::Atoms::CommandExecutor.build_command(args[0], *args[1..])
12
+ result = Ace::Core::Atoms::CommandExecutor.execute(command)
13
+ {
14
+ success: result[:success],
15
+ stdout: result[:stdout].to_s,
16
+ stderr: result[:stderr].to_s,
17
+ exit_code: result[:exit_code]
18
+ }
19
+ end
20
+ end
21
+
22
+ def initialize(command_runner: nil)
23
+ @command_runner = command_runner || CommandRunner.new
24
+ end
25
+
26
+ def execute(run_dir:, session:, chains:, source_original_input_path: nil)
27
+ final_dir = File.join(run_dir, "final")
28
+ FileUtils.mkdir_p(final_dir)
29
+
30
+ source_original_path = File.join(final_dir, "source.original.md")
31
+ input_path = File.join(final_dir, "input.md")
32
+ bundle_path = File.join(final_dir, "user.bundle.md")
33
+ prompt_path = File.join(final_dir, "user.prompt.md")
34
+ raw_output_path = File.join(final_dir, "output.sequence.md")
35
+ report_path = File.join(final_dir, "suggestions.report.md")
36
+ revised_source_path = File.join(final_dir, "source.revised.md")
37
+
38
+ copy_source(source_original_path, session: session, source_original_input_path: source_original_input_path)
39
+ write_input(input_path, session: session, chains: chains)
40
+ write_bundle(bundle_path, workflow_ref: session.synthesis_workflow)
41
+
42
+ bundle_result = command_runner.call(["ace-bundle", bundle_path, "--output", prompt_path])
43
+ unless bundle_result[:success]
44
+ return failure("ace-bundle failed", bundle_result, report_path: report_path, raw_output_path: raw_output_path,
45
+ revised_source_path: revised_source_path)
46
+ end
47
+
48
+ provider = session.synthesis_provider.to_s.strip.empty? ? session.providers.first : session.synthesis_provider
49
+ llm_result = command_runner.call(["ace-llm", provider, "--prompt", prompt_path, "--output", raw_output_path])
50
+ unless llm_result[:success]
51
+ return failure("ace-llm failed", llm_result, report_path: report_path, raw_output_path: raw_output_path,
52
+ revised_source_path: revised_source_path)
53
+ end
54
+
55
+ sequence = read_non_empty(raw_output_path)
56
+ if sequence.nil?
57
+ return missing_output_failure("Final synthesis sequence missing or empty", provider, report_path, raw_output_path,
58
+ revised_source_path)
59
+ end
60
+
61
+ parsed = parse_sequence(sequence)
62
+ unless parsed
63
+ return {
64
+ "status" => "failed",
65
+ "provider" => provider,
66
+ "error" => "Final synthesis output missing required tags: <suggestions-report> and <source-revised>",
67
+ "source_original_path" => source_original_path,
68
+ "input_path" => input_path,
69
+ "bundle_path" => bundle_path,
70
+ "prompt_path" => prompt_path,
71
+ "raw_output_path" => raw_output_path,
72
+ "report_path" => report_path,
73
+ "revised_source_path" => revised_source_path,
74
+ "output_path" => report_path
75
+ }
76
+ end
77
+
78
+ File.write(report_path, parsed.fetch("suggestions_report"))
79
+ File.write(revised_source_path, parsed.fetch("source_revised"))
80
+
81
+ {
82
+ "status" => "ok",
83
+ "provider" => provider,
84
+ "source_original_path" => source_original_path,
85
+ "input_path" => input_path,
86
+ "bundle_path" => bundle_path,
87
+ "prompt_path" => prompt_path,
88
+ "raw_output_path" => raw_output_path,
89
+ "report_path" => report_path,
90
+ "revised_source_path" => revised_source_path,
91
+ "output_path" => report_path
92
+ }
93
+ rescue => e
94
+ {
95
+ "status" => "failed",
96
+ "error" => e.message,
97
+ "report_path" => report_path,
98
+ "raw_output_path" => raw_output_path,
99
+ "revised_source_path" => revised_source_path,
100
+ "output_path" => report_path
101
+ }
102
+ end
103
+
104
+ private
105
+
106
+ attr_reader :command_runner
107
+
108
+ def copy_source(path, session:, source_original_input_path:)
109
+ origin_path = source_original_input_path || session.source.first
110
+ FileUtils.cp(origin_path, path)
111
+ end
112
+
113
+ def write_input(path, session:, chains:)
114
+ sources_display = session.source.is_a?(Array) ? session.source.join(", ") : session.source.to_s
115
+ content = +"# ace-sim final synthesis input\n\n"
116
+ content << "Source file(s): #{sources_display}\n\n"
117
+ content << "## Chain outputs\n\n"
118
+
119
+ chains.each do |chain|
120
+ content << "### Chain #{chain["provider"]}##{chain["iteration"]} (#{chain["status"]})\n\n"
121
+ chain.fetch("steps", []).each do |step|
122
+ content << "#### Step #{step["step"]} (#{step["status"]})\n\n"
123
+ if step["output_path"] && File.exist?(step["output_path"])
124
+ content << "```markdown\n"
125
+ content << File.read(step["output_path"])
126
+ content << "\n```\n\n"
127
+ else
128
+ content << "_No output captured_\n\n"
129
+ end
130
+ end
131
+ end
132
+
133
+ File.write(path, content)
134
+ end
135
+
136
+ def write_bundle(path, workflow_ref:)
137
+ content = <<~MD
138
+ ---
139
+ description: "ace-sim final suggestions synthesis bundle"
140
+ bundle:
141
+ embed_document_source: true
142
+ sections:
143
+ synthesis_workflow:
144
+ files:
145
+ - #{workflow_ref}
146
+ source_original:
147
+ files:
148
+ - ./source.original.md
149
+ simulation_outputs:
150
+ files:
151
+ - ./input.md
152
+ ---
153
+
154
+ # Goal
155
+
156
+ Produce final actionable synthesis from simulation outputs and the original source.
157
+
158
+ ## Instructions
159
+
160
+ 1. Read `<source_original>` completely.
161
+ 2. Read `<simulation_outputs>` completely.
162
+ 3. Follow `<synthesis_workflow>` for evaluation structure.
163
+ 4. Return markdown only with both required tags:
164
+ - `<suggestions-report>...</suggestions-report>`
165
+ - `<source-revised>...</source-revised>`
166
+ 5. The revised source must be directly usable as the next source file iteration.
167
+ MD
168
+
169
+ File.write(path, content)
170
+ end
171
+
172
+ def read_non_empty(path)
173
+ return nil unless File.exist?(path)
174
+
175
+ content = File.read(path)
176
+ return nil if content.strip.empty?
177
+
178
+ content
179
+ end
180
+
181
+ def parse_sequence(content)
182
+ report = extract_tag(content, "suggestions-report")
183
+ revised = extract_tag(content, "source-revised")
184
+ return nil if report.nil? || revised.nil?
185
+
186
+ {
187
+ "suggestions_report" => report,
188
+ "source_revised" => revised
189
+ }
190
+ end
191
+
192
+ def extract_tag(content, tag_name)
193
+ match = content.match(%r{<#{Regexp.escape(tag_name)}>(.*?)</#{Regexp.escape(tag_name)}>}m)
194
+ return nil unless match
195
+
196
+ extracted = match[1].to_s.strip
197
+ return nil if extracted.empty?
198
+
199
+ extracted + "\n"
200
+ end
201
+
202
+ def missing_output_failure(message, provider, report_path, raw_output_path, revised_source_path)
203
+ {
204
+ "status" => "failed",
205
+ "provider" => provider,
206
+ "error" => "#{message}: #{raw_output_path}",
207
+ "report_path" => report_path,
208
+ "raw_output_path" => raw_output_path,
209
+ "revised_source_path" => revised_source_path,
210
+ "output_path" => report_path
211
+ }
212
+ end
213
+
214
+ def failure(prefix, result, report_path:, raw_output_path:, revised_source_path:)
215
+ detail = result[:stderr].to_s.strip
216
+ detail = result[:stdout].to_s.strip if detail.empty?
217
+ detail = "command failed" if detail.empty?
218
+
219
+ {
220
+ "status" => "failed",
221
+ "error" => "#{prefix}: #{detail}",
222
+ "report_path" => report_path,
223
+ "raw_output_path" => raw_output_path,
224
+ "revised_source_path" => revised_source_path,
225
+ "output_path" => report_path
226
+ }
227
+ end
228
+ end
229
+ end
230
+ end
231
+ end
@@ -0,0 +1,66 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "yaml"
4
+ require "fileutils"
5
+
6
+ module Ace
7
+ module Sim
8
+ module Molecules
9
+ class SessionStore
10
+ class RunDirectoryExistsError < StandardError; end
11
+
12
+ attr_reader :cache_root
13
+
14
+ def initialize(cache_root: nil)
15
+ @cache_root = cache_root || Ace::Sim.get("sim", "cache_root") || ".ace-local/sim"
16
+ end
17
+
18
+ def run_dir_for(run_id)
19
+ File.join(cache_root, "simulations", run_id)
20
+ end
21
+
22
+ def prepare_run(run_id)
23
+ run_dir = run_dir_for(run_id)
24
+ raise RunDirectoryExistsError, "Run directory already exists: #{run_dir}" if Dir.exist?(run_dir)
25
+
26
+ FileUtils.mkdir_p(File.join(run_dir, "chains"))
27
+ run_dir
28
+ end
29
+
30
+ def chain_dir(run_dir, provider, iteration)
31
+ provider_slug = provider.to_s.gsub(/[^a-zA-Z0-9_-]/, "-")
32
+ File.join(run_dir, "chains", "#{provider_slug}-#{iteration}")
33
+ end
34
+
35
+ def final_dir(run_dir)
36
+ File.join(run_dir, "final")
37
+ end
38
+
39
+ def prepare_step_dir(run_dir, provider, iteration, step_index, step_name)
40
+ dirname = format("%02d-%s", step_index, step_name)
41
+ dir = File.join(chain_dir(run_dir, provider, iteration), dirname)
42
+ FileUtils.mkdir_p(dir)
43
+ dir
44
+ end
45
+
46
+ def write_session(run_dir, payload)
47
+ write_yaml(File.join(run_dir, "session.yml"), payload)
48
+ end
49
+
50
+ def write_synthesis(run_dir, payload)
51
+ write_yaml(File.join(run_dir, "synthesis.yml"), payload)
52
+ end
53
+
54
+ def write_yaml(path, payload)
55
+ write_text(path, YAML.dump(payload))
56
+ end
57
+
58
+ def write_text(path, content)
59
+ FileUtils.mkdir_p(File.dirname(path))
60
+ File.write(path, content)
61
+ path
62
+ end
63
+ end
64
+ end
65
+ end
66
+ end
@@ -0,0 +1,70 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fileutils"
4
+
5
+ module Ace
6
+ module Sim
7
+ module Molecules
8
+ class SourceBundler
9
+ class CommandRunner
10
+ def call(args)
11
+ command = Ace::Core::Atoms::CommandExecutor.build_command(args[0], *args[1..])
12
+ result = Ace::Core::Atoms::CommandExecutor.execute(command)
13
+ {
14
+ success: result[:success],
15
+ stdout: result[:stdout].to_s,
16
+ stderr: result[:stderr].to_s,
17
+ exit_code: result[:exit_code]
18
+ }
19
+ end
20
+ end
21
+
22
+ def initialize(command_runner: nil)
23
+ @command_runner = command_runner || CommandRunner.new
24
+ end
25
+
26
+ def bundle(sources:, output_path:)
27
+ raise Ace::Sim::ValidationError, "source cannot be empty" if sources.nil? || sources.empty?
28
+
29
+ run_dir = File.dirname(output_path)
30
+ bundle_path = File.join(run_dir, "input.bundle.md")
31
+ content_path = File.join(run_dir, "input.md")
32
+
33
+ FileUtils.mkdir_p(run_dir)
34
+ File.write(bundle_path, build_bundle_yaml(sources))
35
+
36
+ result = command_runner.call(["ace-bundle", bundle_path, "--output", content_path])
37
+ return content_path if result[:success]
38
+
39
+ detail = result[:stderr].to_s.strip
40
+ detail = result[:stdout].to_s.strip if detail.empty?
41
+ detail = "command failed" if detail.empty?
42
+ raise Ace::Sim::ValidationError, "ace-bundle failed: #{detail}"
43
+ end
44
+
45
+ private
46
+
47
+ attr_reader :command_runner
48
+
49
+ def build_bundle_yaml(sources)
50
+ files_yaml = sources.map { |s| " - #{s}" }.join("\n")
51
+ <<~YAML
52
+ ---
53
+ description: "ace-sim source bundle"
54
+ bundle:
55
+ embed_document_source: true
56
+ sections:
57
+ source:
58
+ files:
59
+ #{files_yaml}
60
+ ---
61
+
62
+ # Source Files
63
+
64
+ Combined input from #{sources.length} source(s).
65
+ YAML
66
+ end
67
+ end
68
+ end
69
+ end
70
+ end
@@ -0,0 +1,106 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fileutils"
4
+
5
+ module Ace
6
+ module Sim
7
+ module Molecules
8
+ class StageExecutor
9
+ class CommandRunner
10
+ def call(args)
11
+ command = Ace::Core::Atoms::CommandExecutor.build_command(args[0], *args[1..])
12
+ result = Ace::Core::Atoms::CommandExecutor.execute(command)
13
+ {
14
+ success: result[:success],
15
+ stdout: result[:stdout].to_s,
16
+ stderr: result[:stderr].to_s,
17
+ exit_code: result[:exit_code]
18
+ }
19
+ end
20
+ end
21
+
22
+ def initialize(command_runner: nil)
23
+ @command_runner = command_runner || CommandRunner.new
24
+ end
25
+
26
+ def execute(step:, provider:, iteration:, step_dir:, step_bundle_path:, input_source_path:)
27
+ input_path = File.join(step_dir, "input.md")
28
+ bundle_path = File.join(step_dir, "user.bundle.md")
29
+ prompt_path = File.join(step_dir, "user.prompt.md")
30
+ output_path = File.join(step_dir, "output.md")
31
+
32
+ FileUtils.cp(input_source_path, input_path)
33
+ FileUtils.cp(step_bundle_path, bundle_path)
34
+
35
+ bundle_result = command_runner.call(["ace-bundle", bundle_path, "--output", prompt_path])
36
+ return failure(step, provider, iteration, output_path, "ace-bundle failed", bundle_result) unless bundle_result[:success]
37
+
38
+ llm_result = command_runner.call(["ace-llm", provider, "--prompt", prompt_path, "--output", output_path])
39
+ return failure(step, provider, iteration, output_path, "ace-llm failed", llm_result) unless llm_result[:success]
40
+
41
+ unless valid_output?(output_path)
42
+ return {
43
+ "step" => step,
44
+ "provider" => provider,
45
+ "iteration" => iteration,
46
+ "status" => "failed",
47
+ "input_path" => input_path,
48
+ "bundle_path" => bundle_path,
49
+ "prompt_path" => prompt_path,
50
+ "output_path" => output_path,
51
+ "error" => "Step output missing or empty: #{output_path}"
52
+ }
53
+ end
54
+
55
+ {
56
+ "step" => step,
57
+ "provider" => provider,
58
+ "iteration" => iteration,
59
+ "status" => "ok",
60
+ "input_path" => input_path,
61
+ "bundle_path" => bundle_path,
62
+ "prompt_path" => prompt_path,
63
+ "output_path" => output_path
64
+ }
65
+ rescue => e
66
+ {
67
+ "step" => step,
68
+ "provider" => provider,
69
+ "iteration" => iteration,
70
+ "status" => "failed",
71
+ "input_path" => input_path,
72
+ "bundle_path" => bundle_path,
73
+ "prompt_path" => prompt_path,
74
+ "output_path" => output_path,
75
+ "error" => e.message
76
+ }
77
+ end
78
+
79
+ private
80
+
81
+ attr_reader :command_runner
82
+
83
+ def valid_output?(output_path)
84
+ return false unless File.exist?(output_path)
85
+
86
+ !File.read(output_path).strip.empty?
87
+ end
88
+
89
+ def failure(step, provider, iteration, output_path, prefix, result)
90
+ detail = result[:stderr].to_s.strip
91
+ detail = result[:stdout].to_s.strip if detail.empty?
92
+ detail = "command failed" if detail.empty?
93
+
94
+ {
95
+ "step" => step,
96
+ "provider" => provider,
97
+ "iteration" => iteration,
98
+ "status" => "failed",
99
+ "output_path" => output_path,
100
+ "error" => "#{prefix}: #{detail}"
101
+ }
102
+ end
103
+ end
104
+ end
105
+ end
106
+ end
@@ -0,0 +1,41 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ace
4
+ module Sim
5
+ module Molecules
6
+ class SynthesisBuilder
7
+ def build(session:, sources:, chains:, final_stage: nil)
8
+ {
9
+ "run_id" => session.run_id,
10
+ "preset" => session.preset,
11
+ "sources" => sources,
12
+ "providers" => session.providers,
13
+ "repeat" => session.repeat,
14
+ "dry_run" => session.dry_run?,
15
+ "writeback" => session.writeback,
16
+ "synthesis_workflow" => session.synthesis_workflow,
17
+ "synthesis_provider" => session.synthesis_provider,
18
+ "chains" => chains,
19
+ "final_stage" => final_stage,
20
+ "status" => overall_status(chains, final_stage: final_stage)
21
+ }
22
+ end
23
+
24
+ def chain_status(step_results)
25
+ (step_results.any? { |step| step["status"] == "failed" }) ? "failed" : "ok"
26
+ end
27
+
28
+ private
29
+
30
+ def overall_status(chains, final_stage: nil)
31
+ return "failed" if final_stage && final_stage["status"] == "failed"
32
+ return "failed" if chains.empty?
33
+ return "failed" if chains.all? { |chain| chain["status"] == "failed" }
34
+ return "partial" if chains.any? { |chain| chain["status"] == "failed" }
35
+
36
+ "ok"
37
+ end
38
+ end
39
+ end
40
+ end
41
+ end
@@ -0,0 +1,172 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ace
4
+ module Sim
5
+ module Organisms
6
+ class SimulationRunner
7
+ def initialize(session_store: nil, stage_executor: nil, synthesis_builder: nil,
8
+ final_synthesis_executor: nil, source_bundler: nil)
9
+ @session_store = session_store || Molecules::SessionStore.new
10
+ @stage_executor = stage_executor || Molecules::StageExecutor.new
11
+ @synthesis_builder = synthesis_builder || Molecules::SynthesisBuilder.new
12
+ @final_synthesis_executor = final_synthesis_executor || Molecules::FinalSynthesisExecutor.new
13
+ @source_bundler = source_bundler || Molecules::SourceBundler.new
14
+ end
15
+
16
+ def run(session)
17
+ sources = session.source
18
+ if session.writeback && sources.length > 1
19
+ raise Ace::Sim::ValidationError, "writeback requires a single source file"
20
+ end
21
+
22
+ run_id, run_dir = prepare_unique_run(session)
23
+ bundled_input_path = source_bundler.bundle(
24
+ sources: sources,
25
+ output_path: File.join(run_dir, "input.md")
26
+ )
27
+
28
+ chains = []
29
+ session.providers.each do |provider|
30
+ 1.upto(session.repeat) do |iteration|
31
+ chains << run_chain(
32
+ session: session,
33
+ run_dir: run_dir,
34
+ provider: provider,
35
+ iteration: iteration,
36
+ bundled_input_path: bundled_input_path
37
+ )
38
+ end
39
+ end
40
+
41
+ final_stage = nil
42
+ if session.synthesis_enabled?
43
+ final_stage = final_synthesis_executor.execute(
44
+ run_dir: run_dir,
45
+ session: session,
46
+ chains: chains,
47
+ source_original_input_path: bundled_input_path
48
+ )
49
+ end
50
+
51
+ synthesis = synthesis_builder.build(
52
+ session: session,
53
+ sources: sources,
54
+ chains: chains,
55
+ final_stage: final_stage
56
+ )
57
+ session_store.write_synthesis(run_dir, synthesis)
58
+
59
+ session_store.write_session(
60
+ run_dir,
61
+ session.to_h.merge(
62
+ "run_id" => run_id,
63
+ "sources" => sources,
64
+ "status" => synthesis["status"],
65
+ "chain_count" => chains.length,
66
+ "writeback_applied" => false,
67
+ "synthesis_report_path" => final_stage && final_stage["report_path"],
68
+ "synthesis_revised_source_path" => final_stage && final_stage["revised_source_path"]
69
+ )
70
+ )
71
+
72
+ {
73
+ success: synthesis["status"] != "failed",
74
+ status: synthesis["status"],
75
+ run_id: run_id,
76
+ run_dir: run_dir,
77
+ chains: chains,
78
+ final_stage: final_stage,
79
+ synthesis: synthesis,
80
+ error: (synthesis["status"] == "failed") ? failure_reason(chains, final_stage) : nil
81
+ }
82
+ rescue Ace::Sim::ValidationError => e
83
+ {
84
+ success: false,
85
+ status: "failed",
86
+ run_id: session.run_id,
87
+ run_dir: nil,
88
+ chains: [],
89
+ synthesis: nil,
90
+ error: e.message
91
+ }
92
+ rescue => e
93
+ {
94
+ success: false,
95
+ status: "failed",
96
+ run_id: session.run_id,
97
+ run_dir: nil,
98
+ chains: [],
99
+ synthesis: nil,
100
+ error: "#{e.class}: #{e.message}"
101
+ }
102
+ end
103
+
104
+ private
105
+
106
+ attr_reader :session_store, :stage_executor, :synthesis_builder, :final_synthesis_executor,
107
+ :source_bundler
108
+
109
+ def run_chain(session:, run_dir:, provider:, iteration:, bundled_input_path:)
110
+ current_input_path = bundled_input_path
111
+ step_results = []
112
+
113
+ session.steps.each_with_index do |step, index|
114
+ step_index = case step
115
+ when "draft"
116
+ 1
117
+ when "plan"
118
+ 2
119
+ when "work"
120
+ 3
121
+ else
122
+ index + 1
123
+ end
124
+
125
+ step_dir = session_store.prepare_step_dir(run_dir, provider, iteration, step_index, step)
126
+ result = stage_executor.execute(
127
+ step: step,
128
+ provider: provider,
129
+ iteration: iteration,
130
+ step_dir: step_dir,
131
+ step_bundle_path: session.bundle_path_for(step),
132
+ input_source_path: current_input_path
133
+ )
134
+
135
+ step_results << result
136
+ break if result["status"] == "failed"
137
+
138
+ current_input_path = result["output_path"]
139
+ end
140
+
141
+ {
142
+ "provider" => provider,
143
+ "iteration" => iteration,
144
+ "status" => synthesis_builder.chain_status(step_results),
145
+ "steps" => step_results
146
+ }
147
+ end
148
+
149
+ def prepare_unique_run(session)
150
+ attempts = 0
151
+ begin
152
+ attempts += 1
153
+ run_dir = session_store.prepare_run(session.run_id)
154
+ [session.run_id, run_dir]
155
+ rescue Molecules::SessionStore::RunDirectoryExistsError
156
+ raise Ace::Sim::ValidationError, "Could not allocate unique run id" if attempts >= 5
157
+
158
+ session.regenerate_run_id!
159
+ retry
160
+ end
161
+ end
162
+
163
+ def failure_reason(chains, final_stage)
164
+ return "Final synthesis failed" if final_stage && final_stage["status"] == "failed"
165
+ return "All chains failed" if chains.all? { |chain| chain["status"] == "failed" }
166
+
167
+ "Simulation failed"
168
+ end
169
+ end
170
+ end
171
+ end
172
+ end
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ace
4
+ module Sim
5
+ VERSION = "0.13.0"
6
+ end
7
+ end