The index that replaces the text

The occurrence carried through the search

Backward search returns how many and not where, and every index on this site pays for the second question separately. Carrying one occurrence along with the interval costs a lookup on 75% of the steps for a two-character pattern and on 18% of them for a sixteen-character one, and it is what makes a run-boundary sampling usable at all.

Backward search answers how many and refuses to answer where. It narrows an interval of rows one pattern character at a time, and when the pattern is exhausted the interval’s width is the number of occurrences — a number that arrives without a single occurrence having been located.

That separation is the first thing this collection measured about the structure, in a search that runs backwards, and it is usually described as a virtue. It is also the reason a sampling exists: something has to turn a row into a position, and the interval does not know how.

How often the occurrence carried through the search is freeEach row is 40 patterns of one length, searched in a collection of 4,097 characters with r = 233. The bar is the share of backward-search steps where the character being searched for is already the one at the end of the interval, so the occurrence being carried needs no lookup; the rest cost a select on the run heads. The share is a property of the text and the pattern and appears in no account of the structure.patterns of 225%60 selectspatterns of 441%94 selectspatterns of 863%118 selectspatterns of 1682%117 selectsEnglish-like · 8 copies of 512share of steps needing no lookup40 patterns a row
Fig. 1 The share of backward-search steps on which the occurrence being carried costs nothing, by pattern length. The rest cost one lookup, and the number of them is a property of the text rather than of the structure.

A function with r pieces shows that every occurrence after the first is one predecessor query away from the one before it. What it needs is a place to start: the suffix-array value of one row of the final interval.

Getting that by walking is the thing the whole design is avoiding. Run boundaries are not spread through the text, so a walk from an arbitrary row to the nearest sampled row is bounded by nothing — measured at 6,907 LF steps for a single occurrence in every occurrence at the same price, on the collection where the structure is otherwise at its best.

So the starting occurrence has to be maintained rather than found: carried along with the interval, one step at a time, from a state where it is trivially known.

The invariant, and the two cases

The state carried is SA[ep1]\mathrm{SA}[ep-1]: the text position of the last row of the current interval. At the start the interval is every row, its last row is row n1n-1, and that row’s value is stored — it is the last row of the last run, which the sampling keeps anyway.

A step extends the interval by a character cc. The new interval’s last row is LF(q)\mathrm{LF}(q), where qq is the last row in the current interval carrying cc; and SA[LF(q)]=SA[q]1\mathrm{SA}[\mathrm{LF}(q)] = \mathrm{SA}[q] - 1. So the whole step is: find qq, read its suffix-array value, subtract one.

There are two cases and they cost differently.

The character is already at the end. If the transform’s character at ep1ep-1 is cc, then qq is ep1ep-1 itself and its value is the one being carried. Nothing is looked up; the state is decremented.

It is not. Then qq is the last cc before epep, and the character after qq is not cc — so qq is the last row of a run, and its suffix-array value is one of the rr values the sampling stores. Finding which run it is costs a rank on the run heads and a lookup, charged here as a select.

How often the occurrence carried through the search is freeEach row is 40 patterns of one length, searched in a collection of 4,097 characters with r = 412. The bar is the share of backward-search steps where the character being searched for is already the one at the end of the interval, so the occurrence being carried needs no lookup; the rest cost a select on the run heads. The share is a property of the text and the pattern and appears in no account of the structure.patterns of 424%121 selectspatterns of 856%140 selectspatterns of 1678%143 selectspatterns of 2485%146 selectsfour symbols, uniform · 8 copies of 512share of steps needing no lookup40 patterns a row
Fig. 2 The same measurement on a four-symbol text. The share rises with the pattern length for the same reason and from a lower start, because a narrow alphabet makes the interval’s last character agree with the pattern’s less often.

Why the expensive case lands on a stored value

The second case is the reason the sampling stores the ends of runs and not only their beginnings, and it is worth spelling out because the argument is what turns “keep 2r2r values” from a guess into a construction.

