Author an annotator

An RF domain is annotated by writing one class and one Markdown file. This guide walks through both, using the radar dwell annotator in tests/acceptance/rfgen_acceptance_radar_dwell/ as a worked example — it is a real, complete annotator that lives entirely outside rfgen.

You do not need to read rfgen/annotation/ to do this. If you find yourself needing to, that is a gap in this guide or in the contract.

What you are producing

Every annotated record publishes two things, produced independently:

written by

verified?

prose

the model you configure

no

structured claims

your build_structured_output, before the request is sent

exact by construction

The prose is unverified and will stay that way. There is no reliable way to check a text description: a model can restate a value in a different unit, name a different quantity, invert a relation, or invent a property, and no check over the text sees any of it. Every published row says so, in provenance.prose_verification.

The claims are yours and they are exact, because you build them from evidence this process already holds and the model never touches them. A caption may contradict the claims beside it. That is accepted and visible, not gated.

Design for that split. Put anything a consumer’s software must rely on into the claims. Use the prose for what prose is good at.

1. Decide what is truthfully knowable

Generation already holds every fact about the record exactly. Your job is choosing the smallest sufficient set — not measuring anything, and never inferring anything from the samples.

Keep the distinction between declared, resolved, realized, validated, unavailable and unsupported. A capability your domain does not have must not become a plausible caption: if a quantity is unavailable, say so or omit it, and never default it to zero.

2. Declare what may be transmitted

The projection is the boundary between your corpus and a third party. Its default, core_declared, reproduces a historical communications vocabulary and will project nothing for your domain. Lead with an explicit allow-list:

entity_group: targets
declaration_policy: author_declared
permitted_declarations:
  - range_m
  - radial_velocity_mps
  - rcs_dbsm

Persisting a field and transmitting it are decisions made for different reasons, often by different people, so one cannot stand in for the other. The allow-list is written for this purpose and written separately.

record_declared reads everything a record carries and is the local inspection policy. It is refused on any path that reaches a model.

3. Write your class

Three methods. Nothing else is required.

from typing import Any, ClassVar

from rfgen.annotation.contract import AnnotationPrompt, Annotator, PromptResource


class RadarDwellAnnotator(Annotator[DwellEvidence]):
    identity: ClassVar[str] = "acceptance.radar_dwell"
    template_id: ClassVar[str] = "dwell_report.radar.v1"
    prompt: ClassVar[PromptResource] = PromptResource(
        package="rfgen_acceptance_radar_dwell",
        resource="prompts/dwell_report_radar_v1.md",
        prompt_id="dwell_report.radar.v1",
        revision=2,
    )

    def build_evidence(self, record: Any) -> DwellEvidence: ...
    def build_prompt(self, evidence: DwellEvidence) -> AnnotationPrompt: ...
    def build_structured_output(self, evidence: DwellEvidence) -> DwellClaims: ...

template_id is <annotation_type>.<variant>.v<N>. Core holds no closed list of annotation types — a sensing domain producing an occupancy_report names its own output, and annotation_type() is derived from the id rather than declared twice.

The vN is the contract: the evidence you consume and the reply you ask for. Prompt wording carries its own revision, in the file.

Instances are shared across a worker pool, so the three methods must be pure and re-entrant. Anything computed once per run belongs in configure().

4. build_evidence(record)

Reads the SDS record and returns your own frozen Pydantic model. Your fields, your units, your entity names, your relational layer or none.

What this method reads is the allow-list. Anything it does not read cannot reach a prompt or a published claim, so there is no separate exclusion list to keep in step.

Keep the generator out of it: an implementation’s module:QualName, the simulator that ran, the class catalogue (which is the answer key), seeds and hashes. A caption naming any of those teaches a model to describe waveforms in terms of software a deployed model has never heard of.

Do not read the samples. No FFTs, spectrograms, energy detectors, estimators, or classifiers; no bandwidth, carrier, modulation or SNR inferred from IQ. Everything you need is already declared. This is convention rather than an enforced rule — you are trusted to honour it.

