Annotation interchange and pipeline migration¶
OpenMed provides a clean-room, local-first interchange for moving reviewed annotations into and out of labeling workflows. It also scans declarative pipeline descriptions and produces an OpenMed-native configuration stub without importing or executing referenced code.
The public Python modules are:
openmed.eval.annotationfor records, TSV/JSON interchange, offset conversion, loss reports, access policy, and bounded pagination;openmed.interop.bridges.annotation_toolsfor fact-correction and registry label rows; andopenmed.interop.bridges.pipeline_migrationfor data-only pipeline scans.
All examples and committed fixtures are synthetic. The interchange carries opaque identifiers, controlled results, digests, offsets, and optional numeric embeddings. It never needs source text in a persisted record.
Versioned annotation envelope¶
AnnotationRecord supports four annotation families:
| Type | Coordinates | Required result fields |
|---|---|---|
entity | Required | label, surface_hash |
relation | Not allowed | relation, source_annotation_id, target_annotation_id |
fact_correction | Not allowed | fact_id, field, reason_code, replacement_code |
registry_label | Not allowed | registry_id, record_id, label |
Fact corrections and registry labels may also carry bounded evidence_ids. Metadata is allow-listed: source format, review state, batch or annotator ID, and model ID/version. Unknown fields fail closed rather than disappearing.
Every record and envelope declares schema 1.0.0 and compatibility policy same_major. The bundled persisted schemas are:
annotation_record.schema.jsonannotation_envelope.schema.jsonannotation_loss_report.schema.jsonannotation_page.schema.json
The canonical table is UTF-8 TSV with an exact header. Structured cells use canonical JSON. Duplicate JSON keys, non-finite numbers, extra columns, oversized cells, more than 10,000 rows, and inputs over 8 MiB are rejected.
from openmed.eval.annotation import (
AnnotationRecord,
AnnotationState,
AnnotationType,
CoordinateConvention,
build_annotation_envelope,
export_annotation_tsv,
import_annotation_tsv,
)
record = AnnotationRecord(
annotation_id="annotation_aaaaaaaaaaaa",
document_id="document_aaaaaaaaaaaa",
namespace="default",
annotation_type=AnnotationType.ENTITY,
coordinate_convention=CoordinateConvention.UNICODE_CODEPOINT,
start=0,
end=4,
state=AnnotationState.SUCCESS,
result={
"label": "condition",
"surface_hash": "sha256:" + "0" * 64,
},
metadata={"source_format": "neutral_table"},
embedding=(0.1, 0.2),
)
envelope = build_annotation_envelope([record])
exported = export_annotation_tsv(envelope)
restored = import_annotation_tsv(exported.text)
assert restored.records[0].record_digest == record.record_digest
export_annotation_tsv(..., include_embeddings=False) does not silently drop the vector. It returns a partial AnnotationLossReport with one embedding_omitted entry per affected record. A lossless export returns a success report with no entries.
Offset convention¶
Entity offsets are half-open: start is inclusive and end is exclusive. Each entity declares one convention:
unicode_codepointutf8_byteutf16_code_unittoken_index
convert_record_offsets() converts code-point, UTF-8, and UTF-16 boundaries exactly from caller-held source text. The text is used only for the conversion and is neither returned nor persisted. An offset that splits an encoded character fails with offset_not_boundary. Token conversion fails with token_map_required; OpenMed does not guess a tokenizer or its boundaries.
Access and pagination¶
AnnotationCatalog provides an immutable read surface. An AnnotationQuery binds the namespace, purpose, role, consent state, export policy, selected annotation types, and page size. AnnotationAccessPolicy checks all of them before returning records.
Pages are capped at 100 records. The opaque cursor commits to the full query and envelope snapshot. Reusing it with another role, purpose, filter, or snapshot returns a typed failure instead of returning an ambiguous page. Denied pages contain no records.
Fact-correction and registry-label rows¶
The neutral row adapters accept exact mappings and reject raw or unknown fields:
from openmed.interop.bridges.annotation_tools import (
export_fact_correction_rows,
import_fact_correction_rows,
)
rows = ({
"annotation_id": "annotation_aaaaaaaaaaaa",
"document_id": "document_aaaaaaaaaaaa",
"evidence_ids": ["evidence_aaaaaaaaaaaa"],
"fact_id": "fact_aaaaaaaaaaaa",
"field": "assertion",
"reason_code": "reviewed_correction",
"replacement_code": "absent",
},)
envelope = import_fact_correction_rows(rows)
assert export_fact_correction_rows(envelope) == rows
These adapters exchange review decisions. They do not apply a correction to a clinical store, authorize training, or make a clinical decision.
Data-only pipeline scan¶
The scanner accepts JSON shaped as a top-level stages array. A stage has a controlled type and an optional config object. Native stage names are supported; documented neutral aliases such as ingest, ner, and terminology are mapped; unknown types are unsupported.
Any nested executable-looking key—including module, class, callable, command, script, shell, or plugin—makes that stage manual_review. The stage is excluded from the generated stub. The scanner contains no dynamic import, evaluation, compilation, or process-launch path.
Unsupported or manual-review stages remain in the report and force the overall state to unsupported. Mapped stages produce partial. Only an entirely native description produces success and can_auto_migrate=True.
from openmed.interop.bridges import scan_pipeline_mapping
report = scan_pipeline_mapping({
"stages": [
{"type": "ingest", "config": {"format": "jsonl"}},
{"type": "ner", "config": {"model_id": "model_local"}},
]
})
assert report.state.value == "partial"
assert report.can_auto_migrate is False
The report and native stub use schema 1.0.0 and same_major. The persisted report schema is pipeline_migration_report.schema.json. Reports contain a source digest and controlled stage classifications, not a copy of the source description.
CLI¶
Import canonical TSV to persisted JSON:
Export JSON to canonical TSV and record declared loss:
openmed annotation export \
--input annotations.json \
--output annotations.tsv \
--omit-embeddings \
--loss-report annotation-loss.json
Scan a declarative pipeline and write a report plus a safe native stub:
openmed annotation scan-pipeline \
--input pipeline.json \
--report migration-report.json \
--stub openmed-pipeline.json
Commands refuse to overwrite existing files unless --force is supplied. Machine-readable mode emits counts, states, and digests, never output paths, annotation rows, or source pipeline content. Failures use content-free messages that do not echo a potentially identifying input or output path.
Safety boundary¶
Annotation interchange is a transport and review primitive. A valid record or migration report does not establish clinical correctness, consent for a new use, model suitability, or permission to execute a migrated workflow. Keep source documents in the caller's protected environment, review declared loss, and validate the generated native configuration before running it.