llm-experiment 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.
Files changed (48) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +40 -0
  3. data/CODE_OF_CONDUCT.md +74 -0
  4. data/LICENSE.txt +21 -0
  5. data/README.md +190 -0
  6. data/exe/llmx +6 -0
  7. data/lib/llm-experiment.rb +3 -0
  8. data/lib/llm_experiment/auth.rb +143 -0
  9. data/lib/llm_experiment/cleaner.rb +183 -0
  10. data/lib/llm_experiment/cli/build_command.rb +37 -0
  11. data/lib/llm_experiment/cli/clean_command.rb +42 -0
  12. data/lib/llm_experiment/cli/doctor_command.rb +30 -0
  13. data/lib/llm_experiment/cli/login_command.rb +25 -0
  14. data/lib/llm_experiment/cli/metrics_command.rb +32 -0
  15. data/lib/llm_experiment/cli/new_command.rb +21 -0
  16. data/lib/llm_experiment/cli/parse_command.rb +77 -0
  17. data/lib/llm_experiment/cli/run_command.rb +74 -0
  18. data/lib/llm_experiment/cli/sanitize_command.rb +34 -0
  19. data/lib/llm_experiment/cli/shell_command.rb +35 -0
  20. data/lib/llm_experiment/cli/status_command.rb +69 -0
  21. data/lib/llm_experiment/cli/version_command.rb +19 -0
  22. data/lib/llm_experiment/cli.rb +120 -0
  23. data/lib/llm_experiment/container.rb +178 -0
  24. data/lib/llm_experiment/doctor.rb +126 -0
  25. data/lib/llm_experiment/experiment.rb +163 -0
  26. data/lib/llm_experiment/grid.rb +117 -0
  27. data/lib/llm_experiment/image_builder/app.rb +267 -0
  28. data/lib/llm_experiment/image_builder/base.rb +64 -0
  29. data/lib/llm_experiment/metrics_report.rb +138 -0
  30. data/lib/llm_experiment/pins.rb +22 -0
  31. data/lib/llm_experiment/sanitizer.rb +144 -0
  32. data/lib/llm_experiment/scaffold.rb +43 -0
  33. data/lib/llm_experiment/shell.rb +60 -0
  34. data/lib/llm_experiment/stats.rb +69 -0
  35. data/lib/llm_experiment/transcript/claude.rb +104 -0
  36. data/lib/llm_experiment/transcript/codex.rb +96 -0
  37. data/lib/llm_experiment/transcript/hermeticity.rb +35 -0
  38. data/lib/llm_experiment/transcript/parser.rb +105 -0
  39. data/lib/llm_experiment/transcript.rb +27 -0
  40. data/lib/llm_experiment/trial.rb +204 -0
  41. data/lib/llm_experiment/version.rb +5 -0
  42. data/lib/llm_experiment.rb +70 -0
  43. data/templates/README.md.erb +26 -0
  44. data/templates/base.Containerfile +120 -0
  45. data/templates/experiment.yml.erb +30 -0
  46. data/templates/gitignore +3 -0
  47. data/templates/runner.rb +305 -0
  48. metadata +92 -0