qq is defined as the largest row below epep carrying cc, in a situation where row ep1ep-1 does not carry cc. Every row strictly between qq and epep therefore carries something else. So the run containing qq ends at qq: a run is a maximal stretch of equal characters, and the character changes immediately after.

The set of rows that can ever be qq in the expensive case is therefore a subset of the run ends. There are rr of those and the structure keeps all of them. The sampling is not sized to be small; it is sized to be exactly the set the search can land on, and that it is also small is a fact about the transform rather than about the design.

What it costs, measured rather than argued

The two cases are not equally common, and which is common is a property of the text and the pattern rather than of the index.

Forty patterns of each length, drawn from a collection of eight copies of a 512-character English text, give the free case on 25% of steps at two characters, 41% at four, 63% at eight and 82% at sixteen. On a four-symbol text the same sweep runs 24%, 56%, 78%, 85%.

The direction is the same and the reason is the same. A long pattern narrows its interval quickly, and a narrow interval is likely to be inside a single run — at which point every remaining step finds its own character at the end and costs nothing. A two-character pattern is still ranging over thousands of rows, and the row at the end of that range is whatever it happens to be.

So the toehold is nearly free for the queries that return few answers and costs a lookup per character for the queries that return many. That is the opposite shape from the walk it replaces, whose cost is per occurrence, and it is why the two cannot be compared by counting operations in one column.

What one occurrence costs, by sampling policy80 occurrences of a 6-character pattern in a collection of 8,193 characters with r = 95. The r-index spends exactly one predecessor query per occurrence after the first and takes no LF step at all; a regular sampling walks back to the nearest sampled row, which costs up to its own rate. The bars are in different operations and are drawn on one axis only because the question is how many of anything.r-index: phi per occurrence1.0predecessorregular, one in 83.0LF stepsregular, one in 167.8LF stepsregular, one in 3214.2LF stepsregular, one in 6433.4LF stepsa text that repeats itself · 16 copies · pattern " the t"operations per occurrence located80 occurrences
Fig. 3 The costs a locate actually incurs, by policy. The r-index’s row is a predecessor query per occurrence; the toehold is what the row does not show, because it is paid once for the whole query.

The lookup is not a rank, and the difference is charged

The expensive case costs “a lookup”, and this collection’s rule is that a plate reporting a cost says which act it counted. So: it is a rank on the run heads, followed by an indexed read.

The rank asks how many runs of character cc begin at or before the run containing ep1ep-1. That is a symbol rank on a wavelet tree over rr symbols — a handful of bit-vector ranks, not one, since a wavelet tree turns a symbol rank into as many binary ranks as the symbol’s code is long. The read then takes the last such run’s end row and its stored suffix-array value.

None of this touches the nn rows. The run heads are rr symbols and the arrays are rr entries, so the whole toehold apparatus lives inside the structure’s O(r)O(r) part — which matters, because a toehold implemented against the transform’s nn characters would have reintroduced the term the sampling was replaced to remove.

That distinction between a symbol rank and a bit-vector rank is the one rank is the only thing it does is built around, and it is why the counter here keeps them in separate fields rather than adding them up. A plate that summed them would report the Huffman-shaped tree and the balanced one as costing the same.

A huffman wavelet tree over "abracadabra": 155 bitsEvery node is a bit vector: one bit per symbol still in play, saying which half of the remaining alphabet that symbol falls in. A rank for a symbol walks its code from the root, one bit-vector rank a level, and no symbol is ever compared with another. The whole structure is 23 bits of payload and 132 bits of directory and code table, over 11 characters and 5 distinct symbols.01101010110root01010110101010111one box = one bit · left child 0, right child 15 symbols, 155 bits
Fig. 4 The structure the toehold’s rank runs on, here over a text rather than over run heads. The depth a symbol costs is its code length, which is why one symbol rank is several bit-vector ranks and the two are counted apart.

The invariant is checked at every step, not at the end

A toehold is easy to get subtly wrong in a way that produces the right answer most of the time, so the check is not on the answer.

