# ADR: The search index

Status: accepted, 2026-09-03, shipped as 1.0.0.35. The README listed this as
open work: "precompute each vehicle's lowercase haystack at startup instead of
rebuilding it per request."

## In plain words

Search used to rebuild the text it searches for every row on every request. Now each row's searchable text is built once when the site starts, so a search only compares. A test that ships with it measures the difference, and the record says plainly that a visitor saw no change end to end.

What that is worth: search does less work on every request, and because the record reports what changed and what did not, the organization knows which speedups are worth paying for.

## Context

Free-text search is a full scan. Every one of the hundred thousand rows is
tested against the query, and the version before this one built the thing it
tested against inside that loop:

```
string haystack =
    ($"{vehicle.Year} {vehicle.Make} {vehicle.Model} {vehicle.Trim} " +
     $"{vehicle.BodyStyle} {vehicle.TitleStatus} {vehicle.Province} {vehicle.City} " +
     $"{AuctionSchedule.StatusFor(vehicle.Id, clock)}")
    .ToLowerInvariant();
return Query
    .ToLowerInvariant()
    .Split((char[]?)null, StringSplitOptions.RemoveEmptyEntries)
    .All(haystack.Contains);
```

Three separate pieces of waste, and none of them depends on the row being
tested at that moment:

- The nine-field interpolation and its lowercase copy: two allocations per row,
  a hundred thousand times, for text that has not changed since startup.
- `AuctionSchedule.StatusFor`: an FNV-1a hash and date arithmetic per row, run
  whether or not the query has anything to do with auction status.
- `Query.ToLowerInvariant().Split(...)`: the user typed the query once, and it
  was lowercased and split a hundred thousand times.

## Decision

**Each vehicle's searchable text is built once, when the dataset loads.**
`VehicleSearchIndex` holds it, keyed by vehicle id and built beside the by-id
dictionary that was already built there.

**The query's tokens are computed once per request.** `VehicleFilter.Compile`
returns a predicate with the tokens already lowercased and split. `Matches` is
still there for a caller holding one vehicle; a scan compiles once and reuses.

**The auction status stays out of the index.** It is the one searchable value
the clock decides rather than the data, so it cannot be precomputed. It is
computed only for a token the static text did not already satisfy, which for
most queries is never. This gives the same answer the single concatenated
string gave, because tokens are split on whitespace and so can never straddle
the space between the two parts.

**Keyed by id, not by reference.** The bid overlay rebuilds each vehicle with
`with` before filtering, so the instance the predicate sees is never the
instance the index was built from. Ids survive that; references do not.

## What it bought, measured

The suite carries the measurement, so it is reproducible rather than
remembered: `SearchIndexBenchmarkTests` scans the same hundred thousand rows
with both predicates, no HTTP, no sorting and no serialisation in the way.
Median of five scans, three separate runs, Release build:

| | text rebuilt per row | index |
| --- | --- | --- |
| run 1 | 45 ms | 21 ms |
| run 2 | 37 ms | 14 ms |
| run 3 | 36 ms | 17 ms |

The scan itself is roughly halved. A whole request improves by about the same
absolute amount and a smaller share: `GET /api/vehicles?q=ford&limit=24`
against the local API went from a 138 ms median to 111 ms, because what
remains is dominated by ordering nine thousand matches and serialising a page
of twenty-four. That is the honest shape of this change. It removes two
hundred thousand allocations from a text query and about twenty milliseconds
of work; it does not make the endpoint twice as fast, and the record would be
worth less if it claimed otherwise.

The test asserts only that the indexed path is not slower. A tight timing
assertion on a shared build agent fails for reasons unrelated to the code, and
a suite people learn to re-run is worse than no suite.

### And on the live container, nothing changed

Worth writing down because it is the part that would be easy to leave out. The
same five queries were timed against the running site before and after, warm
both times, twelve calls each:

| query | 1.0.0.34 | 1.0.0.35 |
| --- | --- | --- |
| `q=ford` | 236 ms | 228 ms |
| `q=ford ontario clean` | 239 ms | 231 ms |
| `q=live` | 256 ms | 246 ms |
| no query | 201 ms | 209 ms |
| `make=Ford` | 171 ms | 193 ms |

That is noise, in both directions. On one vCPU behind a proxy, twenty
milliseconds of scan is not what a request costs; TLS, the hop through the
edge, ordering the matches and serialising the page are. The change is still
right, because the work it removes is work that never needed doing and the
cost of not doing it grows with the dataset. But a record claiming the site
got faster would be claiming something these numbers do not show.

