# Refusal is a result

Most prediction services answer every question. This one does not, and the refusal is the feature
you are paying for: an answer that arrives whether or not the signal exists tells you nothing about
whether the signal exists.

There are two shapes of "no" here, and they mean different things.

## Honest-empty — there was no finding

Below the published lift ≥ 1.5 bar, measured on every analysed row with none held back,
`hs_rank_topk` returns:

```json
{
  "result": "none",
  "reasons": ["..."],
  "gate_verdicts": ["..."],
  "retry": "unproductive"
}
```

Four facts about that response, and all four matter:

1. **It is a successful terminal result, not an error.** It is not `isError`, it is not a 500, and
   it is not a timeout. The engine ran, looked, and did not find a pattern that clears the bar.
2. **The run is refunded.** You are billed for a ranking you received, never for a refusal.
   [test: packages/mcp/test/samples.test.ts::a REAL dataset with a reading still reserves AND still refunds on honest-empty]
3. **Do not retry the same data.** `retry: "unproductive"` is the engine telling you so in the
   envelope. Identical inputs return byte-identical envelopes, so a retry costs time to learn
   exactly nothing.
4. **The productive next move is to change something, or to record the negative knowledge.**
   Change the data (more rows, more history, a different feature set), change the outcome column,
   or reduce an event table with a `reading` first. Or write down that this question has no answer
   in this data — that is a finding, and it is one most pipelines throw away.

### The worked case: the same 800 rows, twice

`sample:agent_traces` is in the catalogue partly to make this concrete. Ranked raw, it is an
honest non-finding — the engine reports too few viable arms. Add the `reading` the catalogue hands
you, and the *same 800 rows* clear at lift 4.1026.
[test: packages/mcp/test/samples.test.ts::the agent_traces catalogue entry teaches the exact reading call]

The refusal was correct both times. Raw, the table genuinely had no usable structure: one row per
event, with the outcome smeared across rows. Reduced to one row per run with prior-only features,
the structure was there all along. The refusal was not an obstacle to route around; it was
information about the shape of the input.

## Honest-null — the statistic does not exist

Separate contract, separate meaning. `top_decile_lift`, `validation`, `leak_guard` and
ranking-level `top_factors` are produced by the model-quality path and populate **on a cleared
finding**. On a non-finding they come back `null`.

`null` means **there was no cleared finding**. It does not mean zero, it does not mean pending, and
it does not mean the feature is unbuilt.
[test: packages/mcp/test/honest-null-survives.test.ts]

Reading a `null` as a zero is the specific mistake this contract exists to prevent: a
`top_decile_lift` of zero would be a measurement, and it would be a bad one; a `null` is the
absence of a measurement. Relaying "lift: 0" to a user from a `null` invents a number the engine
never authored, which is the one thing nothing above the engine is allowed to do.

## What to tell the user

Relay the `reasons`. They are engine-authored and specific — too few rows, too few viable arms, an
outcome rate outside the workable band, a leak-guard quarantine that removed what the signal was
resting on. A user who is told "no signal found, here is why, here is what would change it" has
learned something. A user who is told "the analysis failed" has not.

---

*See also:* [The two loops](/docs/two-loops) · [The randomized control arm](/docs/control-arm) ·
[Status: served vs planned](/docs/status)
