Annotate an existing dataset

RFGen has one annotation lifecycle: rfgen annotate submit. It selects a validated source capability rather than exposing a separate direct-annotator command.

The annotation template

One product ships: scene_report.declared.v1, served by the reference annotator at rfgen.annotation.reference.scene_report:SceneReportAnnotator. A run names its annotator by import path, so a new domain ships its own without editing anything here. Its evidence is built from generator-declared metadata (see declared-metadata annotation evidence): the annotation path reads stored record metadata only, never I/Q, and a scene with N declared emitters is annotated as exactly N components, up to the configured component_cap. An annotator_options: {...} block is optional and defaults to the full registered field set; set it only to narrow what is surfaced. The provider receives the sanitized JSON evidence report, never raw I/Q or a spectrogram.

rfgen generate writes a Signal Dataset snapshot, and one field names it, on disk or in a bucket, because a snapshot is addressed by path alone and the reader never lists a directory:

dataset_uri: /abs/path/rfgen-output    # or gs://bucket/prefix

A gs:// snapshot needs the GCS extra and application-default credentials on whatever machine reads it, including the local_concurrent backend, which happily annotates a snapshot in a bucket from your laptop:

uv pip install -e '.[gemini,gcs]'
gcloud auth application-default login

A complete configuration

Copy this to annotation-config/caption.yaml and run it. Every field it omits has a default that works:

dataset_uri: /absolute/path/rfgen-output
annotation_set: scene_reports
publication_id: scene_reports_v1
template_id: scene_report.declared.v1
annotator: rfgen.annotation.reference.scene_report:SceneReportAnnotator
run_id: caption-v1
model: gemini-3.5-flash-lite
inference:
  backend: local_concurrent
  provider: gemini
  concurrency: 4
uv pip install -e '.[gemini]'
export GEMINI_API_KEY=...
rfgen annotate submit --config-dir ./annotation-config --config-name caption

Point dataset_uri at a gs:// root to annotate a snapshot in a bucket; nothing else in the file changes.

That run publishes a dense annotation set beside the snapshot, never rewriting a signal shard. Every record gets a row:

Row status

Means

success

Annotated; the row carries the result

failed

Inference or validation failed for that record

skipped

The annotator found nothing it could truthfully describe

A skipped row is not a failure. An annotator raises RecordNotAnnotatable for a record with nothing to say – a quiet epoch, an empty dwell – and that record is never sent to a provider. Filing it as an error would make a real outage and an honest silence indistinguishable.

Nothing is visible to a reader until the whole run publishes. There is no resume: a killed run is rerun, and publishing the same content under an existing publication id is refused rather than overwritten.

Prompt assembly reads declared metadata only. That is convention rather than an enforced boundary – an annotator is handed the record – and no shipped path reads a waveform.

A failed row’s detail_status carries the failure code for that record; see When records fail.

inference.backend selects where the run executes: local_concurrent calls the provider directly from this process, and vertex_batch stages the whole run as one batch. Both assemble byte-identical requests, so the cheap one is evidence about the expensive one.

Read the annotation set back

A set is named by its annotation_set, not by the run_id and not by the template. signal_dataset.open takes the dataset root either way, on disk or in a bucket, so replace the path with gs://my-bucket/rfgen-output to read a snapshot a remote run annotated. A gs:// root needs application-default credentials, whether the annotation ran locally or on Dataproc:

gcloud auth application-default login
import signal_dataset

dataset = signal_dataset.open("/absolute/path/rfgen-output")
print(list(dataset.annotations))          # every published set, by annotation_set

rows = dataset.annotations["scene_reports"]
row = rows[0]
print(row.source_record_id, row.status, row.detail_status)

print(row.values["output"]["expert_summary"])   # the prose, unverified
print(row.values["claims"])                     # the claims, exact
print(row.provenance["prose_verification"])     # always "not_performed"

row.values carries exactly two keys, and they come from different places. output is what the provider returned, verbatim and unchecked. claims is projected from the same evidence the prompt was built from, in the annotating process, before the request was sent — so nothing in it was authored by a model.

The evidence report itself is not on the row. claims is a deliberately lossy projection of it: the bookkeeping saying how the generator knew each fact is dropped, because a model deployed on a live capture has no such generator to name. If you need the evidence, rebuild it from the record.

A caption may contradict the claims beside it, and nothing detects that. See what a row contains.

A failed row has empty values and a detail_status naming the failure. A skipped row means the annotator found nothing it could truthfully describe.

rfgen inspect --annotations reads the retained fixed-IQ overlay only and does not read an annotation set. See Signal Dataset annotation sets for the layout.

Re-running

There is no resume and no staged-work reuse. rfgen annotate submit is synchronous and prepares the whole corpus before it sends anything, so a killed run is simply run again. wait, cancel and resume do not exist; submit is the only annotation subcommand.

Once a run has published, its publication_id is taken. Publishing different content under that id is refused rather than overwritten, because a published set is immutable and cannot be repaired in place — give the new configuration a new publication_id. Publishing identical content under it again is the idempotent no-op.

A vertex_batch run is driver-only: this process renders every prompt, stages them as one batch, and collects the result. The remote side runs no rfgen code, which is why model eligibility there is a question about the batch service rather than about this repository — see the Vertex Batch guide.