Duration policy validation

Validated with documented limitations.

1. The component

rfgen.planning resolves one caller-configured event duration before an event record is created. It selects the most specific policy from class, family, role, and scene scope; converts the chosen seconds to an integer sample count; and records the result as an immutable planning artifact. A sample is one time-indexed pair of in-phase and quadrature (I/Q) signal values in a receiver capture. The component does not schedule event starts, generate a waveform, model a transmitter or protocol, or compose receiver IQ. Concepts / Scenes locates it among the other planning decisions.

class DurationPolicyResolver:
    @staticmethod
    def resolve(
        config: DurationPolicyConfig | dict[str, object], *,
        class_name: str | None, family: str | None, role: str | None,
        sample_rate_hz: float, capture_samples: int,
        allow_clipping: bool = False,
    ) -> DurationPlan: ...

    @staticmethod
    def write(
        plan: DurationPlan | dict[str, object], *, scene_id: str,
        root: str | Path = ".",
    ) -> Path: ...

    @staticmethod
    def read(path: str | Path) -> DurationPlan: ...

Input or output

Type and units

Meaning

config

DurationPolicyConfig or mapping

A required scene policy plus optional class, family, and role policies. Resolution order is class, family, role, then scene.

policy kind

fixed, uniform, or lognormal

Chooses a constant duration, a bounded uniform draw, or a bounded lognormal draw. A lognormal value is exp(Y) where Y has a normal distribution.

seed

uint64, an unsigned 64-bit integer

Replays the random-policy draw exactly for an unchanged configuration.

sample_rate_hz

positive float; samples/s

Converts requested seconds to an integer capture extent.

capture_samples

positive integer; samples

Maximum available capture extent.

allow_clipping

bool; default False

Keeps containment strict by default; True records an explicitly truncated extent.

returned DurationPlan

immutable schema-version-1 record

Stores bounds, requested seconds, realized samples, issuer, and containment result.

from rfgen.planning import DurationPolicyResolver

plan = DurationPolicyResolver.resolve(
    {"scene": {"kind": "fixed", "value_s": 0.020, "seed": 7}},
    class_name=None,
    family=None,
    role=None,
    sample_rate_hz=20_000_000,
    capture_samples=2_000_000,
)
assert (plan.issuer, plan.realized_duration_samples, plan.contained) == (
    "scene", 400_000, True,
)
DurationPolicyResolver.write(plan, scene_id="capture-0001", root="output")

The persisted record is artifacts/plans/<scene_id>/durations.json. It contains exactly schema_version, policy_kind, min_s, max_s, seed, requested_duration_s, realized_duration_samples, issuer, contained, and clipped_duration_samples.

2. What we validated

This validation establishes four load-bearing claims. Each is supported in section 3.

  1. Hierarchical policy resolution (§3.1): the most-specific configured scope supplies the recorded duration.

  2. Seeded bounded distributions (§3.2): fixed, uniform, and bounded-lognormal policies preserve their stated draw rules.

  3. Sample extent and containment (§3.3): duration conversion and capture fitting use explicit, testable boundaries.

  4. Immutable duration artifact (§3.4): the stored record preserves the resolved decision and rejects incompatible data.

Section 4 states the modeling and numerical limits.

3. Evidence per claim

3.1 Hierarchical policy resolution

The resolver checks class, family, role, then scene, returning the first configured match and recording that scope in DurationPlan.issuer. The acceptance test supplies four fixed policies and verifies the four selected durations and issuers: class 0.25 s, family 0.5 s, role 0.75 s, and scene 1.0 s. At 8 samples/s, those values produce 2, 4, 6, and 8 samples, respectively. This establishes precedence and the recorded provenance of the duration decision.

Evidence: tests/unit/test_duration_policy.py::test_class_family_role_scene_precedence passed as part of the focused suite (15 passed). The component’s scope is intentionally limited to a caller-configured planning decision; it supplies neither a scheduler nor a physical or traffic model.

3.2 Seeded bounded distributions

For a fixed policy, the requested duration equals value_s. For a uniform policy, the implementation delegates to NumPy’s Generator.uniform(min_s, max_s). For a bounded lognormal policy it draws

[ X = \exp(Y), \quad Y \sim \mathcal{N}(\mu, \sigma), ]

and accepts the first finite value satisfying min_s <= X <= max_s, with at most 64 draws. The same configuration and seed produce equal plans. The tests make a constant or ignored-random-generator implementation observable by using nondegenerate bounds, and make an incorrect retry count observable with a backend that yields no in-bound candidate.

