Every function in tantale that describes, compares or plots a set of TALEs takes or returns a tales object. This article is a deep dive into what that object is, how to build one, and what its most important column, dom_code, actually means.
Where this fits
This article extends Mining TALE sequences: read it once that walkthrough has given you a
talesobject to look at, and return there afterwards to continue with classification. Its sibling deep dive, on thetales_msaclass, comes later in the walkthrough.
1 One row per part
A tales object is a long table with one row per part of a TALE array: one row for the N-terminus, one for each central repeat, one for the C-terminus.
That shape is forced by the biology. A TALE array is built from a variable number of repeats (as few as a handful, more than thirty in some natural alleles), so “one row per TALE” would need a ragged number of columns, one per possible repeat position. In a long table, an array with 12 repeats and one with 27 are both just more rows, distinguished by array_id.
Building one from a real tell_tales() run looks like this. The example below uses the small sequence set shipped with the package, so it runs in a few seconds without any correction step:
Code
out <- file.path(tempdir(), "tales_class_article")
invisible(tell_tales(
subject_file = system.file("extdata", "bai3_sample_tal_genomic_regions.fasta",
package = "tantale", mustWork = TRUE),
output_dir = out))Code
x <- suppressWarnings(tales_from_telltales(out))
x
#> <tales> 4 arrays, 96 parts
#> layers: rvd | 6 other columns
#> rvd
#> ROI_00001 NTERM NN NG NN HD HD NI N* NG HD NI NG NN HD NI NG NI NG NN N ...
#> ROI_00002 NTERM NN HD NI NN HD NG HD HD NG NG NI NG NI NG CTERM
#> ROI_00003 NTERM NN ND NN NI NK NN HD NN NG NG N* HD N* HD NI NN HD NG H ...
#> ROI_00004 NTERM NI HD NN NS NN NG HD NG HD NG NN NG HD NS HD NI NG HD H ...Two columns locate a part within its array, and they answer different questions:
-
position_in_arraynumbers every part, termini included, starting at 1 (the N-terminus, when present). -
position_in_crdnumbers only the repeats, starting at 1 for the first one, and isNAon the two termini.
On a repeat, position_in_array is one ahead of position_in_crd when the array has an N-terminus, and equal to it when it does not. Keeping only the repeats of an array (filter(x, domain_type == "repeat")) therefore still gives a valid tales: position_in_crd stays contiguous from 1 even though position_in_array no longer starts there. Completeness (every part present, position_in_array running 1:n) is a precondition some functions need, alignment chiefly, and is checked by tales_assert_complete(). The class itself does not require it.
2 The column contract
A tales requires two columns outright, because without them a row cannot be located at all: array_id and position_in_array. A third requirement is looser: at least one of rvd or dom_code must be present, since a tales with neither has no residues to describe.
Everything else is optional, but validated for shape when it is there:
| column | holds |
|---|---|
domain_type |
"N-terminus", "repeat", or "C-terminus"
|
position_in_crd |
position among repeats only, NA on termini |
aa_seq, dna_seq
|
the part’s amino acid / nucleotide sequence |
seqnames |
the source contig, for parts read off a genome |
dom_code |
an identifier per distinct domain sequence, added by tales_assign_domain_codes() (see below) |
source_directory |
which tell_tales() run produced this row |
A tales built straight from a fasta of RVD sequences (below) has none of these beyond rvd itself, and that is a legitimate, if minimal, object. tales() keeps any further column (a strain name, a clade, a host range) untouched and never warns about it: metadata is expected to accumulate on this table via ordinary mutate() calls.
3 Three ways to build one
From a tell_tales() run, as above, with tales_from_telltales(). This is the richest route: it carries sequences, source coordinates and both position columns.
From bare sequences, with as_tales(): a fasta of RVD or repeat-code strings, sequence names becoming array_id:
Code
rvd_fasta <- system.file("extdata", "TalA_RVDSeqs_AnnoTALE.fasta",
package = "tantale")
cat(readLines(rvd_fasta, n = 2), sep = "\n")
#> >TalA_BAI3
#> NN-NG-NN-HD-HD-NI-N*-NG-HD-NI-NG-NN-HD-NI-NG-NI-NG-NN-NG-HD-NI-NI-NG-HD-NN-NG
as_tales(rvd_fasta, sep = "-")
#> <tales> 11 arrays, 258 parts
#> layers: rvd
#> rvd
#> TalA_BAI3 NN NG NN HD HD NI N* NG HD NI NG NN HD NI NG NI NG NN NG ...
#> TalA_CFBP1947 NN NG NN HD HD NI N* NG HD NI NG NN HD NI NG NI NG NN NG ...
#> ... ...
#> TalA_MAI95 NN N* NN HD HD NI NG NN HD NS NG NI N* NN NG HD NI NI NG ...
#> TalA_MAI99 NN N* NN HD HD NI NG NN HD NS NG NI N* NN NG HD NI NI NG ...This result has only array_id, position_in_array and rvd, because that is all a bare sequence file can supply. It is still a complete, valid tales.
From a comparison, with tales_assign_domain_codes() (see below): the same object, given back with a dom_code column added.
All three produce the same class, checked by the same validate_tales(), usable with the same functions afterwards.
4 It is a tibble
A tales is a tibble with extra structure layered on top. Ordinary dplyr verbs work on it directly:
Subsetting that would break the class, such as dropping array_id, quietly degrades the result to a plain tibble instead of raising an error, so a tales never claims more than it is. Section 9 looks at that degradation more closely, once dom_code is in the picture.
print() and format() follow the same idea Biostrings uses for sequence sets: a header with the essentials (arrays, parts, which residue columns are present), then the first and last arrays rendered as sequences, eliding the middle. summary() adds the slower numbers (how many distinct RVDs, how many repeats per array, whether every array has both termini, and, once dom_code is present, how many distinct domains) and folds in tales_anomalies():
Code
summary(x)
#> <tales> summary
#> arrays / parts 4 / 96
#> distinct RVDs 8
#> repeats per array min 14 median 24 max 26
#> arrays with both termini 4 of 4
#> source sequences 2
#> anomalies none5 Anomalies are reported and kept
Real TALE predictions are messy: a terminus can be duplicated, misplaced, or simply not found. tales() loads such arrays anyway, with a warning. A class that insisted on clean input would force cleaning outside the package and throw away exactly the signal a user would want to inspect.
“Anomalous” means “worth a second look”, which is broader than “wrong”. tales_anomalies() reports two kinds of anomaly. The first kind, "noncanonical", is a TALE that is not standard. A standard TALE has an N-terminus, one or more repeats and a C-terminus, and both termini are canonical TALE terminal domains (NTERM and CTERM, rather than XXXXX; see tales_anchor_codes()). An array missing its C-terminus is flagged, although it may sit at the edge of a contig or be a genuinely truncated TALE; so is a truncTALE, whose C-terminus is coded XXXXX. Such TALEs can be real biology. The second kind, "integrity", is data that cannot be trusted: a part arrangement that cannot be biologically real (two N-termini in one array, say, or one that is not at the start), an array without repeats, a part missing the sequence data it should carry, one amino acid sequence paired with two different RVDs, and position columns that disagree with each other:
Code
odd <- tibble::tibble(
array_id = c("a1", "a1", "a1", "a2", "a2", "a2", "a3", "a3", "a3"),
position_in_array = rep(1:3, 3),
domain_type = c("N-terminus", "N-terminus", "repeat",
"N-terminus", "repeat", "C-terminus",
"N-terminus", "repeat", "C-terminus"),
rvd = c("NTERM", "NTERM", "HD", "NTERM", "HD", "CTERM", "NTERM", "NI", "XXXXX")
)
x_odd <- suppressWarnings(tales(odd))
tales_anomalies(x_odd)
#> # A tibble: 4 × 4
#> array_id check kind detail
#> <chr> <chr> <chr> <chr>
#> 1 a1 terminus_absent noncanonical no C-terminus
#> 2 a1 terminus_duplicated integrity more than one N-terminus
#> 3 a1 terminus_misplaced integrity N-terminus not at position 1
#> 4 a3 terminus_noncanonical noncanonical C-terminus is not a canonical TAL…sanitize drops flagged arrays instead of merely warning about them. sanitize = TRUE drops the arrays with an "integrity" anomaly and keeps the non-canonical ones; sanitize = "canonical" keeps canonical TALEs only:
Code
unique(tales(odd, sanitize = TRUE)$array_id)
#> Warning: Dropped 1 array with inconsistent or incomplete data.
#> ✖ Array: "a1"
#> ℹ Reasons: terminus_duplicated and terminus_misplaced
#> Kept 1 non-canonical array.
#> ℹ Array: "a3"
#> ℹ `tales_anomalies()` lists it, with the reason.
#> [1] "a2" "a3"
unique(tales(odd, sanitize = "canonical")$array_id)
#> Warning: Dropped 2 arrays that are not canonical TALEs.
#> ✖ Arrays: "a1" and "a3"
#> ℹ Reasons: terminus_absent, terminus_duplicated, terminus_misplaced, and
#> terminus_noncanonical
#> [1] "a2"Structural problems, such as a duplicated key or an NA where one is not allowed, remain hard errors either way, because the table cannot be interpreted at all.
6 Projections: turning parts back into sequences
Some consumers want a tales as a table; others want each array back as a single string. Three functions bridge the gap:
-
tales_rvd_strings(): one RVD string per array, the form target-prediction tools consume. Drops the termini by default (repeats_only = TRUE), since only the repeats bind DNA. -
tales_coded_strings(): thedom_codeequivalent, for the array alignments (MAFFT’s text mode, andtales_tale_distances()). Keeps the termini by default, since alignment uses them as anchors, and separates codes with a space, which is what MAFFT’s text mode splits on. -
tales_domain_codes(): the lookup table itself, with one row per distinctdom_codeand the amino acid sequence and RVD it stands for.
Code
xa <- tales_assign_domain_codes(x)
tales_coded_strings(xa)["ROI_00002"]
#> BStringSet object of length 1:
#> width seq names
#> [1] 44 40 19 9 16 36 24 1 7 24 12 26 28 26 16 11 46 ROI_00002
tales_domain_codes(xa) %>% head(4)
#> # A tibble: 4 × 3
#> dom_code aa_seq rvd
#> <chr> <chr> <chr>
#> 1 1 LPPDQVVAIASNGGGKQALETVQRLLPVLCQAHG NG
#> 2 10 LTPAQVVAIASNDGGKQALETVQRLLPVLCQAHG ND
#> 3 11 LTPAQVVAIASNGGGKQALE NG
#> 4 12 LTPAQVVAIASNGGGKQALETVQRLLPVLCQAHG NG
7 What a dom_code is
This is the column the rest of the comparison machinery is built on, and it is worth being precise about.
7.1 It names a domain, repeats and termini alike
A TALE part is an N-terminus, a repeat, or a C-terminus, and all three get dom_codes on the same footing. “Domain” is the word used here because it covers a repeat and a terminus alike; a column that also numbers C-termini cannot be called a repeat code. Two parts with identical amino acid sequences get the same code, whatever kind of part they are.
The distinction matters in practice. In the small example used throughout this article:
Code
n_distinct(xa$dom_code)
#> [1] 47
table(xa$domain_type[match(unique(xa$dom_code), xa$dom_code)])
#>
#> C-terminus N-terminus repeat
#> 4 4 3947 distinct codes, and only 39 of them are repeats; the rest are distinct N- and C-termini. Reading the total as a count of distinct repeats would overstate repeat diversity.
7.2 It is what makes a TALE alignable
Aligning TALE arrays residue by residue is meaningless: the repeats are near-identical (~34 amino acids, differing mainly at two positions), so almost anything matches almost anything else. Giving each distinct domain a symbol turns an array into a sequence of domains, which can be aligned the way a protein sequence is, with insertions and deletions of whole repeats instead of individual residues. This is why tales_align() works on dom_code (or rvd), never on aa_seq directly.
7.3 It is finer than an RVD
The RVD is residues 12-13 of a repeat, and it is what determines which DNA base the repeat binds. Two repeats can share an RVD – the same binding specificity – while differing elsewhere in the protein, and they get different dom_codes. So rvd is the functional layer and dom_code the identity layer, and the class carries both because they answer different questions.
In this example, the RVD HD (which binds cytosine) is shared by several distinct repeat sequences:
Code
reps <- xa %>% filter(domain_type == "repeat")
reps %>% filter(rvd == "HD") %>% distinct(dom_code, aa_seq) %>% head(4)
#> # A tibble: 4 × 2
#> dom_code aa_seq
#> <chr> <chr>
#> 1 24 LTPDQVVAIASHDGGKQALETVQRLLPVLCQAHG
#> 2 32 LTPEQVVAIASHDGGKQALETVQRLLPVLCQAHG
#> 3 7 LTPAQVVAIASHDGGKQALETVQRLLPVLCQAHG
#> 4 8 LTPAQVVAIASHDGGKQALETVQRLLPVLCQVHGA terminus, meanwhile, has no RVD at all: its rvd column holds a placeholder ("NTERM", "CTERM", or "XXXXX" for a terminus that is not a canonical TALE terminal domain; see tales_anchor_codes()), while its dom_code identifies a real sequence.
7.4 How they are computed
tales_assign_domain_codes() groups the parts by aa_seq and numbers the groups (dplyr::cur_group_id()).
7.5 The numbers are only meaningful within one call
Because the numbering depends on which distinct sequences are present, code 42 from one comparison is not code 42 from another: add an array, remove one, or run it on a different day’s data, and the same protein can land on a different number. The package enforces this.
Every object tales_assign_domain_codes() produces carries a namespace stamp, readable with tales_namespace(), derived from the exact set of distinct sequences that produced it:
Code
tales_namespace(xa)
#> [1] "7377e3f80aa36898"Functions that combine codes from two different calls refuse to run. The rest of this section demonstrates that, one step at a time.
Domain distances over the whole example set are the baseline to mix in something incompatible with:
Code
dd_all <- tales_domain_distances(xa)Splitting the example array set in two and re-assigning codes to one part gives it a different namespace, even though every sequence it contains was already present in the full set:
Code
sub <- xa[xa$array_id != "ROI_00001", ][setdiff(names(xa), "dom_code")]
xa_sub <- tales_assign_domain_codes(sub)
tales_namespace(xa_sub) == tales_namespace(xa)
#> [1] FALSEMixing namespaces is an error
Using
xa_sub’s codes againstdd_all, distances keyed by the other namespace, would compute a plausible-looking wrong answer. The function refuses instead:Code
tales_tale_distances(xa_sub, dd_all) #> Error in `.assert_same_namespace()` at tantale/R/distalr.R:245:3: #> ! `x` and `domain_distances` come from different runs. #> ✖ Namespaces "39eacafcbbcbfa90" and "7377e3f80aa36898". #> ℹ Domain codes are only meaningful within the call that minted them. #> ℹ Recompute the distances from this `x` with `tales_domain_distances()`.
8 Where dom_code leads: comparing TALEs
Minting codes is the first of three steps that together compare a set of TALEs: tales_assign_domain_codes() first, then tales_domain_distances() (how different the distinct domains are from each other, pairwise), then tales_tale_distances() (how different the arrays are, aligning their domain-code sequences using the domain distances as the substitution cost). tales_compare_distal() runs all three and returns the corresponding tales, domain_distances and tale_distances objects together:
Code
cmp <- tales_compare_distal(x)
names(cmp)
#> [1] "tales" "domain_distances" "tale_distances"Each step is independently useful (the domain-level distances alone answer questions about domain diversity, without running the array-level alignment) and each is documented in its own right; see tales_compare_distal() for the full chain.
9 Subsetting keeps the class only while the contract holds
A tales is a tibble, so every subsetting route a tibble supports works on it too: [, filter(), select(), arrange(), mutate(). What differs from a plain tibble is what happens to the class along the way.
Row subsetting never breaks anything. Every invariant a tales checks still holds on any subset of its rows. This is why completeness (Section 1, above) is checked only by the functions that need it. Filtering to one array, or to its repeats only, is still a valid tales:
Column subsetting is where it gets interesting. Dropping a column the contract needs (array_id, or both residue columns at once) leaves something that can no longer be described as a tales. The class then quietly steps out of the way, without an error:
The same happens whichever route removes the load-bearing column: select(), mutate(), or plain [, j]. This is deliberate: a tales object that kept claiming to be one after losing its key would let every downstream function trust a promise the object could no longer keep. Dropping an optional column (seqnames, aa_seq, …) has no such consequence, and the class survives untouched.
mutate() earns one extra check the others do not need: it can overwrite array_id or position_in_array in place, which plain subsetting cannot do, so the result could keep every contract column while no longer being a valid key. That case is caught separately, immediately after the mutate:
The row-level biology is not re-checked on every verb: tales_anomalies() does not run again on each filter() call. Anomalies are a property to inspect deliberately (see above).
Attributes travel with a valid subset, which matters most for dom_code_namespace. Cutting xa down to one array with [ keeps its namespace, because the namespace describes the run the codes came from:
Code
tales_namespace(one_array) == tales_namespace(xa)
#> [1] TRUEThat holds because one_array was cut with [ and kept its codes; tales_assign_domain_codes() on a subset mints a different namespace, as the mismatch example above showed.
A tales_msa carries one further column, alignment_position. Losing that while keeping everything else degrades it by exactly one step, to a plain tales. The tales_msa class article covers that graded degradation in full, together with the reverse direction: how a tales is promoted into an alignment in the first place.
10 Next
This article stands on its own; where to go next depends on what you were after. To continue the walkthrough this extends, return to classifying TALE sequences. To go deeper on alignment, the tales_msa class picks up directly from Section 7 and Section 9, above.