# How search ranks entries

> How stacks search scores and tiers hits, what each JSON field means, and when the request leaves your machine.

`stacks search` takes what you want to build in plain words and returns the stacks and fragments that fit, each with a tier and the words that matched. Ranking is lexical: the CLI runs the same scorer locally that the hosted API runs, and search never calls a model.

```sh title="Terminal" group="search"
stacks search go cli --kind stack --limit 3 --local
```

```text title="Output" group="search"
stack go-cli (strong)
A Go command-line tool with subcommands, configurable settings, actionable errors and human or JSON output.
Why: cli (platforms); go (languages, description, summary, requests); go cli (id, name, tags, body)
...

stack go-api (partial)
A Go HTTP API on the standard library with JSON resources, consistent errors, request logs and graceful shutdown.
Why: go (languages, id, name, description, tags, body, summary, requests)
Penalties: platform (cli; entry: server)
Caveat: Race-enabled tests need cgo and a C compiler on every machine and CI runner.
...

stack typescript-cli (partial)
A compiled command-line tool with subcommands, JSON results, lazy configuration and structured logs.
Why: cli (platforms, id, name, description, tags); go cli (body); go (requests)
Penalties: language (go cli (go); entry: typescript)
...

Next: stacks show go-cli, then stacks new go-cli <dir>.
```

With `--local` the request never leaves your machine. `go-api` matches "go" well but is a server, so it takes a platform penalty and drops to `partial`. `typescript-cli` matches "cli" but not the language.

## Flags

| Flag | What it does |
| --- | --- |
| `<request...>` | What you want to build. The words are joined with spaces and trimmed, 1 to 500 characters |
| `--kind stack\|fragment` | Search only one list |
| `--limit <n>` | Maximum hits per list, 1 to 20. Default 5 |
| `--local` | Rank on this machine without sending the request to the API |
| `--offline` | No network requests, including the API call |
| `--json` | Return one `SearchResult` object |

## Where the request goes

By default, search sends the request text to `GET /v1/search` on https://stacks.steamfield.io and ranks the local registry at the same time. API hits replace bundled and cached-index copies of the same entries. Your project and user entries keep their local ranking, and an entry the API didn't return stays available from the local ranking.

The API gets one attempt with an 800 ms deadline, including reading its response. A failure falls back to local ranking and backs off for ten minutes. Only the endpoint URL and expiry time are saved, in `~/.cache/stacks/search/backoff.json`. No request or response is cached, and the request text is never written to logs.

These keep the request on your machine:

| Switch | Effect |
| --- | --- |
| `--local` | This invocation only |
| `--offline` or `STACKS_OFFLINE=1` | Every network request |
| `DO_NOT_TRACK` set to anything except empty, `0` or `false` | Search requests and telemetry |

Search never refreshes the registry index or fetches stats. It uses whatever is cached.

## How a hit is scored

Each entry is scored against the request's words with BM25, field by field, and the field scores are weighted and summed:

| Field | Source | Weight | Strong |
| --- | --- | --- | --- |
| Vocabulary facets (platforms, languages, product kinds, capabilities) | Search card, closed vocabulary | 3 | Yes |
| `requests` (the example request closest to yours) | Search card | 2.5 | Yes |
| `id`, `name` | Frontmatter | 2 | Yes |
| `description` | Frontmatter | 1.5 | Yes |
| `summary`, `useCases`, `synonyms` | Search card | 1.5 | Yes |
| `tags`, `category`, `goodFor` | Frontmatter, search card | 1 | No |
| Body | The guidance prose | 0.15 | No |

