The randomized control arm

hs_action_evidence compares outcomes for entities you acted on against entities you did not. That comparison is only worth having if you decided who to act on by coin flip for part of the cohort. The acted group is selected by the model's own score, so acted-vs-not-acted differences are observational until you randomize. Withholding action on a random slice is not a caveat bolted onto this feature; it is the thing that turns the number into evidence.

This page is the how first, and the why underneath it.


How to run it

Five steps. Everything except step 2 you are already doing.

1 · Score the cohort. One model_ref from a cleared hs_rank_topk, one decision per row.

{ "tool": "hs_score_batch", "arguments": { "model_ref": "$model_ref", "entities": ["$rows"] } }

Keep the rows whose band is act. Those are the ones you would have acted on.

2 · Split that cohort by a seeded coin flip. This is the whole intervention.

import hashlib

def treated(entity_id: str, seed: str = "2026-Q3-outreach", holdout: float = 0.15) -> bool:
    """Deterministic, reproducible, and independent of anything the model saw.

    Hashing the id means the same entity lands the same way every time you re-run this — so a
    retry, a resumed job, or a second batch cannot quietly move somebody between arms. Do NOT
    seed on the score, the band, or anything else the model produced: that puts the model back
    in charge of the assignment and undoes the randomisation.
    """
    h = hashlib.sha256(f"{seed}:{entity_id}".encode()).digest()
    return int.from_bytes(h[:8], "big") / 2**64 >= holdout

Withhold action on 10–20% of the act cohort. Below about 10% the control arm is too small to reach the floors below in any reasonable time; above about 20% you are paying real money in forgone action for precision you probably do not need.

3 · Attest only for the treated half. An attestation is a record that you acted, so the untreated half gets none — that absence is what makes them a control.

{ "tool": "hs_attest_action",
  "arguments": { "model_ref": "$model_ref", "entity_id": "$entity_id",
                 "lever_token": "$lever_token", "dose": "$how_far_you_went" } }

4 · Report outcomes for BOTH halves. This is the step people skip, and skipping it is fatal: if you only report outcomes for entities you acted on, the control arm has no observed outcomes and hs_action_evidence has nothing to compare against.

{ "tool": "hs_report_outcome",
  "arguments": { "model_ref": "$model_ref", "entity_id": "$entity_id",
                 "outcome": true, "event_id": "$your_idempotency_key" } }

hs_report_outcome is append-only and never retrains. Pass an event_id you control: the same event_id twice is one row, so a retried job cannot double-count an outcome into the evidence.

5 · Read the evidence.

{ "tool": "hs_action_evidence", "arguments": { "model_ref": "$model_ref" } }

What you will see first, and why it is right

live: null. [live: S3]

That is the floor working. hs_action_evidence returns live: null until each cell has 30 reported outcomes, and flags small_n until 100. A difference computed across four entities per arm is not evidence — it is noise with a confidence interval drawn around it — and returning one would be worse than returning nothing, because a number gets acted on and a null does not.

Expect the floor to hold for a while. On a cohort of a few hundred with a 15% holdout, the control cell is the binding constraint, and it fills at the rate you actually run the play.


Why — the confounding, in the tool's own terms

hs_rank_topk ranks by likelihood. hs_score_entity bands by that same likelihood. If you then act on the act band and compare it to everything else, the two groups differ in the model's own estimate of their outcome likelihood before you did anything at all. Whatever difference you measure afterwards contains both the effect of acting and the effect of having been selected, and nothing in the arithmetic can separate them.

Randomising within the act band is what removes the selection: both arms were flagged, both were scored the same way, and the only thing that differs is a coin flip that no feature and no score could see.

What this defends against, and what it does not

Hunter-Seeker never grades its own homework in one specific sense: hs_model_quality (how well the model fits held-out data) and hs_action_evidence (whether acting changed outcomes) are separate tools over separate data, and are never merged. The model's fit statistics can never stand in for evidence that acting works. [test: packages/mcp/test/surfaces-parity.test.ts::B4: every surface lists exactly the tools the server registers]

It does not defend against selection bias. Nothing in the engine knows how you chose whom to act on, so nothing in the engine can correct for it. That correction is yours, and the randomised holdout above is the cheapest form of it.

If you cannot randomise

Sometimes you cannot withhold an action — a regulatory obligation, a safety case, a contract. Then the number is observational and you should say so out loud when you report it. The established remedies are:

All four are association under assumptions. The coin flip is the only one that is not.


See also: The two loops · Refusal is a result

The randomized control arm · Hunter-Seeker