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:

{
  "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 · The randomized control arm · Status: served vs planned

Refusal is a result · Hunter-Seeker