File Coverage

blib/lib/Attean/API/QueryPlanner.pm
Criterion Covered Total %
statement 306 336 91.0
branch 70 102 68.6
condition 6 15 40.0
subroutine 38 38 100.0
pod 0 7 0.0
total 420 498 84.3


line stmt bran cond sub pod time code
1 50     50   657 use v5.14;
  50         154  
2 50     50   242 use warnings;
  50         112  
  50         2277  
3              
4             =head1 NAME
5              
6             Attean::API::IDPJoinPlanner - Iterative dynamic programming query planning role
7              
8             =head1 VERSION
9              
10             This document describes Attean::API::IDPJoinPlanner version 0.033
11              
12             =head1 SYNOPSIS
13              
14             extends 'Attean::QueryPlanner';
15             with 'Attean::API::IDPJoinPlanner';
16              
17             =head1 DESCRIPTION
18              
19             The Attean::API::IDPJoinPlanner role provides a query planner the
20             C<< joins_for_plan_alternatives >> method, as well as the cost estimation
21             methods that consume the L<Attean::API::CostPlanner> role.
22              
23             =head1 ATTRIBUTES
24              
25             =over 4
26              
27             =back
28              
29             =head1 METHODS
30              
31             =over 4
32              
33             =cut
34              
35             use Types::Standard qw(CodeRef Object);
36 50     50   275  
  50         87  
  50         381  
37             use Moo::Role;
38 50     50   26767
  50         107  
  50         363  
39             requires 'plan_for_algebra'; # plan_for_algebra($algebra, $model, \@default_graphs)
40             has 'request_signer' => (is => 'rw', isa => Object);
41             }
42              
43             use Scalar::Util qw(refaddr);
44             use Types::Standard qw(CodeRef);
45 50     50   19702  
  50         133  
  50         2685  
46 50     50   344 use Moo::Role;
  50         117  
  50         270  
47             use namespace::clean;
48 50     50   19461 with 'Attean::API::QueryPlanner';
  50         101  
  50         193  
49 50     50   15103
  50         112  
  50         437  
50             requires 'plans_for_algebra'; # plans_for_algebra($algebra, $model, \@active_graphs, \@default_graphs)
51             requires 'cost_for_plan'; # cost_for_plan($plan, $model)
52            
53             before 'cost_for_plan' => sub {
54             my $self = shift;
55             my $plan = shift;
56             my $model = shift;
57            
58             if (refaddr($self) == refaddr($model)) {
59             Carp::confess "Model and planner objects cannot be the same in call to cost_for_plan";
60             } elsif ($self->does('Attean::API::Model') and $model->does('Attean::API::Model')) {
61             Carp::confess "Model and planner objects cannot both consume Attean::API::Model in call to cost_for_plan";
62             }
63             };
64            
65             my $self = shift;
66             my $algebra = shift;
67             my $model = shift;
68 41     41 0 23810 my $default_graphs = shift;
69 41         63 my $active_graphs = $default_graphs;
70 41         66 my @plans = sort { $self->cost_for_plan($a, $model) <=> $self->cost_for_plan($b, $model) } $self->plans_for_algebra($algebra, $model, $active_graphs, $default_graphs);
71 41         58 my $plan = shift(@plans);
72 41         58 return $plan;
73 41         177 }
  93         1517  
74 41         884 }
75 41         278  
76             use Moo::Role;
77             requires 'joins_for_plan_alternatives';
78             }
79              
80 50     50   39963 use Math::Cartesian::Product;
  50         109  
  50         205  
81              
82             use Moo::Role;
83              
84             with 'Attean::API::JoinPlanner';
85 50     50   16960 with 'Attean::API::QueryPlanner';
  50         141  
  50         2428  
86              
87 50     50   302 my $self = shift;
  50         100  
  50         247  
88             my $model = shift;
89             my $active_graphs = shift;
90             my $default_graphs = shift;
91             my $interesting = shift;
92             my @args = @_; # each $args[$i] here is an array reference containing alternate plans for element $i
93 47     47 0 76
94 47         61 my $plans = shift(@args);
95 47         58 while (scalar(@args)) {
96 47         66 my $next = shift(@args);
97 47         72 my @plans = $self->join_plans($model, $active_graphs, $default_graphs, $plans, $next, 'inner');
98 47         77 $plans = \@plans;
99             }
100 47         73
101 47         112 my @plans = @$plans;
102 35         55 return @plans;
103 35         183 }
104 35         119 }
105              
106             use Types::Standard qw(Int);
107 47         122 use Scalar::Util qw(blessed);
108 47         178  
109             use Moo::Role;
110              
111             with 'Attean::API::CostPlanner';
112             with 'MooX::Log::Any';
113 50     50   22925  
  50         122  
  50         309  