Structure your evidence for your domain. The radar example has dwell, targets[] and per-target returns[], units of m, m/s and dBsm, and no pairwise layer at all. Nothing in core requires the communications shape.

5. Write your prompt as a Markdown resource

Package data, addressed through importlib.resources, so it loads from an installed wheel and not only from a source tree. The first line declares its identity:

<!-- rfgen-prompt: dwell_report.radar.v1 revision: 2 -->
You are characterizing one radar dwell for an analyst...

Your class declares the same prompt_id and revision, and loading fails closed if the two disagree. Bump the revision when you change the body; that is a review duty and git diff is the tool.

Never put a plausible number in an example. Use brace placeholders — {RANGE} m, never 4200 m. On an eight-billion-parameter model, a real-looking number lifted from a prompt example appeared in ten of twelve annotations of scenes containing no such signal, every row schema-valid, the run reporting complete success. A test over every shipped prompt enforces this.

You own the voice. The shipped communications prompt phrases declared facts as observations, because the deployed reader of its captions sees only a waveform. That is a domain choice, not a shared rule; word yours differently if your audience differs.

Ask for prose. The response schema you return from build_prompt should ask for text. Do not ask the model for numbers the evidence already holds — you are publishing those yourself in step 6, and asking twice only creates a contradiction nobody can detect.

6. build_structured_output(evidence)

The machine-readable half, projected from the same evidence, published beside the prose.

This is what a consumer’s software reads, and what a trained model will be asked to emit from a waveform it has never seen. So carry what a receiver could plausibly report — values, units, labels — and drop the bookkeeping that explains how your generator knew them. A row publishing source: scenario_parameter teaches a model to assert that about a live capture, which means nothing at inference.

Omit an unavailable quantity rather than publishing it as null. An absent key says “not known”; a null invites a consumer to read it as a measurement and a model to emit nulls. The radar example lists the names it could not resolve in an unavailable field, so a consumer can tell “not measured” from “not applicable”.

7. Point a run at it

template_id: dwell_report.radar.v1
annotator: rfgen_acceptance_radar_dwell:RadarDwellAnnotator
annotator_options:
  # validated by your options_model, opaque to core

That is the whole registration. No entry point, no packaging step, no reinstall — the module only has to be importable. Resolution happens before the dataset is opened or a client is built, so a typo, a bad option or an unreadable prompt is a startup error rather than a failure discovered after a provider has been billed for part of a batch.

If your annotator reads options, declare options_model and core will validate the block against it with your own error messages.

What you get, and must not rebuild

Resolution and role checking. One request assembly shared by both backends. Direct-provider and batch execution. Concurrency, retries, and per-record failure isolation — a bad record fails its own row and leaves the set dense. Deterministic serialization. Reply schema validation against the schema you declared. Provenance. Atomic, append-only publication.

You never write your own provenance. An annotator that reported its own identity could misreport it.

What core will refuse you

condition

error

annotator is not package.module:ClassName

AnnotatorSpecError

the module cannot be imported, or raises

AnnotatorImportError

the module defines no such name

AnnotatorNotFoundError

the object is not a concrete Annotator

AnnotatorRoleError

the class serves a different template_id

ConfigError

options supplied to an annotator with no options_model

ConfigError

the prompt is missing, empty, or its revision disagrees

PromptResourceError

declaration_policy: record_declared on a transmit path

ValueError

author_declared with an empty allow-list

ValueError

a reply that does not match the schema you declared

the row fails; the run continues

Check it before you spend anything

Your annotator is a plain class. Test it without a dataset, a provider, or a publication:

from rfgen.annotation.contract import AnnotatorContext, resolve_annotator

resolved = resolve_annotator("rfgen_acceptance_radar_dwell:RadarDwellAnnotator")
annotator = resolved.implementation.create(
    AnnotatorContext(template_id="dwell_report.radar.v1", entity_group="targets")
)
prepared = annotator.prepare(record)

prepared.prompt.system            # what the model is told
prepared.structured_output        # what publishes beside its prose

prepared.structured_output exists before any request is sent. That is the whole point of the split, and it is worth confirming with your own eyes on your own domain before you pay for a run.