At every step of every search, the carried value is compared against the real suffix array at the interval’s last row. Four pattern lengths, twenty-five patterns each, 450 steps on a collection of four copies: every one of them agreed.

Checking only the final answer would be much weaker, because the error that matters here is an interior one. A toehold that is wrong at step three and right at step four returns a correct occurrence and a correct count, and the walk that produced it is nonsense — and on a repetitive collection, where the occurrences of a pattern are spread evenly, a wrong toehold has a real chance of landing on another genuine occurrence.

This is the same discipline the counted structures elsewhere here are built with. Counting instead of timing is the collection’s founding version of it, and the invariant that was wrong for seven years is the case that most argues for checking an interior condition rather than an output.

phi over 129 positions, in 24 piecesEach point is a text position i against phi(i), the position that precedes it in suffix order. The function is a set of straight segments of slope one, and it breaks only where the row of i is the first row of a run in the transform — 23 times, against 26 runs. The circles mark the anchors the structure stores; between two of them phi is one addition, so r values answer the function at all 129 positions.text position iphi(i)anchor: a run start25 of thema text that repeats itself · 4 copies of 32r = 26 · pieces 24
Fig. 5 What the toehold hands over to. One position, arrived at by the search, and a function that produces every other occurrence from it.

The wrong end of the interval

The interval’s first row is also an occurrence, and it is just as easy to carry. Starting the enumeration there is a bug that no type system, no range check and no count comparison can see.

Applying φ\varphi from SA[sp]\mathrm{SA}[sp] walks upwards out of the interval: φ(SA[sp])=SA[sp1]\varphi(\mathrm{SA}[sp]) = \mathrm{SA}[sp-1], which is a suffix that does not begin with the pattern. The enumeration then returns exactly as many positions as there are occurrences, all of them positions in the text, and the wrong ones.

Measured on a five-character pattern with twelve occurrences: twelve positions returned, and the twelve are not the twelve. The gate’s rejection test is exactly this, and the only check that catches it is agreement with exhaustive search.

The same samples, with and without phi80 occurrences of a 6-character pattern in a collection of 8,193 characters with r = 95. The r-index spends exactly one predecessor query per occurrence after the first and takes no LF step at all; a regular sampling walks back to the nearest sampled row, which costs up to its own rate. The last row is the same run-boundary samples with no phi to enumerate from: the walk is bounded by nothing and reaches 6,907 steps for one occurrence.r-index: phi per occurrence1.0predecessorregular, one in 83.0LF stepsregular, one in 167.8LF stepsregular, one in 3214.2LF stepsregular, one in 6433.4LF stepsrun boundaries, walked2954.9LF, unboundeda text that repeats itself · 16 copies · pattern " the t"operations per occurrence located80 occurrences
Fig. 6 What happens without a toehold at all: the same samples, reached by walking. Two thousand nine hundred and fifty-five LF steps per occurrence, and a worst walk of 6,907.

Two ways to be wrong that produce the right count

The wrong end is one of a pair, and the other is worth naming because it fails in the opposite direction.

The first is the one above: carrying SA[sp]\mathrm{SA}[sp] instead of SA[ep1]\mathrm{SA}[ep-1]. The interval is right, the enumeration starts at a real occurrence, and every subsequent step leaves the interval. Every returned position is a position and the set is wrong.

The second is to maintain the toehold only on the steps where it is free — to take the decrement when the character is already at the end of the interval and to skip the lookup otherwise, on the reasoning that the value is “still an occurrence of a suffix of the pattern”. It is, and it is an occurrence of the wrong one. The result is a position that occurs somewhere in the text and does not begin with the pattern, and on a repetitive collection it will often be near one that does.

Both produce an answer set of exactly the right size. Both survive any check that asks whether the count matches the interval’s width, which is the check an implementation naturally writes first because the width is right there. The only thing that separates a correct locate from either of them is a comparison against exhaustion, on a text small enough to exhaust — which is why this collection keeps such a text in every gate it can.