114 50     50   21082 has 'keep' => (is => 'ro', isa => Int, default => 5);
  50         106  
  50         2061  
115            
116 50     50   280 around 'joins_for_plan_alternatives' => sub {
  50         139  
  50         218  
117             my $orig = shift;
118             my $self = shift;
119             my $model = shift;
120             my @plans = $orig->($self, $model, @_);
121             return $self->prune_plans($model, [], \@plans);
122             };
123            
124             my $self = shift;
125             my $model = shift;
126             my $interesting = shift;
127             my @plans = @{ shift || [] };
128             no sort 'stable';
129             my @sorted = map { $_->[1] } sort { $a->[0] <=> $b->[0] } map { [$self->cost_for_plan($_, $model), $_] } @plans;
130            
131             return ($self->keep) ? splice(@sorted, 0, $self->keep) : @sorted;
132 26     26 0 36 }
133 26         27
134 26         29 my $self = shift;
135 26 50       32 my $plan = shift;
  26         69  
136 50     50   48127 my $model = shift;
  50         25294  
  50         281  
137 26         52 Carp::confess "No model given" unless (blessed($model) and $model->does('Attean::API::Model'));
  691         834  
  3668         3532  
  691         9474  
138            
139 26 50       1428 if ($plan->has_cost) {
140             return $plan->cost;
141             } else {
142             if ($model->does('Attean::API::CostPlanner')) {
143 2082     2082 0 96453 if (defined(my $cost = $model->cost_for_plan($plan, $self))) {
144 2082         2096 $plan->cost($cost);
145 2082         2029 $self->log->info('Model \''.ref($model).'\' did cost planning for \''.ref($plan).'\' and got cost '.$cost);
146 2082 50 33     6619 return $cost;
147             }
148 2082 100       25749 }
149 1283         15814  
150             my $cost = 1;
151 799 50       1161 my @children = @{ $plan->children };
152 799 100       19151 if ($plan->isa('Attean::Plan::Quad')) {
153 27         419 my @vars = map { $_->value } grep { blessed($_) and $_->does('Attean::API::Variable') } $plan->values;
154 27         905 return scalar(@vars);
155 27         328 } elsif ($plan->isa('Attean::Plan::Table')) {
156             my $rows = $plan->rows;
157             $cost = scalar(@$rows);
158             } elsif ($plan->isa('Attean::Plan::NestedLoopJoin')) {
159 772         4788 my $lcost = $self->cost_for_plan($children[0], $model);
160 772         774 my $rcost = $self->cost_for_plan($children[1], $model);
  772         1862  
161 772 50       3515 if ($lcost == 0) {
    50          
    100          
    100          
    100          
    50          
162 0 0       0 $cost = $rcost;
  0         0  
  0         0  
163 0         0 } elsif ($rcost == 0) {
164             $cost = $lcost;
165 0         0 } else {
166 0         0 $cost = $lcost * $rcost;
167             }
168 408         6196
169 408         9720 # a cartesian nested loop join is bad, but the algorithm already
170 408 100       4862 # has to check for all possible joins, so it's not as bad as
    50          
171 26         30 # a cartesian hash join (below)
172             $cost *= 10 unless ($plan->children_are_variable_connected);
173 0         0 } elsif ($plan->isa('Attean::Plan::HashJoin')) {
174             my $joined = $plan->children_are_variable_connected;
175 382         811 my $lcost = $self->cost_for_plan($children[0], $model);
176             my $rcost = $self->cost_for_plan($children[1], $model);
177             $cost = ($lcost + $rcost);
178             $cost += ($lcost < $rcost); # To let the plan with cheaper rhs win
179             $cost *= 100 unless ($plan->children_are_variable_connected);
180             } elsif ($plan->isa('Attean::Plan::Service')) {
181 408 100       1005 my $scost = 10;
182             foreach my $c (@{ $plan->children }) {
183 356         805 $scost += $self->cost_for_plan($c, $model);
184 356         6046 }
185 356         8555 $cost = 5 * $scost;
186 356         4225 } elsif ($plan->isa('Attean::Plan::Unique')) {
187 356         386 $cost = 0; # consider a filter on the iterator (like unique) to be essentially free
188 356 50       764 foreach my $c (@{ $plan->children }) {
189             $cost += $self->cost_for_plan($c, $model);
190 3         6 }
191 3         4 } else {
  3         8  
192 0         0 foreach my $c (@{ $plan->children }) {
193             $cost += $self->cost_for_plan($c, $model);
194 3         6 }
195             }
196 0         0
197 0         0 $plan->cost($cost);
  0         0  
198 0         0 if ($self->log->is_trace) {
199             $self->log->trace("Cost $cost estimated for\n".$plan->as_string);
200             } else {
201 5         6 $self->log->debug('Estimated cost for \''.ref($plan).'\' is '.$cost);
  5         9  
202 5         72 }
203             return $cost;
204             }
205             }
206 772         10501 }
207 772 50       22746  
208 0         0 use Encode qw(encode);
209             use Attean::RDF;
210 772         15894 use LWP::UserAgent;
211             use Scalar::Util qw(blessed reftype);
212 772         7835 use List::Util qw(reduce);
213             use List::MoreUtils qw(all any);
214             use Types::Standard qw(Int ConsumerOf InstanceOf);
215             use URI::Escape;
216             use Algorithm::Combinatorics qw(subsets);
217             use List::Util qw(min);
218 50     50   28051 use Math::Cartesian::Product;
  50         117  
  50         2926  
