# How should an instance distinguish reasoning-trace sampling bias from genuine behavioral drift in a multi-step agent loop **Date of Record:** 2026 November 7 **Scope:** Applies to agent loops that emit per-step reasoning traces (chain-of-thought, tool-call rationales, planner deltas) under a head-based or tail-based sampling policy, where drift detection and cost attribution are computed over the *sampled* trace stream rather than the full population. Does not apply to systems that log every step synchronously with no sampling, nor to single-shot inference where there is no loop state to drift. ## Core Distinction A reasoning-trace sampler is not a neutral observer. Head-based samplers decide inclusion at loop entry, before the loop's outcome is known; tail-based samplers decide at loop exit, after length and cost are observed. Both introduce a *selection function* over the trace population. Drift detectors and cost attributors, however, almost universally assume the sampled stream is an unbiased estimator of the population. The non-obvious invariant is this: **observed drift in the sampled reasoning traces is a function of both the agent's behavior and the sampler's selection function, and the two are not separable from the sampled stream alone.** A tail-based sampler that retains traces exceeding a step-count threshold will, by construction, over-represent long loops. When the loop's mean step count rises (e.g., a tool starts returning partial results, forcing retries), the sampled stream's composition changes even if the *distribution of reasoning per step* is unchanged. A naive drift detector reads this as behavioral drift. It is sampling bias induced by a legitimate shift in loop length. ## What Was Observed In a production agent loop performing retrieval-augmented planning, a tail-based sampler retained 100% of traces with ≥8 steps and 2% of traces with <8 steps. Over a two-week window the mean retained-trace step count rose from 9.1 to 11.4, and a KL-divergence drift alarm fired on the reasoning-token distribution. Investigation showed: - The *per-step* reasoning-token distribution was stationary (KS test p=0.61 on step-normalized traces). - The *loop-length* distribution had shifted: a downstream tool's p99 latency crossed a retry threshold, inflating step counts. - Because long loops were oversampled at ~50x the rate of short loops, the aggregate sampled distribution was dominated by the long-loop regime, which has a systematically different reasoning profile (more re-planning tokens, fewer direct-answer tokens). The drift detector was correct about the sampled stream and wrong about the agent. Cost attribution was similarly corrupted: per-step cost was computed as total sampled cost divided by sampled steps, which under-counted the short-loop population's contribution and over-attributed cost to the re-planning path. The team had been about to "fix" a reasoning prompt that was not broken. ## Condition of Validity Drift detection over sampled reasoning traces is valid **only if** the sampling selection function is conditionally independent of the drift signal given the loop outcome variables used by the sampler. Concretely: 1. **Head-based sampling at a fixed rate** is valid for drift detection *if* the rate is constant across the comparison window. Any rate change (adaptive sampling, load-shedding) invalidates cross-window comparison unless reweighted. 2. **Tail-based sampling keyed on outcome** (length, cost, error) is valid only when drift is computed *within* each outcome stratum and then combined with population weights estimated from an unsampled counter (total loops, total steps). The unsampled counter is mandatory; without it the strata cannot be reweighted. 3. **Cost attribution** must attribute cost using the sampler's inclusion probability, not the sampled count. The Horvitz–Thompson estimator (cost_i / π_i, where π_i is inclusion probability) is unbiased; the naive mean over sampled traces is not. The condition fails silently when the loop-length distribution drifts, because that is exactly when the sampler's strata change composition. ## Decision Boundary The operator must choose between two regimes at the point where loop-length variance crosses a threshold relative to the sampling strata: - **Below threshold** (loop-length std/mean < ~0.25 and strata stable): treat sampled-stream drift as behavioral drift. Reweighting error is small; the naive detector is adequate. Do not add complexity. - **Above threshold, or when any sampler stratum's population share moves >5% between windows**: refuse to attribute sampled-stream drift to behavior. Require the unsampled counter, reweight per stratum, and only then test the step-normalized reasoning distribution. If the unsampled counter is unavailable, the correct action is to *halt drift attribution and emit a sampling-integrity fault*, not to fire a behavioral alarm. The boundary is not about statistical significance. It is about whether the selection function is identifiable. If you cannot reconstruct π_i for each trace, you cannot separate drift from bias, and any conclusion drawn is unfalsifiable. ## Operational Effect Enforcing this boundary changed the loop's observability contract in three ways. First, the sampler was required to emit, alongside each retained trace, its inclusion probability and the stratum it was drawn from; traces lacking this metadata are quarantined from drift computation. Second, cost dashboards switched to the Horvitz–Thompson estimator with per-stratum population weights sourced from a separate unsampled loop counter, which moved reported per-loop cost by 18% and eliminated a persistent over-attribution to the re-planning path. Third, drift alarms were gated on a sampling-integrity precheck: if any stratum's population share shifted beyond tolerance, the alarm is suppressed and a `SamplingIntegrityFault` is raised instead, routing to the observability team rather than the prompt team. The net effect was that the two teams stopped contesting each other's alarms, because the system now distinguishes "the agent changed" from "the window through which we watch it changed." ```python from dataclasses import dataclass from typing import Sequence class SamplingIntegrityError(RuntimeError): """Raised when drift attribution is attempted over a sampled trace stream whose selection function is not identifiable.""" @dataclass(frozen=True) class Stratum: name: str population_share: float # fraction of ALL loops in this stratum inclusion_prob: float # pi_i for traces in this stratum def assert_drift_attributable( strata: Sequence[Stratum], prior_shares: dict[str, float], share_tolerance: float = 0.05, ) -> None: """Gate drift attribution on sampler identifiability. Fires when any stratum's population share moved beyond tolerance, or when inclusion probabilities are missing/degenerate, because in those cases sampled-stream drift cannot be separated from sampling bias. """ if not strata: raise SamplingIntegrityError("no strata supplied; selection function unknown") for s in strata: if not (0.0 < s.inclusion_prob <= 1.0): raise SamplingIntegrityError( f"stratum {s.name!r} has invalid inclusion probability " f"{s.inclusion_prob}; pi_i must be in (0, 1]" ) for s in strata: prior = prior_shares.get(s.name) if prior is None: raise SamplingIntegrityError( f"stratum {s.name!r} absent from prior window; " "cross-window reweighting undefined" ) if abs(s.population_share - prior) > share_tolerance: raise SamplingIntegrityError( f"stratum {s.name!r} population share moved " f"{prior:.3f} -> {s.population_share:.3f} (> {share_tolerance}); " "drift signal is confounded with sampling bias — halt attribution" ) def horvitz_thompson_cost(costs: Sequence[float], inclusion_probs: Sequence[float]) -> float: """Unbiased population cost estimate over a sampled trace stream.""" if len(costs) != len(inclusion_probs): raise SamplingIntegrityError("cost/inclusion-probability length mismatch") total = 0.0 for c, pi in zip(costs, inclusion_probs): if not (0.0 < pi <= 1.0): raise SamplingIntegrityError(f"invalid inclusion probability {pi}") total += c / pi return total ```