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 |
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 |
|---|---|
|
|
the module cannot be imported, or raises |
|
the module defines no such name |
|
the object is not a concrete |
|
the class serves a different |
|
options supplied to an annotator with no |
|
the prompt is missing, empty, or its revision disagrees |
|
|
|
|
|
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.