219 50     50   329  
  50         110  
  50         449  
220 50     50   36200 use Moo::Role;
  50         99  
  50         1039  
221 50     50   228  
  50         97  
  50         2120  
222 50     50   295 with 'Attean::API::JoinPlanner';
  50         98  
  50         2691  
223 50     50   322 with 'Attean::API::SimpleCostPlanner';
  50         97  
  50         347  
224 50     50   47565  
  50         102  
  50         314  
225 50     50   30940 my $self = shift;
  50         109  
  50         2446  
226 50     50   25536 my $model = shift;
  50         157663  
  50         3298  
227 50     50   456 my $active_graphs = shift;
  50         115  
  50         2073  
228 50     50   281 my $default_graphs = shift;
  50         108  
  50         1610  
229             my $interesting = shift;
230 50     50   268 my @args = @_; # each $args[$i] here is an array reference containing alternate plans for element $i
  50         113  
  50         360  
231              
232             my $k = 3; # this is the batch size over which to do full dynamic programming
233            
234             # initialize $optPlan{$i} to be a set of alternate plans for evaluating element $i
235             my %optPlan;
236 25     25 0 32 foreach my $i (0 .. $#args) {
237 25         31 $optPlan{$i} = [$self->prune_plans($model, $interesting, $args[$i])];
238 25         31 }
239 25         31
240 25         31 my @todo = (0 .. $#args); # initialize the todo list to all elements
241 25         43 my $next_symbol = 'a'; # when we start batching together sub-plans, we'll rename them with letters (e.g. elements 1, 2, and 4 might become 'a', and then 3, 5, and 'a' become 'b')
242            
243 25         35 # until we've joined all the elements in todo and are left with a set of plans for the join of all elements
244             while (scalar(@todo) > 1) {
245             $k = ($k < scalar(@todo)) ? $k : scalar(@todo); # in case we're joining fewer than the batch size
246 25         29 foreach my $i (2 .. $k) { # we've already initialized plans for evaluating single elements; now consider plans for groups of elements (with group sizes 2, 3, ..., $k)
247 25         57 foreach my $s (subsets(\@todo, $i)) { # pick a subset of size $i of the elements that need to be planned
248 39         109 my $s_key = join('.', sort @$s);
249             $optPlan{$s_key} = [];
250             foreach my $o (subsets($s)) { # partition the subset s into two (o and not_o)
251 25         60 next if (scalar(@$o) == 0); # only consider proper, non-empty subsets
252 25         74 next if (scalar(@$o) == scalar(@$s)); # only consider proper, non-empty subsets
253             my $o_key = join('.', sort @$o);
254             my %o = map { $_ => 1 } @$o;
255 25         55 my $not_o_key = join('.', sort grep { not exists $o{$_} } @$s);
256 13 50       32
257 13         33 my $lhs = $optPlan{$o_key}; # get the plans for evaluating o
258 14         66 my $rhs = $optPlan{$not_o_key}; # get the plans for evaluating not_o
259 16         1148
260 16         37 # compute and store all the possible ways to evaluate s (o ⋈ not_o)
261 16         46 push(@{ $optPlan{$s_key} }, $self->join_plans($model, $active_graphs, $default_graphs, $lhs, $rhs, 'inner'));
262 68 100       1298 $optPlan{$s_key} = [$self->prune_plans($model, $interesting, $optPlan{$s_key})];
263 52 100       95 }
264 36         88 }
265 36         62 }
  39         102  
266 36         66
  78         145  
267             # find the minimum cost plan $p that computes the join over $k elements (the elements end up in @v)
268 36         58 my %min_plans;
269 36         42 foreach my $w (subsets(\@todo, $k)) {
270             my $w_key = join('.', sort @$w);
271             my $plans = $optPlan{$w_key};
272 36         42 my @costs = map { $self->cost_for_plan($_, $model) => [$_, $w] } @$plans;
  36         152  
273 36         133 my %costs = @costs;
274             my $min = min keys %costs;
275             my @min_plans;
276             while (my ($cost, $data) = splice(@costs, 0, 2)) {
277             if ($cost == $min) {
278             push(@min_plans, $data);
279 13         28 }
280 13         43 }
281 13         830 $min_plans{ $min } = \@min_plans;
282 13         24 }
283 13         25 my $min_cost = min keys %min_plans;
  60         987  
284 13         116 my $min_plans = $min_plans{$min_cost};
285 13         91 my @min_plans;
286 13         27 my $min_key;
287 13         46 foreach my $d (@$min_plans) {
288 60 100       97 my ($p, $v) = @$d;
289 51         102 my $v_key = join('.', sort @$v);
290             if (not(defined($min_key)) or $min_key eq $v_key) {
291             push(@min_plans, $p);
292 13         44 $min_key = $v_key;
293             }
294 13         49 }
295 13         22 # my ($p, $v) = @$min_plan;
296 13         20 # my $v_key = join('.', sort @$v);
297             # warn "Choosing join for $v_key\n";
298 13         45
299 51         79 # generate a new symbol $t to stand in for $p, the join over the elements in @v
300 51         92 my $t = $next_symbol++;
301 51 50 66     136
302 51         59 # remove elements in @v from the todo list, and replace them by the new composite element $t
303 51         65 $optPlan{$t} = [@min_plans];
304             my %v = map { $_ => 1 } split(/[.]/, $min_key);
305             push(@todo, $t);
306             @todo = grep { not exists $v{$_} } @todo;
307            
308             # also remove subsets of @v from the optPlan hash as they are now covered by $optPlan{$t}
309             foreach my $o (subsets([keys %v])) {
310             my $o_key = join('.', sort @$o);
311 13         48 # warn "deleting $o_key\n";
312             delete $optPlan{$o_key};
313             }
314 13         39 }
315 13         48
  27         99  
316 13         27 my $final_key = join('.', sort @todo);
317 13         26 # use Data::Dumper;
  40         73  
318             # warn Dumper($optPlan{$final_key});
319             return $self->prune_plans($model, $interesting, $optPlan{$final_key});
320 13         58 }
321 56         1019
322             my $self = shift;
323 56         174 my $model = shift;
324             my $interesting = shift;
325             my @plans = @{ shift || [] };
326             no sort 'stable';
327 25         63 my @sorted = map { $_->[1] } sort { $a->[0] <=> $b->[0] } map { [$self->cost_for_plan($_, $model), $_] } @plans;
328             if ($self->log->is_trace) {
329             $self->log->trace('============= Plan iteration separator ==============');
330 25         74 foreach my $plan (@sorted){
331             $self->log->trace("Cost: " . $self->cost_for_plan($plan, $model) . " for plan:\n". $plan->as_string);
332             }
333             }
334 125     125 0 171 return splice(@sorted, 0, 5);
335 125         131 }
336 125         140
337 125 50       154 # Return a cost value for $plan. This value is basically opaque, except
  125         279  
