recall_guard.core.ensemble
recall_guard.core.ensemble
Opt-in N-draw execution and reduction over one prompt.
Asking the same prompt many times and reducing the replies is worthwhile
because the serving stack is nondeterministic even at temperature=0: the
model rarely repeats a parameter vector exactly, yet usually reaches the same
decision. A single draw is therefore a poor estimate of the parameters and a
good estimate of the decision, and an ensemble is how a caller gets both, with
the disagreement made explicit rather than averaged away.
Nothing here is reachable unless a caller passes an :class:EnsembleSpec.
There is no implicit default instance, no environment variable, and no
process-wide toggle -- a hidden switch would change what a run persisted
without changing what the caller wrote, which is precisely what an audit trail
cannot tolerate.
Two callbacks, not one. decide maps a reply to a hashable decision and
drives agreement; components optionally maps a reply to named scalars and
drives location and multimodality. One callback cannot serve both: "agreement"
needs a categorical outcome while "location" needs an ordered scalar, and
conflating them is how a reported consensus ends up naming a different reading
than the reported location.
Execution runs in waves rather than one flat fan-out, so the request budget can be enforced, a rejected credential can abort before the remaining draws are paid for, and replies can be reduced incrementally instead of accumulating.
LocationMode
Bases: StrEnum
Which location estimator to apply to an unflagged component.
Source code in recall_guard/core/ensemble.py
63 64 65 66 67 68 | |
MultimodalAction
Bases: StrEnum
What to do with a component that holds separated clusters.
Silently averaging across one is the single behaviour that must never be available: it launders a real disagreement into false precision, returning a value the model effectively never emitted.
Source code in recall_guard/core/ensemble.py
71 72 73 74 75 76 77 78 79 80 | |
ReferenceMode
Bases: StrEnum
Whether the optional reference draw varies per ensemble draw.
Source code in recall_guard/core/ensemble.py
83 84 85 86 87 | |
CostEstimate
dataclass
What an ensemble would cost, computed without issuing anything.
Source code in recall_guard/core/ensemble.py
90 91 92 93 94 95 | |
EnsembleResult
dataclass
One ensemble's reduced answer plus the evidence behind it.
component_verdicts carries the separated-cluster check for every
component, not only the flagged ones. A verdict of separated=False with
masses near the threshold is a very different situation from one with no
mass on either side, and only the caller can judge which matters -- so the
result reports what the test saw rather than only its boolean conclusion. A
None verdict means the check did not run at all.
max_tokens and temperature record the settings the draws were taken
under. An ensemble is an audit artifact, and "under what generation settings"
belongs next to the draw-set digest: a consensus sampled at a different token
budget than production is not measuring the production decision.
sampled_at records when the draws were taken, and is None for a
result produced by replaying a stored draw set -- which is the honest
answer, because a replay was not sampled. It exists because the sampled
distribution moves between sessions as well as within one: the same prompt
against the same model id has been observed to shift a component's median
materially over two days. So a consensus has a shelf life, draws_sha256
pins which draws produced it but nothing else pins when, and a stored
corpus is not ground truth against which to judge a fresh ensemble.
Source code in recall_guard/core/ensemble.py
98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 | |
EnsembleSpec
dataclass
Opt-in ensemble configuration.
Every default here is provisional: all of them were calibrated against a single measurement date at a crisis onset, chosen because it was the hard case. Whether they generalise to calmer regimes is unmeasured, which is why each threshold is a field rather than a literal.
max_tokens and temperature default to None, meaning the client's
own defaults. Set them to whatever production uses. An ensemble drawn at
a different token budget is not measuring the production decision -- and on a
reasoning model the budget is not a detail, because the chain of thought
consumes it and truncates the reply before the payload a caller parses.
Measured on one such model, dropping from a 2048-token production budget to
the 512-token client default took the parse rate from 95% to 48%.
draws is sized for agreement precision, not for component-split
detection; those are different numbers and the second is larger. See
:func:~recall_guard.core.consensus.smallest_detectable_split_n.
Source code in recall_guard/core/ensemble.py
143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 | |
smallest_certifiable_n
property
smallest_certifiable_n
Draws needed to certify agreement_target, or None if unset.
Unanimity is the best case, so this is a hard floor -- below it no observed agreement can clear the target, whatever the model returns.
estimate_cost
estimate_cost(
spec,
*,
max_retries,
has_reference,
seconds_per_request=None,
)
Worst-case request count and duration, without issuing any request.
The nominal draw count is the floor, not the worst case: each logical draw
can become max_retries + 1 requests, and a configured reference model
doubles the whole thing.
Source code in recall_guard/core/ensemble.py
256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 | |
canonical_draw_hash
canonical_draw_hash(contents)
SHA-256 over the draw set's reply text, independent of arrival order.
Covers reply text only. Logprob structures are excluded because their key ordering comes from the provider's JSON and is not stable across servers or library versions, and timing and thread identity are excluded because they are not properties of the answer.
Sorting before hashing is what makes the digest a property of the draw set; the tie-break rules elsewhere in this module recover a deterministic order from content alone, so nothing depends on how the draws arrived.
Source code in recall_guard/core/ensemble.py
280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 | |
reduce_draws
reduce_draws(
draws,
spec,
*,
decide,
components=None,
waves=None,
n_requested=None,
fail_counts=None,
sampled_at=None,
)
Reduce a draw set to one answer. Pure: no I/O, no randomness, no clock.
Separated from execution so a stored draw set can be replayed into a
bit-identical result without contacting a model, which is what makes an
ensemble auditable after the fact. No clock is read here: sampled_at
stays None unless the caller passes through what execution recorded.
Source code in recall_guard/core/ensemble.py
408 409 410 411 412 413 414 415 416 417 418 419 420 421 422 423 424 425 426 427 428 429 430 431 432 433 434 435 436 437 438 439 440 441 442 443 444 445 446 447 448 449 450 451 452 453 454 455 456 457 458 459 460 461 462 463 464 465 466 467 468 469 470 471 472 473 474 475 | |
generate_ensemble
generate_ensemble(
lm, prompt, spec, *, decide, components=None
)
Draw spec.draws replies to prompt and reduce them.
Raises:
| Type | Description |
|---|---|
ValueError
|
If a component holds separated clusters and the spec asks to raise. |
RuntimeError
|
If the request budget is exhausted, too few draws are usable, or transport failures exceed the configured share. Each of these is a refusal to report a confident answer computed from survivors. |
Source code in recall_guard/core/ensemble.py
478 479 480 481 482 483 484 485 486 487 488 489 490 491 492 493 494 495 496 497 498 499 500 501 502 503 504 505 506 507 508 509 510 511 512 513 514 515 516 517 518 519 520 521 522 523 524 525 526 527 528 529 530 531 532 533 534 535 536 537 538 539 540 541 542 543 544 545 546 547 548 549 550 551 552 553 554 555 556 557 558 559 560 | |