sfp 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.
@@ -0,0 +1,460 @@
1
+ module Sfp
2
+ class Planner
3
+ Heuristic = 'mixed' # lmcut, cg, cea, ff, mixed ([cg|cea|ff]=>lmcut)
4
+ Debug = false
5
+
6
+ class Config
7
+ # The timeout for the solver in seconds (default 600s/5mins)
8
+ @@timeout = 600
9
+
10
+ def self.timeout; @@timeout; end
11
+
12
+ def self.set_timeout(timeout); @@timeout = timeout; end
13
+
14
+ # The maximum memory that can be consumed by the solver
15
+ @@max_memory = 2048000 # (in K) -- default ~2GB
16
+
17
+ def self.max_memory; @@max_memory; end
18
+
19
+ def self.set_max_memory(memory); @@max_memory = memory; end
20
+ end
21
+
22
+
23
+ attr_accessor :debug
24
+ attr_reader :parser
25
+
26
+ def initialize(params={})
27
+ @debug = Debug
28
+ @parser = Sfp::Parser.new(params)
29
+ end
30
+
31
+ # @param :string : SFP task in string
32
+ # @param :sfp : SFP task in Hash data structure
33
+ # @param :file : SFP task in file with specified path
34
+ # @param :sas_plan : if true then return a raw SAS plan
35
+ # @param :parallel : if true then return a parallel (partial-order) plan
36
+ # @param :parallel : if false or nil then return a sequential plan
37
+ # @param :json : if true then return the plan in JSON
38
+ # @param :pretty_json : if true then return in pretty JSON
39
+ #
40
+ def solve(params={})
41
+ if params[:string].is_a?(String)
42
+ @parser.parse(string)
43
+ elsif params[:sfp].is_a?(Hash)
44
+ @parser.root = params[:sfp]
45
+ elsif params[:file].is_a?(String)
46
+ raise Exception, "File not found: #{params[:file]}" if not File.exist?(params[:file])
47
+ @parser.home_dir = File.expand_path(File.dirname(params[:file]))
48
+ @parser.parse(File.read(params[:file]))
49
+ end
50
+
51
+ if not @parser.conformant
52
+ return self.solve_classical_task(params)
53
+ else
54
+ return self.solve_conformant_task(params)
55
+ end
56
+ end
57
+
58
+ # @param :parallel : if true then return a parallel (partial-order) plan
59
+ # @param :parallel : if false or nil then return a sequential plan
60
+ # @param :json : if true then return the plan in JSON
61
+ # @param :pretty_json : if true then return in pretty JSON
62
+ #
63
+ def to_bsig(params={})
64
+ raise Exception, "Conformant task is not supported yet" if @parser.conformant
65
+
66
+ bsig = (params[:parallel] ? self.to_parallel_bsig : self.to_sequential_bsig)
67
+ return (params[:json] ? JSON.generate(bsig) :
68
+ (params[:pretty_json] ? JSON.pretty_generate(bsig) : bsig))
69
+ end
70
+
71
+ # @param :json : if true then return in JSON
72
+ # @param :pretty_json : if true then return in pretty JSON
73
+ #
74
+ def final_state(params={})
75
+ return nil if @plan.nil?
76
+ state = @sas_task.final_state
77
+ return (params[:json] ? JSON.generate(state) :
78
+ (params[:pretty_json] ? JSON.pretty_generate(state) : state))
79
+ end
80
+
81
+ protected
82
+ def solve_conformant_task(params={})
83
+ # TODO
84
+ # 1) generate all possible initial states
85
+ # remove states that do not satisfy the global constraint
86
+ def get_possible_partial_initial_states(init)
87
+ def combinators(variables, var_values, index=0, result={}, bucket=[])
88
+ if index >= variables.length
89
+ # collect
90
+ bucket << result.clone
91
+ else
92
+ var = variables[index]
93
+ var_values[var].each do |value|
94
+ result[var] = value
95
+ combinators(variables, var_values, index+1, result, bucket)
96
+ end
97
+ end
98
+ bucket
99
+ end
100
+ # collect variables with non-deterministic value
101
+ collector = Sfp::Visitor::ConformantVariables.new
102
+ init.accept(collector)
103
+ vars = collector.var_values.keys
104
+ combinators(vars, collector.var_values)
105
+ end
106
+
107
+ # 2) for each initial states, generate a plan (if possible)
108
+ # for given goal state
109
+ def get_possible_plans(partial_inits)
110
+ # TODO
111
+ solutions = []
112
+ partial_inits.each do |partial_init|
113
+ parser = Sfp::Parser.new
114
+ parser.root = Sfp::Helper.deep_clone(@parser.root)
115
+ init = parser.root['initial']
116
+ partial_init.each do |path,value|
117
+ parent, var = path.extract
118
+ parent = init.at?(parent)
119
+ parent[var] = value
120
+ end
121
+ plan, sas_task = self.solve_sas(parser)
122
+ solution = {:partial_init => partial_init,
123
+ :plan => plan,
124
+ :sas_task => sas_task}
125
+ solutions << solution
126
+ end
127
+ solutions
128
+ end
129
+
130
+ # 3) merge the plans into one
131
+ def merge_plans(solutions)
132
+ # TODO
133
+ solutions.each { |sol| puts sol[:partial_init].inspect + " => " + sol[:plan].inspect }
134
+ nil
135
+ end
136
+
137
+ partial_inits = get_possible_partial_initial_states(@parser.root['initial'])
138
+ solutions = get_possible_plans(partial_inits)
139
+ merged_plan = merge_plans(solutions)
140
+ end
141
+
142
+ def solve_classical_task(params={})
143
+ @plan, @sas_task = self.solve_sas(@parser)
144
+
145
+ return @plan if params[:sas_plan]
146
+
147
+ plan = (params[:parallel] ? self.get_parallel_plan : self.get_sequential_plan)
148
+ return (params[:json] ? JSON.generate(plan) :
149
+ (params[:pretty_json] ? JSON.pretty_generate(plan) : plan))
150
+ end
151
+
152
+ def bsig_template
153
+ return {'version' => 1, 'operators' => [], 'id' => Time.now.getutc.to_i, 'goal' => []}
154
+ end
155
+
156
+ def to_sequential_bsig
157
+ bsig = self.bsig_template
158
+ return bsig if @plan.length <= 0
159
+ plan = self.get_sequential_plan
160
+ bsig['operators'] = workflow = plan['workflow']
161
+ (workflow.length-1).downto(1) do |i|
162
+ op = workflow[i]
163
+ prev_op = workflow[i-1]
164
+ prev_op['effect'].each { |k,v| op['condition'][k] = v }
165
+ end
166
+ bsig['goal'], _ = self.bsig_goal_operator(workflow)
167
+ return bsig
168
+ end
169
+
170
+ def to_parallel_bsig
171
+ return nil if @plan.nil?
172
+ bsig = self.bsig_template
173
+ return bsig if @plan.length <= 0
174
+ plan = self.get_parallel_plan
175
+ # foreach operator's predecessors, add its effects to operator's conditions
176
+ bsig['operators'] = workflow = plan['workflow']
177
+ workflow.each do |op|
178
+ op['predecessors'].each do |pred|
179
+ pred_op = workflow[pred]
180
+ pred_op['effect'].each { |k,v| op['condition'][k] = v }
181
+ end
182
+ end
183
+ # remove unnecessary information
184
+ workflow.each do |op|
185
+ op.delete('id')
186
+ op.delete('predecessors')
187
+ op.delete('successors')
188
+ end
189
+ bsig['goal'], bsig['goal_operator'] = self.bsig_goal_operator(workflow)
190
+ return bsig
191
+ end
192
+
193
+ def bsig_goal_operator(workflow)
194
+ goal_op = {}
195
+ goal = {}
196
+ @sas_task.final_state.each do |g|
197
+ variable, value = @parser.variable_name_and_value(g[:id], g[:value])
198
+ # search a supporting operator
199
+ (workflow.length-1).downto(0) do |i|
200
+ if workflow[i]['effect'].has_key?(variable)
201
+ if workflow[i]['effect'][variable] == value
202
+ goal_op[variable] = workflow[i]['name']
203
+ goal[variable] = value
204
+ break
205
+ else
206
+ #Nuri::Util.debug "#{variable}=#{value} is not changing"
207
+ #Nuri::Util.debug value.inspect + ' == ' + workflow[i]['effect'][variable].inspect
208
+ end
209
+ end
210
+ end
211
+ end
212
+ return goal, goal_op
213
+ end
214
+
215
+ def get_sequential_plan
216
+ json = { 'type'=>'sequential', 'workflow'=>nil, 'version'=>'1', 'total'=>0 }
217
+ return json if @plan == nil
218
+ json['workflow'] = []
219
+ @plan.each do |line|
220
+ op_name = line[1, line.length-2].split(' ')[0]
221
+ operator = @parser.operators[op_name]
222
+ raise Exception, 'Cannot find operator: ' + op_name if operator == nil
223
+ json['workflow'] << operator.to_sfw
224
+ end
225
+ json['total'] = json['workflow'].length
226
+ return json
227
+ end
228
+
229
+ def get_parallel_plan
230
+ json = {'type'=>'parallel', 'workflow'=>nil, 'init'=>nil, 'version'=>'1', 'total'=>0}
231
+ return json if @plan == nil
232
+ json['workflow'], json['init'], json['total'] = @sas_task.get_partial_order_workflow(@parser)
233
+ return json
234
+ end
235
+
236
+ def extract_sas_plan(sas_plan, parser)
237
+ actions = Array.new
238
+ sas_plan.split("\n").each do |sas_operator|
239
+ op_name = sas_operator[1,sas_operator.length-2].split(' ')[0]
240
+ actions << Action.new(parser.operators[op_name])
241
+ end
242
+ end
243
+
244
+ def solve_sas(parser)
245
+ return nil if parser.nil?
246
+
247
+ tmp_dir = '/tmp/nuri_' + (rand * 100000).to_i.abs.to_s
248
+ begin
249
+ while File.exist?(tmp_dir)
250
+ tmp_dir = '/tmp/nuri_' + (rand * 100000).to_i.abs.to_s
251
+ end
252
+ Dir.mkdir(tmp_dir)
253
+ sas_file = tmp_dir + '/problem.sas'
254
+ plan_file = tmp_dir + '/out.plan'
255
+ File.open(sas_file, 'w') do |f|
256
+ f.write(parser.to_sas)
257
+ f.flush
258
+ end
259
+
260
+ if Heuristic == 'mixed'
261
+ mixed = MixedHeuristic.new(tmp_dir, sas_file, plan_file)
262
+ mixed.solve
263
+ else
264
+ command = Sfp::Planner.getcommand(tmp_dir, sas_file, plan_file, Heuristic)
265
+ Kernel.system(command)
266
+ end
267
+ plan = (File.exist?(plan_file) ? File.read(plan_file) : nil)
268
+
269
+ if plan != nil
270
+ plan = extract_sas_plan(plan, parser)
271
+ sas_task = Nuri::Sas::Task.new(sas_file)
272
+ sas_task.sas_plan = plan
273
+
274
+ tmp = []
275
+ goal_op = nil
276
+ plan.each do |op|
277
+ _, name, _ = op.split('-', 3)
278
+ goal_op = op if name == 'goal'
279
+ next if name == 'goal' or name == 'globalop' or name == 'sometime'
280
+ tmp.push(op)
281
+ end
282
+ sas_task.goal_operator_name = goal_op
283
+ plan = tmp
284
+ end
285
+
286
+ return plan, sas_task
287
+ rescue Exception => exp
288
+ raise exp
289
+ ensure
290
+ File.delete('plan_numbers_and_cost') if File.exist?('plan_numbers_and_cost')
291
+ Kernel.system('rm -rf ' + tmp_dir) if not @debug
292
+ end
293
+
294
+ return nil, nil
295
+ end
296
+
297
+ def self.path
298
+ os = `uname -s`.downcase.strip
299
+ planner = case os
300
+ when 'linux' then File.expand_path(File.dirname(__FILE__) + '/../../bin/solver/linux')
301
+ when 'macos', 'darwin' then File.expand_path(File.dirname(__FILE__) + '/../../bin/solver/macos')
302
+ else nil
303
+ end
304
+ raise UnsupportedPlatformException, os + ' is not supported' if planner == nil
305
+ planner
306
+ end
307
+
308
+ # Return the solver parameters based on given heuristic mode.
309
+ # Default value: FF
310
+ def self.parameters(heuristic='ff')
311
+ return case heuristic
312
+ when 'lmcut' then '--search "astar(lmcut())"'
313
+ when 'blind' then '--search "astar(blind())"'
314
+ when 'cg' then '--search "lazy_greedy(cg(cost_type=2))"'
315
+ when 'cea' then '--search "lazy_greedy(cea(cost_type=2))"'
316
+ when 'mad' then '--search "lazy_greedy(mad())"'
317
+ else '--search "lazy_greedy(ff(cost_type=0))"'
318
+ end
319
+ end
320
+
321
+ # Return a command to run the planner:
322
+ # - within given working directory "dir"
323
+ # - problem in SAS+ format, available in"sas_file"
324
+ # - solution will be saved in "plan_file"
325
+ def self.getcommand(dir, sas_file, plan_file, heuristic='ff', debug=false)
326
+ planner = Sfp::Planner.path
327
+ params = Sfp::Planner.parameters(heuristic)
328
+ timeout = Sfp::Planner::Config.timeout
329
+
330
+ os = `uname -s`.downcase.strip
331
+ command = case os
332
+ when 'linux'
333
+ then "cd #{dir}; " +
334
+ "ulimit -Sv #{Sfp::Planner::Config.max_memory}; " +
335
+ "#{planner}/preprocess < #{sas_file} 2>/dev/null 1>/dev/null; " +
336
+ "if [ -f 'output' ]; then " +
337
+ "timeout #{timeout} nice #{planner}/downward #{params} " +
338
+ "--plan-file #{plan_file} < output; fi"
339
+ when 'macos', 'darwin'
340
+ then "cd #{dir}; " +
341
+ "ulimit -Sv #{Sfp::Planner::Config.max_memory}; " +
342
+ "#{planner}/preprocess < #{sas_file} 1> /dev/null; " +
343
+ "#{planner}/downward #{params} " +
344
+ "--plan-file #{plan_file} < output 1> /dev/null;"
345
+ else nil
346
+ end
347
+
348
+ if not command.nil? and (os == 'linux' or os == 'macos' or os == 'darwin')
349
+ command = "#{command} 1> /dev/null 2>/dev/null"
350
+ end
351
+
352
+ command
353
+ end
354
+
355
+ # Combination between two heuristic to obtain a suboptimal plan.
356
+ # 1) solve the problem with CG/CEA/FF, that will produce (usually) a non-optimal plan
357
+ # 2) remove actions which are not selected by previous step
358
+ # 3) solve the problem with LMCUT using A*-search to obtain a sub-optimal plan
359
+ class MixedHeuristic
360
+ def initialize(dir, sas_file, plan_file)
361
+ @dir = dir
362
+ @sas_file = sas_file
363
+ @plan_file = plan_file
364
+ end
365
+
366
+ def solve
367
+ # 1) solve with FF
368
+ planner1 = Sfp::Planner.getcommand(@dir, @sas_file, @plan_file, 'ff')
369
+ Kernel.system(planner1)
370
+ # 1b) if not found, try CEA
371
+ if not File.exist?(@plan_file)
372
+ planner2 = Sfp::Planner.getcommand(@dir, @sas_file, @plan_file, 'cea')
373
+ Kernel.system(planner2)
374
+ end
375
+ # 1c) if not found, try CG
376
+ if not File.exists?(@plan_file)
377
+ planner3 = Sfp::Planner.getcommand(@dir, @sas_file, @plan_file, 'cg')
378
+ Kernel.system(planner3)
379
+ return false if not File.exist?(@plan_file)
380
+ end
381
+
382
+ # 2) remove unselected operators
383
+ new_sas = @sas_file + '.2'
384
+ new_plan = @plan_file + '.2'
385
+ self.filter_operators(@sas_file, @plan_file, new_sas)
386
+
387
+ # 3) generate the final plan with LMCUT
388
+ lmcut = Sfp::Planner.getcommand(@dir, new_sas, new_plan, 'lmcut')
389
+ Kernel.system(lmcut)
390
+
391
+ # LMCUT cannot find the sub-optimized plan
392
+ File.delete(@plan_file)
393
+ File.rename(new_plan, @plan_file) if File.exist?(new_plan)
394
+
395
+ true
396
+ end
397
+
398
+ def filter_operators(sas, plan, new_sas)
399
+ # generate the selected actions
400
+ selected = []
401
+ File.read(plan).each_line do |line|
402
+ line.strip!
403
+ line = line[1, line.length-2]
404
+ selected << line.split(' ', 2)[0]
405
+ end
406
+
407
+ # remove unselected operators
408
+ output = ""
409
+ operator = nil
410
+ id = nil
411
+ total_op = false
412
+ counter = 0
413
+ File.read(sas).each_line do |line|
414
+ if line =~ /^end_goal/
415
+ total_op = true
416
+ elsif total_op
417
+ output += "__TOTAL_OPERATOR__\n"
418
+ total_op = false
419
+ next
420
+ end
421
+
422
+ if line =~ /^begin_operator/
423
+ operator = ""
424
+ id = nil
425
+ elsif line =~ /^end_operator/
426
+ if not selected.index(id).nil?
427
+ output += "begin_operator\n#{operator}end_operator\n"
428
+ counter += 1
429
+ end
430
+ operator = nil
431
+ id = nil
432
+ elsif operator.nil?
433
+ output += line
434
+ else
435
+ id = line.split(' ', 2)[0] if id.nil?
436
+ operator += line
437
+ end
438
+ end
439
+
440
+ # replace total operator
441
+ output.sub!(/__TOTAL_OPERATOR__/, counter.to_s)
442
+
443
+ # save filtered problem
444
+ File.open(new_sas, 'w') { |f| f.write(output) }
445
+ end
446
+ end
447
+
448
+ class Action
449
+ attr_accessor :operator, :predecessor
450
+
451
+ def initialize(operator)
452
+ @operator = operator
453
+ @predecessor = Array.new
454
+ end
455
+ end
456
+
457
+ class UnsupportedPlatformException < Exception
458
+ end
459
+ end
460
+ end