rfgen.annotators

Phase 2 of the pipeline: turning verified per-emitter and scene metadata into natural-language artifacts (captions, Q&A, reasoning, scene reports, contrastive pairs). Every concrete annotator subclasses BaseAnnotator; the inference call itself goes through a BaseInferenceClient implementation (text-only LLMs, vision-language, and audio-language models all register against the same rfgen.inference_clients entry-point group).

Batch submission backends are planned future surface. The shipped annotation API uses synchronous BaseInferenceClient.complete calls behind BaseAnnotator.

Warning

Pre-implementation. Class signatures, parameter types, and class-attribute defaults are proposals. Once code lands, this page will be regenerated from docstrings via sphinx.ext.autodoc. The shape below matches what autodoc emits so the swap is mechanical.

Module summary

from rfgen.annotators import BaseAnnotator, StoredRecord
from rfgen.enums import AnnotationType
from rfgen.inference import BaseInferenceClient, InferenceResponse

client: BaseInferenceClient = ...   # any BaseInferenceClient implementation
annotator: BaseAnnotator = ...
stored_record: StoredRecord = ...
response = client.complete(
    prompt="FACTS\nemitters: ...\nTASK\nWrite a caption.",
    json_schema={"type": "object", "properties": {"caption": {"type": "string"}}},
    max_tokens=200,
    request_id="sample-001::caption::caption.dense.v3::run-2026-07-08",
    template_id="caption.dense.v3",
    run_id="run-2026-07-08",
)

record = annotator.annotate(
    stored_record,
    annotation_type=AnnotationType.CAPTION,
    template_id="caption.dense.v3",
    run_id="run-2026-07-08",
)

BaseInferenceClient is the provider-agnostic inference call surface. InferenceResponse is the parsed, metadata-tagged result yielded by every inference client.

Class index

Class

Kind

Notes

StoredRecord

datatype

IQ-stripped metadata view of a stored record; the annotator pipeline input

whitelist_filter

function

Strips IQ and unsafe metadata fields from a Record, returning a StoredRecord

InferenceResponse

datatype

Provider-agnostic response from BaseInferenceClient.complete(...)

BaseAnnotator

abc

Inference-grounded text annotation contract; subclassed by every concrete annotator

BaseInferenceClient

abc

Provider-agnostic inference call surface (Gemini / Anthropic / OpenAI / vLLM, including vision- and audio-language models)

Templater

class

Pure deterministic prompt renderer; consumes only whitelist_filter(record, sample_id=...)

select_paes_verifier_subset

function

Deterministically preselects partial PAES verifier subsets for BaseAnnotator.from_components(...)

AnnotationType

enum / Literal

The five annotation types: caption, qa, reasoning, scene_report, contrastive

ClosedVocab

class

Closed-vocabulary container injected into every prompt

Prompt

dataclass

(system, user, output_schema) triple returned by Templater.render

ANNOTATION_OUTPUT_SCHEMAS

config

Mapping from AnnotationType to its Pydantic output schema

For the conceptual map of annotation types and the hallucination-control strategy, see Concepts / Annotations. For the normative prompt structure, output schemas, and validation rules, see Annotation Templates.


class rfgen.core_types.StoredRecord

@dataclass(frozen=True)
class StoredRecord:
    """IQ-stripped metadata view of a stored record.

    The annotator pipeline (Templater, BaseAnnotator, and integrated PAES
    scoring) reads this
    view rather than the full Record so that Phase 2 workers never load IQ
    tensors. Created by StoredRecord.from_record(record) and redacted by
    whitelist_filter(record, sample_id=...) in the pipeline coordinator.
    Lives in rfgen.core_types and is re-exported from rfgen.annotators.
    """

    scene: SceneMetadata
    emitters: tuple[SignalMetadata, ...]
    bboxes: tuple[BBox, ...]
    seg_mask: torch.Tensor | None = None
    text: dict[str, object] | None
    schema_version: int = 1

Kind. Frozen dataclass / value type.

Module. rfgen.core_types; re-exported from rfgen.annotators.

Field

Type

Description

emitters

tuple[SignalMetadata, ...]

Per-emitter ground-truth metadata. Same ordering as the parent Record. The Templater reads emitter.family, emitter.class_name, emitter.bandwidth_hz, emitter.snr_db, and emitter.channel_profile to populate the FACTS block.

