easy_ml 0.2.0.pre.rc41 → 0.2.0.pre.rc43

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 03333c45a1103bf7e75446a0c54d6799b62e64646abc1ab2abac123a206d1424
4
- data.tar.gz: cb5ba985a5b8e5fd136b92e5ca5f65162d5189f06ba91bdd6e6763a69f5fbe56
3
+ metadata.gz: 6fc7f7afffd4e61312ab0302fb20e40a0eed3dcde05e16e9ea9ed1f523cb8681
4
+ data.tar.gz: d39511d86a3e01bc4ececcc20e1f7d0a583ac2eb07c8752c4ca2766b38318343
5
5
  SHA512:
6
- metadata.gz: ddd88ca06fecf8366a7e3fa370b2f78e0e73ebc4c29fefcdd6cd0b208286710d503b767d6fabcf9af5eb40105bb0ffe63ddd773278b0cc9379750dfb4763d87f
7
- data.tar.gz: 0c3b8dfdb6d293692439a1818dca5fe1974e27039895e54f08e492bb621c563a77cc4e0921f1df58603526a0b95354741684052aa9f1df8928e5b54de6f8caac
6
+ metadata.gz: 81f8594d04fcaa7274e7fc1a20ff8f67bd03599988f43e8377d10734639eb3aa48b8516f0422c7ca496774c2922e1e6042105cea72a7bad01c3bf847a553f636
7
+ data.tar.gz: d5c7cf29044fe2ac184b88ae2b68f1b9c12550157c331f263eb2e3ee79225c40245736c2531610141138e58753ffeaa257145052c02aa6fbe7598cafb0699a78
@@ -29,11 +29,10 @@ module EasyML
29
29
  EasyML::Configuration.configure do |config|
30
30
  config.storage = @settings.storage
31
31
  config.timezone = @settings.timezone
32
- config.s3_access_key_id = @settings.s3_access_key_id
33
- config.s3_secret_access_key = @settings.s3_secret_access_key
34
32
  config.s3_bucket = @settings.s3_bucket
35
33
  config.s3_region = @settings.s3_region
36
34
  config.s3_prefix = @settings.s3_prefix
35
+ config.wandb_api_key = @settings.wandb_api_key
37
36
  end
38
37
  flash.now[:notice] = "Settings saved."