338 50     50   53625 # that it will be used to sort plans by cost when determining which is the
  50         126  
  50         356  
339 125         193 # cheapest plan to evaluate.
  470         749  
  810         1008  
  470         7056  
340 125 50       1652 my $self = shift;
341 0         0 my $plan = shift;
342 0         0 my $model = shift;
343 0         0 Carp::confess "No model given" unless (blessed($model) and $model->does('Attean::API::Model'));
344            
345             if ($plan->has_cost) {
346 125         2054 return $plan->cost;
347             } else {
348             if ($model->does('Attean::API::CostPlanner')) {
349             if (defined(my $cost = $model->cost_for_plan($plan, $self))) {
350             $plan->cost($cost);
351             $self->log->info('Model \''.ref($model).'\' did cost planning for \''.ref($plan).'\' and got cost '.$cost);
352             return $cost;
353 1075     1075 0 33637 }
354 1075         1185 }
355 1075         1037  
356 1075 50 33     3261 my $cost = 1;
357             my @children = @{ $plan->children };
358 1075 100       12717 if ($plan->isa('Attean::Plan::Quad')) {
359 796         9668 my @vars = map { $_->value } grep { blessed($_) and $_->does('Attean::API::Variable') } $plan->values;
360             # This gives a cost increasing at a reasonable pace
361 279 50       482 $cost = $self->_hsp_heuristic_triple_sum($plan) * scalar(@vars);
362 279 100       6419 } elsif ($plan->isa('Attean::Plan::Table')) {
363 29         557 my $rows = $plan->rows;
364 29         986 $cost = scalar(@$rows);
365 29         387 } elsif ($plan->isa('Attean::Plan::NestedLoopJoin')) {
366             my $lcost = $self->cost_for_plan($children[0], $model);
367             my $rcost = $self->cost_for_plan($children[1], $model);
368             if ($lcost == 0) {
369 250         1595 $cost = $rcost;
370 250         257 } elsif ($rcost == 0) {
  250         712  
371 250 100       1200 $cost = $lcost;
    50          
    100          
    100          
    100          
    50          
372 4 100       12 } else {
  4         14  
  16         215  
373             my $mult = $self->_penalize_joins($plan);
374 4         26 # warn "$mult * ($lcost * $rcost) [$children[0] $children[1]]";
375             $cost = $mult * $lcost * $rcost;
376 0         0 }
377 0         0 } elsif ($plan->isa('Attean::Plan::HashJoin')) {
378             my $lcost = $self->cost_for_plan($children[0], $model);
379 124         1827 my $rcost = $self->cost_for_plan($children[1], $model);
380 124         1975 if ($lcost == 0) {
381 124 100       634 $cost = $rcost;
    100          
382 2         9 } elsif ($rcost == 0) {
383             $cost = $lcost;
384 2         3 } else {
385             my $mult = $self->_penalize_joins($plan);
386 120         209 # warn "$mult * ($lcost + $rcost)";
387             $cost = $mult * ($lcost + $rcost);
388 120         175 $cost += ($lcost < $rcost); # To let the plan with cheaper rhs win
389             }
390             } elsif ($plan->isa('Attean::Plan::Service')) {
391 104         1494 my $scost = 10;
392 104         1661 foreach my $c (@{ $plan->children }) {
393 104 50       532 $scost += $self->cost_for_plan($c, $model);
    50          
394 0         0 }
395             $cost = 5 * $scost;
396 0         0 } elsif ($plan->isa('Attean::Plan::Unique')) {
397             $cost = 0; # consider a filter on the iterator (like unique) to be essentially free
398 104         196 foreach my $c (@{ $plan->children }) {
399             $cost += $self->cost_for_plan($c, $model);
400 104         155 }
401 104         149 } else {
402             foreach my $c (@{ $plan->children }) {
403             $cost += $self->cost_for_plan($c, $model);
404 1         1 }
405 1         2 }
  1         3  
406 0         0
407             # Costs must be integers for comparisons to work in the IDP planning algorithm
408 1         4 $cost = int($cost);
409             $plan->cost($cost);
410 0         0 return $cost;
411 0         0 }
  0         0  