scene

SceneMetadata

Scene-level ground truth: duration, realized emitter count, receiver geometry, scene ID.

bboxes

tuple[BBox, ...]

Structured time-frequency boxes copied from the parent Record.

seg_mask

torch.Tensor | None

Optional segmentation labels copied from the parent Record.

text

dict[str, object] | None

Existing text annotations written by prior Phase 2 runs, keyed by annotation_type. None for freshly generated records. Used by the verifier to detect re-annotation attempts.

schema_version

int

Stored-record schema version.

Notes

  • IQ is excluded. The coordinator calls whitelist_filter(record, sample_id=...) after reading from BaseStore to strip the IQ tensor before handing the view to annotators. This is a hard API boundary: no component in the annotator pipeline may request IQ.

  • Construction. whitelist_filter(...) starts from StoredRecord.from_record(record), redacts prompt-unsafe metadata, and stores the validated sample_id in scene.extras["sample_id"] for downstream routing. Annotator plugins receive StoredRecord; they never construct it themselves.


whitelist_filter

from rfgen.annotators import whitelist_filter

def whitelist_filter(record: Record, *, sample_id: str) -> StoredRecord: ...

Module. rfgen.annotators (src/rfgen/annotators/__init__.py).

The IQ-stripping bridge between the storage layer and the annotator pipeline. Reads a full Record (which includes the IQ tensor) and returns a StoredRecord that contains only metadata fields safe to inject into inference prompts. The IQ tensor and per-device fingerprint parameters are excluded.

Parameters

Name

Type

Description

record

Record

Full record as returned by StoreHandle.read. Contains IQ, labels, per-emitter metadata, scene metadata, and any existing text annotations.

sample_id

str

Content-hash identifier of this record in the store (the value returned by StoreHandle.write or passed to read). Validated against the record and copied into StoredRecord.scene.extras["sample_id"] for downstream routing.

Returns

A StoredRecord with:

  • scene.extras["sample_id"] set to the supplied sample_id argument.

  • emitters copied from record.emitters, with extras.fingerprint_params and extras.channel_taps removed (these are per-device or per-path technical fields not useful for prose generation).

  • scene copied from record.scene, with realization_seed, config_hash, and operational fields removed.

  • bboxes and seg_mask copied from the parent record.

  • text copied from record.text (existing annotations, or None for fresh records).

Notes

  • Hard boundary. This function is the only place in the annotator pipeline where Record.iq is read; the returned StoredRecord has no IQ field. All annotator plugin code receives StoredRecord and cannot access IQ by design.

  • Deterministic. Same record and sample_id always produce the same StoredRecord. No randomness.

  • Called by the coordinator, not plugins. Annotator plugins (BaseAnnotator, Templater) receive a StoredRecord that was already filtered. They do not call whitelist_filter directly.


class rfgen.inference.InferenceResponse

@dataclass(frozen=True)
class InferenceResponse:
    text: str
    parsed: object | None
    usage: TokenUsage
    finish_reason: str
    model_id: str
    request_id: str

One parsed inference response returned by BaseInferenceClient.complete. Token counts live in the nested TokenUsage value as input_tokens, output_tokens, and cached_input_tokens. parsed is populated only when the caller supplied json_schema and provider output validated successfully; otherwise callers read text and perform their own parsing.


class rfgen.annotators.Templater

class Templater:
    """Pure deterministic prompt renderer.

    Same inputs yield byte-exact output. The templater consumes only the
    output of `whitelist_filter(record, sample_id=...)`; it never reads `iq.zarr` and
    never sees the raw signal.
    """

Kind. Class. Stub: full signature lands with the implementation.

The Templater builds a structured (system, user, output_schema) Prompt from verified metadata, an AnnotationType, a template_id (e.g. "caption.dense.v3"), and a ClosedVocab. It is the first stage of the shipped Phase 2 pipeline (Templater -> inference client -> BaseAnnotator PAES scoring). See Annotation Templates § Prompt structure for the normative spec.

render()

def render(
    self,
    record: StoredRecord,
    annotation_type: AnnotationType,
    template_id: str,
) -> Prompt

Build a structured prompt from verified metadata. Pure function: same inputs yield byte-exact output. The ALLOWED VOCABULARY block comes from self.closed_vocab, which is set when the Templater is constructed.