A 256-seed diagnostic with bounds [0.010, 0.030] s observed 256 distinct, in-bound uniform outcomes, from 0.010080565 s to 0.029952775 s, with mean 0.021017539 s. A bounded-lognormal diagnostic using mu=-3.9 and sigma=0.24 also observed 256 distinct, in-bound values, from 0.010961572 s to 0.029674358 s, with mean 0.020139382 s. These are checks of seeded distribution mechanics, not measurements of RF traffic or transmitter on-time.

Evidence: tests/unit/test_duration_policy.py::test_nondegenerate_uniform_is_seeded_and_seconds_round_half_even_to_samples and tests/unit/test_duration_policy.py::test_nondegenerate_lognormal_is_seeded_bounded_and_retries_exactly_64; the latter verifies exactly 64 rejected candidates yield {code: "duration_draw_failed", attempts: 64}. NumPy documents the uniform and lognormal generator semantics [1, 2].

3.3 Sample extent and containment

The resolver computes the requested capture extent as

[ n = \operatorname{round}_{\mathrm{half\ even}}(\text{seconds} \times \text{sample_rate_hz}). ]

Round-half-even sends an exact tie to the nearest even integer. Python’s Decimal implementation therefore maps 2.5 samples to 2, not 3 [3]. At 1,000 samples/s, probes gave rejection for exactly 0.0005 s, then one sample for 0.0005000001 s; exact 1.5, 2.5, and 3.5 sample values became 2, 2, and 4. This guards the boundary that changes whether an event exists.

Containment is strict by default. A 20-sample request in a 15-sample capture raises PlacementError with {code: "duration_not_contained", duration_samples: 20, capture_samples: 15}. With allow_clipping=True, the plan records contained: false, realized_duration_samples: 15, and clipped_duration_samples: 15. The acceptance test exercises both paths.

Evidence: tests/unit/test_duration_policy.py::test_nondegenerate_uniform_is_seeded_and_seconds_round_half_even_to_samples and tests/unit/test_duration_policy.py::test_default_no_clipping_raises_then_opt_in_records_clipped_result; Python documents the specified decimal rounding mode [3].

3.4 Immutable duration artifact

write creates the canonical path once using exclusive file creation, so a second write raises FileExistsError rather than overwriting the decision. read validates the exact version-1 outer schema and the record invariants: requested seconds must lie within the recorded bounds, fixed policies have equal bounds, and the clipping fields agree with containment. Tests verify a write/read round trip, the exact field set and order, rejection of a modified requested duration, rejection of missing or extra fields, and rejection of a nonliteral schema version.

Evidence: tests/unit/test_duration_policy.py::test_duration_artifact_has_exact_schema_and_is_immutable, tests/unit/test_duration_policy.py::test_duration_reader_rejects_tampered_v1_semantics, and tests/unit/test_duration_policy.py::test_duration_reader_rejects_nonliteral_v1_schema, all included in the focused 15-test suite. Pydantic supplies strict, frozen model validation for the public configuration and artifact records [4].

4. Limits and what is not validated

  • Policy values and seeds are caller-supplied synthetic planning priors. This component does not establish transmitter, protocol, corpus-traffic, or transmitter-on-time realism. Such a claim requires calibration against an appropriate target population outside this component.

  • A duration must be strictly greater than 0.5 / sample_rate_hz to realize at least one sample. Exact half-sample ties and smaller positive durations are rejected with duration_policy_invalid; they are not rounded upward.

  • A bounded lognormal policy needs enough probability within its stated bounds. Otherwise, 64 rejected or nonfinite candidates produce duration_draw_failed; no endpoint is substituted.

  • The artifact records a resolved duration and containment result only. Event count, start time, waveform, device identity, propagation, labels, receiver behavior, and IQ generation remain outside its scope.

5. References

  1. NumPy Generator.uniform documentation. PyPI distribution: numpy; installed version: 2.4.1. Supplies uniform draws used by the resolver and focused diagnostics.

  2. NumPy Generator.lognormal documentation. PyPI distribution: numpy; installed version: 2.4.1. Supplies lognormal draws used by the resolver and focused diagnostics.

  3. Python decimal rounding modes. Python standard library, Python 3.12. Defines ROUND_HALF_EVEN, used to convert seconds to samples.

  4. Pydantic documentation. PyPI distribution: pydantic; installed version: 2.13.3. Supplies strict immutable input and artifact schemas.

  5. Duration policy API. Public policy, containment, artifact, and structured-error contract.