412 0         0 }
413            
414            
415 17         31 # The below function finds a number to aid sorting
  17         35  
416 17         258 # It takes into account Heuristic 1 and 4 of the HSP paper, see REFERENCES
417             # as well as that it was noted in the text that rdf:type is usually less selective.
418              
419             # By assigning the integers to nodes, depending on whether they are in
420             # triple (subject, predicate, object), variables, rdf:type and
421 250         370 # literals, and sum them, they may be sorted. See code for the actual
422 250         3445 # values used.
423 250         5090  
424             # Denoting s for bound subject, p for bound predicate, a for rdf:type
425             # as predicate, o for bound object and l for literal object and ? for
426             # variable, we get the following order, most of which are identical to
427             # the HSP:
428              
429             # spl: 6
430             # spo: 8
431             # sao: 10
432             # s?l: 14
433             # s?o: 16
434             # ?pl: 25
435             # ?po: 27
436             # ?ao: 29
437             # sp?: 30
438             # sa?: 32
439             # ??l: 33
440             # ??o: 35
441             # s??: 38
442             # ?p?: 49
443             # ?a?: 51
444             # ???: 57
445              
446             # Note that this number is not intended as an estimate of selectivity,
447             # merely a sorting key, but further research may possibly create such
448             # numbers.
449              
450             my ($self, $t) = @_;
451             my $sum = 0;
452             if ($t->subject->does('Attean::API::Variable')) {
453             $sum = 20;
454             } else {
455             $sum = 1;
456             }
457             if ($t->predicate->does('Attean::API::Variable')) {
458             $sum += 10;
459             } else {
460             if ($t->predicate->equals(iri('http://www.w3.org/1999/02/22-rdf-syntax-ns#type'))) {
461             $sum += 4;
462             } else {
463             $sum += 2;
464 4     4   13 }
465 4         8 }
466 4 50       13 if ($t->object->does('Attean::API::Variable')) {
467 0         0 $sum += 27;
468             } elsif ($t->object->does('Attean::API::Literal')) {
469 4         70 $sum += 3;
470             } else {
471 4 50       12 $sum += 5;
472 0         0 }
473             return $sum;
474 4 50       70 }
475 0         0  
476             # The following method returns a factor used to penalize certain types of joins.
477 4         9 # It penalizes cartesian joins heavily, but also uses HSP Heuristic 2 (see REFERENCES)
478             my ($self, $plan) = @_;
479             my $jv = $plan->join_variables;
480 4 50       21 my @children = @{ $plan->children };
    0          