Parameters

Name

Type

Description

record

StoredRecord

IQ-stripped metadata view of the record to annotate. The templater reads record.emitters, record.scene, and record.bboxes to populate the FACTS block.

annotation_type

AnnotationType

Which annotation template family to render (caption, qa, reasoning, scene_report, contrastive).

template_id

str

Specific template variant, e.g. "caption.dense.v3". Must exist in the template registry for the given annotation_type.

Returns

A Prompt carrying system, user, and output_schema ready for the inference adapter.


select_paes_verifier_subset

def select_paes_verifier_subset(
    records: Iterable[StoredRecord],
    *,
    verifier_subset_pct: float,
    global_seed: int | str | None = None,
    config_hash: str | None = None,
) -> frozenset[str]: ...

The shipped PAES verifier path is integrated into BaseAnnotator. There is no public Verifier class in rfgen.annotators.

AnnotatorConfig.verifier_subset_pct accepts values from 0.0 through 100.0. 0.0 disables PAES and 100.0 scores every record handed to annotate(...). Intermediate percentages require preselected membership from select_paes_verifier_subset(...), then passed to BaseAnnotator.from_components(...) as verifier_subset_sample_ids with the same non-zero verifier_subset_pct.

global_seed and config_hash are optional deterministic tie-break inputs for out-of-band subset planning. When omitted, the function uses uniform scene.extras["global_seed"] and scene.extras["config_hash"] values if every record carries the same value; otherwise it falls back to stable absent-value sentinels. Supplying these arguments pins the same subset ordering across workers that receive equivalent StoredRecord metadata.

The PAES metric itself is resolved at annotation time through an EntryPointRegistry lookup for the canonical paes metric. That metric owns its result schema. The annotator stores the returned object under the annotation row’s paes field without exposing a separate verifier datatype.


class rfgen.annotators.AnnotationType

# Canonical definition in rfgen.enums; re-exported from rfgen.annotators
class AnnotationType(StrEnum):
    CAPTION      = "caption"
    QA           = "qa"
    REASONING    = "reasoning"
    SCENE_REPORT = "scene_report"
    CONTRASTIVE  = "contrastive"

Kind. StrEnum (re-exported from rfgen.enums). The five normative annotation types shipped by the framework. Used as a field on InferenceResponse, as the routing key in ANNOTATION_OUTPUT_SCHEMAS, and as a parameter of Templater.render.

In Python code prefer the symbolic form (AnnotationType.CAPTION); in YAML use the string value (caption). Pydantic coerces by value at validation time.

See Reference / API / Enums § AnnotationType for the canonical definition and Annotation Templates § The five annotation types for per-type schemas, token budgets, and example outputs.


class rfgen.annotators.ClosedVocab

class ClosedVocab:
    """Closed-vocabulary container injected into every prompt.

    Auto-generated from (1) the hierarchical taxonomy, (2) the channel-profile
    list, (3) physical-attribute units, and (4) generic prose descriptors.
    """

Kind. Class. Stub: full signature lands with the implementation.

ClosedVocab is rendered into the ALLOWED VOCABULARY block of every system prompt and is the basis for the post-hoc validator rule: a rewrite that contains a token unknown to the closed vocabulary and claims a physical attribute fails the record. Generic prose tokens that make no physical claim are not constrained. See Annotation Templates § Closed vocabulary.


class rfgen.annotators.Prompt

@dataclass(frozen=True)
class Prompt:
    system: str            # system instruction, including the forbid clause
    user: str              # user message: FACTS + TASK sections
    output_schema: dict    # JSON Schema for constrained decoding

Kind. Dataclass. The triple returned by Templater.render. Each provider adapter maps the triple to its native call shape (Gemini response_json_schema, OpenAI response_format, Anthropic tools[].input_schema, vLLM grammar). See Annotation Templates § Prompt structure and § JSON-constrained decoding per provider.


ANNOTATION_OUTPUT_SCHEMAS

ANNOTATION_OUTPUT_SCHEMAS: dict[AnnotationType, type[BaseModel]] = {
    AnnotationType.CAPTION:      CaptionOutput,
    AnnotationType.QA:           QAOutput,
    AnnotationType.REASONING:    ReasoningOutput,
    AnnotationType.SCENE_REPORT: SceneReportOutput,
    AnnotationType.CONTRASTIVE:  ContrastivePair,
}

