Merge branch 'master' of github.com:tla/stemmatology
[scpubgit/stemmatology.git] / lib / Text / Tradition / Collation.pm
index 0bbcdf3..2b5dbeb 100644 (file)
@@ -7,7 +7,9 @@ use IPC::Run qw( run binary );
 use Text::CSV_XS;
 use Text::Tradition::Collation::Reading;
 use Text::Tradition::Collation::RelationshipStore;
+use Text::Tradition::Error;
 use XML::LibXML;
+use XML::LibXML::XPathContext;
 use Moose;
 
 has 'sequence' => (
@@ -25,6 +27,7 @@ has 'relations' => (
        handles => {
                relationships => 'relationships',
                related_readings => 'related_readings',
+               del_relationship => 'del_relationship',
        },
        writer => '_set_relations',
        );
@@ -66,12 +69,6 @@ has 'linear' => (
     default => 1,
     );
     
-has 'collapse_punctuation' => (
-       is => 'rw',
-       isa => 'Bool',
-       default => 1,
-       );
-
 has 'ac_label' => (
     is => 'rw',
     isa => 'Str',
@@ -91,6 +88,13 @@ has 'end' => (
        writer => '_set_end',
        weak_ref => 1,
        );
+       
+has 'cached_svg' => (
+       is => 'rw',
+       isa => 'Str',
+       predicate => 'has_cached_svg',
+       clearer => 'wipe_svg',
+       );
 
 =head1 NAME
 
@@ -133,8 +137,6 @@ belongs. Required.
 transposed readings should be treated as two linked readings rather than one, 
 and therefore whether the collation graph is acyclic.  Defaults to true.
 
-=item * collapse_punctuation - TODO
-
 =item * baselabel - The default label for the path taken by a base text 
 (if any). Defaults to 'base text'.
 
@@ -154,8 +156,6 @@ the like.  Defaults to ' (a.c.)'.
 
 =head2 linear
 
-=head2 collapse_punctuation
-
 =head2 wit_list_separator
 
 =head2 baselabel
@@ -256,8 +256,7 @@ sub add_reading {
        }
        # First check to see if a reading with this ID exists.
        if( $self->reading( $reading->id ) ) {
-               warn "Collation already has a reading with id " . $reading->id;
-               return undef;
+               throw( "Collation already has a reading with id " . $reading->id );
        }
        $self->_add_reading( $reading->id => $reading );
        # Once the reading has been added, put it in both graphs.
@@ -411,11 +410,11 @@ sub clear_witness {
 sub add_relationship {
        my $self = shift;
     my( $source, $target, $opts ) = $self->_stringify_args( @_ );
-    my( $ret, @vectors ) = $self->relations->add_relationship( $source, 
+    my( @vectors ) = $self->relations->add_relationship( $source, 
        $self->reading( $source ), $target, $self->reading( $target ), $opts );
     # Force a full rank recalculation every time. Yuck.
-    $self->calculate_ranks() if $ret && $self->end->has_rank;
-    return( $ret, @vectors );
+    $self->calculate_ranks() if $self->end->has_rank;
+    return @vectors;
 }
 
 =head2 reading_witnesses( $reading )
@@ -441,69 +440,60 @@ sub reading_witnesses {
 
 =head1 OUTPUT METHODS
 
-=head2 as_svg
+=head2 as_svg( \%options )
 
 Returns an SVG string that represents the graph, via as_dot and graphviz.
+See as_dot for a list of options.
 
 =cut
 
 sub as_svg {
-    my( $self ) = @_;
-        
-    my @cmd = qw/dot -Tsvg/;
-    my( $svg, $err );
-    my $dotfile = File::Temp->new();
-    ## TODO REMOVE
-    # $dotfile->unlink_on_destroy(0);
-    binmode $dotfile, ':utf8';
-    print $dotfile $self->as_dot();
-    push( @cmd, $dotfile->filename );
-    run( \@cmd, ">", binary(), \$svg );
-    $svg = decode_utf8( $svg );
-    return $svg;
+    my( $self, $opts ) = @_;
+    my $want_subgraph = exists $opts->{'from'} || exists $opts->{'to'};
+    if( !$self->has_cached_svg || $opts->{'recalc'}    || $want_subgraph ) {        
+               my @cmd = qw/dot -Tsvg/;
+               my( $svg, $err );
+               my $dotfile = File::Temp->new();
+               ## USE FOR DEBUGGING
+               # $dotfile->unlink_on_destroy(0);
+               binmode $dotfile, ':utf8';
+               print $dotfile $self->as_dot( $opts );
+               push( @cmd, $dotfile->filename );
+               run( \@cmd, ">", binary(), \$svg );
+               $svg = decode_utf8( $svg );
+               $self->cached_svg( $svg ) unless $want_subgraph;
+               return $svg;
+       } else {
+               return $self->cached_svg;
+       }
 }
 
-=head2 svg_subgraph( $from, $to )
 
-Returns an SVG string that represents the portion of the graph given by the
-specified range.  The $from and $to variables refer to ranks within the graph.
+=head2 as_dot( \%options )
 
-=cut
+Returns a string that is the collation graph expressed in dot
+(i.e. GraphViz) format.  Options include:
 
-sub svg_subgraph {
-    my( $self, $from, $to ) = @_;
-    
-    my $dot = $self->as_dot( $from, $to );
-    unless( $dot ) {
-       warn "Could not output a graph with range $from - $to";
-       return;
-    }
-    
-    my @cmd = qw/dot -Tsvg/;
-    my( $svg, $err );
-    my $dotfile = File::Temp->new();
-    ## TODO REMOVE
-    # $dotfile->unlink_on_destroy(0);
-    binmode $dotfile, ':utf8';
-    print $dotfile $dot;
-    push( @cmd, $dotfile->filename );
-    run( \@cmd, ">", binary(), \$svg );
-    $svg = decode_utf8( $svg );
-    return $svg;
-}
+=over 4
 
+=item * from
 
-=head2 as_dot( $from, $to )
+=item * to
 
-Returns a string that is the collation graph expressed in dot
-(i.e. GraphViz) format.  If $from or $to is passed, as_dot creates
-a subgraph rather than the entire graph.
+=item * color_common
+
+=back
 
 =cut
 
 sub as_dot {
-    my( $self, $startrank, $endrank ) = @_;
-    
+    my( $self, $opts ) = @_;
+    my $startrank = $opts->{'from'} if $opts;
+    my $endrank = $opts->{'to'} if $opts;
+    my $color_common = $opts->{'color_common'} if $opts;
+    my $STRAIGHTENHACK = !$startrank && !$endrank && $self->end->rank 
+       && $self->end->rank > 100;
+
     # Check the arguments
     if( $startrank ) {
        return if $endrank && $startrank > $endrank;
@@ -514,15 +504,29 @@ sub as_dot {
                $endrank = undef if $endrank == $self->end->rank;
        }
        
-    # TODO consider making some of these things configurable
     my $graph_name = $self->tradition->name;
     $graph_name =~ s/[^\w\s]//g;
     $graph_name = join( '_', split( /\s+/, $graph_name ) );
+
+    my %graph_attrs = (
+       'rankdir' => 'LR',
+       'bgcolor' => 'none',
+       );
+    my %node_attrs = (
+       'fontsize' => 14,
+       'fillcolor' => 'white',
+       'style' => 'filled',
+       'shape' => 'ellipse'
+       );
+    my %edge_attrs = ( 
+       'arrowhead' => 'open',
+       'color' => '#000000',
+       'fontcolor' => '#000000',
+       );
+
     my $dot = sprintf( "digraph %s {\n", $graph_name );
-    $dot .= "\tedge [ arrowhead=open ];\n";
-    $dot .= "\tgraph [ rankdir=LR,bgcolor=none ];\n";
-    $dot .= sprintf( "\tnode [ fontsize=%d, fillcolor=%s, style=%s, shape=%s ];\n",
-                     11, "white", "filled", "ellipse" );
+    $dot .= "\tgraph " . _dot_attr_string( \%graph_attrs ) . ";\n";
+    $dot .= "\tnode " . _dot_attr_string( \%node_attrs ) . ";\n";
 
        # Output substitute start/end readings if necessary
        if( $startrank ) {
@@ -531,61 +535,137 @@ sub as_dot {
        if( $endrank ) {
                $dot .= "\t\"#SUBEND#\" [ label=\"...\" ];\n";  
        }
+       if( $STRAIGHTENHACK ) {
+               ## HACK part 1
+               $dot .= "\tsubgraph { rank=same \"#START#\" \"#SILENT#\" }\n";  
+               $dot .= "\t\"#SILENT#\" [ shape=diamond,color=white,penwidth=0,label=\"\" ];"
+       }
        my %used;  # Keep track of the readings that actually appear in the graph
-       my %subedges;
-       my %subend;
-    foreach my $reading ( $self->readings ) {
+       # Sort the readings by rank if we have ranks; this speeds layout.
+       my @all_readings = $self->end->has_rank 
+               ? sort { $a->rank <=> $b->rank } $self->readings
+               : $self->readings;
+       # TODO Refrain from outputting lacuna nodes - just grey out the edges.
+    foreach my $reading ( @all_readings ) {
        # Only output readings within our rank range.
        next if $startrank && $reading->rank < $startrank;
        next if $endrank && $reading->rank > $endrank;
         $used{$reading->id} = 1;
-        $subedges{$reading->id} = '#SUBSTART#' 
-               if $startrank && $startrank == $reading->rank;
-        $subedges{$reading->id} = '#SUBEND#' 
-               if $endrank && $endrank == $reading->rank;
         # Need not output nodes without separate labels
         next if $reading->id eq $reading->text;
-        my $label = $reading->punctuated_form;
+        my $rattrs;
+        my $label = $reading->text;
         $label =~ s/\"/\\\"/g;
-        $dot .= sprintf( "\t\"%s\" [ label=\"%s\" ];\n", $reading->id, $label );
+               $rattrs->{'label'} = $label;
+               $rattrs->{'fillcolor'} = '#b3f36d' if $reading->is_common && $color_common;
+        $dot .= sprintf( "\t\"%s\" %s;\n", $reading->id, _dot_attr_string( $rattrs ) );
     }
     
-    # Add substitute start and end edges if necessary
-    foreach my $node ( keys %subedges ) {
-               my @vector = ( $subedges{$node}, $node );
-               @vector = reverse( @vector ) if $vector[0] =~ /END/;
-       my $witstr = join( ', ', sort $self->reading_witnesses( $self->reading( $node ) ) );
-       my %variables = ( 'color' => '#000000',
-                          'fontcolor' => '#000000',
-                          'label' => $witstr,
-            );
-        my $varopts = join( ', ', map { $_.'="'.$variables{$_}.'"' } sort keys %variables );
-               $dot .= sprintf( "\t\"%s\" -> \"%s\" [ %s ];\n", @vector, $varopts );
-       }
-       
-       # Add the real edges
+       # Add the real edges. Need to weight one edge per rank jump, in a
+       # continuous line.
+       # my $weighted = $self->_add_edge_weights;
     my @edges = $self->paths;
+       my( %substart, %subend );
     foreach my $edge ( @edges ) {
        # Do we need to output this edge?
-       if( $used{$edge->[0]} && $used{$edge->[1]} ) {;
-                       my %variables = ( 'color' => '#000000',
-                                                         'fontcolor' => '#000000',
-                                                         'label' => join( ', ', $self->path_display_label( $edge ) ),
-                               );
-                       my $varopts = join( ', ', map { $_.'="'.$variables{$_}.'"' } sort keys %variables );
+       if( $used{$edge->[0]} && $used{$edge->[1]} ) {
+               my $label = $self->_path_display_label( $self->path_witnesses( $edge ) );
+                       my $variables = { %edge_attrs, 'label' => $label };
+                       
                        # Account for the rank gap if necessary
-                       my $rankgap = $self->reading( $edge->[1] )->rank 
-                               - $self->reading( $edge->[0] )->rank;
-                       $varopts .= ", minlen=$rankgap" if $rankgap > 1;
-                       $dot .= sprintf( "\t\"%s\" -> \"%s\" [ %s ];\n",
-                                                        $edge->[0], $edge->[1], $varopts );
+                       my $rank0 = $self->reading( $edge->[0] )->rank
+                               if $self->reading( $edge->[0] )->has_rank;
+                       my $rank1 = $self->reading( $edge->[1] )->rank
+                               if $self->reading( $edge->[1] )->has_rank;
+                       if( defined $rank0 && defined $rank1 && $rank1 - $rank0 > 1 ) {
+                               $variables->{'minlen'} = $rank1 - $rank0;
+                       }
+                       
+                       # Add the calculated edge weights
+                       # if( exists $weighted->{$edge->[0]} 
+                       #       && $weighted->{$edge->[0]} eq $edge->[1] ) {
+                       #       # $variables->{'color'} = 'red';
+                       #       $variables->{'weight'} = 3.0;
+                       # }
+
+                       # EXPERIMENTAL: make edge width reflect no. of witnesses
+                       my $extrawidth = scalar( $self->path_witnesses( $edge ) ) * 0.2;
+                       $variables->{'penwidth'} = $extrawidth + 0.8; # gives 1 for a single wit
+
+                       my $varopts = _dot_attr_string( $variables );
+                       $dot .= sprintf( "\t\"%s\" -> \"%s\" %s;\n", 
+                               $edge->[0], $edge->[1], $varopts );
+        } elsif( $used{$edge->[0]} ) {
+               $subend{$edge->[0]} = 1;
+        } elsif( $used{$edge->[1]} ) {
+               $substart{$edge->[1]} = 1;
         }
     }
-    
+    # Add substitute start and end edges if necessary
+    foreach my $node ( keys %substart ) {
+       my $witstr = $self->_path_display_label ( $self->reading_witnesses( $self->reading( $node ) ) );
+       my $variables = { %edge_attrs, 'label' => $witstr };
+        my $varopts = _dot_attr_string( $variables );
+        $dot .= "\t\"#SUBSTART#\" -> \"$node\" $varopts;";
+       }
+    foreach my $node ( keys %subend ) {
+       my $witstr = $self->_path_display_label ( $self->reading_witnesses( $self->reading( $node ) ) );
+       my $variables = { %edge_attrs, 'label' => $witstr };
+        my $varopts = _dot_attr_string( $variables );
+        $dot .= "\t\"$node\" -> \"#SUBEND#\" $varopts;";
+       }
+       # HACK part 2
+       if( $STRAIGHTENHACK ) {
+               $dot .= "\t\"#END#\" -> \"#SILENT#\" [ color=white,penwidth=0 ];\n";
+       }       
+
     $dot .= "}\n";
     return $dot;
 }
 
+sub _dot_attr_string {
+       my( $hash ) = @_;
+       my @attrs;
+       foreach my $k ( sort keys %$hash ) {
+               my $v = $hash->{$k};
+               push( @attrs, $k.'="'.$v.'"' );
+       }
+       return( '[ ' . join( ', ', @attrs ) . ' ]' );
+}
+
+sub _add_edge_weights {
+       my $self = shift;
+       # Walk the graph from START to END, choosing the successor node with
+       # the largest number of witness paths each time.
+       my $weighted = {};
+       my $curr = $self->start->id;
+       my $ranked = $self->end->has_rank;
+       while( $curr ne $self->end->id ) {
+               my $rank = $ranked ? $self->reading( $curr )->rank : 0;
+               my @succ = sort { $self->path_witnesses( $curr, $a )
+                                                       <=> $self->path_witnesses( $curr, $b ) } 
+                       $self->sequence->successors( $curr );
+               my $next = pop @succ;
+               my $nextrank = $ranked ? $self->reading( $next )->rank : 0;
+               # Try to avoid lacunae in the weighted path.
+               while( @succ && 
+                          ( $self->reading( $next )->is_lacuna ||
+                                $nextrank - $rank > 1 ) ){
+                       $next = pop @succ;
+               }
+               $weighted->{$curr} = $next;
+               $curr = $next;
+       }
+       return $weighted;       
+}
+
+=head2 path_witnesses( $edge )
+
+Returns the list of sigils whose witnesses are associated with the given edge.
+The edge can be passed as either an array or an arrayref of ( $source, $target ).
+
+=cut
+
 sub path_witnesses {
        my( $self, @edge ) = @_;
        # If edge is an arrayref, cope.
@@ -594,14 +674,15 @@ sub path_witnesses {
                @edge = @$e;
        }
        my @wits = keys %{$self->sequence->get_edge_attributes( @edge )};
-       return sort @wits;
+       return @wits;
 }
 
-sub path_display_label {
-       my( $self, $edge ) = @_;
-       my @wits = $self->path_witnesses( $edge );
+sub _path_display_label {
+       my $self = shift;
+       my @wits = sort @_;
        my $maj = scalar( $self->tradition->witnesses ) * 0.6;
        if( scalar @wits > $maj ) {
+               # TODO break out a.c. wits
                return 'majority';
        } else {
                return join( ', ', @wits );
@@ -752,7 +833,6 @@ sub as_graphml {
         $node_el->setAttribute( 'id', $node_xmlid );
         foreach my $d ( keys %node_data ) {
                my $nval = $n->$d;
-               $nval = $n->punctuated_form if $d eq 'text';
                _add_graphml_data( $node_el, $node_data_keys{$d}, $nval )
                        if defined $nval;
         }
@@ -762,7 +842,7 @@ sub as_graphml {
     my $edge_ctr = 0;
     foreach my $e ( sort { $a->[0] cmp $b->[0] } $self->sequence->edges() ) {
        # We add an edge in the graphml for every witness in $e.
-       foreach my $wit ( $self->path_witnesses( $e ) ) {
+       foreach my $wit ( sort $self->path_witnesses( $e ) ) {
                        my( $id, $from, $to ) = ( 'e'.$edge_ctr++,
                                                                                $node_hash{ $e->[0] },
                                                                                $node_hash{ $e->[1] } );
@@ -788,7 +868,7 @@ sub as_graphml {
        }
        
        # Add the relationship graph to the XML
-       $self->relations->as_graphml( $graphml_ns, $root, \%node_hash, 
+       $self->relations->_as_graphml( $graphml_ns, $root, \%node_hash, 
                $node_data_keys{'id'}, \%edge_data_keys );
 
     # Save and return the thing
@@ -851,10 +931,11 @@ keys have a true hash value will be included.
 
 sub make_alignment_table {
     my( $self, $noderefs, $include ) = @_;
-    unless( $self->linear ) {
-        warn "Need a linear graph in order to make an alignment table";
-        return;
-    }
+    # Make sure we can do this
+       throw( "Need a linear graph in order to make an alignment table" )
+               unless $self->linear;
+       $self->calculate_ranks unless $self->end->has_rank;
+       
     my $table = { 'alignment' => [], 'length' => $self->end->rank - 1 };
     my @all_pos = ( 1 .. $self->end->rank - 1 );
     foreach my $wit ( sort { $a->sigil cmp $b->sigil } $self->tradition->witnesses ) {
@@ -868,7 +949,7 @@ sub make_alignment_table {
                { 'witness' => $wit->sigil, 'tokens' => \@row } );
         if( $wit->is_layered ) {
                my @wit_ac_path = $self->reading_sequence( $self->start, $self->end, 
-                       $wit->sigil.$self->ac_label, $wit->sigil );
+                       $wit->sigil.$self->ac_label );
             my @ac_row = _make_witness_row( \@wit_ac_path, \@all_pos, $noderefs );
                        push( @{$table->{'alignment'}},
                                { 'witness' => $wit->sigil.$self->ac_label, 'tokens' => \@ac_row } );
@@ -943,9 +1024,10 @@ used wherever no path exists for $sigil or $backup.
 =cut
 
 # TODO Think about returning some lazy-eval iterator.
+# TODO Get rid of backup; we should know from what witness is whether we need it.
 
 sub reading_sequence {
-    my( $self, $start, $end, $witness, $backup ) = @_;
+    my( $self, $start, $end, $witness ) = @_;
 
     $witness = $self->baselabel unless $witness;
     my @readings = ( $start );
@@ -953,23 +1035,21 @@ sub reading_sequence {
     my $n = $start;
     while( $n && $n->id ne $end->id ) {
         if( exists( $seen{$n->id} ) ) {
-            warn "Detected loop at " . $n->id;
-            last;
+            throw( "Detected loop for $witness at " . $n->id );
         }
         $seen{$n->id} = 1;
         
-        my $next = $self->next_reading( $n, $witness, $backup );
+        my $next = $self->next_reading( $n, $witness );
         unless( $next ) {
-            warn "Did not find any path for $witness from reading " . $n->id;
-            last;
+            throw( "Did not find any path for $witness from reading " . $n->id );
         }
         push( @readings, $next );
         $n = $next;
     }
     # Check that the last reading is our end reading.
     my $last = $readings[$#readings];
-    warn "Last reading found from " . $start->text .
-        " for witness $witness is not the end!"
+    throw( "Last reading found from " . $start->text .
+        " for witness $witness is not the end!" ) # TODO do we get this far?
         unless $last->id eq $end->id;
     
     return @readings;
@@ -1005,7 +1085,15 @@ sub prior_reading {
 }
 
 sub _find_linked_reading {
-    my( $self, $direction, $node, $path, $alt_path ) = @_;
+    my( $self, $direction, $node, $path ) = @_;
+    
+    # Get a backup if we are dealing with a layered witness
+    my $alt_path;
+    my $aclabel = $self->ac_label;
+    if( $path && $path =~ /^(.*)\Q$aclabel\E$/ ) {
+       $alt_path = $1;
+    }
+    
     my @linked_paths = $direction eq 'next' 
         ? $self->sequence->edges_from( $node ) 
         : $self->sequence->edges_to( $node );
@@ -1022,7 +1110,7 @@ sub _find_linked_reading {
         if( $self->sequence->has_edge_attribute( @$le, $self->baselabel ) ) {
             $base_le = $le;
         }
-               my @le_wits = $self->path_witnesses( $le );
+               my @le_wits = sort $self->path_witnesses( $le );
                if( _is_within( \@path_wits, \@le_wits ) ) {
                        # This is the right path.
                        return $direction eq 'next' ? $le->[1] : $le->[0];
@@ -1061,8 +1149,38 @@ sub _witnesses_of_label {
     my $regex = $self->wit_list_separator;
     my @answer = split( /\Q$regex\E/, $label );
     return @answer;
-}    
+}
+
+=head2 common_readings
+
+Returns the list of common readings in the graph (i.e. those readings that are
+shared by all non-lacunose witnesses.)
+
+=cut
+
+sub common_readings {
+       my $self = shift;
+       my @common = grep { $_->is_common } $self->readings;
+       return @common;
+}
+
+=head2 path_text( $sigil, $mainsigil [, $start, $end ] )
 
+Returns the text of a witness (plus its backup, if we are using a layer)
+as stored in the collation.  The text is returned as a string, where the
+individual readings are joined with spaces and the meta-readings (e.g.
+lacunae) are omitted.  Optional specification of $start and $end allows
+the generation of a subset of the witness text.
+
+=cut
+
+sub path_text {
+       my( $self, $wit, $start, $end ) = @_;
+       $start = $self->start unless $start;
+       $end = $self->end unless $end;
+       my @path = grep { !$_->is_meta } $self->reading_sequence( $start, $end, $wit );
+       return join( ' ', map { $_->text } @path );
+}
 
 =head1 INITIALIZATION METHODS
 
@@ -1119,10 +1237,36 @@ sub make_witness_path {
 Calculate the reading ranks (that is, their aligned positions relative
 to each other) for the graph.  This can only be called on linear collations.
 
+=begin testing
+
+use Text::Tradition;
+
+my $cxfile = 't/data/Collatex-16.xml';
+my $t = Text::Tradition->new( 
+    'name'  => 'inline', 
+    'input' => 'CollateX',
+    'file'  => $cxfile,
+    );
+my $c = $t->collation;
+
+# Make an svg
+my $svg = $c->as_svg;
+is( substr( $svg, 0, 5 ), '<?xml', "Got XML doc for svg" );
+ok( $c->has_cached_svg, "SVG was cached" );
+is( $c->as_svg, $svg, "Cached SVG returned upon second call" );
+$c->calculate_ranks;
+is( $c->as_svg, $svg, "Cached SVG retained with no rank change" );
+$c->add_relationship( 'n9', 'n23', { 'type' => 'spelling' } );
+isnt( $c->as_svg, $svg, "SVG changed after relationship add" );
+
+=end testing
+
 =cut
 
 sub calculate_ranks {
     my $self = shift;
+    # Save the existing ranks, in case we need to invalidate the cached SVG.
+    my %existing_ranks;
     # Walk a version of the graph where every node linked by a relationship 
     # edge is fundamentally the same node, and do a topological ranking on
     # the nodes in this graph.
@@ -1150,6 +1294,7 @@ sub calculate_ranks {
 
     # Add the edges.
     foreach my $r ( $self->readings ) {
+               $existing_ranks{$r} = $r->rank;
         foreach my $n ( $self->sequence->successors( $r->id ) ) {
                my( $tfrom, $tto ) = ( $rel_containers{$r->id},
                        $rel_containers{$n} );
@@ -1171,10 +1316,22 @@ sub calculate_ranks {
         if( defined $node_ranks->{$rel_containers{$r->id}} ) {
             $r->rank( $node_ranks->{$rel_containers{$r->id}} );
         } else {
-            die "No rank calculated for node " . $r->id 
-                . " - do you have a cycle in the graph?";
+               # Die. Find the last rank we calculated.
+               my @all_defined = sort { $node_ranks->{$rel_containers{$a->id}}
+                                <=> $node_ranks->{$rel_containers{$b->id}} }
+                       $self->readings;
+               my $last = pop @all_defined;
+            throw( "Ranks not calculated after $last - do you have a cycle in the graph?" );
         }
     }
+    # Do we need to invalidate the cached SVG?
+    if( $self->has_cached_svg ) {
+       foreach my $r ( $self->readings ) {
+               next if $existing_ranks{$r} == $r->rank;
+               $self->wipe_svg;
+               last;
+       }
+    }
 }
 
 sub _assign_rank {
@@ -1229,12 +1386,126 @@ sub flatten_ranks {
             # Combine!
                # print STDERR "Combining readings at same rank: $key\n";
             $self->merge_readings( $unique_rank_rdg{$key}, $rdg );
+            # TODO see if this now makes a common point.
         } else {
             $unique_rank_rdg{$key} = $rdg;
         }
     }
 }
 
+=head2 remove_collations
+
+Another convenience method for parsing. Removes all 'collation' relationships
+that were defined in order to get the reading ranks to be correct.
+
+=begin testing
+
+use Text::Tradition;
+
+my $cxfile = 't/data/Collatex-16.xml';
+my $t = Text::Tradition->new( 
+    'name'  => 'inline', 
+    'input' => 'CollateX',
+    'file'  => $cxfile,
+    );
+my $c = $t->collation;
+
+isnt( $c->reading('n23')->rank, $c->reading('n9')->rank, "Rank skew exists" );
+$c->add_relationship( 'n23', 'n9', { 'type' => 'collated', 'scope' => 'local' } );
+is( scalar $c->relationships, 4, "Found all expected relationships" );
+$c->remove_collations;
+is( scalar $c->relationships, 3, "Collated relationships now gone" );
+is( $c->reading('n23')->rank, $c->reading('n9')->rank, "Aligned ranks were preserved" );
+
+=end testing
+
+=cut
+
+sub remove_collations {
+       my $self = shift;
+       foreach my $reledge ( $self->relationships ) {
+               my $relobj = $self->relations->get_relationship( $reledge );
+               if( $relobj && $relobj->type eq 'collated' ) {
+                       $self->relations->delete_relationship( $reledge );
+               }
+       }
+}
+       
+
+=head2 calculate_common_readings
+
+Goes through the graph identifying the readings that appear in every witness 
+(apart from those with lacunae at that spot.) Marks them as common and returns
+the list.
+
+=begin testing
+
+use Text::Tradition;
+
+my $cxfile = 't/data/Collatex-16.xml';
+my $t = Text::Tradition->new( 
+    'name'  => 'inline', 
+    'input' => 'CollateX',
+    'file'  => $cxfile,
+    );
+my $c = $t->collation;
+
+my @common = $c->calculate_common_readings();
+is( scalar @common, 8, "Found correct number of common readings" );
+my @marked = sort $c->common_readings();
+is( scalar @common, 8, "All common readings got marked as such" );
+my @expected = qw/ n1 n12 n16 n19 n20 n5 n6 n7 /;
+is_deeply( \@marked, \@expected, "Found correct list of common readings" );
+
+=end testing
+
+=cut
+
+sub calculate_common_readings {
+       my $self = shift;
+       my @common;
+       my $table = $self->make_alignment_table( 1 );
+       foreach my $idx ( 0 .. $table->{'length'} - 1 ) {
+               my @row = map { $_->{'tokens'}->[$idx]->{'t'} } @{$table->{'alignment'}};
+               my %hash;
+               foreach my $r ( @row ) {
+                       if( $r ) {
+                               $hash{$r->id} = $r unless $r->is_meta;
+                       } else {
+                               $hash{'UNDEF'} = $r;
+                       }
+               }
+               if( keys %hash == 1 && !exists $hash{'UNDEF'} ) {
+                       my( $r ) = values %hash;
+                       $r->is_common( 1 );
+                       push( @common, $r );
+               }
+       }
+       return @common;
+}
+
+=head2 text_from_paths
+
+Calculate the text array for all witnesses from the path, for later consistency
+checking.  Only to be used if there is no non-graph-based way to know the
+original texts.
+
+=cut
+
+sub text_from_paths {
+       my $self = shift;
+    foreach my $wit ( $self->tradition->witnesses ) {
+       my @text = split( /\s+/, 
+               $self->reading_sequence( $self->start, $self->end, $wit->sigil ) );
+       $wit->text( \@text );
+       if( $wit->is_layered ) {
+                       my @uctext = split( /\s+/, 
+                               $self->reading_sequence( $self->start, $self->end, 
+                                       $wit->sigil.$self->ac_label ) );
+                       $wit->text( \@uctext );
+       }
+    }    
+}
 
 =head1 UTILITY FUNCTIONS
 
@@ -1276,16 +1547,16 @@ is( $c->common_successor( 'n21', 'n26' )->id,
 sub common_predecessor {
        my $self = shift;
        my( $r1, $r2 ) = $self->_objectify_args( @_ );
-       return $self->common_in_path( $r1, $r2, 'predecessors' );
+       return $self->_common_in_path( $r1, $r2, 'predecessors' );
 }
 
 sub common_successor {
        my $self = shift;
        my( $r1, $r2 ) = $self->_objectify_args( @_ );
-       return $self->common_in_path( $r1, $r2, 'successors' );
+       return $self->_common_in_path( $r1, $r2, 'successors' );
 }
 
-sub common_in_path {
+sub _common_in_path {
        my( $self, $r1, $r2, $dir ) = @_;
        my $iter = $r1->rank > $r2->rank ? $r1->rank : $r2->rank;
        $iter = $self->end->rank - $iter if $dir eq 'successors';
@@ -1310,13 +1581,22 @@ sub common_in_path {
        return $dir eq 'predecessors' ? pop( @answer ) : shift ( @answer );
 }
 
+sub throw {
+       Text::Tradition::Error->throw( 
+               'ident' => 'Collation error',
+               'message' => $_[0],
+               );
+}
+
 no Moose;
 __PACKAGE__->meta->make_immutable;
 
-=head1 BUGS / TODO
+=head1 LICENSE
 
-=over
+This package is free software and is provided "as is" without express
+or implied warranty.  You can redistribute it and/or modify it under
+the same terms as Perl itself.
 
-=item * Get rid of $backup in reading_sequence
+=head1 AUTHOR
 
-=back
+Tara L Andrews E<lt>aurum@cpan.orgE<gt>