481 4         64 my $mult = 1;
482             if (scalar(@$jv)) {
483 0         0 if ( all { $_->isa('Attean::Plan::Quad') } @children[0..1]) {
484             my $var = ${$jv}[0]; # We will join on this
485 0         0 my @lnodes = $children[0]->values;
486             my @rnodes = $children[1]->values;
487 4         22 # Now, find where the join variables are in the triple patterns
488             my %joinpos;
489             for (my $i = 0; $i <= 2; $i++) {
490             if ($lnodes[$i]->does('Attean::API::Variable') && $lnodes[$i]->value eq $var) {
491             $joinpos{l} = $i;
492             }
493 224     224   278 if ($rnodes[$i]->does('Attean::API::Variable') && $rnodes[$i]->value eq $var) {
494 224         374 $joinpos{r} = $i;
495 224         240 }
  224         380  
496 224         285 last if scalar keys(%joinpos) >= 2; # Perhaps a bit premature optimization
497 224 100       341 }
498 208 100   344   822 my $joinpos = join("", sort values(%joinpos)); # We can now match on this string
  344         830  
499 64         69 my %costs = ('12' => 1.1, # The penalty numbers come mostly out from thin air
  64         97  
500 64         139 '01' => 1.2,
501 64         137 '02' => 1.5,
502             '22' => 1.6,
503 64         89 '00' => 1.8,
504 64         115 '11' => 2);
505 64 50 33     179 if (exists $costs{$joinpos}) {
506 64         887 $mult = $costs{$joinpos};
507             }
508 64 50 33     111 #warn "Penalty: $mult for quads:\n" . $children[0]->as_string . $children[1]->as_string
509 64         691 }
510             } else {
511 64 50       124 $mult = 5; # penalize cartesian joins
512             }
513 64         198 return $mult;
514 64         188 }
515             }
516              
517              
518             1;
519              
520 64 50       108  
521 64         199 =back
522              
523             =head1 BUGS
524              
525             Please report any bugs or feature requests to through the GitHub web interface
526 16         22 at L<https://github.com/kasei/attean/issues>.
527              
528 224         553 =head1 REFERENCES
529              
530             The seminal reference for Iterative Dynamic Programming is "Iterative
531             dynamic programming: a new class of query optimization algorithms" by
532             D. Kossmann and K. Stocker, ACM Transactions on Database Systems
533             (2000).
534              
535             The heuristics to order triple patterns in this module is
536             influenced by L<The ICS-FORTH Heuristics-based SPARQL Planner
537             (HSP)|http://www.ics.forth.gr/isl/index_main.php?l=e&c=645>.
538              
539              
540             =head1 SEE ALSO
541              
542              
543              
544             =head1 AUTHOR
545              
546             Gregory Todd Williams C<< <gwilliams@cpan.org> >>
547              
548             =head1 COPYRIGHT
549              
550             Copyright (c) 2014--2022 Gregory Todd Williams.
551             This program is free software; you can redistribute it and/or modify it under
552             the same terms as Perl itself.
553              
554             =cut