Kind. Module-level config / registry. Maps each AnnotationType to the Pydantic schema that validates its raw model output. Used by the BaseAnnotator contract test:

schema_cls = ANNOTATION_OUTPUT_SCHEMAS[annotator.annotation_type]
schema_cls.model_validate(result)

The shipped schemas (CaptionOutput, QAOutput, ReasoningOutput, SceneReportOutput, ContrastivePair) are defined in Annotation Templates; this dict is the single lookup table consumers use to dispatch validation.


Abstract base classes

class rfgen.annotators.BaseAnnotator

Inference-grounded text annotation. Operates on already-labeled samples (does not see IQ; only metadata).

class BaseAnnotator(ABC):
    name: str

    def annotate(
        self,
        record: StoredRecord,
        *,
        annotation_type: AnnotationType,
        template_id: str,
        run_id: str,
    ) -> StoredRecord:
        """Return record with the matching text annotation populated.

        Implementation pattern:
        1. Render a deterministic template from the StoredRecord metadata.
        2. Call client.complete(...) for the requested annotation type, passing
           request_id, template_id, and run_id for retry and validation context.
        3. Optionally call a second client to verify the result is grounded.
        4. Return a new StoredRecord with a text row keyed by
           (annotation_type, template_id, run_id).

        Implementations MUST NOT pass IQ or spectrograms to the model;
        all hallucination control rests on metadata-only prompting plus
        JSON-constrained decoding where the provider supports it.
        """

    def annotate_store(
        self,
        handle: StoreHandle,
        *,
        sample_id: str,
        annotation_type: AnnotationType,
        template_id: str,
        run_id: str,
    ) -> StoredRecord:
        """Read an existing stored sample and append one annotation row."""

annotate()

Public method on BaseAnnotator. Produces annotation text grounded only in metadata; never sees IQ or spectrograms. Re-running with an existing (annotation_type, template_id, run_id) row returns the input StoredRecord unchanged.

annotate_store()

Public method on BaseAnnotator. Reads an existing sample from an open StoreHandle, applies whitelist_filter, runs annotate(...), and persists the resulting row through StoreHandle.append_annotation_row. The sample_id does not change, and the store preserves IQ, scene metadata, labels, canonical sample bytes, and any existing annotation rows.

class rfgen.inference.BaseInferenceClient

Defined in rfgen.inference.protocols and re-exported from rfgen.inference. The ABC is modality-agnostic: text-only LLMs, vision-language, and audio-language models all register against the same rfgen.inference_clients entry-point group.

class BaseInferenceClient(ABC):
    """Abstracts Gemini / Anthropic / OpenAI / vLLM (and vision- or audio-language
    variants) behind a uniform interface."""

    provider_name: ClassVar[str]
    model_id: ClassVar[str]

    @abstractmethod
    def complete(
        self,
        *,
        prompt: str | Messages,
        json_schema: dict | None = None,
        max_tokens: int | None = None,
        request_id: str | None = None,
        template_id: str = "",
        run_id: str = "",
    ) -> InferenceResponse: ...

The shipped surface lives at src/rfgen/inference/protocols.py. Concrete clients in rfgen.inference.clients provide Gemini, Anthropic, OpenAI, and OpenAI-compatible vLLM request shaping. The cross-cutting ABC keeps a single completion entry point so callers can swap providers without changing annotator code.

complete()

Abstract method on BaseInferenceClient. Single synchronous inference call returning a typed InferenceResponse (parsed is populated when json_schema is supplied and the response validates). request_id, template_id, and run_id are per-call metadata used in retry logs and InferenceError context; they are not constructor fields.

Batch annotation orchestration

The shipped Phase 2 surface lives in rfgen.annotation_orchestrators. It owns local and managed-provider submission, bounded polling, result validation, durable resume handles, append-only persistence, cancellation, and provider resource cleanup. It is separate from the synchronous inference-client surface used by BaseAnnotator.

class rfgen.annotation_orchestrators.AnnotationJob

class AnnotationJob(BaseModel):
    sample_id: str
    annotation_type: AnnotationType
    template_id: str
    run_id: str
    prompt: str
    output_schema: dict[str, object] = {}

    @property
    def key(self) -> tuple[str, AnnotationType, str, str]: ...

