File Coverage

lib/Text/Tradition/Collation/RelationshipStore.pm
Criterion Covered Total %
statement 33 610 5.4
branch 0 262 0.0
condition 0 78 0.0
subroutine 15 55 27.2
pod 19 21 90.4
total 67 1026 6.5


line stmt bran cond sub pod time code
1             package Text::Tradition::Collation::RelationshipStore;
2              
3 10     10   83 use strict;
  10         30  
  10         331  
4 10     10   60 use warnings;
  10         25  
  10         348  
5 10     10   55 use Safe::Isa;
  10         23  
  10         1455  
6 10     10   79 use Text::Tradition::Error;
  10         23  
  10         251  
7 10     10   2774 use Text::Tradition::Collation::Relationship;
  10         42  
  10         399  
8 10     10   4001 use Text::Tradition::Collation::RelationshipType;
  10         42  
  10         413  
9 10     10   3722 use TryCatch;
  10         3622869  
  10         72  
10              
11 10     10   4771 use Moose;
  10         30  
  10         85  
12              
13             =head1 NAME
14              
15             Text::Tradition::Collation::RelationshipStore - Keeps track of the relationships
16             between readings in a given collation
17            
18             =head1 DESCRIPTION
19              
20             Text::Tradition is a library for representation and analysis of collated
21             texts, particularly medieval ones. The RelationshipStore is an internal object
22             of the collation, to keep track of the defined relationships (both specific and
23             general) between readings.
24              
25             =begin testing
26              
27             use Text::Tradition;
28             use TryCatch;
29              
30             use_ok( 'Text::Tradition::Collation::RelationshipStore' );
31              
32             # Add some relationships, and delete them
33              
34             my $cxfile = 't/data/Collatex-16.xml';
35             my $t = Text::Tradition->new(
36             'name' => 'inline',
37             'input' => 'CollateX',
38             'file' => $cxfile,
39             );
40             my $c = $t->collation;
41              
42             my @v1 = $c->add_relationship( 'n21', 'n22', { 'type' => 'lexical' } );
43             is( scalar @v1, 1, "Added a single relationship" );
44             is( $v1[0]->[0], 'n21', "Got correct node 1" );
45             is( $v1[0]->[1], 'n22', "Got correct node 2" );
46             my @v2 = $c->add_relationship( 'n24', 'n23',
47             { 'type' => 'spelling', 'scope' => 'global' } );
48             is( scalar @v2, 2, "Added a global relationship with two instances" );
49             @v1 = $c->del_relationship( 'n22', 'n21' );
50             is( scalar @v1, 1, "Deleted first relationship" );
51             @v2 = $c->del_relationship( 'n12', 'n13', 1 );
52             is( scalar @v2, 2, "Deleted second global relationship" );
53             my @v3 = $c->del_relationship( 'n1', 'n2' );
54             is( scalar @v3, 0, "Nothing deleted on non-existent relationship" );
55             my @v4 = $c->add_relationship( 'n24', 'n23',
56             { 'type' => 'spelling', 'scope' => 'global' } );
57             is( @v4, 2, "Re-added global relationship" );
58             @v4 = $c->del_relationship( 'n12', 'n13' );
59             is( @v4, 1, "Only specified relationship deleted this time" );
60             ok( $c->get_relationship( 'n24', 'n23' ), "Other globally-added relationship exists" );
61              
62             =end testing
63              
64             =head1 METHODS
65              
66             =head2 new( collation => $collation );
67              
68             Creates a new relationship store for the given collation.
69              
70             =cut
71              
72             has 'collation' => (
73             is => 'ro',
74             isa => 'Text::Tradition::Collation',
75             required => 1,
76             weak_ref => 1,
77             );
78            
79             =head2 types
80              
81             Registry of possible relationship types. See RelationshipType for more info.
82              
83             =cut
84            
85             has 'relationship_types' => (
86             is => 'ro',
87             traits => ['Hash'],
88             handles => {
89             has_type => 'exists',
90             add_type => 'set',
91             del_type => 'delete',
92             type => 'get',
93             types => 'values'
94             },
95             );
96              
97             has 'scopedrels' => (
98             is => 'ro',
99             isa => 'HashRef[HashRef[Text::Tradition::Collation::Relationship]]',
100             default => sub { {} },
101             );
102              
103             has 'graph' => (
104             is => 'ro',
105             isa => 'Graph',
106             default => sub { Graph->new( undirected => 1 ) },
107             handles => {
108             relationships => 'edges',
109             add_reading => 'add_vertex',
110             delete_reading => 'delete_vertex',
111             },
112             );
113            
114             =head2 equivalence_graph()
115              
116             Returns an equivalence graph of the collation, in which all readings
117             related via a 'colocated' relationship are transformed into a single
118             vertex. Can be used to determine the validity of a new relationship.
119              
120             =cut
121              
122             has 'equivalence_graph' => (
123             is => 'ro',
124             isa => 'Graph',
125             default => sub { Graph->new() },
126             writer => '_reset_equivalence',
127             );
128            
129             has '_node_equivalences' => (
130             is => 'ro',
131             traits => ['Hash'],
132             handles => {
133             equivalence => 'get',
134             set_equivalence => 'set',
135             remove_equivalence => 'delete',
136             _clear_equivalence => 'clear',
137             },
138             );
139              
140             has '_equivalence_readings' => (
141             is => 'ro',
142             traits => ['Hash'],
143             handles => {
144             eqreadings => 'get',
145             set_eqreadings => 'set',
146             remove_eqreadings => 'delete',
147             _clear_eqreadings => 'clear',
148             },
149             );
150            
151             ## Build function - here we have our default set of relationship types.
152              
153             sub BUILD {
154 0     0 0   my $self = shift;
155            
156 0           my @DEFAULT_TYPES = (
157             { name => 'collated', bindlevel => 50, is_weak => 1, is_transitive => 0,
158             is_generalizable => 0, description => 'Internal use only' },
159             { name => 'orthographic', bindlevel => 0, use_regular => 0,
160             description => 'These are the same reading, neither unusually spelled.' },
161             { name => 'punctuation', bindlevel => 0,
162             description => 'These are the same reading apart from punctuation.' },
163             { name => 'spelling', bindlevel => 1,
164             description => 'These are the same reading, spelled differently.' },
165             { name => 'grammatical', bindlevel => 2,
166             description => 'These readings share a root (lemma), but have different parts of speech (morphologies).' },
167             { name => 'lexical', bindlevel => 2,
168             description => 'These readings share a part of speech (morphology), but have different roots (lemmata).' },
169             { name => 'uncertain', bindlevel => 0, is_transitive => 0, is_generalizable => 0,
170             use_regular => 0, description => 'These readings are related, but a clear category cannot be assigned.' },
171             { name => 'other', bindlevel => 0, is_transitive => 0, is_generalizable => 0,
172             description => 'These readings are related in a way not covered by the existing types.' },
173             { name => 'transposition', bindlevel => 50, is_colocation => 0,
174             description => 'This is the same (or nearly the same) reading in a different location.' },
175             { name => 'repetition', bindlevel => 50, is_colocation => 0, is_transitive => 0,
176             description => 'This is a reading that was repeated in one or more witnesses.' }
177             );
178            
179 0           foreach my $type ( @DEFAULT_TYPES ) {
180 0           $self->add_type( $type );
181             }
182             }
183              
184             around add_type => sub {
185             my $orig = shift;
186             my $self = shift;
187             my $new_type;
188             if( @_ == 1 && $_[0]->$_isa( 'Text::Tradition::Collation::RelationshipType' ) ) {
189             $new_type = shift;
190             } else {
191             my %args = @_ == 1 ? %{$_[0]} : @_;
192             $new_type = Text::Tradition::Collation::RelationshipType->new( %args );
193             }
194             $self->$orig( $new_type->name => $new_type );
195             return $new_type;
196             };
197            
198             around add_reading => sub {
199             my $orig = shift;
200             my $self = shift;
201            
202             $self->equivalence_graph->add_vertex( @_ );
203             $self->set_equivalence( $_[0], $_[0] );
204             $self->set_eqreadings( $_[0], [ $_[0] ] );
205             $self->$orig( @_ );
206             };
207              
208             around delete_reading => sub {
209             my $orig = shift;
210             my $self = shift;
211            
212             $self->_remove_equivalence_node( @_ );
213             $self->$orig( @_ );
214             };
215              
216             =head2 get_relationship
217              
218             Return the relationship object, if any, that exists between two readings.
219              
220             =cut
221              
222             sub get_relationship {
223 0     0 1   my $self = shift;
224 0           my @vector;
225 0 0 0       if( @_ == 1 && ref( $_[0] ) eq 'ARRAY' ) {
226             # Dereference the edge arrayref that was passed.
227 0           my $edge = shift;
228 0           @vector = @$edge;
229             } else {
230 0           @vector = @_[0,1];
231             }
232 0           my $relationship;
233 0 0         if( $self->graph->has_edge_attribute( @vector, 'object' ) ) {
234 0           $relationship = $self->graph->get_edge_attribute( @vector, 'object' );
235             }
236 0           return $relationship;
237             }
238              
239             sub _set_relationship {
240 0     0     my( $self, $relationship, @vector ) = @_;
241 0           $self->graph->add_edge( @vector );
242 0           $self->graph->set_edge_attribute( @vector, 'object', $relationship );
243 0 0         $self->_make_equivalence( @vector ) if $relationship->colocated;
244             }
245              
246             =head2 create
247              
248             Create a new relationship with the given options and return it.
249             Warn and return undef if the relationship cannot be created.
250              
251             =cut
252              
253             sub create {
254 0     0 1   my( $self, $options ) = @_;
255             # Check to see if a relationship exists between the two given readings
256 0           my $source = delete $options->{'orig_a'};
257 0           my $target = delete $options->{'orig_b'};
258 0           my $rel = $self->get_relationship( $source, $target );
259 0 0         if( $rel ) {
260 0 0         if( $self->type( $rel->type )->is_weak ) {
    0          
261             # Always replace a weak relationship with a more descriptive
262             # one, if asked.
263 0           $self->del_relationship( $source, $target );
264             } elsif( $rel->type ne $options->{'type'} ) {
265 0           throw( "Another relationship of type " . $rel->type
266             . " already exists between $source and $target" );
267             } else {
268 0           return $rel;
269             }
270             }
271            
272 0           $rel = Text::Tradition::Collation::Relationship->new( $options );
273 0           my $reltype = $self->type( $rel->type );
274 0 0         throw( "Unrecognized relationship type " . $rel->type ) unless $reltype;
275             # Validate the options given against the relationship type wanted
276 0 0 0       throw( "Cannot set nonlocal scope on relationship of type " . $reltype->name )
277             if $rel->nonlocal && !$reltype->is_generalizable;
278            
279 0 0         $self->add_scoped_relationship( $rel ) if $rel->nonlocal;
280 0           return $rel;
281             }
282              
283             =head2 add_scoped_relationship( $rel )
284              
285             Keep track of relationships defined between specific readings that are scoped
286             non-locally. Key on whichever reading occurs first alphabetically.
287              
288             =cut
289              
290             sub add_scoped_relationship {
291 0     0 1   my( $self, $rel ) = @_;
292 0           my $rdga = $rel->reading_a;
293 0           my $rdgb = $rel->reading_b;
294 0           my $r = $self->scoped_relationship( $rdga, $rdgb );
295 0 0         if( $r ) {
296 0           warn sprintf( "Scoped relationship of type %s already exists between %s and %s",
297             $r->type, $rdga, $rdgb );
298 0           return;
299             }
300 0           my( $first, $second ) = sort ( $rdga, $rdgb );
301 0           $self->scopedrels->{$first}->{$second} = $rel;
302             }
303              
304             =head2 scoped_relationship( $reading_a, $reading_b )
305              
306             Returns the general (document-level or global) relationship that has been defined
307             between the two reading strings. Returns undef if there is no general relationship.
308              
309             =cut
310              
311             sub scoped_relationship {
312 0     0 1   my( $self, $rdga, $rdgb ) = @_;
313 0           my( $first, $second ) = sort( $rdga, $rdgb );
314 0 0         if( exists $self->scopedrels->{$first}->{$second} ) {
315 0           return $self->scopedrels->{$first}->{$second};
316             }
317 0           return undef;
318             }
319              
320             =head2 add_relationship( $self, $source, $sourcetext, $target, $targettext, $opts )
321              
322             Adds the relationship specified in $opts (see Text::Tradition::Collation::Relationship
323             for the possible options) between the readings given in $source and $target. Sets
324             up a scoped relationship between $sourcetext and $targettext if the relationship is
325             scoped non-locally.
326              
327             Returns a status boolean and a list of all reading pairs connected by the call to
328             add_relationship.
329              
330             =begin testing
331              
332             use Test::Warn;
333             use Text::Tradition;
334             use TryCatch;
335              
336             my $t1;
337             warnings_exist {
338             $t1 = Text::Tradition->new( 'input' => 'Self', 'file' => 't/data/legendfrag.xml' );
339             } [qr/Cannot set relationship on a meta reading/],
340             "Got expected relationship drop warning on parse";
341              
342             # Test 1.1: try to equate nodes that are prevented with an intermediate collation
343             ok( $t1, "Parsed test fragment file" );
344             my $c1 = $t1->collation;
345             my $trel = $c1->get_relationship( 'r9.2', 'r9.3' );
346             is( ref( $trel ), 'Text::Tradition::Collation::Relationship',
347             "Troublesome relationship exists" );
348             is( $trel->type, 'collated', "Troublesome relationship is a collation" );
349              
350             # Try to make the link we want
351             try {
352             $c1->add_relationship( 'r8.6', 'r10.3', { 'type' => 'orthographic' } );
353             ok( 1, "Added cross-collation relationship as expected" );
354             } catch( Text::Tradition::Error $e ) {
355             ok( 0, "Existing collation blocked equivalence relationship: " . $e->message );
356             }
357              
358             try {
359             $c1->calculate_ranks();
360             ok( 1, "Successfully calculated ranks" );
361             } catch ( Text::Tradition::Error $e ) {
362             ok( 0, "Collation now has a cycle: " . $e->message );
363             }
364              
365             # Test 1.2: attempt merge of an identical reading
366             try {
367             $c1->merge_readings( 'r9.3', 'r11.5' );
368             ok( 1, "Successfully merged reading 'pontifex'" );
369             } catch ( Text::Tradition::Error $e ) {
370             ok( 0, "Merge of mergeable readings failed: $e->message" );
371            
372             }
373              
374             # Test 1.3: attempt relationship with a meta reading (should fail)
375             try {
376             $c1->add_relationship( 'r8.1', 'r9.2', { 'type' => 'collated' } );
377             ok( 0, "Allowed a meta-reading to be used in a relationship" );
378             } catch ( Text::Tradition::Error $e ) {
379             is( $e->message, 'Cannot set relationship on a meta reading',
380             "Relationship link prevented for a meta reading" );
381             }
382              
383             # Test 1.4: try to break a relationship near a meta reading
384             $c1->add_relationship( 'r7.6', 'r7.3', { type => 'orthographic' } );
385             try {
386             $c1->del_relationship( 'r7.6', 'r7.7' );
387             $c1->del_relationship( 'r7.6', 'r7.3' );
388             ok( 1, "Relationship broken with a meta reading as neighbor" );
389             } catch {
390             ok( 0, "Relationship deletion failed with a meta reading as neighbor" );
391             }
392              
393             # Test 2.1: try to equate nodes that are prevented with a real intermediate
394             # equivalence
395             my $t2;
396             warnings_exist {
397             $t2 = Text::Tradition->new( 'input' => 'Self', 'file' => 't/data/legendfrag.xml' );
398             } [qr/Cannot set relationship on a meta reading/],
399             "Got expected relationship drop warning on parse";
400             my $c2 = $t2->collation;
401             $c2->add_relationship( 'r9.2', 'r9.3', { 'type' => 'lexical' } );
402             my $trel2 = $c2->get_relationship( 'r9.2', 'r9.3' );
403             is( ref( $trel2 ), 'Text::Tradition::Collation::Relationship',
404             "Created blocking relationship" );
405             is( $trel2->type, 'lexical', "Blocking relationship is not a collation" );
406             # This time the link ought to fail
407             try {
408             $c2->add_relationship( 'r8.6', 'r10.3', { 'type' => 'orthographic' } );
409             ok( 0, "Added cross-equivalent bad relationship" );
410             } catch ( Text::Tradition::Error $e ) {
411             like( $e->message, qr/witness loop/,
412             "Existing equivalence blocked crossing relationship" );
413             }
414              
415             try {
416             $c2->calculate_ranks();
417             ok( 1, "Successfully calculated ranks" );
418             } catch ( Text::Tradition::Error $e ) {
419             ok( 0, "Collation now has a cycle: " . $e->message );
420             }
421              
422             # Test 3.1: make a straightforward pair of transpositions.
423             my $t3 = Text::Tradition->new( 'input' => 'Self', 'file' => 't/data/lf2.xml' );
424             # Test 1: try to equate nodes that are prevented with an intermediate collation
425             my $c3 = $t3->collation;
426             try {
427             $c3->add_relationship( 'r36.4', 'r38.3', { 'type' => 'transposition' } );
428             ok( 1, "Added straightforward transposition" );
429             } catch ( Text::Tradition::Error $e ) {
430             ok( 0, "Failed to add normal transposition: " . $e->message );
431             }
432             try {
433             $c3->add_relationship( 'r36.3', 'r38.2', { 'type' => 'transposition' } );
434             ok( 1, "Added straightforward transposition complement" );
435             } catch ( Text::Tradition::Error $e ) {
436             ok( 0, "Failed to add normal transposition complement: " . $e->message );
437             }
438              
439             # Test 3.2: try to make a transposition that could be a parallel.
440             try {
441             $c3->add_relationship( 'r28.2', 'r29.2', { 'type' => 'transposition' } );
442             ok( 0, "Added bad colocated transposition" );
443             } catch ( Text::Tradition::Error $e ) {
444             like( $e->message, qr/Readings appear to be colocated/,
445             "Prevented bad colocated transposition" );
446             }
447              
448             # Test 3.3: make the parallel, and then make the transposition again.
449             try {
450             $c3->add_relationship( 'r28.3', 'r29.3', { 'type' => 'orthographic' } );
451             ok( 1, "Equated identical readings for transposition" );
452             } catch ( Text::Tradition::Error $e ) {
453             ok( 0, "Failed to equate identical readings: " . $e->message );
454             }
455             try {
456             $c3->add_relationship( 'r28.2', 'r29.2', { 'type' => 'transposition' } );
457             ok( 1, "Added straightforward transposition complement" );
458             } catch ( Text::Tradition::Error $e ) {
459             ok( 0, "Failed to add normal transposition complement: " . $e->message );
460             }
461              
462             # Test 4: make a global relationship that involves re-ranking a node first, when
463             # the prior rank has a potential match too
464             my $t4 = Text::Tradition->new( 'input' => 'Self', 'file' => 't/data/globalrel_test.xml' );
465             my $c4 = $t4->collation;
466             # Can we even add the relationship?
467             try {
468             $c4->add_relationship( 'r463.2', 'r463.4',
469             { type => 'orthographic', scope => 'global' } );
470             ok( 1, "Added global relationship without error" );
471             } catch ( Text::Tradition::Error $e ) {
472             ok( 0, "Failed to add global relationship when same-rank alternative exists: "
473             . $e->message );
474             }
475             $c4->calculate_ranks();
476             # Do our readings now share a rank?
477             is( $c4->reading('r463.2')->rank, $c4->reading('r463.4')->rank,
478             "Expected readings now at same rank" );
479            
480             # Test group 5: relationship transitivity.
481             my $t5 = Text::Tradition->new( 'input' => 'Self', 'file' => 't/data/john.xml' );
482             my $c5 = $t5->collation;
483             # Test 5.0: propagate all existing transitive rels and make sure it succeeds
484             my $orignumrels = scalar $c5->relationships();
485             try {
486             $c5->relations->propagate_all_relationships();
487             ok( 1, "Propagated all existing transitive relationships" );
488             } catch ( Text::Tradition::Error $err ) {
489             ok( 0, "Failed to propagate all existing relationships: " . $err->message );
490             }
491             ok( scalar( $c5->relationships ) > $orignumrels, "Added some relationships in propagation" );
492              
493             # Test 5.1: make a grammatical link to an orthographically-linked reading
494             $c5->add_relationship( 'r13.5', 'r13.2', { type => 'orthographic' } );
495             $c5->add_relationship( 'r13.1', 'r13.2', { type => 'grammatical', propagate => 1 } );
496             my $impliedrel = $c5->get_relationship( 'r13.1', 'r13.5' );
497             ok( $impliedrel, 'Relationship was made between indirectly linked readings' );
498             if( $impliedrel ) {
499             is( $impliedrel->type, 'grammatical', 'Implicit inbound relationship has the correct type' );
500             }
501              
502             # Test 5.2: make another orthographic link, see if the grammatical one propagates
503             $c5->add_relationship( 'r13.3', 'r13.5', { type => 'orthographic', propagate => 1 } );
504             foreach my $rdg ( qw/ r13.3 r13.5 / ) {
505             my $newgram = $c5->get_relationship( 'r13.1', $rdg );
506             ok( $newgram, 'Relationship was propagaged up between indirectly linked readings' );
507             if( $newgram ) {
508             is( $newgram->type, 'grammatical', 'Implicit outbound relationship has the correct type' );
509             }
510             }
511             my $neworth = $c5->get_relationship( 'r13.2', 'r13.3' );
512             ok( $neworth, 'Relationship was made between indirectly linked siblings' );
513             if( $neworth ) {
514             is( $neworth->type, 'orthographic', 'Implicit direct relationship has the correct type' );
515             }
516              
517             # Test 5.3: make an intermediate (spelling) link to the remaining node
518             $c5->add_relationship( 'r13.4', 'r13.2', { type => 'spelling', propagate => 1 } );
519             # Should be linked grammatically to 12.1, spelling-wise to the rest
520             my $newgram = $c5->get_relationship( 'r13.4', 'r13.1' );
521             ok( $newgram, 'Relationship was made between indirectly linked readings' );
522             if( $newgram ) {
523             is( $newgram->type, 'grammatical', 'Implicit intermediate-out relationship has the correct type' );
524             }
525             foreach my $rdg ( qw/ r13.3 r13.5 / ) {
526             my $newspel = $c5->get_relationship( 'r13.4', $rdg );
527             ok( $newspel, 'Relationship was made between indirectly linked readings' );
528             if( $newspel ) {
529             is( $newspel->type, 'spelling', 'Implicit intermediate-in relationship has the correct type' );
530             }
531             }
532              
533             # Test 5.4: delete a spelling relationship, add it again, make sure it doesn't
534             # throw and make sure all the relationships are the same
535             my $numrel = scalar $c5->relationships;
536             $c5->del_relationship( 'r13.4', 'r13.2' );
537             try {
538             $c5->add_relationship( 'r13.4', 'r13.2', { type => 'spelling', propagate => 1 } );
539             ok( 1, "Managed not to throw an exception re-adding the relationship" );
540             } catch( Text::Tradition::Error $e ) {
541             ok( 0, "Threw an exception trying to re-add our intermediate relationship: " . $e->message );
542             }
543             is( $numrel, scalar $c5->relationships, "Number of relationships did not change" );
544             foreach my $rdg ( qw/ r13.2 r13.3 r13.5 / ) {
545             my $newspel = $c5->get_relationship( 'r13.4', $rdg );
546             ok( $newspel, 'Relationship was made between indirectly linked readings' );
547             if( $newspel ) {
548             is( $newspel->type, 'spelling', 'Reinstated intermediate-in relationship has the correct type' );
549             }
550             }
551             my $stillgram = $c5->get_relationship( 'r13.4', 'r13.1' );
552             ok( $stillgram, 'Relationship was made between indirectly linked readings' );
553             if( $stillgram ) {
554             is( $stillgram->type, 'grammatical', 'Reinstated intermediate-out relationship has the correct type' );
555             }
556              
557             # Test 5.5: add a parallel but not sibling relationship
558             $c5->add_relationship( 'r13.6', 'r13.2', { type => 'lexical', propagate => 1 } );
559             ok( !$c5->get_relationship( 'r13.6', 'r13.1' ),
560             "Lexical relationship did not affect grammatical" );
561             foreach my $rdg ( qw/ r13.3 r13.4 r13.5 / ) {
562             my $newlex = $c5->get_relationship( 'r13.6', $rdg );
563             ok( $newlex, 'Parallel was made between indirectly linked readings' );
564             if( $newlex ) {
565             is( $newlex->type, 'lexical', 'Implicit parallel-down relationship has the correct type' );
566             }
567             }
568              
569             # Test 5.6: try it with non-colocated relationships
570             $numrel = scalar $c5->relationships;
571             $c5->add_relationship( 'r62.1', 'r64.1', { type => 'transposition', propagate => 1 } );
572             is( scalar $c5->relationships, $numrel+1,
573             "Adding non-colo relationship did not propagate" );
574             # Add a pivot point
575             $c5->add_relationship( 'r61.1', 'r61.5', { type => 'orthographic' } );
576             # Add a third transposed node
577             $c5->add_relationship( 'r62.1', 'r60.3', { type => 'transposition', propagate => 1 } );
578             my $newtrans = $c5->get_relationship( 'r64.1', 'r60.3' );
579             ok( $newtrans, 'Non-colo relationship was made between indirectly linked readings' );
580             if( $newtrans ) {
581             is( $newtrans->type, 'transposition', 'Implicit non-colo relationship has the correct type' );
582             }
583             is( scalar $c5->relationships, $numrel+4,
584             "Adding non-colo relationship only propagated on non-colos" );
585              
586             # Test 5.7: ensure that attempts to cross boundaries on bindlevel-equal
587             # relationships fail.
588             try {
589             $c5->add_relationship( 'r39.6', 'r41.1', { type => 'grammatical', propagate => 1 } );
590             ok( 0, "Did not prevent add of conflicting relationship level" );
591             } catch( Text::Tradition::Error $err ) {
592             like( $err->message, qr/Conflicting existing relationship/, "Got correct error message trying to add conflicting relationship level" );
593             }
594              
595             # Test 5.8: ensure that weak relationships don't interfere
596             $c5->add_relationship( 'r50.1', 'r50.2', { type => 'collated' } );
597             $c5->add_relationship( 'r50.3', 'r50.4', { type => 'orthographic' } );
598             try {
599             $c5->add_relationship( 'r50.4', 'r50.1', { type => 'grammatical', propagate => 1 } );
600             ok( 1, "Collation did not interfere with new relationship add" );
601             } catch( Text::Tradition::Error $err ) {
602             ok( 0, "Collation interfered with new relationship add: " . $err->message );
603             }
604             my $crel = $c5->get_relationship( 'r50.1', 'r50.2' );
605             ok( $crel, "Original relationship still exists" );
606             if( $crel ) {
607             is( $crel->type, 'collated', "Original relationship still a collation" );
608             }
609              
610             try {
611             $c5->add_relationship( 'r50.1', 'r51.1', { type => 'spelling', propagate => 1 } );
612             ok( 1, "Collation did not interfere with relationship re-ranking" );
613             } catch( Text::Tradition::Error $err ) {
614             ok( 0, "Collation interfered with relationship re-ranking: " . $err->message );
615             }
616             $crel = $c5->get_relationship( 'r50.1', 'r50.2' );
617             ok( !$crel, "Collation relationship now gone" );
618              
619             # Test 5.9: ensure that strong non-transitive relationships don't interfere
620             $c5->add_relationship( 'r66.1', 'r66.4', { type => 'grammatical' } );
621             $c5->add_relationship( 'r66.2', 'r66.4', { type => 'uncertain', propagate => 1 } );
622             try {
623             $c5->add_relationship( 'r66.1', 'r66.3', { type => 'grammatical', propagate => 1 } );
624             ok( 1, "Non-transitive relationship did not block grammatical add" );
625             } catch( Text::Tradition::Error $err ) {
626             ok( 0, "Non-transitive relationship blocked grammatical add: " . $err->message );
627             }
628             is( scalar $c5->related_readings( 'r66.4' ), 3, "Reading 66.4 has all its links" );
629             is( scalar $c5->related_readings( 'r66.2' ), 1, "Reading 66.2 has only one link" );
630             is( scalar $c5->related_readings( 'r66.1' ), 2, "Reading 66.1 has all its links" );
631             is( scalar $c5->related_readings( 'r66.3' ), 2, "Reading 66.3 has all its links" );
632              
633             =end testing
634              
635             =cut
636              
637             sub add_relationship {
638 0     0 1   my( $self, $source, $target, $options ) = @_;
639 0           my $c = $self->collation;
640 0           my $sourceobj = $c->reading( $source );
641 0           my $targetobj = $c->reading( $target );
642 0 0         throw( "Adding self relationship at $source" ) if $source eq $target;
643 0 0 0       throw( "Cannot set relationship on a meta reading" )
644             if( $sourceobj->is_meta || $targetobj->is_meta );
645 0           my $relationship;
646             my $reltype;
647 0           my $thispaironly = delete $options->{thispaironly};
648 0           my $propagate = delete $options->{propagate};
649 0           my $droppedcolls = [];
650 0 0         if( ref( $options ) eq 'Text::Tradition::Collation::Relationship' ) {
651 0           $relationship = $options;
652 0           $reltype = $self->type( $relationship->type );
653 0           $thispaironly = 1; # If existing rel, set only where asked.
654             # Test the validity
655 0           my( $is_valid, $reason ) = $self->relationship_valid( $source, $target,
656             $relationship->type, $droppedcolls );
657 0 0         unless( $is_valid ) {
658 0           throw( "Invalid relationship: $reason" );
659             }
660             } else {
661 0           $reltype = $self->type( $options->{type} );
662            
663             # Try to create the relationship object.
664 0           my $rdga = $reltype->regularize( $sourceobj );
665 0           my $rdgb = $reltype->regularize( $targetobj );
666 0           $options->{'orig_a'} = $sourceobj;
667 0           $options->{'orig_b'} = $targetobj;
668 0           $options->{'reading_a'} = $rdga;
669 0           $options->{'reading_b'} = $rdgb;
670 0 0 0       if( exists $options->{'scope'} && $options->{'scope'} ne 'local' ) {
671             # Is there a relationship with this a & b already?
672 0 0         if( $rdga eq $rdgb ) {
673             # If we have canonified to the same thing for the relationship
674             # type we want, something is wrong.
675             # NOTE we want to allow this at the local level, as a cheap means
676             # of merging readings in the UI, until we get a better means.
677 0           throw( "Canonifier returns identical form $rdga for this relationship type" );
678             }
679            
680 0           my $otherrel = $self->scoped_relationship( $rdga, $rdgb );
681 0 0 0       if( $otherrel && $otherrel->type eq $options->{type}
    0 0        
682             && $otherrel->scope eq $options->{scope} ) {
683             # warn "Applying existing scoped relationship for $rdga / $rdgb";
684 0           $relationship = $otherrel;
685             } elsif( $otherrel ) {
686             throw( 'Conflicting scoped relationship '
687             . join( '/', $otherrel->type, $otherrel->scope ) . ' vs. '
688             . join( '/', $options->{type}, $options->{scope} )
689 0           . " for $rdga / $rdgb at $source / $target" );
690             }
691             }
692 0 0         $relationship = $self->create( $options ) unless $relationship;
693             # ... Will throw on error
694              
695             # See if the relationship is actually valid here
696             my( $is_valid, $reason ) = $self->relationship_valid( $source, $target,
697 0           $options->{'type'}, $droppedcolls );
698 0 0         unless( $is_valid ) {
699 0           throw( "Invalid relationship: $reason" );
700             }
701             }
702              
703              
704             # Now set the relationship(s).
705 0           my @pairs_set;
706 0           my $rel = $self->get_relationship( $source, $target );
707 0           my $skip;
708 0 0 0       if( $rel && $rel ne $relationship ) {
709 0 0         if( $rel->nonlocal ) {
    0          
710 0           throw( "Found conflicting relationship at $source - $target" );
711             } elsif( !$reltype->is_weak ) {
712             # Replace a weak relationship; leave any other sort in place.
713 0 0         my $r1ann = $rel->has_annotation ? $rel->annotation : '';
714 0 0         my $r2ann = $relationship->has_annotation ? $relationship->annotation : '';
715 0 0 0       unless( $rel->type eq $relationship->type && $r1ann eq $r2ann ) {
716 0           warn sprintf( "Not overriding local relationship %s with global %s "
717             . "set at %s -> %s (%s -> %s)", $rel->type, $relationship->type,
718             $source, $target, $rel->reading_a, $rel->reading_b );
719             }
720 0           $skip = 1;
721             }
722             }
723 0 0         $self->_set_relationship( $relationship, $source, $target ) unless $skip;
724 0           push( @pairs_set, [ $source, $target, $relationship->type ] );
725            
726             # Find all the pairs for which we need to set the relationship.
727 0 0 0       if( $relationship->colocated && $relationship->nonlocal && !$thispaironly ) {
      0        
728 0           my @global_set = $self->add_global_relationship( $relationship );
729 0           push( @pairs_set, @global_set );
730             }
731 0 0         if( $propagate ) {
732 0           my @prop;
733 0           foreach my $ps ( @pairs_set ) {
734 0           my @extra = $self->propagate_relationship( $ps->[0], $ps->[1] );
735 0           push( @prop, @extra );
736             }
737 0 0         push( @pairs_set, @prop ) if @prop;
738             }
739            
740             # Finally, restore whatever collations we can, and return.
741 0           $self->_restore_weak( @$droppedcolls );
742 0           return @pairs_set;
743             }
744              
745             =head2 add_global_relationship( $options, $skipvector )
746              
747             Adds the relationship specified wherever the relevant readings appear together
748             in the graph. Options as in add_relationship above.
749              
750             =cut
751              
752             sub add_global_relationship {
753 0     0 1   my( $self, $relationship ) = @_;
754             # Sanity checking
755 0           my $reltype = $self->type( $relationship->type );
756 0 0         throw( "Relationship passed to add_global is not global" )
757             unless $relationship->nonlocal;
758 0 0         throw( "Relationship passed to add_global is not a valid global type" )
759             unless $reltype->is_generalizable;
760            
761             # Apply the relationship wherever it is valid
762 0           my @pairs_set;
763 0           foreach my $v ( $self->_find_applicable( $relationship ) ) {
764 0           my $exists = $self->get_relationship( @$v );
765 0 0         my $etype = $exists ? $self->type( $exists->type ) : '';
766 0 0 0       if( $exists && !$etype->is_weak ) {
767 0 0         unless( $exists->is_equivalent( $relationship ) ) {
768 0           throw( "Found conflicting relationship at @$v" );
769             }
770             } else {
771 0           my @added;
772 10     10   75945 try {
  0            
  0            
  0            
  0            
773 0           @added = $self->add_relationship( @$v, $relationship );
774 10 0   10   3268 } catch {
  0            
  0            
  0            
775 0           my $reldesc = sprintf( "%s %s -> %s", $relationship->type,
776             $relationship->reading_a, $relationship->reading_b );
777             # print STDERR "Global relationship $reldesc not applicable at @$v\n";
778             }
779 0 0         push( @pairs_set, @added ) if @added;
  0            
780             }
781             }
782 0           return @pairs_set;
783             }
784              
785              
786             =head2 del_scoped_relationship( $reading_a, $reading_b )
787              
788             Returns the general (document-level or global) relationship that has been defined
789             between the two reading strings. Returns undef if there is no general relationship.
790              
791             =cut
792              
793             sub del_scoped_relationship {
794 0     0 1   my( $self, $rdga, $rdgb ) = @_;
795 0           my( $first, $second ) = sort( $rdga, $rdgb );
796 0           return delete $self->scopedrels->{$first}->{$second};
797             }
798              
799             sub _find_applicable {
800 0     0     my( $self, $rel ) = @_;
801 0           my $c = $self->collation;
802 0           my $reltype = $self->type( $rel->type );
803 0           my @vectors;
804             my @identical_readings;
805 0           @identical_readings = grep { $reltype->regularize( $_ ) eq $rel->reading_a }
  0            
806             $c->readings;
807 0           foreach my $ir ( @identical_readings ) {
808 0           my @itarget;
809 0           @itarget = grep { $reltype->regularize( $_ ) eq $rel->reading_b }
  0            
810             $c->readings_at_rank( $ir->rank );
811 0 0         if( @itarget ) {
812             # Warn if there is more than one hit with no closer link between them.
813 0           my $itmain = shift @itarget;
814 0 0         if( @itarget ) {
815 0           my %all_targets;
816 0           my $bindlevel = $reltype->bindlevel;
817 0           map { $all_targets{$_} = 1 } @itarget;
  0            
818 0           map { delete $all_targets{$_} }
819             $self->related_readings( $itmain, sub {
820 0     0     $self->type( $_[0]->type )->bindlevel < $bindlevel } );
  0            
821 0 0         warn "More than one unrelated reading with text " . $itmain->text
822             . " at rank " . $ir->rank . "!" if keys %all_targets;
823             }
824 0           push( @vectors, [ $ir->id, $itmain->id ] );
825             }
826             }
827 0           return @vectors;
828             }
829              
830             =head2 del_relationship( $source, $target, $allscope )
831              
832             Removes the relationship between the given readings. If the relationship is
833             non-local and $allscope is true, removes the relationship throughout the
834             relevant scope.
835              
836             =cut
837              
838             sub del_relationship {
839 0     0 1   my( $self, $source, $target, $allscope ) = @_;
840 0           my $rel = $self->get_relationship( $source, $target );
841 0 0         return () unless $rel; # Nothing to delete; return an empty set.
842 0           my $reltype = $self->type( $rel->type );
843 0           my $colo = $rel->colocated;
844 0           my @vectors = ( [ $source, $target ] );
845 0           $self->_remove_relationship( $colo, $source, $target );
846 0 0 0       if( $rel->nonlocal && $allscope ) {
847             # Remove the relationship wherever it occurs.
848 0           my @rel_edges = grep { $self->get_relationship( @$_ ) eq $rel }
  0            
849             $self->relationships;
850 0           foreach my $re ( @rel_edges ) {
851 0           $self->_remove_relationship( $colo, @$re );
852 0           push( @vectors, $re );
853             }
854 0           $self->del_scoped_relationship( $rel->reading_a, $rel->reading_b );
855             }
856 0           return @vectors;
857             }
858              
859             sub _remove_relationship {
860 0     0     my( $self, $equiv, @vector ) = @_;
861 0           $self->graph->delete_edge( @vector );
862 0 0         $self->_break_equivalence( @vector ) if $equiv;
863             }
864            
865             =head2 relationship_valid( $source, $target, $type )
866              
867             Checks whether a relationship of type $type may exist between the readings given
868             in $source and $target. Returns a tuple of ( status, message ) where status is
869             a yes/no boolean and, if the answer is no, message gives the reason why.
870              
871             =cut
872              
873             sub relationship_valid {
874 0     0 1   my( $self, $source, $target, $rel, $mustdrop ) = @_;
875 0 0         $mustdrop = [] unless $mustdrop; # in case we were passed nothing
876 0           my $c = $self->collation;
877 0           my $reltype = $self->type( $rel );
878             ## Assume validity is okay if we are initializing from scratch.
879 0 0         return ( 1, "initializing" ) unless $c->tradition->_initialized;
880             ## TODO Move this block to relationship type definition when we can save
881             ## coderefs
882 0 0 0       if ( $rel eq 'transposition' || $rel eq 'repetition' ) {
883             # Check that the two readings do (for a repetition) or do not (for
884             # a transposition) appear in the same witness.
885             # TODO this might be called before witness paths are set...
886 0           my %seen_wits;
887 0           map { $seen_wits{$_} = 1 } $c->reading_witnesses( $source );
  0            
888 0           foreach my $w ( $c->reading_witnesses( $target ) ) {
889 0 0         if( $seen_wits{$w} ) {
890 0 0         return ( 0, "Readings both occur in witness $w" )
891             if $rel eq 'transposition';
892 0 0         return ( 1, "ok" ) if $rel eq 'repetition';
893             }
894             }
895 0 0         return ( 0, "Readings occur only in distinct witnesses" )
896             if $rel eq 'repetition';
897             }
898 0 0         if ( $reltype->is_colocation ) {
899             # Check that linking the source and target in a relationship won't lead
900             # to a path loop for any witness.
901             # First, drop/stash any collations that might interfere
902 0           my $sourceobj = $c->reading( $source );
903 0           my $targetobj = $c->reading( $target );
904 0 0         my $sourcerank = $sourceobj->has_rank ? $sourceobj->rank : -1;
905 0 0         my $targetrank = $targetobj->has_rank ? $targetobj->rank : -1;
906 0 0 0       unless( $rel eq 'collated' || $sourcerank == $targetrank ) {
907 0           push( @$mustdrop, $self->_drop_weak( $source ) );
908 0           push( @$mustdrop, $self->_drop_weak( $target ) );
909 0 0         if( $c->end->has_rank ) {
910 0           foreach my $rk ( $sourcerank .. $targetrank ) {
911 0           map { push( @$mustdrop, $self->_drop_weak( $_->id ) ) }
  0            
912             $c->readings_at_rank( $rk );
913             }
914             }
915             }
916 0 0         unless( $self->test_equivalence( $source, $target ) ) {
917 0           $self->_restore_weak( @$mustdrop );
918 0           return( 0, "Relationship would create witness loop" );
919             }
920 0           return ( 1, "ok" );
921             } else {
922             # We also need to check that the readings are not in the same place.
923             # That is, proposing to equate them should cause a witness loop.
924 0 0         if( $self->test_equivalence( $source, $target ) ) {
925 0           return ( 0, "Readings appear to be colocated" );
926             } else {
927 0           return ( 1, "ok" );
928             }
929             }
930             }
931              
932             sub _drop_weak {
933 0     0     my( $self, $reading ) = @_;
934 0           my @dropped;
935 0           foreach my $n ( $self->graph->neighbors( $reading ) ) {
936 0           my $nrel = $self->get_relationship( $reading, $n );
937 0 0         if( $self->type( $nrel->type )->is_weak ) {
938 0           push( @dropped, [ $reading, $n, $nrel->type ] );
939 0           $self->del_relationship( $reading, $n );
940             #print STDERR "Dropped weak relationship $reading -> $n\n";
941             }
942             }
943 0           return @dropped;
944             }
945              
946             sub _restore_weak {
947 0     0     my( $self, @vectors ) = @_;
948 0           foreach my $v ( @vectors ) {
949 0           my $type = pop @$v;
950 0           eval {
951 0           $self->add_relationship( @$v, { 'type' => $type } );
952             #print STDERR "Restored weak relationship @$v\n";
953             }; # if it fails we don't care
954             }
955             }
956              
957             =head2 verify_or_delete( $reading1, $reading2 ) {
958              
959             Given the existing relationship at ( $reading1, $reading2 ), make sure it is
960             still valid. If it is not still valid, delete it. Use this only to check
961             non-colocated relationships!
962              
963             =cut
964              
965             sub verify_or_delete {
966 0     0 1   my( $self, @vector ) = @_;
967 0           my $rel = $self->get_relationship( @vector );
968 0 0         throw( "You should not now be verifying colocated relationships!" )
969             if $rel->colocated;
970 0           my( $ok, $reason ) = $self->relationship_valid( @vector, $rel->type );
971 0 0         unless( $ok ) {
972 0           $self->del_relationship( @vector );
973             }
974 0           return $ok;
975             }
976            
977              
978             =head2 related_readings( $reading, $filter )
979              
980             Returns a list of readings that are connected via direct relationship links
981             to $reading. If $filter is set to a subroutine ref, returns only those
982             related readings where $filter( $relationship ) returns a true value.
983              
984             =cut
985              
986             sub related_readings {
987 0     0 1   my( $self, $reading, $filter ) = @_;
988 0           my $return_object;
989 0 0         if( ref( $reading ) eq 'Text::Tradition::Collation::Reading' ) {
990 0           $reading = $reading->id;
991 0           $return_object = 1;
992             }
993 0           my @answer;
994 0 0         if( $filter ) {
995             # Backwards compat
996 0 0         if( $filter eq 'colocated' ) {
    0          
997 0     0     $filter = sub { $_[0]->colocated };
  0            
998             } elsif( !ref( $filter ) ) {
999 0           my $type = $filter;
1000 0     0     $filter = sub { $_[0]->type eq $type };
  0            
1001             }
1002 0           @answer = grep { &$filter( $self->get_relationship( $reading, $_ ) ) }
  0            
1003             $self->graph->neighbors( $reading );
1004             } else {
1005 0           @answer = $self->graph->neighbors( $reading );
1006             }
1007 0 0         if( $return_object ) {
1008 0           my $c = $self->collation;
1009 0           return map { $c->reading( $_ ) } @answer;
  0            
1010             } else {
1011 0           return @answer;
1012             }
1013             }
1014              
1015             =head2 propagate_relationship( $rel )
1016              
1017             Apply the transitivity and binding level rules to propagate the consequences of
1018             the specified relationship link, ensuring all consequent relationships exist.
1019             For now, we only propagate colocation links if we are passed a colocation, and
1020             we only propagate displacement links if we are given a displacement.
1021              
1022             Returns an array of tuples ( rdg1, rdg2, type ) for each new reading set.
1023              
1024             =cut
1025              
1026             sub propagate_relationship {
1027 0     0 1   my( $self, @rel ) = @_;
1028             ## Check that the vector is an arrayref
1029 0 0         my $rel = @rel > 1 ? \@rel : $rel[0];
1030             ## Get the relationship info
1031 0           my $relobj = $self->get_relationship( $rel );
1032 0           my $reltype = $self->type( $relobj->type );
1033 0 0         return () unless $reltype->is_transitive;
1034 0           my @newly_set;
1035            
1036 0           my $colo = $reltype->is_colocation;
1037 0           my $bindlevel = $reltype->bindlevel;
1038            
1039             ## Find all readings that are linked via this relationship type
1040 0           my %thislevel = ( $rel->[0] => 1, $rel->[1] => 1 );
1041 0           my $check = $rel;
1042 0           my $iter = 0;
1043 0           while( @$check ) {
1044 0           my $more = [];
1045 0           foreach my $r ( @$check ) {
1046 0 0 0       push( @$more, grep { !exists $thislevel{$_}
  0            
1047             && $self->get_relationship( $r, $_ )
1048             && $self->get_relationship( $r, $_ )->type eq $relobj->type }
1049             $self->graph->neighbors( $r ) );
1050             }
1051 0           map { $thislevel{$_} = 1 } @$more;
  0            
1052 0           $check = $more;
1053             }
1054            
1055             ## Make sure every reading of our relationship type is linked to every other
1056 0           my @samelevel = keys %thislevel;
1057 0           while( @samelevel ) {
1058 0           my $r = shift @samelevel;
1059 0           foreach my $nr ( @samelevel ) {
1060 0           my $existing = $self->get_relationship( $r, $nr );
1061 0           my $skip;
1062 0 0         if( $existing ) {
1063 0           my $extype = $self->type( $existing->type );
1064 0 0         unless( $extype->is_weak ) {
1065             # Check that it's a matching type, or a type subsumed by our
1066             # bindlevel
1067 0 0 0       throw( "Conflicting existing relationship of type "
1068             . $existing->type . " at $r, $nr trying to propagate "
1069             . $relobj->type . " relationship at @$rel" )
1070             unless $existing->type eq $relobj->type
1071             || $extype->bindlevel <= $reltype->bindlevel;
1072 0           $skip = 1;
1073             }
1074             }
1075 0 0         unless( $skip ) {
1076             # Try to add a new relationship here
1077 10     10   18892 try {
  0            
  0            
  0            
  0            
1078 0           my @new = $self->add_relationship( $r, $nr, { type => $relobj->type,
1079             annotation => "Propagated from relationship at @$rel" } );
1080 0           push( @newly_set, @new );
1081 10 0   10   6724005 } catch ( Text::Tradition::Error $e ) {
  0 0          
  0            
  0            
  0            
1082 0           throw( "Could not propagate " . $relobj->type .
1083             " relationship (original @$rel) at $r -- $nr: " .
1084             $e->message );
1085             }
1086 0           }
  0            
  0            
1087             }
1088              
1089             ## Now for each sibling our set, look for its direct connections to
1090             ## transitive readings of a different bindlevel, and make sure that
1091             ## all siblings are related to those readings.
1092 0           my @other;
1093 0           foreach my $n ( $self->graph->neighbors( $r ) ) {
1094 0           my $crel = $self->get_relationship( $r, $n );
1095 0 0         next unless $crel;
1096 0           my $crt = $self->type( $crel->type );
1097 0 0 0       if( $crt->is_transitive && $crt->is_colocation == $colo ) {
1098 0 0         next if $crt->bindlevel == $reltype->bindlevel;
1099 0 0         my $nrel = $crt->bindlevel < $reltype->bindlevel
1100             ? $reltype->name : $crt->name;
1101 0           push( @other, [ $n, $nrel ] );
1102             }
1103             }
1104             # The @other array now contains tuples of ( reading, type ) where the
1105             # reading is the non-sibling and the type is the type of relationship
1106             # that the siblings should have to the non-sibling.
1107 0           foreach ( @other ) {
1108 0           my( $nr, $nrtype ) = @$_;
1109 0           foreach my $sib ( keys %thislevel ) {
1110 0 0         next if $sib eq $r;
1111 0 0         next if $sib eq $nr; # can happen if linked to $r by tightrel
1112             # but linked to a sib of $r by thisrel
1113             # e.g. when a rel has been part propagated
1114 0           my $existing = $self->get_relationship( $sib, $nr );
1115 0           my $skip;
1116 0 0         if( $existing ) {
1117             # Check that it's compatible. The existing relationship type
1118             # should match or be subsumed by the looser of the two
1119             # relationships in play, whether the original relationship
1120             # being worked on or the relationship between $r and $or.
1121 0           my $extype = $self->type( $existing->type );
1122 0 0         unless( $extype->is_weak ) {
1123 0 0 0       if( $nrtype ne $extype->name
1124             && $self->type( $nrtype )->bindlevel <= $extype->bindlevel ) {
1125 0           throw( "Conflicting existing relationship at $nr ( -> "
1126             . $self->get_relationship( $nr, $r )->type . " to $r) "
1127             . " -- $sib trying to propagate " . $relobj->type
1128             . " relationship at @$rel" );
1129             }
1130 0           $skip = 1;
1131             }
1132             }
1133 0 0         unless( $skip ) {
1134             # Try to add a new relationship here
1135 10     10   7473 try {
  0            
  0            
  0            
  0            
1136 0           my @new = $self->add_relationship( $sib, $nr, { type => $nrtype,
1137             annotation => "Propagated from relationship at @$rel" } );
1138 0           push( @newly_set, @new );
1139 10 0   10   258485 } catch ( Text::Tradition::Error $e ) {
  0 0          
  0            
  0            
  0            
1140 0           throw( "Could not propagate $nrtype relationship (original " .
1141             $relobj->type . " at @$rel) at $sib -- $nr: " .
1142             $e->message );
1143             }
1144 0           }
  0            
  0            
1145             }
1146             }
1147             }
1148            
1149 0           return @newly_set;
1150             }
1151              
1152             =head2 propagate_all_relationships
1153              
1154             Apply propagation logic retroactively to all relationships in the tradition.
1155              
1156             =cut
1157              
1158             sub propagate_all_relationships {
1159 0     0 1   my $self = shift;
1160 0           my @allrels = sort { $self->_propagate_rel_order( $a, $b ) } $self->relationships;
  0            
1161 0           foreach my $rel ( @allrels ) {
1162 0           my $relobj = $self->get_relationship( $rel );
1163 0 0         if( $self->type( $relobj->type )->is_transitive ) {
1164 0           my @added = $self->propagate_relationship( $rel );
1165             }
1166             }
1167             }
1168              
1169             # Helper sorting function for retroactive propagation order.
1170             sub _propagate_rel_order {
1171 0     0     my( $self, $a, $b ) = @_;
1172 0           my $aobj = $self->get_relationship( $a );
1173 0           my $bobj = $self->get_relationship( $b );
1174 0           my $at = $self->type( $aobj->type ); my $bt = $self->type( $bobj->type );
  0            
1175             # Apply strong relationships before weak
1176 0 0 0       return -1 if $bt->is_weak && !$at->is_weak;
1177 0 0 0       return 1 if $at->is_weak && !$bt->is_weak;
1178             # Apply more tightly bound relationships first
1179 0           return $at->bindlevel <=> $bt->bindlevel;
1180             }
1181              
1182              
1183             =head2 merge_readings( $kept, $deleted );
1184              
1185             Makes a best-effort merge of the relationship links between the given readings, and
1186             stops tracking the to-be-deleted reading.
1187              
1188             =cut
1189              
1190             sub merge_readings {
1191 0     0 1   my( $self, $kept, $deleted, $combined ) = @_;
1192 0           foreach my $edge ( $self->graph->edges_at( $deleted ) ) {
1193             # Get the pair of kept / rel
1194 0           my @vector = ( $kept );
1195 0 0         push( @vector, $edge->[0] eq $deleted ? $edge->[1] : $edge->[0] );
1196 0 0         next if $vector[0] eq $vector[1]; # Don't add a self loop
1197            
1198             # If kept changes its text, drop the relationship.
1199 0 0         next if $combined;
1200            
1201             # If kept / rel already has a relationship, just keep the old
1202 0           my $rel = $self->get_relationship( @vector );
1203 0 0         next if $rel;
1204            
1205             # Otherwise, adopt the relationship that would be deleted.
1206 0           $rel = $self->get_relationship( @$edge );
1207 0           $self->_set_relationship( $rel, @vector );
1208             }
1209 0           $self->_make_equivalence( $deleted, $kept );
1210             }
1211              
1212             ### Equivalence logic
1213              
1214             sub _remove_equivalence_node {
1215 0     0     my( $self, $node ) = @_;
1216 0           my $group = $self->equivalence( $node );
1217 0           my $nodelist = $self->eqreadings( $group );
1218 0 0 0       if( @$nodelist == 1 && $nodelist->[0] eq $node ) {
    0          
1219 0           $self->equivalence_graph->delete_vertex( $group );
1220 0           $self->remove_eqreadings( $group );
1221 0           $self->remove_equivalence( $group );
1222             } elsif( @$nodelist == 1 ) {
1223 0           throw( "DATA INCONSISTENCY in equivalence graph: " . $nodelist->[0] .
1224             " in group that should have only $node" );
1225             } else {
1226 0           my @newlist = grep { $_ ne $node } @$nodelist;
  0            
1227 0           $self->set_eqreadings( $group, \@newlist );
1228 0           $self->remove_equivalence( $node );
1229             }
1230             }
1231              
1232             =head2 add_equivalence_edge
1233              
1234             Add an edge in the equivalence graph corresponding to $source -> $target in the
1235             collation. Should only be called by Collation.
1236              
1237             =cut
1238              
1239             sub add_equivalence_edge {
1240 0     0 1   my( $self, $source, $target ) = @_;
1241 0           my $seq = $self->equivalence( $source );
1242 0           my $teq = $self->equivalence( $target );
1243 0           $self->equivalence_graph->add_edge( $seq, $teq );
1244             }
1245              
1246             =head2 delete_equivalence_edge
1247              
1248             Remove an edge in the equivalence graph corresponding to $source -> $target in the
1249             collation. Should only be called by Collation.
1250              
1251             =cut
1252              
1253             sub delete_equivalence_edge {
1254 0     0 1   my( $self, $source, $target ) = @_;
1255 0           my $seq = $self->equivalence( $source );
1256 0           my $teq = $self->equivalence( $target );
1257 0           $self->equivalence_graph->delete_edge( $seq, $teq );
1258             }
1259              
1260             sub _is_disconnected {
1261 0     0     my $self = shift;
1262 0   0       return( scalar $self->equivalence_graph->predecessorless_vertices > 1
1263             || scalar $self->equivalence_graph->successorless_vertices > 1 );
1264             }
1265              
1266             # Equate two readings in the equivalence graph
1267             sub _make_equivalence {
1268 0     0     my( $self, $source, $target ) = @_;
1269             # Get the source equivalent readings
1270 0           my $seq = $self->equivalence( $source );
1271 0           my $teq = $self->equivalence( $target );
1272             # Nothing to do if they are already equivalent...
1273 0 0         return if $seq eq $teq;
1274 0           my $sourcepool = $self->eqreadings( $seq );
1275             # and add them to the target readings.
1276 0           push( @{$self->eqreadings( $teq )}, @$sourcepool );
  0            
1277 0           map { $self->set_equivalence( $_, $teq ) } @$sourcepool;
  0            
1278             # Then merge the nodes in the equivalence graph.
1279 0           foreach my $pred ( $self->equivalence_graph->predecessors( $seq ) ) {
1280 0 0         next if $pred eq $teq; # don't add a self-loop on concatenation merge
1281 0           $self->equivalence_graph->add_edge( $pred, $teq );
1282             }
1283 0           foreach my $succ ( $self->equivalence_graph->successors( $seq ) ) {
1284 0 0         next if $succ eq $teq; # don't add a self-loop on concatenation merge
1285 0           $self->equivalence_graph->add_edge( $teq, $succ );
1286             }
1287 0           $self->equivalence_graph->delete_vertex( $seq );
1288 0 0 0       throw( "Graph got disconnected making $source / $target equivalence" )
1289             if $self->_is_disconnected && $self->collation->tradition->_initialized;
1290             }
1291              
1292             =head2 test_equivalence
1293              
1294             Test whether, if two readings were equated with a 'colocated' relationship,
1295             the graph would still be valid.
1296              
1297             =cut
1298              
1299             # TODO Used the 'is_reachable' method; it killed performance. Think about doing away
1300             # with the equivalence graph in favor of a transitive closure graph (calculated ONCE)
1301             # on the sequence graph, and test that way.
1302              
1303             sub test_equivalence {
1304 0     0 1   my( $self, $source, $target ) = @_;
1305             # Try merging the nodes in the equivalence graph; return a true value if
1306             # no cycle is introduced thereby. Restore the original graph first.
1307            
1308             # Keep track of edges we add
1309 0           my %added_pred;
1310             my %added_succ;
1311             # Get the reading equivalents
1312 0           my $seq = $self->equivalence( $source );
1313 0           my $teq = $self->equivalence( $target );
1314             # Maybe this is easy?
1315 0 0         return 1 if $seq eq $teq;
1316            
1317             # Save the first graph
1318 0           my $checkstr = $self->equivalence_graph->stringify();
1319             # Add and save relevant edges
1320 0           foreach my $pred ( $self->equivalence_graph->predecessors( $seq ) ) {
1321 0 0         if( $self->equivalence_graph->has_edge( $pred, $teq ) ) {
1322 0           $added_pred{$pred} = 0;
1323             } else {
1324 0           $self->equivalence_graph->add_edge( $pred, $teq );
1325 0           $added_pred{$pred} = 1;
1326             }
1327             }
1328 0           foreach my $succ ( $self->equivalence_graph->successors( $seq ) ) {
1329 0 0         if( $self->equivalence_graph->has_edge( $teq, $succ ) ) {
1330 0           $added_succ{$succ} = 0;
1331             } else {
1332 0           $self->equivalence_graph->add_edge( $teq, $succ );
1333 0           $added_succ{$succ} = 1;
1334             }
1335             }
1336             # Delete source equivalent and test
1337 0           $self->equivalence_graph->delete_vertex( $seq );
1338 0           my $ret = !$self->equivalence_graph->has_a_cycle;
1339            
1340             # Restore what we changed
1341 0           $self->equivalence_graph->add_vertex( $seq );
1342 0           foreach my $pred ( keys %added_pred ) {
1343 0           $self->equivalence_graph->add_edge( $pred, $seq );
1344 0 0         $self->equivalence_graph->delete_edge( $pred, $teq ) if $added_pred{$pred};
1345             }
1346 0           foreach my $succ ( keys %added_succ ) {
1347 0           $self->equivalence_graph->add_edge( $seq, $succ );
1348 0 0         $self->equivalence_graph->delete_edge( $teq, $succ ) if $added_succ{$succ};
1349             }
1350 0 0         unless( $self->equivalence_graph->eq( $checkstr ) ) {
1351 0           throw( "GRAPH CHANGED after testing" );
1352             }
1353             # Return our answer
1354 0           return $ret;
1355             }
1356              
1357             # Unmake an equivalence link between two readings. Should only be called internally.
1358             sub _break_equivalence {
1359 0     0     my( $self, $source, $target ) = @_;
1360            
1361             # This is the hard one. Need to reconstruct the equivalence groups without
1362             # the given link.
1363 0           my( %sng, %tng );
1364 0           map { $sng{$_} = 1 } $self->_find_equiv_without( $source, $target );
  0            
1365 0           map { $tng{$_} = 1 } $self->_find_equiv_without( $target, $source );
  0            
1366             # If these groups intersect, they are still connected; do nothing.
1367 0           foreach my $el ( keys %tng ) {
1368 0 0         return if( exists $sng{$el} );
1369             }
1370             # If they don't intersect, then we split the nodes in the graph and in
1371             # the hashes. First figure out which group has which name
1372 0           my $oldgroup = $self->equivalence( $source ); # same as $target
1373 0           my $keepsource = $sng{$oldgroup};
1374 0 0         my $newgroup = $keepsource ? $target : $source;
1375 0           my( $oldmembers, $newmembers );
1376 0 0         if( $keepsource ) {
1377 0           $oldmembers = [ keys %sng ];
1378 0           $newmembers = [ keys %tng ];
1379             } else {
1380 0           $oldmembers = [ keys %tng ];
1381 0           $newmembers = [ keys %sng ];
1382             }
1383            
1384             # First alter the old group in the hash
1385 0           $self->set_eqreadings( $oldgroup, $oldmembers );
1386 0           foreach my $el ( @$oldmembers ) {
1387 0           $self->set_equivalence( $el, $oldgroup );
1388             }
1389            
1390             # then add the new group back to the hash with its new key
1391 0           $self->set_eqreadings( $newgroup, $newmembers );
1392 0           foreach my $el ( @$newmembers ) {
1393 0           $self->set_equivalence( $el, $newgroup );
1394             }
1395            
1396             # Now add the new group back to the equivalence graph
1397 0           $self->equivalence_graph->add_vertex( $newgroup );
1398             # ...add the appropriate edges to the source group vertext
1399 0           my $c = $self->collation;
1400 0           foreach my $rdg ( @$newmembers ) {
1401 0           foreach my $rp ( $c->sequence->predecessors( $rdg ) ) {
1402 0 0         next unless $self->equivalence( $rp );
1403 0           $self->equivalence_graph->add_edge( $self->equivalence( $rp ), $newgroup );
1404             }
1405 0           foreach my $rs ( $c->sequence->successors( $rdg ) ) {
1406 0 0         next unless $self->equivalence( $rs );
1407 0           $self->equivalence_graph->add_edge( $newgroup, $self->equivalence( $rs ) );
1408             }
1409             }
1410            
1411             # ...and figure out which edges on the old group vertex to delete.
1412 0           my( %old_pred, %old_succ );
1413 0           foreach my $rdg ( @$oldmembers ) {
1414 0           foreach my $rp ( $c->sequence->predecessors( $rdg ) ) {
1415 0 0         next unless $self->equivalence( $rp );
1416 0           $old_pred{$self->equivalence( $rp )} = 1;
1417             }
1418 0           foreach my $rs ( $c->sequence->successors( $rdg ) ) {
1419 0 0         next unless $self->equivalence( $rs );
1420 0           $old_succ{$self->equivalence( $rs )} = 1;
1421             }
1422             }
1423 0           foreach my $p ( $self->equivalence_graph->predecessors( $oldgroup ) ) {
1424 0 0         unless( $old_pred{$p} ) {
1425 0           $self->equivalence_graph->delete_edge( $p, $oldgroup );
1426             }
1427             }
1428 0           foreach my $s ( $self->equivalence_graph->successors( $oldgroup ) ) {
1429 0 0         unless( $old_succ{$s} ) {
1430 0           $self->equivalence_graph->delete_edge( $oldgroup, $s );
1431             }
1432             }
1433             # TODO enable this after collation parsing is done
1434 0 0 0       throw( "Graph got disconnected breaking $source / $target equivalence" )
1435             if $self->_is_disconnected && $self->collation->tradition->_initialized;
1436             }
1437              
1438             sub _find_equiv_without {
1439 0     0     my( $self, $first, $second ) = @_;
1440 0           my %found = ( $first => 1 );
1441 0           my $check = [ $first ];
1442 0           my $iter = 0;
1443 0           while( @$check ) {
1444 0           my $more = [];
1445 0           foreach my $r ( @$check ) {
1446 0           foreach my $nr ( $self->graph->neighbors( $r ) ) {
1447 0 0         next if $r eq $second;
1448 0 0         if( $self->get_relationship( $r, $nr )->colocated ) {
1449 0 0         push( @$more, $nr ) unless exists $found{$nr};
1450 0           $found{$nr} = 1;
1451             }
1452             }
1453             }
1454 0           $check = $more;
1455             }
1456 0           return keys %found;
1457             }
1458              
1459             =head2 rebuild_equivalence
1460              
1461             (Re)build the equivalence graph from scratch. Dumps the graph, makes a new one,
1462             adds all readings and edges, then makes an equivalence for all relationships.
1463              
1464             =cut
1465              
1466             sub rebuild_equivalence {
1467 0     0 1   my $self = shift;
1468 0           my $newgraph = Graph->new();
1469             # Set this as the new equivalence graph
1470 0           $self->_reset_equivalence( $newgraph );
1471             # Clear out the data hashes
1472 0           $self->_clear_equivalence;
1473 0           $self->_clear_eqreadings;
1474            
1475 0           $self->collation->tradition->_init_done(0);
1476             # Add the readings
1477 0           foreach my $r ( $self->collation->readings ) {
1478 0           my $rid = $r->id;
1479 0           $newgraph->add_vertex( $rid );
1480 0           $self->set_equivalence( $rid, $rid );
1481 0           $self->set_eqreadings( $rid, [ $rid ] );
1482             }
1483              
1484             # Now add the edges
1485 0           foreach my $e ( $self->collation->paths ) {
1486 0           $self->add_equivalence_edge( @$e );
1487             }
1488              
1489             # Now equate the colocated readings. This does no testing;
1490             # it assumes that all preexisting relationships are valid.
1491 0           foreach my $rel ( $self->relationships ) {
1492 0           my $relobj = $self->get_relationship( $rel );
1493 0 0 0       next unless $relobj && $relobj->colocated;
1494 0           $self->_make_equivalence( @$rel );
1495             }
1496 0           $self->collation->tradition->_init_done(1);
1497             }
1498              
1499             =head2 equivalence_ranks
1500              
1501             Rank all vertices in the equivalence graph, and return a hash reference with
1502             vertex => rank mapping.
1503              
1504             =cut
1505              
1506             sub equivalence_ranks {
1507 0     0 1   my $self = shift;
1508 0           my $eqstart = $self->equivalence( $self->collation->start );
1509 0           my $eqranks = { $eqstart => 0 };
1510 0           my $rankeqs = { 0 => [ $eqstart ] };
1511 0           my @curr_origin = ( $eqstart );
1512             # A little iterative function.
1513 0           while( @curr_origin ) {
1514 0           @curr_origin = $self->_assign_rank( $eqranks, $rankeqs, @curr_origin );
1515             }
1516 0           return( $eqranks, $rankeqs );
1517             }
1518              
1519             sub _assign_rank {
1520 0     0     my( $self, $node_ranks, $rank_nodes, @current_nodes ) = @_;
1521 0           my $graph = $self->equivalence_graph;
1522             # Look at each of the children of @current_nodes. If all the child's
1523             # parents have a rank, assign it the highest rank + 1 and add it to
1524             # @next_nodes. Otherwise skip it; we will return when the highest-ranked
1525             # parent gets a rank.
1526 0           my @next_nodes;
1527 0           foreach my $c ( @current_nodes ) {
1528             warn "Current reading $c has no rank!"
1529 0 0         unless exists $node_ranks->{$c};
1530 0           foreach my $child ( $graph->successors( $c ) ) {
1531 0 0         next if exists $node_ranks->{$child};
1532 0           my $highest_rank = -1;
1533 0           my $skip = 0;
1534 0           foreach my $parent ( $graph->predecessors( $child ) ) {
1535 0 0         if( exists $node_ranks->{$parent} ) {
1536             $highest_rank = $node_ranks->{$parent}
1537 0 0         if $highest_rank <= $node_ranks->{$parent};
1538             } else {
1539 0           $skip = 1;
1540 0           last;
1541             }
1542             }
1543 0 0         next if $skip;
1544 0           my $c_rank = $highest_rank + 1;
1545             # print STDERR "Assigning rank $c_rank to node $child \n";
1546 0 0         $node_ranks->{$child} = $c_rank if $node_ranks;
1547 0 0         push( @{$rank_nodes->{$c_rank}}, $child ) if $rank_nodes;
  0            
1548 0           push( @next_nodes, $child );
1549             }
1550             }
1551 0           return @next_nodes;
1552             }
1553              
1554             ### Output logic
1555              
1556             sub _as_graphml {
1557 0     0     my( $self, $graphml_ns, $xmlroot, $node_hash, $nodeid_key, $edge_keys ) = @_;
1558            
1559 0           my $rgraph = $xmlroot->addNewChild( $graphml_ns, 'graph' );
1560 0           $rgraph->setAttribute( 'edgedefault', 'directed' );
1561 0           $rgraph->setAttribute( 'id', 'relationships', );
1562 0           $rgraph->setAttribute( 'parse.edgeids', 'canonical' );
1563 0           $rgraph->setAttribute( 'parse.edges', 0 );
1564 0           $rgraph->setAttribute( 'parse.nodeids', 'canonical' );
1565 0           $rgraph->setAttribute( 'parse.nodes', 0 );
1566 0           $rgraph->setAttribute( 'parse.order', 'nodesfirst' );
1567            
1568             # Add the vertices according to their XML IDs
1569 0           my %rdg_lookup = ( reverse %$node_hash );
1570             # my @nlist = sort _by_xmlid keys( %rdg_lookup ); ## CAUSES SEGFAULT
1571 0           my @nlist = sort keys( %rdg_lookup );
1572 0           foreach my $n ( @nlist ) {
1573 0           my $n_el = $rgraph->addNewChild( $graphml_ns, 'node' );
1574 0           $n_el->setAttribute( 'id', $n );
1575 0           _add_graphml_data( $n_el, $nodeid_key, $rdg_lookup{$n} );
1576             }
1577 0           $rgraph->setAttribute( 'parse.nodes', scalar @nlist );
1578            
1579             # Add the relationship edges, with their object information
1580 0           my $edge_ctr = 0;
1581 0           foreach my $e ( sort { $a->[0] cmp $b->[0] } $self->graph->edges ) {
  0            
1582             # Add an edge and fill in its relationship info.
1583 0 0 0       next unless( exists $node_hash->{$e->[0]} && exists $node_hash->{$e->[1]} );
1584 0           my $edge_el = $rgraph->addNewChild( $graphml_ns, 'edge' );
1585 0           $edge_el->setAttribute( 'source', $node_hash->{$e->[0]} );
1586 0           $edge_el->setAttribute( 'target', $node_hash->{$e->[1]} );
1587 0           $edge_el->setAttribute( 'id', 'e'.$edge_ctr++ );
1588              
1589 0           my $rel_obj = $self->get_relationship( @$e );
1590 0           foreach my $key ( keys %$edge_keys ) {
1591 0           my $value = $rel_obj->$key;
1592 0 0         _add_graphml_data( $edge_el, $edge_keys->{$key}, $value )
1593             if defined $value;
1594             }
1595             }
1596 0           $rgraph->setAttribute( 'parse.edges', $edge_ctr );
1597             }
1598              
1599             sub _by_xmlid {
1600 0     0     my $tmp_a = $a;
1601 0           my $tmp_b = $b;
1602 0           $tmp_a =~ s/\D//g;
1603 0           $tmp_b =~ s/\D//g;
1604 0           return $tmp_a <=> $tmp_b;
1605             }
1606              
1607             sub _add_graphml_data {
1608 0     0     my( $el, $key, $value ) = @_;
1609 0 0         return unless defined $value;
1610 0           my $data_el = $el->addNewChild( $el->namespaceURI, 'data' );
1611 0           $data_el->setAttribute( 'key', $key );
1612 0           $data_el->appendText( $value );
1613             }
1614              
1615             sub _dump_segment {
1616 0     0     my( $self, $from, $to ) = @_;
1617 0 0         open( DUMP, ">debug.svg" ) or die "Could not open debug.svg";
1618 0           binmode DUMP, ':utf8';
1619 0           print DUMP $self->collation->as_svg({ from => $from, to => $to, nocalc => 1 });
1620 0           close DUMP;
1621             }
1622              
1623             sub throw {
1624 0     0 0   Text::Tradition::Error->throw(
1625             'ident' => 'Relationship error',
1626             'message' => $_[0],
1627             );
1628             }
1629              
1630 10     10   32565 no Moose;
  10         36  
  10         105  
1631             __PACKAGE__->meta->make_immutable;
1632              
1633             1;