The first pass at this measurement did show a large regression, which was an
artefact: the 1.0.0.35 numbers were taken ten seconds after the container
rolled, against 1.0.0.34 numbers taken twelve minutes into its life. A cold
JIT reads as a slow release. The table above is warm on both sides.

## In the code

The index, and the text it holds (`api/TheYard.Domain/VehicleSearchIndex.cs`):

```csharp
/// <summary>
/// Every searchable field that does not depend on the clock: identity
/// (year, make, model, trim) plus the fields the dropdown filters cover
/// and the city. Single-spaced and lowercased once, because the query
/// tokens are lowercased once too and a token can never contain a space.
/// </summary>
public static string TextFor(Vehicle vehicle) =>
    string.Join(' ',
        vehicle.Year, vehicle.Make, vehicle.Model, vehicle.Trim,
        vehicle.BodyStyle, vehicle.TitleStatus, vehicle.Province, vehicle.City)
    .ToLowerInvariant();
```
*Live from [`api/TheYard.Domain/VehicleSearchIndex.cs`](https://github.com/SteveStout/TheYard/blob/1d6b50b/api/TheYard.Domain/VehicleSearchIndex.cs#L30-L40), region `text`, read from this build at 1d6b50b.*

```csharp
/// <summary>
/// The indexed text for a vehicle, or a freshly computed one when the
/// vehicle is not in the index. The fallback matters: the bid overlay
/// rebuilds each vehicle with `with`, and a test can hand the filter a
/// vehicle this index never saw. Keying by id rather than by reference is
/// what makes the overlay free here, because an overlay only rewrites the
/// bid figures and never the text.
/// </summary>
public string For(Vehicle vehicle) =>
    _byId.TryGetValue(vehicle.Id, out string? text) ? text : TextFor(vehicle);
```
*Live from [`api/TheYard.Domain/VehicleSearchIndex.cs`](https://github.com/SteveStout/TheYard/blob/1d6b50b/api/TheYard.Domain/VehicleSearchIndex.cs#L44-L53), region `lookup`, read from this build at 1d6b50b.*

Compiling the filter once (`api/TheYard.Domain/VehicleFilter.cs`):

```csharp
/// <summary>
/// Turns this filter into a predicate, doing once whatever does not depend
/// on the vehicle. That is the query's tokens: lowercasing and splitting
/// them inside the loop meant repeating the same two allocations for every
/// row scanned, which on the synthetic 100,000-row dataset is 100,000
/// copies of a string the user typed once (ADR: The search index).
///
/// <paramref name="index"/> is optional so that a caller with one vehicle
/// can read <c>filter.Matches(vehicle, clock)</c>. Without it the searchable
/// text is computed per vehicle.
/// </summary>
public Func<Vehicle, bool> Compile(AuctionClock clock, VehicleSearchIndex? index = null)
{
    string[] tokens = string.IsNullOrWhiteSpace(Query)
        ? []
        : Query.ToLowerInvariant().Split((char[]?)null, StringSplitOptions.RemoveEmptyEntries);
    return vehicle => MatchesQuery(vehicle, clock, tokens, index) && MatchesFields(vehicle, clock);
}
```
*Live from [`api/TheYard.Domain/VehicleFilter.cs`](https://github.com/SteveStout/TheYard/blob/1d6b50b/api/TheYard.Domain/VehicleFilter.cs#L32-L49), region `compile`, read from this build at 1d6b50b.*

The scan, and why the status is checked separately:

```csharp
/// <summary>
/// Every token must appear somewhere in the vehicle's searchable text. The
/// text splits in two: the part that never changes, which the index holds,
/// and the auction status, which the clock decides. A token that the
/// static part already satisfies never triggers the status computation, so
/// the common query pays for the hash and the date math zero times instead
/// of once per row.
///
/// This is the same answer the single concatenated haystack gave, because
/// tokens are split on whitespace and so can never straddle the space
/// between the two parts.
/// </summary>
private static bool MatchesQuery(
    Vehicle vehicle, AuctionClock clock, string[] tokens, VehicleSearchIndex? index)
{
    if (tokens.Length == 0)
    {
        return true;
    }
    string text = index is null ? VehicleSearchIndex.TextFor(vehicle) : index.For(vehicle);
    string? status = null;
    foreach (string token in tokens)
    {
        if (text.Contains(token, StringComparison.Ordinal))
        {
            continue;
        }
        status ??= AuctionSchedule.StatusFor(vehicle.Id, clock).ToString().ToLowerInvariant();
        if (!status.Contains(token, StringComparison.Ordinal))
        {
            return false;
        }
    }
    return true;
}
```
*Live from [`api/TheYard.Domain/VehicleFilter.cs`](https://github.com/SteveStout/TheYard/blob/1d6b50b/api/TheYard.Domain/VehicleFilter.cs#L92-L126), region `query`, read from this build at 1d6b50b.*

Where the two meet (`api/TheYard.Application/InventoryService.cs`):

```csharp
// Compile the filter once, then run the predicate down the rows. Both
// halves of the free-text comparison are precomputed by this point: the
// query's tokens by Compile, each vehicle's searchable text by the index
// built at load (ADR: The search index). What is left per row is a
// dictionary lookup and a substring test.
var matches = filter.Compile(clock, Inventory.Index);
var matched = source.Where(matches).ToList();
```
*Live from [`api/TheYard.Application/InventoryService.cs`](https://github.com/SteveStout/TheYard/blob/1d6b50b/api/TheYard.Application/InventoryService.cs#L112-L118), region `search`, read from this build at 1d6b50b.*

## Consequences

- Startup does slightly more work: one lowercase string per vehicle, held for
  the process's life. At a hundred thousand rows that is a few megabytes,
  traded for the allocations above. On a container with 1.5 GB it is not close
  to a concern; on a dataset ten times larger it would be worth revisiting,
  and the honest answer at that size is a real inverted index rather than a
  bigger string per row.
- A query whose tokens are all auction statuses ("live", "ended") still pays
  for the hash and the date math per row, because it must. That case did not
  get faster and the numbers above show it.
- The index is built once and never invalidated, which is correct only because
  the dataset never changes after load. A system where vehicles are edited
  would need the index to change with them, and that is a different design:
  this one is right for a read-only dataset and would be wrong for a mutable
  one.
- `VehicleFilter.Matches` is now `Compile(clock)(vehicle)`. A caller in a loop
  that keeps using it gets the old cost, so the scan is the one place that
  matters and the one place that was changed.

## Where it sits

The work is Domain code (VehicleSearchIndex.cs and VehicleFilter.Compile), built once by Application's InventoryService when the catalogue loads. The index holds only what cannot change while the process runs, each row's lowercase text; auction status stays out because the clock changes it. The price is a few megabytes held for the life of the process, for a speedup no visitor could measure on the live site. A catalogue that changes after load, or one ten times larger, would need an index that updates with its rows or a real inverted index.

## Files

- [`api/TheYard.Domain/VehicleSearchIndex.cs`](https://github.com/SteveStout/TheYard/blob/main/api/TheYard.Domain/VehicleSearchIndex.cs): the index, its text and its fallback.
- [`api/TheYard.Domain/VehicleFilter.cs`](https://github.com/SteveStout/TheYard/blob/main/api/TheYard.Domain/VehicleFilter.cs): `Compile`, and the two-part token check.
- [`api/TheYard.Application/InventoryService.cs`](https://github.com/SteveStout/TheYard/blob/main/api/TheYard.Application/InventoryService.cs): built with the dataset, used by `Search`.
- [`api/TheYard.Tests/VehicleSearchIndexTests.cs`](https://github.com/SteveStout/TheYard/blob/main/api/TheYard.Tests/VehicleSearchIndexTests.cs): the indexed and unindexed paths must answer identically.
- [`api/TheYard.Tests/SearchIndexBenchmarkTests.cs`](https://github.com/SteveStout/TheYard/blob/main/api/TheYard.Tests/SearchIndexBenchmarkTests.cs): the measurement above, and the coverage assertion.
- [`api/TheYard.Tests/InventoryServiceTests.cs`](https://github.com/SteveStout/TheYard/blob/main/api/TheYard.Tests/InventoryServiceTests.cs): the facets are one instance, built with the catalogue (the addendum below).
- [`src/app/hooks/useInventory.ts`](https://github.com/SteveStout/TheYard/blob/main/src/app/hooks/useInventory.ts): the page asks for the facets until it has them, then keeps them.

## Addendum, 2026-09-17: the facets are built with the index

The index exists because a per-request walk over a hundred thousand rows is
work that never changes its answer, and the same was true of a neighbour it
left alone. `/api/facets`, the four sorted distinct lists behind the filter
dropdowns, walked the whole catalogue four times on every call, 12 ms on the
live container by the proof card's own measurement at 1.0.0.94, for an
answer fixed the moment the catalogue loads. And the page asked for it more
than it needed to: the effect that fetched the facets ran on every listing
refresh, which on the inventory page is four times a minute, so an idle
minute signed out was four listing reads and four facet reads (the runner's
measurement on 1.0.0.139, `mentor\logs\lane0917-963-perflane-measure.log`).

Two changes, shipped as 1.0.0.142. The facets are built in `BuildAsync`
beside the dictionary and the index, for the reason those two are, and
`Facets()` hands back that one instance; `InventoryServiceTests` holds it by
reference equality, which is the fact worth holding, since a rebuild would
return a new object with the same values and a value comparison would not
notice. On the page the effect asks until the facets have landed and then
returns before it asks: the reload nonce stays in its dependency list so a
first fetch that failed, the API still booting under `npm start`, is tried
again with the listing, and once there is an answer nothing asks again. No
cache middleware and no memory cache: the catalogue is immutable for the
life of the process and the facets are a field of it, which is the cheapest
cache there is and the only one a reader has to trust.

```csharp
/// <summary>
/// Distinct values feeding the UI's filter dropdowns, sorted. Built once
/// with the catalogue and handed back by reference, because walking all
/// hundred thousand vehicles four times on every request cost 12 ms on the
/// live container for an answer that cannot change after load
/// (ADR: The search index, addendum).
/// </summary>
public InventoryFacets Facets() => Inventory.Facets;

private static InventoryFacets BuildFacets(IReadOnlyList<Vehicle> vehicles) =>
    new(
        Distinct(vehicles, v => v.Make),
        Distinct(vehicles, v => v.BodyStyle),
        Distinct(vehicles, v => v.TitleStatus),
        Distinct(vehicles, v => v.Province));

private static IReadOnlyList<string> Distinct(
    IReadOnlyList<Vehicle> vehicles,
    Func<Vehicle, string> field) =>
    vehicles.Select(field).Distinct().OrderBy(v => v, StringComparer.Ordinal).ToList();
```
*Live from [`api/TheYard.Application/InventoryService.cs`](https://github.com/SteveStout/TheYard/blob/1d6b50b/api/TheYard.Application/InventoryService.cs#L207-L226), region `facets`, read from this build at 1d6b50b.*

```ts
// The filter dropdowns' options (facets) come from the API, because the page
// only holds a slice of the data. If the request fails, the dropdowns are
// empty rather than broken.
//
// Asked until answered, then never again: the server builds the options once
// and they cannot change while the page is open (ADR-025). reloadNonce stays
// in the dependencies so a failed first try (the API still booting under
// `npm start`, say) is retried along with the listing.
useEffect(() => {
  // The dropdowns belong to the inventory too, so the landing page skips them.
  if (!needsListing || facets !== EMPTY_FACETS) return;
  const controller = new AbortController();
  fetchFacets(controller.signal)
    .then(setFacets)
    .catch(() => {});
  return () => controller.abort();
}, [reloadNonce, facets, needsListing]);
```
*Live from [`src/app/hooks/useInventory.ts`](https://github.com/SteveStout/TheYard/blob/1d6b50b/src/app/hooks/useInventory.ts#L117-L133), region `facets-once`, read from this build at 1d6b50b.*

The server-side number is read off the container's own request ring
(`/api/admin/metrics`, `by_path`) after twenty reads, before and after, and
the idle minute is counted the same way as before; both are on the
Performance page.


## Addendum, 2026-10-05 (1.0.3.80): the schedule order

Steve asked why the listing was uneven on a warm site. Measured on the live containers, the server's own time for a page of a hundred vehicles was 406 ms at the median on the Azure SQL site and 692 ms on the Azure Cosmos DB site, with the listing taking 0.4 to 1.4 s through the edge. Timed on his machine by the shape of the query, the default listing took 90 to 100 ms, the same page sorted by price 35 to 45 ms, and one make with one row 25 to 30 ms. The difference was the default order itself: every request worked out all hundred thousand auction windows and sorted every match by them to keep a hundred.

A vehicle's window depends only on its id and the day's anchor, so for one day it never changes. What moves with the clock is which band each vehicle is in. `ScheduleOrder` holds the catalogue sorted twice, by end and by start, built once for each anchor, and reads the ending-soonest order off them at any instant: live by closest end, then upcoming by soonest start, then ended by most recent end, a tie in catalogue order. `InventoryService` keeps one per day and walks it until the page is full; the filter still runs down every row, because the total counts every match. Nothing is stored: the order is the schedule derived again and held in memory for the day it belongs to, so the rule that schedule facts are derived and never persisted still holds.

On the same machine the default listing went from 90 to 100 ms to 25 to 34 ms. `ScheduleOrderTests` holds the order to the full sort at every three hours across a week, ties included, and `InventoryServiceTests` holds the page and the total to the full sort under six filters and offsets.