The immutable job key is (sample_id, annotation_type, template_id, run_id). All string fields must be non-empty. Provider outputs are checked against output_schema before successful results are persisted.

class rfgen.annotation_orchestrators.BatchResult

An immutable result containing its AnnotationJob, an output, or an error string. Provider failures, missing or duplicate provider IDs, malformed output, and schema-validation failures become per-job errors rather than silently changing result order.

class rfgen.annotation_orchestrators.BatchStatus

class BatchStatus(BaseModel):
    total: int
    pending: int
    succeeded: int
    failed: int

class rfgen.annotation_orchestrators.BatchHandle

An opaque handle returned by submit(). to_json() serializes the provider identity, submitted jobs, provider-specific jobs, fetched and reused results, adapter state, persisted job keys, and cleanup state. Pass that JSON to resume() on a new orchestrator instance of the same provider class to resume polling or cleanup without resubmitting completed work.

Handles are bound to the orchestrator instance that created or resumed them. Passing a handle to another instance raises an error.

class rfgen.annotation_orchestrators.BaseBatchAnnotationOrchestrator

class BaseBatchAnnotationOrchestrator(ABC):
    @abstractmethod
    def submit(self, jobs: Sequence[AnnotationJob]) -> BatchHandle: ...

    def poll(self, handle: BatchHandle) -> BatchStatus: ...

    @abstractmethod
    def fetch(self, handle: BatchHandle) -> Iterable[BatchResult]: ...

    @abstractmethod
    def cancel(self, handle: BatchHandle) -> None: ...

    def bind_persistence(
        self,
        *,
        persist: Callable[[BatchResult], None],
        load_completed: Callable[[AnnotationJob], BatchResult | None],
    ) -> None: ...

    def resume(self, payload: str) -> BatchHandle: ...

poll() caches the last status until min_poll_interval_s has elapsed, which prevents tight provider polling loops. Managed provider status normalization counts cancelled and expired jobs as failed terminal results.

Delivery is at least once at the provider boundary and idempotent at dataset persistence. Before submission, an orchestrator loads completed results by AnnotationJob.key and submits only missing jobs. During fetch, each key is persisted at most once per durable handle. StoreAnnotationPersistence writes through the public append-only store API at text[annotation_type][template_id][run_id], the same path it reads to detect completed work.

Managed fetch persists validated results before attempting provider cleanup. If cleanup fails, cleanup_complete remains false and the serialized handle retains the fetched results plus persisted keys. Resuming and fetching that handle retries cleanup without another provider fetch or second dataset write.

class rfgen.annotation_orchestrators.LocalLoopAnnotator

Synchronous sequential implementation for local and small runs. It accepts a completion callable, validates each output, records per-job errors, and reuses completed keys from its bounded cache or bound persistence.

Managed provider implementations

Class

Provider lifecycle

OpenAIBatchAnnotator

OpenAI Files and Batch APIs, including optional input-file deletion.

AnthropicBatchAnnotator

Anthropic Message Batches submission, polling, result iteration, and cancellation.

VertexBatchAnnotator

Vertex batch prediction with content-addressed Google Cloud Storage staging.

SageMakerBatchAnnotator

SageMaker transform jobs with content-addressed Amazon S3 staging.

AzureBatchAnnotator

Azure ML batch endpoints with content-addressed Azure Blob staging.

Each managed class supports from_config() for typed provider resources and from_sdk_client() for an injected official SDK boundary. Cloud SDK imports are lazy. Missing optional dependencies raise BackendUnavailableError with the required extra.

The provider adapters preserve submission ordering with provider custom_id values and turn partial vendor failures into ordered BatchResult entries. Vertex, SageMaker, and Azure own their staging objects unless the corresponding resource config sets retain_staging=true.

The legacy anchors below continue to resolve old links to the shipped classes.


Legacy class names

Pre-implementation drafts named the inference call surface differently. The shipped surface lives at rfgen.inference.protocols; the anchors below redirect old cross-references to the current shipped surface so other doc pages render.

Legacy: BaseLLMClient

Renamed to BaseInferenceClient in the shipped surface. The class wraps any inference backend (text-only LLMs, vision-language models, audio-language models), so the name was generalized.

Legacy: LLMResponse

Renamed to InferenceResponse in the shipped surface. The same modality-agnostic generalization applies.