The search card is a small JSON file beside each entry; [Write your own stack or fragment](https://stacks.steamfield.io/docs/guides/authoring.md) covers writing one.

Penalties then scale the sum down:

| Penalty | When | Effect |
| --- | --- | --- |
| `platform` | You asked for a platform the stack doesn't target (for a fragment: none of the stacks it applies to) | Score times 0.3 |
| `language` | You named a language the stack doesn't use | Score times 0.3 |
| `productKind` | You asked for a kind of product the entry isn't | Score times 0.3 |
| `notFor` | Your request matches something the entry says it's not for, names the entry as something to exclude, asks for a fragment that doesn't apply to the stacks you named, or asks to add to an existing repository and the entry is a stack scaffold | Score times 0.5 |
| `untrusted` | The entry comes from an untrusted origin | No score change, but blocks `strong` |

When a hit has several penalties, the strongest applies in full and each further one applies at its square root, so three mismatches don't bury an entry that otherwise fits.

The adjusted sum is mapped to a `score` between 0 and 1. `coverage` is the share of your request's matched words that appear in an entry's strong fields.

## Tiers and ordering

| Tier | Requires |
| --- | --- |
| `strong` | `score` at least 0.5, `coverage` at least 0.5, no penalties, and a match on the id, the name or a vocabulary facet |
| `partial` | `score` at least 0.25 |
| `weak` | Anything lower with a positive score |

When your request names a stack, every other stack is at most `partial`.

Hits sort by tier, then trusted before untrusted, then score. Cached stats reorder hits only within a tier: the quality adjustment runs from -0.018 to +0.022, so evidence moves two hits at most 0.040 apart, and project counts add at most 0.01 of that. A failed last run adjusts the comparison by -0.002 and a passing one by +0.002. Stats from seeded development data still count toward ordering; `statsPopulation` says when that's the source.

Each list holds only hits with a positive score and at least one reason under `why` beyond a request constraint or the bare word `add`, so it can be shorter than `--limit` or empty. When a list has strong or partial hits, at most two weak hits follow them. A list with only weak hits keeps up to `--limit` of them.

## The JSON result

`stacks search --json` returns a `SearchResult`. `stacks schema search` prints its JSON Schema.

| Field | Meaning |
| --- | --- |
| `ranking` | `api` or `local` |
| `request` | The request text as searched |
| `understood` | Vocabulary terms recognized in the request, and under `unmatched` the content words no entry's strong fields matched. An unmatched word may still appear in entry bodies |
| `noStrongMatch` | `true` when neither list has a `strong` hit |
| `stacks`, `fragments` | The two hit lists |
| `remote` | `used`, `off` or `unavailable` |
| `remoteReason` | When `remote` is `off`: `local_flag`, `offline`, `do_not_track`, `backoff` or `no_endpoint` |
| `method` | API results only: `lexical` or `hybrid` |
| `statsPopulation` | When a shown hit carries stats, where they came from: `stacks projects` or `seeded development data`. If the API and the local cache both supplied shown stats and either is seeded, it's seeded. Absent when no shown hit has stats, or when a source doesn't say and none is seeded |

Each hit has:

| Field | Meaning |
| --- | --- |
| `kind`, `id`, `name`, `summary` | What the entry is |
| `origin`, `trusted` | Where it came from; see [Registries and trust](https://stacks.steamfield.io/docs/concepts/registries-and-trust.md) |
| `score` | 0 to 1, rounded to three decimals. Hits that round to zero are left out |
| `coverage` | Share of matched request words found in strong fields, rounded to three decimals |
| `tier` | `strong`, `partial` or `weak` |
| `why` | Each matching field and the words that matched it |
| `penalties` | Each penalty as `{ kind, request, entry }`: the kind, the request phrase that triggered it, and the entry's conflicting facets or matched `notFor` text. An `untrusted` penalty has an empty `request` and the entry's origin |
| `caveats` | Optional platform or operating limits from the search card |
| `appliesTo` | Fragments only: the stacks or tags it fits |
| `evidence` | Cached stats and last-run data, when available |

## Picking a stack as an agent

If you're an agent picking a stack for someone:

1. Compare the strong hits using `why` and `evidence`, and read their caveats.
2. When `noStrongMatch` is `true`, tell the user nothing fits well and confirm before using a weak match.
3. Never pick an untrusted entry unless the user named it.
4. Run `stacks show <id>` on the pick and explain why it fits before running `stacks new`.

## Exit codes

| Exit | Meaning |
| --- | --- |
| 0 | The search ran, including empty results and no strong match |
| 2 | Invalid input (`Usage`, `BadInput`) |
| 6 | A registry couldn't be read (`PermissionDenied`) |
| 130 | Interrupted |