@@ -0,0 +1,60 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "open3"
4
+ require "shellwords"
5
+
6
+ module LLMExperiment
7
+ # Runs commands. Kept as one small module so container-facing classes can
8
+ # take a `shell:` argument and tests can pass a recorder instead.
9
+ module Shell
10
+ module_function
11
+
12
+ def log(message)
13
+ warn "[llmx] #{message}"
14
+ end
15
+
16
+ # Command echo, as opposed to progress. Every container invocation goes
17
+ # through here, which is a wall of text during a grid, so it is off unless
18
+ # asked for. Progress messages use `log` and always print.
19
+ def log_command(message)
20
+ log(message) if LLMExperiment.verbose
21
+ end
22
+
23
+ # Run a command, stream its output, raise unless it succeeds.
24
+ #
25
+ # `log_as` replaces what gets echoed, for a command carrying an argument
26
+ # nobody wants in their scrollback: a trial passes its whole prompt as one
27
+ # base64 environment value, which is kilobytes of noise on every run.
28
+ def sh(*cmd, allow_failure: false, log_as: nil)
29
+ log_command(log_as || cmd.shelljoin)
30
+ ok = system(*cmd)
31
+ return ok if ok || allow_failure
32
+
33
+ raise Error, "command failed: #{log_as || cmd.shelljoin}"
34
+ end
35
+
36
+ # Hand the terminal to a command (login device flows need a real TTY).
37
+ def interactive(*cmd)
38
+ system(*cmd)
39
+ end
40
+
41
+ # Run a command and capture stdout. Raises unless it succeeds.
42
+ def capture(*cmd, allow_failure: false)
43
+ out, err, status = Open3.capture3(*cmd)
44
+ raise Error, "command failed: #{cmd.shelljoin}\n#{err}" unless status.success? || allow_failure
45
+
46
+ out
47
+ end
48
+
49
+ # Run a command and report whether it worked.
50
+ def try(*cmd)
51
+ out, err, status = Open3.capture3(*cmd)
52
+ [out + err, status.success?]
53
+ end
54
+
55
+ def try_input(input, *cmd)
56
+ out, err, status = Open3.capture3(*cmd, stdin_data: input)
57
+ [out + err, status.success?]
58
+ end
59
+ end
60
+ end
@@ -0,0 +1,69 @@
1
+ # frozen_string_literal: true
2
+
3
+ module LLMExperiment
4
+ # Exact, not approximate. With five tasks per cell the normal approximation to
5
+ # the Mann-Whitney U is not trustworthy, and the whole permutation set is
6
+ # small enough to enumerate: C(10,5) is 252. So the p-values here are counted,
7
+ # not estimated.
8
+ module Stats
9
+ module_function
10
+
11
+ def median(values)
12
+ return nil if values.empty?
13
+
14
+ sorted = values.sort
15
+ mid = sorted.size / 2
16
+ sorted.size.odd? ? sorted[mid] : (sorted[mid - 1] + sorted[mid]) / 2.0
17
+ end
18
+
19
+ # Mid-ranks, so ties do not silently inflate the statistic.
20
+ def ranks(values)
21
+ indexed = values.each_with_index.sort_by { |v, _| v }
22
+ result = Array.new(values.size)
23
+ i = 0
24
+ while i < indexed.size
25
+ j = i
26
+ j += 1 while j + 1 < indexed.size && indexed[j + 1][0] == indexed[i][0]
27
+ mid = ((i + 1) + (j + 1)) / 2.0
28
+ (i..j).each { |k| result[indexed[k][1]] = mid }
29
+ i = j + 1
30
+ end
31
+ result
32
+ end
33
+
34
+ # U for sample a against sample b.
35
+ def u_statistic(a, b)
36
+ all = a + b
37
+ r = ranks(all)
38
+ rank_sum_a = r.first(a.size).sum
39
+ rank_sum_a - (a.size * (a.size + 1) / 2.0)
40
+ end
41
+
42
+ # Exact two-sided p: enumerate every way to split the pooled values into two
43
+ # samples of the observed sizes and count the splits at least as extreme.
44
+ def exact_mann_whitney(a, b)
45
+ n1 = a.size
46
+ n2 = b.size
47
+ return { u: nil, p: nil, note: "empty sample" } if n1.zero? || n2.zero?
48
+
49
+ total = n1 + n2
50
+ combos = (1..total).reduce(1, :*) / ((1..n1).reduce(1, :*) * (1..n2).reduce(1, :*))
51
+ return { u: u_statistic(a, b), p: nil, note: "#{combos} permutations; too many to enumerate" } if combos > 200_000
52
+
53
+ pooled = a + b
54
+ observed = u_statistic(a, b)
55
+ mean_u = n1 * n2 / 2.0
56
+ observed_deviation = (observed - mean_u).abs
57
+
58
+ at_least_as_extreme = 0
59
+ (0...total).to_a.combination(n1).each do |idx|
60
+ set = idx.to_h { |i| [i, true] }
61
+ left = idx.map { |i| pooled[i] }
62
+ right = (0...total).reject { |i| set[i] }.map { |i| pooled[i] }
63
+ at_least_as_extreme += 1 if (u_statistic(left, right) - mean_u).abs >= observed_deviation - 1e-9
64
+ end
65
+
66
+ { u: observed, p: at_least_as_extreme.to_f / combos, permutations: combos }
67
+ end
68
+ end
69
+ end
@@ -0,0 +1,104 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module LLMExperiment
6
+ module Transcript
7
+ # Claude Code emits stream-json: one system/init event, one assistant event
8
+ # per model request, and a final result event carrying the totals.
9
+ #
10
+ # Usage is reported per model request, so the context the very first request
11
+ # carried - the auto-attachment signature - is directly observable here.
12
+ module Claude
13
+ EDIT_TOOLS = %w[Edit Write NotebookEdit MultiEdit].freeze
14
+
15
+ module_function
16
+
17
+ def summarize(events, impl_files:, test_file: nil)
18
+ init = events.find { |e| e["subtype"] == "init" }
19
+ result = events.reverse.find { |e| e["type"] == "result" }
20
+
21
+ turns = []
22
+ tool_calls = []
23
+ events.each do |event|
24
+ next unless event["type"] == "assistant"
25
+
26
+ u = usage_of(event) || {}
27
+ turns << {
28
+ "input_tokens" => u["input_tokens"].to_i,
29
+ "output_tokens" => u["output_tokens"].to_i,
30
+ "cache_creation_input_tokens" => u["cache_creation_input_tokens"].to_i,
31
+ "cache_read_input_tokens" => u["cache_read_input_tokens"].to_i,
32
+ "context_tokens" => u["input_tokens"].to_i +
33
+ u["cache_creation_input_tokens"].to_i +
34
+ u["cache_read_input_tokens"].to_i,
35
+ "tool_calls" => tool_uses_in(event).map { |t| t["name"] }
36
+ }
37
+ tool_uses_in(event).each do |use|
38
+ tool_calls << { "name" => use["name"], "paths" => paths_in_tool_use(use),
39
+ "blob" => (use["input"] || {}).to_json }
40
+ end
41
+ end
42
+
43
+ first_defect_read = tool_calls.index { |t| Transcript.references_impl?(t["blob"], impl_files) }
44
+ first_edit = tool_calls.index { |t| EDIT_TOOLS.include?(t["name"]) }
45
+
46
+ usage = result&.dig("usage") || {}
47
+
48
+ {
49
+ "agent" => "claude",
50
+ "model_reported" => init&.dig("model"),
51
+ "cli_version" => init&.dig("claude_code_version"),
52
+ # Hermeticity: a clean container should report none of these. If any is
53
+ # non-empty the trial ran with help it was not supposed to have.
54
+ "mcp_servers" => init&.dig("mcp_servers") || [],
55
+ "memory_paths" => init&.dig("memory_paths") || {},
56
+ "tools_available" => (init&.dig("tools") || []).size,
57
+ "num_turns" => result&.dig("num_turns"),
58
+ "duration_ms" => result&.dig("duration_ms"),
59
+ "duration_api_ms" => result&.dig("duration_api_ms"),
60
+ "total_cost_usd" => result&.dig("total_cost_usd"),
61
+ "stop_reason" => result&.dig("stop_reason"),
62
+ "is_error" => result&.dig("is_error"),
63
+ "input_tokens" => usage["input_tokens"],
64
+ "output_tokens" => usage["output_tokens"],
65
+ "cache_creation_input_tokens" => usage["cache_creation_input_tokens"],
66
+ "cache_read_input_tokens" => usage["cache_read_input_tokens"],
67
+ "thinking_tokens" => usage.dig("output_tokens_details", "thinking_tokens"),
68
+ "total_tool_calls" => tool_calls.size,
69
+ "tool_call_names" => tool_calls.map { |t| t["name"] },
70
+ "tool_calls_to_first_defect_read" => first_defect_read,
71
+ "tool_calls_to_first_edit" => first_edit,
72
+ "read_before_edit" => first_defect_read && first_edit ? first_defect_read < first_edit : nil,
73
+ # The auto-attachment signature: how much context the very first request
74
+ # carried, before any tool could have fetched anything.
75
+ "context_before_first_tool_call" => turns.first&.dig("context_tokens"),
76
+ "turns" => turns,
77
+ "test_file" => test_file
78
+ }
79
+ end
80
+
81
+ def usage_of(event)
82
+ event.dig("message", "usage") || event["usage"]
83
+ end
84
+
85
+ def tool_uses_in(event)
86
+ content = event.dig("message", "content")
87
+ return [] unless content.is_a?(Array)
88
+
89
+ content.select { |c| c["type"] == "tool_use" }
90
+ end
91
+
92
+ # Which file a tool call touched, when it is knowable. Reads, edits and
93
+ # writes name a path directly; a shell command has to be scanned for one.
94
+ def paths_in_tool_use(use)
95
+ input = use["input"] || {}
96
+ direct = [input["file_path"], input["path"], input["notebook_path"]].compact
97
+ return direct unless direct.empty?
98
+
99
+ text = [input["command"], input["pattern"], input["prompt"]].compact.join(" ")
100
+ text.scan(%r{[\w./-]+\.(?:rb|erb|yml|yaml)}).uniq
101
+ end
102
+ end
103
+ end
104
+ end
@@ -0,0 +1,96 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module LLMExperiment
6
+ module Transcript
7
+ # Codex emits `item.completed` envelopes and one `turn.completed` carrying
8
+ # usage. Item types seen in practice: command_execution (command,
9
+ # aggregated_output, exit_code, status), file_change, agent_message,
10
+ # reasoning, error.
11
+ #
12
+ # One asymmetry to be honest about: Codex reports tokens per TURN, and a
13
+ # turn contains all of its tool calls. So the context carried before the
14
+ # first tool call - the auto-attachment signature this kind of experiment is
15
+ # chasing - is directly observable for Claude and not for Codex. For Codex
16
+ # the same effect can only show up in the totals, which is weaker but not
17
+ # nothing. It is left nil rather than filled with a number that does not
18
+ # mean what the column says.
19
+ module Codex
20
+ EDIT_ITEMS = %w[file_change patch_apply].freeze
21
+ TOOL_ITEMS = (EDIT_ITEMS + %w[command_execution mcp_tool_call web_search]).freeze
22
+
23
+ module_function
24
+
25
+ def summarize(events, impl_files:)
26
+ tool_calls = []
27
+ turns = []
28
+ errors = []
29
+
30
+ events.each do |event|
31
+ case event["type"]
32
+ when "item.completed"
33
+ item = event["item"] || {}
34
+ errors << item["message"] if item["type"] == "error"
35
+ next unless TOOL_ITEMS.include?(item["type"])
36
+
37
+ command = Array(item["command"]).join(" ")
38
+ paths = command.scan(%r{[\w./-]+\.(?:rb|erb|yml|yaml)}).uniq
39
+ paths |= changed_paths(item["changes"])
40
+
41
+ tool_calls << { "name" => item["type"], "paths" => paths, "command" => command,
42
+ "exit_code" => item["exit_code"], "blob" => item.to_json }
43
+ when "turn.completed"
44
+ u = event["usage"] || {}
45
+ turns << {
46
+ "input_tokens" => u["input_tokens"].to_i,
47
+ "output_tokens" => u["output_tokens"].to_i,
48
+ "cache_read_input_tokens" => u["cached_input_tokens"].to_i,
49
+ "cache_creation_input_tokens" => u["cache_write_input_tokens"].to_i,
50
+ "reasoning_output_tokens" => u["reasoning_output_tokens"].to_i,
51
+ "context_tokens" => u["input_tokens"].to_i
52
+ }
53
+ end
54
+ end
55
+
56
+ first_defect_read = tool_calls.index { |t| Transcript.references_impl?(t["blob"], impl_files) }
57
+ first_edit = tool_calls.index { |t| EDIT_ITEMS.include?(t["name"]) }
58
+
59
+ {
60
+ "agent" => "codex",
61
+ "input_tokens" => turns.sum { |t| t["input_tokens"] },
62
+ "output_tokens" => turns.sum { |t| t["output_tokens"] },
63
+ "cache_read_input_tokens" => turns.sum { |t| t["cache_read_input_tokens"] },
64
+ "cache_creation_input_tokens" => turns.sum { |t| t["cache_creation_input_tokens"] },
65
+ "thinking_tokens" => turns.sum { |t| t["reasoning_output_tokens"] },
66
+ "total_tool_calls" => tool_calls.size,
67
+ "tool_call_names" => tool_calls.map { |t| t["name"] },
68
+ "tool_calls_to_first_defect_read" => first_defect_read,
69
+ "tool_calls_to_first_edit" => first_edit,
70
+ "read_before_edit" => first_defect_read && first_edit ? first_defect_read < first_edit : nil,
71
+ # Only meaningful if Codex ever reports more than one turn; see the note above.
72
+ "context_before_first_tool_call" => turns.size > 1 ? turns.first["context_tokens"] : nil,
73
+ "codex_errors" => errors,
74
+ "turns" => turns
75
+ }
76
+ end
77
+
78
+ # A file_change item carries `changes` as a path => diff map.
79
+ #
80
+ # This diverges from the source harness, which wrote
81
+ # `Array(item["changes"]).flat_map { |c| c.is_a?(Hash) ? c.keys : [c.to_s] }`.
82
+ # Array() turns a Hash into [key, value] pairs before the is_a?(Hash)
83
+ # test can ever see one, so that branch was unreachable and every path
84
+ # came out as the string '["app/x.rb", "@@ -1 +1 @@"]' -- the diff text
85
+ # included. Nothing published reads this field today, which is exactly
86
+ # why it could stay wrong unnoticed.
87
+ def changed_paths(changes)
88
+ case changes
89
+ when Hash then changes.keys
90
+ when Array then changes.map(&:to_s)
91
+ else []
92
+ end
93
+ end
94
+ end
95
+ end
96
+ end
@@ -0,0 +1,35 @@
1
+ # frozen_string_literal: true
2
+
3
+ module LLMExperiment
4
+ module Transcript
5
+ # A trial is contaminated when it can reach state that outlives it or is
6
+ # shared with another trial. The CLI always reports an auto-memory path, so
7
+ # the question is not whether one exists -- it always does -- but where it
8
+ # points. Inside the per-trial config directory it dies with the container.
9
+ # Under the mounted credential store it is the same directory for every
10
+ # trial, and what one trial could leave there is the location of the defect.
11
+ # Testing for existence rather than location flags every clean run, which
12
+ # trains you to ignore the warning that matters.
13
+ module Hermeticity
14
+ SHARED_MOUNT = "/home/agent/.agent-auth"
15
+
16
+ module_function
17
+
18
+ def contamination(row)
19
+ reasons = []
20
+
21
+ servers = Array(row["mcp_servers"])
22
+ unless servers.empty?
23
+ names = servers.map { |s| s.is_a?(Hash) ? s["name"] : s }
24
+ reasons << "mcp servers attached: #{names.join(", ")}"
25
+ end
26
+
27
+ Hash(row["memory_paths"]).each do |kind, path|
28
+ reasons << "#{kind} memory lives in the shared mount: #{path}" if path.to_s.start_with?(SHARED_MOUNT)
29
+ end
30
+
31
+ reasons
32
+ end
33
+ end
34
+ end
35
+ end
@@ -0,0 +1,105 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module LLMExperiment
6
+ module Transcript
7
+ # Reads one trial directory and writes back what can be measured from it:
8
+ # events.jsonl (the transcript with unparsable lines dropped) and
9
+ # metrics.json (the summary the stats read).
10
+ class Parser
11
+ # Claude emits stream-json; Codex emits its own JSONL. Detect rather than
12
+ # configure, so a transcript can be parsed without knowing who produced it.
13
+ def self.detect_agent(events)
14
+ return "claude" if events.any? { |e| e["type"] == "system" && e["subtype"] == "init" }
15
+ return "codex" if events.any? { |e| e["type"].to_s.start_with?("item.", "turn.", "thread.") }
16
+
17
+ "unknown"
18
+ end
19
+
20
+ def initialize(experiment:)
21
+ @experiment = experiment
22
+ end
23
+
24
+ # Every trial that got as far as writing a transcript.
25
+ def trial_dirs
26
+ Dir.glob(File.join(@experiment.results_raw_dir, "**", "transcript.jsonl"))
27
+ .map { |p| File.dirname(p) }.sort
28
+ end
29
+
30
+ # Returns the summary it wrote, or nil for a directory with no transcript.
31
+ def parse(dir)
32
+ transcript = File.join(dir, "transcript.jsonl")
33
+ unless File.exist?(transcript)
34
+ Shell.log "skip #{dir}: no transcript.jsonl"
35
+ return nil
36
+ end
37
+
38
+ meta = read_meta(dir)
39
+ bad = 0
40
+ events = File.readlines(transcript).filter_map do |line|
41
+ JSON.parse(line)
42
+ rescue JSON::ParserError
43
+ bad += 1
44
+ nil
45
+ end
46
+
47
+ summary = summarize(events, meta)
48
+ summary["unparsable_lines"] = bad
49
+ summary["raw_events"] = events.size
50
+ summary.merge!(meta_fields(meta))
51
+ summary["trial_dir"] = dir.sub("#{@experiment.root}/", "")
52
+
53
+ File.write(File.join(dir, "events.jsonl"), "#{events.map(&:to_json).join("\n")}\n")
54
+ File.write(File.join(dir, "metrics.json"), JSON.pretty_generate(summary))
55
+ summary
56
+ end
57
+
58
+ private
59
+
60
+ def summarize(events, meta)
61
+ impl_files, test_file = ground_truth(meta)
62
+
63
+ case meta["agent"] || self.class.detect_agent(events)
64
+ when "claude" then Claude.summarize(events, impl_files: impl_files, test_file: test_file)
65
+ when "codex" then Codex.summarize(events, impl_files: impl_files)
66
+ else { "agent" => "unknown", "events" => events.size }
67
+ end
68
+ end
69
+
70
+ # experiment.yml is the fallback for impl_files/test_file. A trial recorded
71
+ # before the runner persisted them has neither in meta.json, and matching
72
+ # against an empty list makes tool_calls_to_first_defect_read nil for every
73
+ # row.
74
+ def ground_truth(meta)
75
+ task = task_for(meta["task_id"])
76
+ [Array(meta["impl_files"] || task&.impl_files), meta["test_file"] || task&.test_file]
77
+ end
78
+
79
+ def task_for(id)
80
+ return nil unless id
81
+
82
+ @experiment.task(id)
83
+ rescue ConfigError
84
+ nil
85
+ end
86
+
87
+ def read_meta(dir)
88
+ path = File.join(dir, "meta.json")
89
+ File.exist?(path) ? JSON.parse(File.read(path)) : {}
90
+ end
91
+
92
+ def meta_fields(meta)
93
+ {
94
+ "task_id" => meta["task_id"],
95
+ "condition" => meta["condition"],
96
+ "app" => meta["app"],
97
+ "fix_verified" => meta["fix_verified"],
98
+ "wall_seconds" => meta["wall_seconds"],
99
+ "timed_out" => meta["timed_out"],
100
+ "task_reproduces" => meta["task_reproduces"]
101
+ }
102
+ end
103
+ end
104
+ end
105
+ end
@@ -0,0 +1,27 @@
1
+ # frozen_string_literal: true
2
+
3
+ module LLMExperiment
4
+ # Turning a trial's raw JSONL transcript into measurements.
5
+ #
6
+ # The headline number an experiment like this is after is context growth
7
+ # BEFORE the first tool call. If a mention makes a CLI attach the file itself,
8
+ # the first request carries the file's tokens and no read is needed to get
9
+ # them; if the mention is inert text, the first request looks like the bare
10
+ # one and the file arrives later through a normal read. Those two shapes are
11
+ # distinguishable only because the per-agent summarizers keep per-turn token
12
+ # counts rather than only the totals.
13
+ module Transcript
14
+ module_function
15
+
16
+ # Does this tool call refer to the defect file?
17
+ #
18
+ # Matching on the full relative path rather than the basename: a basename
19
+ # would also fire on an unrelated grep for a common word, and the point of
20
+ # the metric is how many calls it took to arrive at the file, not how many
21
+ # mentioned a similar-sounding name. The whole serialised input is searched,
22
+ # so a Read with a file_path and a shell `cat <path>` both count.
23
+ def references_impl?(blob, impl_files)
24
+ impl_files.any? { |f| blob.include?(f) }
25
+ end
26
+ end
27
+ end