The pieces phi has, against the runs it is allowedFour texts of 1,200 characters. The bar is the number of positions where phi is not its predecessor plus one — the pieces the function genuinely has — and the number beside it is r, which is the bound. The bound is never exceeded and is never quite reached: an anchor whose segment happens to continue is stored and not needed, which costs bits and cannot be detected without computing the function.a text that repeats itself102r = 104English-like413r = 415four symbols, uniform914r = 916periodic, period 171,054r = 1,056n = 1,200 in every rowpieces of phi · r beside each1,200 characters
Fig. 7 The audit that catches the interior version: the function’s pieces measured against the runs that bound them, on four texts, position by position.

Where the count still comes for free

One property survives all of this and is worth stating, because it is the reason an index of this kind is worth having for queries that are not locates.

How many still costs two ranks per pattern character and nothing else. The toehold rides along on the same steps and adds a lookup on some of them; it does not change the interval, does not change the count, and does not touch a suffix-array value unless the search is going to be asked for positions.

So a query that only needs a count can skip the whole apparatus, and a query that needs positions pays for the toehold once and then one predecessor query per answer. Three questions, three costs, no dial — which is what an index has a size has been arguing since the field opened, and what rank is the only thing it does established about the primitive underneath.

What this does to the shape of a locate

Put the three costs together and the structure’s locate has a shape no rate-based sampling has.

A regularly sampled index pays O(s)O(s) per occurrence: the cost is linear in the answer and the constant is the rate. An r-index pays one lookup on some fraction of mm steps, then one predecessor query per occurrence. The first term is a property of the query and the second is one operation per answer, and neither of them contains a number somebody chose.

On the collection measured above, an eighty-occurrence query costs twelve selects for the toehold and seventy-nine predecessor queries for the rest, against 1,136 LF steps at one value in thirty-two. That is not a constant-factor improvement in the same shape; it is a different function of the same inputs.

The trade a sampling rate buys, and the point that is off itEach dot is a regular sampling rate on the same collection of 8,193 characters: its bits against the LF steps one occurrence costs. The rate is the only dial, and it moves the two together. The square is the r-index, at 10,244 bits and 0.99 predecessor queries an occurrence — a different act from an LF step, which is why the axis says steps and the caption says which. The nearest rate that matches it for steps is one in 2, at 69,649 bits, 6.8 times as many. Both axes are logarithmic.10,000110sampling, bitssteps per occurrence1 in 21 in 41 in 81 in 161 in 321 in 641 in 128r-indexEnglish-like · 16 copies · r = 233pattern " time "
Fig. 8 The two shapes on one plate, in bits and steps. The curve is a rate being tuned; the square is a structure with no rate in it.

What the toehold says about the sampling’s shape

There is a temptation to describe the 2r2r values as “the samples”, one array with two uses. The measurement above says they are two arrays with one use each, and the distinction matters for anybody sizing the structure.

The run ends exist for the toehold. They are the set the search can land on in its expensive case, and no other part of the structure reads them for that purpose. The run starts exist for φ\varphi: they are the positions where the function jumps, and its anchors point at them.

They are stored together because they are the same kind of thing and because φ\varphi’s values are run-end entries — which is the sharing that keeps the sampling at 2r2r rather than 3r3r, as the sampling that follows the runs accounts for in full. But the two halves answer different questions, and a structure that dropped either one would fail on a different query.

That is worth saying because the obvious economy is to keep only the run starts, since those are what φ\varphi needs and φ\varphi is what produces the occurrences. An index built that way answers how many correctly, answers where correctly whenever the search’s last step happens to be a free one, and is wrong the rest of the time.

What share of each structure is its samplingThe regular sampling's share of the run-length index climbs from 7% to 65% across a collection that is the same text repeated, because the sampling is n/32 values while everything else follows r. The number beside each bar is the same share for the r-index, which does not move: its sampling is 2r values whatever n is. Both structures hold the same transform.1 copy7%r-index 67%2 copies12%r-index 66%4 copies21%r-index 66%8 copies33%r-index 65%16 copies49%r-index 65%32 copies65%r-index 64%English-like · base 512 · sample 32share of the structure's own bitsn = 16,385
Fig. 9 What the two arrays are a share of, across a growing collection. Halving them would halve the shaded quantity and break one of the two questions.