39
38
  render inertia: "pages/SettingsPage", props: {
@@ -47,8 +46,6 @@ module EasyML
47
46
  params.require(:settings).permit(
48
47
  :storage,
49
48
  :timezone,
50
- :s3_access_key_id,
51
- :s3_secret_access_key,
52
49
  :s3_bucket,
53
50
  :s3_region,
54
51
  :s3_prefix,
@@ -1,7 +1,7 @@
1
1
  import React, { useState } from 'react';
2
2
  import { usePage } from '@inertiajs/react'
3
3
  import { useInertiaForm } from 'use-inertia-form';
4
- import { Settings2, Save, AlertCircle, Key, Database, Globe2 } from 'lucide-react';
4
+ import { Settings2, Save, AlertCircle, Key, Globe2, Database } from 'lucide-react';
5
5
  import { PluginSettings } from '../components/settings/PluginSettings';
6
6
 
7
7
  interface Settings {
@@ -9,9 +9,6 @@ interface Settings {
9
9
  timezone: string;
10
10
  s3_bucket: string;
11
11
  s3_region: string;
12
- s3_access_key_id: string;
13
- s3_secret_access_key: string;
14
- wandb_api_key: string;
15
12
  }
16
13
  }
17
14
 
@@ -88,7 +85,6 @@ export default function SettingsPage({ settings: initialSettings }: { settings:
88
85
  <select
89
86
  id="timezone"
90
87
  value={formData.settings.timezone}
91
-
92
88
  onChange={(e) => setFormData({
93
89
  ...formData,
94
90
  settings: {
@@ -113,7 +109,6 @@ export default function SettingsPage({ settings: initialSettings }: { settings:
113
109
  {/* S3 Configuration */}
114
110
  <div className="space-y-4">
115
111
  <div className="flex items-center gap-2 mb-4">
116
-
117
112
  <Database className="w-5 h-5 text-gray-500" />
118
113
  <h3 className="text-lg font-medium text-gray-900">S3 Configuration</h3>
119
114
  </div>
@@ -162,80 +157,6 @@ export default function SettingsPage({ settings: initialSettings }: { settings:
162
157
  </select>
163
158
  </div>
164
159
  </div>
165
-
166
- <div className="bg-blue-50 rounded-lg p-4">
167
- <div className="flex gap-2">
168
- <AlertCircle className="w-5 h-5 text-blue-500 mt-0.5" />
169
- <div>
170
- <h4 className="text-sm font-medium text-blue-900">AWS Credentials</h4>
171
- <p className="mt-1 text-sm text-blue-700">
172
- These credentials will be used as default for all S3 operations. You can override them per datasource.
173
- </p>
174
- </div>
175
- </div>
176
- </div>
177
-
178
- <div className="grid grid-cols-2 gap-6">
179
- <div>
180
- <label htmlFor="accessKeyId" className="block text-sm font-medium text-gray-700 mb-1">
181
- Access Key ID
182
- </label>
183
- <div className="relative">
184
- <Key className="absolute left-3 top-1/2 transform -translate-y-1/2 w-4 h-4 text-gray-400" />
185
- <input
186
- type="text"
187
- id="accessKeyId"
188
- value={formData.settings.s3_access_key_id}
189
- onChange={(e) => setFormData({
190
- ...formData,
191
- settings: {
192
- ...formData.settings,
193
- s3_access_key_id: e.target.value
194
- }
195
- })}
196
- className="mt-1 block w-full pl-9 rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500"
197
- placeholder="AKIA..."
198
- />
199
- </div>
200
- </div>
201
-
202
- <div>
203
- <label htmlFor="secretAccessKey" className="block text-sm font-medium text-gray-700 mb-1">
204
- Secret Access Key
205
- </label>
206
- <div className="relative">
207
- <Key className="absolute left-3 top-1/2 transform -translate-y-1/2 w-4 h-4 text-gray-400" />
208
- <input
209
- type={showSecretKey ? 'text' : 'password'}
210
- id="secretAccessKey"
211
- value={formData.settings.s3_secret_access_key}
212
- onChange={(e) => setFormData({
213
- ...formData,
214
- settings: {
215
- ...formData.settings,
216
- s3_secret_access_key: e.target.value
217
- }
218
- })}
219
- className="mt-1 block w-full pl-9 pr-24 rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500"
220
- placeholder="Your secret key"
221
- />
222
- <button
223
- type="button"
224
- onClick={() => setShowSecretKey(!showSecretKey)}
225
- className="absolute right-2 top-1/2 transform -translate-y-1/2 text-sm text-gray-500 hover:text-gray-700"
226
- >
227
- {showSecretKey ? 'Hide' : 'Show'}
228
- </button>
229
- </div>
230
- </div>
231
- </div>
232
- </div>
233
-
234
- <div className="border-t border-gray-200 pt-8">
235
- <PluginSettings
236
- settings={formData.settings}
237
- setData={(settings) => setFormData({ ...settings })}
238
- />
239
160
  </div>
240
161
 
241
162
  <div className="pt-6 border-t flex items-center justify-between">
@@ -12,7 +12,14 @@ module EasyML
12
12
  # E.g. EasyML::ComputeFeatureBatchJob.enqueue_batch(features.map(&:id))
13
13
  #
14
14
  def enqueue_batch(args_list, batch_id = default_batch_id)
15
- args_list = args_list.map { |arg| arg.is_a?(Array) ? arg : [arg] }
15
+ args_list = args_list.map do |arg|
16
+ arg = arg.is_a?(Array) ? arg : [arg]
17
+ arg.map do |arg|
18
+ arg.merge!(
19
+ batch_id: batch_id,
20
+ )
21
+ end
22
+ end
16
23
  store_batch_arguments(batch_id, args_list)
17
24
 
18
25
  args_list.each do |args|
@@ -22,8 +29,45 @@ module EasyML
22
29
  batch_id
23
30
  end
24
31
 
32
+ def enqueue_ordered_batches(args_list)
33
+ parent_id = get_parent_batch_id(args_list)
34
+ store_batch_arguments(parent_id, args_list)
35
+
36
+ batch = args_list.first
37
+ rest = args_list[1..]
38
+
39
+ rest.map do |batch|
40
+ Resque.redis.rpush("batch:#{parent_id}:remaining", batch.to_json)
41
+ end
42
+
43
+ enqueue_batch(batch)
44
+ end
45
+
46
+ def enqueue_next_batch(caller, parent_id)
47
+ next_batch = Resque.redis.lpop("batch:#{parent_id}:remaining")
48
+ payload = Resque.decode(next_batch)
49
+
50
+ caller.enqueue_batch(payload)
51
+ end
52
+
53
+ def next_batch?(parent_id)
54
+ batches_remaining(parent_id) > 0
55
+ end
56
+
57
+ def batches_remaining(parent_id)
58
+ Resque.redis.llen("batch:#{parent_id}:remaining")
59
+ end
60
+
61
+ def cleanup_batch(parent_id)
62
+ Resque.redis.del("batch:#{parent_id}:remaining")
63
+ end
64
+
25
65
  private
26
66
 
67
+ def get_parent_batch_id(args_list)
68
+ args_list.dup.flatten.first.dig(:parent_batch_id)
69
+ end
70
+
27
71
  # Store batch arguments in Redis
28
72
  def store_batch_arguments(batch_id, args_list)
29
73
  redis_key = "#{batch(batch_id)}:original_args"
@@ -5,7 +5,6 @@ module EasyML
5
5
  @queue = :easy_ml
6
6
 
7
7
  def self.perform(batch_id, options = {})
8
- puts "processing batch_id #{batch_id}"
9
8
  options.symbolize_keys!
10
9
  feature_id = options.dig(:feature_id)
11
10
  feature = EasyML::Feature.find(feature_id)
@@ -18,13 +17,12 @@ module EasyML
18
17
  end
19
18
 
20
19
  begin
20
+ feature.update(workflow_status: :analyzing) if feature.workflow_status == :ready
21
21
  feature.fit_batch(options.merge!(batch_id: batch_id))
22
22
  rescue => e
23
- puts "Error computing feature: #{e.message}"
24
23
  EasyML::Feature.transaction do
25
24
  return if dataset.reload.workflow_status == :failed
26
25
 
27
- puts "Logging error"
28
26
  feature.update(workflow_status: :failed)
29
27
  dataset.update(workflow_status: :failed)
30
28
  build_error_with_context(dataset, e, batch_id, feature)
@@ -42,12 +40,25 @@ module EasyML
42
40
 
43
41
  def self.after_batch_hook(batch_id, *args)
44
42
  puts "After batch!"
45
- feature_ids = fetch_batch_arguments(batch_id).flatten.map(&:symbolize_keys).pluck(:feature_id).uniq
46
- dataset = EasyML::Feature.find_by(id: feature_ids.first).dataset
47
- dataset.after_fit_features
48
- end
43
+ batch_args = fetch_batch_arguments(batch_id).flatten.map(&:symbolize_keys)
44
+ feature_ids = batch_args.pluck(:feature_id).uniq
45
+ parent_id = batch_args.pluck(:parent_batch_id).first
46
+
47
+ feature = EasyML::Feature.find_by(id: feature_ids.first)
48
+
49
+ if feature.failed?
50
+ dataset.features.where(workflow_status: :analyzing).update_all(workflow_status: :ready)
51
+ return BatchJob.cleanup_batch(parent_id)
52
+ end
53
+
54
+ feature.after_fit
49
55
 
50
- def self.feature_fully_processed?(feature)
56
+ if BatchJob.next_batch?(parent_id)
57
+ BatchJob.enqueue_next_batch(self, parent_id)
58
+ else
59
+ dataset = EasyML::Feature.find_by(id: feature_ids.first).dataset
60
+ dataset.after_fit_features
61
+ end
51
62
  end
52
63
 
53
64
  private
@@ -196,12 +196,15 @@ module EasyML
196
196
  max_id = df[primary_key.last].max
197
197
  end
198
198
 
199
- (min_id..max_id).step(batch_size).map do |batch_start|
199
+ (min_id..max_id).step(batch_size).map.with_index do |batch_start, idx|
200
200
  batch_end = [batch_start + batch_size, max_id + 1].min - 1
201
201
  {
202
202
  feature_id: id,
203
203
  batch_start: batch_start,
204
204
  batch_end: batch_end,
205
+ batch_number: feature_position,
206
+ subbatch_number: idx,
207
+ parent_batch_id: Random.uuid,
205
208
  }
206
209
  end
207
210
  end
@@ -211,17 +214,16 @@ module EasyML
211
214
  end
212
215
 
213
216
  def fit(features: [self], async: false)
214
- # Sort features by position to ensure they're processed in order
215
- features.update_all(workflow_status: :analyzing)
216
217
  ordered_features = features.sort_by(&:feature_position)
217
- jobs = ordered_features.flat_map(&:build_batches)
218
+ jobs = ordered_features.map(&:build_batches)
218
219
 
219
220
  if async
220
- EasyML::ComputeFeatureJob.enqueue_batch(jobs)
221
+ EasyML::ComputeFeatureJob.enqueue_ordered_batches(jobs)
221
222
  else
222
- jobs.each do |job|
223
+ jobs.flatten.each do |job|
223
224
  EasyML::ComputeFeatureJob.perform(nil, job)
224
225
  end
226
+ features.each(&:after_fit) unless features.any?(&:failed?)
225
227
  end
226
228
  end
227
229
 
@@ -393,13 +395,11 @@ module EasyML
393
395
  updates = {
394
396
  applied_at: Time.current,
395
397
  needs_fit: false,
398
+ workflow_status: :ready,
396
399
  }.compact
397
400
  update!(updates)
398
401
  end
399
402
 
400
- def fully_processed?
401
- end
402
-
403
403
  private
404
404
 
405
405
  def bulk_update_positions(features)
@@ -250,6 +250,7 @@ module EasyML
250
250
  bump_version(force: true)
251
251
  path = model_file.full_path(version)
252
252
  full_path = adapter.save_model_file(path)
253
+ puts "saving model to #{full_path}"
253
254
  model_file.upload(full_path)
254
255
 
255
256
  model_file.save
@@ -266,6 +267,7 @@ module EasyML
266
267
  end
267
268
 
268
269
  def cleanup
270
+ puts "keeping files #{files_to_keep}"
269
271
  get_model_file&.cleanup(files_to_keep)
270
272
  end
271
273
 
@@ -488,13 +490,9 @@ module EasyML
488
490
  end
489
491
 
490
492
  def root_dir
491
- persisted = read_attribute(:root_dir)
493
+ relative_dir = read_attribute(:root_dir) || default_root_dir
492
494
 
493
- if persisted.present? && !persisted.blank?
494
- EasyML::Engine.root_dir.join(persisted).to_s
495
- else
496
- default_root_dir
497
- end
495
+ EasyML::Engine.root_dir.join(relative_dir).to_s
498
496
  end
499
497
 
500
498
  def default_root_dir
@@ -33,12 +33,23 @@ module EasyML
33
33
  s3_region: s3_region,
34
34
  s3_access_key_id: s3_access_key_id,
35
35
  s3_secret_access_key: s3_secret_access_key,
36
- root_dir: full_dir,
36
+ root_dir: root_dir,
37
37
  )
38
38
  end
39
39
 
40
40
  def root_dir
41
- model.root_dir
41
+ Pathname.new(model.root_dir)
42
+ end
43
+
44
+ def model_root
45
+ File.expand_path("..", root_dir.to_s)
46
+ end
47
+
48
+ def full_path(filename = nil)
49
+ filename = self.filename if filename.nil?
50
+ return nil if filename.nil?
51
+
52
+ root_dir.join(filename).to_s
42
53
  end
43
54
 
44
55
  def exist?
@@ -58,33 +69,7 @@ module EasyML
58
69
 
59
70
  def upload(path)
60
71
  synced_file.upload(path)
61
- set_path(path)
62
- end
63
-
64
- def set_path(path)
65
- path = get_full_path(path)
66
- basename = Pathname.new(path).basename.to_s
67
- unless path.start_with?(full_dir)
68
- new_path = File.join(full_dir, basename).to_s
69
- FileUtils.mkdir_p(Pathname.new(new_path).dirname.to_s)
70
- FileUtils.cp(path, new_path)
71
- path = new_path
72
- end
73
- self.filename = basename
74
- self.path = get_relative_path(path)
75
- end
76
-
77
- def get_full_path(path)
78
- path = path.to_s
79
-
80
- path = Rails.root.join(path) unless path.match?(Regexp.new(Rails.root.to_s))
81
- path
82
- end
83
-
84
- def get_relative_path(path)
85
- path = path.to_s
86
- path = path.to_s.split(Rails.root.to_s).last
87
- path.to_s.split("/")[0..-2].reject(&:empty?).join("/")
72
+ update(filename: Pathname.new(path).basename.to_s)
88
73
  end
89
74
 
90
75
  def download
@@ -98,26 +83,6 @@ module EasyML
98
83
  Digest::SHA256.file(full_path).hexdigest
99
84
  end
100
85
 
101
- def full_path(filename = nil)
102
- filename = self.filename if filename.nil?
103
- return nil if filename.nil?
104
- return nil if relative_dir.nil?
105
-
106
- Rails.root.join(relative_dir, filename).to_s
107
- end
108
-
109
- def relative_dir
110
- root_dir.to_s.gsub(Regexp.new(Rails.root.to_s), "").gsub(%r{^/}, "")
111
- end
112
-
113
- def full_dir
114
- Rails.root.join(relative_dir).to_s
115
- end
116
-
117
- def model_root
118
- File.expand_path("..", full_dir)
119
- end
120
-
121
86
  def cleanup!
122
87
  [model_root].each do |dir|
123
88
  EasyML::Support::FileRotate.new(dir, []).cleanup(extension_allowlist)
@@ -5,7 +5,12 @@ module EasyML
5
5
  include JSONAPI::Serializer
6
6
 
7
7
  attribute :prediction do |object|
8
- object.prediction_value.symbolize_keys.dig(:value)
8
+ case object.prediction_value
9
+ when Hash
10
+ object.prediction_value.symbolize_keys.dig(:value)
11
+ when Numeric
12
+ object.prediction_value
13
+ end
9
14
  end
10
15
 
11
16
  attributes :id,
@@ -225,7 +225,7 @@ module EasyML
225
225
 
226
226
  # Filter out any datetime columns, and use maybe_convert_date to convert later
227
227
  date_cols = (filtered[:dtypes] || {}).select { |k, v| v.class == Polars::Datetime }.keys
228
- filtered[:dtypes] = (filtered[:dtypes] || {}).reject { |k, v| v.class == Polars::Datetime }
228
+ filtered[:dtypes] = (filtered[:dtypes] || {}).reject { |k, v| v.class == Polars::Datetime }.compact.to_h
229
229
  filtered = filtered.select { |k, _| supported_params.include?(k) }
230
230
  return filtered, date_cols
231
231
  end
@@ -88,6 +88,17 @@ module EasyML
88
88
  end
89
89
  end
90
90
 
91
+ initializer "easy_ml.configure_secrets" do
92
+ EasyML::Configuration.configure do |config|
93
+ raise "S3_ACCESS_KEY_ID is missing. Set ENV['S3_ACCESS_KEY_ID']" unless ENV["S3_ACCESS_KEY_ID"]
94
+ raise "S3_SECRET_ACCESS_KEY is missing. Set ENV['S3_SECRET_ACCESS_KEY']" unless ENV["S3_SECRET_ACCESS_KEY"]
95
+
96
+ config.s3_access_key_id = ENV["S3_ACCESS_KEY_ID"]
97
+ config.s3_secret_access_key = ENV["S3_SECRET_ACCESS_KEY"]
98
+ config.wandb_api_key = ENV["WANDB_API_KEY"] if ENV["WANDB_API_KEY"]
99
+ end
100
+ end
101
+
91
102
  initializer "easy_ml.setup_generators" do |app|
92
103
  generators_path = EasyML::Engine.root.join("lib/easy_ml/railtie/generators")
93
104
  generators_dirs = Dir[File.join(generators_path, "**", "*.rb")]
@@ -10,23 +10,38 @@ module EasyML
10
10
  @models = {}
11
11
  end
12
12
 
13
- def self.predict(model_name, df)
13
+ def self.predict(model_name, df, serialize: false)
14
14
  if df.is_a?(Hash)
15
15
  df = Polars::DataFrame.new(df)
16
16
  end
17
- raw_input = df.to_hashes&.first
17
+ raw_input = df.to_hashes
18
18
  df = instance.normalize(model_name, df)
19
+ normalized_input = df.to_hashes
19
20
  preds = instance.predict(model_name, df)
20
21
  current_version = instance.get_model(model_name)
21
22
 
22
- EasyML::Prediction.create!(
23
- model: current_version.model,
24
- model_history: current_version,
25
- prediction_type: current_version.model.task,
26
- prediction_value: preds.first,
27
- raw_input: raw_input,
28
- normalized_input: df.to_hashes&.first,
29
- )
23
+ output = preds.zip(raw_input, normalized_input).map do |pred, raw, norm|
24
+ EasyML::Prediction.create!(
25
+ model: current_version.model,
26
+ model_history: current_version,
27
+ prediction_type: current_version.model.task,
28
+ prediction_value: pred,
29
+ raw_input: raw,
30
+ normalized_input: norm,
31
+ )
32
+ end
33
+
34
+ output = if output.is_a?(Array) && output.count == 1
35
+ output.first
36
+ else
37
+ output
38
+ end
39
+
40
+ if serialize
41
+ EasyML::PredictionSerializer.new(output).serializable_hash
42
+ else
43
+ output
44
+ end
30
45
  end
31
46
 
32
47
  def self.train(model_name, tuner: nil, evaluator: nil)
@@ -39,6 +39,7 @@ module EasyML
39
39
  create_easy_ml_predictions
40
40
  create_easy_ml_event_contexts
41
41
  add_workflow_status_to_easy_ml_features
42
+ drop_path_from_easy_ml_model_files
42
43
  ].freeze
43
44
 
44
45
  # Specify the next migration number
@@ -4,5 +4,10 @@ class AddWorkflowStatusToEasyMLFeatures < ActiveRecord::Migration[<%= ActiveReco
4
4
  add_column :easy_ml_features, :workflow_status, :string
5
5
  add_index :easy_ml_features, :workflow_status
6
6
  end
7
+
8
+ unless column_exists?(:easy_ml_feature_histories, :workflow_status)
9
+ add_column :easy_ml_feature_histories, :workflow_status, :string
10
+ add_index :easy_ml_feature_histories, :workflow_status
11
+ end
7
12
  end
8
13
  end
@@ -32,6 +32,7 @@ class CreateEasyMLRetrainingJobs < ActiveRecord::Migration[<%= ActiveRecord::Mig
32
32
  t.index :auto_deploy
33
33
  t.index :tuning_enabled
34
34
  end
35
+ end
35
36
 
36
37
  unless table_exists?(:easy_ml_retraining_runs)
37
38
  create_table :easy_ml_retraining_runs do |t|
@@ -0,0 +1,11 @@
1
+ class DropPathFromEasyMLModelFiles < ActiveRecord::Migration[<%= ActiveRecord::Migration.current_version %>]
2
+ def change
3
+ if column_exists?(:easy_ml_model_files, :path)
4
+ remove_column :easy_ml_model_files, :path
5
+ end
6
+
7
+ if column_exists?(:easy_ml_model_file_histories, :path)
8
+ remove_column :easy_ml_model_file_histories, :path
9
+ end
10
+ end
11
+ end
@@ -1,7 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module EasyML
4
- VERSION = "0.2.0-rc41"
4
+ VERSION = "0.2.0-rc43"
5
5
 
6
6
  module Version
7
7
  end
@@ -1,11 +1,11 @@
1
1
  {
2
2
  "entrypoints/Application.tsx": {
3
- "file": "assets/entrypoints/Application.tsx-DF5SSkYi.js",
3
+ "file": "assets/entrypoints/Application.tsx-jPsqOyb0.js",
4
4
  "name": "entrypoints/Application.tsx",
5
5
  "src": "entrypoints/Application.tsx",
6
6
  "isEntry": true,
7
7
  "css": [
8
- "assets/Application-Cu7lNJmG.css"
8
+ "assets/Application-zpGA_Q9c.css"
9
9
  ]
10
10
  }
11
11
  }