The share is a rate; the cost is three lookups

The plates report a share of free steps, and a share is not a cost. Multiplying it out is one line and it produces a number far more useful than the percentages, because the number does not move.

Take the English row: 75% of steps expensive at two characters, 59% at four, 37% at eight, 18% at sixteen. Multiply each by its pattern length and the expensive steps come to 1.5, 2.4, 3.0 and 2.9. The four-symbol row: 76%, 44%, 22% and 15% at four, eight, sixteen and twenty-four characters, which is 3.0, 3.5, 3.5 and 3.6.

The toehold costs about three lookups a query, at every pattern length, on both alphabets. The share rises with the pattern only because the denominator does; the numerator has stopped moving.

That is exactly what the mechanism predicts once it is read as a statement about when the expensive case happens rather than how often. The lookup is needed while the interval is still wide enough that its last row carries an arbitrary character, and an interval stops being wide after about logσn\log_\sigma n characters have been matched — six on four symbols over four thousand rows, three on a twenty-symbol alphabet. After that the interval is inside a run and every remaining step is free, however many remain.

So the share is 1(logσn)/m1 - (\log_\sigma n)/m to a good approximation, and checking it against the measurements: on English at sixteen characters the formula gives 0.82 against a measured 0.82, and at eight it gives 0.65 against 0.63. On the four-symbol text with the measured constant of 3.5 it gives 0.78 and 0.85 at sixteen and twenty-four, against 0.78 and 0.85.

Three consequences, and the first changes how the cost should be quoted.

The toehold is O(logσn)O(\log_\sigma n) and not O(m)O(m). Quoting it as a share invites a reader to multiply by their own pattern length and arrive at a term that grows; it does not grow. A sixteen-character pattern and a hundred-character one pay the same three lookups.

It is therefore negligible against everything else in a locate. Three lookups, once, against one predecessor query per occurrence — so on the eighty-occurrence query this essay measures, the toehold is under four per cent of the locate’s operations and falls as the answer grows. A function with r pieces’s per-occurrence term is the whole cost for any query worth calling a locate.

And the comparison with the walk is not close at any pattern length. The alternative this apparatus replaces is 2,955 LF steps per occurrence, with a worst walk of 6,907, measured in every occurrence at the same price. Three lookups against thousands of steps per answer is not a trade with a crossing in it; the toehold is what makes the run-boundary sampling usable, and its own cost is a rounding error in the structure it makes possible.

Which is worth stating because the plates, read as shares, make the toehold look like a term that might matter at short patterns. At two characters it costs one and a half lookups.

The honest limit

Everything above assumes the toehold’s lookup is a select on the run heads, and the implementation here answers it with a per-symbol array of run indices and a binary search rather than with a select structure over the wavelet tree. The bits are charged as rlog2rr\lceil \log_2 r\rceil, which is what such an array costs; a select structure would be smaller and its query would be slower, and the plates in this essay do not sweep that choice.

The share measurements are also a property of these texts. Forty patterns per length is enough to establish the direction and not enough to quote a share for a corpus this collection does not contain. What is robust is the mechanism — a narrow interval agrees with its own last character more often — and that is stated as the finding rather than the percentages.

The measurement that is missing is the one on a collection with document boundaries, where the intervals of a pattern occurring once per document are wide and shallow rather than narrow and deep, and the free case should be rarer for a reason that has nothing to do with pattern length.

What this makes readable

Essays that name this one as a prerequisite.

Named alongside this one

Essays reaching for the same objects. Nobody chose these; they are what the concept index makes visible.

What links here

Every essay whose body links to this one.

The objects this essay names

Each one links to every other essay that touches it.

Backward searchBurrows-wheeler transformFM-indexIndex sizeLocateMeasurementPhi functionR-indexRank queryRun boundary samplingRun-lengthSamplingSelf-indexToehold